diff --git a/blefs/basic.go b/blefs/basic.go new file mode 100644 index 0000000..f31293c --- /dev/null +++ b/blefs/basic.go @@ -0,0 +1,54 @@ +package blefs + +// Rename moves or renames a file or directory +func (blefs *FS) Rename(old, new string) error { + // Create move request + err := blefs.request( + FSCmdMove, + true, + uint16(len(old)), + uint16(len(new)), + old, + byte(0x00), + new, + ) + if err != nil { + return err + } + var status int8 + // Upon receiving 0x61 (FSResponseMove) + blefs.on(FSResponseMove, func(data []byte) error { + // Read status byte + return decode(data, &status) + }) + // If status is not ok, return error + if status != FSStatusOk { + return FSError{status} + } + return nil +} + +// Remove removes a file or directory +func (blefs *FS) Remove(path string) error { + // Create delete request + err := blefs.request( + FSCmdDelete, + true, + uint16(len(path)), + path, + ) + if err != nil { + return err + } + var status int8 + // Upon receiving 0x31 (FSResponseDelete) + blefs.on(FSResponseDelete, func(data []byte) error { + // Read status byte + return decode(data, &status) + }) + if status == FSStatusError { + // If status is not ok, return error + return FSError{status} + } + return nil +} diff --git a/blefs/dir.go b/blefs/dir.go new file mode 100644 index 0000000..768d8c4 --- /dev/null +++ b/blefs/dir.go @@ -0,0 +1,156 @@ +package blefs + +import ( + "fmt" + "io/fs" + "strconv" + "time" +) + +// Mkdir creates a directory at the given path +func (blefs *FS) Mkdir(path string) error { + // Create make directory request + err := blefs.request( + FSCmdMkdir, + true, + uint16(len(path)), + padding(4), + uint64(time.Now().UnixNano()), + path, + ) + if err != nil { + return err + } + var status int8 + // Upon receiving 0x41 (FSResponseMkdir), read status byte + blefs.on(FSResponseMkdir, func(data []byte) error { + return decode(data, &status) + }) + // If status not ok, return error + if status != FSStatusOk { + return FSError{status} + } + return nil +} + +// ReadDir returns a list of directory entries from the given path +func (blefs *FS) ReadDir(path string) ([]fs.DirEntry, error) { + // Create list directory request + err := blefs.request( + FSCmdListDir, + true, + uint16(len(path)), + path, + ) + if err != nil { + return nil, err + } + var out []fs.DirEntry + for { + // Create new directory entry + listing := DirEntry{} + // Upon receiving 0x50 (FSResponseListDir) + blefs.on(FSResponseListDir, func(data []byte) error { + // Read data into listing + err := decode( + data, + &listing.status, + &listing.pathLen, + &listing.entryNum, + &listing.entries, + &listing.flags, + &listing.modtime, + &listing.size, + &listing.path, + ) + if err != nil { + return err + } + return nil + }) + // If status is not ok, return error + if listing.status != FSStatusOk { + return nil, FSError{listing.status} + } + // Stop once entry number equals total entries + if listing.entryNum == listing.entries { + break + } + // Append listing to slice + out = append(out, listing) + } + return out, nil +} + +// DirEntry represents an entry from a directory listing +type DirEntry struct { + status int8 + pathLen uint16 + entryNum uint32 + entries uint32 + flags uint32 + modtime uint64 + size uint32 + path string +} + +// Name returns the name of the file described by the entry +func (de DirEntry) Name() string { + return de.path +} + +// IsDir reports whether the entry describes a directory. +func (de DirEntry) IsDir() bool { + return de.flags&0b1 == 1 +} + +// Type returns the type bits for the entry. +func (de DirEntry) Type() fs.FileMode { + if de.IsDir() { + return fs.ModeDir + } else { + return 0 + } +} + +// Info returns the FileInfo for the file or subdirectory described by the entry. +func (de DirEntry) Info() (fs.FileInfo, error) { + return FileInfo{ + name: de.path, + size: de.size, + modtime: de.modtime, + mode: de.Type(), + isDir: de.IsDir(), + }, nil +} + +func (de DirEntry) String() string { + var isDirChar rune + if de.IsDir() { + isDirChar = 'd' + } else { + isDirChar = '-' + } + + // Get human-readable value for file size + val, unit := bytesHuman(de.size) + prec := 0 + // If value is less than 10, set precision to 1 + if val < 10 { + prec = 1 + } + // Convert float to string + valStr := strconv.FormatFloat(val, 'f', prec, 64) + + // Return string formatted like so: + // - 10 kB file + // or: + // d 0 B . + return fmt.Sprintf( + "%c %3s %-2s %s", + isDirChar, + valStr, + unit, + de.path, + ) +} diff --git a/blefs/error.go b/blefs/error.go new file mode 100644 index 0000000..27e03b4 --- /dev/null +++ b/blefs/error.go @@ -0,0 +1,61 @@ +package blefs + +import ( + "errors" + "fmt" +) + +var ( + ErrFileNotExists = errors.New("file does not exist") + ErrFileReadOnly = errors.New("file is read only") + ErrFileWriteOnly = errors.New("file is write only") + ErrInvalidOffset = errors.New("invalid file offset") + ErrOffsetChanged = errors.New("offset has already been changed") +) + +// FSError represents an error returned by BLE FS +type FSError struct { + Code int8 +} + +// Error returns the string associated with the error code +func (err FSError) Error() string { + switch err.Code { + case 0x02: + return "filesystem error" + case 0x05: + return "read-only filesystem" + case 0x03: + return "no such file" + case 0x04: + return "protocol error" + case -5: + return "input/output error" + case -84: + return "filesystem is corrupted" + case -2: + return "no such directory entry" + case -17: + return "entry already exists" + case -20: + return "entry is not a directory" + case -39: + return "directory is not empty" + case -9: + return "bad file number" + case -27: + return "file is too large" + case -22: + return "invalid parameter" + case -28: + return "no space left on device" + case -12: + return "no more memory available" + case -61: + return "no attr available" + case -36: + return "file name is too long" + default: + return fmt.Sprintf("unknown error (code %d)", err.Code) + } +} diff --git a/blefs/file.go b/blefs/file.go new file mode 100644 index 0000000..6fe9c5d --- /dev/null +++ b/blefs/file.go @@ -0,0 +1,350 @@ +package blefs + +import ( + "io" + "io/fs" + "path/filepath" + "time" +) + +// File represents a file on the BLE filesystem +type File struct { + fs *FS + path string + offset uint32 + length uint32 + amtLeft uint32 + amtTferd uint32 + isReadOnly bool + isWriteOnly bool + offsetChanged bool +} + +// Open opens a file and returns it as an fs.File to +// satisfy the fs.FS interface +func (blefs *FS) Open(path string) (fs.File, error) { + // Make a read file request. This opens the file for reading. + err := blefs.request( + FSCmdReadFile, + true, + uint16(len(path)), + uint32(0), + uint32(blefs.maxData()), + path, + ) + if err != nil { + return nil, err + } + return &File{ + fs: blefs, + path: path, + length: 0, + offset: 0, + isReadOnly: true, + isWriteOnly: false, + }, nil +} + +// Create makes a new file on the BLE file system and returns it. +func (blefs *FS) Create(path string, size uint32) (*File, error) { + // Make a write file request. This will create and open a file for writing. + err := blefs.request( + FSCmdWriteFile, + true, + uint16(len(path)), + uint32(0), + uint64(time.Now().UnixNano()), + size, + path, + ) + if err != nil { + return nil, err + } + return &File{ + fs: blefs, + path: path, + length: size, + amtLeft: size, + offset: 0, + isReadOnly: false, + isWriteOnly: true, + }, nil +} + +// Read reads data from a file into b +func (fl *File) Read(b []byte) (n int, err error) { + // If file is write only (opened by FS.Create()) + if fl.isWriteOnly { + return 0, ErrFileWriteOnly + } + + // If offset has been changed (Seek() called) + if fl.offsetChanged { + // Create new read file request with the specified offset to restart reading + err := fl.fs.request( + FSCmdReadFile, + true, + uint16(len(fl.path)), + fl.offset, + uint32(fl.fs.maxData()), + fl.path, + ) + if err != nil { + return 0, err + } + // Reset offsetChanged + fl.offsetChanged = false + } + + // Get length of b. This will be the maximum amount that can be read. + maxLen := uint32(len(b)) + if maxLen == 0 { + return 0, nil + } + var buf []byte + for { + // If amount transfered equals max length + if fl.amtTferd == maxLen { + // Reset amount transfered + fl.amtTferd = 0 + // Copy buffer contents to b + copy(b, buf) + // Return max length with no error + return int(maxLen), nil + } + // Create new empty fileReadResponse + resp := fileReadResponse{} + // Upon receiving 0x11 (FSResponseReadFile) + err := fl.fs.on(FSResponseReadFile, func(data []byte) error { + // Read binary data into struct + err := decode( + data, + &resp.status, + &resp.padding, + &resp.offset, + &resp.length, + &resp.chunkLen, + &resp.data, + ) + if err != nil { + return err + } + // If status is not ok + if resp.status != FSStatusOk { + return FSError{resp.status} + } + return nil + }) + if err != nil { + return 0, err + } + // If entire file transferred, break + if fl.offset == resp.length { + break + } + + // Append data returned in response to buffer + buf = append(buf, resp.data...) + // Set file length + fl.length = resp.length + // Add returned chunk length to offset and amount transferred + fl.offset += resp.chunkLen + fl.amtTferd += resp.chunkLen + + // Calculate amount of bytes to be sent in next request + chunkLen := min(fl.length-fl.offset, uint32(fl.fs.maxData())) + // If after transferring, there will be more data than max length + if fl.amtTferd+chunkLen > maxLen { + // Set chunk length to amount left to fill max length + chunkLen = maxLen - fl.amtTferd + } + // Make data request. This will return more data from the file. + fl.fs.request( + FSCmdDataReq, + false, + byte(FSStatusOk), + padding(2), + fl.offset, + chunkLen, + ) + } + // Copy buffer contents to b + copied := copy(b, buf) + // Return amount of bytes copied with EOF error + return copied, io.EOF +} + +// Write writes data from b into a file on the BLE filesysyem +func (fl *File) Write(b []byte) (n int, err error) { + maxLen := uint32(cap(b)) + // If file is read only (opened by FS.Open()) + if fl.isReadOnly { + return 0, ErrFileReadOnly + } + + // If offset has been changed (Seek() called) + if fl.offsetChanged { + // Create new write file request with the specified offset to restart writing + err := fl.fs.request( + FSCmdWriteFile, + true, + uint16(len(fl.path)), + fl.offset, + uint64(time.Now().UnixNano()), + fl.length, + fl.path, + ) + if err != nil { + return 0, err + } + // Reset offsetChanged + fl.offsetChanged = false + } + + for { + // If amount transfered equals max length + if fl.amtTferd == maxLen { + // Reset amount transfered + fl.amtTferd = 0 + // Return max length with no error + return int(maxLen), nil + } + + // Create new empty fileWriteResponse + resp := fileWriteResponse{} + // Upon receiving 0x21 (FSResponseWriteFile) + err := fl.fs.on(FSResponseWriteFile, func(data []byte) error { + // Read binary data into struct + err := decode( + data, + &resp.status, + &resp.padding, + &resp.offset, + &resp.modtime, + &resp.free, + ) + if err != nil { + return err + } + // If status is not ok + if resp.status != FSStatusOk { + return FSError{resp.status} + } + return nil + }) + + if err != nil { + return 0, err + } + // If no free space left in current file, break + if resp.free == 0 { + break + } + + // Calculate amount of bytes to be transferred in next request + chunkLen := min(fl.length-fl.offset, uint32(fl.fs.maxData())) + // If after transferring, there will be more data than max length + if fl.amtTferd+chunkLen > maxLen { + // Set chunk length to amount left to fill max length + chunkLen = maxLen - fl.amtTferd + } + // Get data from b + chunk := b[fl.amtTferd : fl.amtTferd+chunkLen] + // Create transfer request. This will transfer the chunk to the file. + fl.fs.request( + FSCmdTransfer, + false, + byte(FSStatusOk), + padding(2), + fl.offset, + chunkLen, + chunk, + ) + // Add chunk length to offset and amount transferred + fl.offset += chunkLen + fl.amtTferd += chunkLen + } + return int(fl.offset), nil +} + +// WriteString converts the string to []byte and calls Write() +func (fl *File) WriteString(s string) (n int, err error) { + return fl.Write([]byte(s)) +} + +// Seek changes the offset of the file being read/written. +// This can only be done once in between reads/writes. +func (fl *File) Seek(offset int64, whence int) (int64, error) { + if fl.offsetChanged { + return int64(fl.offset), ErrOffsetChanged + } + var newOffset int64 + switch whence { + case io.SeekCurrent: + newOffset = int64(fl.offset) + offset + case io.SeekStart: + newOffset = offset + case io.SeekEnd: + newOffset = int64(fl.length) + offset + default: + newOffset = int64(fl.offset) + } + if newOffset < 0 || uint32(newOffset) > fl.length { + return int64(fl.offset), ErrInvalidOffset + } + fl.offset = uint32(newOffset) + fl.offsetChanged = true + return int64(newOffset), nil +} + +// Close implements the fs.File interface. +// It just returns nil. +func (fl *File) Close() error { + return nil +} + +// Stat does a RedDir() and finds the current file in the output +func (fl *File) Stat() (fs.FileInfo, error) { + // Get directory in filepath + dir := filepath.Dir(fl.path) + // Read directory + dirEntries, err := fl.fs.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range dirEntries { + // If file name is base name of path + if entry.Name() == filepath.Base(fl.path) { + // Return file info + return entry.Info() + } + } + return nil, ErrFileNotExists +} + +// fileReadResponse represents a response for a read request +type fileReadResponse struct { + status int8 + padding [2]byte + offset uint32 + length uint32 + chunkLen uint32 + data []byte +} + +// fileWriteResponse represents a response for a write request +type fileWriteResponse struct { + status int8 + padding [2]byte + offset uint32 + modtime uint64 + free uint32 +} + +// min returns the smaller uint32 out of two given +func min(o, t uint32) uint32 { + if t < o { + return t + } + return o +} diff --git a/blefs/fileinfo.go b/blefs/fileinfo.go new file mode 100644 index 0000000..01c0c2d --- /dev/null +++ b/blefs/fileinfo.go @@ -0,0 +1,47 @@ +package blefs + +import ( + "io/fs" + "time" +) + +// FileInfo implements fs.FileInfo +type FileInfo struct { + name string + size uint32 + modtime uint64 + mode fs.FileMode + isDir bool +} + +// Name returns the base name of the file +func (fi FileInfo) Name() string { + return fi.name +} + +// Size returns the total size of the file +func (fi FileInfo) Size() int64 { + return int64(fi.size) +} + +// Mode returns the mode of the file +func (fi FileInfo) Mode() fs.FileMode { + return fi.mode +} + +// ModTime returns the modification time of the file +// As of now, this is unimplemented in InfiniTime, and +// will always return 0. +func (fi FileInfo) ModTime() time.Time { + return time.Unix(0, int64(fi.modtime)) +} + +// IsDir returns whether the file is a directory +func (fi FileInfo) IsDir() bool { + return fi.isDir +} + +// Sys is unimplemented and returns nil +func (fi FileInfo) Sys() interface{} { + return nil +} diff --git a/blefs/fs.go b/blefs/fs.go new file mode 100644 index 0000000..8e20de9 --- /dev/null +++ b/blefs/fs.go @@ -0,0 +1,232 @@ +package blefs + +import ( + "bytes" + "encoding/binary" + "errors" + "time" + + "github.com/muka/go-bluetooth/bluez" + "github.com/muka/go-bluetooth/bluez/profile/gatt" +) + +var ( + ErrFSUnexpectedResponse = errors.New("unexpected response returned by filesystem") + ErrFSResponseTimeout = errors.New("timed out waiting for response") + ErrFSError = errors.New("error reported by filesystem") +) + +const ( + FSStatusOk = 0x01 + FSStatusError = 0x02 +) + +// Filesystem command +const ( + FSCmdReadFile = 0x10 + FSCmdDataReq = 0x12 + FSCmdWriteFile = 0x20 + FSCmdTransfer = 0x22 + FSCmdDelete = 0x30 + FSCmdMkdir = 0x40 + FSCmdListDir = 0x50 + FSCmdMove = 0x60 +) + +// Filesystem response +const ( + FSResponseReadFile = 0x11 + FSResponseWriteFile = 0x21 + FSResponseDelete = 0x31 + FSResponseMkdir = 0x41 + FSResponseListDir = 0x51 + FSResponseMove = 0x61 +) + +// btOptsCmd cause a write command rather than a wrire request +var btOptsCmd = map[string]interface{}{"type": "command"} + +// FS implements the fs.FS interface for the Adafruit BLE FS protocol +type FS struct { + transferChar *gatt.GattCharacteristic1 + transferRespCh <-chan *bluez.PropertyChanged +} + +// New creates a new fs given the transfer characteristic +func New(transfer *gatt.GattCharacteristic1) (*FS, error) { + // Create new FS instance + out := &FS{transferChar: transfer} + + // Start notifications on transfer characteristic + err := out.transferChar.StartNotify() + if err != nil { + return nil, err + } + // Watch properties of transfer characteristic + ch, err := out.transferChar.WatchProperties() + if err != nil { + return nil, err + } + // Create buffered channel for propery change events + bufCh := make(chan *bluez.PropertyChanged, 10) + go func() { + // Relay all messages from original channel to buffered + for val := range ch { + bufCh <- val + } + }() + // Set transfer response channel to buffered channel + out.transferRespCh = bufCh + return out, nil +} + +func (blefs *FS) Close() error { + return blefs.transferChar.StopNotify() +} + +// request makes a request on the transfer characteristic +func (blefs *FS) request(cmd byte, padding bool, data ...interface{}) error { + // Encode data as binary + dataBin, err := encode(data...) + if err != nil { + return err + } + bin := []byte{cmd} + if padding { + bin = append(bin, 0x00) + } + // Append encoded data to command with one byte of padding + bin = append(bin, dataBin...) + // Write value to characteristic + err = blefs.transferChar.WriteValue(bin, btOptsCmd) + if err != nil { + return err + } + return nil +} + +// on waits for the given command to be received on +// the control point characteristic, then runs the callback. +func (blefs *FS) on(resp byte, onCmdCb func(data []byte) error) error { + // Use for loop in case of invalid property + for { + select { + case propChanged := <-blefs.transferRespCh: + // If property was invalid + if propChanged.Name != "Value" { + // Keep waiting + continue + } + // Assert propery value as byte slice + data := propChanged.Value.([]byte) + // If command has prefix of given command + if data[0] == resp { + // Return callback with data after command + return onCmdCb(data[1:]) + } + return ErrFSUnexpectedResponse + case <-time.After(time.Minute): + return ErrFSResponseTimeout + } + } +} + +// encode encodes go values to binary +func encode(data ...interface{}) ([]byte, error) { + // Create new buffer + buf := &bytes.Buffer{} + // For every data element + for _, elem := range data { + switch val := elem.(type) { + case string: + // Write string to buffer + if _, err := buf.WriteString(val); err != nil { + return nil, err + } + case []byte: + // Write bytes to buffer + if _, err := buf.Write(val); err != nil { + return nil, err + } + default: + // Encode and write value as little endian binary + if err := binary.Write(buf, binary.LittleEndian, val); err != nil { + return nil, err + } + } + } + // Return bytes from buffer + return buf.Bytes(), nil +} + +// decode reads binary into pointers given in vals +func decode(data []byte, vals ...interface{}) error { + offset := 0 + for _, elem := range vals { + // If at end of data, stop + if offset == len(data) { + break + } + switch val := elem.(type) { + case *string: + // Set val to string starting from offset + *val = string(data[offset:]) + // Add string length to offset + offset += len(data) - offset + case *[]byte: + // Set val to byte slice starting from offset + *val = data[offset:] + // Add slice length to offset + offset += len(data) - offset + default: + // Create new reader for data starting from offset + reader := bytes.NewReader(data[offset:]) + // Read binary into value pointer + err := binary.Read(reader, binary.LittleEndian, val) + if err != nil { + return err + } + // Add size of value to offset + offset += binary.Size(val) + } + } + return nil +} + +// maxData returns MTU-20. This is the maximum amount of data +// to send in a packet. Subtracting 20 ensures that the MTU +// is never exceeded. +func (blefs *FS) maxData() uint16 { + return blefs.transferChar.Properties.MTU - 20 +} + +// padding returns a slice of len amount of 0x00. +func padding(len int) []byte { + return make([]byte, len) +} + +// bytesHuman returns a human-readable string for +// the amount of bytes inputted. +func bytesHuman(b uint32) (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 := uint32(unit), 0 + // Get decimal values and unit prefix index + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + // Create string for full unit + unitStr := string([]rune{units[exp], 'B'}) + + // Return decimal with unit string + return float64(b) / float64(div), unitStr +} diff --git a/infinitime.go b/infinitime.go index 8ba5605..63d3c96 100644 --- a/infinitime.go +++ b/infinitime.go @@ -11,6 +11,7 @@ import ( "github.com/muka/go-bluetooth/bluez/profile/adapter" "github.com/muka/go-bluetooth/bluez/profile/device" "github.com/muka/go-bluetooth/bluez/profile/gatt" + "go.arsenm.dev/infinitime/blefs" ) const BTName = "InfiniTime" @@ -24,6 +25,8 @@ const ( CurrentTimeChar = "00002a2b-0000-1000-8000-00805f9b34fb" BatteryLvlChar = "00002a19-0000-1000-8000-00805f9b34fb" HeartRateChar = "00002a37-0000-1000-8000-00805f9b34fb" + FSTransferChar = "adaf0200-4669-6c65-5472-616e73666572" + FSVersionChar = "adaf0100-4669-6c65-5472-616e73666572" ) type Device struct { @@ -37,6 +40,8 @@ type Device struct { currentTimeChar *gatt.GattCharacteristic1 battLevelChar *gatt.GattCharacteristic1 heartRateChar *gatt.GattCharacteristic1 + fsVersionChar *gatt.GattCharacteristic1 + fsTransferChar *gatt.GattCharacteristic1 onReconnect func() Music MusicCtrl DFU DFU @@ -311,6 +316,10 @@ func (i *Device) resolveChars() error { i.DFU.ctrlPointChar = char case DFUPacketChar: i.DFU.packetChar = char + case FSTransferChar: + i.fsTransferChar = char + case FSVersionChar: + i.fsVersionChar = char } } return nil @@ -642,3 +651,8 @@ func (i *Device) NotifyCall(from string) (<-chan uint8, error) { }() return out, nil } + +// FS creates and returns a new filesystem from the device +func (i *Device) FS() (*blefs.FS, error) { + return blefs.New(i.fsTransferChar) +}