diff --git a/repeater/engine.py b/repeater/engine.py index 0cb6083..90ead0b 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -11,6 +11,7 @@ from pymc_core.protocol import Packet from pymc_core.protocol.constants import ( MAX_PATH_SIZE, PAYLOAD_TYPE_ADVERT, + PAYLOAD_TYPE_ANON_REQ, PH_ROUTE_MASK, PH_TYPE_MASK, PH_TYPE_SHIFT, @@ -409,6 +410,87 @@ class RepeaterHandler(BaseHandler): if len(self.recent_packets) > self.max_recent_packets: self.recent_packets.pop(0) + def record_packet_only(self, packet: Packet, metadata: dict) -> None: + """Record a packet for UI/storage without running forwarding or duplicate logic. + + Used by the packet router for injection-only types (ANON_REQ, ACK, PATH, etc.) + so they still appear in the web UI. + """ + if not self.storage: + return + rssi = metadata.get("rssi", 0) + snr = metadata.get("snr", 0.0) + if not hasattr(packet, "header") or packet.header is None: + logger.debug("record_packet_only: packet missing header, skipping") + return + header_info = PacketHeaderUtils.parse_header(packet.header) + payload_type = header_info["payload_type"] + route_type = header_info["route_type"] + pkt_hash = packet.calculate_packet_hash().hex().upper() + original_path_hashes = packet.get_path_hashes_hex() + path_hash_size = packet.get_path_hash_size() + display_hashes = original_path_hashes + path_hash = None + if display_hashes: + display = display_hashes[:8] + if len(display_hashes) > 8: + display = list(display) + ["..."] + path_hash = "[" + ", ".join(display) + "]" + src_hash = None + dst_hash = None + if payload_type in [0x00, 0x01, 0x02, 0x08]: + if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2: + dst_hash = f"{packet.payload[0]:02X}" + src_hash = f"{packet.payload[1]:02X}" + elif payload_type == PAYLOAD_TYPE_ADVERT: + if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1: + src_hash = f"{packet.payload[0]:02X}" + elif payload_type == PAYLOAD_TYPE_ANON_REQ: + if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1: + dst_hash = f"{packet.payload[0]:02X}" + packet_record = { + "timestamp": time.time(), + "header": f"0x{packet.header:02X}", + "payload": ( + packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None + ), + "payload_length": ( + len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0 + ), + "type": payload_type, + "route": route_type, + "length": len(packet.payload or b""), + "rssi": rssi, + "snr": snr, + "score": self.calculate_packet_score( + snr, len(packet.payload or b""), self.radio_config["spreading_factor"] + ), + "tx_delay_ms": 0.0, + "transmitted": False, + "is_duplicate": False, + "packet_hash": pkt_hash[:16], + "drop_reason": None, + "path_hash": path_hash, + "src_hash": src_hash, + "dst_hash": dst_hash, + "original_path": original_path_hashes or None, + "forwarded_path": None, + "path_hash_size": path_hash_size, + "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, + "lbt_attempts": 0, + "lbt_backoff_delays_ms": None, + "lbt_channel_busy": False, + } + try: + self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False) + except Exception as e: + logger.error(f"Failed to store packet record (record_packet_only): {e}") + return + self.recent_packets.append(packet_record) + if len(self.recent_packets) > self.max_recent_packets: + self.recent_packets.pop(0) + self.rx_count += 1 + def cleanup_cache(self): now = time.time() diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 843fc44..3a94466 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -69,6 +69,15 @@ class PacketRouter: self._companion_delivered[key] = now + _COMPANION_DEDUPE_TTL_SEC return True + def _record_for_ui(self, packet, metadata: dict) -> None: + """Record an injection-only packet for the web UI (storage + recent_packets).""" + handler = getattr(self.daemon, "repeater_handler", None) + if handler and getattr(handler, "storage", None): + try: + handler.record_packet_only(packet, metadata) + except Exception as e: + logger.debug("Record for UI failed: %s", e) + async def enqueue(self, packet): """Add packet to router queue.""" await self.queue.put(packet) @@ -124,6 +133,11 @@ class PacketRouter: payload_type = packet.get_payload_type() processed_by_injection = False + metadata = { + "rssi": getattr(packet, "rssi", 0), + "snr": getattr(packet, "snr", 0.0), + "timestamp": getattr(packet, "timestamp", 0), + } # Route to specific handlers for parsing only if payload_type == TraceHandler.payload_type(): @@ -132,6 +146,7 @@ class PacketRouter: await self.daemon.trace_helper.process_trace_packet(packet) # Skip engine processing for trace packets - they're handled by trace helper processed_by_injection = True + self._record_for_ui(packet, metadata) elif payload_type == ControlHandler.payload_type(): # Process control/discovery packet @@ -180,6 +195,8 @@ class PacketRouter: else: # Login request for remote repeater (we already TXed it via inject); don't treat as RX. processed_by_injection = True + if processed_by_injection: + self._record_for_ui(packet, metadata) elif payload_type == AckHandler.payload_type(): # ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed @@ -190,6 +207,7 @@ class PacketRouter: except Exception as e: logger.debug(f"Companion bridge ACK error: {e}") processed_by_injection = True + self._record_for_ui(packet, metadata) elif payload_type == TextMessageHandler.payload_type(): dest_hash = packet.payload[0] if packet.payload else None @@ -197,6 +215,7 @@ class PacketRouter: if dest_hash is not None and dest_hash in companion_bridges: await companion_bridges[dest_hash].process_received_packet(packet) processed_by_injection = True + self._record_for_ui(packet, metadata) elif self.daemon.text_helper: handled = await self.daemon.text_helper.process_text_packet(packet) if handled: @@ -209,6 +228,7 @@ class PacketRouter: if self._should_deliver_path_to_companions(packet): await companion_bridges[dest_hash].process_received_packet(packet) processed_by_injection = True + self._record_for_ui(packet, metadata) elif companion_bridges and self._should_deliver_path_to_companions(packet): # Dest not in bridges: path-return with ephemeral dest (e.g. multi-hop login). # Deliver to all bridges; each will try to decrypt and ignore if not relevant. @@ -223,6 +243,7 @@ class PacketRouter: len(companion_bridges), ) processed_by_injection = True + self._record_for_ui(packet, metadata) elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) @@ -244,6 +265,7 @@ class PacketRouter: except Exception as e: logger.debug(f"Companion bridge RESPONSE error: {e}") processed_by_injection = True + self._record_for_ui(packet, metadata) elif dest_hash == local_hash and companion_bridges: # Response addressed to this repeater (e.g. path-based reply to first hop) for bridge in companion_bridges.values(): @@ -257,6 +279,7 @@ class PacketRouter: len(companion_bridges), ) processed_by_injection = True + self._record_for_ui(packet, metadata) elif companion_bridges: # Dest not in bridges and not local: likely ANON_REQ response (dest = ephemeral # sender hash). Deliver to all bridges; each will try to decrypt and ignore if @@ -272,6 +295,7 @@ class PacketRouter: len(companion_bridges), ) processed_by_injection = True + self._record_for_ui(packet, metadata) elif payload_type == ProtocolResponseHandler.payload_type(): # PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.). @@ -285,6 +309,7 @@ class PacketRouter: logger.debug(f"Companion bridge RESPONSE error: {e}") if companion_bridges: processed_by_injection = True + self._record_for_ui(packet, metadata) elif payload_type == ProtocolRequestHandler.payload_type(): dest_hash = packet.payload[0] if packet.payload else None @@ -292,10 +317,12 @@ class PacketRouter: if dest_hash is not None and dest_hash in companion_bridges: await companion_bridges[dest_hash].process_received_packet(packet) processed_by_injection = True + self._record_for_ui(packet, metadata) elif self.daemon.protocol_request_helper: handled = await self.daemon.protocol_request_helper.process_request_packet(packet) if handled: processed_by_injection = True + self._record_for_ui(packet, metadata) elif payload_type == GroupTextHandler.payload_type(): # GRP_TXT: pass to all companions (they filter by channel); still forward