136 Commits

Author SHA1 Message Date
078b8dc490 Update 'go.mod' 2022-04-26 05:01:14 -07:00
14aaa8a0ed Update 'go.mod' 2022-04-26 04:56:31 -07:00
1f4d59d84e Update 'calls.go' 2022-04-26 04:53:47 -07:00
3643a479ab Update 'main.go' 2022-04-26 04:52:42 -07:00
625805fe96 Add comments 2022-04-24 00:58:39 -07:00
4b6f7d408e Support bidirectional requests over gateway 2022-04-24 00:54:04 -07:00
9034ef7c6b Add debug logs 2022-04-23 20:20:13 -07:00
9939f724c4 Re-add watch commands to itctl 2022-04-23 18:46:49 -07:00
8dce33f7b1 Enable RPCX gateway 2022-04-23 11:29:16 -07:00
563009c44d Merge branch 'master' of ssh://192.168.100.62:2222/Arsen6331/itd 2022-04-22 19:22:32 -07:00
d4a8a9f8c9 Improve error handling 2022-04-22 18:43:13 -07:00
7fd9af3288 Remove old code comment 2022-04-22 17:19:23 -07:00
4508559bfd Update module go version to 1.17 2022-04-22 17:15:41 -07:00
0cdf8a4bed Switch from custom socket API to rpcx 2022-04-22 17:12:30 -07:00
2af6c1887f Fix typo in code (Czeck -> Czech) 2022-04-16 10:15:55 -07:00
3a3f95acdf Fix typo (Czeck -> Czech) 2022-04-16 10:14:18 -07:00
d318c584da Use new changes in infinitime library to stop removing InfiniTime devices (Fixes #10) 2022-04-16 04:28:53 -07:00
c8c617c10a Fix itctl panic when itd is not running (Fixes #14) 2022-04-02 15:20:31 -07:00
365414f951 Merge pull request 'emoji translation: Add my frequently received emojis' (#15) from earboxer/itd:common-emojis into master
Reviewed-on: https://gitea.arsenm.dev/Arsen6331/itd/pulls/15
2022-03-25 17:22:02 -07:00
9b04d06560 emoji translation: Add my frequently received emojis
mapped to a common ASCII emoticon, or to the shortcode
2022-03-21 11:58:13 -04:00
23e9195e70 Remove debug code 2022-03-15 19:25:37 -07:00
cd68fbd7f3 Update 'cmd/itctl/main.go' 2022-03-15 16:16:44 -07:00
205a041758 Remove exit error handler because it causes duplicated help text 2022-03-15 16:06:05 -07:00
62597f70ee Transliterate song metadata (Fixes #13) 2022-03-11 13:14:23 -08:00
32bb141244 Merge pull request 'Romanian transliterate' (#12) from eugenr/itd:romanian into master
Reviewed-on: https://gitea.arsenm.dev/Arsen6331/itd/pulls/12
2022-03-11 10:04:26 -08:00
f28c68438a Add Romanian to README.md 2022-03-11 04:17:12 -08:00
aa90e9eb26 Romanian translit 2022-03-11 04:15:10 -08:00
553709ce8d Make sure fs is only updated if dev.FS() succeeds (#11) 2022-03-08 08:32:31 -08:00
2ded0d36b1 Update infinitime library for #9 fix 2022-03-04 12:05:58 -08:00
a885eacc70 Rewrite itctl to use urfave/cli instead of spf13/cobra 2022-02-24 21:26:40 -08:00
9e63401db3 Add update weather command to itctl 2022-02-23 21:22:03 -08:00
2f14e70721 Add default version.txt file 2022-02-22 08:44:50 -08:00
614d14e399 Add version flag 2022-02-22 08:43:29 -08:00
c08ddfd810 Add enable switch for weather to config 2022-02-22 08:33:27 -08:00
4bdb82b1bc Add error logging for weather 2022-02-21 16:27:04 -08:00
b4d302caf6 Implement weather via MET Norway 2022-02-21 16:18:52 -08:00
4b2694ee0d Switch from viper to koanf 2022-02-21 11:20:02 -08:00
4c36144b0b Update version of infinitime library for rewritten connection code 2022-02-21 02:47:48 -08:00
e88dea40fb Reorganize and clean code 2021-12-17 00:31:05 -08:00
23b9cfe8a3 Update Infinitime library to use custom agent 2021-12-16 21:32:06 -08:00
518fe74e96 Propagate FS errors on read/write and close files when finished writing 2021-12-13 09:58:34 -08:00
27aabdceba Make paths absolute for firmware upgrades 2021-12-12 17:46:50 -08:00
c019d7523b Implement file transfer progress 2021-12-12 17:08:48 -08:00
03c3c6b22f Remove debug code 2021-12-11 22:23:01 -08:00
69d1027f01 Create absolute directories for ITD to read/write 2021-12-11 22:13:21 -08:00
873df67d1f Directly read/write files from ITD 2021-12-11 22:11:01 -08:00
a9ef386883 Fix comments in filesystem commands 2021-11-27 00:11:37 -08:00
24cfda82d7 Fix and add error messages to fs operations 2021-11-27 00:03:13 -08:00
655af5c446 Ensure that the FS works after a reconnect 2021-11-25 20:35:03 -08:00
b363a20a9d Remove replace directive 2021-11-25 19:49:07 -08:00
f5d326124d Add missing responses to some FS operations 2021-11-25 19:46:04 -08:00
cb8fb2c0bc Add newline for read file command if output is stdout 2021-11-25 19:44:43 -08:00
38119435f1 Get BLE FS once rather than on every connection 2021-11-25 19:41:44 -08:00
034a69c12f Allow multiple call notification responses 2021-11-25 12:41:36 -08:00
70006a3d7b Remove playerctl from depencency list 2021-11-24 16:46:57 -08:00
8aada58d64 Update music control implementation 2021-11-24 16:44:36 -08:00
5d231207cd Remove useless function call 2021-11-24 13:07:48 -08:00
584d9426e6 Make sure modemmanager exists for call notifications 2021-11-24 13:04:20 -08:00
7a772a5458 Add comments 2021-11-24 12:00:44 -08:00
079c733b60 Use clearer variable names 2021-11-24 11:54:16 -08:00
e24a8e9088 Use new helper functions 2021-11-24 11:52:52 -08:00
0b5d777077 Switch calls to use dbus library and add helpers for private connections 2021-11-24 11:36:36 -08:00
75327286ef Switch to private bus connection 2021-11-23 22:03:41 -08:00
099b0cd849 Add filesystem to itctl 2021-11-23 14:14:45 -08:00
c9c00e0072 Allow multiple paths in mkdir and remove 2021-11-23 13:35:18 -08:00
b2ffb2062a Fix write file in api package 2021-11-23 11:19:21 -08:00
2e8c825fff Add BLE FS to API package 2021-11-23 11:12:16 -08:00
7b870950d1 Implement BLE FS 2021-11-22 22:04:09 -08:00
3a877c41a4 Update infinitime library to fix compatibility with BlueZ 5.62 2021-11-22 01:18:40 -08:00
04fb390bee Add reminder to validate firmware to itctl and itgui 2021-11-06 19:06:17 -07:00
50b17d3266 Update default values to reflect new config fields 2021-11-01 11:28:55 -07:00
763d408405 Upgrade infinitime library version 2021-11-01 11:21:37 -07:00
fbb7cd9bc1 Remove config version field 2021-10-27 08:34:10 -07:00
f1b7f70313 Add whitelist support 2021-10-27 07:27:12 -07:00
552f19676b Add motion service to itgui 2021-10-25 09:45:19 -07:00
9d58ea0ae7 Remove debug print and add error handling (itgui) 2021-10-25 00:11:41 -07:00
76875db7ea Use api package in itgui 2021-10-24 13:27:14 -07:00
be5bdc625b Add watch commands to itctl 2021-10-24 11:02:29 -07:00
0d0db949af Handle unknown request type 2021-10-24 01:11:57 -07:00
0d164aef3d Use request type for error response type 2021-10-24 01:09:27 -07:00
28610d9ebb Fix API package 2021-10-24 00:49:48 -07:00
dff34b484d Return request type for response type 2021-10-24 00:45:50 -07:00
2ea9f99db6 Disable firmware updating error once progress channel closed 2021-10-23 22:03:33 -07:00
44dc5f8e47 Use sent bytes to check if transfer complete 2021-10-23 19:36:23 -07:00
4d35912466 Remove test 2021-10-23 18:42:22 -07:00
ef29b9bee4 Update itctl to use api 2021-10-23 18:41:03 -07:00
e198b769f9 Generalize socket cancellation and update API accordingly 2021-10-23 18:03:17 -07:00
ef4bad94b5 Reorganize itctl structure 2021-10-23 15:11:04 -07:00
8cf2b47733 Add cancellation to api package 2021-10-22 22:30:58 -07:00
f20fdcb161 Add responses to cancellation requests 2021-10-22 22:15:35 -07:00
eeba9b2964 Add cancellation to watchable values 2021-10-22 22:14:01 -07:00
d7057e3f9c Add doc comments to api package 2021-10-22 21:01:18 -07:00
80a5867d6b Send response types in socket responses and create api package 2021-10-22 20:47:57 -07:00
f001dd6079 Update readme 2021-10-22 17:12:46 -07:00
b87586ef15 Add MotionValues type 2021-10-22 13:42:33 -07:00
295892c8a8 Add motion service to itctl 2021-10-22 13:40:16 -07:00
1492db7566 Implement motion service 2021-10-22 13:21:14 -07:00
e7de7bd7bb Update infinitime library version to fix intermittent DFU issues 2021-10-21 20:27:58 -07:00
7b849a3fc7 Remove replace directive 2021-10-15 00:27:10 -07:00
21d4964207 Mention call notifications in readme 2021-10-15 00:26:14 -07:00
604ea57c5f Add call notifications for ModemManager 2021-10-15 00:25:34 -07:00
b15cbb6349 Show update file names when selected in itgui 2021-10-07 13:38:13 -07:00
843e369bab Remove replace directive 2021-10-06 17:47:07 -07:00
eec7a3db48 Update infinitime library 2021-10-06 17:15:42 -07:00
c23201e18c Add and fix comments, fix transliteration maps so only first character is capitalized 2021-10-06 13:26:16 -07:00
4bc6eb9d41 Fix chinese transliteration when chinese characters are not followed by non-chinese characters 2021-10-06 13:15:49 -07:00
2a59e74a2c Only do init once for Armenian transliteration 2021-10-06 13:08:25 -07:00
df743cca96 Add init functions to transliterators 2021-10-06 09:41:33 -07:00
01bf493c77 Fix capital letters for Armenian transliteration 2021-10-05 09:09:19 -07:00
b6e9ad6160 Add Chinese transliteration via Pinyin conversion library 2021-10-04 22:26:16 -07:00
c56c0ae198 Update variable names and comments for interface-based transliteration 2021-10-04 20:23:54 -07:00
6b94030b83 Fix Korean transliteration 2021-10-04 20:06:08 -07:00
2bbd722ecd Add korean transliteration 2021-10-04 19:07:54 -07:00
73f16fcfef Use interface to allow for more complex transliteration implementations 2021-10-04 17:45:26 -07:00
9df6531023 Fix German transliteration for Ü and add attribution 2021-10-04 13:17:48 -07:00
1db2ca3395 Add transliteration 2021-10-04 01:05:01 -07:00
419b2f5a79 Break transfer loops after refreshing progress bar 2021-08-27 09:01:46 -07:00
44607ba9e2 Add fatal error dialog 2021-08-27 08:47:24 -07:00
f4d2f4e6eb Mention GUI in README 2021-08-26 09:01:03 -07:00
0721b7f9d4 Add comments to gui 2021-08-26 08:47:17 -07:00
b7bd385c43 Add GUI frontend 2021-08-25 21:18:24 -07:00
cbcefb149e Fix indentation in config 2021-08-24 20:35:25 -07:00
cb8d207249 Switch to iota for request types and move to types package 2021-08-24 20:32:17 -07:00
7786ea1d58 Fix debug config paths 2021-08-24 08:55:22 -07:00
6e16aa7a7a Add config defaults and run go fmt 2021-08-24 08:54:08 -07:00
91f7132d5e Create new config format 2021-08-24 08:33:41 -07:00
b186f77bea Remove replace directive 2021-08-22 15:07:45 -07:00
adb297c6dd Fix find and replace error 2021-08-22 13:53:32 -07:00
b4992cb393 Update infinitime library to fix connection bug 2021-08-22 13:13:37 -07:00
a5490b8364 Use new pair timeout option 2021-08-21 20:35:21 -07:00
44d1f5552b Mention interactive mode in readme 2021-08-21 19:07:59 -07:00
5e34f656b3 Disable completion command 2021-08-21 19:03:18 -07:00
560d19860e Fix binary download link 2021-08-21 18:52:11 -07:00
ea1a7fa9f4 Add badges 2021-08-21 18:48:43 -07:00
81fe634ed8 Add uninstall rule to makefile 2021-08-21 17:17:25 -07:00
281e1dcbac Add CI status to readme 2021-08-21 16:36:10 -07:00
56 changed files with 5113 additions and 1599 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/itctl
/itd
/itd
/itgui
/version.txt

View File

@@ -3,18 +3,28 @@ 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
install -Dm755 ./itctl $(BIN_PREFIX)/itctl
install -Dm644 ./itd.service $(SERVICE_PREFIX)/itd.service
install -Dm644 ./itd.toml $(CFG_PREFIX)/itd.toml
clean:
rm -f itctl
rm -f itd
uninstall:
rm $(BIN_PREFIX)/itd
rm $(BIN_PREFIX)/itctl
rm $(SERVICE_PREFIX)/itd.service
rm $(CFG_PREFIX)/itd.toml
.PHONY: all install clean
version:
printf "r%s.%s" "$(shell git rev-list --count HEAD)" "$(shell git rev-parse --short HEAD)" > version.txt
.PHONY: all clean install uninstall version

106
README.md
View File

@@ -3,13 +3,19 @@
`itd` is a daemon that uses my infinitime [library](https://go.arsenm.dev/infinitime) to interact with the [PineTime](https://www.pine64.org/pinetime/) running [InfiniTime](https://infinitime.io).
[![Build status](https://ci.appveyor.com/api/projects/status/xgj5sobw76ndqaod?svg=true)](https://ci.appveyor.com/project/moussaelianarsen/itd)
[![Binary downloads](https://img.shields.io/badge/download-binary-orange)](https://minio.arsenm.dev/minio/itd/)
[![AUR package](https://img.shields.io/aur/version/itd-git?label=itd-git&logo=archlinux)](https://aur.archlinux.org/packages/itd-git/)
---
### 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
@@ -23,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.
@@ -32,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.
@@ -41,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
@@ -52,10 +95,53 @@ 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
![Info tab](https://i.imgur.com/okxG9EI.png)
![Notify tab](https://i.imgur.com/DrVhOAq.png)
![Set time tab](https://i.imgur.com/j9civeY.png)
![Upgrade tab](https://i.imgur.com/1KY6fG4.png)
![Upgrade in progress](https://i.imgur.com/w5qbWAw.png)
---
#### Interactive mode
Running `itctl` by itself will open interactive mode. It's essentially a shell where you can enter commands. For example:
```
$ itctl
itctl> fw ver
1.3.0
itctl> get batt
81%
itctl> get heart
92 BPM
itctl> set time 2021-08-22T00:06:18-07:00
itctl> set time now
itctl> exit
```
---
### Installation
@@ -92,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).
---
@@ -100,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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

79
cmd/itctl/firmware.go Normal file
View 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
View 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
View File

@@ -0,0 +1,71 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/urfave/cli/v2"
)
func getAddress(c *cli.Context) error {
address, err := client.Address()
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
}

View File

@@ -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
View File

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

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

@@ -0,0 +1,24 @@
package main
import (
"time"
"github.com/urfave/cli/v2"
)
func setTime(c *cli.Context) error {
// Ensure required arguments
if c.Args().Len() < 1 {
return cli.Exit("Command time requires one argument", 1)
}
if c.Args().Get(0) == "now" {
return client.SetTime(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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -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-20210821070429-ea488067fb9b
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
)

955
go.sum

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
View File

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

View File

@@ -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")))
}
}
}()

View File

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

860
socket.go
View File

@@ -19,278 +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 existant
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 types.Request")
break
}
var reqData types.ReqDataNotify
// Decode data map to notify types.Request data
err = mapstructure.Decode(req.Data, &reqData)
if err != nil {
connErr(conn, err, "Error decoding types.Request data")
break
}
// Send notification to watch
err = dev.Notify(reqData.Title, reqData.Body)
if err != nil {
connErr(conn, err, "Error sending notification")
break
}
// Encode empty types.Response to connection
json.NewEncoder(conn).Encode(types.Response{})
case ReqTypeSetTime:
// If no data, return error
if req.Data == nil {
connErr(conn, nil, "Data required for settime types.Request")
break
}
// Get string from data or return error
reqTimeStr, ok := req.Data.(string)
if !ok {
connErr(conn, nil, "Data for settime types.Request must be RFC3339 formatted time string")
break
}
var reqTime time.Time
if reqTimeStr == "now" {
reqTime = time.Now()
} else {
// Parse time as RFC3339/ISO9601
reqTime, err = time.Parse(time.RFC3339, reqTimeStr)
if err != nil {
connErr(conn, err, "Invalid time format. Time string must be formatted as ISO8601 or the word `now`")
break
}
}
// Set time on watch
err = dev.SetTime(reqTime)
if err != nil {
connErr(conn, err, "Error setting device time")
break
}
// Encode empty types.Response to connection
json.NewEncoder(conn).Encode(types.Response{})
case ReqTypeFwUpgrade:
// If no data, return error
if req.Data == nil {
connErr(conn, nil, "Data required for firmware upgrade types.Request")
break
}
var reqData types.ReqDataFwUpgrade
// Decode data map to firmware upgrade types.Request data
err = mapstructure.Decode(req.Data, &reqData)
if err != nil {
connErr(conn, err, "Error decoding types.Request data")
break
}
switch reqData.Type {
case UpgradeTypeArchive:
// If less than one file, return error
if len(reqData.Files) < 1 {
connErr(conn, nil, "Archive upgrade requires one file with .zip extension")
break
}
// If file is not zip archive, return error
if filepath.Ext(reqData.Files[0]) != ".zip" {
connErr(conn, nil, "Archive upgrade file must be a zip archive")
break
}
// Load DFU archive
err := dev.DFU.LoadArchive(reqData.Files[0])
if err != nil {
connErr(conn, err, "Error loading archive file")
break
}
case UpgradeTypeFiles:
// If less than two files, return error
if len(reqData.Files) < 2 {
connErr(conn, nil, "Files upgrade requires two files. First with .dat and second with .bin extension.")
break
}
// If first file is not init packet, return error
if filepath.Ext(reqData.Files[0]) != ".dat" {
connErr(conn, nil, "First file must be a .dat file")
break
}
// If second file is not firmware image, return error
if filepath.Ext(reqData.Files[1]) != ".bin" {
connErr(conn, nil, "Second file must be a .bin file")
break
}
// Load individual DFU files
err := dev.DFU.LoadFiles(reqData.Files[0], reqData.Files[1])
if err != nil {
connErr(conn, err, "Error loading firmware files")
break
}
}
go func() {
// Get progress
progress := dev.DFU.Progress()
// For every progress event
for event := range progress {
// Encode event on connection
json.NewEncoder(conn).Encode(types.Response{
Value: event,
})
}
}()
// Set firmwareUpdating
firmwareUpdating = true
// Start DFU
err = dev.DFU.Start()
if err != nil {
connErr(conn, err, "Error performing upgrade")
break
}
firmwareUpdating = false
}
}
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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
unknown

277
weather.go Normal file
View 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)))
}