Skip to content

Commit 5eb53fc

Browse files
committed
fs-download
1 parent 8fdba59 commit 5eb53fc

2 files changed

Lines changed: 282 additions & 1 deletion

File tree

meshtastic/__main__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,24 @@ def setSimpleConfig(modem_preset):
10261026
closeNow = True
10271027
interface.showFileSystem()
10281028

1029+
if args.fs_download:
1030+
if len(args.fs_download) > 2:
1031+
print("--fs-download expects at most two arguments: <node_src> [host_dst]")
1032+
return
1033+
if args.dest != BROADCAST_ADDR:
1034+
print("Downloading from a remote node is not supported.")
1035+
return
1036+
node_src = args.fs_download[0]
1037+
host_dst = args.fs_download[1] if len(args.fs_download) == 2 else "."
1038+
try:
1039+
destination_path = interface.download_file(node_src, host_dst)
1040+
except MeshInterface.MeshInterfaceError as ex:
1041+
closeNow = True
1042+
print(f"ERROR: {ex}")
1043+
return
1044+
closeNow = True
1045+
print(f"Downloaded '{node_src}' to '{destination_path}'.")
1046+
10291047
if args.qr or args.qr_all:
10301048
closeNow = True
10311049
url = interface.getNode(args.dest, True, **getNode_kwargs).getURL(includeAll=args.qr_all)
@@ -1840,6 +1858,13 @@ def addLocalActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
18401858
action="store_true",
18411859
)
18421860

1861+
group.add_argument(
1862+
"--fs-download",
1863+
nargs="+",
1864+
metavar=("NODE_SRC", "HOST_DST"),
1865+
help="Download a file from the node filesystem. Provide NODE_SRC and optionally a HOST_DST path.",
1866+
)
1867+
18431868
return parser
18441869

18451870
def addRemoteActionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:

meshtastic/mesh_interface.py

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import traceback
1515
from datetime import datetime
1616
from decimal import Decimal
17+
from pathlib import Path
1718
from typing import Any, Callable, Dict, List, Optional, Union
1819

1920
import google.protobuf.json_format
@@ -36,7 +37,7 @@
3637
protocols,
3738
publishingThread,
3839
)
39-
from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2
40+
from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2, xmodem_pb2
4041
from meshtastic.util import (
4142
Acknowledgment,
4243
Timeout,
@@ -134,6 +135,8 @@ def __init__(
134135
self.queue: collections.OrderedDict = collections.OrderedDict()
135136
self._localChannels = None
136137
self.filesystem_entries = collections.OrderedDict()
138+
self._xmodem_lock: threading.Lock = threading.Lock()
139+
self._xmodem_state: Optional[Dict[str, Any]] = None
137140

138141
# We could have just not passed in debugOut to MeshInterface, and instead told consumers to subscribe to
139142
# the meshtastic.log.line publish instead. Alas though changing that now would be a breaking API change
@@ -393,6 +396,258 @@ def showFileSystem(self) -> str:
393396
print(table)
394397
return table
395398

399+
@staticmethod
400+
def _crc16_ccitt(data: bytes) -> int:
401+
crc16 = 0
402+
for byte in data:
403+
crc16 = ((crc16 >> 8) & 0xFF) | ((crc16 << 8) & 0xFFFF)
404+
crc16 ^= byte
405+
crc16 ^= (crc16 & 0xFF) >> 4
406+
crc16 ^= (crc16 << 8) << 4
407+
crc16 ^= ((crc16 & 0xFF) << 4) << 1
408+
crc16 &= 0xFFFF
409+
return crc16
410+
411+
def _sendXmodemControl(
412+
self,
413+
control: xmodem_pb2.XModem.Control.ValueType,
414+
seq: int = 0,
415+
payload: bytes = b"",
416+
crc16: Optional[int] = None,
417+
) -> None:
418+
message = mesh_pb2.ToRadio()
419+
packet = message.xmodemPacket
420+
packet.control = control
421+
packet.seq = seq
422+
if payload:
423+
packet.buffer = payload
424+
if crc16 is not None:
425+
packet.crc16 = crc16
426+
self._sendToRadio(message)
427+
428+
def _sendXmodemRequest(self, node_src: str) -> None:
429+
request = mesh_pb2.ToRadio()
430+
packet = request.xmodemPacket
431+
packet.control = xmodem_pb2.XModem.Control.STX
432+
packet.seq = 0
433+
packet.buffer = node_src.encode("utf-8")
434+
self._sendToRadio(request)
435+
436+
def _complete_xmodem_locked(self, success: bool, error: Optional[str] = None) -> None:
437+
state = self._xmodem_state
438+
if not state or state.get("done"):
439+
return
440+
file_handle = state.get("file")
441+
if file_handle and not state.get("closed"):
442+
try:
443+
file_handle.flush()
444+
except Exception:
445+
pass
446+
try:
447+
file_handle.close()
448+
except Exception:
449+
pass
450+
state["closed"] = True
451+
state["success"] = success
452+
state["error"] = error
453+
state["done"] = True
454+
state["should_remove"] = not success
455+
state["event"].set()
456+
457+
def _cleanup_xmodem_state_locked(self, remove_partial: bool = False) -> None:
458+
state = self._xmodem_state
459+
if not state:
460+
return
461+
file_handle = state.get("file")
462+
if file_handle and not state.get("closed"):
463+
try:
464+
file_handle.close()
465+
except Exception:
466+
pass
467+
state["closed"] = True
468+
path = state.get("path")
469+
self._xmodem_state = None
470+
if remove_partial and path:
471+
try:
472+
Path(path).unlink(missing_ok=True)
473+
except Exception as ex:
474+
logger.debug(f"Failed to remove partial download {path}: {ex}")
475+
476+
def _handleXmodemPacket(self, packet: xmodem_pb2.XModem) -> None:
477+
control_to_send: Optional[xmodem_pb2.XModem.Control.ValueType] = None
478+
seq_to_send = packet.seq
479+
480+
with self._xmodem_lock:
481+
state = self._xmodem_state
482+
if not state or state.get("mode") != "download" or state.get("done"):
483+
return
484+
485+
control = packet.control
486+
if control in (
487+
xmodem_pb2.XModem.Control.SOH,
488+
xmodem_pb2.XModem.Control.STX,
489+
):
490+
expected_seq = state.get("expected_seq", 1)
491+
seq = packet.seq
492+
if seq != expected_seq:
493+
logger.warning(
494+
"Unexpected XMODEM sequence. expected=%s got=%s",
495+
expected_seq,
496+
seq,
497+
)
498+
control_to_send = xmodem_pb2.XModem.Control.NAK
499+
state["last_activity"] = time.time()
500+
else:
501+
data = packet.buffer
502+
crc_local = self._crc16_ccitt(data)
503+
if packet.crc16 != crc_local:
504+
logger.warning(
505+
"XMODEM CRC mismatch for %s. expected=%s got=%s",
506+
state.get("path"),
507+
packet.crc16,
508+
crc_local,
509+
)
510+
control_to_send = xmodem_pb2.XModem.Control.NAK
511+
state["last_activity"] = time.time()
512+
else:
513+
try:
514+
file_handle = state["file"]
515+
file_handle.write(data)
516+
except Exception as ex:
517+
logger.error(
518+
"Error writing XMODEM data to %s: %s",
519+
state.get("path"),
520+
ex,
521+
)
522+
self._complete_xmodem_locked(
523+
False,
524+
f"Failed writing to {state.get('path')}: {ex}",
525+
)
526+
control_to_send = xmodem_pb2.XModem.Control.CAN
527+
else:
528+
state["expected_seq"] = expected_seq + 1
529+
state["last_activity"] = time.time()
530+
control_to_send = xmodem_pb2.XModem.Control.ACK
531+
elif control == xmodem_pb2.XModem.Control.EOT:
532+
control_to_send = xmodem_pb2.XModem.Control.ACK
533+
self._complete_xmodem_locked(True)
534+
elif control == xmodem_pb2.XModem.Control.NAK:
535+
logger.error("Device reported NAK while sending %s", state.get("path"))
536+
self._complete_xmodem_locked(
537+
False, "Device reported NAK during XMODEM transfer."
538+
)
539+
elif control == xmodem_pb2.XModem.Control.CAN:
540+
logger.error("Device cancelled XMODEM transfer for %s", state.get("path"))
541+
self._complete_xmodem_locked(
542+
False, "Device cancelled the XMODEM transfer."
543+
)
544+
elif control == xmodem_pb2.XModem.Control.ACK:
545+
# Ignore ACKs from device during download.
546+
pass
547+
else:
548+
logger.error("Unsupported XMODEM control %s", control)
549+
control_to_send = xmodem_pb2.XModem.Control.CAN
550+
self._complete_xmodem_locked(
551+
False, f"Unsupported XMODEM control {control}."
552+
)
553+
554+
if control_to_send is not None:
555+
try:
556+
self._sendXmodemControl(control_to_send, seq_to_send)
557+
except Exception as ex:
558+
logger.error(f"Failed to send XMODEM control {control_to_send}: {ex}")
559+
560+
def download_file(
561+
self,
562+
node_src: str,
563+
host_dst: Optional[str] = None,
564+
*,
565+
overwrite: bool = False,
566+
timeout: int = 120,
567+
) -> str:
568+
if not node_src:
569+
raise MeshInterface.MeshInterfaceError("Remote path must be provided.")
570+
571+
node_src_clean = node_src.strip()
572+
if not node_src_clean:
573+
raise MeshInterface.MeshInterfaceError("Remote path must not be empty.")
574+
575+
destination = Path(host_dst or ".")
576+
if destination.is_dir():
577+
destination = destination / Path(node_src_clean).name
578+
579+
if destination.exists() and not overwrite:
580+
raise MeshInterface.MeshInterfaceError(
581+
f"Destination file '{destination}' already exists."
582+
)
583+
584+
destination.parent.mkdir(parents=True, exist_ok=True)
585+
586+
transfer_event = threading.Event()
587+
file_handle = open(destination, "wb")
588+
589+
with self._xmodem_lock:
590+
if self._xmodem_state and not self._xmodem_state.get("done"):
591+
file_handle.close()
592+
raise MeshInterface.MeshInterfaceError(
593+
"Another XMODEM transfer is already in progress."
594+
)
595+
self._xmodem_state = {
596+
"mode": "download",
597+
"expected_seq": 1,
598+
"file": file_handle,
599+
"event": transfer_event,
600+
"path": str(destination),
601+
"success": None,
602+
"error": None,
603+
"done": False,
604+
"closed": False,
605+
"should_remove": False,
606+
"last_activity": time.time(),
607+
}
608+
609+
try:
610+
self._sendXmodemRequest(node_src_clean)
611+
except Exception as ex:
612+
with self._xmodem_lock:
613+
self._complete_xmodem_locked(False, f"Failed to start XMODEM transfer: {ex}")
614+
self._cleanup_xmodem_state_locked(remove_partial=True)
615+
raise
616+
617+
if not transfer_event.wait(timeout):
618+
with self._xmodem_lock:
619+
# Attempt to cancel on timeout
620+
try:
621+
self._sendXmodemControl(xmodem_pb2.XModem.Control.CAN)
622+
except Exception as ex:
623+
logger.debug(f"Failed to send XMODEM cancel: {ex}")
624+
self._complete_xmodem_locked(
625+
False, "Timed out waiting for XMODEM transfer."
626+
)
627+
state = self._xmodem_state
628+
error_message = "Timed out waiting for XMODEM transfer."
629+
if state and state.get("error"):
630+
error_message = state["error"]
631+
self._cleanup_xmodem_state_locked(remove_partial=True)
632+
raise MeshInterface.MeshInterfaceError(error_message)
633+
634+
with self._xmodem_lock:
635+
state = self._xmodem_state
636+
if not state:
637+
raise MeshInterface.MeshInterfaceError("XMODEM transfer state missing.")
638+
success = bool(state.get("success"))
639+
error_message = state.get("error")
640+
destination_path = state.get("path")
641+
remove_partial = state.get("should_remove", False)
642+
self._cleanup_xmodem_state_locked(remove_partial=remove_partial)
643+
644+
if not success:
645+
raise MeshInterface.MeshInterfaceError(
646+
error_message or "XMODEM transfer failed."
647+
)
648+
649+
return destination_path or str(destination)
650+
396651
def getNode(
397652
self, nodeId: str, requestChannels: bool = True, requestChannelAttempts: int = 3, timeout: int = 300
398653
) -> meshtastic.node.Node:
@@ -1389,6 +1644,7 @@ def _handleFromRadio(self, fromRadioBytes):
13891644
)
13901645

13911646
elif fromRadio.HasField("xmodemPacket"):
1647+
self._handleXmodemPacket(fromRadio.xmodemPacket)
13921648
publishingThread.queueWork(
13931649
lambda: pub.sendMessage(
13941650
"meshtastic.xmodempacket",

0 commit comments

Comments
 (0)