From 67c59cc34156279ad33e298df8b3624ae4f6f339 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 9 Jun 2026 12:39:34 +0200 Subject: [PATCH] feat(channels): backend resend endpoint via CMD_SEND_RAW_PACKET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3 of 5. Adds POST /api/messages//resend, which re-broadcasts an own channel message verbatim using the raw_packet bytes captured at send time. Pushes the wire bytes directly through companion command 0x41 (CMD_SEND_RAW_PACKET), bypassing the higher-level send paths so repeaters dedupe by packet hash via Mesh::hasSeen — only previously-unreached nodes will pick up the resend. Returns 404 for unknown msg_id, 400 for not-own / missing snapshot / disconnected device, 500 for unexpected device errors. Co-Authored-By: Claude Opus 4.7 --- app/device_manager.py | 53 +++++++++++++++++++++++++++++++++++++++++++ app/meshcore/cli.py | 10 ++++++++ app/routes/api.py | 26 +++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/app/device_manager.py b/app/device_manager.py index a44112a..9784136 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -1729,6 +1729,59 @@ class DeviceManager: logger.error(f"Failed to send channel message: {e}") return {'success': False, 'error': str(e)} + # CMD_SEND_RAW_PACKET (firmware MyMesh.cpp:1976) — companion command 0x41. + # Frame: [cmd=0x41, priority(1), raw_packet_bytes...]. Firmware parses + # the packet via Packet::readFrom and queues it through sendPacket(), + # bypassing the higher-level sendFlood path (no MSG_SENT event, just OK/ERR). + _CMD_SEND_RAW_PACKET = 0x41 + + def resend_channel_message(self, msg_id: int) -> Dict: + """Re-broadcast an own channel message verbatim so repeaters can dedupe. + + Looks up channel_messages.raw_packet (captured at send time, refreshed + from echo correlation when clock drift is detected) and pushes the + full wire bytes through CMD_SEND_RAW_PACKET. Repeaters that already + forwarded the original packet ignore it via Mesh::hasSeen; repeaters + that missed it can now pick it up — so the only new echoes we see are + from previously-unreached nodes. + """ + if not self.is_connected: + return {'success': False, 'error': 'Device not connected'} + + msg = self.db.get_channel_message_by_id(msg_id) + if not msg: + return {'success': False, 'error': f'Message #{msg_id} not found'} + if not msg.get('is_own'): + return {'success': False, 'error': 'Can only resend own messages'} + raw_packet_hex = msg.get('raw_packet') + if not raw_packet_hex: + return {'success': False, + 'error': 'Message has no raw_packet snapshot (likely sent before this feature was deployed)'} + + try: + raw_packet = bytes.fromhex(raw_packet_hex) + except ValueError as e: + return {'success': False, 'error': f'Corrupt raw_packet: {e}'} + + cmd_frame = bytes([self._CMD_SEND_RAW_PACKET, 0]) + raw_packet # priority 0 + try: + from meshcore.events import EventType + event = self.execute( + self.mc.commands.send(cmd_frame, [EventType.OK, EventType.ERROR]) + ) + if event is None: + return {'success': False, 'error': 'No response from device'} + if event.type == EventType.ERROR: + err = getattr(event, 'payload', {}).get('reason') or \ + getattr(event, 'payload', {}).get('error') or 'unknown error' + logger.warning(f"Resend msg #{msg_id} failed: {err}") + return {'success': False, 'error': f'Device rejected resend: {err}'} + logger.info(f"Resent channel msg #{msg_id} via CMD_SEND_RAW_PACKET ({len(raw_packet)} bytes)") + return {'success': True, 'message': 'Resent', 'id': msg_id, 'bytes': len(raw_packet)} + except Exception as e: + logger.error(f"resend_channel_message #{msg_id} failed: {e}") + return {'success': False, 'error': str(e)} + def send_dm(self, recipient_pubkey: str, text: str) -> Dict: """Send a direct message with background retry. Returns result dict.""" if not self.is_connected: diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 6e5d9f5..5d9c481 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -73,6 +73,16 @@ def send_message(text: str, reply_to: Optional[str] = None, channel_index: int = return {'success': False, 'error': str(e)} +def resend_channel_message(msg_id: int) -> Dict: + """Re-broadcast an own channel message verbatim (raw resend with same packet hash).""" + try: + dm = _get_dm() + return dm.resend_channel_message(msg_id) + except Exception as e: + logger.error(f"resend_channel_message error: {e}") + return {'success': False, 'error': str(e)} + + # ============================================================================= # Contacts # ============================================================================= diff --git a/app/routes/api.py b/app/routes/api.py index b27ea9c..3d3cc5a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -608,6 +608,32 @@ def get_message_meta(msg_id): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/messages//resend', methods=['POST']) +def resend_channel_message(msg_id): + """Raw re-broadcast of an own channel message via CMD_SEND_RAW_PACKET. + + Pushes the exact stored wire bytes again so repeaters that already saw + the original packet dedupe it (same Mesh::hasSeen hash), while any + repeaters that missed it can pick it up. Used for "I never heard echoes + back" and "I want better coverage" scenarios. + """ + try: + result = cli.resend_channel_message(msg_id) + if result.get('success'): + return jsonify(result), 200 + err = result.get('error', 'Resend failed') + # 404 for missing snapshot or unknown id, 400 for ownership/disconnect, + # 500 for unexpected device errors. + if 'not found' in err.lower(): + return jsonify(result), 404 + if 'no raw_packet' in err.lower() or 'own messages' in err.lower() or 'not connected' in err.lower(): + return jsonify(result), 400 + return jsonify(result), 500 + except Exception as e: + logger.error(f"Error resending message #{msg_id}: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/messages', methods=['POST']) def send_message(): """