storage.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import os
  2. import sys
  3. import serial
  4. import time
  5. import hashlib
  6. import math
  7. import logging
  8. import posixpath
  9. import enum
  10. def timing(func):
  11. """
  12. Speedometer decorator
  13. """
  14. def wrapper(*args, **kwargs):
  15. time1 = time.monotonic()
  16. ret = func(*args, **kwargs)
  17. time2 = time.monotonic()
  18. print(
  19. "{:s} function took {:.3f} ms".format(
  20. func.__name__, (time2 - time1) * 1000.0
  21. )
  22. )
  23. return ret
  24. return wrapper
  25. class StorageErrorCode(enum.Enum):
  26. OK = "OK"
  27. NOT_READY = "filesystem not ready"
  28. EXIST = "file/dir already exist"
  29. NOT_EXIST = "file/dir not exist"
  30. INVALID_PARAMETER = "invalid parameter"
  31. DENIED = "access denied"
  32. INVALID_NAME = "invalid name/path"
  33. INTERNAL = "internal error"
  34. NOT_IMPLEMENTED = "function not implemented"
  35. ALREADY_OPEN = "file is already open"
  36. UNKNOWN = "unknown error"
  37. @property
  38. def is_error(self):
  39. return self != self.OK
  40. @classmethod
  41. def from_value(cls, s: str | bytes):
  42. if isinstance(s, bytes):
  43. s = s.decode("ascii")
  44. for code in cls:
  45. if code.value == s:
  46. return code
  47. return cls.UNKNOWN
  48. class FlipperStorageException(Exception):
  49. @staticmethod
  50. def from_error_code(path: str, error_code: StorageErrorCode):
  51. return FlipperStorageException(
  52. f"Storage error: path '{path}': {error_code.value}"
  53. )
  54. class BufferedRead:
  55. def __init__(self, stream):
  56. self.buffer = bytearray()
  57. self.stream = stream
  58. def until(self, eol: str = "\n", cut_eol: bool = True):
  59. eol = eol.encode("ascii")
  60. while True:
  61. # search in buffer
  62. i = self.buffer.find(eol)
  63. if i >= 0:
  64. if cut_eol:
  65. read = self.buffer[:i]
  66. else:
  67. read = self.buffer[: i + len(eol)]
  68. self.buffer = self.buffer[i + len(eol) :]
  69. return read
  70. # read and append to buffer
  71. i = max(1, self.stream.in_waiting)
  72. data = self.stream.read(i)
  73. self.buffer.extend(data)
  74. class FlipperStorage:
  75. CLI_PROMPT = ">: "
  76. CLI_EOL = "\r\n"
  77. def __init__(self, portname: str, chunk_size: int = 8192):
  78. self.port = serial.Serial()
  79. self.port.port = portname
  80. self.port.timeout = 2
  81. self.port.baudrate = 115200 # Doesn't matter for VCP
  82. self.read = BufferedRead(self.port)
  83. self.chunk_size = chunk_size
  84. def __enter__(self):
  85. self.start()
  86. return self
  87. def __exit__(self, exc_type, exc_value, traceback):
  88. self.stop()
  89. def start(self):
  90. self.port.open()
  91. self.port.reset_input_buffer()
  92. # Send a command with a known syntax to make sure the buffer is flushed
  93. self.send("device_info\r")
  94. self.read.until("hardware_model")
  95. # And read buffer until we get prompt
  96. self.read.until(self.CLI_PROMPT)
  97. def stop(self) -> None:
  98. self.port.close()
  99. def send(self, line: str) -> None:
  100. self.port.write(line.encode("ascii"))
  101. def send_and_wait_eol(self, line: str):
  102. self.send(line)
  103. return self.read.until(self.CLI_EOL)
  104. def send_and_wait_prompt(self, line: str):
  105. self.send(line)
  106. return self.read.until(self.CLI_PROMPT)
  107. def has_error(self, data: bytes | str) -> bool:
  108. """Is data an error message"""
  109. return data.find(b"Storage error:") != -1
  110. def get_error(self, data: bytes) -> StorageErrorCode:
  111. """Extract error text from data and print it"""
  112. _, error_text = data.decode("ascii").split(": ")
  113. return StorageErrorCode.from_value(error_text.strip())
  114. def list_tree(self, path: str = "/", level: int = 0):
  115. """List files and dirs on Flipper"""
  116. path = path.replace("//", "/")
  117. self.send_and_wait_eol(f'storage list "{path}"\r')
  118. data = self.read.until(self.CLI_PROMPT)
  119. lines = data.split(b"\r\n")
  120. for line in lines:
  121. try:
  122. # TODO: better decoding, considering non-ascii characters
  123. line = line.decode("ascii")
  124. except Exception:
  125. continue
  126. line = line.strip()
  127. if len(line) == 0:
  128. continue
  129. if self.has_error(line.encode("ascii")):
  130. print(self.get_error(line.encode("ascii")))
  131. continue
  132. if line == "Empty":
  133. continue
  134. type, info = line.split(" ", 1)
  135. if type == "[D]":
  136. # Print directory name
  137. print((path + "/" + info).replace("//", "/"))
  138. # And recursively go inside
  139. self.list_tree(path + "/" + info, level + 1)
  140. elif type == "[F]":
  141. name, size = info.rsplit(" ", 1)
  142. # Print file name and size
  143. print((path + "/" + name).replace("//", "/") + ", size " + size)
  144. else:
  145. # Something wrong, pass
  146. pass
  147. def walk(self, path: str = "/"):
  148. dirs = []
  149. nondirs = []
  150. walk_dirs = []
  151. path = path.replace("//", "/")
  152. self.send_and_wait_eol(f'storage list "{path}"\r')
  153. data = self.read.until(self.CLI_PROMPT)
  154. lines = data.split(b"\r\n")
  155. for line in lines:
  156. try:
  157. # TODO: better decoding, considering non-ascii characters
  158. line = line.decode("ascii")
  159. except Exception:
  160. continue
  161. line = line.strip()
  162. if len(line) == 0:
  163. continue
  164. if self.has_error(line.encode("ascii")):
  165. continue
  166. if line == "Empty":
  167. continue
  168. type, info = line.split(" ", 1)
  169. if type == "[D]":
  170. # Print directory name
  171. dirs.append(info)
  172. walk_dirs.append((path + "/" + info).replace("//", "/"))
  173. elif type == "[F]":
  174. name, size = info.rsplit(" ", 1)
  175. # Print file name and size
  176. nondirs.append(name)
  177. else:
  178. # Something wrong, pass
  179. pass
  180. # topdown walk, yield before recursing
  181. yield path, dirs, nondirs
  182. for new_path in walk_dirs:
  183. yield from self.walk(new_path)
  184. def send_file(self, filename_from: str, filename_to: str):
  185. """Send file from local device to Flipper"""
  186. if self.exist_file(filename_to):
  187. self.remove(filename_to)
  188. with open(filename_from, "rb") as file:
  189. filesize = os.fstat(file.fileno()).st_size
  190. buffer_size = self.chunk_size
  191. while True:
  192. filedata = file.read(buffer_size)
  193. size = len(filedata)
  194. if size == 0:
  195. break
  196. self.send_and_wait_eol(f'storage write_chunk "{filename_to}" {size}\r')
  197. answer = self.read.until(self.CLI_EOL)
  198. if self.has_error(answer):
  199. last_error = self.get_error(answer)
  200. self.read.until(self.CLI_PROMPT)
  201. raise FlipperStorageException.from_error_code(
  202. filename_to, last_error
  203. )
  204. self.port.write(filedata)
  205. self.read.until(self.CLI_PROMPT)
  206. percent = str(math.ceil(file.tell() / filesize * 100))
  207. total_chunks = str(math.ceil(filesize / buffer_size))
  208. current_chunk = str(math.ceil(file.tell() / buffer_size))
  209. sys.stdout.write(
  210. f"\r{percent}%, chunk {current_chunk} of {total_chunks}"
  211. )
  212. sys.stdout.flush()
  213. print()
  214. def read_file(self, filename: str):
  215. """Receive file from Flipper, and get filedata (bytes)"""
  216. buffer_size = self.chunk_size
  217. self.send_and_wait_eol(
  218. 'storage read_chunks "' + filename + '" ' + str(buffer_size) + "\r"
  219. )
  220. answer = self.read.until(self.CLI_EOL)
  221. filedata = bytearray()
  222. if self.has_error(answer):
  223. last_error = self.get_error(answer)
  224. self.read.until(self.CLI_PROMPT)
  225. raise FlipperStorageException(filename, last_error)
  226. # return filedata
  227. size = int(answer.split(b": ")[1])
  228. read_size = 0
  229. while read_size < size:
  230. self.read.until("Ready?" + self.CLI_EOL)
  231. self.send("y")
  232. chunk_size = min(size - read_size, buffer_size)
  233. filedata.extend(self.port.read(chunk_size))
  234. read_size = read_size + chunk_size
  235. percent = str(math.ceil(read_size / size * 100))
  236. total_chunks = str(math.ceil(size / buffer_size))
  237. current_chunk = str(math.ceil(read_size / buffer_size))
  238. sys.stdout.write(f"\r{percent}%, chunk {current_chunk} of {total_chunks}")
  239. sys.stdout.flush()
  240. print()
  241. self.read.until(self.CLI_PROMPT)
  242. return filedata
  243. def receive_file(self, filename_from: str, filename_to: str):
  244. """Receive file from Flipper to local storage"""
  245. with open(filename_to, "wb") as file:
  246. data = self.read_file(filename_from)
  247. file.write(data)
  248. def exist(self, path: str):
  249. """Does file or dir exist on Flipper"""
  250. self.send_and_wait_eol(f'storage stat "{path}"\r')
  251. response = self.read.until(self.CLI_EOL)
  252. self.read.until(self.CLI_PROMPT)
  253. return not self.has_error(response)
  254. def exist_dir(self, path: str):
  255. """Does dir exist on Flipper"""
  256. self.send_and_wait_eol(f'storage stat "{path}"\r')
  257. response = self.read.until(self.CLI_EOL)
  258. self.read.until(self.CLI_PROMPT)
  259. if self.has_error(response):
  260. error_code = self.get_error(response)
  261. if error_code in (
  262. StorageErrorCode.NOT_EXIST,
  263. StorageErrorCode.INVALID_NAME,
  264. ):
  265. return False
  266. raise FlipperStorageException.from_error_code(path, error_code)
  267. return response == b"Directory" or response.startswith(b"Storage")
  268. def exist_file(self, path: str):
  269. """Does file exist on Flipper"""
  270. self.send_and_wait_eol(f'storage stat "{path}"\r')
  271. response = self.read.until(self.CLI_EOL)
  272. self.read.until(self.CLI_PROMPT)
  273. return response.find(b"File, size:") != -1
  274. def _check_no_error(self, response, path=None):
  275. if self.has_error(response):
  276. raise FlipperStorageException.from_error_code(
  277. path, self.get_error(response)
  278. )
  279. def size(self, path: str):
  280. """file size on Flipper"""
  281. self.send_and_wait_eol(f'storage stat "{path}"\r')
  282. response = self.read.until(self.CLI_EOL)
  283. self.read.until(self.CLI_PROMPT)
  284. self._check_no_error(response, path)
  285. if response.find(b"File, size:") != -1:
  286. size = int(
  287. "".join(
  288. ch
  289. for ch in response.split(b": ")[1].decode("ascii")
  290. if ch.isdigit()
  291. )
  292. )
  293. return size
  294. raise FlipperStorageException("Not a file")
  295. def mkdir(self, path: str):
  296. """Create a directory on Flipper"""
  297. self.send_and_wait_eol(f'storage mkdir "{path}"\r')
  298. response = self.read.until(self.CLI_EOL)
  299. self.read.until(self.CLI_PROMPT)
  300. self._check_no_error(response, path)
  301. def format_ext(self):
  302. """Format external storage on Flipper"""
  303. self.send_and_wait_eol("storage format /ext\r")
  304. self.send_and_wait_eol("y\r")
  305. response = self.read.until(self.CLI_EOL)
  306. self.read.until(self.CLI_PROMPT)
  307. self._check_no_error(response, "/ext")
  308. def remove(self, path: str):
  309. """Remove file or directory on Flipper"""
  310. self.send_and_wait_eol(f'storage remove "{path}"\r')
  311. response = self.read.until(self.CLI_EOL)
  312. self.read.until(self.CLI_PROMPT)
  313. self._check_no_error(response, path)
  314. def hash_local(self, filename: str):
  315. """Hash of local file"""
  316. hash_md5 = hashlib.md5()
  317. with open(filename, "rb") as f:
  318. for chunk in iter(lambda: f.read(self.chunk_size), b""):
  319. hash_md5.update(chunk)
  320. return hash_md5.hexdigest()
  321. def hash_flipper(self, filename: str):
  322. """Get hash of file on Flipper"""
  323. self.send_and_wait_eol('storage md5 "' + filename + '"\r')
  324. hash = self.read.until(self.CLI_EOL)
  325. self.read.until(self.CLI_PROMPT)
  326. self._check_no_error(hash, filename)
  327. return hash.decode("ascii")
  328. class FlipperStorageOperations:
  329. def __init__(self, storage):
  330. self.storage: FlipperStorage = storage
  331. self.logger = logging.getLogger("FStorageOps")
  332. def send_file_to_storage(
  333. self, flipper_file_path: str, local_file_path: str, force: bool = False
  334. ):
  335. self.logger.debug(
  336. f"* send_file_to_storage: {local_file_path}->{flipper_file_path}, {force=}"
  337. )
  338. exists = self.storage.exist_file(flipper_file_path)
  339. do_upload = not exists
  340. if exists:
  341. hash_local = self.storage.hash_local(local_file_path)
  342. hash_flipper = self.storage.hash_flipper(flipper_file_path)
  343. self.logger.debug(f"hash check: local {hash_local}, flipper {hash_flipper}")
  344. do_upload = force or (hash_local != hash_flipper)
  345. if do_upload:
  346. self.logger.info(f'Sending "{local_file_path}" to "{flipper_file_path}"')
  347. self.storage.send_file(local_file_path, flipper_file_path)
  348. # make directory with exist check
  349. def mkpath(self, flipper_dir_path: str):
  350. path_components, dirs_to_create = flipper_dir_path.split("/"), []
  351. while not self.storage.exist_dir(dir_path := "/".join(path_components)):
  352. self.logger.debug(f'"{dir_path}" does not exist, will create')
  353. dirs_to_create.append(path_components.pop())
  354. for dir_to_create in reversed(dirs_to_create):
  355. path_components.append(dir_to_create)
  356. self.storage.mkdir("/".join(path_components))
  357. # send file or folder recursively
  358. def recursive_send(self, flipper_path: str, local_path: str, force: bool = False):
  359. if not os.path.exists(local_path):
  360. raise FlipperStorageException(f'"{local_path}" does not exist')
  361. if os.path.isdir(local_path):
  362. # create parent dir
  363. self.mkpath(flipper_path)
  364. for dirpath, dirnames, filenames in os.walk(local_path):
  365. self.logger.debug(f'Processing directory "{os.path.normpath(dirpath)}"')
  366. dirnames.sort()
  367. filenames.sort()
  368. rel_path = os.path.relpath(dirpath, local_path)
  369. # create subdirs
  370. for dirname in dirnames:
  371. flipper_dir_path = os.path.join(flipper_path, rel_path, dirname)
  372. flipper_dir_path = os.path.normpath(flipper_dir_path).replace(
  373. os.sep, "/"
  374. )
  375. self.mkpath(flipper_dir_path)
  376. # send files
  377. for filename in filenames:
  378. flipper_file_path = os.path.join(flipper_path, rel_path, filename)
  379. flipper_file_path = os.path.normpath(flipper_file_path).replace(
  380. os.sep, "/"
  381. )
  382. local_file_path = os.path.normpath(os.path.join(dirpath, filename))
  383. self.send_file_to_storage(flipper_file_path, local_file_path, force)
  384. else:
  385. self.mkpath(posixpath.dirname(flipper_path))
  386. self.send_file_to_storage(flipper_path, local_path, force)
  387. def recursive_receive(self, flipper_path: str, local_path: str):
  388. if self.storage.exist_dir(flipper_path):
  389. for dirpath, dirnames, filenames in self.storage.walk(flipper_path):
  390. self.logger.debug(
  391. f'Processing directory "{os.path.normpath(dirpath)}"'.replace(
  392. os.sep, "/"
  393. )
  394. )
  395. dirnames.sort()
  396. filenames.sort()
  397. rel_path = os.path.relpath(dirpath, flipper_path)
  398. for dirname in dirnames:
  399. local_dir_path = os.path.join(local_path, rel_path, dirname)
  400. local_dir_path = os.path.normpath(local_dir_path)
  401. os.makedirs(local_dir_path, exist_ok=True)
  402. for filename in filenames:
  403. local_file_path = os.path.join(local_path, rel_path, filename)
  404. local_file_path = os.path.normpath(local_file_path)
  405. flipper_file_path = os.path.normpath(
  406. os.path.join(dirpath, filename)
  407. ).replace(os.sep, "/")
  408. self.logger.info(
  409. f'Receiving "{flipper_file_path}" to "{local_file_path}"'
  410. )
  411. self.storage.receive_file(flipper_file_path, local_file_path)
  412. else:
  413. self.logger.info(f'Receiving "{flipper_path}" to "{local_path}"')
  414. self.storage.receive_file(flipper_path, local_path)