├── .gitignore ├── requirements.txt ├── .gitmodules ├── README.md ├── flipper_serial.py ├── fzfs.py ├── serial_ble.py ├── flipper_api.py └── flipper_fs.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | venv/ 3 | __pycache__/ 4 | mount/ 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak==1.1.1 2 | dbus-fast==2.44.3 3 | fusepy==3.0.1 4 | numpy==2.3.3 5 | protobuf==6.32.1 6 | pyserial==3.5 7 | typing_extensions==4.15.0 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "flipperzero_protobuf_py"] 2 | path = flipperzero_protobuf_py 3 | url = https://github.com/flipperdevices/flipperzero_protobuf_py.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flipper Zero filesystem driver 2 | 3 | This driver allows you to mount the flipper zero over its serial connection and manage it like a regular mass storage. 4 | 5 | ## Installation 6 | 7 | ``` 8 | git clone --recursive https://github.com/dakhnod/fzfs.git 9 | cd fzfs 10 | python3 -m venv venv 11 | . venv/bin/activate 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## Connect via USB Serial 16 | 17 | The script takes two arguments, the serial port and the mount point 18 | 19 | ``` 20 | venv/bin/python3 fzfs.py -d /dev/ttyACM0 -m /home/user/flipper-zero 21 | ``` 22 | 23 | Then you should be able to access your flipper files through file browser of the console in the mountpoint. 24 | 25 | ## Connect via BLE Serial 26 | 27 | First, you need to pair your flipper with your computer. This process varies, but a good starting point is: 28 | ``` 29 | bluetoothctl 30 | agent on 31 | pair your_flipper_mac_address 32 | disconnect your_flipper_mac_address 33 | ``` 34 | 35 | This should ask you for a confirmation code and pair your device. 36 | After that, ensure that your Flipper is disconnected from your computer. 37 | 38 | Then, you can run 39 | 40 | ``` 41 | venv/bin/python3 fzfs.py -a "your_flipper_mac_address" -m /home/user/flipper-zero 42 | ``` 43 | 44 | ## Disclaimer 45 | 46 | This software is still work in progress and may have errors despite my best efforts, so use with caution. 47 | -------------------------------------------------------------------------------- /flipper_serial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # pylint: disable=missing-class-docstring 4 | # pylint: disable=missing-function-docstring 5 | # pylint: disable=missing-module-docstring 6 | # pylint: disable=line-too-long 7 | 8 | 9 | import serial 10 | import serial.tools.list_ports 11 | import serial_ble 12 | 13 | class FlipperSerial(): 14 | _flipperusb = "USB VID:PID=0483:5740" 15 | _read_characteristic = '19ed82ae-ed21-4c9d-4145-228e61fe0000' 16 | _write_characteristic = '19ed82ae-ed21-4c9d-4145-228e62fe0000' 17 | _is_cli = True 18 | 19 | def discover(self): 20 | ports = serial.tools.list_ports.comports() 21 | for check_port in ports: 22 | if self._flipperusb in check_port.hwid: 23 | print("Found: ", check_port.description, "(",check_port.device,")") 24 | return check_port.device 25 | return None 26 | 27 | def open(self, **resource): 28 | for key, value in resource.items(): 29 | if key == "serial_device" and value is not None: 30 | rsc = self._create_physical_serial(value) 31 | if key == "ble_address" and value is not None: 32 | rsc = self._create_ble_serial(value) 33 | 34 | if rsc is None: 35 | raise FlipperSerialException 36 | return rsc 37 | 38 | def close(self): 39 | try: 40 | self._serial_device.stop() 41 | print('stopped bluetooth') 42 | except AttributeError: 43 | pass 44 | 45 | def _create_physical_serial(self, file): 46 | resource = serial.Serial(file, timeout=1) 47 | if self._is_cli: 48 | resource.read_until(b'>: ') 49 | resource.write(b"start_rpc_session\r") 50 | resource.read_until(b'\n') 51 | 52 | return resource 53 | 54 | def _create_ble_serial(self, address): 55 | bluetooth = serial_ble.BLESerial(address, self._read_characteristic, self._write_characteristic) 56 | print('connecting...') 57 | bluetooth.start(None) 58 | print('connected') 59 | 60 | return bluetooth 61 | 62 | class FlipperSerialException(Exception): 63 | pass 64 | -------------------------------------------------------------------------------- /fzfs.py: -------------------------------------------------------------------------------- 1 | 2 | # pylint: disable=missing-class-docstring 3 | # pylint: disable=missing-function-docstring 4 | # pylint: disable=missing-module-docstring 5 | # pylint: disable=line-too-long 6 | 7 | import argparse 8 | import logging 9 | import os 10 | 11 | import fuse 12 | 13 | import flipper_fs 14 | import flipper_serial 15 | 16 | def main(): 17 | parser = argparse.ArgumentParser(description='FUSE driver for flipper serial connection') 18 | parser.add_argument('-d', '--device', help='Serial device to connect to', dest='serial_device') 19 | parser.add_argument('-a', '--address', help='Flipper BLE address', dest='ble_address') 20 | parser.add_argument('-m', '--mount', help='Mount point to mount the FZ to', dest='mountpoint', required=True) 21 | args = parser.parse_args() 22 | 23 | logging.basicConfig(level=logging.DEBUG) 24 | 25 | serial_device = flipper_serial.FlipperSerial() 26 | 27 | if not os.path.isdir(args.mountpoint) and len(os.listdir(args.mountpoint)) != 0: 28 | print(args.mountpoint, ': mountpoint must be an empty folder') 29 | return 30 | 31 | if args.serial_device is None and args.ble_address is None: 32 | args.serial_device = serial_device.discover() 33 | 34 | if args.serial_device is None and args.ble_address is None: 35 | print('either serial_device or ble_address required') 36 | return 37 | 38 | if args.serial_device is not None and args.ble_address is not None: 39 | print('only one of serial_device/ble_address required') 40 | return 41 | 42 | if args.ble_address is None and not os.path.exists(args.serial_device): 43 | print(args.serial_device,': no such file or directory') 44 | parser.print_usage() 45 | return 46 | 47 | try: 48 | serial_device = serial_device.open(serial_device=args.serial_device, ble_address=args.ble_address) 49 | except flipper_serial.FlipperSerialException: 50 | print('Failed creating serial device') 51 | return 52 | 53 | print('starting fs...') 54 | backend = flipper_fs.FlipperZeroFileSystem(serial_device) 55 | fuse.FUSE(backend, args.mountpoint, foreground=True) 56 | print('fuse stopped') 57 | 58 | backend.close() 59 | serial_device.close() 60 | 61 | if __name__ == '__main__': 62 | logging.basicConfig(level=logging.INFO) 63 | main() 64 | -------------------------------------------------------------------------------- /serial_ble.py: -------------------------------------------------------------------------------- 1 | import bleak 2 | import serial 3 | import asyncio 4 | import threading 5 | 6 | class BLESerial(serial.Serial): 7 | def __init__(self, address: str, read_characteristic: str, write_characteristic: str, read_timeout=1): 8 | self.address = address 9 | self.read_characteristic = read_characteristic 10 | self.write_characteristic = write_characteristic 11 | self.client = None 12 | self.read_buffer = [] 13 | self.loop = asyncio.new_event_loop() 14 | self.thread = threading.Thread(target=self.loop.run_forever) 15 | self.connect_condition = threading.Condition() 16 | self.write_condition = threading.Condition() 17 | self.read_condition = threading.Condition() 18 | self.read_timeout = read_timeout 19 | self.exception = None 20 | 21 | def start(self, disconnect_handler): 22 | self.thread.start() 23 | asyncio.run_coroutine_threadsafe(self.connect(60, disconnect_handler), self.loop) 24 | with self.connect_condition: 25 | self.connect_condition.wait() 26 | 27 | def stop(self): 28 | asyncio.run_coroutine_threadsafe(self.disconnect(), self.loop) 29 | with self.connect_condition: 30 | self.connect_condition.wait(20) 31 | with self.connect_condition: 32 | self.connect_condition.notify() 33 | with self.read_condition: 34 | self.read_condition.notify() 35 | with self.write_condition: 36 | self.write_condition.notify() 37 | self.loop.stop() 38 | 39 | async def disconnect(self): 40 | await self.client.disconnect() 41 | with self.connect_condition: 42 | self.connect_condition.notify() 43 | 44 | async def connect(self, timeout, disconnect_handler=None): 45 | self.client = bleak.BleakClient(self.address, addr_type='random') 46 | self.client.set_disconnected_callback(disconnect_handler) 47 | connected = False 48 | for i in range(10): 49 | try: 50 | print(f'connect attempt {i + 1}/10') 51 | result = await self.client.connect(timeout) 52 | connected = True 53 | break 54 | except bleak.BleakError as e: 55 | pass 56 | 57 | 58 | if not connected: 59 | raise f'Could not connect to {self.address}' 60 | 61 | await self.client.start_notify(self.read_characteristic, self.on_serial_data) 62 | 63 | with self.connect_condition: 64 | self.connect_condition.notify() 65 | 66 | def on_serial_data(self, size, data): 67 | # print(f'received {list(data)}') 68 | self.read_buffer.extend(list(data)) 69 | with self.read_condition: 70 | self.read_condition.notify() 71 | 72 | def read(self, size: int): 73 | # print(f'reading {size}, available {len(self.read_buffer)}') 74 | if len(self.read_buffer) < size: 75 | with self.read_condition: 76 | self.read_condition.wait(self.read_timeout) 77 | if not self.client.is_connected: 78 | raise Exception('Device disconnected') 79 | data = self.read_buffer[:size] 80 | self.read_buffer = self.read_buffer[size:] 81 | 82 | return bytes(data) 83 | 84 | async def write_char(self, char, data): 85 | try: 86 | result = await self.client.write_gatt_char(char, data) 87 | except Exception as e: 88 | print(e) 89 | with self.write_condition: 90 | self.write_condition.notify() 91 | 92 | def write(self, data): 93 | if not self.client.is_connected: 94 | raise Exception('Device disconnected') 95 | # print(f'writing {list(data)}') 96 | asyncio.run_coroutine_threadsafe(self.write_char(self.write_characteristic, data), self.loop) 97 | with self.write_condition: 98 | self.write_condition.wait(5) 99 | if not self.client.is_connected: 100 | raise Exception('Device disconnected') 101 | -------------------------------------------------------------------------------- /flipper_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=protected-access 3 | # pylint: disable=no-member 4 | # pylint: disable=missing-class-docstring 5 | # pylint: disable=missing-function-docstring 6 | 7 | import threading 8 | 9 | from flipperzero_protobuf_py.flipperzero_protobuf.flipperzero_protobuf_compiled import flipper_pb2, storage_pb2 10 | from flipperzero_protobuf_py.flipperzero_protobuf.flipper_proto import FlipperProto 11 | from flipperzero_protobuf_py.flipperzero_protobuf.cli_helpers import * 12 | 13 | 14 | class FlipperAPI(): 15 | def __init__(self, flipper_serial) -> None: 16 | self.serial_port = flipper_serial 17 | self.proto = None 18 | self.mutex=threading.Lock() 19 | 20 | def connect(self): 21 | with self.mutex: 22 | self.proto = FlipperProto(self.serial_port) 23 | self.proto._in_session = True 24 | 25 | print("Ping result: ") 26 | print_hex(self.proto.rpc_system_ping()) 27 | 28 | 29 | def _cmd_storage_list_directory(self, path): 30 | cmd_data = storage_pb2.ListRequest() 31 | cmd_data.path = path 32 | self.proto._rpc_send(cmd_data, 'storage_list_request') 33 | 34 | def _cmd_storage_stat(self, path): 35 | cmd_data = storage_pb2.StatRequest() 36 | cmd_data.path = path 37 | return self.proto._rpc_send_and_read_answer(cmd_data, 'storage_stat_request') 38 | 39 | def _cmd_storage_read(self, path): 40 | cmd_data = storage_pb2.ReadRequest() 41 | cmd_data.path = path 42 | self.proto._rpc_send(cmd_data, 'storage_read_request') 43 | 44 | def _cmd_storage_mkdir(self, path): 45 | cmd_data = storage_pb2.MkdirRequest() 46 | cmd_data.path = path 47 | self.proto._rpc_send(cmd_data, 'storage_mkdir_request') 48 | 49 | def _cmd_storage_rmdir(self, path): 50 | cmd_data = storage_pb2.RmdirRequest() 51 | cmd_data.path = path 52 | self.proto._rpc_send(cmd_data, 'storage_rmdir_request') 53 | 54 | def _cmd_storage_rename(self, old_path, new_path): 55 | cmd_data = storage_pb2.RenameRequest() 56 | cmd_data.old_path = old_path 57 | cmd_data.new_path = new_path 58 | self.proto._rpc_send(cmd_data, 'storage_rename_request') 59 | 60 | def _cmd_storage_delete(self, path, recursive): 61 | cmd_data = storage_pb2.DeleteRequest() 62 | cmd_data.path = path 63 | cmd_data.recursive = recursive 64 | self.proto._rpc_send(cmd_data, 'storage_delete_request') 65 | 66 | def _cmd_storage_write(self, path, data): 67 | cmd_data = storage_pb2.WriteRequest() 68 | cmd_data.path = path 69 | cmd_data.file.data = data 70 | self.proto._rpc_send(cmd_data, 'storage_write_request') 71 | 72 | def check_response_status(self, response): 73 | if response.command_status == flipper_pb2.CommandStatus.ERROR_STORAGE_INVALID_NAME: 74 | raise InvalidNameError() 75 | 76 | 77 | def list_directory(self, path, additional_data): 78 | with self.mutex: 79 | self._cmd_storage_list_directory(path) 80 | 81 | files = [] 82 | 83 | while True: 84 | packet = self.proto._rpc_read_answer() 85 | self.check_response_status(packet) 86 | for file in packet.storage_list_response.file: 87 | files.append({**{ 88 | 'name': file.name, 89 | 'type': storage_pb2.File.FileType.Name(file.type) 90 | }, **additional_data}) 91 | if not packet.has_next: 92 | break 93 | 94 | return files 95 | 96 | def stat(self, path): 97 | with self.mutex: 98 | response = self._cmd_storage_stat(path) 99 | 100 | if response.command_status == flipper_pb2.CommandStatus.ERROR_STORAGE_INVALID_NAME: 101 | raise InvalidNameError() 102 | 103 | response = response.storage_stat_response 104 | 105 | return {'size': response.file.size} 106 | 107 | def read_file_contents(self, path): 108 | with self.mutex: 109 | self._cmd_storage_read(path) 110 | 111 | contents = [] 112 | 113 | while True: 114 | packet = self.proto._rpc_read_answer() 115 | print(packet) 116 | self.check_response_status(packet) 117 | contents.extend(packet.storage_read_response.file.data) 118 | if not packet.has_next: 119 | break 120 | return {'data': contents} 121 | 122 | def mkdir(self, path): 123 | with self.mutex: 124 | print(f'mkdir {path}') 125 | 126 | self._cmd_storage_mkdir(path) 127 | 128 | def rmdir(self, path): 129 | with self.mutex: 130 | print(f'rmdir {path}') 131 | 132 | self._cmd_storage_rmdir(path) 133 | 134 | def rename(self, old_path, new_path): 135 | with self.mutex: 136 | self._cmd_storage_rename(old_path, new_path) 137 | 138 | def delete(self, path, recursive): 139 | with self.mutex: 140 | self._cmd_storage_delete(path, recursive) 141 | 142 | def write(self, path, data): 143 | with self.mutex: 144 | self._cmd_storage_write(path, data) 145 | 146 | def close(self): 147 | return 148 | # disabled because of buf in flipper_proto 149 | with self.mutex: 150 | self.proto.rpc_stop_session() 151 | 152 | class InvalidNameError(RuntimeError): 153 | pass 154 | -------------------------------------------------------------------------------- /flipper_fs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # pylint: disable=missing-class-docstring 4 | # pylint: disable=missing-function-docstring 5 | # pylint: disable=missing-module-docstring 6 | 7 | import errno 8 | import stat 9 | import time 10 | 11 | import fuse 12 | 13 | import flipper_api 14 | 15 | class FlipperZeroFileSystem(fuse.Operations, fuse.LoggingMixIn): 16 | def __init__(self, serial_port) -> None: 17 | super().__init__() 18 | self.api = flipper_api.FlipperAPI(serial_port) 19 | self.api.connect() 20 | self.file_root = { 21 | 'type': 'DIR' 22 | } 23 | self._fd = 0 24 | 25 | def find_child_by_name(self, parent, child_name): 26 | for child in parent['children']: 27 | if child['name'] == child_name: 28 | return child 29 | raise fuse.FuseOSError(errno.ENOENT) 30 | 31 | def get_file_from_parts(self, parent, parts, index): 32 | def list_dir(dir_path): 33 | return self.api.list_directory(dir_path, {'full_path': dir_path, 'parent': parent}) 34 | 35 | if index <= len(parts): 36 | full_path = f"/{'/'.join(parts[:index])}" 37 | 38 | if parent['type'] == 'DIR': 39 | try: 40 | parent['children'] 41 | except KeyError: 42 | parent['children'] = list_dir(full_path) 43 | 44 | if index == len(parts): 45 | return parent 46 | 47 | child = self.find_child_by_name(parent, parts[index]) 48 | 49 | return self.get_file_from_parts(child, parts, index + 1) 50 | 51 | return parent 52 | 53 | def get_file_by_path(self, path_full: str): 54 | path = path_full[:] 55 | if path[0] == '/': 56 | path = path[1:] 57 | if path == '': 58 | parts = [] 59 | else: 60 | parts = path.split('/') 61 | 62 | return self.get_file_from_parts(self.file_root, parts, 0) 63 | 64 | def readdir(self, path, fh = None): 65 | # print(f'requested {path}') 66 | 67 | parent = self.get_file_by_path(path) 68 | 69 | return ['.', '..'] + [child['name'] for child in parent['children']] 70 | 71 | def getattr(self, path, fh=None): 72 | file = self.get_file_by_path(path) 73 | 74 | try: 75 | return file['attr'] 76 | except KeyError: 77 | pass 78 | 79 | print(f'getting attr for {path}') 80 | 81 | now = time.time() 82 | 83 | attr = { 84 | 'st_mode': 0o777, 85 | 'st_ctime': now, 86 | 'st_mtime': now, 87 | 'st_atime': now 88 | } 89 | 90 | if file['type'] == 'DIR': 91 | attr['st_mode'] |= stat.S_IFDIR 92 | attr['st_nlink'] = 2 93 | else: 94 | response = self.api.stat(path) 95 | attr['st_size'] = response['size'] 96 | attr['st_mode'] |= stat.S_IFREG 97 | attr['st_nlink'] = 1 98 | 99 | file['attr'] = attr 100 | return attr 101 | 102 | def read(self, path, size, offset, fh): 103 | cached = self.get_file_by_path(path) 104 | 105 | try: 106 | return bytes(cached['contents'][offset:offset + size]) 107 | except KeyError: 108 | pass 109 | 110 | data = None 111 | 112 | print(f'reading {path}') 113 | 114 | data = self.api.read_file_contents(path)['data'] 115 | 116 | cached['contents'] = data 117 | return bytes(data[offset:offset + size]) 118 | 119 | def write(self, path, data, offset, fh): 120 | print(f'write file: {path} offset: {offset} length: {len(data)}') 121 | try: 122 | cached = self.get_file_by_path(path) 123 | except OSError: 124 | self.create(path, None) 125 | cached = self.get_file_by_path(path) 126 | 127 | cached['contents'][offset:offset] = list(data) 128 | cached['attr']['st_size'] = len(cached['contents']) 129 | self.api.write(path, bytes(cached['contents'])) 130 | return len(data) 131 | 132 | def open(self, path, flags): 133 | print(f'open {path} {flags}') 134 | self._fd += 1 135 | return self._fd 136 | 137 | def get_filename_from_path(self, path): 138 | parts = path[1:].split('/') 139 | return parts[-1] 140 | 141 | def get_parent_from_path(self, path): 142 | return path[:-(len(self.get_filename_from_path(path)) + 1)] 143 | 144 | def append_to_parend(self, child_path, child): 145 | parent_path = self.get_parent_from_path(child_path) 146 | parent = self.get_file_by_path(parent_path) 147 | child['parent'] = parent 148 | parent['children'].append(child) 149 | 150 | def mkdir(self, path, mode): 151 | print(f'mkdir {path}') 152 | self.append_to_parend(path, { 153 | 'name': self.get_filename_from_path(path), 154 | 'type': 'DIR' 155 | }) 156 | self.api.mkdir(path) 157 | return 158 | 159 | def rename(self, old, new): 160 | try: 161 | new_file = self.get_file_by_path(new) 162 | new_file['parent']['children'].remove(new_file) 163 | self.api.delete(new, True) 164 | except OSError: 165 | pass 166 | 167 | print(f'renaming {old} -> {new}') 168 | cached = self.get_file_by_path(old) 169 | self.api.rename(old, new) 170 | parts = new.split('/') 171 | cached['name'] = parts[-1] 172 | 173 | def rmdir(self, path): 174 | self.unlink(path) 175 | 176 | def create(self, path, mode, fi=None): 177 | print(f'creating {path}') 178 | self.append_to_parend(path, { 179 | 'name': self.get_filename_from_path(path), 180 | 'type': 'FILE', 181 | 'contents': [], 182 | }) 183 | self.api.write(path, bytes()) 184 | self._fd += 1 185 | return self._fd 186 | 187 | def unlink(self, path): 188 | # print(f'unlinking {path}') 189 | cached = self.get_file_by_path(path) 190 | self.api.delete(path, True) 191 | cached['parent']['children'].remove(cached) 192 | 193 | def close(self): 194 | self.api.close() 195 | --------------------------------------------------------------------------------