Compare commits

...

No commits in common. "v0.0.1" and "master" have entirely different histories.

108 changed files with 12526 additions and 1390 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
liberapay: Elara6331

6
.gitignore vendored
View File

@ -1,2 +1,6 @@
/itctl
/itd
/itd
/itgui
/itgui-linux-*
/version.txt
dist/

View File

@ -1,3 +0,0 @@
[repos]
origin = "ssh://git@192.168.100.62:2222/Arsen6331/itd.git"
gitlab = "git@gitlab.com:moussaelianarsen/itd.git"

121
.goreleaser.yaml Normal file
View File

@ -0,0 +1,121 @@
before:
hooks:
- go generate
- go mod tidy
builds:
- id: itd
env:
- CGO_ENABLED=0
binary: itd
goos:
- linux
goarch:
- 386
- amd64
- arm
- arm64
- riscv64
goarm:
- 7
- id: itctl
env:
- CGO_ENABLED=0
main: ./cmd/itctl
binary: itctl
goos:
- linux
goarch:
- 386
- amd64
- arm
- arm64
goarm:
- 7
archives:
- name_template: >-
{{- .ProjectName }}-{{.Version}}-{{.Os}}-
{{- if eq .Arch "386" }}i386
{{- else if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "arm64" }}aarch64
{{- else }}{{.Arch}}
{{- end }}
files:
- LICENSE
- README.md
- itd.toml
- itd.service
allow_different_binary_count: true
nfpms:
- id: itd
file_name_template: >-
{{- .PackageName }}-{{.Version}}-{{.Os}}-
{{- if eq .Arch "386" }}i386
{{- else if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "arm64" }}aarch64
{{- else }}{{.Arch}}
{{- end }}
description: "Companion daemon for the InfiniTime firmware on the PineTime smartwatch"
homepage: 'https://gitea.elara.ws/Elara6331/itd'
maintainer: 'Elara Ivy <elara@elara.ws>'
license: GPLv3
formats:
- apk
- deb
- rpm
- archlinux
dependencies:
- dbus
- bluez
contents:
- src: itd.toml
dst: /etc/itd.toml
type: "config|noreplace"
- src: itd.service
dst: /usr/lib/systemd/user/itd.service
file_info:
mode: 0755
aurs:
- name: itd-bin
homepage: 'https://gitea.elara.ws/Elara6331/itd'
description: "Companion daemon for the InfiniTime firmware on the PineTime smartwatch"
maintainers:
- 'Elara Ivy <elara@elara.ws>'
license: GPLv3
private_key: '{{ .Env.AUR_KEY }}'
git_url: 'ssh://aur@aur.archlinux.org/itd-bin.git'
provides:
- itd
- itctl
conflicts:
- itd
- itctl
depends:
- dbus
- bluez
package: |-
# binaries
install -Dm755 ./itd "${pkgdir}/usr/bin/itd"
install -Dm755 ./itctl "${pkgdir}/usr/bin/itctl"
# service
install -Dm644 "./itd.service" ${pkgdir}/usr/lib/systemd/user/itd.service
# config
install -Dm644 "./itd.toml" ${pkgdir}/etc/itd.toml
# license
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/itd/LICENSE"
release:
gitea:
owner: Elara6331
name: itd
gitea_urls:
api: 'https://gitea.elara.ws/api/v1/'
download: 'https://gitea.elara.ws'
skip_tls_verify: false
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc

8
.woodpecker.yml Normal file
View File

@ -0,0 +1,8 @@
pipeline:
release:
image: goreleaser/goreleaser
commands:
- goreleaser release
secrets: [ gitea_token, aur_key ]
when:
event: tag

View File

@ -3,9 +3,14 @@ BIN_PREFIX = $(DESTDIR)$(PREFIX)/bin
SERVICE_PREFIX = $(DESTDIR)$(PREFIX)/lib/systemd/user
CFG_PREFIX = $(DESTDIR)/etc
all:
go build $(GOFLAGS)
go build ./cmd/itctl $(GOFLAGS)
all: version.txt
go build
go build ./cmd/itctl
clean:
rm -f itctl
rm -f itd
rm -f version.txt
install:
install -Dm755 ./itd $(BIN_PREFIX)/itd
@ -13,8 +18,13 @@ install:
install -Dm644 ./itd.service $(SERVICE_PREFIX)/itd.service
install -Dm644 ./itd.toml $(CFG_PREFIX)/itd.toml
clean:
rm -f itctl
rm -f itd
uninstall:
rm $(BIN_PREFIX)/itd
rm $(BIN_PREFIX)/itctl
rm $(SERVICE_PREFIX)/itd.service
rm $(CFG_PREFIX)/itd.toml
.PHONY: all install clean
version.txt:
go generate
.PHONY: all clean install uninstall

153
README.md
View File

@ -1,34 +1,64 @@
# ITD
## InfiniTime Daemon
`itd` is a daemon that uses my infinitime [library](https://go.arsenm.dev/infinitime) to interact with the [PineTime](https://www.pine64.org/pinetime/) running [InfiniTime](https://infinitime.io).
`itd` is a daemon that uses my infinitime [library](https://go.elara.ws/infinitime) to interact with the [PineTime](https://www.pine64.org/pinetime/) running [InfiniTime](https://infinitime.io).
[![status-badge](https://ci.elara.ws/api/badges/Elara6331/itd/status.svg)](https://ci.elara.ws/Elara6331/itd)
[![itd-git AUR package](https://img.shields.io/aur/version/itd-git?label=itd-git&logo=archlinux)](https://aur.archlinux.org/packages/itd-git/)
[![itd-bin AUR package](https://img.shields.io/aur/version/itd-bin?label=itd-bin&logo=archlinux)](https://aur.archlinux.org/packages/itd-bin/)
[![LURE badge for itd-git](https://lure.sh/pkg/default/itd-git/badge.svg)](https://lure.sh/pkg/default/itd-git)
[![LURE badge for itd-bin](https://lure.sh/pkg/default/itd-bin/badge.svg)](https://lure.sh/pkg/default/itd-bin)
This repository is part of the Software Heritage Archive:
[![SWH](https://archive.softwareheritage.org/badge/swh:1:dir:1374aa47b5c0a0d636d6f9c69f77af5e5bae99b2/)](https://archive.softwareheritage.org/swh:1:dir:1374aa47b5c0a0d636d6f9c69f77af5e5bae99b2;origin=https://gitea.elara.ws/Elara6331/itd;visit=swh:1:snp:d2935acbc966dfe1b15c771927bb08b5fc2ec89f;anchor=swh:1:rev:395cded9758dccc020fcd5b666f83a62308c9ab7)
---
### Features
- Notification relay
- Notification transliteration
- Call Notifications (ModemManager)
- Music control
- Get info from watch (HRM, Battery level, Firmware version)
- Get info from watch (HRM, Battery level, Firmware version, Motion)
- Set current time
- Control socket
- Firmware upgrades
- Weather
- BLE Filesystem
- Navigation (PureMaps)
- FUSE Filesystem
---
### Socket
### Installation
This daemon creates a UNIX socket at `/tmp/itd/socket`. It allows you to directly control the daemon and, by extension, the connected watch.
Since ITD 0.0.7, packages are built and uploaded whenever a new release is created.
The socket accepts JSON requests. For example, sending a notification looks like this:
#### Arch Linux
```json
{"type": "notify", "data": {"title": "title1", "body": "body1"}}
```
Use the `itd-bin` or `itd-git` AUR packages.
It will return a JSON response. A response can have 3 fields: `error`, `msg`, and `value`. Error is a boolean that signals whether an error was returned. If error is true, the msg field will contain the error. Value can contain any data and depends on what the request was.
#### Debian/Ubuntu
The various request types and their data requirements can be seen in `internal/types`. I can make separate docs for it if I get enough requests.
- Go to the [latest release](https://gitea.elara.ws/Elara6331/itd/releases/latest) and download the `.deb` package for your CPU architecture. You can find your architecture by running `uname -m` in the terminal.
- Run `sudo apt install <package>`, replacing `<package>` with the path to the downloaded file. Note: relative paths must begin with `./`.
- Example: `sudo apt install ~/Downloads/itd-0.0.7-linux-aarch64.deb`
#### Fedora
- Go to the [latest release](https://gitea.elara.ws/Elara6331/itd/releases/latest) and download the `.rpm` package for your CPU architecture. You can find your architecture by running `uname -m` in the terminal.
- Run `sudo dnf install <package>`, replacing `<package>` with the path to the downloaded file.
- Example: `sudo dnf install ~/Downloads/itd-0.0.7-linux-aarch64.rpm`
#### Alpine (and postmarketOS)
- Go to the [latest release](https://gitea.elara.ws/Elara6331/itd/releases/latest) and download the `.apk` package for your CPU architecture. You can find your architecture by running `uname -m` in the terminal.
- Run `sudo apk add --allow-untrusted <package>`, replacing `<package>` with the path to the downloaded file.
- Example: `sudo apk add --allow-untrusted ~/Downloads/itd-0.0.7-linux-aarch64.apk`
Note: `--allow-untrusted` is required because ITD isn't part of a repository, and therefore is not signed.
---
@ -38,35 +68,90 @@ This daemon comes with a binary called `itctl` which uses the socket to control
This is the `itctl` usage screen:
```
Control the itd daemon for InfiniTime smartwatches
NAME:
itctl - A new cli application
Usage:
itctl [command]
USAGE:
itctl [global options] command [command options] [arguments...]
Available Commands:
completion generate the autocompletion script for the specified shell
firmware Manage InfiniTime firmware
get Get information from InfiniTime
help Help about any command
notify Send notification to InfiniTime
set Set information on InfiniTime
COMMANDS:
help Display help screen for a command
resources, res Handle InfiniTime resource loading
filesystem, fs Perform filesystem operations on the PineTime
firmware, fw Manage InfiniTime firmware
get Get information from InfiniTime
notify Send notification to InfiniTime
set Set information on InfiniTime
update, upd Update information on InfiniTime
watch Watch a value for changes
Flags:
-h, --help help for itctl
Use "itctl [command] --help" for more information about a command.
GLOBAL OPTIONS:
--socket-path value, -s value Path to itd socket (default: "/tmp/itd/socket")
```
---
### Installation
### `itgui`
To install, install the go compiler and make. Usually, go is provided by a package either named `go` or `golang`, and make is usually provided by `make`. The go compiler must be version 1.16 or newer for the `io/fs` module.
In `cmd/itgui`, there is a gui frontend to the socket of `itd`. It uses the [Fyne library](https://fyne.io/) for Go.
#### Easy Installation
The easiest way to install `itgui` is to use my other project, [LURE](https://gitea.elara.ws/Elara6331/lure). LURE will only work if your package manager is `apt`, `dnf`, `yum`, `zypper`, `pacman`, or `apk`.
Instructions:
1. Install LURE. This can be done with the following command: `curl https://www.elara.ws/lure.sh | bash`.
2. Check to make sure LURE is properly installed by running `lure ref`.
3. Run `lure in itgui`. This process may take a while as it will compile `itgui` from source and package it for your distro.
4. Once the process is complete, you should be able to open and use `itgui` like any other app.
#### Compilation
Before compiling, certain prerequisites must be installed. These are listed on the following page: https://developer.fyne.io/started/#prerequisites
It can be compiled by running:
To install, run
```shell
make && sudo make install
go build ./cmd/itgui
```
#### Cross-compilation
Due to the use of OpenGL, cross-compilation of `itgui` isn't as simple as that of `itd` and `itctl`. The following guide from the Fyne website should work for `itgui`: https://developer.fyne.io/started/cross-compiling.
#### Screenshots
![Info tab](cmd/itgui/screenshots/info.png)
![Motion tab](cmd/itgui/screenshots/motion.png)
![Notify tab](cmd/itgui/screenshots/notify.png)
![FS tab](cmd/itgui/screenshots/fs.png)
![FS mkdir](cmd/itgui/screenshots/mkdir.png)
![FS resource upload](cmd/itgui/screenshots/resources.png)
![Time tab](cmd/itgui/screenshots/time.png)
![Firmware tab](cmd/itgui/screenshots/firmware.png)
![Upgrade in progress](cmd/itgui/screenshots/progress.png)
![Metrics tab](cmd/itgui/screenshots/metrics.png)
---
### Socket
This daemon creates a UNIX socket at `/tmp/itd/socket`. It allows you to directly control the daemon and, by extension, the connected watch.
The socket uses the [DRPC](https://github.com/storj/drpc) library for requests. The code generated by this framework is located in [`internal/rpc`](internal/rpc)
The API description is located in the [`internal/rpc/itd.proto`](internal/rpc/itd.proto) file.
---
### Starting
@ -92,7 +177,7 @@ To cross compile, simply set the go environment variables. For example, for Pine
make GOOS=linux GOARCH=arm64
```
This will compile `itd` and `itctl` for Linux aarch64 which is what runs on the PinePhone. This daemon only runs on Linux due to the library's dependencies (`dbus`, `bluez`, and `playerctl` specifically).
This will compile `itd` and `itctl` for Linux aarch64 which is what runs on the PinePhone. This daemon only runs on Linux due to the library's dependencies (`dbus`, and `bluez` specifically).
---
@ -100,4 +185,12 @@ This will compile `itd` and `itctl` for Linux aarch64 which is what runs on the
This daemon places a config file at `/etc/itd.toml`. This is the global config. `itd` will also look for a config at `~/.config/itd.toml`.
Most of the time, the daemon does not need to be restarted for config changes to take effect.
Most of the time, the daemon does not need to be restarted for config changes to take effect.
---
### Attribution
Location data from OpenStreetMap Nominatim, &copy; [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors
Weather data from the [Norwegian Meteorological Institute](https://www.met.no/en)

62
api/api.go Normal file
View File

@ -0,0 +1,62 @@
package api
import (
"io"
"net"
"go.elara.ws/drpc/muxconn"
"go.elara.ws/itd/internal/rpc"
"storj.io/drpc"
)
const DefaultAddr = "/tmp/itd/socket"
// Client is a client for ITD's socket API
type Client struct {
conn drpc.Conn
client rpc.DRPCITDClient
}
// New connects to the UNIX socket at the given
// path, and returns a client that communicates
// with that socket.
func New(sockPath string) (*Client, error) {
conn, err := net.Dial("unix", sockPath)
if err != nil {
return nil, err
}
mconn, err := muxconn.New(conn)
if err != nil {
return nil, err
}
return &Client{
conn: mconn,
client: rpc.NewDRPCITDClient(mconn),
}, nil
}
// NewFromConn returns a client that communicates
// over the given connection.
func NewFromConn(conn io.ReadWriteCloser) (*Client, error) {
mconn, err := muxconn.New(conn)
if err != nil {
return nil, err
}
return &Client{
conn: mconn,
client: rpc.NewDRPCITDClient(mconn),
}, nil
}
// FS returns the filesystem API client
func (c *Client) FS() *FSClient {
return &FSClient{rpc.NewDRPCFSClient(c.conn)}
}
// Close closes the client connection
func (c *Client) Close() error {
return c.conn.Close()
}

36
api/firmware.go Normal file
View File

@ -0,0 +1,36 @@
package api
import (
"context"
"go.elara.ws/itd/internal/rpc"
)
type DFUProgress struct {
Sent int64
Received int64
Total int64
Err error
}
func (c *Client) FirmwareUpgrade(ctx context.Context, upgType UpgradeType, files ...string) (chan DFUProgress, error) {
progressCh := make(chan DFUProgress, 5)
fc, err := c.client.FirmwareUpgrade(ctx, &rpc.FirmwareUpgradeRequest{
Type: rpc.FirmwareUpgradeRequest_Type(upgType),
Files: files,
})
if err != nil {
return nil, err
}
go fsRecvToChannel[rpc.DFUProgress](fc, progressCh, func(evt *rpc.DFUProgress, err error) DFUProgress {
return DFUProgress{
Sent: evt.Sent,
Received: evt.Recieved,
Total: evt.Total,
Err: err,
}
})
return progressCh, nil
}

122
api/fs.go Normal file
View File

@ -0,0 +1,122 @@
package api
import (
"context"
"errors"
"io"
"go.elara.ws/itd/internal/rpc"
)
type FSClient struct {
client rpc.DRPCFSClient
}
func (c *FSClient) RemoveAll(ctx context.Context, paths ...string) error {
_, err := c.client.RemoveAll(ctx, &rpc.PathsRequest{Paths: paths})
return err
}
func (c *FSClient) Remove(ctx context.Context, paths ...string) error {
_, err := c.client.Remove(ctx, &rpc.PathsRequest{Paths: paths})
return err
}
func (c *FSClient) Rename(ctx context.Context, old, new string) error {
_, err := c.client.Rename(ctx, &rpc.RenameRequest{
From: old,
To: new,
})
return err
}
func (c *FSClient) MkdirAll(ctx context.Context, paths ...string) error {
_, err := c.client.MkdirAll(ctx, &rpc.PathsRequest{Paths: paths})
return err
}
func (c *FSClient) Mkdir(ctx context.Context, paths ...string) error {
_, err := c.client.Mkdir(ctx, &rpc.PathsRequest{Paths: paths})
return err
}
func (c *FSClient) ReadDir(ctx context.Context, dir string) ([]FileInfo, error) {
res, err := c.client.ReadDir(ctx, &rpc.PathRequest{Path: dir})
if err != nil {
return nil, err
}
return convertEntries(res.Entries), nil
}
func convertEntries(e []*rpc.FileInfo) []FileInfo {
out := make([]FileInfo, len(e))
for i, fi := range e {
out[i] = FileInfo{
Name: fi.Name,
Size: fi.Size,
IsDir: fi.IsDir,
}
}
return out
}
func (c *FSClient) Upload(ctx context.Context, dst, src string) (chan FSTransferProgress, error) {
progressCh := make(chan FSTransferProgress, 5)
tc, err := c.client.Upload(ctx, &rpc.TransferRequest{Source: src, Destination: dst})
if err != nil {
return nil, err
}
go fsRecvToChannel[rpc.TransferProgress](tc, progressCh, func(evt *rpc.TransferProgress, err error) FSTransferProgress {
return FSTransferProgress{
Sent: evt.Sent,
Total: evt.Total,
Err: err,
}
})
return progressCh, nil
}
func (c *FSClient) Download(ctx context.Context, dst, src string) (chan FSTransferProgress, error) {
progressCh := make(chan FSTransferProgress, 5)
tc, err := c.client.Download(ctx, &rpc.TransferRequest{Source: src, Destination: dst})
if err != nil {
return nil, err
}
go fsRecvToChannel[rpc.TransferProgress](tc, progressCh, func(evt *rpc.TransferProgress, err error) FSTransferProgress {
return FSTransferProgress{
Sent: evt.Sent,
Total: evt.Total,
Err: err,
}
})
return progressCh, nil
}
// fsRecvToChannel converts a DRPC stream client to a Go channel, using cf to convert
// RPC generated types to API response types.
func fsRecvToChannel[R any, A any](s StreamClient[R], ch chan<- A, cf func(evt *R, err error) A) {
defer close(ch)
var err error
var evt *R
for {
select {
case <-s.Context().Done():
return
default:
evt, err = s.Recv()
if errors.Is(err, io.EOF) {
return
} else if err != nil {
ch <- cf(new(R), err)
return
}
ch <- cf(evt, nil)
}
}
}

41
api/get.go Normal file
View File

@ -0,0 +1,41 @@
package api
import (
"context"
"go.elara.ws/itd/internal/rpc"
)
func (c *Client) HeartRate(ctx context.Context) (uint8, error) {
res, err := c.client.HeartRate(ctx, &rpc.Empty{})
return uint8(res.Value), err
}
func (c *Client) BatteryLevel(ctx context.Context) (uint8, error) {
res, err := c.client.BatteryLevel(ctx, &rpc.Empty{})
return uint8(res.Value), err
}
type MotionValues struct {
X, Y, Z int16
}
func (c *Client) Motion(ctx context.Context) (MotionValues, error) {
res, err := c.client.Motion(ctx, &rpc.Empty{})
return MotionValues{int16(res.X), int16(res.Y), int16(res.Z)}, err
}
func (c *Client) StepCount(ctx context.Context) (out uint32, err error) {
res, err := c.client.StepCount(ctx, &rpc.Empty{})
return res.Value, err
}
func (c *Client) Version(ctx context.Context) (out string, err error) {
res, err := c.client.Version(ctx, &rpc.Empty{})
return res.Value, err
}
func (c *Client) Address(ctx context.Context) (out string, err error) {
res, err := c.client.Address(ctx, &rpc.Empty{})
return res.Value, err
}

15
api/notify.go Normal file
View File

@ -0,0 +1,15 @@
package api
import (
"context"
"go.elara.ws/itd/internal/rpc"
)
func (c *Client) Notify(ctx context.Context, title, body string) error {
_, err := c.client.Notify(ctx, &rpc.NotifyRequest{
Title: title,
Body: body,
})
return err
}

51
api/resources.go Normal file
View File

@ -0,0 +1,51 @@
package api
import (
"context"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/rpc"
)
type ResourceOperation infinitime.ResourceOperation
const (
ResourceRemove = infinitime.ResourceRemove
ResourceUpload = infinitime.ResourceUpload
)
type ResourceLoadProgress struct {
Operation ResourceOperation
Name string
Total int64
Sent int64
Err error
}
// LoadResources loads resources onto the watch from the given
// file path to the resources zip
func (c *FSClient) LoadResources(ctx context.Context, path string) (<-chan ResourceLoadProgress, error) {
progCh := make(chan ResourceLoadProgress, 2)
rc, err := c.client.LoadResources(ctx, &rpc.PathRequest{Path: path})
if err != nil {
return nil, err
}
go fsRecvToChannel[rpc.ResourceLoadProgress](rc, progCh, func(evt *rpc.ResourceLoadProgress, err error) ResourceLoadProgress {
return ResourceLoadProgress{
Operation: ResourceOperation(evt.Operation),
Name: evt.Name,
Sent: evt.Sent,
Total: evt.Total,
Err: err,
}
})
return progCh, nil
}
type StreamClient[T any] interface {
Recv() (*T, error)
Context() context.Context
}

13
api/set.go Normal file
View File

@ -0,0 +1,13 @@
package api
import (
"context"
"time"
"go.elara.ws/itd/internal/rpc"
)
func (c *Client) SetTime(ctx context.Context, t time.Time) error {
_, err := c.client.SetTime(ctx, &rpc.SetTimeRequest{UnixNano: t.UnixNano()})
return err
}

97
api/types.go Normal file
View File

@ -0,0 +1,97 @@
package api
import (
"fmt"
"strconv"
)
type UpgradeType uint8
const (
UpgradeTypeArchive UpgradeType = iota
UpgradeTypeFiles
)
type FSData struct {
Files []string
Data string
}
type FwUpgradeData struct {
Type UpgradeType
Files []string
}
type NotifyData struct {
Title string
Body string
}
type FSTransferProgress struct {
Total uint32
Sent uint32
Err error
}
type FileInfo struct {
Name string
Size int64
IsDir bool
}
func (fi FileInfo) String() string {
var isDirChar rune
if fi.IsDir {
isDirChar = 'd'
} else {
isDirChar = '-'
}
// Get human-readable value for file size
val, unit := bytesHuman(fi.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,
fi.Name,
)
}
// bytesHuman returns a human-readable string for
// the amount of bytes inputted.
func bytesHuman(b int64) (float64, string) {
const unit = 1000
// Set possible units 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 := int64(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
}

12
api/update.go Normal file
View File

@ -0,0 +1,12 @@
package api
import (
"context"
"go.elara.ws/itd/internal/rpc"
)
func (c *Client) WeatherUpdate(ctx context.Context) error {
_, err := c.client.WeatherUpdate(ctx, &rpc.Empty{})
return err
}

135
api/watch.go Normal file
View File

@ -0,0 +1,135 @@
package api
import (
"context"
"go.elara.ws/itd/internal/rpc"
)
func (c *Client) WatchHeartRate(ctx context.Context) (<-chan uint8, error) {
outCh := make(chan uint8, 2)
wc, err := c.client.WatchHeartRate(ctx, &rpc.Empty{})
if err != nil {
return nil, err
}
go func() {
defer close(outCh)
var err error
var evt *rpc.IntResponse
for {
select {
case <-ctx.Done():
wc.Close()
return
default:
evt, err = wc.Recv()
if err != nil {
return
}
}
outCh <- uint8(evt.Value)
}
}()
return outCh, nil
}
func (c *Client) WatchBatteryLevel(ctx context.Context) (<-chan uint8, error) {
outCh := make(chan uint8, 2)
wc, err := c.client.WatchBatteryLevel(ctx, &rpc.Empty{})
if err != nil {
return nil, err
}
go func() {
defer close(outCh)
var err error
var evt *rpc.IntResponse
for {
select {
case <-ctx.Done():
wc.Close()
return
default:
evt, err = wc.Recv()
if err != nil {
return
}
}
outCh <- uint8(evt.Value)
}
}()
return outCh, nil
}
func (c *Client) WatchStepCount(ctx context.Context) (<-chan uint32, error) {
outCh := make(chan uint32, 2)
wc, err := c.client.WatchStepCount(ctx, &rpc.Empty{})
if err != nil {
return nil, err
}
go func() {
defer close(outCh)
var err error
var evt *rpc.IntResponse
for {
select {
case <-ctx.Done():
wc.Close()
return
default:
evt, err = wc.Recv()
if err != nil {
return
}
}
outCh <- evt.Value
}
}()
return outCh, nil
}
func (c *Client) WatchMotion(ctx context.Context) (<-chan MotionValues, error) {
outCh := make(chan MotionValues, 2)
wc, err := c.client.WatchMotion(ctx, &rpc.Empty{})
if err != nil {
return nil, err
}
go func() {
defer close(outCh)
var err error
var evt *rpc.MotionResponse
for {
select {
case <-ctx.Done():
wc.Close()
return
default:
evt, err = wc.Recv()
if err != nil {
return
}
}
outCh <- MotionValues{int16(evt.X), int16(evt.Y), int16(evt.Z)}
}
}()
return outCh, nil
}

179
calls.go Normal file
View File

@ -0,0 +1,179 @@
package main
import (
"context"
"github.com/godbus/dbus/v5"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/utils"
"go.elara.ws/logger/log"
)
func initCallNotifs(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// Connect to system bus. This connection is for method calls.
conn, err := utils.NewSystemBusConn(ctx)
if err != nil {
return err
}
// Check if modem manager interface exists
exists, err := modemManagerExists(ctx, conn)
if err != nil {
return err
}
// If it does not exist, stop function
if !exists {
conn.Close()
return nil
}
// Connect to system bus. This connection is for monitoring.
monitorConn, err := utils.NewSystemBusConn(ctx)
if err != nil {
return err
}
// Add match for new calls to monitor connection
err = monitorConn.AddMatchSignal(
dbus.WithMatchSender("org.freedesktop.ModemManager1"),
dbus.WithMatchInterface("org.freedesktop.ModemManager1.Modem.Voice"),
dbus.WithMatchMember("CallAdded"),
)
if err != nil {
return err
}
// Create channel to receive calls
callCh := make(chan *dbus.Message, 5)
// Notify channel upon received message
monitorConn.Eavesdrop(callCh)
var callObj dbus.BusObject
wg.Add(1)
go func() {
defer wg.Done("callNotifs")
for {
select {
case event := <-callCh:
// Get path to call object
callPath := event.Body[0].(dbus.ObjectPath)
// Get call object
callObj = conn.Object("org.freedesktop.ModemManager1", callPath)
// Get phone number from call object using method call connection
phoneNum, err := getPhoneNum(conn, callObj)
if err != nil {
log.Error("Error getting phone number").Err(err).Send()
continue
}
// Get direction of call object using method call connection
direction, err := getDirection(conn, callObj)
if err != nil {
log.Error("Error getting call direction").Err(err).Send()
continue
}
if direction != MMCallDirectionIncoming {
continue
}
// Send call notification to InfiniTime
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
}
case <-ctx.Done():
return
}
}
}()
log.Info("Relaying calls to InfiniTime").Send()
return nil
}
func modemManagerExists(ctx context.Context, conn *dbus.Conn) (bool, error) {
var names []string
err := conn.BusObject().CallWithContext(
ctx, "org.freedesktop.DBus.ListNames", 0,
).Store(&names)
if err != nil {
return false, err
}
return strSlcContains(names, "org.freedesktop.ModemManager1"), nil
}
// getPhoneNum gets a phone number from a call object using a DBus connection
func getPhoneNum(conn *dbus.Conn, callObj dbus.BusObject) (string, error) {
var out string
// Get number property on DBus object and store return value in out
err := callObj.StoreProperty("org.freedesktop.ModemManager1.Call.Number", &out)
if err != nil {
return "", err
}
return out, nil
}
type MMCallDirection int
const (
MMCallDirectionUnknown MMCallDirection = iota
MMCallDirectionIncoming
MMCallDirectionOutgoing
)
// getDirection gets the direction of a call object using a DBus connection
func getDirection(conn *dbus.Conn, callObj dbus.BusObject) (MMCallDirection, error) {
var out MMCallDirection
// Get number property on DBus object and store return value in out
err := callObj.StoreProperty("org.freedesktop.ModemManager1.Call.Direction", &out)
if err != nil {
return 0, err
}
return out, nil
}
// getPhoneNum accepts a call using a DBus connection
func acceptCall(ctx context.Context, conn *dbus.Conn, callObj dbus.BusObject) error {
// Call Accept() method on DBus object
call := callObj.CallWithContext(
ctx, "org.freedesktop.ModemManager1.Call.Accept", 0,
)
if call.Err != nil {
return call.Err
}
return nil
}
// getPhoneNum declines a call using a DBus connection
func declineCall(ctx context.Context, conn *dbus.Conn, callObj dbus.BusObject) error {
// Call Hangup() method on DBus object
call := callObj.CallWithContext(
ctx, "org.freedesktop.ModemManager1.Call.Hangup", 0,
)
if call.Err != nil {
return call.Err
}
return nil
}

View File

@ -1,87 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// addressCmd represents the address command
var addressCmd = &cobra.Command{
Use: "address",
Aliases: []string{"addr"},
Short: "Get InfiniTime's bluetooth address",
Run: func(cmd *cobra.Command, args []string) {
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeBtAddress,
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connection
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Decode line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
// Print returned value
fmt.Println(res.Value)
},
}
func init() {
getCmd.AddCommand(addressCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// addressCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// addressCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View File

@ -1,77 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// batteryCmd represents the batt command
var batteryCmd = &cobra.Command{
Use: "battery",
Aliases: []string{"batt"},
Short: "Get battery level from InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeBattLevel,
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connection
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Deocde line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
// Print returned percentage
fmt.Printf("%d%%\n", int(res.Value.(float64)))
},
}
func init() {
getCmd.AddCommand(batteryCmd)
}

View File

@ -1,41 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
const SockPath = "/tmp/itd/socket"
const (
ReqTypeHeartRate = "hrt"
ReqTypeBattLevel = "battlvl"
ReqTypeFwVersion = "fwver"
ReqTypeFwUpgrade = "fwupg"
ReqTypeBtAddress = "btaddr"
ReqTypeNotify = "notify"
ReqTypeSetTime = "settime"
)
const (
UpgradeTypeArchive = iota
UpgradeTypeFiles
)
type DFUProgress struct {
Received int64 `mapstructure:"recvd"`
Total int64 `mapstructure:"total"`
}

View File

@ -1,34 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/spf13/cobra"
)
// firmwareCmd represents the firmware command
var firmwareCmd = &cobra.Command{
Use: "firmware",
Short: "Manage InfiniTime firmware",
Aliases: []string{"fw"},
}
func init() {
rootCmd.AddCommand(firmwareCmd)
}

View File

@ -1,33 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/spf13/cobra"
)
// getCmd represents the get command
var getCmd = &cobra.Command{
Use: "get",
Short: "Get information from InfiniTime",
}
func init() {
rootCmd.AddCommand(getCmd)
}

View File

@ -1,76 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// heartCmd represents the heart command
var heartCmd = &cobra.Command{
Use: "heart",
Short: "Get heart rate from InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeHeartRate,
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connection
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Decode line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
// Print returned BPM
fmt.Printf("%d BPM\n", int(res.Value.(float64)))
},
}
func init() {
getCmd.AddCommand(heartCmd)
}

View File

@ -1,82 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// notifyCmd represents the notify command
var notifyCmd = &cobra.Command{
Use: "notify <title> <body>",
Short: "Send notification to InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
// Ensure required arguments
if len(args) != 2 {
cmd.Usage()
log.Fatal().Msg("Command notify requires two arguments")
}
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeNotify,
Data: types.ReqDataNotify{
Title: args[0],
Body: args[1],
},
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connection
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Decode line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
},
}
func init() {
rootCmd.AddCommand(notifyCmd)
}

View File

@ -1,64 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/abiosoft/ishell"
"github.com/spf13/cobra"
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "itctl",
Short: "Control the itd daemon for InfiniTime smartwatches",
Run: func(cmd *cobra.Command, args []string) {
// Create new shell
sh := ishell.New()
sh.SetPrompt("itctl> ")
// For every command in cobra
for _, subCmd := range cmd.Commands() {
// Add top level command to ishell
sh.AddCmd(&ishell.Cmd{
Name: subCmd.Name(),
Help: subCmd.Short,
Aliases: subCmd.Aliases,
LongHelp: subCmd.Long,
Func: func(ctx *ishell.Context) {
// Append name and arguments of command
args := append([]string{ctx.Cmd.Name}, ctx.Args...)
// Set root command arguments
cmd.SetArgs(args)
// Execute root command with new arguments
cmd.Execute()
},
})
}
// Start shell
sh.Run()
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}

View File

@ -1,33 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/spf13/cobra"
)
// setCmd represents the set command
var setCmd = &cobra.Command{
Use: "set",
Short: "Set information on InfiniTime",
}
func init() {
rootCmd.AddCommand(setCmd)
}

View File

@ -1,80 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// timeCmd represents the time command
var timeCmd = &cobra.Command{
Use: `time <ISO8601|"now">`,
Short: "Set InfiniTime's clock to specified time",
Run: func(cmd *cobra.Command, args []string) {
// Ensure required arguments
if len(args) != 1 {
cmd.Usage()
log.Warn().Msg("Command time requires one argument")
return
}
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeSetTime,
Data: args[0],
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connetion
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Decode line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
},
}
func init() {
setCmd.AddCommand(timeCmd)
}

View File

@ -1,127 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"net"
"github.com/cheggaaa/pb/v3"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/internal/types"
)
// upgradeCmd represents the upgrade command
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Upgrade InfiniTime firmware using files or archive",
Aliases: []string{"upg"},
Run: func(cmd *cobra.Command, args []string) {
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
var data types.ReqDataFwUpgrade
// Get relevant data struct
if viper.GetString("archive") != "" {
// Get archive data struct
data = types.ReqDataFwUpgrade{
Type: UpgradeTypeArchive,
Files: []string{viper.GetString("archive")},
}
} else if viper.GetString("initPkt") != "" && viper.GetString("firmware") != "" {
// Get files data struct
data = types.ReqDataFwUpgrade{
Type: UpgradeTypeFiles,
Files: []string{viper.GetString("initPkt"), viper.GetString("firmware")},
}
} else {
cmd.Usage()
log.Warn().Msg("Upgrade command requires either archive or init packet and firmware.")
return
}
// Encode response into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeFwUpgrade,
Data: data,
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Create progress bar template
barTmpl := `{{counters . }} B {{bar . "|" "-" (cycle .) " " "|"}} {{percent . }} {{rtime . "%s"}}`
// Start full bar at 0 total
bar := pb.ProgressBarTemplate(barTmpl).Start(0)
// Create new scanner of connection
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
var res types.Response
// Decode scanned line into response struct
err = json.Unmarshal(scanner.Bytes(), &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON response")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
var event DFUProgress
// Decode response data into progress struct
err = mapstructure.Decode(res.Value, &event)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding response data")
}
// If transfer finished, break
if event.Received == event.Total {
break
}
// Set total bytes in progress bar
bar.SetTotal(event.Total)
// Set amount of bytes received in progress bar
bar.SetCurrent(event.Received)
}
// Finish progress bar
bar.Finish()
if scanner.Err() != nil {
log.Fatal().Err(scanner.Err()).Msg("Error while scanning output")
}
},
}
func init() {
firmwareCmd.AddCommand(upgradeCmd)
// Register flags
upgradeCmd.Flags().StringP("archive", "a", "", "Path to firmware archive")
upgradeCmd.Flags().StringP("init-pkt", "i", "", "Path to init packet (.dat file)")
upgradeCmd.Flags().StringP("firmware", "f", "", "Path to firmware image (.bin file)")
// Bind flags to viper keys
viper.BindPFlag("archive", upgradeCmd.Flags().Lookup("archive"))
viper.BindPFlag("initPkt", upgradeCmd.Flags().Lookup("init-pkt"))
viper.BindPFlag("firmware", upgradeCmd.Flags().Lookup("firmware"))
}

View File

@ -1,77 +0,0 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"net"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"go.arsenm.dev/itd/internal/types"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Aliases: []string{"ver"},
Short: "Get firmware version of InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
// Connect to itd UNIX socket
conn, err := net.Dial("unix", SockPath)
if err != nil {
log.Fatal().Err(err).Msg("Error dialing socket. Is itd running?")
}
defer conn.Close()
// Encode request into connection
err = json.NewEncoder(conn).Encode(types.Request{
Type: ReqTypeFwVersion,
})
if err != nil {
log.Fatal().Err(err).Msg("Error making request")
}
// Read one line from connection
line, _, err := bufio.NewReader(conn).ReadLine()
if err != nil {
log.Fatal().Err(err).Msg("Error reading line from connection")
}
var res types.Response
// Decode line into response
err = json.Unmarshal(line, &res)
if err != nil {
log.Fatal().Err(err).Msg("Error decoding JSON data")
}
if res.Error {
log.Fatal().Msg(res.Message)
}
// Print returned value
fmt.Println(res.Value)
},
}
func init() {
firmwareCmd.AddCommand(versionCmd)
}

98
cmd/itctl/firmware.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"fmt"
"path/filepath"
"time"
"github.com/cheggaaa/pb/v3"
"github.com/urfave/cli/v2"
"go.elara.ws/itd/api"
"go.elara.ws/logger/log"
)
func fwUpgrade(c *cli.Context) error {
resources := c.String("resources")
if resources != "" {
absRes, err := filepath.Abs(resources)
if err != nil {
return err
}
err = resLoad(c.Context, []string{absRes})
if err != nil {
log.Error("Resource loading has returned an error. This can happen if your current version of InfiniTime doesn't support BLE FS. Try updating without resource loading, and then load them after using the `itctl res load` command.").Send()
return err
}
}
start := time.Now()
var upgType api.UpgradeType
var files []string
// Get relevant data struct
if c.String("archive") != "" {
// Get archive data struct
upgType = api.UpgradeTypeArchive
files = []string{c.String("archive")}
} else if c.String("init-packet") != "" && c.String("firmware") != "" {
// Get files data struct
upgType = api.UpgradeTypeFiles
files = []string{c.String("init-packet"), c.String("firmware")}
} else {
return cli.Exit("Upgrade command requires either archive or init packet and firmware.", 1)
}
progress, err := client.FirmwareUpgrade(c.Context, upgType, abs(files)...)
if err != nil {
return err
}
// Create progress bar template
barTmpl := `{{counters . }} B {{bar . "|" "-" (cycle .) " " "|"}} {{percent . }} {{rtime . "%s"}}`
// Start full bar at 0 total
bar := pb.ProgressBarTemplate(barTmpl).Start(0)
// Create new scanner of connection
for event := range progress {
if event.Err != nil {
return event.Err
}
// Set total bytes in progress bar
bar.SetTotal(event.Total)
// Set amount of bytes received in progress bar
bar.SetCurrent(int64(event.Received))
// If transfer finished, break
if int64(event.Sent) == event.Total {
break
}
}
// Finish progress bar
bar.Finish()
fmt.Printf("Transferred %d B in %s.\n", bar.Total(), time.Since(start))
fmt.Println("Remember to validate the new firmware in the InfiniTime settings.")
return nil
}
func fwVersion(c *cli.Context) error {
version, err := client.Version(c.Context)
if err != nil {
return err
}
fmt.Println(version)
return nil
}
func abs(paths []string) []string {
for index, path := range paths {
newPath, err := filepath.Abs(path)
if err != nil {
continue
}
paths[index] = newPath
}
return paths
}

182
cmd/itctl/fs.go Normal file
View File

@ -0,0 +1,182 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/cheggaaa/pb/v3"
"github.com/urfave/cli/v2"
)
func fsList(c *cli.Context) error {
dirPath := "/"
if c.Args().Len() > 0 {
dirPath = c.Args().Get(0)
}
listing, err := client.FS().ReadDir(c.Context, dirPath)
if err != nil {
return err
}
for _, entry := range listing {
fmt.Println(entry)
}
return nil
}
func fsMkdir(c *cli.Context) error {
if c.Args().Len() < 1 {
return cli.Exit("Command mkdir requires one or more arguments", 1)
}
var err error
if c.Bool("parents") {
err = client.FS().MkdirAll(c.Context, c.Args().Slice()...)
} else {
err = client.FS().Mkdir(c.Context, c.Args().Slice()...)
}
if err != nil {
return err
}
return nil
}
func fsMove(c *cli.Context) error {
if c.Args().Len() != 2 {
return cli.Exit("Command move requires two arguments", 1)
}
err := client.FS().Rename(c.Context, c.Args().Get(0), c.Args().Get(1))
if err != nil {
return err
}
return nil
}
func fsRead(c *cli.Context) error {
if c.Args().Len() != 2 {
return cli.Exit("Command read requires two arguments", 1)
}
var tmpFile *os.File
var path string
var err error
if c.Args().Get(1) == "-" {
tmpFile, err = os.CreateTemp("/tmp", "itctl.*")
if err != nil {
return err
}
path = tmpFile.Name()
} else {
path, err = filepath.Abs(c.Args().Get(1))
if err != nil {
return err
}
}
progress, err := client.FS().Download(c.Context, path, c.Args().Get(0))
if err != nil {
return err
}
// Create progress bar template
barTmpl := `{{counters . }} B {{bar . "|" "-" (cycle .) " " "|"}} {{percent . }} {{rtime . "%s"}}`
// Start full bar at 0 total
bar := pb.ProgressBarTemplate(barTmpl).Start(0)
// Get progress events
for event := range progress {
if event.Err != nil {
return event.Err
}
// Set total bytes in progress bar
bar.SetTotal(int64(event.Total))
// Set amount of bytes sent in progress bar
bar.SetCurrent(int64(event.Sent))
}
bar.Finish()
if c.Args().Get(1) == "-" {
io.Copy(os.Stdout, tmpFile)
os.Stdout.WriteString("\n")
os.Stdout.Sync()
tmpFile.Close()
}
return nil
}
func fsRemove(c *cli.Context) error {
if c.Args().Len() < 1 {
return cli.Exit("Command remove requires one or more arguments", 1)
}
var err error
if c.Bool("recursive") {
err = client.FS().RemoveAll(c.Context, c.Args().Slice()...)
} else {
err = client.FS().Remove(c.Context, c.Args().Slice()...)
}
if err != nil {
return err
}
return nil
}
func fsWrite(c *cli.Context) error {
if c.Args().Len() != 2 {
return cli.Exit("Command write requires two arguments", 1)
}
var tmpFile *os.File
var path string
var err error
if c.Args().Get(0) == "-" {
tmpFile, err = os.CreateTemp("/tmp", "itctl.*")
if err != nil {
return err
}
path = tmpFile.Name()
} else {
path, err = filepath.Abs(c.Args().Get(0))
if err != nil {
return err
}
}
if c.Args().Get(0) == "-" {
io.Copy(tmpFile, os.Stdin)
defer tmpFile.Close()
defer os.Remove(path)
}
progress, err := client.FS().Upload(c.Context, c.Args().Get(1), path)
if err != nil {
return err
}
// Create progress bar template
barTmpl := `{{counters . }} B {{bar . "|" "-" (cycle .) " " "|"}} {{percent . }} {{rtime . "%s"}}`
// Start full bar at 0 total
bar := pb.ProgressBarTemplate(barTmpl).Start(0)
// Get progress events
for event := range progress {
if event.Err != nil {
return event.Err
}
// Set total bytes in progress bar
bar.SetTotal(int64(event.Total))
// Set amount of bytes sent in progress bar
bar.SetCurrent(int64(event.Sent))
}
return nil
}

71
cmd/itctl/get.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/urfave/cli/v2"
)
func getAddress(c *cli.Context) error {
address, err := client.Address(c.Context)
if err != nil {
return err
}
fmt.Println(address)
return nil
}
func getBattery(c *cli.Context) error {
battLevel, err := client.BatteryLevel(c.Context)
if err != nil {
return err
}
// Print returned percentage
fmt.Printf("%d%%\n", battLevel)
return nil
}
func getHeart(c *cli.Context) error {
heartRate, err := client.HeartRate(c.Context)
if err != nil {
return err
}
// Print returned BPM
fmt.Printf("%d BPM\n", heartRate)
return nil
}
func getMotion(c *cli.Context) error {
motionVals, err := client.Motion(c.Context)
if err != nil {
return err
}
if c.Bool("shell") {
fmt.Printf(
"X=%d\nY=%d\nZ=%d\n",
motionVals.X,
motionVals.Y,
motionVals.Z,
)
} else {
return json.NewEncoder(os.Stdout).Encode(motionVals)
}
return nil
}
func getSteps(c *cli.Context) error {
stepCount, err := client.StepCount(c.Context)
if err != nil {
return err
}
// Print returned BPM
fmt.Printf("%d Steps\n", stepCount)
return nil
}

View File

@ -1,36 +1,319 @@
/*
* itd uses bluetooth low energy to communicate with InfiniTime devices
* Copyright (C) 2021 Arsen Musayelyan
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"go.arsenm.dev/itd/cmd/itctl/cmd"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"go.elara.ws/itd/api"
"go.elara.ws/logger"
"go.elara.ws/logger/log"
)
func init() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
var client *api.Client
func main() {
cmd.Execute()
log.Logger = logger.NewPretty(os.Stderr)
ctx := context.Background()
ctx, _ = signal.NotifyContext(
ctx,
syscall.SIGINT,
syscall.SIGTERM,
)
// This goroutine ensures that itctl will exit
// at most 200ms after the user sends SIGINT/SIGTERM.
go func() {
<-ctx.Done()
time.Sleep(200 * time.Millisecond)
os.Exit(0)
}()
app := cli.App{
Name: "itctl",
HideHelpCommand: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "socket-path",
Aliases: []string{"s"},
Value: api.DefaultAddr,
Usage: "Path to itd socket",
},
},
Commands: []*cli.Command{
{
Name: "help",
ArgsUsage: "<command>",
Usage: "Display help screen for a command",
Action: helpCmd,
},
{
Name: "resources",
Aliases: []string{"res"},
Usage: "Handle InfiniTime resource loading",
Subcommands: []*cli.Command{
{
Name: "load",
ArgsUsage: "<path>",
Usage: "Load an InifiniTime resources package",
Action: resourcesLoad,
},
},
},
{
Name: "filesystem",
Aliases: []string{"fs"},
Usage: "Perform filesystem operations on the PineTime",
Subcommands: []*cli.Command{
{
Name: "list",
ArgsUsage: "[dir]",
Aliases: []string{"ls"},
Usage: "List a directory",
Action: fsList,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "parents",
Aliases: []string{"p"},
Usage: "Make parent directories if needed, no error if already existing",
},
},
Name: "mkdir",
ArgsUsage: "<paths...>",
Usage: "Create new directories",
Action: fsMkdir,
},
{
Name: "move",
ArgsUsage: "<old> <new>",
Aliases: []string{"mv"},
Usage: "Move a file or directory",
Action: fsMove,
},
{
Name: "read",
ArgsUsage: `<remote path> <local path>`,
Usage: "Read a file from InfiniTime.",
Description: `Read is used to read files from InfiniTime's filesystem. A "-" can be used to signify stdout`,
Action: fsRead,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "recursive",
Aliases: []string{"r", "R"},
Usage: "Remove directories and their contents recursively",
},
},
Name: "remove",
ArgsUsage: "<paths...>",
Aliases: []string{"rm"},
Usage: "Remove a file from InfiniTime",
Action: fsRemove,
},
{
Name: "write",
ArgsUsage: `<local path> <remote path>`,
Usage: "Write a file to InfiniTime",
Description: `Write is used to write files to InfiniTime's filesystem. A "-" can be used to signify stdin`,
Action: fsWrite,
},
},
},
{
Name: "firmware",
Aliases: []string{"fw"},
Usage: "Manage InfiniTime firmware",
Subcommands: []*cli.Command{
{
Flags: []cli.Flag{
&cli.PathFlag{
Name: "init-packet",
Aliases: []string{"i"},
Usage: "Path to init packet (.dat file)",
},
&cli.PathFlag{
Name: "firmware",
Aliases: []string{"f"},
Usage: "Path to firmware image (.bin file)",
},
&cli.PathFlag{
Name: "resources",
Aliases: []string{"r"},
Usage: "Path to resources file (.zip file)",
},
&cli.PathFlag{
Name: "archive",
Aliases: []string{"a"},
Usage: "Path to firmware archive (.zip file)",
},
},
Name: "upgrade",
Aliases: []string{"upg"},
Usage: "Upgrade InfiniTime firmware using files or archive",
Action: fwUpgrade,
},
{
Name: "version",
Aliases: []string{"ver"},
Usage: "Get firmware version of InfiniTime",
Action: fwVersion,
},
},
},
{
Name: "get",
Usage: "Get information from InfiniTime",
Subcommands: []*cli.Command{
{
Name: "address",
Aliases: []string{"addr"},
Usage: "Get InfiniTime's bluetooth address",
Action: getAddress,
},
{
Name: "battery",
Aliases: []string{"batt"},
Usage: "Get InfiniTime's battery percentage",
Action: getBattery,
},
{
Name: "heart",
Usage: "Get heart rate from InfiniTime",
Action: getHeart,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{Name: "shell"},
},
Name: "motion",
Usage: "Get motion values from InfiniTime",
Action: getMotion,
},
{
Name: "steps",
Usage: "Get step count from InfiniTime",
Action: getSteps,
},
},
},
{
Name: "notify",
Usage: "Send notification to InfiniTime",
Action: notify,
},
{
Name: "set",
Usage: "Set information on InfiniTime",
Subcommands: []*cli.Command{
{
Name: "time",
ArgsUsage: `<ISO8601|"now">`,
Usage: "Set InfiniTime's clock to specified time",
Action: setTime,
},
},
},
{
Name: "update",
Usage: "Update information on InfiniTime",
Aliases: []string{"upd"},
Subcommands: []*cli.Command{
{
Name: "weather",
Usage: "Force an immediate update of weather data",
Action: updateWeather,
},
},
},
{
Name: "watch",
Usage: "Watch a value for changes",
Subcommands: []*cli.Command{
{
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
&cli.BoolFlag{Name: "shell"},
},
Name: "heart",
Usage: "Watch heart rate value for changes",
Action: watchHeart,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
&cli.BoolFlag{Name: "shell"},
},
Name: "steps",
Usage: "Watch step count value for changes",
Action: watchStepCount,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
&cli.BoolFlag{Name: "shell"},
},
Name: "motion",
Usage: "Watch motion coordinates for changes",
Action: watchMotion,
},
{
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
&cli.BoolFlag{Name: "shell"},
},
Name: "battery",
Aliases: []string{"batt"},
Usage: "Watch battery level value for changes",
Action: watchBattLevel,
},
},
},
},
Before: func(c *cli.Context) error {
if !isHelpCmd() {
newClient, err := api.New(c.String("socket-path"))
if err != nil {
return err
}
client = newClient
}
return nil
},
After: func(*cli.Context) error {
if client != nil {
client.Close()
}
return nil
},
}
err := app.RunContext(ctx, os.Args)
if err != nil {
log.Fatal("Error while running app").Err(err).Send()
}
}
func helpCmd(c *cli.Context) error {
cmdArgs := append([]string{os.Args[0]}, c.Args().Slice()...)
cmdArgs = append(cmdArgs, "-h")
return c.App.RunContext(c.Context, cmdArgs)
}
func isHelpCmd() bool {
if len(os.Args) == 1 {
return true
}
for _, arg := range os.Args {
if arg == "-h" || arg == "help" {
return true
}
}
return false
}

17
cmd/itctl/notify.go Normal file
View File

@ -0,0 +1,17 @@
package main
import "github.com/urfave/cli/v2"
func notify(c *cli.Context) error {
// Ensure required arguments
if c.Args().Len() != 2 {
return cli.Exit("Command notify requires two arguments", 1)
}
err := client.Notify(c.Context, c.Args().Get(0), c.Args().Get(1))
if err != nil {
return err
}
return nil
}

57
cmd/itctl/resources.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"context"
"path/filepath"
"github.com/cheggaaa/pb/v3"
"github.com/urfave/cli/v2"
"go.elara.ws/itd/infinitime"
)
func resourcesLoad(c *cli.Context) error {
return resLoad(c.Context, c.Args().Slice())
}
func resLoad(ctx context.Context, args []string) error {
if len(args) == 0 {
return cli.Exit("Command load requires one argument.", 1)
}
// Create progress bar templates
rmTmpl := `Removing {{string . "filename"}}`
upTmpl := `Uploading {{string . "filename"}} {{counters . }} B {{bar . "|" "-" (cycle .) " " "|"}} {{percent . }} {{rtime . "%s"}}`
// Start full bar at 0 total
bar := pb.ProgressBarTemplate(rmTmpl).Start(0)
path, err := filepath.Abs(args[0])
if err != nil {
return err
}
progCh, err := client.FS().LoadResources(ctx, path)
if err != nil {
return err
}
for evt := range progCh {
if evt.Err != nil {
return evt.Err
}
if evt.Operation == infinitime.ResourceRemove {
bar.SetTemplateString(rmTmpl)
bar.Set("filename", evt.Name)
} else {
bar.SetTemplateString(upTmpl)
bar.Set("filename", evt.Name)
bar.SetTotal(evt.Total)
bar.SetCurrent(evt.Sent)
}
}
bar.Finish()
return nil
}

24
cmd/itctl/set.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
"time"
"github.com/urfave/cli/v2"
)
func setTime(c *cli.Context) error {
// Ensure required arguments
if c.Args().Len() < 1 {
return cli.Exit("Command time requires one argument", 1)
}
if c.Args().Get(0) == "now" {
return client.SetTime(c.Context, time.Now())
} else {
parsed, err := time.Parse(time.RFC3339, c.Args().Get(0))
if err != nil {
return err
}
return client.SetTime(c.Context, parsed)
}
}

7
cmd/itctl/update.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/urfave/cli/v2"
func updateWeather(c *cli.Context) error {
return client.WeatherUpdate(c.Context)
}

108
cmd/itctl/watch.go Normal file
View File

@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/urfave/cli/v2"
)
func watchHeart(c *cli.Context) error {
heartCh, err := client.WatchHeartRate(c.Context)
if err != nil {
return err
}
for {
select {
case heartRate := <-heartCh:
if c.Bool("json") {
json.NewEncoder(os.Stdout).Encode(
map[string]uint8{"heartRate": heartRate},
)
} else if c.Bool("shell") {
fmt.Printf("HEART_RATE=%d\n", heartRate)
} else {
fmt.Println(heartRate, "BPM")
}
case <-c.Done():
return nil
}
}
}
func watchBattLevel(c *cli.Context) error {
battLevelCh, err := client.WatchBatteryLevel(c.Context)
if err != nil {
return err
}
for {
select {
case battLevel := <-battLevelCh:
if c.Bool("json") {
json.NewEncoder(os.Stdout).Encode(
map[string]uint8{"battLevel": battLevel},
)
} else if c.Bool("shell") {
fmt.Printf("BATTERY_LEVEL=%d\n", battLevel)
} else {
fmt.Printf("%d%%\n", battLevel)
}
case <-c.Done():
return nil
}
}
}
func watchStepCount(c *cli.Context) error {
stepCountCh, err := client.WatchStepCount(c.Context)
if err != nil {
return err
}
for {
select {
case stepCount := <-stepCountCh:
if c.Bool("json") {
json.NewEncoder(os.Stdout).Encode(
map[string]uint32{"stepCount": stepCount},
)
} else if c.Bool("shell") {
fmt.Printf("STEP_COUNT=%d\n", stepCount)
} else {
fmt.Println(stepCount, "Steps")
}
case <-c.Done():
return nil
}
}
}
func watchMotion(c *cli.Context) error {
motionCh, err := client.WatchMotion(c.Context)
if err != nil {
return err
}
for {
select {
case motionVals := <-motionCh:
if c.Bool("json") {
json.NewEncoder(os.Stdout).Encode(motionVals)
} else if c.Bool("shell") {
fmt.Printf(
"X=%d\nY=%d\nZ=%d\n",
motionVals.X,
motionVals.Y,
motionVals.Z,
)
} else {
fmt.Println(motionVals)
}
case <-c.Done():
return nil
}
}
}

57
cmd/itgui/error.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"image/color"
"os"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func guiErr(err error, msg string, fatal bool, parent fyne.Window) {
// Create new label containing message
msgLbl := widget.NewLabel(msg)
// Text formatting settings
msgLbl.Wrapping = fyne.TextWrapWord
msgLbl.Alignment = fyne.TextAlignCenter
// Create new rectangle to set the size of the dialog
rect := canvas.NewRectangle(color.Transparent)
// Set minimum size of rectangle to 350x0
rect.SetMinSize(fyne.NewSize(350, 0))
// Create new container containing message and rectangle
content := container.NewVBox(
msgLbl,
rect,
)
if err != nil {
// Create new label containing error text
errEntry := widget.NewEntry()
errEntry.SetText(err.Error())
// If text changed, change it back
errEntry.OnChanged = func(string) {
errEntry.SetText(err.Error())
}
// Create new dropdown containing error label
content.Add(widget.NewAccordion(
widget.NewAccordionItem("More Details", errEntry),
))
}
if fatal {
// Create new error dialog
errDlg := dialog.NewCustom("Error", "Close", content, parent)
// On close, exit with code 1
errDlg.SetOnClosed(func() {
os.Exit(1)
})
// Show dialog
errDlg.Show()
// Run app prematurely to stop further execution
parent.ShowAndRun()
} else {
// Show error dialog
dialog.NewCustom("Error", "Ok", content, parent).Show()
}
}

163
cmd/itgui/firmware.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"context"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
"go.elara.ws/itd/api"
)
func firmwareTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
// Create select to chose between archive and files upgrade
typeSelect := widget.NewSelect([]string{"Archive", "Files"}, nil)
typeSelect.PlaceHolder = "Upgrade Type"
// Create map to store files
files := map[string]string{}
// Create and disable start button
startBtn := widget.NewButton("Start", nil)
startBtn.Disable()
// Create new file open dialog for archive
archiveDlg := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
// Set archive path in map
files[".zip"] = uc.URI().Path()
// Enable start button
startBtn.Enable()
}, w)
// Only allow .zip files
archiveDlg.SetFilter(storage.NewExtensionFileFilter([]string{".zip"}))
// Create button to show dialog
archiveBtn := widget.NewButton("Select Archive (.zip)", archiveDlg.Show)
// Create new file open dialog for firmware image
imageDlg := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
// Set firmware image path in map
files[".bin"] = uc.URI().Path()
// If the init packet was already selected
_, datOk := files[".dat"]
if datOk {
// Enable start button
startBtn.Enable()
}
}, w)
// Only allow .bin files
imageDlg.SetFilter(storage.NewExtensionFileFilter([]string{".bin"}))
// Create button to show dialog
imageBtn := widget.NewButton("Select Firmware (.bin)", imageDlg.Show)
// Create new file open dialog for init packet
initDlg := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
defer uc.Close()
// Set init packet path in map
files[".dat"] = uc.URI().Path()
// If the firmware image was already selected
_, binOk := files[".bin"]
if binOk {
// Enable start button
startBtn.Enable()
}
}, w)
// Only allow .dat files
initDlg.SetFilter(storage.NewExtensionFileFilter([]string{".dat"}))
// Create button to show dialog
initBtn := widget.NewButton("Select Init Packet (.dat)", initDlg.Show)
var upgType api.UpgradeType = 255
// When upgrade type changes
typeSelect.OnChanged = func(s string) {
// Delete all files from map
delete(files, ".bin")
delete(files, ".dat")
delete(files, ".zip")
// Hide all dialog buttons
imageBtn.Hide()
initBtn.Hide()
archiveBtn.Hide()
// Disable start button
startBtn.Disable()
switch s {
case "Files":
// Set file upgrade type
upgType = api.UpgradeTypeFiles
// Show firmware image and init packet buttons
imageBtn.Show()
initBtn.Show()
case "Archive":
// Set archive upgrade type
upgType = api.UpgradeTypeArchive
// Show archive button
archiveBtn.Show()
}
}
// Select archive by default
typeSelect.SetSelectedIndex(0)
// When start button pressed
startBtn.OnTapped = func() {
var args []string
// Append the appropriate files for upgrade type
switch upgType {
case api.UpgradeTypeArchive:
args = append(args, files[".zip"])
case api.UpgradeTypeFiles:
args = append(args, files[".dat"], files[".bin"])
}
// If args are nil (invalid upgrade type)
if args == nil {
return
}
// Create new progress dialog
progress := newProgress(w)
// Start firmware upgrade
progressCh, err := client.FirmwareUpgrade(ctx, upgType, args...)
if err != nil {
guiErr(err, "Error performing firmware upgrade", false, w)
return
}
// Show progress dialog
progress.Show()
// For every progress event
for progressEvt := range progressCh {
// Set progress bar values
progress.SetTotal(float64(progressEvt.Total))
progress.SetValue(float64(progressEvt.Sent))
}
// Hide progress dialog
progress.Hide()
}
return container.NewVBox(
layout.NewSpacer(),
typeSelect,
archiveBtn,
imageBtn,
initBtn,
startBtn,
layout.NewSpacer(),
)
}

402
cmd/itgui/fs.go Normal file
View File

@ -0,0 +1,402 @@
package main
import (
"context"
"path/filepath"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"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 {
c := container.NewVBox()
// Create new binding to store current directory
cwdData := binding.NewString()
cwdData.Set("/")
// Create new list binding to store fs listing entries
lsData := binding.NewUntypedList()
// This goroutine waits until the fs tab is opened to
// request the listing from the watch
go func() {
// Wait for opened signal
<-opened
// Show loading pop up
loading := newLoadingPopUp(w)
loading.Show()
// Read root directory
ls, err := client.FS().ReadDir(ctx, "/")
if err != nil {
guiErr(err, "Error reading directory", false, w)
return
}
// Set ls binding
lsData.Set(lsToAny(ls))
// Hide loading pop up
loading.Hide()
}()
toolbar := widget.NewToolbar(
widget.NewToolbarAction(
theme.ViewRefreshIcon(),
func() {
refresh(ctx, cwdData, lsData, client, w, c)
},
),
widget.NewToolbarAction(
theme.FileApplicationIcon(),
func() {
dlg := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
resPath := uc.URI().Path()
uc.Close()
progressDlg := newProgress(w)
progressDlg.Show()
progCh, err := client.FS().LoadResources(ctx, resPath)
if err != nil {
guiErr(err, "Error loading resources", false, w)
return
}
for evt := range progCh {
switch evt.Operation {
case infinitime.ResourceRemove:
progressDlg.SetText("Removing " + evt.Name)
case infinitime.ResourceUpload:
progressDlg.SetText("Uploading " + evt.Name)
progressDlg.SetTotal(float64(evt.Total))
progressDlg.SetValue(float64(evt.Sent))
}
}
progressDlg.Hide()
refresh(ctx, cwdData, lsData, client, w, c)
}, w)
dlg.SetConfirmText("Upload Resources")
dlg.SetFilter(storage.NewExtensionFileFilter([]string{
".zip",
}))
dlg.Show()
},
),
widget.NewToolbarAction(
theme.UploadIcon(),
func() {
// Create open dialog for file that will be uploaded
dlg := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
if err != nil || uc == nil {
return
}
// Get filepath and close
localPath := uc.URI().Path()
uc.Close()
// Create new entry to store filepath
filenameEntry := widget.NewEntry()
// Set entry text to the file name of the selected file
filenameEntry.SetText(filepath.Base(localPath))
// Create new dialog asking for the filename of the file to be stored on the watch
uploadDlg := dialog.NewForm("Upload", "Upload", "Cancel", []*widget.FormItem{
widget.NewFormItem("Filename", filenameEntry),
}, func(ok bool) {
if !ok {
return
}
// Get current directory
cwd, _ := cwdData.Get()
// Get remote path by joining current directory with filename
remotePath := filepath.Join(cwd, filenameEntry.Text)
// Create new progress dialog
progressDlg := newProgress(w)
progressDlg.Show()
// Upload file
progressCh, err := client.FS().Upload(ctx, remotePath, localPath)
if err != nil {
guiErr(err, "Error uploading file", false, w)
return
}
for progressEvt := range progressCh {
progressDlg.SetTotal(float64(progressEvt.Total))
progressDlg.SetValue(float64(progressEvt.Sent))
if progressEvt.Sent == progressEvt.Total {
break
}
}
// Close progress dialog
progressDlg.Hide()
// Add file to listing (avoids full refresh)
lsData.Append(api.FileInfo{
IsDir: false,
Name: filepath.Base(remotePath),
})
}, w)
uploadDlg.Show()
}, w)
dlg.Show()
},
),
widget.NewToolbarAction(
theme.FolderNewIcon(),
func() {
// Create new entry for filename
filenameEntry := widget.NewEntry()
// Create new dialog to ask for the filename
mkdirDialog := dialog.NewForm("Make Directory", "Create", "Cancel", []*widget.FormItem{
widget.NewFormItem("Filename", filenameEntry),
}, func(ok bool) {
if !ok {
return
}
// Get current directory
cwd, _ := cwdData.Get()
// Get remote path by joining current directory and filename
remotePath := filepath.Join(cwd, filenameEntry.Text)
// Make directory
err := client.FS().Mkdir(ctx, remotePath)
if err != nil {
guiErr(err, "Error creating directory", false, w)
return
}
// Add directory to listing (avoids full refresh)
lsData.Append(api.FileInfo{
IsDir: true,
Name: filepath.Base(remotePath),
})
}, w)
mkdirDialog.Show()
},
),
)
// Add listener to listing data to create the new items on the GUI
// whenever the listing changes
lsData.AddListener(binding.NewDataListener(func() {
c.Objects = makeItems(ctx, client, lsData, cwdData, w, c)
c.Refresh()
}))
return container.NewBorder(
nil,
toolbar,
nil,
nil,
container.NewVScroll(c),
)
}
// makeItems creates GUI objects from listing data
func makeItems(
ctx context.Context,
client *api.Client,
lsData binding.UntypedList,
cwdData binding.String,
w fyne.Window,
c *fyne.Container,
) []fyne.CanvasObject {
// Get listing data
ls, _ := lsData.Get()
// Create output slice with dame length as listing
out := make([]fyne.CanvasObject, len(ls))
for index, val := range ls {
// Assert value as file info
item := val.(api.FileInfo)
var icon fyne.Resource
// Decide which icon to use
if item.IsDir {
if item.Name == ".." {
icon = theme.NavigateBackIcon()
} else {
icon = theme.FolderIcon()
}
} else {
icon = theme.FileIcon()
}
// Create new button with the decided icon and the item name
btn := widget.NewButtonWithIcon(item.Name, icon, nil)
// Align left
btn.Alignment = widget.ButtonAlignLeading
// Decide which callback function to use
if item.IsDir {
btn.OnTapped = func() {
// Get current directory
cwd, _ := cwdData.Get()
// Join current directory with item name
cwd = filepath.Join(cwd, item.Name)
// Set new current directory
cwdData.Set(cwd)
// Refresh GUI to display new directory
refresh(ctx, cwdData, lsData, client, w, c)
}
} else {
btn.OnTapped = func() {
// Get current directory
cwd, _ := cwdData.Get()
// Join current directory with item name
remotePath := filepath.Join(cwd, item.Name)
// Create new save dialog
dlg := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) {
if err != nil || uc == nil {
return
}
// Get path of selected file
localPath := uc.URI().Path()
// Close WriteCloser (it's not needed)
uc.Close()
// Create new progress dialog
progressDlg := newProgress(w)
progressDlg.Show()
// Download file
progressCh, err := client.FS().Download(ctx, localPath, remotePath)
if err != nil {
guiErr(err, "Error downloading file", false, w)
return
}
// For every progress event
for progressEvt := range progressCh {
progressDlg.SetTotal(float64(progressEvt.Total))
progressDlg.SetValue(float64(progressEvt.Sent))
}
// Close progress dialog
progressDlg.Hide()
}, w)
// Set filename to the item name
dlg.SetFileName(item.Name)
dlg.Show()
}
}
if item.Name == ".." {
out[index] = btn
continue
}
moveBtn := widget.NewButtonWithIcon("", theme.NavigateNextIcon(), func() {
moveEntry := widget.NewEntry()
dlg := dialog.NewForm("Move", "Move", "Cancel", []*widget.FormItem{
widget.NewFormItem("New Path", moveEntry),
}, func(ok bool) {
if !ok {
return
}
// Get current directory
cwd, _ := cwdData.Get()
// Join current directory with item name
oldPath := filepath.Join(cwd, item.Name)
// Rename file
err := client.FS().Rename(ctx, oldPath, moveEntry.Text)
if err != nil {
guiErr(err, "Error renaming file", false, w)
return
}
// Refresh GUI
refresh(ctx, cwdData, lsData, client, w, c)
}, w)
dlg.Show()
})
removeBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
// Get current directory
cwd, _ := cwdData.Get()
// Join current directory with item name
path := filepath.Join(cwd, item.Name)
// Remove file
err := client.FS().Remove(ctx, path)
if err != nil {
guiErr(err, "Error removing file", false, w)
return
}
// Refresh GUI
refresh(ctx, cwdData, lsData, client, w, c)
})
// Add button to GUI component list
out[index] = container.NewBorder(
nil,
nil,
nil,
container.NewHBox(moveBtn, removeBtn),
btn,
)
}
return out
}
func refresh(
ctx context.Context,
cwdData binding.String,
lsData binding.UntypedList,
client *api.Client,
w fyne.Window,
c *fyne.Container,
) {
// Create and show new loading pop up
loading := newLoadingPopUp(w)
loading.Show()
// Close pop up at the end of the function
defer loading.Hide()
// Get current directory
cwd, _ := cwdData.Get()
// Read directory
ls, err := client.FS().ReadDir(ctx, cwd)
if err != nil {
guiErr(err, "Error reading directory", false, w)
return
}
// Set new listing data
lsData.Set(lsToAny(ls))
// Create new GUI objects
c.Objects = makeItems(ctx, client, lsData, cwdData, w, c)
// Refresh GUI
c.Refresh()
}
func lsToAny(ls []api.FileInfo) []interface{} {
out := make([]interface{}, len(ls)-1)
for i, e := range ls {
// Skip first element as it is always "."
if i == 0 {
continue
}
out[i-1] = e
}
return out
}

150
cmd/itgui/graph.go Normal file
View File

@ -0,0 +1,150 @@
package main
import (
"context"
"database/sql"
"image/color"
"os"
"path/filepath"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/x/fyne/widget/charts"
"go.elara.ws/itd/api"
_ "modernc.org/sqlite"
)
func graphTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
// Get user configuration directory
userCfgDir, err := os.UserConfigDir()
if err != nil {
return nil
}
cfgDir := filepath.Join(userCfgDir, "itd")
dbPath := filepath.Join(cfgDir, "metrics.db")
// If stat on database returns error, return nil
if _, err := os.Stat(dbPath); err != nil {
return nil
}
// Open database
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil
}
// Get heart rate data and create chart
heartRateData := getData(db, "bpm", "heartRate")
heartRate := newLineChartData(nil, heartRateData)
// Get step count data and create chart
stepCountData := getData(db, "steps", "stepCount")
stepCount := newLineChartData(nil, stepCountData)
// Get battery level data and create chart
battLevelData := getData(db, "percent", "battLevel")
battLevel := newLineChartData(nil, battLevelData)
// Get motion data
motionData := getMotionData(db)
// Create chart for each coordinate
xChart := newLineChartData(theme.PrimaryColorNamed(theme.ColorRed), motionData["X"])
yChart := newLineChartData(theme.PrimaryColorNamed(theme.ColorGreen), motionData["Y"])
zChart := newLineChartData(theme.PrimaryColorNamed(theme.ColorBlue), motionData["Z"])
// Create new max container with all the charts
motion := container.NewMax(xChart, yChart, zChart)
// Create tabs for charts
chartTabs := container.NewAppTabs(
container.NewTabItem("Heart Rate", heartRate),
container.NewTabItem("Step Count", stepCount),
container.NewTabItem("Battery Level", battLevel),
container.NewTabItem("Motion", motion),
)
// Place tabs on left
chartTabs.SetTabLocation(container.TabLocationLeading)
return chartTabs
}
func newLineChartData(col color.Color, data []float64) *charts.LineChart {
// Create new line chart
lc := charts.NewLineChart(nil)
setOpts(lc, col)
// If no data, make the stroke transparent
if len(data) == 0 {
lc.Options().StrokeColor = color.RGBA{0, 0, 0, 0}
}
// Set data
lc.SetData(data)
return lc
}
func setOpts(lc *charts.LineChart, col color.Color) {
// Get pointer to options
opts := lc.Options()
// Set fill color to transparent
opts.FillColor = color.RGBA{0, 0, 0, 0}
// Set stroke width
opts.StrokeWidth = 2
// If color provided
if col != nil {
// Set stroke color
opts.StrokeColor = col
} else {
// Set stroke color to orange primary color
opts.StrokeColor = theme.PrimaryColorNamed(theme.ColorOrange)
}
}
func getData(db *sql.DB, field, table string) []float64 {
// Get data from database
rows, err := db.Query("SELECT " + field + " FROM " + table + " ORDER BY time;")
if err != nil {
return nil
}
defer rows.Close()
var out []float64
for rows.Next() {
var val int64
// Scan data into int
err := rows.Scan(&val)
if err != nil {
return nil
}
// Convert to float64 and append to data slice
out = append(out, float64(val))
}
return out
}
func getMotionData(db *sql.DB) map[string][]float64 {
// Get data from database
rows, err := db.Query("SELECT X, Y, Z FROM motion ORDER BY time;")
if err != nil {
return nil
}
defer rows.Close()
out := map[string][]float64{}
for rows.Next() {
var x, y, z int64
// Scan data into ints
err := rows.Scan(&x, &y, &z)
if err != nil {
return nil
}
// Convert to float64 and append to appropriate slice
out["X"] = append(out["X"], float64(x))
out["Y"] = append(out["Y"], float64(y))
out["Z"] = append(out["Z"], float64(z))
}
return out
}

86
cmd/itgui/info.go Normal file
View File

@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"go.elara.ws/itd/api"
)
func infoTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
c := container.NewVBox()
// Create titled text for heart rate
heartRateText := newTitledText("Heart Rate", "0 BPM")
c.Add(heartRateText)
// Watch heart rate
heartRateCh, err := client.WatchHeartRate(ctx)
if err != nil {
guiErr(err, "Error watching heart rate", true, w)
}
go func() {
// For every heart rate sample
for heartRate := range heartRateCh {
// Set body of titled text
heartRateText.SetBody(fmt.Sprintf("%d BPM", heartRate))
}
}()
// Create titled text for battery level
battLevelText := newTitledText("Battery Level", "0%")
c.Add(battLevelText)
// Watch battery level
battLevelCh, err := client.WatchBatteryLevel(ctx)
if err != nil {
guiErr(err, "Error watching battery level", true, w)
}
go func() {
// For every battery level sample
for battLevel := range battLevelCh {
// Set body of titled text
battLevelText.SetBody(fmt.Sprintf("%d%%", battLevel))
}
}()
// Create titled text for step count
stepCountText := newTitledText("Step Count", "0 Steps")
c.Add(stepCountText)
// Watch step count
stepCountCh, err := client.WatchStepCount(ctx)
if err != nil {
guiErr(err, "Error watching step count", true, w)
}
go func() {
// For every step count sample
for stepCount := range stepCountCh {
// Set body of titled text
stepCountText.SetBody(fmt.Sprintf("%d Steps", stepCount))
}
}()
// Create new titled text for address
addressText := newTitledText("Address", "")
c.Add(addressText)
// Get address
address, err := client.Address(ctx)
if err != nil {
guiErr(err, "Error getting address", true, w)
}
// Set body of titled text
addressText.SetBody(address)
// Create new titled text for version
versionText := newTitledText("Version", "")
c.Add(versionText)
// Get version
version, err := client.Version(ctx)
if err != nil {
guiErr(err, "Error getting version", true, w)
}
// Set body of titled text
versionText.SetBody(version)
return container.NewVScroll(c)
}

21
cmd/itgui/loading.go Normal file
View File

@ -0,0 +1,21 @@
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func newLoadingPopUp(w fyne.Window) *widget.PopUp {
pb := widget.NewProgressBarInfinite()
rect := canvas.NewRectangle(color.Transparent)
rect.SetMinSize(fyne.NewSize(200, 0))
return widget.NewModalPopUp(
container.NewMax(rect, pb),
w.Canvas(),
)
}

60
cmd/itgui/main.go Normal file
View File

@ -0,0 +1,60 @@
package main
import (
"context"
"sync"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"go.elara.ws/itd/api"
)
func main() {
a := app.New()
w := a.NewWindow("itgui")
// Create new context for use with the API client
ctx, cancel := context.WithCancel(context.Background())
// Connect to ITD API
client, err := api.New(api.DefaultAddr)
if err != nil {
guiErr(err, "Error connecting to ITD", true, w)
}
// Create channel to signal that the fs tab has been opened
fsOpened := make(chan struct{})
fsOnce := &sync.Once{}
// Create app tabs
tabs := container.NewAppTabs(
container.NewTabItem("Info", infoTab(ctx, client, w)),
container.NewTabItem("Motion", motionTab(ctx, client, w)),
container.NewTabItem("Notify", notifyTab(ctx, client, w)),
container.NewTabItem("FS", fsTab(ctx, client, w, fsOpened)),
container.NewTabItem("Time", timeTab(ctx, client, w)),
container.NewTabItem("Firmware", firmwareTab(ctx, client, w)),
)
metricsTab := graphTab(ctx, client, w)
if metricsTab != nil {
tabs.Append(container.NewTabItem("Metrics", metricsTab))
}
// When a tab is selected
tabs.OnSelected = func(ti *container.TabItem) {
// If the tab's name is FS
if ti.Text == "FS" {
// Signal fsOpened only once
fsOnce.Do(func() {
fsOpened <- struct{}{}
})
}
}
// Cancel context on close
w.SetOnClosed(cancel)
// Set content and show window
w.SetContent(tabs)
w.ShowAndRun()
}

62
cmd/itgui/motion.go Normal file
View File

@ -0,0 +1,62 @@
package main
import (
"context"
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"go.elara.ws/itd/api"
)
func motionTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
// Create titledText for each coordinate
xText := newTitledText("X Coordinate", "0")
yText := newTitledText("Y Coordinate", "0")
zText := newTitledText("Z Coordinate", "0")
var ctxCancel func()
// Create start button
toggleBtn := widget.NewButton("Start", nil)
// Set button's on tapped callback
toggleBtn.OnTapped = func() {
switch toggleBtn.Text {
case "Start":
// Create new context for motion
motionCtx, cancel := context.WithCancel(ctx)
// Set ctxCancel to function so that stop button can run it
ctxCancel = cancel
// Watch motion
motionCh, err := client.WatchMotion(motionCtx)
if err != nil {
guiErr(err, "Error watching motion", false, w)
return
}
go func() {
// For every motion event
for motion := range motionCh {
// Set coordinates
xText.SetBody(fmt.Sprint(motion.X))
yText.SetBody(fmt.Sprint(motion.Y))
zText.SetBody(fmt.Sprint(motion.Z))
}
}()
// Set button text to "Stop"
toggleBtn.SetText("Stop")
case "Stop":
// Cancel motion context
ctxCancel()
// Set button text to "Start"
toggleBtn.SetText("Start")
}
}
return container.NewVScroll(container.NewVBox(
toggleBtn,
xText,
yText,
zText,
))
}

40
cmd/itgui/notify.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"context"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"go.elara.ws/itd/api"
)
func notifyTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
c := container.NewVBox()
c.Add(layout.NewSpacer())
// Create new entry for title
titleEntry := widget.NewEntry()
titleEntry.SetPlaceHolder("Title")
c.Add(titleEntry)
// Create new multiline entry for body
bodyEntry := widget.NewMultiLineEntry()
bodyEntry.SetPlaceHolder("Body")
c.Add(bodyEntry)
// Create new send button
sendBtn := widget.NewButton("Send", func() {
// Send notification
err := client.Notify(ctx, titleEntry.Text, bodyEntry.Text)
if err != nil {
guiErr(err, "Error sending notification", false, w)
return
}
})
c.Add(sendBtn)
c.Add(layout.NewSpacer())
return container.NewVScroll(c)
}

64
cmd/itgui/progress.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"fmt"
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type progress struct {
lbl *widget.Label
progLbl *widget.Label
pb *widget.ProgressBar
*widget.PopUp
}
func newProgress(w fyne.Window) progress {
out := progress{}
out.lbl = widget.NewLabel("")
out.lbl.Hide()
// Create label to show how many bytes transfered and center it
out.progLbl = widget.NewLabel("0 / 0 B")
out.progLbl.Alignment = fyne.TextAlignCenter
// Create new progress bar
out.pb = widget.NewProgressBar()
// Create new rectangle to set the size of the popup
sizeRect := canvas.NewRectangle(color.Transparent)
sizeRect.SetMinSize(fyne.NewSize(300, 50))
// Create vbox for label and progress bar
l := container.NewVBox(out.lbl, out.progLbl, out.pb)
// Create popup
out.PopUp = widget.NewModalPopUp(container.NewMax(l, sizeRect), w.Canvas())
return out
}
func (p progress) SetText(s string) {
p.lbl.SetText(s)
if s == "" {
p.lbl.Hide()
} else {
p.lbl.Show()
}
}
func (p progress) SetTotal(v float64) {
p.pb.Max = v
p.pb.Refresh()
p.progLbl.SetText(fmt.Sprintf("%.0f / %.0f B", p.pb.Value, v))
}
func (p progress) SetValue(v float64) {
p.pb.SetValue(v)
p.progLbl.SetText(fmt.Sprintf("%.0f / %.0f B", v, p.pb.Max))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

57
cmd/itgui/time.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"context"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"go.elara.ws/itd/api"
)
func timeTab(ctx context.Context, client *api.Client, w fyne.Window) fyne.CanvasObject {
c := container.NewVBox()
c.Add(layout.NewSpacer())
// Create entry for time string
timeEntry := widget.NewEntry()
timeEntry.SetText(time.Now().Format(time.RFC1123))
timeEntry.SetPlaceHolder("RFC1123")
// Create button to set current time
setCurrentBtn := widget.NewButton("Set current time", func() {
// Set current time
err := client.SetTime(ctx, time.Now())
if err != nil {
guiErr(err, "Error setting time", false, w)
return
}
// Set time entry to current time
timeEntry.SetText(time.Now().Format(time.RFC1123))
})
// Create button to set time from entry
setBtn := widget.NewButton("Set", func() {
// Parse RFC1123 time string in entry
newTime, err := time.Parse(time.RFC1123, timeEntry.Text)
if err != nil {
guiErr(err, "Error parsing time string", false, w)
return
}
// Set time from parsed string
err = client.SetTime(ctx, newTime)
if err != nil {
guiErr(err, "Error setting time", false, w)
return
}
})
c.Add(timeEntry)
c.Add(setBtn)
c.Add(setCurrentBtn)
c.Add(layout.NewSpacer())
return c
}

35
cmd/itgui/titledText.go Normal file
View File

@ -0,0 +1,35 @@
package main
import "fyne.io/fyne/v2/widget"
type titledText struct {
*widget.RichText
}
func newTitledText(title, text string) titledText {
titleStyle := widget.RichTextStyleHeading
titleStyle.TextStyle.Bold = false
return titledText{
widget.NewRichText(
&widget.TextSegment{
Style: widget.RichTextStyleParagraph,
Text: title,
},
&widget.TextSegment{
Style: titleStyle,
Text: text,
},
&widget.SeparatorSegment{},
),
}
}
func (t titledText) SetTitle(s string) {
t.RichText.Segments[0].(*widget.TextSegment).Text = s
t.Refresh()
}
func (t titledText) SetBody(s string) {
t.RichText.Segments[1].(*widget.TextSegment).Text = s
t.Refresh()
}

113
config.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"os"
"path/filepath"
"strings"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"go.elara.ws/logger"
"go.elara.ws/logger/log"
)
var cfgDir string
func init() {
etcPath := "/etc/itd.toml"
// Set up logger
log.Logger = logger.NewPretty(os.Stderr)
// Get user's configuration directory
userCfgDir, err := os.UserConfigDir()
if err != nil {
panic(err)
}
cfgDir = filepath.Join(userCfgDir, "itd")
// If config dir is not readable
if _, err = os.ReadDir(cfgDir); err != nil {
// Create config dir with 700 permissions
err = os.MkdirAll(cfgDir, 0o700)
if err != nil {
panic(err)
}
}
// Get current and old config paths
cfgPath := filepath.Join(cfgDir, "itd.toml")
oldCfgPath := filepath.Join(userCfgDir, "itd.toml")
// If old config path exists
if _, err = os.Stat(oldCfgPath); err == nil {
// Move old config to new path
err = os.Rename(oldCfgPath, cfgPath)
if err != nil {
panic(err)
}
}
// Set config defaults
setCfgDefaults()
// Load and watch config files
loadAndwatchCfgFile(etcPath)
loadAndwatchCfgFile(cfgPath)
// Load envireonment variables
k.Load(env.Provider("ITD_", "_", func(s string) string {
return strings.ToLower(strings.TrimPrefix(s, "ITD_"))
}), nil)
}
func loadAndwatchCfgFile(filename string) {
provider := file.Provider(filename)
if cfgError := k.Load(provider, toml.Parser()); cfgError != nil {
log.Warn("Error while trying to read config file").Str("filename", filename).Err(cfgError).Send()
}
// Watch for changes and reload when detected
provider.Watch(func(_ interface{}, err error) {
if err != nil {
return
}
if cfgError := k.Load(provider, toml.Parser()); cfgError != nil {
log.Warn("Error while trying to read config file").Str("filename", filename).Err(cfgError).Send()
}
})
}
func setCfgDefaults() {
k.Load(confmap.Provider(map[string]interface{}{
"bluetooth.adapter": "hci0",
"socket.path": "/tmp/itd/socket",
"conn.reconnect": true,
"conn.whitelist.enabled": false,
"conn.whitelist.devices": []string{},
"on.connect.notify": true,
"on.reconnect.notify": true,
"on.reconnect.setTime": true,
"notifs.translit.use": []string{"eASCII"},
"notifs.translit.custom": []string{},
"notifs.ignore.sender": []string{},
"notifs.ignore.summary": []string{"InfiniTime"},
"notifs.ignore.body": []string{},
"music.vol.interval": 5,
"fuse.enabled": false,
"fuse.mountpoint": "/tmp/itd/mnt",
}, "."), nil)
}

66
fuse.go Normal file
View File

@ -0,0 +1,66 @@
package main
import (
"context"
"os"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/fusefs"
"go.elara.ws/logger/log"
)
func startFUSE(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// This is where we'll mount the FS
err := os.MkdirAll(k.String("fuse.mountpoint"), 0o755)
if err != nil && !os.IsExist(err) {
return err
}
// Ignore the error because nothing might be mounted on the mountpoint
_ = fusefs.Unmount(k.String("fuse.mountpoint"))
root, err := fusefs.BuildRootNode(dev)
if err != nil {
log.Error("Building root node failed").
Err(err).
Send()
return err
}
server, err := fs.Mount(k.String("fuse.mountpoint"), root, &fs.Options{
MountOptions: fuse.MountOptions{
// Set to true to see how the file system works.
Debug: false,
SingleThreaded: true,
},
})
if err != nil {
log.Error("Mounting failed").
Str("target", k.String("fuse.mountpoint")).
Err(err).
Send()
return err
}
log.Info("Mounted on target").
Str("target", k.String("fuse.mountpoint")).
Send()
fusefs.BuildProperties(dev)
if err != nil {
log.Warn("Error getting BLE filesystem").Err(err).Send()
return err
}
wg.Add(1)
go func() {
defer wg.Done("fuse")
<-ctx.Done()
server.Unmount()
}()
return nil
}

108
go.mod
View File

@ -1,25 +1,91 @@
module go.arsenm.dev/itd
module go.elara.ws/itd
go 1.16
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 (
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/abiosoft/ishell v2.0.0+incompatible
github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db // indirect
github.com/cheggaaa/pb/v3 v3.0.8
github.com/fatih/color v1.12.0 // indirect
github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
github.com/fsnotify/fsnotify v1.5.0 // indirect
github.com/godbus/dbus/v5 v5.0.4
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mitchellh/mapstructure v1.4.1
github.com/rs/zerolog v1.23.0
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.8.1
go.arsenm.dev/infinitime v0.0.0-20210821070429-ea488067fb9b
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 // indirect
golang.org/x/text v0.3.7 // indirect
fyne.io/fyne/v2 v2.3.0
fyne.io/x/fyne v0.0.0-20220107050838-c4a1de51d4ce
github.com/cheggaaa/pb/v3 v3.1.0
github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d
github.com/godbus/dbus/v5 v5.1.0
github.com/hanwen/go-fuse/v2 v2.2.0
github.com/knadh/koanf v1.4.4
github.com/mattn/go-isatty v0.0.17
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/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 (
fyne.io/systray v1.10.1-0.20221115204952-d16a6177e6f1 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/benoitkugler/textlayout v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
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/fredbi/uri v1.0.0 // indirect
github.com/fsnotify/fsnotify v1.6.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
github.com/gookit/color v1.5.1 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
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/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/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/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
github.com/zeebo/errs v1.3.0 // indirect
golang.org/x/image v0.2.0 // indirect
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.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
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
)

466
go.sum
View File

@ -37,28 +37,58 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
fyne.io/fyne/v2 v2.1.0/go.mod h1:c1vwI38Ebd0dAdxVa6H1Pj6/+cK1xtDy61+I31g+s14=
fyne.io/fyne/v2 v2.3.0 h1:g9tPI3lyBK50IvyPbXqv2zI3JJ4uhMAffu89f3nX5PU=
fyne.io/fyne/v2 v2.3.0/go.mod h1:odfJmbFnODiKn1MXdL44JR6CK+0v8lrmgdPlrUF6w0M=
fyne.io/systray v1.10.1-0.20221115204952-d16a6177e6f1 h1:OiHw+bZAGEaSreHsA8dDkBOVJmSFzsNTOc/htpM+fOc=
fyne.io/systray v1.10.1-0.20221115204952-d16a6177e6f1/go.mod h1:oM2AQqGJ1AMo4nNqZFYU8xYygSBZkW2hmdJ7n4yjedE=
github.com/Andrew-M-C/go.jsonvalue v1.1.2-0.20211223013816-e873b56b4a84/go.mod h1:oTJGG91FhtsxvUFVwHSvr6zuaTcAuroj/ToxfT7Ox8U=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I=
github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw=
github.com/abiosoft/ishell v2.0.0+incompatible/go.mod h1:HQR9AqF2R3P4XXpMpI0NAzgHf/aS6+zVXRj14cVk9qg=
github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db h1:CjPUSXOiYptLbTdr1RceuZgSFDQ7U15ITERUGrUORx8=
github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db/go.mod h1:rB3B4rKii8V21ydCbIzH5hZiCQE7f5E9SzUb/ZZx530=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw=
github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ=
github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8=
github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk=
github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.0 h1:2ehWXEkgb6RUokTjXh1LzdGwG4dRP6X3dqhYYDYhUVk=
github.com/benoitkugler/textlayout v0.3.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1 h1:AvFxBxpfrQd8v55qH59mZOJOQjtD6K2SFe9/HvnIbJk=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cheggaaa/pb/v3 v3.0.8 h1:bC8oemdChbke2FHIIGy9mn4DPJ2caZYQnfbRqwmdCoA=
github.com/cheggaaa/pb/v3 v3.0.8/go.mod h1:UICbiLec/XO6Hw6k+BHEtHeQFzzBH4i2/qk/ow1EJTA=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cheggaaa/pb/v3 v3.1.0 h1:3uouEsl32RL7gTiQsuaXD4Bzbfl5tGztXGUvXbs4O04=
github.com/cheggaaa/pb/v3 v3.1.0/go.mod h1:YjrevcBqadFDaGQKRdmZxTY42pXEqda48Ea3lt0K/BE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@ -66,10 +96,19 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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=
@ -78,24 +117,64 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
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/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI=
github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M=
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=
github.com/fredbi/uri v1.0.0 h1:s4QwUAZ8fz+mbTsukND+4V5f+mJ/wjaTokwstGUAemg=
github.com/fredbi/uri v1.0.0/go.mod h1:1xC40RnIOGCaQzswaOvrzvG/3M3F0hyDVb3aO/1iGy0=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.0 h1:NO5hkcB+srp1x6QmwvNZLeaOgbM8cmBTN32THzjvu2k=
github.com/fsnotify/fsnotify v1.5.0/go.mod h1:BX0DCEr5pT4jm2CnQdVP1lFV521fcCNcyEeNp4DQQDk=
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/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=
github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504/go.mod h1:gLRWYfYnMA9TONeppRSikMdXlHQ97xVsPojddUv3b/E=
github.com/fyne-io/glfw-js v0.0.0-20220517201726-bebc2019cd33 h1:0Ayg0/do/sqX2R7NonoLZvWxGrd9utTVf3A0QvCbC88=
github.com/fyne-io/glfw-js v0.0.0-20220517201726-bebc2019cd33/go.mod h1:gLRWYfYnMA9TONeppRSikMdXlHQ97xVsPojddUv3b/E=
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
github.com/fyne-io/image v0.0.0-20221020213044-f609c6a24345 h1:ONkcbJmsWUOHyjUm0wlnkFc/uaacFFtStVbsG6qJfew=
github.com/fyne-io/image v0.0.0-20221020213044-f609c6a24345/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d h1:dHYKX8CBAs1zSGXm3q3M15CLAEwPEkwrK1ed8FCo+Xo=
github.com/gen2brain/dlgs v0.0.0-20220603100644-40c77870fa8d/go.mod h1:/eFcjDXaU2THSOOqLxOPETIbHETnamk8FA/hMjhg/gU=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/gl v0.0.0-20210813123233-e4099ee2221f/go.mod h1:wjpnOv6ONl2SuJSxqCPVaPZibGFdSci9HFocT9qtVYM=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20211213063430-748e38ca8aec/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b h1:GgabKamyOYguHqHjSkDACcgoPIz3w0Dis/zJ1wyHHHU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
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.4/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=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff/go.mod h1:wfqRWLHRBsRgkp5dmbG56SA0DmVtwrF5N3oPdI8t+Aw=
github.com/goki/freetype v0.0.0-20220119013949-7a161fd3728c h1:JGCm/+tJ9gC6THUxooTldS+CUDsba0qvkvU3DHklqW8=
github.com/goki/freetype v0.0.0-20220119013949-7a161fd3728c/go.mod h1:wfqRWLHRBsRgkp5dmbG56SA0DmVtwrF5N3oPdI8t+Aw=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -125,6 +204,7 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -138,6 +218,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -153,26 +236,51 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
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=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gookit/color v1.5.1 h1:Vjg2VEcdHpwq+oY63s/ksHrgJYCTo0bwWvmmYWdE9fQ=
github.com/gookit/color v1.5.1/go.mod h1:wZFzea4X8qN6vHOSP2apMb4/+w/orMznEzYsIHPaqKM=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
github.com/goxjs/glfw v0.0.0-20191126052801-d2efb5f20838/go.mod h1:oS8P8gVOT4ywTcjV6wZlOU4GuVFQ8F5328KY3MJ79CY=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hanwen/go-fuse/v2 v2.2.0 h1:jo5QZYmBLNcl9ovypWaQ5yXMSSV+Ch68xoC3rtZvvBM=
github.com/hanwen/go-fuse/v2 v2.2.0/go.mod h1:B1nGE/6RBFyBRC1RRnf23UpwCdyJ31eukw34oAKukAc=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -180,118 +288,253 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jackmordaunt/icns v0.0.0-20181231085925-4f16af745526/go.mod h1:UQkeMHVoNcyXYq9otUupF7/h/2tmHlhrS2zw7ZVvUqc=
github.com/jackmordaunt/icns/v2 v2.2.1/go.mod h1:6aYIB9eSzyfHHMKqDf17Xrs1zetQPReAkiUSHzdw4cI=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/josephspurrier/goversioninfo v0.0.0-20200309025242-14b0ab84c6ca/go.mod h1:eJTEwMjXb7kZ633hO3Ln9mBUCOjX2+FlTljvpl9SYdE=
github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knadh/koanf v1.4.4 h1:d2jY5nCCeoaiqvEKSBW9rEc93EfNy/XWgWsSB3j7JEA=
github.com/knadh/koanf v1.4.4/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/lucor/goinfo v0.0.0-20210802170112-c078a2b0f08b/go.mod h1:PRq09yoB+Q2OJReAmwzKivcYyremnibWGbK7WfftHzc=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/metal3d/fyne-x v0.0.0-20220508095732-177117e583fb h1:+fP6ENsbd+BUOmD/kSjNtrOmi2vgJ/JfWDSWjTKmTVY=
github.com/metal3d/fyne-x v0.0.0-20220508095732-177117e583fb/go.mod h1:jBspDudEQ+Rdono8vBGHDtMUPE8ZpB/xq7FUYRqT3CI=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d h1:EG/xyWjHT19rkUpwsWSkyiCCmyqNwFovr9m10rhyOxU=
github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0=
github.com/mozillazg/go-pinyin v0.19.0 h1:p+J8/kjJ558KPvVGYLvqBhxf8jbZA2exSLCs2uUVN8c=
github.com/mozillazg/go-pinyin v0.19.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
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=
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
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/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk=
github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
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=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.23.0 h1:UskrK+saS9P9Y789yNNulYKdARjPZuS35B8gJF2x60g=
github.com/rs/zerolog v1.23.0/go.mod h1:6c7hFfxPOy7TacJc4Fcdi24/J0NKYGzjG8FWRI916Qo=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
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 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
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 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44=
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
github.com/srwiley/oksvg v0.0.0-20200311192757-870daf9aa564/go.mod h1:afMbS0qvv1m5tfENCwnOdZGOF8RGR/FsZ7bvBxQGZG4=
github.com/srwiley/oksvg v0.0.0-20220731023508-a61f04f16b76/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU=
github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
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/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/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=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.arsenm.dev/infinitime v0.0.0-20210821070429-ea488067fb9b h1:Wwj7F0gqYHUx+9H8fCCIy5JZTlCusJRpPuzeSFM0EoU=
go.arsenm.dev/infinitime v0.0.0-20210821070429-ea488067fb9b/go.mod h1:gaepaueUz4J5FfxuV19B4w5pi+V3mD0LTef50ryxr/Q=
github.com/yuin/goldmark v1.3.8/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
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/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=
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -302,16 +545,21 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
@ -321,6 +569,13 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -335,6 +590,9 @@ golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPI
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
golang.org/x/mobile v0.0.0-20221110043201-43a038452099 h1:aIu0lKmfdgtn2uTj7JI2oN4TUrQvgB+wzTPO23bCKt8=
golang.org/x/mobile v0.0.0-20221110043201-43a038452099/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
@ -344,9 +602,13 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -355,9 +617,11 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -365,6 +629,7 @@ golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@ -380,6 +645,13 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -403,11 +675,18 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -415,14 +694,20 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -433,7 +718,8 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/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-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=
@ -441,26 +727,43 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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-20210820121016-41cdb8703e55 h1:rw6UNGRMfarCepjI8qOepea/SXwIBVfTKjztZ5gBbq4=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/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-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=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.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=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -478,6 +781,7 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -508,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=
@ -516,6 +819,11 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -551,6 +859,7 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -590,9 +899,11 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@ -622,21 +933,33 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2/go.mod h1:sUMDUKNB2ZcVjt92UnLy3cdGs+wDAcrPdV3JP6sVgA4=
honnef.co/go/js/dom v0.0.0-20221001195520-26252dedbe70 h1:2ZZFiPwRLxiNX2E/YO6Jgw1pCjDRDgmx20PGyw/cw+M=
honnef.co/go/js/dom v0.0.0-20221001195520-26252dedbe70/go.mod h1:sUMDUKNB2ZcVjt92UnLy3cdGs+wDAcrPdV3JP6sVgA4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@ -644,6 +967,33 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.1 h1:z6qRLw72B0VfRrJjs3l6hWkzYDx1bo0WGVrBGP4ohhM=
modernc.org/sqlite v1.20.1/go.mod h1:fODt+bFmc/j8LcoCbMSkAuKuGmhxjG45KGc25N2705M=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
storj.io/drpc v0.0.32 h1:5p5ZwsK/VOgapaCu+oxaPVwO6UwIs+iwdMiD50+R4PI=
storj.io/drpc v0.0.32/go.mod h1:6rcOyR/QQkSTX/9L5ZGtlZaE2PtXTTZl8d+ulSeeYEg=

142
infinitime/chars.go Normal file
View File

@ -0,0 +1,142 @@
package infinitime
import "tinygo.org/x/bluetooth"
type btChar struct {
Name string
ID bluetooth.UUID
ServiceID bluetooth.UUID
}
var (
musicServiceUUID = mustParse("00000000-78fc-48fe-8e23-433b3a1942d0")
navigationServiceUUID = mustParse("00010000-78fc-48fe-8e23-433b3a1942d0")
motionServiceUUID = mustParse("00030000-78fc-48fe-8e23-433b3a1942d0")
weatherServiceUUID = mustParse("00050000-78fc-48fe-8e23-433b3a1942d0")
)
var (
newAlertChar = btChar{
"New Alert",
bluetooth.CharacteristicUUIDNewAlert,
bluetooth.ServiceUUIDAlertNotification,
}
notifEventChar = btChar{
"Notification Event",
mustParse("00020001-78fc-48fe-8e23-433b3a1942d0"),
bluetooth.ServiceUUIDAlertNotification,
}
stepCountChar = btChar{
"Step Count",
mustParse("00030001-78fc-48fe-8e23-433b3a1942d0"),
motionServiceUUID,
}
rawMotionChar = btChar{
"Raw Motion",
mustParse("00030002-78fc-48fe-8e23-433b3a1942d0"),
motionServiceUUID,
}
firmwareVerChar = btChar{
"Firmware Version",
bluetooth.CharacteristicUUIDFirmwareRevisionString,
bluetooth.ServiceUUIDDeviceInformation,
}
currentTimeChar = btChar{
"Current Time",
bluetooth.CharacteristicUUIDCurrentTime,
bluetooth.ServiceUUIDCurrentTime,
}
localTimeChar = btChar{
"Local Time",
bluetooth.CharacteristicUUIDLocalTimeInformation,
bluetooth.ServiceUUIDCurrentTime,
}
batteryLevelChar = btChar{
"Battery Level",
bluetooth.CharacteristicUUIDBatteryLevel,
bluetooth.ServiceUUIDBattery,
}
heartRateChar = btChar{
"Heart Rate",
bluetooth.CharacteristicUUIDHeartRateMeasurement,
bluetooth.ServiceUUIDHeartRate,
}
fsVersionChar = btChar{
"Filesystem Version",
mustParse("adaf0200-4669-6c65-5472-616e73666572"),
bluetooth.ServiceUUIDFileTransferByAdafruit,
}
fsTransferChar = btChar{
"Filesystem Transfer",
mustParse("adaf0200-4669-6c65-5472-616e73666572"),
bluetooth.ServiceUUIDFileTransferByAdafruit,
}
dfuCtrlPointChar = btChar{
"DFU Control Point",
bluetooth.CharacteristicUUIDLegacyDFUControlPoint,
bluetooth.ServiceUUIDLegacyDFU,
}
dfuPacketChar = btChar{
"DFU Packet",
bluetooth.CharacteristicUUIDLegacyDFUPacket,
bluetooth.ServiceUUIDLegacyDFU,
}
navigationFlagsChar = btChar{
"Navigation Flags",
mustParse("00010001-78fc-48fe-8e23-433b3a1942d0"),
navigationServiceUUID,
}
navigationNarrativeChar = btChar{
"Navigation Narrative",
mustParse("00010002-78fc-48fe-8e23-433b3a1942d0"),
navigationServiceUUID,
}
navigationManDist = btChar{
"Navigation Man Dist",
mustParse("00010003-78fc-48fe-8e23-433b3a1942d0"),
navigationServiceUUID,
}
navigationProgress = btChar{
"Navigation Progress",
mustParse("00010004-78fc-48fe-8e23-433b3a1942d0"),
navigationServiceUUID,
}
weatherDataChar = btChar{
"Weather Data",
mustParse("00050001-78fc-48fe-8e23-433b3a1942d0"),
weatherServiceUUID,
}
musicEventChar = btChar{
"Music Event",
mustParse("00000001-78fc-48fe-8e23-433b3a1942d0"),
musicServiceUUID,
}
musicStatusChar = btChar{
"Music Status",
mustParse("00000002-78fc-48fe-8e23-433b3a1942d0"),
musicServiceUUID,
}
musicArtistChar = btChar{
"Music Artist",
mustParse("00000003-78fc-48fe-8e23-433b3a1942d0"),
musicServiceUUID,
}
musicTrackChar = btChar{
"Music Track",
mustParse("00000004-78fc-48fe-8e23-433b3a1942d0"),
musicServiceUUID,
}
musicAlbumChar = btChar{
"Music Album",
mustParse("00000005-78fc-48fe-8e23-433b3a1942d0"),
musicServiceUUID,
}
)
func mustParse(s string) bluetooth.UUID {
uuid, err := bluetooth.ParseUUID(s)
if err != nil {
panic(err)
}
return uuid
}

222
infinitime/dfu.go Normal file
View File

@ -0,0 +1,222 @@
package infinitime
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"io/fs"
"tinygo.org/x/bluetooth"
)
const (
dfuSegmentSize = 20 // Size of each firmware packet
dfuPktRecvInterval = 10 // Amount of packets to send before checking for receipt
)
var (
dfuCmdStart = []byte{0x01, 0x04}
dfuCmdRecvInitPkt = []byte{0x02, 0x00}
dfuCmdInitPktComplete = []byte{0x02, 0x01}
dfuCmdPktReceiptInterval = []byte{0x08}
dfuCmdRecvFirmware = []byte{0x03}
dfuCmdValidate = []byte{0x04}
dfuCmdActivateReset = []byte{0x05}
dfuResponseStart = []byte{0x10, 0x01, 0x01}
dfuResponseInitParams = []byte{0x10, 0x02, 0x01}
dfuResponseRecvFwImgSuccess = []byte{0x10, 0x03, 0x01}
dfuResponseValidate = []byte{0x10, 0x04, 0x01}
)
// DFUOptions contains options for [UpgradeFirmware]
type DFUOptions struct {
InitPacket fs.File
FirmwareImage fs.File
ProgressFunc func(sent, received, total uint32)
SegmentSize int
ReceiveInterval uint8
}
// UpgradeFirmware upgrades the firmware running on the PineTime.
func (d *Device) UpgradeFirmware(opts DFUOptions) error {
if opts.SegmentSize <= 0 {
opts.SegmentSize = dfuSegmentSize
}
if opts.ReceiveInterval <= 0 {
opts.ReceiveInterval = dfuPktRecvInterval
}
ctrlPoint, err := d.getChar(dfuCtrlPointChar)
if err != nil {
return err
}
packet, err := d.getChar(dfuPacketChar)
if err != nil {
return err
}
d.deviceMtx.Lock()
defer d.deviceMtx.Unlock()
d.updating.Store(true)
defer d.updating.Store(false)
_, err = ctrlPoint.WriteWithoutResponse(dfuCmdStart)
if err != nil {
return err
}
fi, err := opts.FirmwareImage.Stat()
if err != nil {
return err
}
size := uint32(fi.Size())
sizePacket := make([]byte, 8, 12)
sizePacket = binary.LittleEndian.AppendUint32(sizePacket, size)
_, err = packet.WriteWithoutResponse(sizePacket)
if err != nil {
return err
}
_, err = awaitDFUResponse(ctrlPoint, dfuResponseStart)
if err != nil {
return err
}
err = writeDFUInitPacket(ctrlPoint, packet, opts.InitPacket)
if err != nil {
return err
}
err = setRecvInterval(ctrlPoint, opts.ReceiveInterval)
if err != nil {
return err
}
err = sendFirmware(ctrlPoint, packet, opts, size)
if err != nil {
return err
}
return finalize(ctrlPoint)
}
func finalize(ctrlPoint *bluetooth.DeviceCharacteristic) error {
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdValidate)
if err != nil {
return err
}
_, err = awaitDFUResponse(ctrlPoint, dfuResponseValidate)
if err != nil {
return err
}
_, _ = ctrlPoint.WriteWithoutResponse(dfuCmdActivateReset)
return nil
}
func sendFirmware(ctrlPoint, packet *bluetooth.DeviceCharacteristic, opts DFUOptions, totalSize uint32) error {
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdRecvFirmware)
if err != nil {
return err
}
var (
chunksSinceReceipt uint8
bytesSent uint32
)
chunk := make([]byte, opts.SegmentSize)
for {
n, err := opts.FirmwareImage.Read(chunk)
if err != nil && !errors.Is(err, io.EOF) {
return err
} else if n == 0 {
break
}
bytesSent += uint32(n)
_, err = packet.WriteWithoutResponse(chunk[:n])
if err != nil {
return err
}
if errors.Is(err, io.EOF) {
break
}
chunksSinceReceipt += 1
if chunksSinceReceipt == opts.ReceiveInterval {
sizeData, err := awaitDFUResponse(ctrlPoint, []byte{0x11})
if err != nil {
return err
}
size := binary.LittleEndian.Uint32(sizeData)
if size != bytesSent {
return fmt.Errorf("size mismatch: expected %d, got %d", bytesSent, size)
}
if opts.ProgressFunc != nil {
opts.ProgressFunc(bytesSent, size, totalSize)
}
chunksSinceReceipt = 0
}
}
return nil
}
func writeDFUInitPacket(ctrlPoint, packet *bluetooth.DeviceCharacteristic, initPkt fs.File) error {
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdRecvInitPkt)
if err != nil {
return err
}
initData, err := io.ReadAll(initPkt)
if err != nil {
return err
}
_, err = packet.WriteWithoutResponse(initData)
if err != nil {
return err
}
_, err = ctrlPoint.WriteWithoutResponse(dfuCmdInitPktComplete)
if err != nil {
return err
}
_, err = awaitDFUResponse(ctrlPoint, dfuResponseInitParams)
return err
}
func setRecvInterval(ctrlPoint *bluetooth.DeviceCharacteristic, interval uint8) error {
_, err := ctrlPoint.WriteWithoutResponse(append(dfuCmdPktReceiptInterval, interval))
return err
}
func awaitDFUResponse(ctrlPoint *bluetooth.DeviceCharacteristic, expect []byte) ([]byte, error) {
respCh := make(chan []byte, 1)
err := ctrlPoint.EnableNotifications(func(buf []byte) {
respCh <- buf
})
if err != nil {
return nil, err
}
data := <-respCh
ctrlPoint.EnableNotifications(nil)
if !bytes.HasPrefix(data, expect) {
return nil, fmt.Errorf("unexpected dfu response %x (expected %x)", data, expect)
}
return bytes.TrimPrefix(data, expect), nil
}

617
infinitime/fs.go Normal file
View File

@ -0,0 +1,617 @@
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
closed 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.closed {
return 0, fsproto.ErrFileClosed
}
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) {
if fl.closed {
return 0, fsproto.ErrFileClosed
}
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 fl.closed {
return 0, fsproto.ErrFileClosed
}
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 closes the file for future operations
func (fl *File) Close() error {
fl.fs.mtx.Lock()
defer fl.fs.mtx.Unlock()
fl.closed = true
return nil
}
// requestThenAwaitResponse executes a BLE FS request and then waits for one or more responses,
// until fn returns true or an error is encountered.
func (ifs *FS) requestThenAwaitResponse(char *bluetooth.DeviceCharacteristic, opcode fsproto.FSReqOpcode, req any, fn func(buf []byte) (bool, error)) error {
var stopped atomic.Bool
errCh := make(chan error, 1)
char.EnableNotifications(func(buf []byte) {
stop, err := fn(buf)
if err != nil && !stopped.Load() {
errCh <- err
char.EnableNotifications(nil)
return
} else if !stopped.Load() {
errCh <- nil
}
if stop && !stopped.Load() {
stopped.Store(true)
close(errCh)
char.EnableNotifications(nil)
}
})
defer char.EnableNotifications(nil)
err := fsproto.WriteRequest(char, opcode, req)
if err != nil {
return err
}
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
func (ifs *FS) mtu(char *bluetooth.DeviceCharacteristic) uint16 {
mtuVal, _ := char.GetMTU()
if mtuVal == 0 {
mtuVal = 256
}
return mtuVal - 20
}
var _ fs.FS = (*GoFS)(nil)
var _ fs.StatFS = (*GoFS)(nil)
var _ fs.ReadDirFS = (*GoFS)(nil)
// GoFS implements [io/fs.FS], [io/fs.StatFS], and [io/fs.ReadDirFS]
// for the InfiniTime filesystem
type GoFS struct {
*FS
}
// Open opens an existing file at the specified path.
// It returns a handle for the file and an error, if any.
func (gfs GoFS) Open(path string) (fs.File, error) {
return gfs.FS.Open(path)
}

142
infinitime/fstypes.go Normal file
View File

@ -0,0 +1,142 @@
package infinitime
import (
"fmt"
"io/fs"
"strconv"
"time"
)
// DirEntry represents an entry from a directory listing
type DirEntry struct {
flags uint32
modtime uint64
size uint32
path string
}
// Name returns the name of the file described by the entry
func (de DirEntry) Name() string {
return de.path
}
// IsDir reports whether the entry describes a directory.
func (de DirEntry) IsDir() bool {
return de.flags&0b1 == 1
}
// Type returns the type bits for the entry.
func (de DirEntry) Type() fs.FileMode {
if de.IsDir() {
return fs.ModeDir
} else {
return 0
}
}
// Info returns the FileInfo for the file or subdirectory described by the entry.
func (de DirEntry) Info() (fs.FileInfo, error) {
return FileInfo{
name: de.path,
size: de.size,
modtime: de.modtime,
mode: de.Type(),
isDir: de.IsDir(),
}, nil
}
func (de DirEntry) String() string {
var isDirChar rune
if de.IsDir() {
isDirChar = 'd'
} else {
isDirChar = '-'
}
// Get human-readable value for file size
val, unit := bytesHuman(de.size)
prec := 0
// If value is less than 10, set precision to 1
if val < 10 {
prec = 1
}
// Convert float to string
valStr := strconv.FormatFloat(val, 'f', prec, 64)
// Return string formatted like so:
// - 10 kB file
// or:
// d 0 B .
return fmt.Sprintf(
"%c %3s %-2s %s",
isDirChar,
valStr,
unit,
de.path,
)
}
func bytesHuman(b uint32) (float64, string) {
const unit = 1000
// Set possible unit prefixes (PineTime flash is 4MB)
units := [2]rune{'k', 'M'}
// If amount of bytes is less than smallest unit
if b < unit {
// Return unchanged with unit "B"
return float64(b), "B"
}
div, exp := uint32(unit), 0
// Get decimal values and unit prefix index
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
// Create string for full unit
unitStr := string([]rune{units[exp], 'B'})
// Return decimal with unit string
return float64(b) / float64(div), unitStr
}
// FileInfo implements fs.FileInfo
type FileInfo struct {
name string
size uint32
modtime uint64
mode fs.FileMode
isDir bool
}
// Name returns the base name of the file
func (fi FileInfo) Name() string {
return fi.name
}
// Size returns the total size of the file
func (fi FileInfo) Size() int64 {
return int64(fi.size)
}
// Mode returns the mode of the file
func (fi FileInfo) Mode() fs.FileMode {
return fi.mode
}
// ModTime returns the modification time of the file
// As of now, this is unimplemented in InfiniTime, and
// will always return 0.
func (fi FileInfo) ModTime() time.Time {
return time.Unix(0, int64(fi.modtime))
}
// IsDir returns whether the file is a directory
func (fi FileInfo) IsDir() bool {
return fi.isDir
}
// Sys is unimplemented and returns nil
func (fi FileInfo) Sys() any {
return nil
}

173
infinitime/infinitime.go Normal file
View File

@ -0,0 +1,173 @@
package infinitime
import (
"fmt"
"sync"
"sync/atomic"
"time"
"tinygo.org/x/bluetooth"
)
type Options struct {
Allowlist []string
Blocklist []string
ScanInterval time.Duration
OnDisconnect func(dev *Device)
OnReconnect func(dev *Device)
OnConnect func(dev *Device)
}
func reconnect(opts Options, adapter *bluetooth.Adapter, device *Device, mac string) {
if device == nil {
return
}
done := false
for {
adapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) {
if sr.Address.String() != mac {
return
}
dev, err := a.Connect(sr.Address, bluetooth.ConnectionParams{})
if err != nil {
return
}
adapter.StopScan()
device.deviceMtx.Lock()
device.device = dev
device.deviceMtx.Unlock()
device.notifierMtx.Lock()
for char, notifier := range device.notifierMap {
c, err := device.getChar(char)
if err != nil {
continue
}
err = c.EnableNotifications(nil)
if err != nil {
continue
}
err = c.EnableNotifications(notifier.notify)
if err != nil {
continue
}
}
device.notifierMtx.Unlock()
done = true
})
if done {
return
}
time.Sleep(opts.ScanInterval)
}
}
func Connect(opts Options) (device *Device, err error) {
adapter := bluetooth.DefaultAdapter
if opts.ScanInterval == 0 {
opts.ScanInterval = 2 * time.Minute
}
var mac string
adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) {
if mac == "" || dev.Address.String() != mac {
return
}
if connected {
if opts.OnReconnect != nil {
opts.OnReconnect(device)
}
} else {
if opts.OnDisconnect != nil {
opts.OnDisconnect(device)
}
go reconnect(opts, adapter, device, mac)
}
})
err = adapter.Enable()
if err != nil {
return nil, err
}
var scanErr error
err = adapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) {
if sr.LocalName() != "InfiniTime" {
return
}
dev, err := a.Connect(sr.Address, bluetooth.ConnectionParams{})
if err != nil {
scanErr = err
adapter.StopScan()
return
}
mac = dev.Address.String()
device = &Device{adapter: a, device: dev, notifierMap: map[btChar]notifier{}}
if opts.OnConnect != nil {
opts.OnConnect(device)
}
adapter.StopScan()
})
if err != nil {
return nil, err
}
if scanErr != nil {
return nil, scanErr
}
return device, nil
}
// Device represents an InfiniTime device
type Device struct {
adapter *bluetooth.Adapter
deviceMtx sync.Mutex
device bluetooth.Device
updating atomic.Bool
notifierMtx sync.Mutex
notifierMap map[btChar]notifier
}
// FS returns a handle for InifniTime's filesystem'
func (d *Device) FS() *FS {
return &FS{
dev: d,
}
}
func (d *Device) getChar(c btChar) (*bluetooth.DeviceCharacteristic, error) {
if d.updating.Load() {
return nil, fmt.Errorf("device is currently updating")
}
d.deviceMtx.Lock()
defer d.deviceMtx.Unlock()
services, err := d.device.DiscoverServices([]bluetooth.UUID{c.ServiceID})
if err != nil {
return nil, fmt.Errorf("characteristic %s (%s) not found", c.ID, c.Name)
}
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{c.ID})
if err != nil {
return nil, fmt.Errorf("characteristic %s (%s) not found", c.ID, c.Name)
}
return chars[0], err
}

101
infinitime/info.go Normal file
View File

@ -0,0 +1,101 @@
package infinitime
import (
"context"
"encoding/binary"
)
// Address returns the MAC address of the connected device.
func (d *Device) Address() string {
return d.device.Address.String()
}
// Version returns the version of InifniTime that the connected device is running.
func (d *Device) Version() (string, error) {
c, err := d.getChar(firmwareVerChar)
if err != nil {
return "", err
}
ver := make([]byte, 16)
n, err := c.Read(ver)
return string(ver[:n]), err
}
// BatteryLevel returns the current battery level of the connected PineTime.
func (d *Device) BatteryLevel() (lvl uint8, err error) {
c, err := d.getChar(batteryLevelChar)
if err != nil {
return 0, err
}
err = binary.Read(c, binary.LittleEndian, &lvl)
return lvl, err
}
// WatchBatteryLevel calls fn whenever the battery level changes.
func (d *Device) WatchBatteryLevel(ctx context.Context, fn func(level uint8, err error)) error {
return watchChar(ctx, d, batteryLevelChar, fn)
}
// StepCount returns the current step count recorded on the watch.
func (d *Device) StepCount() (sc uint32, err error) {
c, err := d.getChar(stepCountChar)
if err != nil {
return 0, err
}
err = binary.Read(c, binary.LittleEndian, &sc)
return sc, err
}
// WatchStepCount calls fn whenever the step count changes.
func (d *Device) WatchStepCount(ctx context.Context, fn func(count uint32, err error)) error {
return watchChar(ctx, d, stepCountChar, fn)
}
// HeartRate returns the current heart rate recorded on the watch.
func (d *Device) HeartRate() (uint8, error) {
c, err := d.getChar(heartRateChar)
if err != nil {
return 0, err
}
data := make([]byte, 2)
_, err = c.Read(data)
if err != nil {
return 0, err
}
return data[1], nil
}
// WatchHeartRate calls fn whenever the heart rate changes.
func (d *Device) WatchHeartRate(ctx context.Context, fn func(rate uint8, err error)) error {
return watchChar(ctx, d, heartRateChar, func(rate [2]uint8, err error) {
fn(rate[1], err)
})
}
// MotionValues represents gyroscope coordinates.
type MotionValues struct {
X int16
Y int16
Z int16
}
// Motion returns the current gyroscope coordinates of the PineTime.
func (d *Device) Motion() (mv MotionValues, err error) {
c, err := d.getChar(rawMotionChar)
if err != nil {
return MotionValues{}, err
}
err = binary.Read(c, binary.LittleEndian, &mv)
return mv, err
}
// WatchMotion calls fn whenever the gyroscope coordinates change.
func (d *Device) WatchMotion(ctx context.Context, fn func(level MotionValues, err error)) error {
return watchChar(ctx, d, rawMotionChar, fn)
}

68
infinitime/music.go Normal file
View File

@ -0,0 +1,68 @@
package infinitime
import "context"
type MusicEvent uint8
const (
MusicEventOpen MusicEvent = 0xe0
MusicEventPlay MusicEvent = 0x00
MusicEventPause MusicEvent = 0x01
MusicEventNext MusicEvent = 0x03
MusicEventPrev MusicEvent = 0x04
MusicEventVolUp MusicEvent = 0x05
MusicEventVolDown MusicEvent = 0x06
)
// SetMusicStatus sets whether the music is playing or paused.
func (d *Device) SetMusicStatus(playing bool) error {
char, err := d.getChar(musicStatusChar)
if err != nil {
return err
}
if playing {
_, err = char.WriteWithoutResponse([]byte{0x1})
} else {
_, err = char.WriteWithoutResponse([]byte{0x0})
}
return err
}
// SetMusicArtist sets the music artist.
func (d *Device) SetMusicArtist(artist string) error {
char, err := d.getChar(musicArtistChar)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(artist))
return err
}
// SetMusicTrack sets the music track name.
func (d *Device) SetMusicTrack(track string) error {
char, err := d.getChar(musicTrackChar)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(track))
return err
}
// SetMusicAlbum sets the music album name.
func (d *Device) SetMusicAlbum(album string) error {
char, err := d.getChar(musicAlbumChar)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(album))
return err
}
// WatchMusicEvents calls fn whenever the InfiniTime music app broadcasts an event.
func (d *Device) WatchMusicEvents(ctx context.Context, fn func(event MusicEvent, err error)) error {
return watchChar(ctx, d, musicEventChar, fn)
}

137
infinitime/navigation.go Normal file
View File

@ -0,0 +1,137 @@
package infinitime
type NavFlag string
const (
NavFlagArrive NavFlag = "arrive"
NavFlagArriveLeft NavFlag = "arrive-left"
NavFlagArriveRight NavFlag = "arrive-right"
NavFlagArriveStraight NavFlag = "arrive-straight"
NavFlagClose NavFlag = "close"
NavFlagContinue NavFlag = "continue"
NavFlagContinueLeft NavFlag = "continue-left"
NavFlagContinueRight NavFlag = "continue-right"
NavFlagContinueSlightLeft NavFlag = "continue-slight-left"
NavFlagContinueSlightRight NavFlag = "continue-slight-right"
NavFlagContinueStraight NavFlag = "continue-straight"
NavFlagContinueUturn NavFlag = "continue-uturn"
NavFlagDepart NavFlag = "depart"
NavFlagDepartLeft NavFlag = "depart-left"
NavFlagDepartRight NavFlag = "depart-right"
NavFlagDepartStraight NavFlag = "depart-straight"
NavFlagEndOfRoadLeft NavFlag = "end-of-road-left"
NavFlagEndOfRoadRight NavFlag = "end-of-road-right"
NavFlagFerry NavFlag = "ferry"
NavFlagFlag NavFlag = "flag"
NavFlagFork NavFlag = "fork"
NavFlagForkLeft NavFlag = "fork-left"
NavFlagForkRight NavFlag = "fork-right"
NavFlagForkSlightLeft NavFlag = "fork-slight-left"
NavFlagForkSlightRight NavFlag = "fork-slight-right"
NavFlagForkStraight NavFlag = "fork-straight"
NavFlagInvalid NavFlag = "invalid"
NavFlagInvalidLeft NavFlag = "invalid-left"
NavFlagInvalidRight NavFlag = "invalid-right"
NavFlagInvalidSlightLeft NavFlag = "invalid-slight-left"
NavFlagInvalidSlightRight NavFlag = "invalid-slight-right"
NavFlagInvalidStraight NavFlag = "invalid-straight"
NavFlagInvalidUturn NavFlag = "invalid-uturn"
NavFlagMergeLeft NavFlag = "merge-left"
NavFlagMergeRight NavFlag = "merge-right"
NavFlagMergeSlightLeft NavFlag = "merge-slight-left"
NavFlagMergeSlightRight NavFlag = "merge-slight-right"
NavFlagMergeStraight NavFlag = "merge-straight"
NavFlagNewNameLeft NavFlag = "new-name-left"
NavFlagNewNameRight NavFlag = "new-name-right"
NavFlagNewNameSharpLeft NavFlag = "new-name-sharp-left"
NavFlagNewNameSharpRight NavFlag = "new-name-sharp-right"
NavFlagNewNameSlightLeft NavFlag = "new-name-slight-left"
NavFlagNewNameSlightRight NavFlag = "new-name-slight-right"
NavFlagNewNameStraight NavFlag = "new-name-straight"
NavFlagNotificationLeft NavFlag = "notification-left"
NavFlagNotificationRight NavFlag = "notification-right"
NavFlagNotificationSharpLeft NavFlag = "notification-sharp-left"
NavFlagNotificationSharpRight NavFlag = "notification-sharp-right"
NavFlagNotificationSlightLeft NavFlag = "notification-slight-left"
NavFlagNotificationSlightRight NavFlag = "notification-slight-right"
NavFlagNotificationStraight NavFlag = "notification-straight"
NavFlagOffRampLeft NavFlag = "off-ramp-left"
NavFlagOffRampRight NavFlag = "off-ramp-right"
NavFlagOffRampSharpLeft NavFlag = "off-ramp-sharp-left"
NavFlagOffRampSharpRight NavFlag = "off-ramp-sharp-right"
NavFlagOffRampSlightLeft NavFlag = "off-ramp-slight-left"
NavFlagOffRampSlightRight NavFlag = "off-ramp-slight-right"
NavFlagOffRampStraight NavFlag = "off-ramp-straight"
NavFlagOnRampLeft NavFlag = "on-ramp-left"
NavFlagOnRampRight NavFlag = "on-ramp-right"
NavFlagOnRampSharpLeft NavFlag = "on-ramp-sharp-left"
NavFlagOnRampSharpRight NavFlag = "on-ramp-sharp-right"
NavFlagOnRampSlightLeft NavFlag = "on-ramp-slight-left"
NavFlagOnRampSlightRight NavFlag = "on-ramp-slight-right"
NavFlagOnRampStraight NavFlag = "on-ramp-straight"
NavFlagRotary NavFlag = "rotary"
NavFlagRotaryLeft NavFlag = "rotary-left"
NavFlagRotaryRight NavFlag = "rotary-right"
NavFlagRotarySharpLeft NavFlag = "rotary-sharp-left"
NavFlagRotarySharpRight NavFlag = "rotary-sharp-right"
NavFlagRotarySlightLeft NavFlag = "rotary-slight-left"
NavFlagRotarySlightRight NavFlag = "rotary-slight-right"
NavFlagRotaryStraight NavFlag = "rotary-straight"
NavFlagRoundabout NavFlag = "roundabout"
NavFlagRoundaboutLeft NavFlag = "roundabout-left"
NavFlagRoundaboutRight NavFlag = "roundabout-right"
NavFlagRoundaboutSharpLeft NavFlag = "roundabout-sharp-left"
NavFlagRoundaboutSharpRight NavFlag = "roundabout-sharp-right"
NavFlagRoundaboutSlightLeft NavFlag = "roundabout-slight-left"
NavFlagRoundaboutSlightRight NavFlag = "roundabout-slight-right"
NavFlagRoundaboutStraight NavFlag = "roundabout-straight"
NavFlagTurnLeft NavFlag = "turn-left"
NavFlagTurnRight NavFlag = "turn-right"
NavFlagTurnSharpLeft NavFlag = "turn-sharp-left"
NavFlagTurnSharpRight NavFlag = "turn-sharp-right"
NavFlagTurnSlightLeft NavFlag = "turn-slight-left"
NavFlagTurnSlightRight NavFlag = "turn-slight-right"
NavFlagTurnStraight NavFlag = "turn-straight"
NavFlagUpDown NavFlag = "updown"
NavFlagUTurn NavFlag = "uturn"
)
// SetNavFlag sets the navigation flag icon.
func (d *Device) SetNavFlag(flag NavFlag) error {
char, err := d.getChar(navigationFlagsChar)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(flag))
return err
}
// SetNavNarrative sets the navigation narrative string.
func (d *Device) SetNavNarrative(narrative string) error {
char, err := d.getChar(navigationNarrativeChar)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(narrative))
return err
}
// SetNavManeuverDistance sets the navigation maneuver distance.
func (d *Device) SetNavManeuverDistance(manDist string) error {
char, err := d.getChar(navigationManDist)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte(manDist))
return err
}
// SetNavProgress sets the navigation progress.
func (d *Device) SetNavProgress(progress uint8) error {
char, err := d.getChar(navigationProgress)
if err != nil {
return err
}
_, err = char.WriteWithoutResponse([]byte{progress})
return err
}

42
infinitime/notifs.go Normal file
View File

@ -0,0 +1,42 @@
package infinitime
var (
regularNotifHeader = []byte{0x00, 0x01, 0x00}
callNotifHeader = []byte{0x03, 0x01, 0x00}
)
// Notify sends a notification to the PineTime using the Alert Notification Service
func (d *Device) Notify(title, body string) error {
c, err := d.getChar(newAlertChar)
if err != nil {
return err
}
content := title + "\x00" + body
_, err = c.WriteWithoutResponse(append(regularNotifHeader, content...))
return err
}
type CallStatus uint8
const (
CallStatusDeclined CallStatus = iota
CallStatusAccepted
CallStatusMuted
)
// NotifyCall sends a call to the PineTime using the Alert Notification Service,
// then executes fn once the user presses a button on the watch.
func (d *Device) NotifyCall(from string, fn func(CallStatus)) error {
c, err := d.getChar(newAlertChar)
if err != nil {
return err
}
_, err = c.WriteWithoutResponse(append(callNotifHeader, from...))
if err != nil {
return err
}
return watchCharOnce(d, notifEventChar, fn)
}

135
infinitime/resources.go Normal file
View File

@ -0,0 +1,135 @@
package infinitime
import (
"archive/zip"
"encoding/json"
"errors"
"io"
"path/filepath"
)
type ResourceOperation int
const (
// ResourceUpload represents the upload phase
// of resource loading
ResourceUpload = iota
// ResourceRemove represents the obsolete
// file removal phase of resource loading
ResourceRemove
)
// resourceManifest is the structure of the resource manifest file
type resourceManifest struct {
Resources []resource `json:"resources"`
Obsolete []obsoleteResource `json:"obsolete_files"`
}
// resource represents a resource entry in the manifest
type resource struct {
Name string `json:"filename"`
Path string `json:"path"`
}
// obsoleteResource represents an obsolete file entry in the manifest
type obsoleteResource struct {
Path string `json:"path"`
Since string `json:"since"`
}
// ResourceLoadProgress contains information on the progress of
// a resource load
type ResourceLoadProgress struct {
Operation ResourceOperation
Name string
Total uint32
Transferred uint32
}
// LoadResources accepts the path of an InfiniTime resource archive and loads its contents to the watch's filesystem.
func LoadResources(archivePath string, fs *FS, progress func(ResourceLoadProgress)) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
manifestFl, err := r.Open("resources.json")
if err != nil {
return err
}
var manifest resourceManifest
err = json.NewDecoder(manifestFl).Decode(&manifest)
if err != nil {
return err
}
err = manifestFl.Close()
if err != nil {
return err
}
for _, file := range manifest.Obsolete {
err := fs.RemoveAll(file.Path)
if err != nil {
return err
}
progress(ResourceLoadProgress{
Operation: ResourceRemove,
Name: filepath.Base(file.Path),
})
}
for _, file := range manifest.Resources {
src, err := r.Open(file.Name)
if err != nil {
return err
}
fi, err := src.Stat()
if err != nil {
return err
}
err = fs.MkdirAll(filepath.Dir(file.Path))
if err != nil {
return err
}
dst, err := fs.Create(file.Path, uint32(fi.Size()))
if err != nil {
return err
}
dst.ProgressFunc = func(transferred, total uint32) {
progress(ResourceLoadProgress{
Name: file.Name,
Transferred: transferred,
Total: total,
})
}
_, err = io.Copy(dst, src)
if err != nil {
return errors.Join(
err,
src.Close(),
dst.Close(),
)
}
err = src.Close()
if err != nil {
return err
}
err = dst.Close()
if err != nil {
return err
}
}
return nil
}

58
infinitime/time.go Normal file
View File

@ -0,0 +1,58 @@
package infinitime
import (
"bytes"
"encoding/binary"
"time"
)
// SetTime sets the current time, and then sets the timezone data,
// if the local time characteristic is available.
func (d *Device) SetTime(t time.Time) error {
c, err := d.getChar(currentTimeChar)
if err != nil {
return err
}
buf := &bytes.Buffer{}
binary.Write(buf, binary.LittleEndian, uint16(t.Year()))
binary.Write(buf, binary.LittleEndian, uint8(t.Month()))
binary.Write(buf, binary.LittleEndian, uint8(t.Day()))
binary.Write(buf, binary.LittleEndian, uint8(t.Hour()))
binary.Write(buf, binary.LittleEndian, uint8(t.Minute()))
binary.Write(buf, binary.LittleEndian, uint8(t.Second()))
binary.Write(buf, binary.LittleEndian, uint8(t.Weekday()))
binary.Write(buf, binary.LittleEndian, uint8((t.Nanosecond()/1000)/1e6*256))
binary.Write(buf, binary.LittleEndian, uint8(0b0001))
_, err = c.WriteWithoutResponse(buf.Bytes())
if err != nil {
return err
}
ltc, err := d.getChar(localTimeChar)
if err != nil {
return nil
}
_, offset := t.Zone()
dst := 0
// Local time expects two values: the timezone offset and the dst offset, both
// expressed in quarters of an hour.
// Timezone offset is to be constant over DST, with dst offset holding the offset != 0
// when DST is in effect.
// As there is no standard way in go to get the actual dst offset, we assume it to be 1h
// when DST is in effect
if t.IsDST() {
dst = 3600
offset -= 3600
}
buf.Reset()
binary.Write(buf, binary.LittleEndian, uint8(offset/3600*4))
binary.Write(buf, binary.LittleEndian, uint8(dst/3600*4))
_, err = ltc.WriteWithoutResponse(buf.Bytes())
return err
}

108
infinitime/watch.go Normal file
View File

@ -0,0 +1,108 @@
package infinitime
import (
"bytes"
"context"
"encoding/binary"
"sync"
"tinygo.org/x/bluetooth"
)
type notifier interface {
notify([]byte)
}
type watcher[T any] struct {
mu sync.Mutex
nextFuncID int
callbacks map[int]func(T, error)
char *bluetooth.DeviceCharacteristic
}
func (w *watcher[T]) addCallback(fn func(T, error)) int {
w.mu.Lock()
defer w.mu.Unlock()
funcID := w.nextFuncID
w.callbacks[funcID] = fn
w.nextFuncID++
return funcID
}
func (w *watcher[T]) notify(b []byte) {
var val T
err := binary.Read(bytes.NewReader(b), binary.LittleEndian, &val)
w.mu.Lock()
for _, fn := range w.callbacks {
go fn(val, err)
}
w.mu.Unlock()
}
func (w *watcher[T]) cancelFn(d *Device, ch btChar, id int) func() {
return func() {
w.mu.Lock()
delete(w.callbacks, id)
w.mu.Unlock()
if len(w.callbacks) == 0 {
d.notifierMtx.Lock()
delete(d.notifierMap, ch)
d.notifierMtx.Unlock()
w.char.EnableNotifications(nil)
}
}
}
func watchChar[T any](ctx context.Context, d *Device, ch btChar, fn func(T, error)) error {
d.notifierMtx.Lock()
defer d.notifierMtx.Unlock()
if n, ok := d.notifierMap[ch]; ok {
w := n.(*watcher[T])
funcID := w.addCallback(fn)
context.AfterFunc(ctx, w.cancelFn(d, ch, funcID))
go func() {
<-ctx.Done()
w.cancelFn(d, ch, funcID)()
}()
return nil
} else {
c, err := d.getChar(ch)
if err != nil {
return err
}
w := &watcher[T]{callbacks: map[int]func(T, error){}}
err = c.EnableNotifications(w.notify)
if err != nil {
return err
}
w.char = c
funcID := w.addCallback(fn)
d.notifierMap[ch] = w
context.AfterFunc(ctx, w.cancelFn(d, ch, funcID))
return nil
}
}
func watchCharOnce[T any](d *Device, ch btChar, fn func(T)) error {
ctx, cancel := context.WithCancel(context.Background())
var watchErr error
err := watchChar(ctx, d, ch, func(val T, err error) {
defer cancel()
if err != nil {
watchErr = err
return
}
fn(val)
})
if err != nil {
return err
}
<-ctx.Done()
return watchErr
}

124
infinitime/weather.go Normal file
View File

@ -0,0 +1,124 @@
package infinitime
import (
"bytes"
"encoding/binary"
"errors"
"time"
)
const (
weatherVersion = 0
currentWeatherType = 0
forecastWeatherType = 1
)
type WeatherIcon uint8
const (
WeatherIconClear WeatherIcon = iota
WeatherIconFewClouds
WeatherIconClouds
WeatherIconHeavyClouds
WeatherIconCloudsWithRain
WeatherIconRain
WeatherIconThunderstorm
WeatherIconSnow
WeatherIconMist
)
// CurrentWeather represents the current weather
type CurrentWeather struct {
Time time.Time
CurrentTemp float32
MinTemp float32
MaxTemp float32
Location string
Icon WeatherIcon
}
// Bytes returns the [CurrentWeather] struct encoded using the InfiniTime
// weather wire protocol.
func (cw CurrentWeather) Bytes() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(currentWeatherType)
buf.WriteByte(weatherVersion)
_, offset := cw.Time.Zone()
binary.Write(buf, binary.LittleEndian, cw.Time.Unix()+int64(offset))
binary.Write(buf, binary.LittleEndian, int16(cw.CurrentTemp*100))
binary.Write(buf, binary.LittleEndian, int16(cw.MinTemp*100))
binary.Write(buf, binary.LittleEndian, int16(cw.MaxTemp*100))
location := make([]byte, 32)
copy(location, cw.Location)
buf.Write(location)
buf.WriteByte(byte(cw.Icon))
return buf.Bytes()
}
// Forecast represents a weather forecast
type Forecast struct {
Time time.Time
Days []ForecastDay
}
// ForecastDay represents a forecast for a single day
type ForecastDay struct {
MinTemp int16
MaxTemp int16
Icon WeatherIcon
}
// Bytes returns the [Forecast] struct encoded using the InfiniTime
// weather wire protocol.
func (f Forecast) Bytes() []byte {
buf := &bytes.Buffer{}
buf.WriteByte(forecastWeatherType)
buf.WriteByte(weatherVersion)
_, offset := f.Time.Zone()
binary.Write(buf, binary.LittleEndian, f.Time.Unix()+int64(offset))
buf.WriteByte(uint8(len(f.Days)))
for _, day := range f.Days {
binary.Write(buf, binary.LittleEndian, day.MinTemp*100)
binary.Write(buf, binary.LittleEndian, day.MaxTemp*100)
buf.WriteByte(byte(day.Icon))
}
return buf.Bytes()
}
// SetCurrentWeather updates the current weather data on the PineTime
func (d *Device) SetCurrentWeather(cw CurrentWeather) error {
c, err := d.getChar(weatherDataChar)
if err != nil {
return err
}
_, err = c.WriteWithoutResponse(cw.Bytes())
return err
}
// SetForecast sets future forecast data on the PineTime
func (d *Device) SetForecast(f Forecast) error {
c, err := d.getChar(weatherDataChar)
if err != nil {
return err
}
if len(f.Days) > 5 {
return errors.New("amount of forecast days exceeds maximum of 5")
}
_, err = c.WriteWithoutResponse(f.Bytes())
return err
}

View File

@ -0,0 +1,62 @@
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")
ErrFileClosed = errors.New("cannot perform operation on a closed file")
)
// 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)
}
}

212
internal/fsproto/fsproto.go Normal file
View File

@ -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
}

600
internal/fusefs/fuse.go Normal file
View File

@ -0,0 +1,600 @@
package fusefs
import (
"bytes"
"context"
"io"
"strconv"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"go.elara.ws/itd/infinitime"
"go.elara.ws/logger/log"
)
type ITProperty struct {
name string
Ino uint64
gen func() ([]byte, error)
}
type DirEntry struct {
isDir bool
modtime uint64
size uint32
path string
}
type ITNode struct {
fs.Inode
kind nodeKind
Ino uint64
lst []DirEntry
self DirEntry
path string
}
type nodeKind uint8
const (
nodeKindRoot = iota
nodeKindInfo
nodeKindFS
nodeKindReadOnly
)
var (
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 = dev.FS()
if err != nil {
log.Error("FUSE Failed to get filesystem").Err(err).Send()
return nil, err
}
return &ITNode{kind: nodeKindRoot}, nil
}
var properties = make([]ITProperty, 6)
func BuildProperties(dev *infinitime.Device) {
properties[0] = ITProperty{
"heartrate", 2,
func() ([]byte, error) {
ans, err := dev.HeartRate()
return []byte(strconv.Itoa(int(ans)) + "\n"), err
},
}
properties[1] = ITProperty{
"battery", 3,
func() ([]byte, error) {
ans, err := dev.BatteryLevel()
return []byte(strconv.Itoa(int(ans)) + "\n"), err
},
}
properties[2] = ITProperty{
"motion", 4,
func() ([]byte, error) {
ans, err := dev.Motion()
return []byte(strconv.Itoa(int(ans.X)) + " " + strconv.Itoa(int(ans.Y)) + " " + strconv.Itoa(int(ans.Z)) + "\n"), err
},
}
properties[3] = ITProperty{
"stepcount", 6,
func() ([]byte, error) {
ans, err := dev.StepCount()
return []byte(strconv.Itoa(int(ans)) + "\n"), err
},
}
properties[4] = ITProperty{
"version", 7,
func() ([]byte, error) {
ans, err := dev.Version()
return []byte(ans + "\n"), err
},
}
properties[5] = ITProperty{
"address", 8,
func() ([]byte, error) {
ans := dev.Address()
return []byte(ans + "\n"), nil
},
}
}
var _ fs.NodeReaddirer = (*ITNode)(nil)
// Readdir is part of the NodeReaddirer interface
func (n *ITNode) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
switch n.kind {
case 0:
// root folder
r := make([]fuse.DirEntry, 2)
r[0] = fuse.DirEntry{
Name: "info",
Ino: 0,
Mode: fuse.S_IFDIR,
}
r[1] = fuse.DirEntry{
Name: "fs",
Ino: 1,
Mode: fuse.S_IFDIR,
}
return fs.NewListDirStream(r), 0
case 1:
// info folder
r := make([]fuse.DirEntry, 6)
for ind, value := range properties {
r[ind] = fuse.DirEntry{
Name: value.name,
Ino: value.Ino,
Mode: fuse.S_IFREG,
}
}
return fs.NewListDirStream(r), 0
case 2:
// on info
files, err := myfs.ReadDir(n.path)
if err != nil {
log.Error("FUSE ReadDir failed").Str("path", n.path).Err(err).Send()
return nil, syscallErr(err)
}
log.Debug("FUSE ReadDir succeeded").Str("path", n.path).Int("objects", len(files)).Send()
r := make([]fuse.DirEntry, len(files))
n.lst = make([]DirEntry, len(files))
for ind, entry := range files {
info, err := entry.Info()
if err != nil {
log.Error("FUSE Info failed").Str("path", n.path).Err(err).Send()
return nil, syscallErr(err)
}
name := info.Name()
file := DirEntry{
path: n.path + "/" + name,
size: uint32(info.Size()),
modtime: uint64(info.ModTime().Unix()),
isDir: info.IsDir(),
}
n.lst[ind] = file
ino := inodemap[file.path]
if ino == 0 {
ino = uint64(len(inodemap)) + 1
inodemap[file.path] = ino
}
if file.isDir {
r[ind] = fuse.DirEntry{
Name: name,
Mode: fuse.S_IFDIR,
Ino: ino + 10,
}
} else {
r[ind] = fuse.DirEntry{
Name: name,
Mode: fuse.S_IFREG,
Ino: ino + 10,
}
}
}
return fs.NewListDirStream(r), 0
}
r := make([]fuse.DirEntry, 0)
return fs.NewListDirStream(r), 0
}
var _ fs.NodeLookuper = (*ITNode)(nil)
func (n *ITNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
switch n.kind {
case 0:
// root folder
if name == "info" {
stable := fs.StableAttr{
Mode: fuse.S_IFDIR,
Ino: uint64(0),
}
operations := &ITNode{kind: nodeKindInfo, Ino: 0}
child := n.NewInode(ctx, operations, stable)
return child, 0
} else if name == "fs" {
stable := fs.StableAttr{
Mode: fuse.S_IFDIR,
Ino: uint64(1),
}
operations := &ITNode{kind: nodeKindFS, Ino: 1, path: ""}
child := n.NewInode(ctx, operations, stable)
return child, 0
}
case 1:
// info folder
for _, value := range properties {
if value.name == name {
stable := fs.StableAttr{
Mode: fuse.S_IFREG,
Ino: uint64(value.Ino),
}
operations := &ITNode{kind: nodeKindReadOnly, Ino: value.Ino}
child := n.NewInode(ctx, operations, stable)
return child, 0
}
}
case 2:
// FS object
if len(n.lst) == 0 {
n.Readdir(ctx)
}
for _, file := range n.lst {
if file.path != n.path+"/"+name {
continue
}
log.Debug("FUSE Lookup successful").Str("path", file.path).Send()
if file.isDir {
stable := fs.StableAttr{
Mode: fuse.S_IFDIR,
Ino: inodemap[file.path],
}
operations := &ITNode{kind: nodeKindFS, path: file.path}
child := n.NewInode(ctx, operations, stable)
return child, 0
} else {
stable := fs.StableAttr{
Mode: fuse.S_IFREG,
Ino: inodemap[file.path],
}
operations := &ITNode{
kind: nodeKindFS, path: file.path,
self: file,
}
child := n.NewInode(ctx, operations, stable)
return child, 0
}
}
log.Warn("FUSE Lookup failed").Str("path", n.path+"/"+name).Send()
}
return nil, syscall.ENOENT
}
type bytesFileReadHandle struct {
content []byte
}
var _ fs.FileReader = (*bytesFileReadHandle)(nil)
func (fh *bytesFileReadHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
log.Debug("FUSE Executing Read").Int("size", len(fh.content)).Send()
end := off + int64(len(dest))
if end > int64(len(fh.content)) {
end = int64(len(fh.content))
}
return fuse.ReadResultData(fh.content[off:end]), 0
}
type sensorFileReadHandle struct {
content []byte
}
var _ fs.FileReader = (*sensorFileReadHandle)(nil)
func (fh *sensorFileReadHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
log.Debug("FUSE Executing Read").Int("size", len(fh.content)).Send()
end := off + int64(len(dest))
if end > int64(len(fh.content)) {
end = int64(len(fh.content))
}
return fuse.ReadResultData(fh.content[off:end]), 0
}
var _ fs.FileFlusher = (*sensorFileReadHandle)(nil)
func (fh *sensorFileReadHandle) Flush(ctx context.Context) (errno syscall.Errno) {
return 0
}
type bytesFileWriteHandle struct {
content []byte
path string
}
var _ fs.FileWriter = (*bytesFileWriteHandle)(nil)
func (fh *bytesFileWriteHandle) Write(ctx context.Context, data []byte, off int64) (written uint32, errno syscall.Errno) {
log.Debug("FUSE Executing Write").Str("path", fh.path).Int("prev_size", len(fh.content)).Int("next_size", len(data)).Send()
if off != int64(len(fh.content)) {
log.Error("FUSE Write file size changed unexpectedly").Int("expect", int(off)).Int("received", len(fh.content)).Send()
return 0, syscall.ENXIO
}
fh.content = append(fh.content[:], data[:]...)
return uint32(len(data)), 0
}
var _ fs.FileFlusher = (*bytesFileWriteHandle)(nil)
func (fh *bytesFileWriteHandle) Flush(ctx context.Context) (errno syscall.Errno) {
log.Debug("FUSE Attempting flush").Str("path", fh.path).Send()
fp, err := myfs.Create(fh.path, uint32(len(fh.content)))
if err != nil {
log.Error("FUSE Flush failed: create").Str("path", fh.path).Err(err).Send()
return syscallErr(err)
}
if len(fh.content) == 0 {
log.Debug("FUSE Flush no data to write").Str("path", fh.path).Send()
err = fp.Close()
if err != nil {
log.Error("FUSE Flush failed during close").Str("path", fh.path).Err(err).Send()
return syscallErr(err)
}
return 0
}
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)
if err != nil {
log.Error("FUSE Flush failed during write").Str("path", fh.path).Err(err).Send()
fp.Close()
return syscallErr(err)
}
if int(nread) != len(fh.content) {
log.Error("FUSE Flush failed during write").Str("path", fh.path).Int("expect", len(fh.content)).Int("got", int(nread)).Send()
fp.Close()
return syscall.EIO
}
err = fp.Close()
if err != nil {
log.Error("FUSE Flush failed during close").Str("path", fh.path).Err(err).Send()
return syscallErr(err)
}
log.Debug("FUSE Flush done").Str("path", fh.path).Int("size", len(fh.content)).Send()
return 0
}
var _ fs.FileFsyncer = (*bytesFileWriteHandle)(nil)
func (fh *bytesFileWriteHandle) Fsync(ctx context.Context, flags uint32) (errno syscall.Errno) {
return fh.Flush(ctx)
}
var _ fs.NodeGetattrer = (*ITNode)(nil)
func (bn *ITNode) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
log.Debug("FUSE getattr").Str("path", bn.path).Send()
out.Ino = bn.Ino
out.Mtime = bn.self.modtime
out.Ctime = bn.self.modtime
out.Atime = bn.self.modtime
out.Size = uint64(bn.self.size)
return 0
}
var _ fs.NodeSetattrer = (*ITNode)(nil)
func (bn *ITNode) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
log.Debug("FUSE setattr").Str("path", bn.path).Send()
out.Size = 0
out.Mtime = 0
return 0
}
var _ fs.NodeOpener = (*ITNode)(nil)
func (f *ITNode) Open(ctx context.Context, openFlags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
switch f.kind {
case 2:
// FS file
if openFlags&syscall.O_RDWR != 0 {
log.Error("FUSE Open failed: RDWR").Str("path", f.path).Send()
return nil, 0, syscall.EROFS
}
if openFlags&syscall.O_WRONLY != 0 {
log.Debug("FUSE Opening for write").Str("path", f.path).Send()
fh = &bytesFileWriteHandle{
path: f.path,
content: make([]byte, 0),
}
return fh, fuse.FOPEN_DIRECT_IO, 0
} else {
log.Debug("FUSE Opening for read").Str("path", f.path).Send()
fp, err := myfs.Open(f.path)
if err != nil {
log.Error("FUSE: Opening failed").Str("path", f.path).Err(err).Send()
return nil, 0, syscallErr(err)
}
defer fp.Close()
b := &bytes.Buffer{}
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 {
log.Error("FUSE Read failed").Str("path", f.path).Err(err).Send()
fp.Close()
return nil, 0, syscallErr(err)
}
fh = &bytesFileReadHandle{
content: b.Bytes(),
}
return fh, fuse.FOPEN_DIRECT_IO, 0
}
case 3:
// Device file
// disallow writes
if openFlags&(syscall.O_RDWR|syscall.O_WRONLY) != 0 {
return nil, 0, syscall.EROFS
}
for _, value := range properties {
if value.Ino == f.Ino {
ans, err := value.gen()
if err != nil {
return nil, 0, syscallErr(err)
}
fh = &sensorFileReadHandle{
content: ans,
}
return fh, fuse.FOPEN_DIRECT_IO, 0
}
}
}
return nil, 0, syscall.EINVAL
}
var _ fs.NodeCreater = (*ITNode)(nil)
func (f *ITNode) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if f.kind != 2 {
return nil, nil, 0, syscall.EROFS
}
path := f.path + "/" + name
ino := uint64(len(inodemap)) + 11
inodemap[path] = ino
stable := fs.StableAttr{
Mode: fuse.S_IFREG,
Ino: ino,
}
operations := &ITNode{
kind: nodeKindFS, Ino: ino,
path: path,
}
node = f.NewInode(ctx, operations, stable)
fh = &bytesFileWriteHandle{
path: path,
content: make([]byte, 0),
}
log.Debug("FUSE Creating file").Str("path", path).Send()
errno = 0
return node, fh, fuseFlags, 0
}
var _ fs.NodeMkdirer = (*ITNode)(nil)
func (f *ITNode) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
if f.kind != 2 {
return nil, syscall.EROFS
}
path := f.path + "/" + name
err := myfs.Mkdir(path)
if err != nil {
log.Error("FUSE Mkdir failed").
Str("path", path).
Err(err).
Send()
return nil, syscallErr(err)
}
ino := uint64(len(inodemap)) + 11
inodemap[path] = ino
stable := fs.StableAttr{
Mode: fuse.S_IFDIR,
Ino: ino,
}
operations := &ITNode{
kind: nodeKindFS, Ino: ino,
path: path,
}
node := f.NewInode(ctx, operations, stable)
log.Debug("FUSE Mkdir success").
Str("path", path).
Int("ino", int(ino)).
Send()
return node, 0
}
var _ fs.NodeRenamer = (*ITNode)(nil)
func (f *ITNode) Rename(ctx context.Context, name string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno {
if f.kind != 2 {
return syscall.EROFS
}
p1 := f.path + "/" + name
p2 := newParent.EmbeddedInode().Path(nil)[2:] + "/" + newName
err := myfs.Rename(p1, p2)
if err != nil {
log.Error("FUSE Rename failed").
Str("src", p1).
Str("dest", p2).
Err(err).
Send()
return syscallErr(err)
}
log.Debug("FUSE Rename sucess").
Str("src", p1).
Str("dest", p2).
Send()
ino := inodemap[p1]
delete(inodemap, p1)
inodemap[p2] = ino
return 0
}
var _ fs.NodeUnlinker = (*ITNode)(nil)
func (f *ITNode) Unlink(ctx context.Context, name string) syscall.Errno {
if f.kind != 2 {
return syscall.EROFS
}
delete(inodemap, f.path+"/"+name)
err := myfs.Remove(f.path + "/" + name)
if err != nil {
log.Error("FUSE Unlink failed").
Str("file", f.path+"/"+name).
Err(err).
Send()
return syscallErr(err)
}
log.Debug("FUSE Unlink success").
Str("file", f.path+"/"+name).
Send()
return 0
}
var _ fs.NodeRmdirer = (*ITNode)(nil)
func (f *ITNode) Rmdir(ctx context.Context, name string) syscall.Errno {
return f.Unlink(ctx, name)
}

View File

@ -0,0 +1,72 @@
package fusefs
import (
"syscall"
"go.elara.ws/itd/internal/fsproto"
)
func syscallErr(err error) syscall.Errno {
if err == nil {
return 0
}
switch err := err.(type) {
case fsproto.Error:
switch err.Code {
case 0x02: // filesystem error
return syscall.EIO
case 0x05: // read-only filesystem
return syscall.EROFS
case 0x03: // no such file
return syscall.ENOENT
case 0x04: // protocol error
return syscall.EPROTO
case -5: // input/output error
return syscall.EIO
case -84: // filesystem is corrupted
return syscall.ENOTRECOVERABLE
case -2: // no such directory entry
return syscall.ENOENT
case -17: // entry already exists
return syscall.EEXIST
case -20: // entry is not a directory
return syscall.ENOTDIR
case -39: // directory is not empty
return syscall.ENOTEMPTY
case -9: // bad file number
return syscall.EBADF
case -27: // file is too large
return syscall.EFBIG
case -22: // invalid parameter
return syscall.EINVAL
case -28: // no space left on device
return syscall.ENOSPC
case -12: // no more memory available
return syscall.ENOMEM
case -61: // no attr available
return syscall.ENODATA
case -36: // file name is too long
return syscall.ENAMETOOLONG
}
default:
switch err {
case fsproto.ErrFileNotExists: // file does not exist
return syscall.ENOENT
case fsproto.ErrFileReadOnly: // file is read only
return syscall.EACCES
case fsproto.ErrFileWriteOnly: // file is write only
return syscall.EACCES
case fsproto.ErrInvalidOffset: // invalid file offset
return syscall.EINVAL
case fsproto.ErrNoRemoveRoot: // refusing to remove root directory
return syscall.EPERM
case fsproto.ErrFileClosed: // cannot perform operation on closed file
return syscall.EBADF
default:
return syscall.EINVAL
}
}
return syscall.EIO
}

View File

@ -0,0 +1,17 @@
package fusefs
import (
_ "unsafe"
"github.com/hanwen/go-fuse/v2/fuse"
)
func Unmount(mountPoint string) error {
return unmount(mountPoint, &fuse.MountOptions{DirectMount: false})
}
// Unfortunately, the FUSE library does not export its unmount function,
// so this is required until that changes
//
//go:linkname unmount github.com/hanwen/go-fuse/v2/fuse.unmount
func unmount(mountPoint string, opts *fuse.MountOptions) error

3
internal/rpc/gen.go Normal file
View File

@ -0,0 +1,3 @@
package rpc
//go:generate protoc --go_out=. --go_opt=paths=source_relative --go-drpc_out=. --go-drpc_opt=paths=source_relative itd.proto

1424
internal/rpc/itd.pb.go Normal file

File diff suppressed because it is too large Load Diff

124
internal/rpc/itd.proto Normal file
View File

@ -0,0 +1,124 @@
syntax = "proto3";
package rpc;
option go_package = "go.arsenm.dev/itd/internal/rpc";
message Empty {};
message IntResponse {
uint32 value = 1;
}
message StringResponse {
string value = 1;
}
message MotionResponse {
int32 x = 1;
int32 y = 2;
int32 z = 3;
}
message NotifyRequest {
string title = 1;
string body = 2;
}
message SetTimeRequest {
int64 unix_nano = 1;
}
message FirmwareUpgradeRequest {
enum Type {
Archive = 0;
Files = 1;
}
Type type = 1;
repeated string files = 2;
}
message DFUProgress {
int64 sent = 1;
int64 recieved = 2;
int64 total = 3;
}
service ITD {
rpc HeartRate(Empty) returns (IntResponse);
rpc WatchHeartRate(Empty) returns (stream IntResponse);
rpc BatteryLevel(Empty) returns (IntResponse);
rpc WatchBatteryLevel(Empty) returns (stream IntResponse);
rpc Motion(Empty) returns (MotionResponse);
rpc WatchMotion(Empty) returns (stream MotionResponse);
rpc StepCount(Empty) returns (IntResponse);
rpc WatchStepCount(Empty) returns (stream IntResponse);
rpc Version(Empty) returns (StringResponse);
rpc Address(Empty) returns (StringResponse);
rpc Notify(NotifyRequest) returns (Empty);
rpc SetTime(SetTimeRequest) returns (Empty);
rpc WeatherUpdate(Empty) returns (Empty);
rpc FirmwareUpgrade(FirmwareUpgradeRequest) returns (stream DFUProgress);
}
message PathRequest {
string path = 1;
}
message PathsRequest {
repeated string paths = 1;
}
message RenameRequest {
string from = 1;
string to = 2;
}
message TransferRequest {
string source = 1;
string destination = 2;
}
message FileInfo {
string name = 1;
int64 size = 2;
bool is_dir = 3;
}
message DirResponse {
repeated FileInfo entries = 1;
}
message TransferProgress {
uint32 sent = 1;
uint32 total = 2;
}
message ResourceLoadProgress {
enum Operation {
Upload = 0;
RemoveObsolete = 1;
}
string name = 1;
int64 total = 2;
int64 sent = 3;
Operation operation = 4;
}
service FS {
rpc RemoveAll(PathsRequest) returns (Empty);
rpc Remove(PathsRequest) returns (Empty);
rpc Rename(RenameRequest) returns (Empty);
rpc MkdirAll(PathsRequest) returns (Empty);
rpc Mkdir(PathsRequest) returns (Empty);
rpc ReadDir(PathRequest) returns (DirResponse);
rpc Upload(TransferRequest) returns (stream TransferProgress);
rpc Download(TransferRequest) returns (stream TransferProgress);
rpc LoadResources(PathRequest) returns (stream ResourceLoadProgress);
}

1218
internal/rpc/itd_drpc.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
package types
type ReqDataFwUpgrade struct {
Type int
Files []string
}
type Response struct {
Value interface{} `json:"value,omitempty"`
Message string `json:"msg,omitempty"`
Error bool `json:"error"`
}
type Request struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
}
type ReqDataNotify struct {
Title string
Body string
}

41
internal/utils/dbus.go Normal file
View File

@ -0,0 +1,41 @@
package utils
import (
"context"
"github.com/godbus/dbus/v5"
)
func NewSystemBusConn(ctx context.Context) (*dbus.Conn, error) {
// Connect to dbus session bus
conn, err := dbus.SystemBusPrivate(dbus.WithContext(ctx))
if err != nil {
return nil, err
}
err = conn.Auth(nil)
if err != nil {
return nil, err
}
err = conn.Hello()
if err != nil {
return nil, err
}
return conn, nil
}
func NewSessionBusConn(ctx context.Context) (*dbus.Conn, error) {
// Connect to dbus session bus
conn, err := dbus.SessionBusPrivate(dbus.WithContext(ctx))
if err != nil {
return nil, err
}
err = conn.Auth(nil)
if err != nil {
return nil, err
}
err = conn.Hello()
if err != nil {
return nil, err
}
return conn, nil
}

View File

@ -1,14 +1,53 @@
[bluetooth]
adapter = "hci0"
[socket]
path = "/tmp/itd/socket"
[metrics]
enabled = false
[metrics.heartRate]
enabled = true
[metrics.stepCount]
enabled = true
[metrics.battLevel]
enabled = true
[metrics.motion]
# This may lower the battery life of the PineTime
enabled = false
[conn]
reconnect = true
[notify]
onConnect = true
onReconnect = true
[conn.whitelist]
enabled = false
devices = []
[notifications.ignore]
[on.connect]
notify = true
[on.reconnect]
notify = true
setTime = true
[notifs.translit]
use = ["eASCII", "Russian", "Emoji"]
[notifs.ignore]
sender = []
summary = ["InfiniTime"]
body = []
[music]
volInterval = 5
vol.interval = 5
[weather]
enabled = true
location = "Los Angeles, CA"
[logging]
level = "info"

5
itgui.desktop Normal file
View File

@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Terminal=false
Exec=/usr/bin/itgui
Name=itgui

229
main.go
View File

@ -19,108 +19,209 @@
package main
import (
"context"
_ "embed"
"flag"
"fmt"
"os"
"strings"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"go.arsenm.dev/infinitime"
"github.com/gen2brain/dlgs"
"github.com/knadh/koanf"
"github.com/mattn/go-isatty"
"go.elara.ws/itd/infinitime"
"go.elara.ws/logger"
"go.elara.ws/logger/log"
)
var firmwareUpdating = false
var k = koanf.New(".")
func init() {
// Set up logger
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
// Set config settings
viper.AddConfigPath("$HOME/.config")
viper.AddConfigPath("/etc")
viper.SetConfigName("itd")
viper.SetConfigType("toml")
viper.WatchConfig()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.SetEnvPrefix("itd")
if err := viper.ReadInConfig(); err != nil {
log.Warn().Err(err).Msg("Could not read in config")
}
viper.AutomaticEnv()
}
var (
firmwareUpdating = false
// The FS must be updated when the watch is reconnected
updateFS = false
)
func main() {
// Cleanly exit after function
defer infinitime.Exit()
// Connect to InfiniTime with default options
dev, err := infinitime.Connect(&infinitime.Options{
AttemptReconnect: viper.GetBool("conn.reconnect"),
})
if err != nil {
log.Error().Err(err).Msg("Error connecting to InfiniTime")
showVer := flag.Bool("version", false, "Show version number and exit")
flag.Parse()
// If version requested, print and exit
if *showVer {
fmt.Println(version)
return
}
// When InfiniTime reconnects
dev.OnReconnect(func() {
// Set time to current time
err = dev.SetTime(time.Now())
if err != nil {
log.Error().Err(err).Msg("Error setting current time on connected InfiniTime")
}
level, err := logger.ParseLogLevel(k.String("logging.level"))
if err != nil {
level = logger.LogLevelInfo
}
log.Logger.SetLevel(level)
// If config specifies to notify on reconnect
if viper.GetBool("notify.onReconnect") {
// Send notification to InfiniTime
err = dev.Notify("itd", "Successfully reconnected")
if err != nil {
log.Error().Err(err).Msg("Error sending notification to InfiniTime")
// Create infinitime options struct
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(opts)
if err != nil {
log.Fatal("Error connecting to InfiniTime").Err(err).Send()
}
// Get firmware version
ver, err := dev.Version()
if err != nil {
log.Error().Err(err).Msg("Error getting firmware version")
log.Error("Error getting firmware version").Err(err).Send()
}
// Log connection
log.Info().Str("version", ver).Msg("Connected to InfiniTime")
log.Info("Connected to InfiniTime").Str("version", ver).Send()
// If config specifies to notify on connect
if viper.GetBool("notify.onConnect") {
if k.Bool("on.connect.notify") {
// Send notification to InfiniTime
err = dev.Notify("itd", "Successfully connected")
if err != nil {
log.Error().Err(err).Msg("Error sending notification to InfiniTime")
log.Error("Error sending notification to InfiniTime").Err(err).Send()
}
}
// Set time to current time
err = dev.SetTime(time.Now())
if err != nil {
log.Error().Err(err).Msg("Error setting current time on connected InfiniTime")
log.Error("Error setting current time on connected InfiniTime").Err(err).Send()
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
sig := <-sigCh
log.Warn("Signal received, shutting down").Stringer("signal", sig).Send()
cancel()
}()
wg := WaitGroup{&sync.WaitGroup{}}
// Initialize music controls
err = initMusicCtrl(dev)
err = initMusicCtrl(ctx, wg, dev)
if err != nil {
log.Error().Err(err).Msg("Error initializing music control")
}
// Initialize notification relay
err = initNotifRelay(dev)
if err != nil {
log.Error().Err(err).Msg("Error initializing notification relay")
log.Error("Error initializing music control").Err(err).Send()
}
// Start control socket
err = startSocket(dev)
err = initCallNotifs(ctx, wg, dev)
if err != nil {
log.Error().Err(err).Msg("Error starting socket")
log.Error("Error initializing call notifications").Err(err).Send()
}
// Block forever
select {}
// Initialize notification relay
err = initNotifRelay(ctx, wg, dev)
if err != nil {
log.Error("Error initializing notification relay").Err(err).Send()
}
// Initializa weather
err = initWeather(ctx, wg, dev)
if err != nil {
log.Error("Error initializing weather").Err(err).Send()
}
// Initialize metrics collection
err = initMetrics(ctx, wg, dev)
if err != nil {
log.Error("Error intializing metrics collection").Err(err).Send()
}
// Initialize puremaps integration
err = initPureMaps(ctx, wg, dev)
if err != nil {
log.Error("Error intializing puremaps integration").Err(err).Send()
}
// Start fuse socket
if k.Bool("fuse.enabled") {
err = startFUSE(ctx, wg, dev)
if err != nil {
log.Error("Error starting fuse socket").Err(err).Send()
}
}
// Start control socket
err = startSocket(ctx, wg, dev)
if err != nil {
log.Error("Error starting socket").Err(err).Send()
}
wg.Wait()
}
type x struct {
n int
*sync.WaitGroup
}
func (xy *x) Add(i int) {
xy.n += i
xy.WaitGroup.Add(i)
fmt.Println("add: counter:", xy.n)
}
func (xy *x) Done() {
xy.n -= 1
xy.WaitGroup.Done()
fmt.Println("done: counter:", xy.n)
}
func onReqPasskey() (uint32, error) {
var out uint32
if isatty.IsTerminal(os.Stdin.Fd()) {
fmt.Print("Passkey: ")
_, err := fmt.Scanln(&out)
if err != nil {
return 0, err
}
} else {
passkey, ok, err := dlgs.Entry("Pairing", "Enter the passkey displayed on your watch.", "")
if err != nil {
return 0, err
}
if !ok {
return 0, nil
}
passkeyInt, err := strconv.Atoi(passkey)
return uint32(passkeyInt), err
}
return out, nil
}

214
maps.go Normal file
View File

@ -0,0 +1,214 @@
package main
import (
"context"
"strings"
"github.com/godbus/dbus/v5"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/utils"
"go.elara.ws/logger/log"
)
const (
interfaceName = "io.github.rinigus.PureMaps.navigator"
iconProperty = interfaceName + ".icon"
narrativeProperty = interfaceName + ".narrative"
manDistProperty = interfaceName + ".manDist"
progressProperty = interfaceName + ".progress"
)
func initPureMaps(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// Connect to session bus. This connection is for method calls.
conn, err := utils.NewSessionBusConn(ctx)
if err != nil {
return err
}
exists, err := pureMapsExists(ctx, conn)
if err != nil {
return err
}
// Connect to session bus. This connection is for method calls.
monitorConn, err := utils.NewSessionBusConn(ctx)
if err != nil {
return err
}
// Define rules to listen for
rules := []string{
"type='signal',interface='io.github.rinigus.PureMaps.navigator'",
}
var flag uint = 0
// Becode monitor for notifications
call := monitorConn.BusObject().CallWithContext(
ctx, "org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, rules, flag,
)
if call.Err != nil {
return call.Err
}
var navigator dbus.BusObject
if exists {
navigator = conn.Object("io.github.rinigus.PureMaps", "/io/github/rinigus/PureMaps/navigator")
err = setAll(navigator, dev)
if err != nil {
log.Error("Error setting all navigation fields").Err(err).Send()
}
}
wg.Add(1)
go func() {
defer wg.Done("pureMaps")
signalCh := make(chan *dbus.Message, 10)
monitorConn.Eavesdrop(signalCh)
for {
select {
case sig := <-signalCh:
if sig.Type != dbus.TypeSignal {
continue
}
var member string
err = sig.Headers[dbus.FieldMember].Store(&member)
if err != nil {
log.Error("Error getting dbus member field").Err(err).Send()
continue
}
if !strings.HasSuffix(member, "Changed") {
continue
}
log.Debug("Signal received from PureMaps navigator").Str("member", member).Send()
// The object must be retrieved in this loop in case PureMaps was not
// open at the time ITD was started.
navigator = conn.Object("io.github.rinigus.PureMaps", "/io/github/rinigus/PureMaps/navigator")
member = strings.TrimSuffix(member, "Changed")
switch member {
case "icon":
var icon string
err = navigator.StoreProperty(iconProperty, &icon)
if err != nil {
log.Error("Error getting property").Err(err).Str("property", member).Send()
continue
}
err = dev.SetNavFlag(infinitime.NavFlag(icon))
if err != nil {
log.Error("Error setting flag").Err(err).Str("property", member).Send()
continue
}
case "narrative":
var narrative string
err = navigator.StoreProperty(narrativeProperty, &narrative)
if err != nil {
log.Error("Error getting property").Err(err).Str("property", member).Send()
continue
}
err = dev.SetNavNarrative(narrative)
if err != nil {
log.Error("Error setting flag").Err(err).Str("property", member).Send()
continue
}
case "manDist":
var manDist string
err = navigator.StoreProperty(manDistProperty, &manDist)
if err != nil {
log.Error("Error getting property").Err(err).Str("property", member).Send()
continue
}
err = dev.SetNavManeuverDistance(manDist)
if err != nil {
log.Error("Error setting flag").Err(err).Str("property", member).Send()
continue
}
case "progress":
var progress int32
err = navigator.StoreProperty(progressProperty, &progress)
if err != nil {
log.Error("Error getting property").Err(err).Str("property", member).Send()
continue
}
err = dev.SetNavProgress(uint8(progress))
if err != nil {
log.Error("Error setting flag").Err(err).Str("property", member).Send()
continue
}
}
case <-ctx.Done():
return
}
}
}()
if exists {
log.Info("Sending PureMaps data to InfiniTime").Send()
}
return nil
}
func setAll(navigator dbus.BusObject, dev *infinitime.Device) error {
var icon string
err := navigator.StoreProperty(iconProperty, &icon)
if err != nil {
return err
}
err = dev.SetNavFlag(infinitime.NavFlag(icon))
if err != nil {
return err
}
var narrative string
err = navigator.StoreProperty(narrativeProperty, &narrative)
if err != nil {
return err
}
err = dev.SetNavNarrative(narrative)
if err != nil {
return err
}
var manDist string
err = navigator.StoreProperty(manDistProperty, &manDist)
if err != nil {
return err
}
err = dev.SetNavManeuverDistance(manDist)
if err != nil {
return err
}
var progress int32
err = navigator.StoreProperty(progressProperty, &progress)
if err != nil {
return err
}
return dev.SetNavProgress(uint8(progress))
}
// pureMapsExists checks to make sure the PureMaps service exists on the bus
func pureMapsExists(ctx context.Context, conn *dbus.Conn) (bool, error) {
var names []string
err := conn.BusObject().CallWithContext(
ctx, "org.freedesktop.DBus.ListNames", 0,
).Store(&names)
if err != nil {
return false, err
}
return strSlcContains(names, "io.github.rinigus.PureMaps"), nil
}

151
metrics.go Normal file
View File

@ -0,0 +1,151 @@
package main
import (
"context"
"database/sql"
"path/filepath"
"time"
"go.elara.ws/itd/infinitime"
"go.elara.ws/logger/log"
_ "modernc.org/sqlite"
)
func initMetrics(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// If metrics disabled, return nil
if !k.Bool("metrics.enabled") {
return nil
}
// Open metrics database
db, err := sql.Open("sqlite", filepath.Join(cfgDir, "metrics.db"))
if err != nil {
return err
}
// Create heartRate table
_, err = db.Exec("CREATE TABLE IF NOT EXISTS heartRate(time INT, bpm INT);")
if err != nil {
return err
}
// Create stepCount table
_, err = db.Exec("CREATE TABLE IF NOT EXISTS stepCount(time INT, steps INT);")
if err != nil {
return err
}
// Create battLevel table
_, err = db.Exec("CREATE TABLE IF NOT EXISTS battLevel(time INT, percent INT);")
if err != nil {
return err
}
// Create motion table
_, err = db.Exec("CREATE TABLE IF NOT EXISTS motion(time INT, X INT, Y INT, Z INT);")
if err != nil {
return err
}
// Watch heart rate
if k.Bool("metrics.heartRate.enabled") {
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
}
}
// If step count metrics enabled in config
if k.Bool("metrics.stepCount.enabled") {
// Watch step count
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
}
}
// 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") {
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
}
}
// Watch motion values
if k.Bool("metrics.motion.enabled") {
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
}
}
wg.Add(1)
go func() {
defer wg.Done("metrics")
<-ctx.Done()
db.Close()
}()
log.Info("Initialized metrics collection").Send()
return nil
}

270
mpris/mpris.go Normal file
View File

@ -0,0 +1,270 @@
package mpris
import (
"context"
"strings"
"sync"
"github.com/godbus/dbus/v5"
"go.elara.ws/itd/internal/utils"
)
var (
method, monitor *dbus.Conn
monitorCh chan *dbus.Message
onChangeOnce sync.Once
)
// Init makes required connections to DBus and
// initializes change monitoring channel
func Init(ctx context.Context) error {
// Connect to session bus for monitoring
monitorConn, err := utils.NewSessionBusConn(ctx)
if err != nil {
return err
}
// Add match rule for PropertiesChanged on media player
monitorConn.AddMatchSignal(
dbus.WithMatchObjectPath("/org/mpris/MediaPlayer2"),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchMember("PropertiesChanged"),
)
monitorCh = make(chan *dbus.Message, 10)
monitorConn.Eavesdrop(monitorCh)
// Connect to session bus for method calls
methodConn, err := utils.NewSessionBusConn(ctx)
if err != nil {
return err
}
method, monitor = methodConn, monitorConn
return nil
}
// Exit closes all connections and channels
func Exit() {
close(monitorCh)
method.Close()
monitor.Close()
}
// Play uses MPRIS to play media
func Play() error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
call := player.Call("org.mpris.MediaPlayer2.Player.Play", 0)
if call.Err != nil {
return call.Err
}
}
return nil
}
// Pause uses MPRIS to pause media
func Pause() error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
call := player.Call("org.mpris.MediaPlayer2.Player.Pause", 0)
if call.Err != nil {
return call.Err
}
}
return nil
}
// Next uses MPRIS to skip to next media
func Next() error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
call := player.Call("org.mpris.MediaPlayer2.Player.Next", 0)
if call.Err != nil {
return call.Err
}
}
return nil
}
// Prev uses MPRIS to skip to previous media
func Prev() error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
call := player.Call("org.mpris.MediaPlayer2.Player.Previous", 0)
if call.Err != nil {
return call.Err
}
}
return nil
}
func VolUp(percent uint) error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
currentVal, err := player.GetProperty("org.mpris.MediaPlayer2.Player.Volume")
if err != nil {
return err
}
newVal := currentVal.Value().(float64) + (float64(percent) / 100)
err = player.SetProperty("org.mpris.MediaPlayer2.Player.Volume", newVal)
if err != nil {
return err
}
}
return nil
}
func VolDown(percent uint) error {
player, err := getPlayerObj()
if err != nil {
return err
}
if player != nil {
currentVal, err := player.GetProperty("org.mpris.MediaPlayer2.Player.Volume")
if err != nil {
return err
}
newVal := currentVal.Value().(float64) - (float64(percent) / 100)
err = player.SetProperty("org.mpris.MediaPlayer2.Player.Volume", newVal)
if err != nil {
return err
}
}
return nil
}
type ChangeType int
const (
ChangeTypeTitle ChangeType = iota
ChangeTypeArtist
ChangeTypeAlbum
ChangeTypeStatus
)
func (ct ChangeType) String() string {
switch ct {
case ChangeTypeTitle:
return "Title"
case ChangeTypeAlbum:
return "Album"
case ChangeTypeArtist:
return "Artist"
case ChangeTypeStatus:
return "Status"
}
return ""
}
// OnChange runs cb when a value changes
func OnChange(cb func(ChangeType, string)) {
go onChangeOnce.Do(func() {
// For every message on channel
for msg := range monitorCh {
// Parse PropertiesChanged
iface, changed, ok := parsePropertiesChanged(msg)
if !ok || iface != "org.mpris.MediaPlayer2.Player" {
continue
}
// For every property changed
for name, val := range changed {
// If metadata changed
if name == "Metadata" {
// Get fields
fields := val.Value().(map[string]dbus.Variant)
// For every field
for name, val := range fields {
// Handle each field appropriately
if strings.HasSuffix(name, "title") {
title := val.Value().(string)
if title == "" {
title = "Unknown " + ChangeTypeTitle.String()
}
cb(ChangeTypeTitle, title)
} else if strings.HasSuffix(name, "album") {
album := val.Value().(string)
if album == "" {
album = "Unknown " + ChangeTypeAlbum.String()
}
cb(ChangeTypeAlbum, album)
} else if strings.HasSuffix(name, "artist") {
var artists string
switch artistVal := val.Value().(type) {
case string:
artists = artistVal
case []string:
artists = strings.Join(artistVal, ", ")
}
if artists == "" {
artists = "Unknown " + ChangeTypeArtist.String()
}
cb(ChangeTypeArtist, artists)
}
}
} else if name == "PlaybackStatus" {
// Handle status change
cb(ChangeTypeStatus, val.Value().(string))
}
}
}
})
}
// getPlayerNames gets all DBus MPRIS player bus names
func getPlayerNames(conn *dbus.Conn) ([]string, error) {
var names []string
err := conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
if err != nil {
return nil, err
}
var players []string
for _, name := range names {
if strings.HasPrefix(name, "org.mpris.MediaPlayer2") {
players = append(players, name)
}
}
return players, nil
}
// GetPlayerObj gets the object corresponding to the first
// bus name found in DBus
func getPlayerObj() (dbus.BusObject, error) {
players, err := getPlayerNames(method)
if err != nil {
return nil, err
}
if len(players) == 0 {
return nil, nil
}
return method.Object(players[0], "/org/mpris/MediaPlayer2"), nil
}
// parsePropertiesChanged parses a DBus PropertiesChanged signal
func parsePropertiesChanged(msg *dbus.Message) (iface string, changed map[string]dbus.Variant, ok bool) {
if len(msg.Body) != 3 {
return "", nil, false
}
iface, ok = msg.Body[0].(string)
if !ok {
return
}
changed, ok = msg.Body[1].(map[string]dbus.Variant)
if !ok {
return
}
return
}

84
mpris/mpris_test.go Normal file
View File

@ -0,0 +1,84 @@
package mpris
import (
"reflect"
"testing"
"github.com/godbus/dbus/v5"
)
// TestParsePropertiesChanged checks the parsePropertiesChanged function to
// make sure it correctly parses a DBus PropertiesChanged signal.
func TestParsePropertiesChanged(t *testing.T) {
// Create a DBus message
msg := &dbus.Message{
Body: []interface{}{
"com.example.Interface",
map[string]dbus.Variant{
"Property1": dbus.MakeVariant(true),
"Property2": dbus.MakeVariant("Hello, world!"),
},
[]string{},
},
}
// Parse the message
iface, changed, ok := parsePropertiesChanged(msg)
if !ok {
t.Error("Expected parsePropertiesChanged to return true, but got false")
}
// Check the parsed values
expectedIface := "com.example.Interface"
if iface != expectedIface {
t.Errorf("Expected iface to be %q, but got %q", expectedIface, iface)
}
expectedChanged := map[string]dbus.Variant{
"Property1": dbus.MakeVariant(true),
"Property2": dbus.MakeVariant("Hello, world!"),
}
if !reflect.DeepEqual(changed, expectedChanged) {
t.Errorf("Expected changed to be %v, but got %v", expectedChanged, changed)
}
// Test a message with an invalid number of arguments
msg = &dbus.Message{
Body: []interface{}{
"com.example.Interface",
},
}
_, _, ok = parsePropertiesChanged(msg)
if ok {
t.Error("Expected parsePropertiesChanged to return false, but got true")
}
// Test a message with an invalid first argument
msg = &dbus.Message{
Body: []interface{}{
123,
map[string]dbus.Variant{
"Property1": dbus.MakeVariant(true),
"Property2": dbus.MakeVariant("Hello, world!"),
},
[]string{},
},
}
_, _, ok = parsePropertiesChanged(msg)
if ok {
t.Error("Expected parsePropertiesChanged to return false, but got true")
}
// Test a message with an invalid second argument
msg = &dbus.Message{
Body: []interface{}{
"com.example.Interface",
123,
[]string{},
},
}
_, _, ok = parsePropertiesChanged(msg)
if ok {
t.Error("Expected parsePropertiesChanged to return false, but got true")
}
}

115
music.go
View File

@ -19,81 +19,64 @@
package main
import (
"go.arsenm.dev/infinitime"
"go.arsenm.dev/infinitime/pkg/player"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"context"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/mpris"
"go.elara.ws/itd/translit"
"go.elara.ws/logger/log"
)
func initMusicCtrl(dev *infinitime.Device) error {
// On player status change, set status
err := player.Status(func(newStatus bool) {
if !firmwareUpdating {
dev.Music.SetStatus(newStatus)
}
})
if err != nil {
return err
}
func initMusicCtrl(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
mpris.Init(ctx)
// On player title change, set track
err = player.Metadata("title", func(newTitle string) {
if !firmwareUpdating {
dev.Music.SetTrack(newTitle)
}
})
if err != nil {
return err
}
maps := k.Strings("notifs.translit.use")
translit.Transliterators["custom"] = translit.Map(k.Strings("notifs.translit.custom"))
// On player album change, set album
err = player.Metadata("album", func(newAlbum string) {
mpris.OnChange(func(ct mpris.ChangeType, val string) {
newVal := translit.Transliterate(val, maps...)
if !firmwareUpdating {
dev.Music.SetAlbum(newAlbum)
}
})
if err != nil {
return err
}
// On player artist change, set artist
err = player.Metadata("artist", func(newArtist string) {
if !firmwareUpdating {
dev.Music.SetArtist(newArtist)
}
})
if err != nil {
return err
}
// Watch for music events
musicEvtCh, err := dev.Music.WatchEvents()
if err != nil {
return err
}
go func() {
// For every music event received
for musicEvt := range musicEvtCh {
// Perform appropriate action based on event
switch musicEvt {
case infinitime.MusicEventPlay:
player.Play()
case infinitime.MusicEventPause:
player.Pause()
case infinitime.MusicEventNext:
player.Next()
case infinitime.MusicEventPrev:
player.Prev()
case infinitime.MusicEventVolUp:
player.VolUp(viper.GetUint("music.volInterval"))
case infinitime.MusicEventVolDown:
player.VolDown(viper.GetUint("music.volInterval"))
switch ct {
case mpris.ChangeTypeStatus:
dev.SetMusicStatus(val == "Playing")
case mpris.ChangeTypeTitle:
dev.SetMusicTrack(newVal)
case mpris.ChangeTypeAlbum:
dev.SetMusicAlbum(newVal)
case mpris.ChangeTypeArtist:
dev.SetMusicArtist(newVal)
}
}
}()
})
// Watch for music events
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
}
// Log completed initialization
log.Info().Msg("Initialized InfiniTime music controls")
log.Info("Initialized InfiniTime music controls").Send()
return nil
}

View File

@ -19,28 +19,32 @@
package main
import (
"context"
"fmt"
"github.com/godbus/dbus/v5"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"go.arsenm.dev/infinitime"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/utils"
"go.elara.ws/itd/translit"
"go.elara.ws/logger/log"
)
func initNotifRelay(dev *infinitime.Device) error {
func initNotifRelay(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// Connect to dbus session bus
bus, err := dbus.SessionBus()
bus, err := utils.NewSessionBusConn(ctx)
if err != nil {
return err
}
// Define rules to listen for
var rules = []string{
rules := []string{
"type='method_call',member='Notify',path='/org/freedesktop/Notifications',interface='org.freedesktop.Notifications'",
}
var flag uint = 0
// Becode monitor for notifications
call := bus.BusObject().Call("org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, rules, flag)
call := bus.BusObject().CallWithContext(
ctx, "org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, rules, flag,
)
if call.Err != nil {
return call.Err
}
@ -50,49 +54,63 @@ func initNotifRelay(dev *infinitime.Device) error {
// Send events to channel
bus.Eavesdrop(notifCh)
wg.Add(1)
go func() {
defer wg.Done("notifRelay")
// For every event sent to channel
for v := range notifCh {
// If firmware is updating, skip
if firmwareUpdating {
continue
for {
select {
case v := <-notifCh:
// If firmware is updating, skip
if firmwareUpdating {
continue
}
// If body does not contain 5 elements, skip
if len(v.Body) < 5 {
continue
}
// Get requred fields
sender, summary, body := v.Body[0].(string), v.Body[3].(string), v.Body[4].(string)
// If fields are ignored in config, skip
if ignored(sender, summary, body) {
continue
}
maps := k.Strings("notifs.translit.use")
translit.Transliterators["custom"] = translit.Map(k.Strings("notifs.translit.custom"))
sender = translit.Transliterate(sender, maps...)
summary = translit.Transliterate(summary, maps...)
body = translit.Transliterate(body, maps...)
var msg string
// If summary does not exist, set message to body.
// If it does, set message to summary, two newlines, and then body
if summary == "" {
msg = body
} else {
msg = fmt.Sprintf("%s\n\n%s", summary, body)
}
dev.Notify(sender, msg)
case <-ctx.Done():
bus.Close()
return
}
// If body does not contain 5 elements, skip
if len(v.Body) < 5 {
continue
}
// Get requred fields
sender, summary, body := v.Body[0].(string), v.Body[3].(string), v.Body[4].(string)
// If fields are ignored in config, skip
if ignored(sender, summary, body) {
continue
}
var msg string
// If summary does not exist, set message to body.
// If it does, set message to summary, two newlines, and then body
if summary == "" {
msg = body
} else {
msg = fmt.Sprintf("%s\n\n%s", summary, body)
}
dev.Notify(sender, msg)
}
}()
log.Info().Msg("Relaying notifications to InfiniTime")
log.Info("Relaying notifications to InfiniTime").Send()
return nil
}
// ignored checks whether any fields were ignored in the config
func ignored(sender, summary, body string) bool {
ignoreSender := viper.GetStringSlice("notifications.ignore.sender")
ignoreSummary := viper.GetStringSlice("notifications.ignore.summary")
ignoreBody := viper.GetStringSlice("notifications.ignore.body")
ignoreSender := k.Strings("notifs.ignore.sender")
ignoreSummary := k.Strings("notifs.ignore.summary")
ignoreBody := k.Strings("notifs.ignore.body")
return strSlcContains(ignoreSender, sender) ||
strSlcContains(ignoreSummary, summary) ||
strSlcContains(ignoreBody, body)

3
scripts/gen-version.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
git describe --tags > version.txt

691
socket.go
View File

@ -19,278 +19,495 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"archive/zip"
"context"
"errors"
"io"
"net"
"os"
"path/filepath"
"time"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog/log"
"go.arsenm.dev/infinitime"
"go.arsenm.dev/itd/internal/types"
"go.elara.ws/drpc/muxserver"
"go.elara.ws/itd/infinitime"
"go.elara.ws/itd/internal/rpc"
"go.elara.ws/logger/log"
"storj.io/drpc/drpcmux"
)
const SockPath = "/tmp/itd/socket"
const (
ReqTypeHeartRate = "hrt"
ReqTypeBattLevel = "battlvl"
ReqTypeFwVersion = "fwver"
ReqTypeFwUpgrade = "fwupg"
ReqTypeBtAddress = "btaddr"
ReqTypeNotify = "notify"
ReqTypeSetTime = "settime"
var (
ErrDFUInvalidFile = errors.New("provided file is invalid for given upgrade type")
ErrDFUNotEnoughFiles = errors.New("not enough files provided for given upgrade type")
ErrDFUInvalidUpgType = errors.New("invalid upgrade type")
)
const (
UpgradeTypeArchive = iota
UpgradeTypeFiles
)
func startSocket(dev *infinitime.Device) error {
// Make socket directory if non existant
err := os.MkdirAll(filepath.Dir(SockPath), 0755)
func startSocket(ctx context.Context, wg WaitGroup, dev *infinitime.Device) error {
// Make socket directory if non-existant
err := os.MkdirAll(filepath.Dir(k.String("socket.path")), 0o755)
if err != nil {
return err
}
// Remove old socket if it exists
err = os.RemoveAll(SockPath)
err = os.RemoveAll(k.String("socket.path"))
if err != nil {
return err
}
// Listen on socket path
ln, err := net.Listen("unix", SockPath)
ln, err := net.Listen("unix", k.String("socket.path"))
if err != nil {
return err
}
fs := dev.FS()
mux := drpcmux.New()
err = rpc.DRPCRegisterITD(mux, &ITD{dev})
if err != nil {
return err
}
err = rpc.DRPCRegisterFS(mux, &FS{dev, fs})
if err != nil {
return err
}
log.Info("Starting control socket").Str("path", k.String("socket.path")).Send()
wg.Add(1)
go func() {
for {
// Accept socket connection
conn, err := ln.Accept()
if err != nil {
log.Error().Err(err).Msg("Error accepting connection")
}
// Concurrently handle connection
go handleConnection(conn, dev)
}
defer wg.Done("socket")
muxserver.New(mux).Serve(ctx, ln)
}()
// Log socket start
log.Info().Str("path", SockPath).Msg("Started control socket")
return nil
}
func handleConnection(conn net.Conn, dev *infinitime.Device) {
defer conn.Close()
// If firmware is updating, return error
if firmwareUpdating {
connErr(conn, nil, "Firmware update in progress")
return
}
type ITD struct {
dev *infinitime.Device
}
// Create new scanner on connection
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
var req types.Request
// Decode scanned message into types.Request
err := json.Unmarshal(scanner.Bytes(), &req)
func (i *ITD) HeartRate(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, error) {
hr, err := i.dev.HeartRate()
return &rpc.IntResponse{Value: uint32(hr)}, err
}
func (i *ITD) WatchHeartRate(_ *rpc.Empty, s rpc.DRPCITD_WatchHeartRateStream) error {
errCh := make(chan error)
err := i.dev.WatchHeartRate(s.Context(), func(rate uint8, err error) {
if err != nil {
connErr(conn, err, "Error decoding JSON input")
continue
errCh <- err
return
}
switch req.Type {
case ReqTypeHeartRate:
// Get heart rate from watch
heartRate, err := dev.HeartRate()
if err != nil {
connErr(conn, err, "Error getting heart rate")
break
}
// Encode heart rate to connection
json.NewEncoder(conn).Encode(types.Response{
Value: heartRate,
})
case ReqTypeBattLevel:
// Get battery level from watch
battLevel, err := dev.BatteryLevel()
if err != nil {
connErr(conn, err, "Error getting battery level")
break
}
// Encode battery level to connection
json.NewEncoder(conn).Encode(types.Response{
Value: battLevel,
})
case ReqTypeFwVersion:
// Get firmware version from watch
version, err := dev.Version()
if err != nil {
connErr(conn, err, "Error getting battery level")
break
}
// Encode version to connection
json.NewEncoder(conn).Encode(types.Response{
Value: version,
})
case ReqTypeBtAddress:
// Encode bluetooth address to connection
json.NewEncoder(conn).Encode(types.Response{
Value: dev.Address(),
})
case ReqTypeNotify:
// If no data, return error
if req.Data == nil {
connErr(conn, nil, "Data required for notify types.Request")
break
}
var reqData types.ReqDataNotify
// Decode data map to notify types.Request data
err = mapstructure.Decode(req.Data, &reqData)
if err != nil {
connErr(conn, err, "Error decoding types.Request data")
break
}
// Send notification to watch
err = dev.Notify(reqData.Title, reqData.Body)
if err != nil {
connErr(conn, err, "Error sending notification")
break
}
// Encode empty types.Response to connection
json.NewEncoder(conn).Encode(types.Response{})
case ReqTypeSetTime:
// If no data, return error
if req.Data == nil {
connErr(conn, nil, "Data required for settime types.Request")
break
}
// Get string from data or return error
reqTimeStr, ok := req.Data.(string)
if !ok {
connErr(conn, nil, "Data for settime types.Request must be RFC3339 formatted time string")
break
}
var reqTime time.Time
if reqTimeStr == "now" {
reqTime = time.Now()
} else {
// Parse time as RFC3339/ISO9601
reqTime, err = time.Parse(time.RFC3339, reqTimeStr)
if err != nil {
connErr(conn, err, "Invalid time format. Time string must be formatted as ISO8601 or the word `now`")
break
}
}
// Set time on watch
err = dev.SetTime(reqTime)
if err != nil {
connErr(conn, err, "Error setting device time")
break
}
// Encode empty types.Response to connection
json.NewEncoder(conn).Encode(types.Response{})
case ReqTypeFwUpgrade:
// If no data, return error
if req.Data == nil {
connErr(conn, nil, "Data required for firmware upgrade types.Request")
break
}
var reqData types.ReqDataFwUpgrade
// Decode data map to firmware upgrade types.Request data
err = mapstructure.Decode(req.Data, &reqData)
if err != nil {
connErr(conn, err, "Error decoding types.Request data")
break
}
switch reqData.Type {
case UpgradeTypeArchive:
// If less than one file, return error
if len(reqData.Files) < 1 {
connErr(conn, nil, "Archive upgrade requires one file with .zip extension")
break
}
// If file is not zip archive, return error
if filepath.Ext(reqData.Files[0]) != ".zip" {
connErr(conn, nil, "Archive upgrade file must be a zip archive")
break
}
// Load DFU archive
err := dev.DFU.LoadArchive(reqData.Files[0])
if err != nil {
connErr(conn, err, "Error loading archive file")
break
}
case UpgradeTypeFiles:
// If less than two files, return error
if len(reqData.Files) < 2 {
connErr(conn, nil, "Files upgrade requires two files. First with .dat and second with .bin extension.")
break
}
// If first file is not init packet, return error
if filepath.Ext(reqData.Files[0]) != ".dat" {
connErr(conn, nil, "First file must be a .dat file")
break
}
// If second file is not firmware image, return error
if filepath.Ext(reqData.Files[1]) != ".bin" {
connErr(conn, nil, "Second file must be a .bin file")
break
}
// Load individual DFU files
err := dev.DFU.LoadFiles(reqData.Files[0], reqData.Files[1])
if err != nil {
connErr(conn, err, "Error loading firmware files")
break
}
}
go func() {
// Get progress
progress := dev.DFU.Progress()
// For every progress event
for event := range progress {
// Encode event on connection
json.NewEncoder(conn).Encode(types.Response{
Value: event,
})
}
}()
// Set firmwareUpdating
firmwareUpdating = true
// Start DFU
err = dev.DFU.Start()
if err != nil {
connErr(conn, err, "Error performing upgrade")
break
}
firmwareUpdating = false
err = s.Send(&rpc.IntResponse{Value: uint32(rate)})
if err != nil {
errCh <- err
}
}
}
func connErr(conn net.Conn, err error, msg string) {
var res types.Response
// If error exists, add to types.Response, otherwise don't
})
if err != nil {
log.Error().Err(err).Msg(msg)
res = types.Response{Message: fmt.Sprintf("%s: %s", msg, err)}
} else {
log.Error().Msg(msg)
res = types.Response{Message: msg}
return err
}
res.Error = true
// Encode error to connection
json.NewEncoder(conn).Encode(res)
select {
case <-errCh:
return err
case <-s.Context().Done():
return nil
}
}
func (i *ITD) BatteryLevel(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, error) {
bl, err := i.dev.BatteryLevel()
return &rpc.IntResponse{Value: uint32(bl)}, err
}
func (i *ITD) WatchBatteryLevel(_ *rpc.Empty, s rpc.DRPCITD_WatchBatteryLevelStream) error {
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
}
select {
case <-errCh:
return err
case <-s.Context().Done():
return nil
}
}
func (i *ITD) Motion(_ context.Context, _ *rpc.Empty) (*rpc.MotionResponse, error) {
motionVals, err := i.dev.Motion()
return &rpc.MotionResponse{
X: int32(motionVals.X),
Y: int32(motionVals.Y),
Z: int32(motionVals.Z),
}, err
}
func (i *ITD) WatchMotion(_ *rpc.Empty, s rpc.DRPCITD_WatchMotionStream) error {
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
}
select {
case <-errCh:
return err
case <-s.Context().Done():
return nil
}
}
func (i *ITD) StepCount(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, error) {
sc, err := i.dev.StepCount()
return &rpc.IntResponse{Value: sc}, err
}
func (i *ITD) WatchStepCount(_ *rpc.Empty, s rpc.DRPCITD_WatchStepCountStream) error {
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
}
select {
case <-errCh:
return err
case <-s.Context().Done():
return nil
}
}
func (i *ITD) Version(_ context.Context, _ *rpc.Empty) (*rpc.StringResponse, error) {
v, err := i.dev.Version()
return &rpc.StringResponse{Value: v}, err
}
func (i *ITD) Address(_ context.Context, _ *rpc.Empty) (*rpc.StringResponse, error) {
return &rpc.StringResponse{Value: i.dev.Address()}, nil
}
func (i *ITD) Notify(_ context.Context, data *rpc.NotifyRequest) (*rpc.Empty, error) {
return &rpc.Empty{}, i.dev.Notify(data.Title, data.Body)
}
func (i *ITD) SetTime(_ context.Context, data *rpc.SetTimeRequest) (*rpc.Empty, error) {
return &rpc.Empty{}, i.dev.SetTime(time.Unix(0, data.UnixNano))
}
func (i *ITD) WeatherUpdate(context.Context, *rpc.Empty) (*rpc.Empty, error) {
sendWeatherCh <- struct{}{}
return &rpc.Empty{}, nil
}
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:
fwimg, initpkt, err = extractDFU(data.Files[0])
if err != nil {
return err
}
case rpc.FirmwareUpgradeRequest_Files:
if len(data.Files) < 2 {
return ErrDFUNotEnoughFiles
}
if filepath.Ext(data.Files[0]) != ".dat" {
return ErrDFUInvalidFile
}
if filepath.Ext(data.Files[1]) != ".bin" {
return ErrDFUInvalidFile
}
initpkt, err = os.Open(data.Files[0])
if err != nil {
return err
}
fwimg, err = os.Open(data.Files[1])
if err != nil {
return err
}
default:
return ErrDFUInvalidUpgType
}
defer os.Remove(fwimg.Name())
defer os.Remove(initpkt.Name())
defer fwimg.Close()
defer initpkt.Close()
firmwareUpdating = true
defer func() { firmwareUpdating = false }()
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 *infinitime.FS
}
func (fs *FS) RemoveAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) {
for _, path := range req.Paths {
err := fs.fs.RemoveAll(path)
if err != nil {
return &rpc.Empty{}, err
}
}
return &rpc.Empty{}, nil
}
func (fs *FS) Remove(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) {
for _, path := range req.Paths {
err := fs.fs.Remove(path)
if err != nil {
return &rpc.Empty{}, err
}
}
return &rpc.Empty{}, nil
}
func (fs *FS) Rename(_ context.Context, req *rpc.RenameRequest) (*rpc.Empty, error) {
return &rpc.Empty{}, fs.fs.Rename(req.From, req.To)
}
func (fs *FS) MkdirAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) {
for _, path := range req.Paths {
err := fs.fs.MkdirAll(path)
if err != nil {
return &rpc.Empty{}, err
}
}
return &rpc.Empty{}, nil
}
func (fs *FS) Mkdir(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) {
for _, path := range req.Paths {
err := fs.fs.Mkdir(path)
if err != nil {
return &rpc.Empty{}, err
}
}
return &rpc.Empty{}, nil
}
func (fs *FS) ReadDir(_ context.Context, req *rpc.PathRequest) (*rpc.DirResponse, error) {
entries, err := fs.fs.ReadDir(req.Path)
if err != nil {
return nil, err
}
var fileInfo []*rpc.FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
return nil, err
}
fileInfo = append(fileInfo, &rpc.FileInfo{
Name: info.Name(),
Size: info.Size(),
IsDir: info.IsDir(),
})
}
return &rpc.DirResponse{Entries: fileInfo}, nil
}
func (fs *FS) Upload(req *rpc.TransferRequest, s rpc.DRPCFS_UploadStream) error {
localFile, err := os.Open(req.Source)
if err != nil {
return err
}
localInfo, err := localFile.Stat()
if err != nil {
return err
}
remoteFile, err := fs.fs.Create(req.Destination, uint32(localInfo.Size()))
if err != nil {
return err
}
remoteFile.ProgressFunc = func(transferred, total uint32) {
_ = s.Send(&rpc.TransferProgress{
Total: total,
Sent: transferred,
})
}
io.Copy(remoteFile, localFile)
localFile.Close()
remoteFile.Close()
return nil
}
func (fs *FS) Download(req *rpc.TransferRequest, s rpc.DRPCFS_DownloadStream) error {
localFile, err := os.Create(req.Destination)
if err != nil {
return err
}
remoteFile, err := fs.fs.Open(req.Source)
if err != nil {
return err
}
defer localFile.Close()
defer remoteFile.Close()
remoteFile.ProgressFunc = func(transferred, total uint32) {
_ = s.Send(&rpc.TransferProgress{
Total: total,
Sent: transferred,
})
}
_, err = io.Copy(localFile, remoteFile)
if err != nil {
return err
}
return nil
}
func (fs *FS) LoadResources(req *rpc.PathRequest, s rpc.DRPCFS_LoadResourcesStream) error {
return infinitime.LoadResources(req.Path, fs.fs, func(evt infinitime.ResourceLoadProgress) {
_ = s.Send(&rpc.ResourceLoadProgress{
Name: evt.Name,
Total: int64(evt.Total),
Sent: int64(evt.Transferred),
Operation: rpc.ResourceLoadProgress_Operation(evt.Operation),
})
})
}
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
}
}
}
if fwimg == nil || initpkt == nil {
return nil, nil, errors.New("invalid dfu archive")
}
return fwimg, initpkt, nil
}

Some files were not shown because too many files have changed in this diff Show More