|
14 | 14 | import traceback |
15 | 15 | from datetime import datetime |
16 | 16 | from decimal import Decimal |
| 17 | +from pathlib import Path |
17 | 18 | from typing import Any, Callable, Dict, List, Optional, Union |
18 | 19 |
|
19 | 20 | import google.protobuf.json_format |
|
36 | 37 | protocols, |
37 | 38 | publishingThread, |
38 | 39 | ) |
39 | | -from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2 |
| 40 | +from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2, xmodem_pb2 |
40 | 41 | from meshtastic.util import ( |
41 | 42 | Acknowledgment, |
42 | 43 | Timeout, |
@@ -134,6 +135,8 @@ def __init__( |
134 | 135 | self.queue: collections.OrderedDict = collections.OrderedDict() |
135 | 136 | self._localChannels = None |
136 | 137 | self.filesystem_entries = collections.OrderedDict() |
| 138 | + self._xmodem_lock: threading.Lock = threading.Lock() |
| 139 | + self._xmodem_state: Optional[Dict[str, Any]] = None |
137 | 140 |
|
138 | 141 | # We could have just not passed in debugOut to MeshInterface, and instead told consumers to subscribe to |
139 | 142 | # 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: |
393 | 396 | print(table) |
394 | 397 | return table |
395 | 398 |
|
| 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 | + |
396 | 651 | def getNode( |
397 | 652 | self, nodeId: str, requestChannels: bool = True, requestChannelAttempts: int = 3, timeout: int = 300 |
398 | 653 | ) -> meshtastic.node.Node: |
@@ -1389,6 +1644,7 @@ def _handleFromRadio(self, fromRadioBytes): |
1389 | 1644 | ) |
1390 | 1645 |
|
1391 | 1646 | elif fromRadio.HasField("xmodemPacket"): |
| 1647 | + self._handleXmodemPacket(fromRadio.xmodemPacket) |
1392 | 1648 | publishingThread.queueWork( |
1393 | 1649 | lambda: pub.sendMessage( |
1394 | 1650 | "meshtastic.xmodempacket", |
|
0 commit comments