From e9a92bac4671af2b949446a220f31e37e1423638 Mon Sep 17 00:00:00 2001
From: Arsen Musayelyan <moussaelianarsen@gmail.com>
Date: Mon, 22 Nov 2021 21:19:30 -0800
Subject: [PATCH] Implement BLE filesystem (experimental and will change in the
 future)

---
 blefs/basic.go    |  54 +++++++
 blefs/dir.go      | 156 +++++++++++++++++++++
 blefs/error.go    |  61 ++++++++
 blefs/file.go     | 350 ++++++++++++++++++++++++++++++++++++++++++++++
 blefs/fileinfo.go |  47 +++++++
 blefs/fs.go       | 232 ++++++++++++++++++++++++++++++
 infinitime.go     |  14 ++
 7 files changed, 914 insertions(+)
 create mode 100644 blefs/basic.go
 create mode 100644 blefs/dir.go
 create mode 100644 blefs/error.go
 create mode 100644 blefs/file.go
 create mode 100644 blefs/fileinfo.go
 create mode 100644 blefs/fs.go

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)
+}