storage.py 17 KB

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