Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions meshtastic/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
except ImportError as e:
have_test = False

import meshtastic.file_transfer_cli as file_transfer_cli
import meshtastic.ota
import meshtastic.util
import meshtastic.serial_interface
Expand Down Expand Up @@ -443,6 +444,116 @@ def onConnected(interface):
# Must turn off encryption on primary channel
interface.getNode(args.dest, **getNode_kwargs).turnOffEncryptionOnPrimaryChannel()

if args.ls is not None:
closeNow = True
remote_dir = args.ls
depth = int(getattr(args, "ls_depth", 0) or 0)
node = interface.localNode
rows = node.listDir(remote_dir, depth=depth)
if rows is None:
meshtastic.util.our_exit("listDir failed", 1)
for path, sz in rows:
print(f"{sz}\t{path}")

if args.upload is not None:
closeNow = True
node = interface.localNode
local_tokens = list(args.upload[:-1])
remote_base = args.upload[-1]
try:
upload_pairs = file_transfer_cli.plan_upload(local_tokens, remote_base)
except file_transfer_cli.FileTransferCliError as e:
meshtastic.util.our_exit(str(e), 1)

def _upload_progress(sent, total):
pct = 100 * sent // total if total else 0
bar = '#' * (pct // 5) + '.' * (20 - pct // 5)
print(f"\r [{bar}] {pct}%", end="", flush=True)

for i, (lp, devp) in enumerate(upload_pairs, start=1):
print(f"Uploading ({i}/{len(upload_pairs)}) {lp} → {devp}")
ok = node.uploadFile(lp, devp, on_progress=_upload_progress)
print(f"\r {'OK' if ok else 'FAILED'}: {lp} → {devp} ")
if not ok:
meshtastic.util.our_exit("Upload failed", 1)

if args.download is not None:
closeNow = True
node = interface.localNode
rpath, lpath = args.download
lpath_abs = os.path.abspath(os.path.expanduser(lpath))
if os.path.isdir(lpath_abs):
meshtastic.util.our_exit(
"ERROR: --download LOCAL must be a file path, not a directory (use --download-tree or --download-glob).",
1,
)
parent = os.path.dirname(lpath_abs)
if parent:
os.makedirs(parent, exist_ok=True)
print(f"Downloading {rpath} → {lpath}")
def _download_progress(received, _total):
print(f"\r {received} bytes received...", end="", flush=True)
ok = node.downloadFile(rpath, lpath_abs, on_progress=_download_progress)
print(f"\r {'OK' if ok else 'FAILED'}: {rpath} → {lpath_abs} ")
if not ok:
meshtastic.util.our_exit("Download failed", 1)

if args.download_tree is not None:
closeNow = True
node = interface.localNode
rdir, ldir = args.download_tree
depth = 255
rows = node.listDir(rdir.rstrip("/") or "/", depth=depth)
if rows is None:
meshtastic.util.our_exit("listDir failed", 1)
try:
tree_pairs = file_transfer_cli.plan_download_tree(rdir, ldir, rows)
except file_transfer_cli.FileTransferCliError as e:
meshtastic.util.our_exit(str(e), 1)
if not tree_pairs:
meshtastic.util.our_exit("No files matched for --download-tree", 1)

def _download_progress(received, _total):
print(f"\r {received} bytes received...", end="", flush=True)

for i, (devp, locp) in enumerate(tree_pairs, start=1):
os.makedirs(os.path.dirname(locp) or ".", exist_ok=True)
print(f"Downloading ({i}/{len(tree_pairs)}) {devp} → {locp}")
ok = node.downloadFile(devp, locp, on_progress=_download_progress)
print(f"\r {'OK' if ok else 'FAILED'}: {devp} → {locp} ")
if not ok:
meshtastic.util.our_exit("Download failed", 1)

if args.download_glob is not None:
closeNow = True
node = interface.localNode
pattern, ldir = args.download_glob
try:
base, _rel = file_transfer_cli.split_remote_glob_pattern(pattern)
except file_transfer_cli.FileTransferCliError as e:
meshtastic.util.our_exit(str(e), 1)
depth = 255
rows = node.listDir(base, depth=depth)
if rows is None:
meshtastic.util.our_exit("listDir failed", 1)
try:
glob_pairs = file_transfer_cli.plan_download_glob(pattern, ldir, rows)
except file_transfer_cli.FileTransferCliError as e:
meshtastic.util.our_exit(str(e), 1)
if not glob_pairs:
meshtastic.util.our_exit("No files matched for --download-glob", 1)

def _download_progress(received, _total):
print(f"\r {received} bytes received...", end="", flush=True)

for i, (devp, locp) in enumerate(glob_pairs, start=1):
os.makedirs(os.path.dirname(locp) or ".", exist_ok=True)
print(f"Downloading ({i}/{len(glob_pairs)}) {devp} → {locp}")
ok = node.downloadFile(devp, locp, on_progress=_download_progress)
print(f"\r {'OK' if ok else 'FAILED'}: {devp} → {locp} ")
if not ok:
meshtastic.util.our_exit("Download failed", 1)

if args.reboot:
closeNow = True
waitForAckNak = True
Expand Down Expand Up @@ -1876,6 +1987,74 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
default=None
)

xfer = group.add_mutually_exclusive_group()
xfer.add_argument(
"--upload",
help=(
"Upload local files to the device via XModem (requires matching firmware). "
"Usage: --upload LOCAL [LOCAL ...] REMOTE. "
"Last argument is the device path: for a single plain file LOCAL, REMOTE is the exact destination file path; "
"otherwise REMOTE is a directory prefix and relative paths are preserved. "
"LOCAL may be files, directories (recursive), or globs (quote patterns with **). "
"Each device path must be <= 128 UTF-8 bytes."
),
nargs="+",
metavar="SPEC",
default=None,
)
xfer.add_argument(
"--download",
help=(
"Download one file from the device. "
"Usage: --download REMOTE_FILE LOCAL_FILE. "
"For a directory tree use --download-tree; for remote globs use --download-glob."
),
nargs=2,
metavar=("REMOTE", "LOCAL"),
default=None,
)
xfer.add_argument(
"--download-tree",
help=(
"Download a full remote directory tree via MFLIST + XModem. "
"Usage: --download-tree REMOTE_DIR LOCAL_DIR. "
"Only rows with size > 0 are treated as files."
),
nargs=2,
metavar=("REMOTE_DIR", "LOCAL_DIR"),
default=None,
)
xfer.add_argument(
"--download-glob",
help=(
"Download remote files matching a glob (MFLIST at the literal base + filter). "
"Usage: --download-glob 'REMOTE_PATTERN' LOCAL_DIR. "
"Pattern must include * ? or [; ** matches across / (relative to the literal base)."
),
nargs=2,
metavar=("REMOTE_PATTERN", "LOCAL_DIR"),
default=None,
)

group.add_argument(
"--ls",
help=(
"List files on the device under REMOTE_DIR via XMODEM MFLIST (requires matching firmware). "
"Output: size_bytes<TAB>path (one per line)."
),
nargs="?",
const="/",
default=None,
metavar="REMOTE_DIR",
)

group.add_argument(
"--ls-depth",
help="Max directory depth for --ls (0 = files in REMOTE_DIR only).",
type=int,
default=0,
)

return parser

def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
Expand Down
Loading