|
37 | 37 | except ImportError as e: |
38 | 38 | have_test = False |
39 | 39 |
|
| 40 | +import meshtastic.file_transfer_cli as file_transfer_cli |
40 | 41 | import meshtastic.ota |
41 | 42 | import meshtastic.util |
42 | 43 | import meshtastic.serial_interface |
@@ -454,28 +455,102 @@ def onConnected(interface): |
454 | 455 | for path, sz in rows: |
455 | 456 | print(f"{sz}\t{path}") |
456 | 457 |
|
457 | | - if args.cp: |
| 458 | + if args.upload is not None: |
458 | 459 | closeNow = True |
459 | | - src, dst = args.cp |
460 | | - import os |
461 | 460 | node = interface.localNode |
462 | | - # Direction: if src is an existing local file → upload; otherwise → download |
463 | | - if os.path.isfile(src): |
464 | | - print(f"Uploading {src} → {dst}") |
465 | | - def _upload_progress(sent, total): |
466 | | - pct = 100 * sent // total |
467 | | - bar = '#' * (pct // 5) + '.' * (20 - pct // 5) |
468 | | - print(f"\r [{bar}] {pct}%", end="", flush=True) |
469 | | - ok = node.uploadFile(src, dst, on_progress=_upload_progress) |
470 | | - print(f"\r {'OK' if ok else 'FAILED'}: {src} → {dst} ") |
| 461 | + local_tokens = list(args.upload[:-1]) |
| 462 | + remote_base = args.upload[-1] |
| 463 | + try: |
| 464 | + upload_pairs = file_transfer_cli.plan_upload(local_tokens, remote_base) |
| 465 | + except file_transfer_cli.FileTransferCliError as e: |
| 466 | + meshtastic.util.our_exit(str(e), 1) |
| 467 | + |
| 468 | + def _upload_progress(sent, total): |
| 469 | + pct = 100 * sent // total if total else 0 |
| 470 | + bar = '#' * (pct // 5) + '.' * (20 - pct // 5) |
| 471 | + print(f"\r [{bar}] {pct}%", end="", flush=True) |
| 472 | + |
| 473 | + for i, (lp, devp) in enumerate(upload_pairs, start=1): |
| 474 | + print(f"Uploading ({i}/{len(upload_pairs)}) {lp} → {devp}") |
| 475 | + ok = node.uploadFile(lp, devp, on_progress=_upload_progress) |
| 476 | + print(f"\r {'OK' if ok else 'FAILED'}: {lp} → {devp} ") |
471 | 477 | if not ok: |
472 | 478 | meshtastic.util.our_exit("Upload failed", 1) |
473 | | - else: |
474 | | - print(f"Downloading {src} → {dst}") |
475 | | - def _download_progress(received, _total): |
476 | | - print(f"\r {received} bytes received...", end="", flush=True) |
477 | | - ok = node.downloadFile(src, dst, on_progress=_download_progress) |
478 | | - print(f"\r {'OK' if ok else 'FAILED'}: {src} → {dst} ") |
| 479 | + |
| 480 | + if args.download is not None: |
| 481 | + closeNow = True |
| 482 | + node = interface.localNode |
| 483 | + rpath, lpath = args.download |
| 484 | + lpath_abs = os.path.abspath(os.path.expanduser(lpath)) |
| 485 | + if os.path.isdir(lpath_abs): |
| 486 | + meshtastic.util.our_exit( |
| 487 | + "ERROR: --download LOCAL must be a file path, not a directory (use --download-tree or --download-glob).", |
| 488 | + 1, |
| 489 | + ) |
| 490 | + parent = os.path.dirname(lpath_abs) |
| 491 | + if parent: |
| 492 | + os.makedirs(parent, exist_ok=True) |
| 493 | + print(f"Downloading {rpath} → {lpath}") |
| 494 | + def _download_progress(received, _total): |
| 495 | + print(f"\r {received} bytes received...", end="", flush=True) |
| 496 | + ok = node.downloadFile(rpath, lpath_abs, on_progress=_download_progress) |
| 497 | + print(f"\r {'OK' if ok else 'FAILED'}: {rpath} → {lpath_abs} ") |
| 498 | + if not ok: |
| 499 | + meshtastic.util.our_exit("Download failed", 1) |
| 500 | + |
| 501 | + if args.download_tree is not None: |
| 502 | + closeNow = True |
| 503 | + node = interface.localNode |
| 504 | + rdir, ldir = args.download_tree |
| 505 | + depth = 255 |
| 506 | + rows = node.listDir(rdir.rstrip("/") or "/", depth=depth) |
| 507 | + if rows is None: |
| 508 | + meshtastic.util.our_exit("listDir failed", 1) |
| 509 | + try: |
| 510 | + tree_pairs = file_transfer_cli.plan_download_tree(rdir, ldir, rows) |
| 511 | + except file_transfer_cli.FileTransferCliError as e: |
| 512 | + meshtastic.util.our_exit(str(e), 1) |
| 513 | + if not tree_pairs: |
| 514 | + meshtastic.util.our_exit("No files matched for --download-tree", 1) |
| 515 | + |
| 516 | + def _download_progress(received, _total): |
| 517 | + print(f"\r {received} bytes received...", end="", flush=True) |
| 518 | + |
| 519 | + for i, (devp, locp) in enumerate(tree_pairs, start=1): |
| 520 | + os.makedirs(os.path.dirname(locp) or ".", exist_ok=True) |
| 521 | + print(f"Downloading ({i}/{len(tree_pairs)}) {devp} → {locp}") |
| 522 | + ok = node.downloadFile(devp, locp, on_progress=_download_progress) |
| 523 | + print(f"\r {'OK' if ok else 'FAILED'}: {devp} → {locp} ") |
| 524 | + if not ok: |
| 525 | + meshtastic.util.our_exit("Download failed", 1) |
| 526 | + |
| 527 | + if args.download_glob is not None: |
| 528 | + closeNow = True |
| 529 | + node = interface.localNode |
| 530 | + pattern, ldir = args.download_glob |
| 531 | + try: |
| 532 | + base, _rel = file_transfer_cli.split_remote_glob_pattern(pattern) |
| 533 | + except file_transfer_cli.FileTransferCliError as e: |
| 534 | + meshtastic.util.our_exit(str(e), 1) |
| 535 | + depth = 255 |
| 536 | + rows = node.listDir(base, depth=depth) |
| 537 | + if rows is None: |
| 538 | + meshtastic.util.our_exit("listDir failed", 1) |
| 539 | + try: |
| 540 | + glob_pairs = file_transfer_cli.plan_download_glob(pattern, ldir, rows) |
| 541 | + except file_transfer_cli.FileTransferCliError as e: |
| 542 | + meshtastic.util.our_exit(str(e), 1) |
| 543 | + if not glob_pairs: |
| 544 | + meshtastic.util.our_exit("No files matched for --download-glob", 1) |
| 545 | + |
| 546 | + def _download_progress(received, _total): |
| 547 | + print(f"\r {received} bytes received...", end="", flush=True) |
| 548 | + |
| 549 | + for i, (devp, locp) in enumerate(glob_pairs, start=1): |
| 550 | + os.makedirs(os.path.dirname(locp) or ".", exist_ok=True) |
| 551 | + print(f"Downloading ({i}/{len(glob_pairs)}) {devp} → {locp}") |
| 552 | + ok = node.downloadFile(devp, locp, on_progress=_download_progress) |
| 553 | + print(f"\r {'OK' if ok else 'FAILED'}: {devp} → {locp} ") |
479 | 554 | if not ok: |
480 | 555 | meshtastic.util.our_exit("Download failed", 1) |
481 | 556 |
|
@@ -1912,17 +1987,53 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars |
1912 | 1987 | default=None |
1913 | 1988 | ) |
1914 | 1989 |
|
1915 | | - group.add_argument( |
1916 | | - "--cp", |
| 1990 | + xfer = group.add_mutually_exclusive_group() |
| 1991 | + xfer.add_argument( |
| 1992 | + "--upload", |
| 1993 | + help=( |
| 1994 | + "Upload local files to the device via XModem (requires matching firmware). " |
| 1995 | + "Usage: --upload LOCAL [LOCAL ...] REMOTE. " |
| 1996 | + "Last argument is the device path: for a single plain file LOCAL, REMOTE is the exact destination file path; " |
| 1997 | + "otherwise REMOTE is a directory prefix and relative paths are preserved. " |
| 1998 | + "LOCAL may be files, directories (recursive), or globs (quote patterns with **). " |
| 1999 | + "Each device path must be <= 128 UTF-8 bytes." |
| 2000 | + ), |
| 2001 | + nargs="+", |
| 2002 | + metavar="SPEC", |
| 2003 | + default=None, |
| 2004 | + ) |
| 2005 | + xfer.add_argument( |
| 2006 | + "--download", |
| 2007 | + help=( |
| 2008 | + "Download one file from the device. " |
| 2009 | + "Usage: --download REMOTE_FILE LOCAL_FILE. " |
| 2010 | + "For a directory tree use --download-tree; for remote globs use --download-glob." |
| 2011 | + ), |
| 2012 | + nargs=2, |
| 2013 | + metavar=("REMOTE", "LOCAL"), |
| 2014 | + default=None, |
| 2015 | + ) |
| 2016 | + xfer.add_argument( |
| 2017 | + "--download-tree", |
1917 | 2018 | help=( |
1918 | | - "Copy a file to or from the device via XModem. " |
1919 | | - "Usage: --cp <src> <dst>. " |
1920 | | - "If <src> is an existing local file it is uploaded to <dst> on the device. " |
1921 | | - "Otherwise <src> is treated as a device path and downloaded to local <dst>. " |
1922 | | - "Use /__ext__/ or /__int__/ prefixes to target external or internal flash." |
| 2019 | + "Download a full remote directory tree via MFLIST + XModem. " |
| 2020 | + "Usage: --download-tree REMOTE_DIR LOCAL_DIR. " |
| 2021 | + "Only rows with size > 0 are treated as files." |
1923 | 2022 | ), |
1924 | 2023 | nargs=2, |
1925 | | - metavar=("SRC", "DST"), |
| 2024 | + metavar=("REMOTE_DIR", "LOCAL_DIR"), |
| 2025 | + default=None, |
| 2026 | + ) |
| 2027 | + xfer.add_argument( |
| 2028 | + "--download-glob", |
| 2029 | + help=( |
| 2030 | + "Download remote files matching a glob (MFLIST at the literal base + filter). " |
| 2031 | + "Usage: --download-glob 'REMOTE_PATTERN' LOCAL_DIR. " |
| 2032 | + "Pattern must include * ? or [; ** matches across / (relative to the literal base)." |
| 2033 | + ), |
| 2034 | + nargs=2, |
| 2035 | + metavar=("REMOTE_PATTERN", "LOCAL_DIR"), |
| 2036 | + default=None, |
1926 | 2037 | ) |
1927 | 2038 |
|
1928 | 2039 | group.add_argument( |
|
0 commit comments