Compare commits

..

22 Commits

Author SHA1 Message Date
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
19 changed files with 862 additions and 171 deletions

View File

@ -177,7 +177,7 @@ To cross compile, simply set the go environment variables. For example, for Pine
make GOOS=linux GOARCH=arm64 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).
--- ---

96
api/fs.go Normal file
View File

@ -0,0 +1,96 @@
package api
import (
"github.com/mitchellh/mapstructure"
"go.arsenm.dev/itd/internal/types"
)
func (c *Client) Rename(old, new string) error {
_, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeMove,
Files: []string{old, new},
},
})
if err != nil {
return err
}
return nil
}
func (c *Client) Remove(paths ...string) error {
_, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeDelete,
Files: paths,
},
})
if err != nil {
return err
}
return nil
}
func (c *Client) Mkdir(paths ...string) error {
_, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeMkdir,
Files: paths,
},
})
if err != nil {
return err
}
return nil
}
func (c *Client) ReadDir(path string) ([]types.FileInfo, error) {
res, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeList,
Files: []string{path},
},
})
if err != nil {
return nil, err
}
var out []types.FileInfo
err = mapstructure.Decode(res.Value, &out)
if err != nil {
return nil, err
}
return out, nil
}
func (c *Client) ReadFile(path string) (string, error) {
res, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeRead,
Files: []string{path},
},
})
if err != nil {
return "", err
}
return res.Value.(string), nil
}
func (c *Client) WriteFile(path, data string) error {
_, err := c.request(types.Request{
Type: types.ReqTypeFS,
Data: types.ReqDataFS{
Type: types.FSTypeWrite,
Files: []string{path},
Data: data,
},
})
if err != nil {
return err
}
return nil
}

210
calls.go
View File

@ -1,85 +1,90 @@
package main package main
import ( import (
"bufio" "sync"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/godbus/dbus/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"go.arsenm.dev/infinitime" "go.arsenm.dev/infinitime"
) )
func initCallNotifs(dev *infinitime.Device) error { func initCallNotifs(dev *infinitime.Device) error {
// Define rule to filter dbus messages // Connect to system bus. This connection is for method calls.
rule := "type='signal',sender='org.freedesktop.ModemManager1',interface='org.freedesktop.ModemManager1.Modem.Voice',member='CallAdded'" conn, err := newSystemBusConn()
// Use dbus-monitor command with profiling output as a workaround
// because go-bluetooth seems to monopolize the system bus connection
// which makes monitoring show only bluez-related messages.
cmd := exec.Command("dbus-monitor", "--system", "--profile", rule)
// Get command output pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Run command asynchronously
err = cmd.Start()
if err != nil { if err != nil {
return err return err
} }
// Create new scanner for command output // Check if modem manager interface exists
scanner := bufio.NewScanner(stdout) 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() { go func() {
// For each line in output // For every message received
for scanner.Scan() { for event := range callCh {
// Get line as string // Get path to call object
text := scanner.Text() callPath := event.Body[0].(dbus.ObjectPath)
// Get call object
callObj = conn.Object("org.freedesktop.ModemManager1", callPath)
// If line starts with "#", it is part of // Get phone number from call object using method call connection
// the field format, skip it. phoneNum, err := getPhoneNum(conn, callObj)
if strings.HasPrefix(text, "#") { if err != nil {
log.Error().Err(err).Msg("Error getting phone number")
continue continue
} }
// Split line into fields. The order is as follows: // Send call notification to InfiniTime
// type timestamp serial sender destination path interface member
fields := strings.Fields(text)
// Field 7 is Member. Make sure it is "CallAdded".
if fields[7] == "CallAdded" {
// Get Modem ID from modem path
modemID := parseModemID(fields[5])
// Get call ID of current call
callID, err := getCurrentCallID(modemID)
if err != nil {
continue
}
// Get phone number of current call
phoneNum, err := getPhoneNum(callID)
if err != nil {
continue
}
// Send call notification to PineTime
resCh, err := dev.NotifyCall(phoneNum) resCh, err := dev.NotifyCall(phoneNum)
if err != nil { if err != nil {
continue continue
} }
go func() {
go respHandlerOnce.Do(func() {
// Wait for PineTime response // Wait for PineTime response
res := <-resCh for res := range resCh {
switch res { switch res {
case infinitime.CallStatusAccepted: case infinitime.CallStatusAccepted:
// Attempt to accept call // Attempt to accept call
err = acceptCall(callID) err = acceptCall(conn, callObj)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Error accepting call") log.Warn().Err(err).Msg("Error accepting call")
} }
case infinitime.CallStatusDeclined: case infinitime.CallStatusDeclined:
// Attempt to decline call // Attempt to decline call
err = declineCall(callID) err = declineCall(conn, callObj)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Error declining call") log.Warn().Err(err).Msg("Error declining call")
} }
@ -87,90 +92,51 @@ func initCallNotifs(dev *infinitime.Device) error {
// Warn about unimplemented muting // Warn about unimplemented muting
log.Warn().Msg("Muting calls is not implemented") log.Warn().Msg("Muting calls is not implemented")
} }
}()
} }
})
} }
}() }()
log.Info().Msg("Relaying calls to InfiniTime")
return nil return nil
} }
func parseModemID(modemPath string) int { func modemManagerExists(conn *dbus.Conn) (bool, error) {
// Split path by "/" var names []string
splitPath := strings.Split(modemPath, "/") err := conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names)
// Get last element and convert to integer if err != nil {
id, _ := strconv.Atoi(splitPath[len(splitPath)-1]) return false, err
return id }
return strSlcContains(names, "org.freedesktop.ModemManager1"), nil
} }
func getCurrentCallID(modemID int) (int, error) { // getPhoneNum gets a phone number from a call object using a DBus connection
// Create mmcli command func getPhoneNum(conn *dbus.Conn, callObj dbus.BusObject) (string, error) {
cmd := exec.Command("mmcli", "--voice-list-calls", "-m", fmt.Sprint(modemID), "-J") var out string
// Run command and get output // Get number property on DBus object and store return value in out
data, err := cmd.Output() err := callObj.StoreProperty("org.freedesktop.ModemManager1.Call.Number", &out)
if err != nil {
return 0, err
}
var calls map[string][]string
// Decode JSON from command output
err = json.Unmarshal(data, &calls)
if err != nil {
return 0, err
}
// Get first call in output
firstCall := calls["modem.voice.call"][0]
// Split path by "/"
splitCall := strings.Split(firstCall, "/")
// Return last element converted to integer
return strconv.Atoi(splitCall[len(splitCall)-1])
}
func getPhoneNum(callID int) (string, error) {
// Create dbus-send command
cmd := exec.Command("dbus-send",
"--dest=org.freedesktop.ModemManager1",
"--system",
"--print-reply=literal",
"--type=method_call",
fmt.Sprintf("/org/freedesktop/ModemManager1/Call/%d", callID),
"org.freedesktop.DBus.Properties.Get",
"string:org.freedesktop.ModemManager1.Call",
"string:Number",
)
// Run command and get output
numData, err := cmd.Output()
if err != nil { if err != nil {
return "", err return "", err
} }
// Split output into fields return out, nil
num := strings.Fields(string(numData))
// Return last field
return num[len(num)-1], nil
} }
func acceptCall(callID int) error { // getPhoneNum accepts a call using a DBus connection
// Create dbus-send command func acceptCall(conn *dbus.Conn, callObj dbus.BusObject) error {
cmd := exec.Command("dbus-send", // Call Accept() method on DBus object
"--dest=org.freedesktop.ModemManager1", call := callObj.Call("org.freedesktop.ModemManager1.Call.Accept", 0)
"--print-reply", if call.Err != nil {
"--system", return call.Err
"--type=method_call", }
fmt.Sprintf("/org/freedesktop/ModemManager1/Call/%d", callID), return nil
"org.freedesktop.ModemManager1.Call.Accept",
)
// Run command and return errpr
return cmd.Run()
} }
func declineCall(callID int) error { // getPhoneNum declines a call using a DBus connection
// Create dbus-send command func declineCall(conn *dbus.Conn, callObj dbus.BusObject) error {
cmd := exec.Command("dbus-send", // Call Hangup() method on DBus object
"--dest=org.freedesktop.ModemManager1", call := callObj.Call("org.freedesktop.ModemManager1.Call.Hangup", 0)
"--print-reply", if call.Err != nil {
"--system", return call.Err
"--type=method_call", }
fmt.Sprintf("/org/freedesktop/ModemManager1/Call/%d", callID), return nil
"org.freedesktop.ModemManager1.Call.Hangup",
)
// Run command and return errpr
return cmd.Run()
} }

View File

@ -0,0 +1,35 @@
/*
* 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 filesystem
import (
"github.com/spf13/cobra"
"go.arsenm.dev/itd/cmd/itctl/root"
)
// filesystemCmd represents the filesystem command
var filesystemCmd = &cobra.Command{
Use: "filesystem",
Aliases: []string{"fs"},
Short: "Perform filesystem operations on the PineTime",
}
func init() {
root.RootCmd.AddCommand(filesystemCmd)
}

View File

@ -0,0 +1,56 @@
/*
* 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 filesystem
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// listCmd represents the list command
var listCmd = &cobra.Command{
Use: "list [path]",
Aliases: []string{"ls"},
Short: "List a directory",
Run: func(cmd *cobra.Command, args []string) {
dirPath := "/"
if len(args) > 0 {
dirPath = args[0]
}
client := viper.Get("client").(*api.Client)
listing, err := client.ReadDir(dirPath)
if err != nil {
log.Fatal().Err(err).Msg("Error getting directory listing")
}
for _, entry := range listing {
fmt.Println(entry)
}
},
}
func init() {
filesystemCmd.AddCommand(listCmd)
}

View File

@ -0,0 +1,49 @@
/*
* 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 filesystem
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// mkdirCmd represents the mkdir command
var mkdirCmd = &cobra.Command{
Use: "mkdir <path...>",
Short: "Create a new directory",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
cmd.Usage()
log.Fatal().Msg("Command mkdir requires one or more arguments")
}
client := viper.Get("client").(*api.Client)
err := client.Mkdir(args...)
if err != nil {
log.Fatal().Err(err).Msg("Error creating directory")
}
},
}
func init() {
filesystemCmd.AddCommand(mkdirCmd)
}

View File

@ -0,0 +1,50 @@
/*
* 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 filesystem
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// moveCmd represents the move command
var moveCmd = &cobra.Command{
Use: "move <old> <new>",
Aliases: []string{"mv"},
Short: "Move a file or directory",
Run: func(cmd *cobra.Command, args []string) {
if len(args) != 2 {
cmd.Usage()
log.Fatal().Msg("Command move requires two arguments")
}
client := viper.Get("client").(*api.Client)
err := client.Rename(args[0], args[1])
if err != nil {
log.Fatal().Err(err).Msg("Error moving file or directory")
}
},
}
func init() {
filesystemCmd.AddCommand(moveCmd)
}

View File

@ -0,0 +1,73 @@
/*
* 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 filesystem
import (
"os"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// readCmd represents the read command
var readCmd = &cobra.Command{
Use: `read <remote path> <local path | "-">`,
Short: "Read a file from InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
if len(args) != 2 {
cmd.Usage()
log.Fatal().Msg("Command read requires two arguments")
}
start := time.Now()
client := viper.Get("client").(*api.Client)
data, err := client.ReadFile(args[0])
if err != nil {
log.Fatal().Err(err).Msg("Error moving file or directory")
}
var suffix string
var out *os.File
if args[1] == "-" {
out = os.Stdout
suffix = "\n"
} else {
out, err = os.Create(args[1])
if err != nil {
log.Fatal().Err(err).Msg("Error opening local file")
}
}
n, err := out.WriteString(data)
if err != nil {
log.Fatal().Err(err).Msg("Error writing to local file")
}
out.WriteString(suffix)
log.Info().Msgf("Read %d bytes in %s", n, time.Since(start))
},
}
func init() {
filesystemCmd.AddCommand(readCmd)
}

View File

@ -0,0 +1,50 @@
/*
* 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 filesystem
import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// removeCmd represents the remove command
var removeCmd = &cobra.Command{
Use: "remove <path...>",
Aliases: []string{"rm"},
Short: "Create a new directory",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
cmd.Usage()
log.Fatal().Msg("Command mkdir requires one or more arguments")
}
client := viper.Get("client").(*api.Client)
err := client.Remove(args...)
if err != nil {
log.Fatal().Err(err).Msg("Error removing file or directory")
}
},
}
func init() {
filesystemCmd.AddCommand(removeCmd)
}

View File

@ -0,0 +1,72 @@
/*
* 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 filesystem
import (
"io"
"os"
"time"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.arsenm.dev/itd/api"
)
// writeCmd represents the write command
var writeCmd = &cobra.Command{
Use: `write <local path | "-"> <remote path>`,
Short: "Write a file to InfiniTime",
Run: func(cmd *cobra.Command, args []string) {
if len(args) != 2 {
cmd.Usage()
log.Fatal().Msg("Command write requires two arguments")
}
start := time.Now()
client := viper.Get("client").(*api.Client)
var in *os.File
if args[0] == "-" {
in = os.Stdin
} else {
fl, err := os.Open(args[0])
if err != nil {
log.Fatal().Err(err).Msg("Error opening local file")
}
in = fl
}
data, err := io.ReadAll(in)
if err != nil {
log.Fatal().Err(err).Msg("Error moving file or directory")
}
err = client.WriteFile(args[1], string(data))
if err != nil {
log.Fatal().Err(err).Msg("Error writing to remote file")
}
log.Info().Msgf("Wrote %d bytes in %s", len(data), time.Since(start))
},
}
func init() {
filesystemCmd.AddCommand(writeCmd)
}

View File

@ -25,6 +25,7 @@ import (
"go.arsenm.dev/itd/cmd/itctl/root" "go.arsenm.dev/itd/cmd/itctl/root"
_ "go.arsenm.dev/itd/cmd/itctl/set" _ "go.arsenm.dev/itd/cmd/itctl/set"
_ "go.arsenm.dev/itd/cmd/itctl/watch" _ "go.arsenm.dev/itd/cmd/itctl/watch"
_ "go.arsenm.dev/itd/cmd/itctl/filesystem"
"os" "os"

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
}

2
go.mod
View File

@ -25,7 +25,7 @@ require (
github.com/srwiley/oksvg v0.0.0-20210519022825-9fc0c575d5fe // indirect github.com/srwiley/oksvg v0.0.0-20210519022825-9fc0c575d5fe // indirect
github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 // indirect github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 // indirect
github.com/yuin/goldmark v1.4.1 // indirect github.com/yuin/goldmark v1.4.1 // indirect
go.arsenm.dev/infinitime v0.0.0-20211122091416-ec43bad46652 go.arsenm.dev/infinitime v0.0.0-20211126043306-522c10a9c0a7
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20211011170408-caeb26a5c8c0 // indirect golang.org/x/net v0.0.0-20211011170408-caeb26a5c8c0 // indirect
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect

4
go.sum
View File

@ -367,8 +367,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.3.8/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.8/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1 h1:/vn0k+RBvwlxEmP5E7SZMqNxPhfMVFEJiykr15/0XKM= github.com/yuin/goldmark v1.4.1 h1:/vn0k+RBvwlxEmP5E7SZMqNxPhfMVFEJiykr15/0XKM=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.arsenm.dev/infinitime v0.0.0-20211122091416-ec43bad46652 h1:2Z09crdXIs+aMy1xwuD6w2kml+EObfmzgCBaOxoZIv0= go.arsenm.dev/infinitime v0.0.0-20211126043306-522c10a9c0a7 h1:m6BVtAiWMRbfUgBZVthXq2eZhSwDxdtOi8Dh/59hXik=
go.arsenm.dev/infinitime v0.0.0-20211122091416-ec43bad46652/go.mod h1:kNBKxQfqeLUfi13GM6tB1kSvLm8HlZ7PM47AYeJQIiw= go.arsenm.dev/infinitime v0.0.0-20211126043306-522c10a9c0a7/go.mod h1:TzAhsz7TAqEm/vWhgMvmIxHS5jt46hqKkPvr6cqvVyA=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=

View File

@ -1,5 +1,10 @@
package types package types
import (
"fmt"
"strconv"
)
const ( const (
ReqTypeHeartRate = iota ReqTypeHeartRate = iota
ReqTypeBattLevel ReqTypeBattLevel
@ -15,6 +20,7 @@ const (
ReqTypeStepCount ReqTypeStepCount
ReqTypeWatchStepCount ReqTypeWatchStepCount
ReqTypeCancel ReqTypeCancel
ReqTypeFS
) )
const ( const (
@ -22,6 +28,21 @@ const (
UpgradeTypeFiles UpgradeTypeFiles
) )
const (
FSTypeWrite = iota
FSTypeRead
FSTypeMove
FSTypeDelete
FSTypeList
FSTypeMkdir
)
type ReqDataFS struct {
Type int `json:"type"`
Files []string `json:"files"`
Data string `json:"data,omitempty"`
}
type ReqDataFwUpgrade struct { type ReqDataFwUpgrade struct {
Type int Type int
Files []string Files []string
@ -56,3 +77,66 @@ type MotionValues struct {
Y int16 Y int16
Z int16 Z int16
} }
type FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
}
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
}

View File

@ -26,7 +26,11 @@ import (
"go.arsenm.dev/infinitime" "go.arsenm.dev/infinitime"
) )
var firmwareUpdating = false var (
firmwareUpdating = false
// The FS must be updated when the watch is reconnected
updateFS = false
)
func main() { func main() {
infinitime.Init() infinitime.Init()
@ -61,6 +65,8 @@ func main() {
log.Error().Err(err).Msg("Error sending notification to InfiniTime") log.Error().Err(err).Msg("Error sending notification to InfiniTime")
} }
} }
updateFS = true
}) })
// Get firmware version // Get firmware version

View File

@ -26,45 +26,22 @@ import (
) )
func initMusicCtrl(dev *infinitime.Device) error { func initMusicCtrl(dev *infinitime.Device) error {
// On player status change, set status player.Init()
err := player.Status(func(newStatus bool) {
if !firmwareUpdating {
dev.Music.SetStatus(newStatus)
}
})
if err != nil {
return err
}
// On player title change, set track player.OnChange(func(ct player.ChangeType, val string) {
err = player.Metadata("title", func(newTitle string) {
if !firmwareUpdating { if !firmwareUpdating {
dev.Music.SetTrack(newTitle) switch ct {
case player.ChangeTypeStatus:
dev.Music.SetStatus(val == "Playing")
case player.ChangeTypeTitle:
dev.Music.SetTrack(val)
case player.ChangeTypeAlbum:
dev.Music.SetAlbum(val)
case player.ChangeTypeArtist:
dev.Music.SetArtist(val)
}
} }
}) })
if err != nil {
return err
}
// On player album change, set album
err = player.Metadata("album", func(newAlbum string) {
if !firmwareUpdating {
dev.Music.SetAlbum(newAlbum)
}
})
if err != nil {
return err
}
// On player artist change, set artist
err = player.Metadata("artist", func(newArtist string) {
if !firmwareUpdating {
dev.Music.SetArtist(newArtist)
}
})
if err != nil {
return err
}
// Watch for music events // Watch for music events
musicEvtCh, err := dev.Music.WatchEvents() musicEvtCh, err := dev.Music.WatchEvents()

View File

@ -30,7 +30,7 @@ import (
func initNotifRelay(dev *infinitime.Device) error { func initNotifRelay(dev *infinitime.Device) error {
// Connect to dbus session bus // Connect to dbus session bus
bus, err := dbus.SessionBus() bus, err := newSessionBusConn()
if err != nil { if err != nil {
return err return err
} }

143
socket.go
View File

@ -22,6 +22,7 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@ -32,6 +33,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.arsenm.dev/infinitime" "go.arsenm.dev/infinitime"
"go.arsenm.dev/infinitime/blefs"
"go.arsenm.dev/itd/internal/types" "go.arsenm.dev/itd/internal/types"
"go.arsenm.dev/itd/translit" "go.arsenm.dev/itd/translit"
) )
@ -78,6 +80,11 @@ func startSocket(dev *infinitime.Device) error {
return err return err
} }
fs, err := dev.FS()
if err != nil {
log.Warn().Err(err).Msg("Error getting BLE filesystem")
}
go func() { go func() {
for { for {
// Accept socket connection // Accept socket connection
@ -87,7 +94,7 @@ func startSocket(dev *infinitime.Device) error {
} }
// Concurrently handle connection // Concurrently handle connection
go handleConnection(conn, dev) go handleConnection(conn, dev, fs)
} }
}() }()
@ -97,9 +104,23 @@ func startSocket(dev *infinitime.Device) error {
return nil return nil
} }
func handleConnection(conn net.Conn, dev *infinitime.Device) { func handleConnection(conn net.Conn, dev *infinitime.Device, fs *blefs.FS) {
defer conn.Close() defer conn.Close()
// If an FS update is required (reconnect ocurred)
if updateFS {
// Get new FS
newFS, err := dev.FS()
if err != nil {
fs = nil
log.Warn().Err(err).Msg("Error updating BLE filesystem")
}
// Set FS pointer to new FS
*fs = *newFS
// Reset updateFS
updateFS = false
}
// Create new scanner on connection // Create new scanner on connection
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
for scanner.Scan() { for scanner.Scan() {
@ -431,6 +452,124 @@ func handleConnection(conn net.Conn, dev *infinitime.Device) {
break break
} }
firmwareUpdating = false firmwareUpdating = false
case types.ReqTypeFS:
if fs == nil {
connErr(conn, req.Type, nil, "BLE filesystem is not available")
break
}
// If no data, return error
if req.Data == nil {
connErr(conn, req.Type, nil, "Data required for filesystem operations")
break
}
var reqData types.ReqDataFS
// Decode data map to firmware upgrade request data
err = mapstructure.Decode(req.Data, &reqData)
if err != nil {
connErr(conn, req.Type, err, "Error decoding request data")
break
}
switch reqData.Type {
case types.FSTypeDelete:
if len(reqData.Files) == 0 {
connErr(conn, req.Type, nil, "Remove FS command requires at least one file")
break
}
for _, file := range reqData.Files {
err := fs.Remove(file)
if err != nil {
connErr(conn, req.Type, err, "Error removing file")
break
}
}
json.NewEncoder(conn).Encode(types.Response{Type: req.Type})
case types.FSTypeMove:
if len(reqData.Files) != 2 {
connErr(conn, req.Type, nil, "Move FS command requires an old path and new path in the files list")
break
}
err := fs.Rename(reqData.Files[0], reqData.Files[1])
if err != nil {
connErr(conn, req.Type, err, "Error moving file")
break
}
json.NewEncoder(conn).Encode(types.Response{Type: req.Type})
case types.FSTypeMkdir:
if len(reqData.Files) == 0 {
connErr(conn, req.Type, nil, "Mkdir FS command requires at least one file")
break
}
for _, file := range reqData.Files {
err := fs.Mkdir(file)
if err != nil {
connErr(conn, req.Type, err, "Error creating directory")
break
}
}
json.NewEncoder(conn).Encode(types.Response{Type: req.Type})
case types.FSTypeList:
if len(reqData.Files) != 1 {
connErr(conn, req.Type, nil, "List FS command requires a path to list in the files list")
break
}
entries, err := fs.ReadDir(reqData.Files[0])
if err != nil {
connErr(conn, req.Type, err, "Error reading directory")
break
}
var out []types.FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
connErr(conn, req.Type, err, "Error getting file info")
break
}
out = append(out, types.FileInfo{
Name: info.Name(),
Size: info.Size(),
IsDir: info.IsDir(),
})
}
json.NewEncoder(conn).Encode(types.Response{
Type: req.Type,
Value: out,
})
case types.FSTypeWrite:
if len(reqData.Files) != 1 {
connErr(conn, req.Type, nil, "Write FS command requires a path to the file to write")
break
}
file, err := fs.Create(reqData.Files[0], uint32(len(reqData.Data)))
if err != nil {
connErr(conn, req.Type, err, "Error creating file")
break
}
_, err = file.WriteString(reqData.Data)
if err != nil {
connErr(conn, req.Type, err, "Error writing to file")
break
}
json.NewEncoder(conn).Encode(types.Response{Type: req.Type})
case types.FSTypeRead:
if len(reqData.Files) != 1 {
connErr(conn, req.Type, nil, "Read FS command requires a path to the file to read")
break
}
file, err := fs.Open(reqData.Files[0])
if err != nil {
connErr(conn, req.Type, err, "Error opening file")
break
}
data, err := io.ReadAll(file)
if err != nil {
connErr(conn, req.Type, err, "Error reading from file")
break
}
json.NewEncoder(conn).Encode(types.Response{
Type: req.Type,
Value: string(data),
})
}
case types.ReqTypeCancel: case types.ReqTypeCancel:
if req.Data == nil { if req.Data == nil {
connErr(conn, req.Type, nil, "No data provided. Cancel request requires request ID string as data.") connErr(conn, req.Type, nil, "No data provided. Cancel request requires request ID string as data.")