618 lines
13 KiB
Go
618 lines
13 KiB
Go
package infinitime
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"math"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"go.elara.ws/itd/internal/fsproto"
|
|
"tinygo.org/x/bluetooth"
|
|
)
|
|
|
|
// FS represents a remote BLE filesystem
|
|
type FS struct {
|
|
mtx sync.Mutex
|
|
dev *Device
|
|
}
|
|
|
|
// Stat gets information about a file at the given path.
|
|
//
|
|
// WARNING: Since there's no stat command in the BLE FS protocol,
|
|
// this function does a ReadDir and then finds the requested file
|
|
// in the results, which makes it pretty slow.
|
|
func (ifs *FS) Stat(p string) (fs.FileInfo, error) {
|
|
dir := path.Dir(p)
|
|
entries, err := ifs.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.Name() == path.Base(p) {
|
|
return entry.Info()
|
|
}
|
|
}
|
|
|
|
return nil, fsproto.ErrFileNotExists
|
|
}
|
|
|
|
// Remove removes a file or empty directory at the given path.
|
|
//
|
|
// For a function that removes directories recursively, see [FS.RemoveAll]
|
|
func (ifs *FS) Remove(path string) error {
|
|
ifs.mtx.Lock()
|
|
defer ifs.mtx.Unlock()
|
|
|
|
char, err := ifs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ifs.requestThenAwaitResponse(
|
|
char,
|
|
fsproto.DeleteFileOpcode,
|
|
fsproto.DeleteFileRequest{
|
|
PathLen: uint16(len(path)),
|
|
Path: path,
|
|
},
|
|
func(buf []byte) (bool, error) {
|
|
var mdr fsproto.DeleteFileResponse
|
|
return true, fsproto.ReadResponse(buf, fsproto.DeleteFileResp, &mdr)
|
|
},
|
|
)
|
|
}
|
|
|
|
// Rename moves a file or directory from an old path to a new path.
|
|
func (ifs *FS) Rename(old, new string) error {
|
|
ifs.mtx.Lock()
|
|
defer ifs.mtx.Unlock()
|
|
|
|
char, err := ifs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ifs.requestThenAwaitResponse(
|
|
char,
|
|
fsproto.MoveFileOpcode,
|
|
fsproto.MoveFileRequest{
|
|
OldPathLen: uint16(len(old)),
|
|
OldPath: old,
|
|
NewPathLen: uint16(len(new)),
|
|
NewPath: new,
|
|
},
|
|
func(buf []byte) (bool, error) {
|
|
var mfr fsproto.MoveFileResponse
|
|
return true, fsproto.ReadResponse(buf, fsproto.MoveFileResp, &mfr)
|
|
},
|
|
)
|
|
}
|
|
|
|
// Mkdir creates a new directory at the specified path.
|
|
//
|
|
// For a function that creates necessary parents as well, see [FS.MkdirAll]
|
|
func (ifs *FS) Mkdir(path string) error {
|
|
ifs.mtx.Lock()
|
|
defer ifs.mtx.Unlock()
|
|
|
|
char, err := ifs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ifs.requestThenAwaitResponse(
|
|
char,
|
|
fsproto.MakeDirectoryOpcode,
|
|
fsproto.MkdirRequest{
|
|
PathLen: uint16(len(path)),
|
|
Path: path,
|
|
},
|
|
func(buf []byte) (bool, error) {
|
|
var mdr fsproto.MkdirResponse
|
|
return true, fsproto.ReadResponse(buf, fsproto.MakeDirectoryResp, &mdr)
|
|
},
|
|
)
|
|
}
|
|
|
|
// ReadDir reads the directory at the specified path and returns a list of directory entries.
|
|
func (ifs *FS) ReadDir(path string) ([]fs.DirEntry, error) {
|
|
ifs.mtx.Lock()
|
|
defer ifs.mtx.Unlock()
|
|
|
|
char, err := ifs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out []fs.DirEntry
|
|
return out, ifs.requestThenAwaitResponse(
|
|
char,
|
|
fsproto.ListDirectoryOpcode,
|
|
fsproto.ListDirRequest{
|
|
PathLen: uint16(len(path)),
|
|
Path: path,
|
|
},
|
|
func(buf []byte) (bool, error) {
|
|
var ldr fsproto.ListDirResponse
|
|
err := fsproto.ReadResponse(buf, fsproto.ListDirectoryResp, &ldr)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
|
|
if ldr.EntryNum == ldr.TotalEntries {
|
|
return true, nil
|
|
}
|
|
|
|
out = append(out, DirEntry{
|
|
flags: ldr.Flags,
|
|
modtime: ldr.ModTime,
|
|
size: ldr.FileSize,
|
|
path: string(ldr.Path),
|
|
})
|
|
|
|
return false, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// RemoveAll removes the file at the specified path and any children it contains,
|
|
// similar to the rm -r command.
|
|
func (ifs *FS) RemoveAll(p string) error {
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
|
|
if path.Clean(p) == "/" {
|
|
return fsproto.ErrNoRemoveRoot
|
|
}
|
|
|
|
fi, err := ifs.Stat(p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if fi.IsDir() {
|
|
return ifs.removeWithChildren(p)
|
|
} else {
|
|
err = ifs.Remove(p)
|
|
|
|
var code int8
|
|
if err, ok := err.(fsproto.Error); ok {
|
|
code = err.Code
|
|
}
|
|
|
|
if err != nil && code != -2 {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// removeWithChildren removes the directory at the given path and its children recursively.
|
|
func (ifs *FS) removeWithChildren(p string) error {
|
|
list, err := ifs.ReadDir(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, entry := range list {
|
|
name := entry.Name()
|
|
|
|
if name == "." || name == ".." {
|
|
continue
|
|
}
|
|
entryPath := path.Join(p, name)
|
|
|
|
if entry.IsDir() {
|
|
err = ifs.removeWithChildren(entryPath)
|
|
} else {
|
|
err = ifs.Remove(entryPath)
|
|
}
|
|
|
|
var code int8
|
|
if err, ok := err.(fsproto.Error); ok {
|
|
code = err.Code
|
|
}
|
|
|
|
if err != nil && code != -2 {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return ifs.Remove(p)
|
|
}
|
|
|
|
// MkdirAll creates a directory and any necessary parents in the file system,
|
|
// similar to the mkdir -p command.
|
|
func (ifs *FS) MkdirAll(path string) error {
|
|
if path == "" || path == "/" {
|
|
return nil
|
|
}
|
|
|
|
splitPath := strings.Split(path, "/")
|
|
for i := 1; i < len(splitPath); i++ {
|
|
curPath := strings.Join(splitPath[0:i+1], "/")
|
|
|
|
err := ifs.Mkdir(curPath)
|
|
|
|
var code int8
|
|
if err, ok := err.(fsproto.Error); ok {
|
|
code = err.Code
|
|
}
|
|
|
|
if err != nil && code != -17 {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var _ fs.File = (*File)(nil)
|
|
|
|
// File represents a remote file on a BLE filesystem.
|
|
//
|
|
// If ProgressFunc is set, it will be called whenever a read or write happens
|
|
// with the amount of bytes transferred and the total size of the file.
|
|
type File struct {
|
|
fs *FS
|
|
path string
|
|
offset uint32
|
|
size uint32
|
|
readOnly bool
|
|
closed bool
|
|
ProgressFunc func(transferred, total uint32)
|
|
}
|
|
|
|
// Open opens an existing file at the specified path.
|
|
// It returns a handle for the file and an error, if any.
|
|
func (ifs *FS) Open(path string) (*File, error) {
|
|
return &File{
|
|
fs: ifs,
|
|
path: path,
|
|
offset: 0,
|
|
readOnly: true,
|
|
}, nil
|
|
}
|
|
|
|
// Create creates a new file with the specified path and size.
|
|
// It returns a handle for the created file and an error, if any.
|
|
func (ifs *FS) Create(path string, size uint32) (*File, error) {
|
|
return &File{
|
|
fs: ifs,
|
|
path: path,
|
|
offset: 0,
|
|
size: size,
|
|
}, nil
|
|
}
|
|
|
|
// Write writes data from the byte slice b to the file.
|
|
// It returns the number of bytes written and an error, if any.
|
|
func (fl *File) Write(b []byte) (int, error) {
|
|
if fl.closed {
|
|
return 0, fsproto.ErrFileClosed
|
|
}
|
|
|
|
if fl.readOnly {
|
|
return 0, fsproto.ErrFileReadOnly
|
|
}
|
|
|
|
fl.fs.mtx.Lock()
|
|
defer fl.fs.mtx.Unlock()
|
|
|
|
char, err := fl.fs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer char.EnableNotifications(nil)
|
|
|
|
var chunkLen uint32
|
|
|
|
dataLen := uint32(len(b))
|
|
transferred := uint32(0)
|
|
mtu := uint32(fl.fs.mtu(char))
|
|
|
|
// continueCh is used to prevent race conditions. When the
|
|
// request loop starts, it reads from continueCh, blocking it
|
|
// until it's "released" by the notification function after
|
|
// the response is processed.
|
|
continueCh := make(chan struct{}, 2)
|
|
var notifErr error
|
|
err = char.EnableNotifications(func(buf []byte) {
|
|
var wfr fsproto.WriteFileResponse
|
|
err = fsproto.ReadResponse(buf, fsproto.WriteFileResp, &wfr)
|
|
if err != nil {
|
|
notifErr = err
|
|
char.EnableNotifications(nil)
|
|
close(continueCh)
|
|
return
|
|
}
|
|
|
|
transferred += chunkLen
|
|
fl.offset += chunkLen
|
|
|
|
if wfr.FreeSpace == 0 || transferred == dataLen {
|
|
char.EnableNotifications(nil)
|
|
close(continueCh)
|
|
return
|
|
}
|
|
|
|
if fl.ProgressFunc != nil {
|
|
fl.ProgressFunc(transferred, fl.size)
|
|
}
|
|
|
|
// Release the request loop
|
|
continueCh <- struct{}{}
|
|
})
|
|
|
|
err = fsproto.WriteRequest(char, fsproto.WriteFileHeaderOpcode, fsproto.WriteFileHeaderRequest{
|
|
PathLen: uint16(len(fl.path)),
|
|
Offset: fl.offset,
|
|
FileSize: fl.size,
|
|
Path: fl.path,
|
|
})
|
|
if err != nil {
|
|
return int(transferred), err
|
|
}
|
|
|
|
for range continueCh {
|
|
if notifErr != nil {
|
|
return int(transferred), notifErr
|
|
}
|
|
|
|
amountLeft := dataLen - transferred
|
|
chunkLen = mtu
|
|
if amountLeft < mtu {
|
|
chunkLen = amountLeft
|
|
}
|
|
|
|
err = fsproto.WriteRequest(char, fsproto.WriteFileOpcode, fsproto.WriteFileRequest{
|
|
Status: 0x01,
|
|
Offset: fl.offset,
|
|
ChunkLen: chunkLen,
|
|
Data: b[transferred : transferred+chunkLen],
|
|
})
|
|
if err != nil {
|
|
return int(transferred), err
|
|
}
|
|
}
|
|
|
|
return int(transferred), notifErr
|
|
}
|
|
|
|
// Read reads data from the file into the byte slice b.
|
|
// It returns the number of bytes read and an error, if any.
|
|
func (fl *File) Read(b []byte) (int, error) {
|
|
if fl.closed {
|
|
return 0, fsproto.ErrFileClosed
|
|
}
|
|
|
|
fl.fs.mtx.Lock()
|
|
defer fl.fs.mtx.Unlock()
|
|
|
|
char, err := fl.fs.dev.getChar(fsTransferChar)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer char.EnableNotifications(nil)
|
|
|
|
transferred := uint32(0)
|
|
maxLen := uint32(len(b))
|
|
mtu := uint32(fl.fs.mtu(char))
|
|
|
|
var (
|
|
notifErr error
|
|
done bool
|
|
)
|
|
|
|
// continueCh is used to prevent race conditions. When the
|
|
// request loop starts, it reads from continueCh, blocking it
|
|
// until it's "released" by the notification function after
|
|
// the response is processed.
|
|
continueCh := make(chan struct{}, 2)
|
|
err = char.EnableNotifications(func(buf []byte) {
|
|
var rfr fsproto.ReadFileResponse
|
|
err = fsproto.ReadResponse(buf, fsproto.ReadFileResp, &rfr)
|
|
if err != nil {
|
|
notifErr = err
|
|
char.EnableNotifications(nil)
|
|
close(continueCh)
|
|
return
|
|
}
|
|
|
|
fl.size = rfr.FileSize
|
|
|
|
if rfr.Offset == rfr.FileSize || rfr.ChunkLen == 0 {
|
|
notifErr = io.EOF
|
|
done = true
|
|
char.EnableNotifications(nil)
|
|
close(continueCh)
|
|
return
|
|
}
|
|
|
|
n := copy(b[transferred:], rfr.Data[:rfr.ChunkLen])
|
|
fl.offset += uint32(n)
|
|
transferred += uint32(n)
|
|
|
|
if fl.ProgressFunc != nil {
|
|
fl.ProgressFunc(transferred, rfr.FileSize)
|
|
}
|
|
|
|
// Release the request loop
|
|
continueCh <- struct{}{}
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer char.EnableNotifications(nil)
|
|
|
|
amountLeft := maxLen - transferred
|
|
chunkLen := mtu
|
|
if amountLeft < mtu {
|
|
chunkLen = amountLeft
|
|
}
|
|
|
|
err = fsproto.WriteRequest(char, fsproto.ReadFileHeaderOpcode, fsproto.ReadFileHeaderRequest{
|
|
PathLen: uint16(len(fl.path)),
|
|
Offset: fl.offset,
|
|
ReadLen: chunkLen,
|
|
Path: fl.path,
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if notifErr != nil {
|
|
return int(transferred), notifErr
|
|
}
|
|
|
|
for !done {
|
|
// Wait for the notification function to release the loop
|
|
<-continueCh
|
|
|
|
if notifErr != nil {
|
|
return int(transferred), notifErr
|
|
}
|
|
|
|
amountLeft = maxLen - transferred
|
|
chunkLen = mtu
|
|
if amountLeft < mtu {
|
|
chunkLen = amountLeft
|
|
}
|
|
|
|
err = fsproto.WriteRequest(char, fsproto.ReadFileOpcode, fsproto.ReadFileRequest{
|
|
Status: 0x01,
|
|
Offset: fl.offset,
|
|
ReadLen: chunkLen,
|
|
})
|
|
if err != nil {
|
|
return int(transferred), err
|
|
}
|
|
}
|
|
|
|
return int(transferred), notifErr
|
|
}
|
|
|
|
// Stat returns information about the file,
|
|
func (fl *File) Stat() (fs.FileInfo, error) {
|
|
return fl.fs.Stat(fl.path)
|
|
}
|
|
|
|
// Seek sets the offset for the next Read or Write on the file to the specified offset.
|
|
// The whence parameter specifies the seek reference point:
|
|
//
|
|
// io.SeekStart: offset is relative to the start of the file.
|
|
// io.SeekCurrent: offset is relative to the current offset.
|
|
// io.SeekEnd: offset is relative to the end of the file.
|
|
//
|
|
// Seek returns the new offset and an error, if any.
|
|
func (fl *File) Seek(offset int64, whence int) (int64, error) {
|
|
if fl.closed {
|
|
return 0, fsproto.ErrFileClosed
|
|
}
|
|
|
|
if offset > math.MaxUint32 {
|
|
return 0, fsproto.ErrInvalidOffset
|
|
}
|
|
u32Offset := uint32(offset)
|
|
|
|
fl.fs.mtx.Lock()
|
|
defer fl.fs.mtx.Unlock()
|
|
|
|
if fl.size == 0 {
|
|
return 0, errors.New("file size unknown")
|
|
}
|
|
|
|
var newOffset uint32
|
|
switch whence {
|
|
case io.SeekStart:
|
|
newOffset = u32Offset
|
|
case io.SeekCurrent:
|
|
newOffset = fl.offset + u32Offset
|
|
case io.SeekEnd:
|
|
newOffset = fl.size + u32Offset
|
|
}
|
|
|
|
if newOffset > fl.size || newOffset < 0 {
|
|
return 0, fsproto.ErrInvalidOffset
|
|
}
|
|
fl.offset = newOffset
|
|
|
|
return int64(fl.offset), nil
|
|
}
|
|
|
|
// Close closes the file for future operations
|
|
func (fl *File) Close() error {
|
|
fl.fs.mtx.Lock()
|
|
defer fl.fs.mtx.Unlock()
|
|
fl.closed = true
|
|
return nil
|
|
}
|
|
|
|
// requestThenAwaitResponse executes a BLE FS request and then waits for one or more responses,
|
|
// until fn returns true or an error is encountered.
|
|
func (ifs *FS) requestThenAwaitResponse(char *bluetooth.DeviceCharacteristic, opcode fsproto.FSReqOpcode, req any, fn func(buf []byte) (bool, error)) error {
|
|
var stopped atomic.Bool
|
|
errCh := make(chan error, 1)
|
|
char.EnableNotifications(func(buf []byte) {
|
|
stop, err := fn(buf)
|
|
if err != nil && !stopped.Load() {
|
|
errCh <- err
|
|
char.EnableNotifications(nil)
|
|
return
|
|
} else if !stopped.Load() {
|
|
errCh <- nil
|
|
}
|
|
|
|
if stop && !stopped.Load() {
|
|
stopped.Store(true)
|
|
close(errCh)
|
|
char.EnableNotifications(nil)
|
|
}
|
|
})
|
|
defer char.EnableNotifications(nil)
|
|
|
|
err := fsproto.WriteRequest(char, opcode, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for err := range errCh {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ifs *FS) mtu(char *bluetooth.DeviceCharacteristic) uint16 {
|
|
mtuVal, _ := char.GetMTU()
|
|
if mtuVal == 0 {
|
|
mtuVal = 256
|
|
}
|
|
return mtuVal - 20
|
|
}
|
|
|
|
var _ fs.FS = (*GoFS)(nil)
|
|
var _ fs.StatFS = (*GoFS)(nil)
|
|
var _ fs.ReadDirFS = (*GoFS)(nil)
|
|
|
|
// GoFS implements [io/fs.FS], [io/fs.StatFS], and [io/fs.ReadDirFS]
|
|
// for the InfiniTime filesystem
|
|
type GoFS struct {
|
|
*FS
|
|
}
|
|
|
|
// Open opens an existing file at the specified path.
|
|
// It returns a handle for the file and an error, if any.
|
|
func (gfs GoFS) Open(path string) (fs.File, error) {
|
|
return gfs.FS.Open(path)
|
|
}
|