Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 078b8dc490 | |||
| 14aaa8a0ed | |||
| 1f4d59d84e | |||
| 3643a479ab | |||
| 625805fe96 | |||
| 4b6f7d408e | |||
| 9034ef7c6b | |||
| 9939f724c4 | |||
| 8dce33f7b1 | |||
| 563009c44d | |||
| d4a8a9f8c9 | |||
| 7fd9af3288 | |||
| 4508559bfd | |||
| 0cdf8a4bed | |||
| 2af6c1887f | |||
| 3a3f95acdf | |||
| d318c584da | |||
| c8c617c10a | |||
| 365414f951 | |||
| 9b04d06560 | |||
| 23e9195e70 | |||
| cd68fbd7f3 | |||
| 205a041758 | |||
| 62597f70ee | |||
| 32bb141244 | |||
| f28c68438a | |||
| aa90e9eb26 | |||
| 553709ce8d | |||
| 2ded0d36b1 | |||
| a885eacc70 | |||
| 9e63401db3 | |||
| 2f14e70721 | |||
| 614d14e399 | |||
| c08ddfd810 | |||
| 4bdb82b1bc | |||
| b4d302caf6 | |||
| 4b2694ee0d | |||
| 4c36144b0b | |||
| e88dea40fb | |||
| 23b9cfe8a3 | |||
| 518fe74e96 | |||
| 27aabdceba | |||
| c019d7523b | |||
| 03c3c6b22f | |||
| 69d1027f01 | |||
| 873df67d1f | |||
| a9ef386883 | |||
| 24cfda82d7 | |||
| 655af5c446 | |||
| b363a20a9d | |||
| f5d326124d | |||
| cb8fb2c0bc | |||
| 38119435f1 | |||
| 034a69c12f | |||
| 70006a3d7b | |||
| 8aada58d64 | |||
| 5d231207cd | |||
| 584d9426e6 | |||
| 7a772a5458 | |||
| 079c733b60 | |||
| e24a8e9088 | |||
| 0b5d777077 | |||
| 75327286ef | |||
| 099b0cd849 | |||
| c9c00e0072 | |||
| b2ffb2062a | |||
| 2e8c825fff | |||
| 7b870950d1 | |||
| 3a877c41a4 | |||
| 04fb390bee | |||
| 50b17d3266 | |||
| 763d408405 | |||
| fbb7cd9bc1 | |||
| f1b7f70313 | |||
| 552f19676b | |||
| 9d58ea0ae7 | |||
| 76875db7ea | |||
| be5bdc625b | |||
| 0d0db949af | |||
| 0d164aef3d | |||
| 28610d9ebb | |||
| dff34b484d | |||
| 2ea9f99db6 | |||
| 44dc5f8e47 | |||
| 4d35912466 | |||
| ef29b9bee4 | |||
| e198b769f9 | |||
| ef4bad94b5 | |||
| 8cf2b47733 | |||
| f20fdcb161 | |||
| eeba9b2964 | |||
| d7057e3f9c | |||
| 80a5867d6b | |||
| f001dd6079 | |||
| b87586ef15 | |||
| 295892c8a8 | |||
| 1492db7566 | |||
| e7de7bd7bb | |||
| 7b849a3fc7 | |||
| 21d4964207 | |||
| 604ea57c5f | |||
| b15cbb6349 | |||
| 843e369bab | |||
| eec7a3db48 | |||
| c23201e18c | |||
| 4bc6eb9d41 | |||
| 2a59e74a2c | |||
| df743cca96 | |||
| 01bf493c77 | |||
| b6e9ad6160 | |||
| c56c0ae198 | |||
| 6b94030b83 | |||
| 2bbd722ecd | |||
| 73f16fcfef | |||
| 9df6531023 | |||
| 1db2ca3395 | |||
| 419b2f5a79 | |||
| 44607ba9e2 | |||
| f4d2f4e6eb | |||
| 0721b7f9d4 | |||
| b7bd385c43 | |||
| cbcefb149e | |||
| cb8d207249 | |||
| 7786ea1d58 | |||
| 6e16aa7a7a | |||
| 91f7132d5e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
/itctl
|
||||
/itd
|
||||
/itd
|
||||
/itgui
|
||||
/version.txt
|
||||
8
Makefile
8
Makefile
@@ -3,13 +3,14 @@ BIN_PREFIX = $(DESTDIR)$(PREFIX)/bin
|
||||
SERVICE_PREFIX = $(DESTDIR)$(PREFIX)/lib/systemd/user
|
||||
CFG_PREFIX = $(DESTDIR)/etc
|
||||
|
||||
all:
|
||||
all: version
|
||||
go build $(GOFLAGS)
|
||||
go build ./cmd/itctl $(GOFLAGS)
|
||||
|
||||
clean:
|
||||
rm -f itctl
|
||||
rm -f itd
|
||||
printf "unknown" > version.txt
|
||||
|
||||
install:
|
||||
install -Dm755 ./itd $(BIN_PREFIX)/itd
|
||||
@@ -23,4 +24,7 @@ uninstall:
|
||||
rm $(SERVICE_PREFIX)/itd.service
|
||||
rm $(CFG_PREFIX)/itd.toml
|
||||
|
||||
.PHONY: all clean install uninstall
|
||||
version:
|
||||
printf "r%s.%s" "$(shell git rev-list --count HEAD)" "$(shell git rev-parse --short HEAD)" > version.txt
|
||||
|
||||
.PHONY: all clean install uninstall version
|
||||
84
README.md
84
README.md
@@ -12,8 +12,10 @@
|
||||
### 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
|
||||
@@ -27,7 +29,7 @@ This daemon creates a UNIX socket at `/tmp/itd/socket`. It allows you to directl
|
||||
The socket accepts JSON requests. For example, sending a notification looks like this:
|
||||
|
||||
```json
|
||||
{"type": "notify", "data": {"title": "title1", "body": "body1"}}
|
||||
{"type": 5, "data": {"title": "title1", "body": "body1"}}
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -36,6 +38,43 @@ The various request types and their data requirements can be seen in `internal/t
|
||||
|
||||
---
|
||||
|
||||
### Transliteration
|
||||
|
||||
Since the PineTime does not have enough space to store all unicode glyphs, it only stores the ASCII space and Cyrillic. Therefore, this daemon can transliterate unsupported characters into supported ones. Since some languages have different transliterations, the transliterators to be used must be specified in the config. Here are the available transliterators:
|
||||
|
||||
- eASCII
|
||||
- Scandinavian
|
||||
- German
|
||||
- Hebrew
|
||||
- Greek
|
||||
- Russian
|
||||
- Ukranian
|
||||
- Arabic
|
||||
- Farsi
|
||||
- Polish
|
||||
- Lithuanian
|
||||
- Estonian
|
||||
- Icelandic
|
||||
- Czech
|
||||
- French
|
||||
- Armenian
|
||||
- Korean
|
||||
- Chinese
|
||||
- Romanian
|
||||
- Emoji
|
||||
|
||||
Place the desired map names in an array as `notifs.translit.use`. They will be evaluated in order. You can also put custom transliterations in `notifs.translit.custom`. These take priority over any other maps. The `notifs.translit` config section should look like this:
|
||||
|
||||
```toml
|
||||
[notifs.translit]
|
||||
use = ["eASCII", "Russian", "Emoji"]
|
||||
custom = [
|
||||
"test", "replaced"
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `itctl`
|
||||
|
||||
This daemon comes with a binary called `itctl` which uses the socket to control the daemon from the command line. As such, it can be scripted using bash.
|
||||
@@ -45,10 +84,10 @@ This is the `itctl` usage screen:
|
||||
Control the itd daemon for InfiniTime smartwatches
|
||||
|
||||
Usage:
|
||||
itctl [flags]
|
||||
itctl [command]
|
||||
|
||||
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
|
||||
@@ -56,11 +95,36 @@ Available Commands:
|
||||
set Set information on InfiniTime
|
||||
|
||||
Flags:
|
||||
-h, --help help for itctl
|
||||
-h, --help help for itctl
|
||||
-s, --socket-path string Path to itd socket
|
||||
|
||||
Use "itctl [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `itgui`
|
||||
|
||||
In `cmd/itgui`, there is a gui frontend to the socket of `itd`. It uses the [fyne library](https://fyne.io/) for Go. It can be compiled by running:
|
||||
|
||||
```shell
|
||||
go build ./cmd/itgui
|
||||
```
|
||||
|
||||
#### Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
#### Interactive mode
|
||||
|
||||
Running `itctl` by itself will open interactive mode. It's essentially a shell where you can enter commands. For example:
|
||||
@@ -114,7 +178,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).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,4 +186,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, © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors
|
||||
|
||||
Weather data from the [Norwegian Meteorological Institute](https://www.met.no/en)
|
||||
117
api/api.go
Normal file
117
api/api.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/smallnest/rpcx/client"
|
||||
"github.com/smallnest/rpcx/protocol"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go.arsenm.dev/infinitime"
|
||||
)
|
||||
|
||||
const DefaultAddr = "/tmp/itd/socket"
|
||||
|
||||
type Client struct {
|
||||
itdClient client.XClient
|
||||
itdCh chan *protocol.Message
|
||||
fsClient client.XClient
|
||||
fsCh chan *protocol.Message
|
||||
srvVals map[string]chan interface{}
|
||||
}
|
||||
|
||||
func New(sockPath string) (*Client, error) {
|
||||
d, err := client.NewPeer2PeerDiscovery("unix@"+sockPath, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &Client{}
|
||||
|
||||
out.itdCh = make(chan *protocol.Message, 5)
|
||||
out.itdClient = client.NewBidirectionalXClient(
|
||||
"ITD",
|
||||
client.Failtry,
|
||||
client.RandomSelect,
|
||||
d,
|
||||
client.DefaultOption,
|
||||
out.itdCh,
|
||||
)
|
||||
|
||||
out.fsCh = make(chan *protocol.Message, 5)
|
||||
out.fsClient = client.NewBidirectionalXClient(
|
||||
"FS",
|
||||
client.Failtry,
|
||||
client.RandomSelect,
|
||||
d,
|
||||
client.DefaultOption,
|
||||
out.fsCh,
|
||||
)
|
||||
|
||||
out.srvVals = map[string]chan interface{}{}
|
||||
|
||||
go out.handleMessages(out.itdCh)
|
||||
go out.handleMessages(out.fsCh)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) handleMessages(msgCh chan *protocol.Message) {
|
||||
for msg := range msgCh {
|
||||
_, ok := c.srvVals[msg.ServicePath]
|
||||
if !ok {
|
||||
c.srvVals[msg.ServicePath] = make(chan interface{}, 5)
|
||||
}
|
||||
|
||||
//fmt.Printf("%+v\n", msg)
|
||||
|
||||
ch := c.srvVals[msg.ServicePath]
|
||||
|
||||
switch msg.ServiceMethod {
|
||||
case "FSProgress":
|
||||
var progress FSTransferProgress
|
||||
msgpack.Unmarshal(msg.Payload, &progress)
|
||||
ch <- progress
|
||||
case "DFUProgress":
|
||||
var progress infinitime.DFUProgress
|
||||
msgpack.Unmarshal(msg.Payload, &progress)
|
||||
ch <- progress
|
||||
case "MotionSample":
|
||||
var motionVals infinitime.MotionValues
|
||||
msgpack.Unmarshal(msg.Payload, &motionVals)
|
||||
ch <- motionVals
|
||||
case "Done":
|
||||
close(c.srvVals[msg.ServicePath])
|
||||
delete(c.srvVals, msg.ServicePath)
|
||||
default:
|
||||
var value interface{}
|
||||
msgpack.Unmarshal(msg.Payload, &value)
|
||||
ch <- value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) done(id string) error {
|
||||
return c.itdClient.Call(
|
||||
context.Background(),
|
||||
"Done",
|
||||
id,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
err := c.itdClient.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = c.fsClient.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
close(c.itdCh)
|
||||
close(c.fsCh)
|
||||
|
||||
return nil
|
||||
}
|
||||
40
api/firmware.go
Normal file
40
api/firmware.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.arsenm.dev/infinitime"
|
||||
)
|
||||
|
||||
func (c *Client) FirmwareUpgrade(upgType UpgradeType, files ...string) (chan infinitime.DFUProgress, error) {
|
||||
var id string
|
||||
err := c.itdClient.Call(
|
||||
context.Background(),
|
||||
"FirmwareUpgrade",
|
||||
FwUpgradeData{
|
||||
Type: upgType,
|
||||
Files: files,
|
||||
},
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
progressCh := make(chan infinitime.DFUProgress, 5)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
progressCh <- val.(infinitime.DFUProgress)
|
||||
}
|
||||
close(progressCh)
|
||||
}()
|
||||
|
||||
return progressCh, nil
|
||||
}
|
||||
101
api/fs.go
Normal file
101
api/fs.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) Remove(paths ...string) error {
|
||||
return c.fsClient.Call(
|
||||
context.Background(),
|
||||
"Remove",
|
||||
paths,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) Rename(old, new string) error {
|
||||
return c.fsClient.Call(
|
||||
context.Background(),
|
||||
"Remove",
|
||||
[2]string{old, new},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) Mkdir(paths ...string) error {
|
||||
return c.fsClient.Call(
|
||||
context.Background(),
|
||||
"Mkdir",
|
||||
paths,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) ReadDir(dir string) (out []FileInfo, err error) {
|
||||
err = c.fsClient.Call(
|
||||
context.Background(),
|
||||
"ReadDir",
|
||||
dir,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Upload(dst, src string) (chan FSTransferProgress, error) {
|
||||
var id string
|
||||
err := c.fsClient.Call(
|
||||
context.Background(),
|
||||
"Upload",
|
||||
[2]string{dst, src},
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
progressCh := make(chan FSTransferProgress, 5)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
progressCh <- val.(FSTransferProgress)
|
||||
}
|
||||
close(progressCh)
|
||||
}()
|
||||
|
||||
return progressCh, nil
|
||||
}
|
||||
|
||||
func (c *Client) Download(dst, src string) (chan FSTransferProgress, error) {
|
||||
var id string
|
||||
err := c.fsClient.Call(
|
||||
context.Background(),
|
||||
"Download",
|
||||
[2]string{dst, src},
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
progressCh := make(chan FSTransferProgress, 5)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
progressCh <- val.(FSTransferProgress)
|
||||
}
|
||||
close(progressCh)
|
||||
}()
|
||||
|
||||
return progressCh, nil
|
||||
}
|
||||
67
api/get.go
Normal file
67
api/get.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.arsenm.dev/infinitime"
|
||||
)
|
||||
|
||||
func (c *Client) HeartRate() (out uint8, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"HeartRate",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) BatteryLevel() (out uint8, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"BatteryLevel",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Motion() (out infinitime.MotionValues, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"Motion",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) StepCount() (out uint32, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"StepCount",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Version() (out string, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"Version",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Client) Address() (out string, err error) {
|
||||
err = c.itdClient.Call(
|
||||
context.Background(),
|
||||
"Address",
|
||||
nil,
|
||||
&out,
|
||||
)
|
||||
return
|
||||
}
|
||||
17
api/notify.go
Normal file
17
api/notify.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func (c *Client) Notify(title, body string) error {
|
||||
return c.itdClient.Call(
|
||||
context.Background(),
|
||||
"Notify",
|
||||
NotifyData{
|
||||
Title: title,
|
||||
Body: body,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
15
api/set.go
Normal file
15
api/set.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) SetTime(t time.Time) error {
|
||||
return c.itdClient.Call(
|
||||
context.Background(),
|
||||
"SetTime",
|
||||
t,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
96
api/types.go
Normal file
96
api/types.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
12
api/update.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import "context"
|
||||
|
||||
func (c *Client) WeatherUpdate() error {
|
||||
return c.itdClient.Call(
|
||||
context.Background(),
|
||||
"WeatherUpdate",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
144
api/watch.go
Normal file
144
api/watch.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.arsenm.dev/infinitime"
|
||||
)
|
||||
|
||||
func (c *Client) WatchHeartRate() (<-chan uint8, func(), error) {
|
||||
var id string
|
||||
err := c.itdClient.Call(
|
||||
context.Background(),
|
||||
"WatchHeartRate",
|
||||
nil,
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan uint8, 2)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
outCh <- val.(uint8)
|
||||
}
|
||||
}()
|
||||
|
||||
doneFn := func() {
|
||||
c.done(id)
|
||||
close(c.srvVals[id])
|
||||
delete(c.srvVals, id)
|
||||
}
|
||||
|
||||
return outCh, doneFn, nil
|
||||
}
|
||||
|
||||
func (c *Client) WatchBatteryLevel() (<-chan uint8, func(), error) {
|
||||
var id string
|
||||
err := c.itdClient.Call(
|
||||
context.Background(),
|
||||
"WatchBatteryLevel",
|
||||
nil,
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan uint8, 2)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
outCh <- val.(uint8)
|
||||
}
|
||||
}()
|
||||
|
||||
doneFn := func() {
|
||||
c.done(id)
|
||||
close(c.srvVals[id])
|
||||
delete(c.srvVals, id)
|
||||
}
|
||||
|
||||
return outCh, doneFn, nil
|
||||
}
|
||||
|
||||
func (c *Client) WatchStepCount() (<-chan uint32, func(), error) {
|
||||
var id string
|
||||
err := c.itdClient.Call(
|
||||
context.Background(),
|
||||
"WatchStepCount",
|
||||
nil,
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan uint32, 2)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
outCh <- val.(uint32)
|
||||
}
|
||||
}()
|
||||
|
||||
doneFn := func() {
|
||||
c.done(id)
|
||||
close(c.srvVals[id])
|
||||
delete(c.srvVals, id)
|
||||
}
|
||||
|
||||
return outCh, doneFn, nil
|
||||
}
|
||||
|
||||
func (c *Client) WatchMotion() (<-chan infinitime.MotionValues, func(), error) {
|
||||
var id string
|
||||
err := c.itdClient.Call(
|
||||
context.Background(),
|
||||
"WatchMotion",
|
||||
nil,
|
||||
&id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
outCh := make(chan infinitime.MotionValues, 2)
|
||||
go func() {
|
||||
srvValCh, ok := c.srvVals[id]
|
||||
for !ok {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
srvValCh, ok = c.srvVals[id]
|
||||
}
|
||||
|
||||
for val := range srvValCh {
|
||||
outCh <- val.(infinitime.MotionValues)
|
||||
}
|
||||
}()
|
||||
|
||||
doneFn := func() {
|
||||
c.done(id)
|
||||
close(c.srvVals[id])
|
||||
delete(c.srvVals, id)
|
||||
}
|
||||
|
||||
return outCh, doneFn, nil
|
||||
}
|
||||
142
calls.go
Normal file
142
calls.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitea.arsenm.dev/Arsen6331/infinitime"
|
||||
)
|
||||
|
||||
func initCallNotifs(dev *infinitime.Device) error {
|
||||
// Connect to system bus. This connection is for method calls.
|
||||
conn, err := newSystemBusConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if modem manager interface exists
|
||||
exists, err := modemManagerExists(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 := newSystemBusConn()
|
||||
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 respHandlerOnce sync.Once
|
||||
var callObj dbus.BusObject
|
||||
|
||||
go func() {
|
||||
// For every message received
|
||||
for event := range 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().Err(err).Msg("Error getting phone number")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send call notification to InfiniTime
|
||||
resCh, err := dev.NotifyCall(phoneNum)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
go respHandlerOnce.Do(func() {
|
||||
// Wait for PineTime response
|
||||
for res := range resCh {
|
||||
switch res {
|
||||
case infinitime.CallStatusAccepted:
|
||||
// Attempt to accept call
|
||||
err = acceptCall(conn, callObj)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Error accepting call")
|
||||
}
|
||||
case infinitime.CallStatusDeclined:
|
||||
// Attempt to decline call
|
||||
err = declineCall(conn, callObj)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Error declining call")
|
||||
}
|
||||
case infinitime.CallStatusMuted:
|
||||
// Warn about unimplemented muting
|
||||
log.Warn().Msg("Muting calls is not implemented")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info().Msg("Relaying calls to InfiniTime")
|
||||
return nil
|
||||
}
|
||||
|
||||
func modemManagerExists(conn *dbus.Conn) (bool, error) {
|
||||
var names []string
|
||||
err := conn.BusObject().Call("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
|
||||
}
|
||||
|
||||
// getPhoneNum accepts a call using a DBus connection
|
||||
func acceptCall(conn *dbus.Conn, callObj dbus.BusObject) error {
|
||||
// Call Accept() method on DBus object
|
||||
call := callObj.Call("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(conn *dbus.Conn, callObj dbus.BusObject) error {
|
||||
// Call Hangup() method on DBus object
|
||||
call := callObj.Call("org.freedesktop.ModemManager1.Call.Hangup", 0)
|
||||
if call.Err != nil {
|
||||
return call.Err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,65 +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() {
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
cobra.CheckErr(rootCmd.Execute())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
79
cmd/itctl/firmware.go
Normal file
79
cmd/itctl/firmware.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cheggaaa/pb/v3"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func fwUpgrade(c *cli.Context) error {
|
||||
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(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 {
|
||||
// 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()
|
||||
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
|
||||
}
|
||||
165
cmd/itctl/fs.go
Normal file
165
cmd/itctl/fs.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"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.ReadDir(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)
|
||||
}
|
||||
|
||||
err := client.Mkdir(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.Rename(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 = ioutil.TempFile("/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.Download(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 {
|
||||
// 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)
|
||||
}
|
||||
|
||||
err := client.Remove(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 = ioutil.TempFile("/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.Upload(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 {
|
||||
// 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
71
cmd/itctl/get.go
Normal 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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(address)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBattery(c *cli.Context) error {
|
||||
battLevel, err := client.BatteryLevel()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print returned BPM
|
||||
fmt.Printf("%d Steps\n", stepCount)
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +1,257 @@
|
||||
/*
|
||||
* 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 (
|
||||
"os"
|
||||
|
||||
"go.arsenm.dev/itd/cmd/itctl/cmd"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
}
|
||||
var client *api.Client
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
app := cli.App{
|
||||
Name: "itctl",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "socket-path",
|
||||
Aliases: []string{"s"},
|
||||
Value: api.DefaultAddr,
|
||||
Usage: "Path to itd socket",
|
||||
},
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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: "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 {
|
||||
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.Run(os.Args)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Error while running app")
|
||||
}
|
||||
}
|
||||
|
||||
func catchSignal(fn func()) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
sigCh,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
go func() {
|
||||
<-sigCh
|
||||
fn()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
17
cmd/itctl/notify.go
Normal file
17
cmd/itctl/notify.go
Normal 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.Args().Get(0), c.Args().Get(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
24
cmd/itctl/set.go
Normal file
24
cmd/itctl/set.go
Normal 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(time.Now())
|
||||
} else {
|
||||
parsed, err := time.Parse(time.RFC3339, c.Args().Get(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.SetTime(parsed)
|
||||
}
|
||||
}
|
||||
7
cmd/itctl/update.go
Normal file
7
cmd/itctl/update.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func updateWeather(c *cli.Context) error {
|
||||
return client.WeatherUpdate()
|
||||
}
|
||||
104
cmd/itctl/watch.go
Normal file
104
cmd/itctl/watch.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func watchHeart(c *cli.Context) error {
|
||||
heartCh, cancel, err := client.WatchHeartRate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catchSignal(cancel)
|
||||
|
||||
for heartRate := range 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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchBattLevel(c *cli.Context) error {
|
||||
battLevelCh, cancel, err := client.WatchBatteryLevel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catchSignal(cancel)
|
||||
|
||||
for battLevel := range 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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchStepCount(c *cli.Context) error {
|
||||
stepCountCh, cancel, err := client.WatchStepCount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catchSignal(cancel)
|
||||
|
||||
for stepCount := range 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")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchMotion(c *cli.Context) error {
|
||||
motionCh, cancel, err := client.WatchMotion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catchSignal(cancel)
|
||||
|
||||
for motionVals := range 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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
53
cmd/itgui/error.go
Normal file
53
cmd/itgui/error.go
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
errLbl := widget.NewLabel(err.Error())
|
||||
// Create new dropdown containing error label
|
||||
content.Add(widget.NewAccordion(
|
||||
widget.NewAccordionItem("More Details", errLbl),
|
||||
))
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
123
cmd/itgui/info.go
Normal file
123
cmd/itgui/info.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func infoTab(parent fyne.Window, client *api.Client) *fyne.Container {
|
||||
infoLayout := container.NewVBox(
|
||||
// Add rectangle for a bit of padding
|
||||
canvas.NewRectangle(color.Transparent),
|
||||
)
|
||||
|
||||
// Create label for heart rate
|
||||
heartRateLbl := newText("0 BPM", 24)
|
||||
// Creae container to store heart rate section
|
||||
heartRateSect := container.NewVBox(
|
||||
newText("Heart Rate", 12),
|
||||
heartRateLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
infoLayout.Add(heartRateSect)
|
||||
|
||||
heartRateCh, cancel, err := client.WatchHeartRate()
|
||||
if err != nil {
|
||||
guiErr(err, "Error getting heart rate channel", true, parent)
|
||||
}
|
||||
onClose = append(onClose, cancel)
|
||||
go func() {
|
||||
for heartRate := range heartRateCh {
|
||||
// Change text of heart rate label
|
||||
heartRateLbl.Text = fmt.Sprintf("%d BPM", heartRate)
|
||||
// Refresh label
|
||||
heartRateLbl.Refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
// Create label for heart rate
|
||||
stepCountLbl := newText("0 Steps", 24)
|
||||
// Creae container to store heart rate section
|
||||
stepCountSect := container.NewVBox(
|
||||
newText("Step Count", 12),
|
||||
stepCountLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
infoLayout.Add(stepCountSect)
|
||||
|
||||
stepCountCh, cancel, err := client.WatchStepCount()
|
||||
if err != nil {
|
||||
guiErr(err, "Error getting step count channel", true, parent)
|
||||
}
|
||||
onClose = append(onClose, cancel)
|
||||
go func() {
|
||||
for stepCount := range stepCountCh {
|
||||
// Change text of heart rate label
|
||||
stepCountLbl.Text = fmt.Sprintf("%d Steps", stepCount)
|
||||
// Refresh label
|
||||
stepCountLbl.Refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
// Create label for battery level
|
||||
battLevelLbl := newText("0%", 24)
|
||||
// Create container to store battery level section
|
||||
battLevel := container.NewVBox(
|
||||
newText("Battery Level", 12),
|
||||
battLevelLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
infoLayout.Add(battLevel)
|
||||
|
||||
battLevelCh, cancel, err := client.WatchBatteryLevel()
|
||||
if err != nil {
|
||||
guiErr(err, "Error getting battery level channel", true, parent)
|
||||
}
|
||||
onClose = append(onClose, cancel)
|
||||
go func() {
|
||||
for battLevel := range battLevelCh {
|
||||
// Change text of battery level label
|
||||
battLevelLbl.Text = fmt.Sprintf("%d%%", battLevel)
|
||||
// Refresh label
|
||||
battLevelLbl.Refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
fwVerString, err := client.Version()
|
||||
if err != nil {
|
||||
guiErr(err, "Error getting firmware string", true, parent)
|
||||
}
|
||||
|
||||
fwVer := container.NewVBox(
|
||||
newText("Firmware Version", 12),
|
||||
newText(fwVerString, 24),
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
infoLayout.Add(fwVer)
|
||||
|
||||
btAddrString, err := client.Address()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
btAddr := container.NewVBox(
|
||||
newText("Bluetooth Address", 12),
|
||||
newText(btAddrString, 24),
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
infoLayout.Add(btAddr)
|
||||
|
||||
return infoLayout
|
||||
}
|
||||
|
||||
func newText(t string, size float32) *canvas.Text {
|
||||
text := canvas.NewText(t, theme.ForegroundColor())
|
||||
text.TextSize = size
|
||||
return text
|
||||
}
|
||||
43
cmd/itgui/main.go
Normal file
43
cmd/itgui/main.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
var onClose []func()
|
||||
|
||||
func main() {
|
||||
// Create new app
|
||||
a := app.New()
|
||||
// Create new window with title "itgui"
|
||||
window := a.NewWindow("itgui")
|
||||
window.SetOnClosed(func() {
|
||||
for _, closeFn := range onClose {
|
||||
closeFn()
|
||||
}
|
||||
})
|
||||
|
||||
client, err := api.New(api.DefaultAddr)
|
||||
if err != nil {
|
||||
guiErr(err, "Error connecting to itd", true, window)
|
||||
}
|
||||
onClose = append(onClose, func() {
|
||||
client.Close()
|
||||
})
|
||||
|
||||
// Create new app tabs container
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItem("Info", infoTab(window, client)),
|
||||
container.NewTabItem("Motion", motionTab(window, client)),
|
||||
container.NewTabItem("Notify", notifyTab(window, client)),
|
||||
container.NewTabItem("Set Time", timeTab(window, client)),
|
||||
container.NewTabItem("Upgrade", upgradeTab(window, client)),
|
||||
)
|
||||
|
||||
// Set tabs as window content
|
||||
window.SetContent(tabs)
|
||||
// Show window and run app
|
||||
window.ShowAndRun()
|
||||
}
|
||||
105
cmd/itgui/motion.go
Normal file
105
cmd/itgui/motion.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strconv"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func motionTab(parent fyne.Window, client *api.Client) *fyne.Container {
|
||||
// Create label for heart rate
|
||||
xCoordLbl := newText("0", 24)
|
||||
// Creae container to store heart rate section
|
||||
xCoordSect := container.NewVBox(
|
||||
newText("X Coordinate", 12),
|
||||
xCoordLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
|
||||
// Create label for heart rate
|
||||
yCoordLbl := newText("0", 24)
|
||||
// Creae container to store heart rate section
|
||||
yCoordSect := container.NewVBox(
|
||||
newText("Y Coordinate", 12),
|
||||
yCoordLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
// Create label for heart rate
|
||||
zCoordLbl := newText("0", 24)
|
||||
// Creae container to store heart rate section
|
||||
zCoordSect := container.NewVBox(
|
||||
newText("Z Coordinate", 12),
|
||||
zCoordLbl,
|
||||
canvas.NewLine(theme.ShadowColor()),
|
||||
)
|
||||
|
||||
// Create variable to keep track of whether motion started
|
||||
started := false
|
||||
|
||||
// Create button to stop motion
|
||||
stopBtn := widget.NewButton("Stop", nil)
|
||||
// Create button to start motion
|
||||
startBtn := widget.NewButton("Start", func() {
|
||||
// if motion is started
|
||||
if started {
|
||||
// Do nothing
|
||||
return
|
||||
}
|
||||
// Set motion started
|
||||
started = true
|
||||
// Watch motion values
|
||||
motionCh, cancel, err := client.WatchMotion()
|
||||
if err != nil {
|
||||
guiErr(err, "Error getting heart rate channel", true, parent)
|
||||
}
|
||||
// Create done channel
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case motion := <-motionCh:
|
||||
// Set labels to new values
|
||||
xCoordLbl.Text = strconv.Itoa(int(motion.X))
|
||||
yCoordLbl.Text = strconv.Itoa(int(motion.Y))
|
||||
zCoordLbl.Text = strconv.Itoa(int(motion.Z))
|
||||
// Refresh labels to display new values
|
||||
xCoordLbl.Refresh()
|
||||
yCoordLbl.Refresh()
|
||||
zCoordLbl.Refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Create stop function
|
||||
stopBtn.OnTapped = func() {
|
||||
done <- struct{}{}
|
||||
started = false
|
||||
cancel()
|
||||
}
|
||||
|
||||
})
|
||||
// Run stop button function on close if possible
|
||||
onClose = append(onClose, func() {
|
||||
if stopBtn.OnTapped != nil {
|
||||
stopBtn.OnTapped()
|
||||
}
|
||||
})
|
||||
|
||||
// Return new container containing all elements
|
||||
return container.NewVBox(
|
||||
// Add rectangle for a bit of padding
|
||||
canvas.NewRectangle(color.Transparent),
|
||||
startBtn,
|
||||
stopBtn,
|
||||
xCoordSect,
|
||||
yCoordSect,
|
||||
zCoordSect,
|
||||
)
|
||||
}
|
||||
37
cmd/itgui/notify.go
Normal file
37
cmd/itgui/notify.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func notifyTab(parent fyne.Window, client *api.Client) *fyne.Container {
|
||||
// Create new entry for notification title
|
||||
titleEntry := widget.NewEntry()
|
||||
titleEntry.SetPlaceHolder("Title")
|
||||
|
||||
// Create multiline entry for notification body
|
||||
bodyEntry := widget.NewMultiLineEntry()
|
||||
bodyEntry.SetPlaceHolder("Body")
|
||||
|
||||
// Create new button to send notification
|
||||
sendBtn := widget.NewButton("Send", func() {
|
||||
err := client.Notify(titleEntry.Text, bodyEntry.Text)
|
||||
if err != nil {
|
||||
guiErr(err, "Error sending notification", false, parent)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Return new container containing all elements
|
||||
return container.NewVBox(
|
||||
layout.NewSpacer(),
|
||||
titleEntry,
|
||||
bodyEntry,
|
||||
sendBtn,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
}
|
||||
60
cmd/itgui/time.go
Normal file
60
cmd/itgui/time.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func timeTab(parent fyne.Window, client *api.Client) *fyne.Container {
|
||||
// Create new entry for time string
|
||||
timeEntry := widget.NewEntry()
|
||||
// Set text to current time formatter properly
|
||||
timeEntry.SetText(time.Now().Format(time.RFC1123))
|
||||
|
||||
// Create button to set current time
|
||||
currentBtn := widget.NewButton("Set Current", func() {
|
||||
timeEntry.SetText(time.Now().Format(time.RFC1123))
|
||||
setTime(client, true)
|
||||
})
|
||||
|
||||
// Create button to set time inside entry
|
||||
timeBtn := widget.NewButton("Set", func() {
|
||||
// Parse time as RFC1123 string
|
||||
parsedTime, err := time.Parse(time.RFC1123, timeEntry.Text)
|
||||
if err != nil {
|
||||
guiErr(err, "Error parsing time string", false, parent)
|
||||
return
|
||||
}
|
||||
// Set time to parsed time
|
||||
setTime(client, false, parsedTime)
|
||||
})
|
||||
|
||||
// Return new container with all elements centered
|
||||
return container.NewVBox(
|
||||
layout.NewSpacer(),
|
||||
timeEntry,
|
||||
currentBtn,
|
||||
timeBtn,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
}
|
||||
|
||||
// setTime sets the first element in the variadic parameter
|
||||
// if current is false, otherwise, it sets the current time.
|
||||
func setTime(client *api.Client, current bool, t ...time.Time) error {
|
||||
var err error
|
||||
if current {
|
||||
err = client.SetTime(time.Now())
|
||||
} else {
|
||||
err = client.SetTime(t[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
180
cmd/itgui/upgrade.go
Normal file
180
cmd/itgui/upgrade.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"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.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
func upgradeTab(parent fyne.Window, client *api.Client) *fyne.Container {
|
||||
var (
|
||||
archivePath string
|
||||
firmwarePath string
|
||||
initPktPath string
|
||||
)
|
||||
|
||||
var archiveBtn *widget.Button
|
||||
// Create archive selection dialog
|
||||
archiveDialog := dialog.NewFileOpen(func(uc fyne.URIReadCloser, e error) {
|
||||
if e != nil || uc == nil {
|
||||
return
|
||||
}
|
||||
uc.Close()
|
||||
archivePath = uc.URI().Path()
|
||||
archiveBtn.SetText(fmt.Sprintf("Select archive (.zip) [%s]", filepath.Base(archivePath)))
|
||||
}, parent)
|
||||
// Limit dialog to .zip files
|
||||
archiveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".zip"}))
|
||||
// Create button to show dialog
|
||||
archiveBtn = widget.NewButton("Select archive (.zip)", archiveDialog.Show)
|
||||
|
||||
var firmwareBtn *widget.Button
|
||||
// Create firmware selection dialog
|
||||
firmwareDialog := dialog.NewFileOpen(func(uc fyne.URIReadCloser, e error) {
|
||||
if e != nil || uc == nil {
|
||||
return
|
||||
}
|
||||
uc.Close()
|
||||
firmwarePath = uc.URI().Path()
|
||||
firmwareBtn.SetText(fmt.Sprintf("Select firmware (.bin) [%s]", filepath.Base(firmwarePath)))
|
||||
}, parent)
|
||||
// Limit dialog to .bin files
|
||||
firmwareDialog.SetFilter(storage.NewExtensionFileFilter([]string{".bin"}))
|
||||
// Create button to show dialog
|
||||
firmwareBtn = widget.NewButton("Select firmware (.bin)", firmwareDialog.Show)
|
||||
|
||||
var initPktBtn *widget.Button
|
||||
// Create init packet selection dialog
|
||||
initPktDialog := dialog.NewFileOpen(func(uc fyne.URIReadCloser, e error) {
|
||||
if e != nil || uc == nil {
|
||||
return
|
||||
}
|
||||
uc.Close()
|
||||
initPktPath = uc.URI().Path()
|
||||
initPktBtn.SetText(fmt.Sprintf("Select init packet (.dat) [%s]", filepath.Base(initPktPath)))
|
||||
}, parent)
|
||||
// Limit dialog to .dat files
|
||||
initPktDialog.SetFilter(storage.NewExtensionFileFilter([]string{".dat"}))
|
||||
// Create button to show dialog
|
||||
initPktBtn = widget.NewButton("Select init packet (.dat)", initPktDialog.Show)
|
||||
|
||||
// Hide init packet and firmware buttons
|
||||
initPktBtn.Hide()
|
||||
firmwareBtn.Hide()
|
||||
|
||||
// Create dropdown to select upgrade type
|
||||
upgradeTypeSelect := widget.NewSelect([]string{
|
||||
"Archive",
|
||||
"Files",
|
||||
}, func(s string) {
|
||||
// Hide all buttons
|
||||
archiveBtn.Hide()
|
||||
initPktBtn.Hide()
|
||||
firmwareBtn.Hide()
|
||||
// Unhide appropriate button(s)
|
||||
switch s {
|
||||
case "Archive":
|
||||
archiveBtn.Show()
|
||||
case "Files":
|
||||
initPktBtn.Show()
|
||||
firmwareBtn.Show()
|
||||
}
|
||||
})
|
||||
// Select first elemetn
|
||||
upgradeTypeSelect.SetSelectedIndex(0)
|
||||
|
||||
// Create new button to start DFU
|
||||
startBtn := widget.NewButton("Start", func() {
|
||||
// If archive path does not exist and both init packet and firmware paths
|
||||
// also do not exist, return error
|
||||
if archivePath == "" && (initPktPath == "" && firmwarePath == "") {
|
||||
guiErr(nil, "Upgrade requires archive or files selected", false, parent)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new label for byte progress
|
||||
progressLbl := widget.NewLabelWithStyle("0 / 0 B", fyne.TextAlignCenter, fyne.TextStyle{})
|
||||
// Create new progress bar
|
||||
progressBar := widget.NewProgressBar()
|
||||
// Create modal dialog containing label and progress bar
|
||||
progressDlg := widget.NewModalPopUp(container.NewVBox(
|
||||
layout.NewSpacer(),
|
||||
progressLbl,
|
||||
progressBar,
|
||||
layout.NewSpacer(),
|
||||
), parent.Canvas())
|
||||
// Resize modal to 300x100
|
||||
progressDlg.Resize(fyne.NewSize(300, 100))
|
||||
|
||||
var fwUpgType api.UpgradeType
|
||||
var files []string
|
||||
// Get appropriate upgrade type and file paths
|
||||
switch upgradeTypeSelect.Selected {
|
||||
case "Archive":
|
||||
fwUpgType = api.UpgradeTypeArchive
|
||||
files = append(files, archivePath)
|
||||
case "Files":
|
||||
fwUpgType = api.UpgradeTypeFiles
|
||||
files = append(files, initPktPath, firmwarePath)
|
||||
}
|
||||
|
||||
progress, err := client.FirmwareUpgrade(fwUpgType, files...)
|
||||
if err != nil {
|
||||
guiErr(err, "Error initiating DFU", false, parent)
|
||||
return
|
||||
}
|
||||
|
||||
// Show progress dialog
|
||||
progressDlg.Show()
|
||||
|
||||
for event := range progress {
|
||||
// Set label text to received / total B
|
||||
progressLbl.SetText(fmt.Sprintf("%d / %d B", event.Received, event.Total))
|
||||
// Set progress bar values
|
||||
progressBar.Max = float64(event.Total)
|
||||
progressBar.Value = float64(event.Received)
|
||||
// Refresh progress bar
|
||||
progressBar.Refresh()
|
||||
// If transfer finished, break
|
||||
if int64(event.Sent) == event.Total {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Hide progress dialog after completion
|
||||
progressDlg.Hide()
|
||||
|
||||
// Reset screen to default
|
||||
upgradeTypeSelect.SetSelectedIndex(0)
|
||||
firmwareBtn.SetText("Select firmware (.bin)")
|
||||
initPktBtn.SetText("Select init packet (.dat)")
|
||||
archiveBtn.SetText("Select archive (.zip)")
|
||||
firmwarePath = ""
|
||||
initPktPath = ""
|
||||
archivePath = ""
|
||||
|
||||
dialog.NewInformation(
|
||||
"Upgrade Complete",
|
||||
"The firmware was transferred successfully.\nRemember to validate the firmware in InfiniTime settings.",
|
||||
parent,
|
||||
).Show()
|
||||
})
|
||||
|
||||
// Return container containing all elements
|
||||
return container.NewVBox(
|
||||
layout.NewSpacer(),
|
||||
upgradeTypeSelect,
|
||||
archiveBtn,
|
||||
firmwareBtn,
|
||||
initPktBtn,
|
||||
startBtn,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
}
|
||||
79
config.go
Normal file
79
config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up logger
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
// Get user's configuration directory
|
||||
cfgDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set config defaults
|
||||
setCfgDefaults()
|
||||
|
||||
// Load config files
|
||||
etcProvider := file.Provider("/etc/itd.toml")
|
||||
cfgProvider := file.Provider(filepath.Join(cfgDir, "itd.toml"))
|
||||
k.Load(etcProvider, toml.Parser())
|
||||
k.Load(cfgProvider, toml.Parser())
|
||||
|
||||
// Watch configs for changes
|
||||
cfgWatch(etcProvider)
|
||||
cfgWatch(cfgProvider)
|
||||
|
||||
// Load envireonment variables
|
||||
k.Load(env.Provider("ITD_", "_", func(s string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(s, "ITD_"))
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func cfgWatch(provider *file.File) {
|
||||
// Watch for changes and reload when detected
|
||||
provider.Watch(func(_ interface{}, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
k.Load(provider, toml.Parser())
|
||||
})
|
||||
}
|
||||
|
||||
func setCfgDefaults() {
|
||||
k.Load(confmap.Provider(map[string]interface{}{
|
||||
"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,
|
||||
}, "."), nil)
|
||||
}
|
||||
37
dbus.go
Normal file
37
dbus.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import "github.com/godbus/dbus/v5"
|
||||
|
||||
func newSystemBusConn() (*dbus.Conn, error) {
|
||||
// Connect to dbus session bus
|
||||
conn, err := dbus.SystemBusPrivate()
|
||||
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() (*dbus.Conn, error) {
|
||||
// Connect to dbus session bus
|
||||
conn, err := dbus.SessionBusPrivate()
|
||||
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
|
||||
}
|
||||
133
go.mod
133
go.mod
@@ -1,25 +1,122 @@
|
||||
module go.arsenm.dev/itd
|
||||
module gitea.arsenm.dev/cpyarger/itd
|
||||
|
||||
go 1.16
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.1.2
|
||||
github.com/cheggaaa/pb/v3 v3.0.8
|
||||
github.com/gen2brain/dlgs v0.0.0-20211108104213-bade24837f0b
|
||||
github.com/godbus/dbus/v5 v5.0.6
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/knadh/koanf v1.4.0
|
||||
github.com/mattn/go-isatty v0.0.14
|
||||
github.com/mozillazg/go-pinyin v0.19.0
|
||||
github.com/rs/zerolog v1.26.1
|
||||
github.com/smallnest/rpcx v1.7.4
|
||||
github.com/urfave/cli/v2 v2.3.0
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5
|
||||
gitea.arsenm.dev/Arsen6331/infinitime v0.0.0-20220424030849-6c3f1b14c948
|
||||
golang.org/x/text v0.3.7
|
||||
)
|
||||
|
||||
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/akutz/memconn v0.1.0 // indirect
|
||||
github.com/apache/thrift v0.16.0 // indirect
|
||||
github.com/armon/go-metrics v0.3.10 // indirect
|
||||
github.com/cenk/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cheekybits/genny v1.0.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-jump v0.0.0-20211018200510-ba001c3ffce0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/edwingeng/doublejump v0.0.0-20210724020454-c82f1bcb3280 // indirect
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/fredbi/uri v0.0.0-20181227131451-3dcfdacbaaf3 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20211204153444-caad923f49f4 // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ping/ping v0.0.0-20211130115550-779d1e919534 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/hashicorp/consul/api v1.12.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-hclog v1.2.0 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/hashicorp/serf v0.9.7 // indirect
|
||||
github.com/juju/ratelimit v1.0.1 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||
github.com/kavu/go_reuseport v1.5.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
|
||||
github.com/klauspost/reedsolomon v1.9.16 // indirect
|
||||
github.com/lucas-clemente/quic-go v0.27.0 // indirect
|
||||
github.com/marten-seemann/qtls-go1-16 v0.1.5 // indirect
|
||||
github.com/marten-seemann/qtls-go1-17 v0.1.1 // indirect
|
||||
github.com/marten-seemann/qtls-go1-18 v0.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // 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/miekg/dns v1.1.48 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/muka/go-bluetooth v0.0.0-20220219050759-674a63b8741a // indirect
|
||||
github.com/nxadm/tail v1.4.8 // indirect
|
||||
github.com/onsi/ginkgo v1.16.5 // indirect
|
||||
github.com/pelletier/go-toml v1.9.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rpcxio/libkv v0.5.1-0.20210420120011-1fceaedca8a5 // indirect
|
||||
github.com/rs/cors v1.8.2 // indirect
|
||||
github.com/rubyist/circuitbreaker v2.2.1+incompatible // indirect
|
||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||
github.com/samuel/go-zookeeper v0.0.0-20201211165307-7117e9ea2414 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
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-20210822201216-955384489609
|
||||
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
github.com/smallnest/quick v0.0.0-20220103065406-780def6371e6 // indirect
|
||||
github.com/soheilhy/cmux v0.1.5 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20211120171407-1837d6608d8c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 // indirect
|
||||
github.com/stretchr/testify v1.7.1 // indirect
|
||||
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect
|
||||
github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect
|
||||
github.com/tinylib/msgp v1.1.6 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xtaci/kcp-go v5.4.20+incompatible // indirect
|
||||
github.com/yuin/goldmark v1.4.4 // indirect
|
||||
go.opentelemetry.io/otel v1.6.3 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.6.3 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 // indirect
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
|
||||
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect
|
||||
golang.org/x/tools v0.1.10 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
30
itd.toml
30
itd.toml
@@ -1,14 +1,34 @@
|
||||
[socket]
|
||||
path = "/tmp/itd/socket"
|
||||
|
||||
[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"
|
||||
126
main.go
126
main.go
@@ -19,65 +19,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gen2brain/dlgs"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"gitea.arsenm.dev/cpyarger/itd"
|
||||
)
|
||||
|
||||
var firmwareUpdating = false
|
||||
var k = koanf.New(".")
|
||||
|
||||
func init() {
|
||||
// Set up logger
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
// 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() {
|
||||
showVer := flag.Bool("version", false, "Show version number and exit")
|
||||
flag.Parse()
|
||||
// If version requested, print and exit
|
||||
if *showVer {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := zerolog.ParseLevel(k.String("logging.level"))
|
||||
if err != nil || level == zerolog.NoLevel {
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
// Initialize infinitime library
|
||||
infinitime.Init()
|
||||
// Cleanly exit after function
|
||||
defer infinitime.Exit()
|
||||
|
||||
// Create infinitime options struct
|
||||
opts := &infinitime.Options{
|
||||
AttemptReconnect: k.Bool("conn.reconnect"),
|
||||
WhitelistEnabled: k.Bool("conn.whitelist.enabled"),
|
||||
Whitelist: k.Strings("conn.whitelist.devices"),
|
||||
OnReqPasskey: onReqPasskey,
|
||||
Logger: log.Logger,
|
||||
LogLevel: level,
|
||||
}
|
||||
|
||||
// Connect to InfiniTime with default options
|
||||
dev, err := infinitime.Connect(&infinitime.Options{
|
||||
AttemptReconnect: viper.GetBool("conn.reconnect"),
|
||||
})
|
||||
dev, err := infinitime.Connect(opts)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error connecting to InfiniTime")
|
||||
log.Fatal().Err(err).Msg("Error connecting to InfiniTime")
|
||||
}
|
||||
|
||||
// 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")
|
||||
opts.OnReconnect = func() {
|
||||
if k.Bool("on.reconnect.setTime") {
|
||||
// Set time to current time
|
||||
err = dev.SetTime(time.Now())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If config specifies to notify on reconnect
|
||||
if viper.GetBool("notify.onReconnect") {
|
||||
if k.Bool("on.reconnect.notify") {
|
||||
// Send notification to InfiniTime
|
||||
err = dev.Notify("itd", "Successfully reconnected")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error sending notification to InfiniTime")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// FS must be updated on reconnect
|
||||
updateFS = true
|
||||
// Resend weather on reconnect
|
||||
sendWeatherCh <- struct{}{}
|
||||
}
|
||||
|
||||
// Get firmware version
|
||||
ver, err := dev.Version()
|
||||
@@ -89,7 +115,7 @@ func main() {
|
||||
log.Info().Str("version", ver).Msg("Connected to InfiniTime")
|
||||
|
||||
// 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 {
|
||||
@@ -109,12 +135,24 @@ func main() {
|
||||
log.Error().Err(err).Msg("Error initializing music control")
|
||||
}
|
||||
|
||||
// Start control socket
|
||||
err = initCallNotifs(dev)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error initializing call notifications")
|
||||
}
|
||||
|
||||
// Initialize notification relay
|
||||
err = initNotifRelay(dev)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error initializing notification relay")
|
||||
}
|
||||
|
||||
// Initializa weather
|
||||
err = initWeather(dev)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error initializing weather")
|
||||
}
|
||||
|
||||
// Start control socket
|
||||
err = startSocket(dev)
|
||||
if err != nil {
|
||||
@@ -124,3 +162,25 @@ func main() {
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
57
music.go
57
music.go
@@ -19,52 +19,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"go.arsenm.dev/infinitime/pkg/player"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"go.arsenm.dev/itd/translit"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
player.Init()
|
||||
|
||||
// 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) {
|
||||
player.OnChange(func(ct player.ChangeType, val string) {
|
||||
newVal := translit.Transliterate(val, maps...)
|
||||
if !firmwareUpdating {
|
||||
dev.Music.SetAlbum(newAlbum)
|
||||
switch ct {
|
||||
case player.ChangeTypeStatus:
|
||||
dev.Music.SetStatus(val == "Playing")
|
||||
case player.ChangeTypeTitle:
|
||||
dev.Music.SetTrack(newVal)
|
||||
case player.ChangeTypeAlbum:
|
||||
dev.Music.SetAlbum(newVal)
|
||||
case player.ChangeTypeArtist:
|
||||
dev.Music.SetArtist(newVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
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()
|
||||
@@ -85,9 +66,9 @@ func initMusicCtrl(dev *infinitime.Device) error {
|
||||
case infinitime.MusicEventPrev:
|
||||
player.Prev()
|
||||
case infinitime.MusicEventVolUp:
|
||||
player.VolUp(viper.GetUint("music.volInterval"))
|
||||
player.VolUp(uint(k.Int("music.vol.interval")))
|
||||
case infinitime.MusicEventVolDown:
|
||||
player.VolDown(viper.GetUint("music.volInterval"))
|
||||
player.VolDown(uint(k.Int("music.vol.interval")))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
16
notifs.go
16
notifs.go
@@ -23,13 +23,13 @@ import (
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"go.arsenm.dev/itd/translit"
|
||||
)
|
||||
|
||||
func initNotifRelay(dev *infinitime.Device) error {
|
||||
// Connect to dbus session bus
|
||||
bus, err := dbus.SessionBus()
|
||||
bus, err := newSessionBusConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,6 +71,12 @@ func initNotifRelay(dev *infinitime.Device) error {
|
||||
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
|
||||
@@ -90,9 +96,9 @@ func initNotifRelay(dev *infinitime.Device) error {
|
||||
|
||||
// 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)
|
||||
|
||||
861
socket.go
861
socket.go
@@ -19,279 +19,670 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/smallnest/rpcx/server"
|
||||
"github.com/smallnest/rpcx/share"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"go.arsenm.dev/itd/internal/types"
|
||||
"go.arsenm.dev/infinitime/blefs"
|
||||
"go.arsenm.dev/itd/api"
|
||||
)
|
||||
|
||||
const SockPath = "/tmp/itd/socket"
|
||||
// This type signifies an unneeded value.
|
||||
// A struct{} is used as it takes no space in memory.
|
||||
// This exists for readability purposes
|
||||
type none = struct{}
|
||||
|
||||
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")
|
||||
ErrRPCXNoReturnURL = errors.New("bidirectional requests over gateway require a returnURL field in the metadata")
|
||||
)
|
||||
|
||||
const (
|
||||
UpgradeTypeArchive = iota
|
||||
UpgradeTypeFiles
|
||||
)
|
||||
type DoneMap map[string]chan struct{}
|
||||
|
||||
func (dm DoneMap) Exists(key string) bool {
|
||||
_, ok := dm[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (dm DoneMap) Done(key string) {
|
||||
ch := dm[key]
|
||||
ch <- struct{}{}
|
||||
}
|
||||
|
||||
func (dm DoneMap) Create(key string) {
|
||||
dm[key] = make(chan struct{}, 1)
|
||||
}
|
||||
|
||||
func (dm DoneMap) Remove(key string) {
|
||||
close(dm[key])
|
||||
delete(dm, key)
|
||||
}
|
||||
|
||||
var done = DoneMap{}
|
||||
|
||||
func startSocket(dev *infinitime.Device) error {
|
||||
// Make socket directory if non-existent
|
||||
err := os.MkdirAll(filepath.Dir(SockPath), 0755)
|
||||
// Make socket directory if non-existant
|
||||
err := os.MkdirAll(filepath.Dir(k.String("socket.path")), 0755)
|
||||
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
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
// Accept socket connection
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error accepting connection")
|
||||
}
|
||||
fs, err := dev.FS()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Error getting BLE filesystem")
|
||||
}
|
||||
|
||||
// Concurrently handle connection
|
||||
go handleConnection(conn, dev)
|
||||
}
|
||||
}()
|
||||
srv := server.NewServer()
|
||||
|
||||
itdAPI := &ITD{
|
||||
dev: dev,
|
||||
srv: srv,
|
||||
}
|
||||
err = srv.Register(itdAPI, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fsAPI := &FS{
|
||||
dev: dev,
|
||||
fs: fs,
|
||||
srv: srv,
|
||||
}
|
||||
err = srv.Register(fsAPI, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go srv.ServeListener("tcp", ln)
|
||||
|
||||
// Log socket start
|
||||
log.Info().Str("path", SockPath).Msg("Started control socket")
|
||||
log.Info().Str("path", k.String("socket.path")).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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
connErr(conn, err, "Error decoding JSON input")
|
||||
continue
|
||||
}
|
||||
|
||||
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 request")
|
||||
break
|
||||
}
|
||||
var reqData types.ReqDataNotify
|
||||
// Decode data map to notify request data
|
||||
err = mapstructure.Decode(req.Data, &reqData)
|
||||
if err != nil {
|
||||
connErr(conn, err, "Error decoding 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 request")
|
||||
break
|
||||
}
|
||||
// Get string from data or return error
|
||||
reqTimeStr, ok := req.Data.(string)
|
||||
if !ok {
|
||||
connErr(conn, nil, "Data for settime request must be RFC3339 formatted time string")
|
||||
break
|
||||
}
|
||||
|
||||
var reqTime time.Time
|
||||
if reqTimeStr == "now" {
|
||||
reqTime = time.Now()
|
||||
} else {
|
||||
// Parse time as RFC3339/ISO8601
|
||||
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 request")
|
||||
break
|
||||
}
|
||||
var reqData types.ReqDataFwUpgrade
|
||||
// Decode data map to firmware upgrade request data
|
||||
err = mapstructure.Decode(req.Data, &reqData)
|
||||
if err != nil {
|
||||
connErr(conn, err, "Error decoding 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")
|
||||
firmwareUpdating = false
|
||||
break
|
||||
}
|
||||
firmwareUpdating = false
|
||||
}
|
||||
}
|
||||
type ITD struct {
|
||||
dev *infinitime.Device
|
||||
srv *server.Server
|
||||
}
|
||||
|
||||
func connErr(conn net.Conn, err error, msg string) {
|
||||
var res types.Response
|
||||
// If error exists, add to types.Response, otherwise don't
|
||||
func (i *ITD) HeartRate(_ context.Context, _ none, out *uint8) error {
|
||||
heartRate, err := i.dev.HeartRate()
|
||||
*out = heartRate
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *ITD) WatchHeartRate(ctx context.Context, _ none, out *string) error {
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, i.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if !ok {
|
||||
return ErrRPCXNoReturnURL
|
||||
}
|
||||
|
||||
heartRateCh, cancel, err := i.dev.WatchHeartRate()
|
||||
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)
|
||||
id := uuid.New().String()
|
||||
go func() {
|
||||
done.Create(id)
|
||||
// For every heart rate value
|
||||
for heartRate := range heartRateCh {
|
||||
select {
|
||||
case <-done[id]:
|
||||
// Stop notifications if done signal received
|
||||
cancel()
|
||||
done.Remove(id)
|
||||
return
|
||||
default:
|
||||
data, err := msgpack.Marshal(heartRate)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding heart rate")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to connection if no done signal received
|
||||
msgSender.SendMessage(id, "HeartRateSample", nil, data)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
*out = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) BatteryLevel(_ context.Context, _ none, out *uint8) error {
|
||||
battLevel, err := i.dev.BatteryLevel()
|
||||
*out = battLevel
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *ITD) WatchBatteryLevel(ctx context.Context, _ none, out *string) error {
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, i.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if !ok {
|
||||
return ErrRPCXNoReturnURL
|
||||
}
|
||||
|
||||
battLevelCh, cancel, err := i.dev.WatchBatteryLevel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
go func() {
|
||||
done.Create(id)
|
||||
// For every heart rate value
|
||||
for battLevel := range battLevelCh {
|
||||
select {
|
||||
case <-done[id]:
|
||||
// Stop notifications if done signal received
|
||||
cancel()
|
||||
done.Remove(id)
|
||||
return
|
||||
default:
|
||||
data, err := msgpack.Marshal(battLevel)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding battery level")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to connection if no done signal received
|
||||
msgSender.SendMessage(id, "BatteryLevelSample", nil, data)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
*out = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) Motion(_ context.Context, _ none, out *infinitime.MotionValues) error {
|
||||
motionVals, err := i.dev.Motion()
|
||||
*out = motionVals
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *ITD) WatchMotion(ctx context.Context, _ none, out *string) error {
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, i.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if !ok {
|
||||
return ErrRPCXNoReturnURL
|
||||
}
|
||||
|
||||
motionValsCh, cancel, err := i.dev.WatchMotion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
go func() {
|
||||
done.Create(id)
|
||||
// For every heart rate value
|
||||
for motionVals := range motionValsCh {
|
||||
select {
|
||||
case <-done[id]:
|
||||
// Stop notifications if done signal received
|
||||
cancel()
|
||||
done.Remove(id)
|
||||
return
|
||||
default:
|
||||
data, err := msgpack.Marshal(motionVals)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding motion values")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to connection if no done signal received
|
||||
msgSender.SendMessage(id, "MotionSample", nil, data)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
*out = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) StepCount(_ context.Context, _ none, out *uint32) error {
|
||||
stepCount, err := i.dev.StepCount()
|
||||
*out = stepCount
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *ITD) WatchStepCount(ctx context.Context, _ none, out *string) error {
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, i.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if !ok {
|
||||
return ErrRPCXNoReturnURL
|
||||
}
|
||||
|
||||
stepCountCh, cancel, err := i.dev.WatchStepCount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
go func() {
|
||||
done.Create(id)
|
||||
// For every heart rate value
|
||||
for stepCount := range stepCountCh {
|
||||
select {
|
||||
case <-done[id]:
|
||||
// Stop notifications if done signal received
|
||||
cancel()
|
||||
done.Remove(id)
|
||||
return
|
||||
default:
|
||||
data, err := msgpack.Marshal(stepCount)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding step count")
|
||||
continue
|
||||
}
|
||||
|
||||
// Send response to connection if no done signal received
|
||||
msgSender.SendMessage(id, "StepCountSample", nil, data)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
*out = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) Version(_ context.Context, _ none, out *string) error {
|
||||
version, err := i.dev.Version()
|
||||
*out = version
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *ITD) Address(_ context.Context, _ none, out *string) error {
|
||||
addr := i.dev.Address()
|
||||
*out = addr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) Notify(_ context.Context, data api.NotifyData, _ *none) error {
|
||||
return i.dev.Notify(data.Title, data.Body)
|
||||
}
|
||||
|
||||
func (i *ITD) SetTime(_ context.Context, t time.Time, _ *none) error {
|
||||
return i.dev.SetTime(t)
|
||||
}
|
||||
|
||||
func (i *ITD) WeatherUpdate(_ context.Context, _ none, _ *none) error {
|
||||
sendWeatherCh <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) FirmwareUpgrade(ctx context.Context, reqData api.FwUpgradeData, out *string) error {
|
||||
i.dev.DFU.Reset()
|
||||
|
||||
switch reqData.Type {
|
||||
case api.UpgradeTypeArchive:
|
||||
// If less than one file, return error
|
||||
if len(reqData.Files) < 1 {
|
||||
return ErrDFUNotEnoughFiles
|
||||
}
|
||||
// If file is not zip archive, return error
|
||||
if filepath.Ext(reqData.Files[0]) != ".zip" {
|
||||
return ErrDFUInvalidFile
|
||||
}
|
||||
// Load DFU archive
|
||||
err := i.dev.DFU.LoadArchive(reqData.Files[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case api.UpgradeTypeFiles:
|
||||
// If less than two files, return error
|
||||
if len(reqData.Files) < 2 {
|
||||
return ErrDFUNotEnoughFiles
|
||||
}
|
||||
// If first file is not init packet, return error
|
||||
if filepath.Ext(reqData.Files[0]) != ".dat" {
|
||||
return ErrDFUInvalidFile
|
||||
}
|
||||
// If second file is not firmware image, return error
|
||||
if filepath.Ext(reqData.Files[1]) != ".bin" {
|
||||
return ErrDFUInvalidFile
|
||||
}
|
||||
// Load individual DFU files
|
||||
err := i.dev.DFU.LoadFiles(reqData.Files[0], reqData.Files[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrDFUInvalidUpgType
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
*out = id
|
||||
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, i.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if ok {
|
||||
go func() {
|
||||
// For every progress event
|
||||
for event := range i.dev.DFU.Progress() {
|
||||
data, err := msgpack.Marshal(event)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding DFU progress event")
|
||||
continue
|
||||
}
|
||||
|
||||
msgSender.SendMessage(id, "DFUProgress", nil, data)
|
||||
}
|
||||
|
||||
firmwareUpdating = false
|
||||
msgSender.SendMessage(id, "Done", nil, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
// Set firmwareUpdating
|
||||
firmwareUpdating = true
|
||||
|
||||
go func() {
|
||||
// Start DFU
|
||||
err := i.dev.DFU.Start()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error while upgrading firmware")
|
||||
firmwareUpdating = false
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ITD) Done(_ context.Context, id string, _ *none) error {
|
||||
done.Done(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type FS struct {
|
||||
dev *infinitime.Device
|
||||
fs *blefs.FS
|
||||
srv *server.Server
|
||||
}
|
||||
|
||||
func (fs *FS) Remove(_ context.Context, paths []string, _ *none) error {
|
||||
fs.updateFS()
|
||||
for _, path := range paths {
|
||||
err := fs.fs.Remove(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) Rename(_ context.Context, paths [2]string, _ *none) error {
|
||||
fs.updateFS()
|
||||
return fs.fs.Rename(paths[0], paths[1])
|
||||
}
|
||||
|
||||
func (fs *FS) Mkdir(_ context.Context, paths []string, _ *none) error {
|
||||
fs.updateFS()
|
||||
for _, path := range paths {
|
||||
err := fs.fs.Mkdir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) ReadDir(_ context.Context, dir string, out *[]api.FileInfo) error {
|
||||
fs.updateFS()
|
||||
|
||||
entries, err := fs.fs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var fileInfo []api.FileInfo
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileInfo = append(fileInfo, api.FileInfo{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
*out = fileInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) Upload(ctx context.Context, paths [2]string, out *string) error {
|
||||
fs.updateFS()
|
||||
|
||||
localFile, err := os.Open(paths[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localInfo, err := localFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteFile, err := fs.fs.Create(paths[0], uint32(localInfo.Size()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
*out = id
|
||||
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, fs.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if ok {
|
||||
go func() {
|
||||
// For every progress event
|
||||
for sent := range remoteFile.Progress() {
|
||||
data, err := msgpack.Marshal(api.FSTransferProgress{
|
||||
Total: remoteFile.Size(),
|
||||
Sent: sent,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding filesystem transfer progress event")
|
||||
continue
|
||||
}
|
||||
|
||||
msgSender.SendMessage(id, "FSProgress", nil, data)
|
||||
}
|
||||
|
||||
msgSender.SendMessage(id, "Done", nil, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
io.Copy(remoteFile, localFile)
|
||||
localFile.Close()
|
||||
remoteFile.Close()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) Download(ctx context.Context, paths [2]string, out *string) error {
|
||||
fs.updateFS()
|
||||
|
||||
localFile, err := os.Create(paths[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteFile, err := fs.fs.Open(paths[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
*out = id
|
||||
|
||||
// Get client message sender
|
||||
msgSender, ok := getMsgSender(ctx, fs.srv)
|
||||
// If user is using gateway, the client connection will not be available
|
||||
if ok {
|
||||
go func() {
|
||||
// For every progress event
|
||||
for rcvd := range remoteFile.Progress() {
|
||||
data, err := msgpack.Marshal(api.FSTransferProgress{
|
||||
Total: remoteFile.Size(),
|
||||
Sent: rcvd,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error encoding filesystem transfer progress event")
|
||||
continue
|
||||
}
|
||||
|
||||
msgSender.SendMessage(id, "FSProgress", nil, data)
|
||||
}
|
||||
|
||||
msgSender.SendMessage(id, "Done", nil, nil)
|
||||
localFile.Close()
|
||||
remoteFile.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
go io.Copy(localFile, remoteFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FS) updateFS() {
|
||||
if fs.fs == nil || updateFS {
|
||||
// Get new FS
|
||||
newFS, err := fs.dev.FS()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Error updating BLE filesystem")
|
||||
} else {
|
||||
// Set FS pointer to new FS
|
||||
fs.fs = newFS
|
||||
// Reset updateFS
|
||||
updateFS = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanPaths runs strings.TrimSpace and filepath.Clean
|
||||
// on all inputs, and returns the updated slice
|
||||
func cleanPaths(paths []string) []string {
|
||||
for index, path := range paths {
|
||||
newPath := strings.TrimSpace(path)
|
||||
paths[index] = filepath.Clean(newPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func getMsgSender(ctx context.Context, srv *server.Server) (MessageSender, bool) {
|
||||
// Get client message sender
|
||||
clientConn, ok := ctx.Value(server.RemoteConnContextKey).(net.Conn)
|
||||
// If the connection exists, use rpcMsgSender
|
||||
if ok {
|
||||
return &rpcMsgSender{srv, clientConn}, true
|
||||
} else {
|
||||
// Get metadata if it exists
|
||||
metadata, ok := ctx.Value(share.ReqMetaDataKey).(map[string]string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Get returnURL field from metadata if it exists
|
||||
returnURL, ok := metadata["returnURL"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Use httpMsgSender
|
||||
return &httpMsgSender{returnURL}, true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// The MessageSender interface sends messages to the client
|
||||
type MessageSender interface {
|
||||
SendMessage(servicePath, serviceMethod string, metadata map[string]string, data []byte) error
|
||||
}
|
||||
|
||||
// rpcMsgSender sends messages using RPCX, for clients that support it
|
||||
type rpcMsgSender struct {
|
||||
srv *server.Server
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// SendMessage uses the server to send an RPCX message back to the client
|
||||
func (r *rpcMsgSender) SendMessage(servicePath, serviceMethod string, metadata map[string]string, data []byte) error {
|
||||
return r.srv.SendMessage(r.conn, servicePath, serviceMethod, metadata, data)
|
||||
}
|
||||
|
||||
// httpMsgSender sends messages to the given return URL, for clients that provide it
|
||||
type httpMsgSender struct {
|
||||
url string
|
||||
}
|
||||
|
||||
// SendMessage uses HTTP to send a message back to the client
|
||||
func (h *httpMsgSender) SendMessage(servicePath, serviceMethod string, metadata map[string]string, data []byte) error {
|
||||
// Create new POST request with provided URL
|
||||
req, err := http.NewRequest(http.MethodPost, h.url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set service path and method headers
|
||||
req.Header.Set("X-RPCX-ServicePath", servicePath)
|
||||
req.Header.Set("X-RPCX-ServiceMethod", serviceMethod)
|
||||
|
||||
// Create new URL query values
|
||||
query := url.Values{}
|
||||
// Transfer values from metadata to query
|
||||
for k, v := range metadata {
|
||||
query.Set(k, v)
|
||||
}
|
||||
// Set metadata header by encoding query values
|
||||
req.Header.Set("X-RPCX-Meta", query.Encode())
|
||||
|
||||
// Perform request
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Close body
|
||||
return res.Body.Close()
|
||||
}
|
||||
|
||||
150
translit/armenian.go
Normal file
150
translit/armenian.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package translit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ArmenianTranslit struct {
|
||||
initComplete bool
|
||||
}
|
||||
|
||||
var armenianMap = []string{
|
||||
"աու", "au",
|
||||
"բու", "bu",
|
||||
"գու", "gu",
|
||||
"դու", "du",
|
||||
"եու", "eu",
|
||||
"զու", "zu",
|
||||
"էու", "eu",
|
||||
"ըու", "yu",
|
||||
"թու", "tu",
|
||||
"ժու", "ju",
|
||||
"իու", "iu",
|
||||
"լու", "lu",
|
||||
"խու", "xu",
|
||||
"ծու", "cu",
|
||||
"կու", "ku",
|
||||
"հու", "hu",
|
||||
"ձու", "dzu",
|
||||
"ղու", "xu",
|
||||
"ճու", "cu",
|
||||
"մու", "mu",
|
||||
"յու", "yu",
|
||||
"նու", "nu",
|
||||
"շու", "shu",
|
||||
"չու", "chu",
|
||||
"պու", "pu",
|
||||
"ջու", "ju",
|
||||
"ռու", "ru",
|
||||
"սու", "su",
|
||||
"վու", "vu",
|
||||
"տու", "tu",
|
||||
"րու", "ru",
|
||||
"ցու", "cu",
|
||||
"փու", "pu",
|
||||
"քու", "qu",
|
||||
"օու", "ou",
|
||||
"ևու", "eu",
|
||||
"ֆու", "fu",
|
||||
"ոու", "vou",
|
||||
"ու", "u",
|
||||
"բո", "bo",
|
||||
"գո", "go",
|
||||
"դո", "do",
|
||||
"զո", "zo",
|
||||
"թո", "to",
|
||||
"ժո", "jo",
|
||||
"լո", "lo",
|
||||
"խո", "xo",
|
||||
"ծո", "co",
|
||||
"կո", "ko",
|
||||
"հո", "ho",
|
||||
"ձո", "dzo",
|
||||
"ղո", "xo",
|
||||
"ճո", "co",
|
||||
"մո", "mo",
|
||||
"յո", "yo",
|
||||
"նո", "no",
|
||||
"շո", "so",
|
||||
"չո", "co",
|
||||
"պո", "po",
|
||||
"ջո", "jo",
|
||||
"ռո", "ro",
|
||||
"սո", "so",
|
||||
"վո", "vo",
|
||||
"տո", "to",
|
||||
"րո", "ro",
|
||||
"ցո", "co",
|
||||
"փո", "po",
|
||||
"քո", "qo",
|
||||
"ևո", "eo",
|
||||
"ֆո", "fo",
|
||||
"ո", "vo",
|
||||
"եւ", "ev",
|
||||
"եվ", "ev",
|
||||
"ա", "a",
|
||||
"բ", "b",
|
||||
"գ", "g",
|
||||
"դ", "d",
|
||||
"ե", "e",
|
||||
"զ", "z",
|
||||
"է", "e",
|
||||
"ը", "y",
|
||||
"թ", "t",
|
||||
"ժ", "j",
|
||||
"ի", "i",
|
||||
"լ", "l",
|
||||
"խ", "x",
|
||||
"ծ", "c",
|
||||
"կ", "k",
|
||||
"հ", "h",
|
||||
"ձ", "dz",
|
||||
"ղ", "x",
|
||||
"ճ", "c",
|
||||
"մ", "m",
|
||||
"յ", "y",
|
||||
"ն", "n",
|
||||
"շ", "sh",
|
||||
"չ", "ch",
|
||||
"պ", "p",
|
||||
"ջ", "j",
|
||||
"ռ", "r",
|
||||
"ս", "s",
|
||||
"վ", "v",
|
||||
"տ", "t",
|
||||
"ր", "r",
|
||||
"ց", "c",
|
||||
"փ", "p",
|
||||
"ք", "q",
|
||||
"օ", "o",
|
||||
"և", "ev",
|
||||
"ֆ", "f",
|
||||
"ւ", "",
|
||||
}
|
||||
|
||||
func (at *ArmenianTranslit) Init() {
|
||||
if !at.initComplete {
|
||||
// Copy map as original will be changed
|
||||
lower := armenianMap
|
||||
// For every value in copied map
|
||||
for i, val := range lower {
|
||||
// If index is odd, skip
|
||||
if i%2 == 1 {
|
||||
continue
|
||||
}
|
||||
// Capitalize first letter
|
||||
capital := strings.Title(val)
|
||||
// If capital is not the same as lowercase
|
||||
if capital != val {
|
||||
// Add capital to map
|
||||
armenianMap = append(armenianMap, capital, strings.Title(armenianMap[i+1]))
|
||||
}
|
||||
}
|
||||
// Set init complete to true so it is not run again
|
||||
at.initComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
func (at *ArmenianTranslit) Transliterate(s string) string {
|
||||
return strings.NewReplacer(armenianMap...).Replace(s)
|
||||
}
|
||||
53
translit/chinese.go
Normal file
53
translit/chinese.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package translit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/mozillazg/go-pinyin"
|
||||
)
|
||||
|
||||
// ChineseTranslit implements Transliterator using a pinyin
|
||||
// conversion library.
|
||||
type ChineseTranslit struct{}
|
||||
|
||||
func (ChineseTranslit) Init() {}
|
||||
|
||||
func (ct *ChineseTranslit) Transliterate(s string) string {
|
||||
// Create buffer for final output
|
||||
outBuf := &bytes.Buffer{}
|
||||
// Create buffer to temporarily store chinese characters
|
||||
tmpBuf := &bytes.Buffer{}
|
||||
// For every character in string
|
||||
for _, char := range s {
|
||||
// If character in Han range
|
||||
if unicode.Is(unicode.Han, char) {
|
||||
// Write character to temporary buffer
|
||||
tmpBuf.WriteRune(char)
|
||||
} else {
|
||||
// If buffer contains characters
|
||||
if tmpBuf.Len() > 0 {
|
||||
// Convert to pinyin (without tones)
|
||||
out := pinyin.LazyConvert(tmpBuf.String(), nil)
|
||||
// Write space-separated string to output
|
||||
outBuf.WriteString(strings.Join(out, " "))
|
||||
// Reset temporary buffer
|
||||
tmpBuf.Reset()
|
||||
}
|
||||
// Write character to output
|
||||
outBuf.WriteRune(char)
|
||||
}
|
||||
}
|
||||
// If buffer contains characters
|
||||
if tmpBuf.Len() > 0 {
|
||||
// Convert to pinyin (without tones)
|
||||
out := pinyin.LazyConvert(tmpBuf.String(), nil)
|
||||
// Write space-separated string to output
|
||||
outBuf.WriteString(strings.Join(out, " "))
|
||||
// Reset temporary buffer
|
||||
tmpBuf.Reset()
|
||||
}
|
||||
// Return output string
|
||||
return outBuf.String()
|
||||
}
|
||||
472
translit/korean.go
Normal file
472
translit/korean.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package translit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// https://en.wikipedia.org/wiki/Hangul_Jamo_%28Unicode_block%29
|
||||
var jamoBlock = &unicode.RangeTable{
|
||||
R16: []unicode.Range16{{
|
||||
Lo: 0x1100,
|
||||
Hi: 0x11FF,
|
||||
Stride: 1,
|
||||
}},
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Hangul_Syllables
|
||||
var syllablesBlock = &unicode.RangeTable{
|
||||
R16: []unicode.Range16{{
|
||||
Lo: 0xAC00,
|
||||
Hi: 0xD7A3,
|
||||
Stride: 1,
|
||||
}},
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Hangul_Compatibility_Jamo
|
||||
var compatJamoBlock = &unicode.RangeTable{
|
||||
R16: []unicode.Range16{{
|
||||
Lo: 0x3131,
|
||||
Hi: 0x318E,
|
||||
Stride: 1,
|
||||
}},
|
||||
}
|
||||
|
||||
// KoreanTranslit implements transliteration for Korean.
|
||||
//
|
||||
// This was translated to Go from the code in https://codeberg.org/Freeyourgadget/Gadgetbridge
|
||||
type KoreanTranslit struct{}
|
||||
|
||||
func (KoreanTranslit) Init() {}
|
||||
|
||||
// User input consisting of isolated jamo is usually mapped to the KS X 1001 compatibility
|
||||
// block, but jamo resulting from decomposed syllables are mapped to the modern one. This
|
||||
// function maps compat jamo to modern ones where possible and returns all other characters
|
||||
// unmodified.
|
||||
//
|
||||
// https://en.wikipedia.org/wiki/Hangul_Compatibility_Jamo
|
||||
// https://en.wikipedia.org/wiki/Hangul_Jamo_%28Unicode_block%29
|
||||
func decompatJamo(jamo rune) rune {
|
||||
// KS X 1001 Hangul filler, not used in modern Unicode. A useful landmark in the
|
||||
// compatibility jamo block.
|
||||
// https://en.wikipedia.org/wiki/KS_X_1001#Hangul_Filler
|
||||
var hangulFiller rune = 0x3164
|
||||
|
||||
// Ignore characters outside compatibility jamo block
|
||||
if !unicode.In(jamo, compatJamoBlock) {
|
||||
return jamo
|
||||
}
|
||||
|
||||
// Vowels are contiguous, in the same order, and unambiguous so it's a simple offset.
|
||||
if jamo >= 0x314F && jamo < hangulFiller {
|
||||
return jamo - 0x1FEE
|
||||
}
|
||||
|
||||
// Consonants are organized differently. No clean way to do this.
|
||||
// The compatibility jamo block doesn't distinguish between Choseong (leading) and Jongseong
|
||||
// (final) positions, but the modern block does. We map to Choseong here.
|
||||
switch jamo {
|
||||
case 0x3131:
|
||||
return 0x1100 // ㄱ
|
||||
case 0x3132:
|
||||
return 0x1101 // ㄲ
|
||||
case 0x3134:
|
||||
return 0x1102 // ㄴ
|
||||
case 0x3137:
|
||||
return 0x1103 // ㄷ
|
||||
case 0x3138:
|
||||
return 0x1104 // ㄸ
|
||||
case 0x3139:
|
||||
return 0x1105 // ㄹ
|
||||
case 0x3141:
|
||||
return 0x1106 // ㅁ
|
||||
case 0x3142:
|
||||
return 0x1107 // ㅂ
|
||||
case 0x3143:
|
||||
return 0x1108 // ㅃ
|
||||
case 0x3145:
|
||||
return 0x1109 // ㅅ
|
||||
case 0x3146:
|
||||
return 0x110A // ㅆ
|
||||
case 0x3147:
|
||||
return 0x110B // ㅇ
|
||||
case 0x3148:
|
||||
return 0x110C // ㅈ
|
||||
case 0x3149:
|
||||
return 0x110D // ㅉ
|
||||
case 0x314A:
|
||||
return 0x110E // ㅊ
|
||||
case 0x314B:
|
||||
return 0x110F // ㅋ
|
||||
case 0x314C:
|
||||
return 0x1110 // ㅌ
|
||||
case 0x314D:
|
||||
return 0x1111 // ㅍ
|
||||
case 0x314E:
|
||||
return 0x1112 // ㅎ
|
||||
}
|
||||
|
||||
// The rest of the compatibility block consists of archaic compounds that are
|
||||
// unlikely to be encountered in modern systems. Just leave them alone.
|
||||
return jamo
|
||||
}
|
||||
|
||||
// Transliterates one jamo at a time.
|
||||
// Does nothing if it isn't in the modern jamo block.
|
||||
func translitSingleJamo(jamo rune) string {
|
||||
jamo = decompatJamo(jamo)
|
||||
|
||||
switch jamo {
|
||||
// Choseong (leading position consonants)
|
||||
case 0x1100:
|
||||
return "g" // ㄱ
|
||||
case 0x1101:
|
||||
return "kk" // ㄲ
|
||||
case 0x1102:
|
||||
return "n" // ㄴ
|
||||
case 0x1103:
|
||||
return "d" // ㄷ
|
||||
case 0x1104:
|
||||
return "tt" // ㄸ
|
||||
case 0x1105:
|
||||
return "r" // ㄹ
|
||||
case 0x1106:
|
||||
return "m" // ㅁ
|
||||
case 0x1107:
|
||||
return "b" // ㅂ
|
||||
case 0x1108:
|
||||
return "pp" // ㅃ
|
||||
case 0x1109:
|
||||
return "s" // ㅅ
|
||||
case 0x110A:
|
||||
return "ss" // ㅆ
|
||||
case 0x110B:
|
||||
return "" // ㅇ
|
||||
case 0x110C:
|
||||
return "j" // ㅈ
|
||||
case 0x110D:
|
||||
return "jj" // ㅉ
|
||||
case 0x110E:
|
||||
return "ch" // ㅊ
|
||||
case 0x110F:
|
||||
return "k" // ㅋ
|
||||
case 0x1110:
|
||||
return "t" // ㅌ
|
||||
case 0x1111:
|
||||
return "p" // ㅍ
|
||||
case 0x1112:
|
||||
return "h" // ㅎ
|
||||
// Jungseong (vowels)
|
||||
case 0x1161:
|
||||
return "a" // ㅏ
|
||||
case 0x1162:
|
||||
return "ae" // ㅐ
|
||||
case 0x1163:
|
||||
return "ya" // ㅑ
|
||||
case 0x1164:
|
||||
return "yae" // ㅒ
|
||||
case 0x1165:
|
||||
return "eo" // ㅓ
|
||||
case 0x1166:
|
||||
return "e" // ㅔ
|
||||
case 0x1167:
|
||||
return "yeo" // ㅕ
|
||||
case 0x1168:
|
||||
return "ye" // ㅖ
|
||||
case 0x1169:
|
||||
return "o" // ㅗ
|
||||
case 0x116A:
|
||||
return "wa" // ㅘ
|
||||
case 0x116B:
|
||||
return "wae" // ㅙ
|
||||
case 0x116C:
|
||||
return "oe" // ㅚ
|
||||
case 0x116D:
|
||||
return "yo" // ㅛ
|
||||
case 0x116E:
|
||||
return "u" // ㅜ
|
||||
case 0x116F:
|
||||
return "wo" // ㅝ
|
||||
case 0x1170:
|
||||
return "we" // ㅞ
|
||||
case 0x1171:
|
||||
return "wi" // ㅟ
|
||||
case 0x1172:
|
||||
return "yu" // ㅠ
|
||||
case 0x1173:
|
||||
return "eu" // ㅡ
|
||||
case 0x1174:
|
||||
return "ui" // ㅢ
|
||||
case 0x1175:
|
||||
return "i" // ㅣ
|
||||
// Jongseong (final position consonants)
|
||||
case 0x11A8:
|
||||
return "k" // ㄱ
|
||||
case 0x11A9:
|
||||
return "k" // ㄲ
|
||||
case 0x11AB:
|
||||
return "n" // ㄴ
|
||||
case 0x11AE:
|
||||
return "t" // ㄷ
|
||||
case 0x11AF:
|
||||
return "l" // ㄹ
|
||||
case 0x11B7:
|
||||
return "m" // ㅁ
|
||||
case 0x11B8:
|
||||
return "p" // ㅂ
|
||||
case 0x11BA:
|
||||
return "t" // ㅅ
|
||||
case 0x11BB:
|
||||
return "t" // ㅆ
|
||||
case 0x11BC:
|
||||
return "ng" // ㅇ
|
||||
case 0x11BD:
|
||||
return "t" // ㅈ
|
||||
case 0x11BE:
|
||||
return "t" // ㅊ
|
||||
case 0x11BF:
|
||||
return "k" // ㅋ
|
||||
case 0x11C0:
|
||||
return "t" // ㅌ
|
||||
case 0x11C1:
|
||||
return "p" // ㅍ
|
||||
case 0x11C2:
|
||||
return "t" // ㅎ
|
||||
}
|
||||
|
||||
return string(jamo)
|
||||
}
|
||||
|
||||
// Some combinations of ending jamo in one syllable and initial jamo in the next are romanized
|
||||
// irregularly. These exceptions are called "special provisions". In cases where multiple
|
||||
// romanizations are permitted, we use the one that's least commonly used elsewhere.
|
||||
//
|
||||
// Returns empty strring and false if either character is not in the modern jamo block,
|
||||
// or if there is no special provision for that pair of jamo.
|
||||
func translitSpecialProvisions(previousEnding rune, nextInitial rune) (string, bool) {
|
||||
// Return false if previousEnding not in modern jamo block
|
||||
if !unicode.In(previousEnding, jamoBlock) {
|
||||
return "", false
|
||||
}
|
||||
// Return false if nextInitial not in modern jamo block
|
||||
if !unicode.In(nextInitial, jamoBlock) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Jongseong (final position) ㅎ has a number of special provisions.
|
||||
if previousEnding == 0x11C2 {
|
||||
switch nextInitial {
|
||||
case 0x110B:
|
||||
return "h", true // ㅇ
|
||||
case 0x1100:
|
||||
return "k", true // ㄱ
|
||||
case 0x1102:
|
||||
return "nn", true // ㄴ
|
||||
case 0x1103:
|
||||
return "t", true // ㄷ
|
||||
case 0x1105:
|
||||
return "nn", true // ㄹ
|
||||
case 0x1106:
|
||||
return "nm", true // ㅁ
|
||||
case 0x1107:
|
||||
return "p", true // ㅂ
|
||||
case 0x1109:
|
||||
return "hs", true // ㅅ
|
||||
case 0x110C:
|
||||
return "ch", true // ㅈ
|
||||
case 0x1112:
|
||||
return "t", true // ㅎ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, special provisions are denser when grouped by the second jamo.
|
||||
switch nextInitial {
|
||||
case 0x1100: // ㄱ
|
||||
switch previousEnding {
|
||||
case 0x11AB:
|
||||
return "n-g", true // ㄴ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x1102: // ㄴ
|
||||
switch previousEnding {
|
||||
case 0x11A8:
|
||||
return "ngn", true // ㄱ
|
||||
case 0x11AE:
|
||||
fallthrough // ㄷ
|
||||
case 0x11BA:
|
||||
fallthrough // ㅅ
|
||||
case 0x11BD:
|
||||
fallthrough // ㅈ
|
||||
case 0x11BE:
|
||||
fallthrough // ㅊ
|
||||
case 0x11C0: // ㅌ
|
||||
return "nn", true
|
||||
case 0x11AF:
|
||||
return "ll", true // ㄹ
|
||||
case 0x11B8:
|
||||
return "mn", true // ㅂ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x1105: // ㄹ
|
||||
switch previousEnding {
|
||||
case 0x11A8:
|
||||
fallthrough // ㄱ
|
||||
case 0x11AB:
|
||||
fallthrough // ㄴ
|
||||
case 0x11AF: // ㄹ
|
||||
return "ll", true
|
||||
case 0x11AE:
|
||||
fallthrough // ㄷ
|
||||
case 0x11BA:
|
||||
fallthrough // ㅅ
|
||||
case 0x11BD:
|
||||
fallthrough // ㅈ
|
||||
case 0x11BE:
|
||||
fallthrough // ㅊ
|
||||
case 0x11C0: // ㅌ
|
||||
return "nn", true
|
||||
case 0x11B7:
|
||||
fallthrough // ㅁ
|
||||
case 0x11B8: // ㅂ
|
||||
return "mn", true
|
||||
case 0x11BC:
|
||||
return "ngn", true // ㅇ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x1106: // ㅁ
|
||||
switch previousEnding {
|
||||
case 0x11A8:
|
||||
return "ngm", true // ㄱ
|
||||
case 0x11AE:
|
||||
fallthrough // ㄷ
|
||||
case 0x11BA:
|
||||
fallthrough // ㅅ
|
||||
case 0x11BD:
|
||||
fallthrough // ㅈ
|
||||
case 0x11BE:
|
||||
fallthrough // ㅊ
|
||||
case 0x11C0: // ㅌ
|
||||
return "nm", true
|
||||
case 0x11B8:
|
||||
return "mm", true // ㅂ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x110B: // ㅇ
|
||||
switch previousEnding {
|
||||
case 0x11A8:
|
||||
return "g", true // ㄱ
|
||||
case 0x11AE:
|
||||
return "d", true // ㄷ
|
||||
case 0x11AF:
|
||||
return "r", true // ㄹ
|
||||
case 0x11B8:
|
||||
return "b", true // ㅂ
|
||||
case 0x11BA:
|
||||
return "s", true // ㅅ
|
||||
case 0x11BC:
|
||||
return "ng-", true // ㅇ
|
||||
case 0x11BD:
|
||||
return "j", true // ㅈ
|
||||
case 0x11BE:
|
||||
return "ch", true // ㅊ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x110F: // ㅋ
|
||||
switch previousEnding {
|
||||
case 0x11A8:
|
||||
return "k-k", true // ㄱ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x1110: // ㅌ
|
||||
switch previousEnding {
|
||||
case 0x11AE:
|
||||
fallthrough // ㄷ
|
||||
case 0x11BA:
|
||||
fallthrough // ㅅ
|
||||
case 0x11BD:
|
||||
fallthrough // ㅈ
|
||||
case 0x11BE:
|
||||
fallthrough // ㅊ
|
||||
case 0x11C0: // ㅌ
|
||||
return "t-t", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
case 0x1111: // ㅍ
|
||||
switch previousEnding {
|
||||
case 0x11B8:
|
||||
return "p-p", true // ㅂ
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Decompose a syllable into several jamo. Does nothing if that isn't possible.
|
||||
func decompose(syllable rune) string {
|
||||
return norm.NFD.String(string(syllable))
|
||||
}
|
||||
|
||||
// Transliterate any Hangul in the given string.
|
||||
// Leaves any non-Hangul characters unmodified.
|
||||
func (kt *KoreanTranslit) Transliterate(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
builder := &strings.Builder{}
|
||||
|
||||
nextInitialJamoConsumed := false
|
||||
|
||||
for i, syllable := range s {
|
||||
// If character not in blocks, leave it unmodified
|
||||
if !unicode.In(syllable, jamoBlock, syllablesBlock, compatJamoBlock) {
|
||||
builder.WriteRune(syllable)
|
||||
continue
|
||||
}
|
||||
|
||||
jamo := decompose(syllable)
|
||||
for j, char := range jamo {
|
||||
// If we already transliterated the first jamo of this syllable as part of a special
|
||||
// provision, skip it. Otherwise, handle it in the unconditional else branch.
|
||||
if j == 0 && nextInitialJamoConsumed {
|
||||
nextInitialJamoConsumed = false
|
||||
continue
|
||||
}
|
||||
|
||||
// If this is the last jamo of this syllable and not the last syllable of the
|
||||
// string, check for special provisions. If the next char is whitespace or not
|
||||
// Hangul, run translitSpecialProvisions() should return no value.
|
||||
if j == len(jamo)-1 && i < len(s)-1 {
|
||||
nextSyllable := s[i+1]
|
||||
nextJamo := decompose(rune(nextSyllable))[0]
|
||||
|
||||
// Attempt to handle special provision
|
||||
specialProvision, ok := translitSpecialProvisions(char, rune(nextJamo))
|
||||
if ok {
|
||||
builder.WriteString(specialProvision)
|
||||
nextInitialJamoConsumed = true
|
||||
} else {
|
||||
// Not a special provision, transliterate normally
|
||||
builder.WriteString(translitSingleJamo(char))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Transliterate normally
|
||||
builder.WriteString(translitSingleJamo(char))
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
415
translit/translit.go
Normal file
415
translit/translit.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package translit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Transliterate runs the given maps on s and returns the result
|
||||
func Transliterate(s string, useMaps ...string) string {
|
||||
// Create variable to store modified string
|
||||
out := s
|
||||
// If custom map exists
|
||||
if custom, ok := Transliterators["custom"]; ok {
|
||||
// Perform transliteration with it
|
||||
out = custom.Transliterate(out)
|
||||
}
|
||||
// For every map to use
|
||||
for _, useMap := range useMaps {
|
||||
// If custom, skip
|
||||
if useMap == "custom" {
|
||||
continue
|
||||
}
|
||||
// Get requested map
|
||||
transliterator, ok := Transliterators[useMap]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
transliterator.Init()
|
||||
// Perform transliteration
|
||||
out = transliterator.Transliterate(out)
|
||||
}
|
||||
// Return result
|
||||
return out
|
||||
}
|
||||
|
||||
// Transliterator is implemented by anything with a
|
||||
// Transliterate method, which performs transliteration
|
||||
// and returns the resulting string.
|
||||
type Transliterator interface {
|
||||
Transliterate(string) string
|
||||
Init()
|
||||
}
|
||||
|
||||
// Map implements Transliterator using a slice where
|
||||
// every even element is a key and every odd one is a value
|
||||
// which replaces the key.
|
||||
type Map []string
|
||||
|
||||
func (mt Map) Transliterate(s string) string {
|
||||
return strings.NewReplacer(mt...).Replace(s)
|
||||
}
|
||||
|
||||
func (Map) Init() {}
|
||||
|
||||
// Transliterators stores transliterator implementations for each supported language.
|
||||
// Some of these were sourced from https://codeberg.org/Freeyourgadget/Gadgetbridge
|
||||
var Transliterators = map[string]Transliterator{
|
||||
"eASCII": Map{
|
||||
"œ", "oe",
|
||||
"ª", "a",
|
||||
"°", "o",
|
||||
"«", `"`,
|
||||
"»", `"`,
|
||||
},
|
||||
"Scandinavian": Map{
|
||||
"Æ", "Ae",
|
||||
"æ", "ae",
|
||||
"Ø", "Oe",
|
||||
"ø", "oe",
|
||||
"Å", "Aa",
|
||||
"å", "aa",
|
||||
},
|
||||
"German": Map{
|
||||
"ä", "ae",
|
||||
"ö", "oe",
|
||||
"ü", "ue",
|
||||
"Ä", "Ae",
|
||||
"Ö", "Oe",
|
||||
"Ü", "Ue",
|
||||
"ß", "ss",
|
||||
"ẞ", "SS",
|
||||
},
|
||||
"Hebrew": Map{
|
||||
"א", "a",
|
||||
"ב", "b",
|
||||
"ג", "g",
|
||||
"ד", "d",
|
||||
"ה", "h",
|
||||
"ו", "u",
|
||||
"ז", "z",
|
||||
"ח", "kh",
|
||||
"ט", "t",
|
||||
"י", "y",
|
||||
"כ", "c",
|
||||
"ל", "l",
|
||||
"מ", "m",
|
||||
"נ", "n",
|
||||
"ס", "s",
|
||||
"ע", "'",
|
||||
"פ", "p",
|
||||
"צ", "ts",
|
||||
"ק", "k",
|
||||
"ר", "r",
|
||||
"ש", "sh",
|
||||
"ת", "th",
|
||||
"ף", "f",
|
||||
"ץ", "ts",
|
||||
"ך", "ch",
|
||||
"ם", "m",
|
||||
"ן", "n",
|
||||
},
|
||||
"Greek": Map{
|
||||
"α", "a",
|
||||
"ά", "a",
|
||||
"β", "v",
|
||||
"γ", "g",
|
||||
"δ", "d",
|
||||
"ε", "e",
|
||||
"έ", "e",
|
||||
"ζ", "z",
|
||||
"η", "i",
|
||||
"ή", "i",
|
||||
"θ", "th",
|
||||
"ι", "i",
|
||||
"ί", "i",
|
||||
"ϊ", "i",
|
||||
"ΐ", "i",
|
||||
"κ", "k",
|
||||
"λ", "l",
|
||||
"μ", "m",
|
||||
"ν", "n",
|
||||
"ξ", "ks",
|
||||
"ο", "o",
|
||||
"ό", "o",
|
||||
"π", "p",
|
||||
"ρ", "r",
|
||||
"σ", "s",
|
||||
"ς", "s",
|
||||
"τ", "t",
|
||||
"υ", "y",
|
||||
"ύ", "y",
|
||||
"ϋ", "y",
|
||||
"ΰ", "y",
|
||||
"φ", "f",
|
||||
"χ", "ch",
|
||||
"ψ", "ps",
|
||||
"ω", "o",
|
||||
"ώ", "o",
|
||||
"Α", "A",
|
||||
"Ά", "A",
|
||||
"Β", "B",
|
||||
"Γ", "G",
|
||||
"Δ", "D",
|
||||
"Ε", "E",
|
||||
"Έ", "E",
|
||||
"Ζ", "Z",
|
||||
"Η", "I",
|
||||
"Ή", "I",
|
||||
"Θ", "Th",
|
||||
"Ι", "I",
|
||||
"Ί", "I",
|
||||
"Ϊ", "I",
|
||||
"Κ", "K",
|
||||
"Λ", "L",
|
||||
"Μ", "M",
|
||||
"Ν", "N",
|
||||
"Ξ", "Ks",
|
||||
"Ο", "O",
|
||||
"Ό", "O",
|
||||
"Π", "P",
|
||||
"Ρ", "R",
|
||||
"Σ", "S",
|
||||
"Τ", "T",
|
||||
"Υ", "Y",
|
||||
"Ύ", "Y",
|
||||
"Ϋ", "Y",
|
||||
"Φ", "F",
|
||||
"Χ", "Ch",
|
||||
"Ψ", "Ps",
|
||||
"Ω", "O",
|
||||
"Ώ", "O",
|
||||
},
|
||||
"Russian": Map{
|
||||
"Ё", "Йo",
|
||||
"ё", "йo",
|
||||
},
|
||||
"Ukranian": Map{
|
||||
"ґ", "gh",
|
||||
"є", "je",
|
||||
"і", "i",
|
||||
"ї", "ji",
|
||||
"Ґ", "Gh",
|
||||
"Є", "Je",
|
||||
"І", "I",
|
||||
"Ї", "JI",
|
||||
},
|
||||
"Arabic": Map{
|
||||
"ا", "a",
|
||||
"ب", "b",
|
||||
"ت", "t",
|
||||
"ث", "th",
|
||||
"ج", "j",
|
||||
"ح", "7",
|
||||
"خ", "5",
|
||||
"د", "d",
|
||||
"ذ", "th",
|
||||
"ر", "r",
|
||||
"ز", "z",
|
||||
"س", "s",
|
||||
"ش", "sh",
|
||||
"ص", "9",
|
||||
"ض", "9'",
|
||||
"ط", "6",
|
||||
"ظ", "6'",
|
||||
"ع", "3",
|
||||
"غ", "3'",
|
||||
"ف", "f",
|
||||
"ق", "q",
|
||||
"ك", "k",
|
||||
"ل", "l",
|
||||
"م", "m",
|
||||
"ن", "n",
|
||||
"ه", "h",
|
||||
"و", "w",
|
||||
"ي", "y",
|
||||
"ى", "a",
|
||||
"ﺓ", "",
|
||||
"آ", "2",
|
||||
"ئ", "2",
|
||||
"إ", "2",
|
||||
"ؤ", "2",
|
||||
"أ", "2",
|
||||
"ء", "2",
|
||||
"٠", "0",
|
||||
"١", "1",
|
||||
"٢", "2",
|
||||
"٣", "3",
|
||||
"٤", "4",
|
||||
"٥", "5",
|
||||
"٦", "6",
|
||||
"٧", "7",
|
||||
"٨", "8",
|
||||
"٩", "9",
|
||||
},
|
||||
"Farsi": Map{
|
||||
"پ", "p",
|
||||
"چ", "ch",
|
||||
"ژ", "zh",
|
||||
"ک", "k",
|
||||
"گ", "g",
|
||||
"ی", "y",
|
||||
"\u200c", " ",
|
||||
"؟", "?",
|
||||
"٪", "%",
|
||||
"؛", ";",
|
||||
"،", ":",
|
||||
"۱", "1",
|
||||
"۲", "2",
|
||||
"۳", "3",
|
||||
"۴", "4",
|
||||
"۵", "5",
|
||||
"۶", "6",
|
||||
"۷", "7",
|
||||
"۸", "8",
|
||||
"۹", "9",
|
||||
"۰", "0",
|
||||
"»", "<",
|
||||
"«", ">",
|
||||
"ِ", "e",
|
||||
"َ", "a",
|
||||
"ُ", "o",
|
||||
"ّ", "",
|
||||
},
|
||||
"Polish": Map{
|
||||
"Ł", "L",
|
||||
"ł", "l",
|
||||
},
|
||||
"Lithuanian": Map{
|
||||
"ą", "a",
|
||||
"č", "c",
|
||||
"ę", "e",
|
||||
"ė", "e",
|
||||
"į", "i",
|
||||
"š", "s",
|
||||
"ų", "u",
|
||||
"ū", "u",
|
||||
"ž", "z",
|
||||
},
|
||||
"Estonian": Map{
|
||||
"ä", "a",
|
||||
"Ä", "A",
|
||||
"ö", "o",
|
||||
"õ", "o",
|
||||
"Ö", "O",
|
||||
"Õ", "O",
|
||||
"ü", "u",
|
||||
"Ü", "U",
|
||||
},
|
||||
"Icelandic": Map{
|
||||
"Þ", "Th",
|
||||
"þ", "th",
|
||||
"Ð", "D",
|
||||
"ð", "d",
|
||||
},
|
||||
"Czech": Map{
|
||||
"ř", "r",
|
||||
"ě", "e",
|
||||
"ý", "y",
|
||||
"á", "a",
|
||||
"í", "i",
|
||||
"é", "e",
|
||||
"ó", "o",
|
||||
"ú", "u",
|
||||
"ů", "u",
|
||||
"ď", "d",
|
||||
"ť", "t",
|
||||
"ň", "n",
|
||||
},
|
||||
"French": Map{
|
||||
"à", "a",
|
||||
"â", "a",
|
||||
"é", "e",
|
||||
"è", "e",
|
||||
"ê", "e",
|
||||
"ë", "e",
|
||||
"ù", "u",
|
||||
"ü", "u",
|
||||
"ÿ", "y",
|
||||
"ç", "c",
|
||||
},
|
||||
"Romanian": Map{
|
||||
"ă", "a",
|
||||
"Ă", "A",
|
||||
"â", "a",
|
||||
"Â", "A",
|
||||
"î", "i",
|
||||
"Î", "I",
|
||||
"ș", "s",
|
||||
"Ș", "S",
|
||||
"ț", "t",
|
||||
"Ț", "T",
|
||||
"ş", "s",
|
||||
"Ş", "S",
|
||||
"ţ", "t",
|
||||
"Ţ", "T",
|
||||
"„", "\"",
|
||||
"”", "\"",
|
||||
},
|
||||
"Emoji": Map{
|
||||
"😂", "XD",
|
||||
"🤣", "XD",
|
||||
"😊", ":)",
|
||||
"☺️", ":)",
|
||||
"😌", ":)",
|
||||
"😃", ":D",
|
||||
"😁", ":D",
|
||||
"😋", ":P",
|
||||
"😛", ":P",
|
||||
"😜", ";P",
|
||||
"🙃", "(:",
|
||||
"😎", "8)",
|
||||
"😶", ":#",
|
||||
"😩", "-_-",
|
||||
"😕", ":(",
|
||||
"😏", ":‑J",
|
||||
"💜", "<3",
|
||||
"💖", "<3",
|
||||
"💗", "<3",
|
||||
"❤️", "<3",
|
||||
"💕", "<3",
|
||||
"💞", "<3",
|
||||
"💘", "<3",
|
||||
"💓", "<3",
|
||||
"💚", "<3",
|
||||
"💙", "<3",
|
||||
"💟", "<3",
|
||||
"❣️", "<3!",
|
||||
"💔", "</3",
|
||||
"😱", "D:",
|
||||
"😮", ":O",
|
||||
"😯", ":O",
|
||||
"😝", "xP",
|
||||
"🤔", "',:-|",
|
||||
"😔", ":|",
|
||||
"😍", ":*",
|
||||
"😘", ":*",
|
||||
"😚", ":*",
|
||||
"😙", ":*",
|
||||
"👍", ":thumbsup:",
|
||||
"👌", ":ok_hand:",
|
||||
"🤞", ":crossed_fingers:",
|
||||
"✌️", ":victory_hand:",
|
||||
"🌄", ":sunrise_over_mountains:",
|
||||
"🌞", ":sun_with_face:",
|
||||
"🤗", ":hugging_face:",
|
||||
"🌻", ":sunflower:",
|
||||
"🥱", ":yawning_face:",
|
||||
"🙄", ":face_with_rolling_eyes:",
|
||||
"🔫", ":gun:",
|
||||
"🥔", ":potato:",
|
||||
"😬", ":E",
|
||||
"✨", "***",
|
||||
"🌌", "***",
|
||||
"💀", "8-X",
|
||||
"😅", "':D",
|
||||
"😢", ":'(",
|
||||
"💯", ":100:",
|
||||
"🔥", ":fire:",
|
||||
"😉", ";)",
|
||||
"😴", ":zzz:",
|
||||
"💤", ":zzz:",
|
||||
},
|
||||
"Korean": &KoreanTranslit{},
|
||||
"Chinese": &ChineseTranslit{},
|
||||
"Armenian": &ArmenianTranslit{},
|
||||
}
|
||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
unknown
|
||||
277
weather.go
Normal file
277
weather.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.arsenm.dev/infinitime"
|
||||
"go.arsenm.dev/infinitime/weather"
|
||||
)
|
||||
|
||||
// METResponse represents a response from
|
||||
// the MET Norway API
|
||||
type METResponse struct {
|
||||
Properties struct {
|
||||
Timeseries []struct {
|
||||
Time time.Time
|
||||
Data METData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// METData represents data in a METResponse
|
||||
type METData struct {
|
||||
Instant struct {
|
||||
Details struct {
|
||||
AirPressure float32 `json:"air_pressure_at_sea_level"`
|
||||
AirTemperature float32 `json:"air_temperature"`
|
||||
DewPoint float32 `json:"dew_point_temperature"`
|
||||
CloudAreaFraction float32 `json:"cloud_area_fraction"`
|
||||
FogAreaFraction float32 `json:"fog_area_fraction"`
|
||||
RelativeHumidity float32 `json:"relative_humidity"`
|
||||
UVIndex float32 `json:"ultraviolet_index_clear_sky"`
|
||||
WindDirection float32 `json:"wind_from_direction"`
|
||||
WindSpeed float32 `json:"wind_speed"`
|
||||
}
|
||||
}
|
||||
NextHour struct {
|
||||
Summary struct {
|
||||
SymbolCode string `json:"symbol_code"`
|
||||
}
|
||||
Details struct {
|
||||
PrecipitationAmount float32 `json:"precipitation_amount"`
|
||||
}
|
||||
} `json:"next_1_hours"`
|
||||
}
|
||||
|
||||
// OSMData represents lat/long data from
|
||||
// OpenStreetMap Nominatim
|
||||
type OSMData []struct {
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
}
|
||||
|
||||
var sendWeatherCh = make(chan struct{}, 1)
|
||||
|
||||
func initWeather(dev *infinitime.Device) error {
|
||||
if !k.Bool("weather.enabled") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get location based on string in config
|
||||
lat, lon, err := getLocation(k.String("weather.location"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Hour)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
// Attempt to get weather
|
||||
data, err := getWeather(lat, lon)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Error getting weather data")
|
||||
// Wait 15 minutes before retrying
|
||||
time.Sleep(15 * time.Minute)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get current data
|
||||
current := data.Properties.Timeseries[0]
|
||||
currentData := current.Data.Instant.Details
|
||||
|
||||
// Add temperature event
|
||||
err = dev.AddWeatherEvent(weather.TemperatureEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypeTemperature,
|
||||
time.Hour,
|
||||
),
|
||||
Temperature: int16(round(currentData.AirTemperature * 100)),
|
||||
DewPoint: int16(round(currentData.DewPoint)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding temperature event")
|
||||
}
|
||||
|
||||
// Add precipitation event
|
||||
err = dev.AddWeatherEvent(weather.PrecipitationEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypePrecipitation,
|
||||
time.Hour,
|
||||
),
|
||||
Type: parseSymbol(current.Data.NextHour.Summary.SymbolCode),
|
||||
Amount: uint8(round(current.Data.NextHour.Details.PrecipitationAmount)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding precipitation event")
|
||||
}
|
||||
|
||||
// Add wind event
|
||||
err = dev.AddWeatherEvent(weather.WindEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypeWind,
|
||||
time.Hour,
|
||||
),
|
||||
SpeedMin: uint8(round(currentData.WindSpeed)),
|
||||
SpeedMax: uint8(round(currentData.WindSpeed)),
|
||||
DirectionMin: uint8(round(currentData.WindDirection)),
|
||||
DirectionMax: uint8(round(currentData.WindDirection)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding wind event")
|
||||
}
|
||||
|
||||
// Add cloud event
|
||||
err = dev.AddWeatherEvent(weather.CloudsEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypeClouds,
|
||||
time.Hour,
|
||||
),
|
||||
Amount: uint8(round(currentData.CloudAreaFraction)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding clouds event")
|
||||
}
|
||||
|
||||
// Add humidity event
|
||||
err = dev.AddWeatherEvent(weather.HumidityEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypeHumidity,
|
||||
time.Hour,
|
||||
),
|
||||
Humidity: uint8(round(currentData.RelativeHumidity)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding humidity event")
|
||||
}
|
||||
|
||||
// Add pressure event
|
||||
err = dev.AddWeatherEvent(weather.PressureEvent{
|
||||
TimelineHeader: weather.NewHeader(
|
||||
weather.EventTypePressure,
|
||||
time.Hour,
|
||||
),
|
||||
Pressure: int16(round(currentData.AirPressure)),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error adding pressure event")
|
||||
}
|
||||
|
||||
// Reset timer to 1 hour
|
||||
timer.Stop()
|
||||
timer.Reset(time.Hour)
|
||||
|
||||
// Wait for timer to fire or manual update signal
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-sendWeatherCh:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLocation returns the latitude and longitude
|
||||
// given a location
|
||||
func getLocation(loc string) (lat, lon float64, err error) {
|
||||
// Create request URL and perform GET request
|
||||
reqURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search.php?q=%s&format=jsonv2", url.QueryEscape(loc))
|
||||
res, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Decode JSON from response into OSMData
|
||||
data := OSMData{}
|
||||
err = json.NewDecoder(res.Body).Decode(&data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// If no data points
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get first data point
|
||||
out := data[0]
|
||||
|
||||
// Attempt to parse latitude
|
||||
lat, err = strconv.ParseFloat(out.Lat, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Attempt to parse longitude
|
||||
lon, err = strconv.ParseFloat(out.Lon, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// getWeather gets weather data given a latitude and longitude
|
||||
func getWeather(lat, lon float64) (*METResponse, error) {
|
||||
// Create new GET request
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf(
|
||||
"https://api.met.no/weatherapi/locationforecast/2.0/complete?lat=%.2f&lon=%.2f",
|
||||
lat,
|
||||
lon,
|
||||
),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set identifying user agent as per NMI requirements
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ITD/%s gitea.arsenm.dev/Arsen6331/itd", version))
|
||||
|
||||
// Perform request
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode JSON from response to METResponse struct
|
||||
out := &METResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseSymbol determines what type of precipitation a symbol code
|
||||
// codes for.
|
||||
func parseSymbol(symCode string) weather.PrecipitationType {
|
||||
switch {
|
||||
case strings.Contains(symCode, "lightrain"):
|
||||
return weather.PrecipitationTypeRain
|
||||
case strings.Contains(symCode, "rain"):
|
||||
return weather.PrecipitationTypeRain
|
||||
case strings.Contains(symCode, "snow"):
|
||||
return weather.PrecipitationTypeSnow
|
||||
case strings.Contains(symCode, "sleet"):
|
||||
return weather.PrecipitationTypeSleet
|
||||
case strings.Contains(symCode, "snow"):
|
||||
return weather.PrecipitationTypeSnow
|
||||
default:
|
||||
return weather.PrecipitationTypeNone
|
||||
}
|
||||
}
|
||||
|
||||
// round rounds 32-bit floats to 32-bit integers
|
||||
func round(f float32) int32 {
|
||||
return int32(math.Round(float64(f)))
|
||||
}
|
||||
Reference in New Issue
Block a user