diff --git a/repeater/engine.py b/repeater/engine.py index 29f6ce1..febcbaa 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -458,6 +458,62 @@ class RepeaterHandler(BaseHandler): if len(self.recent_packets) > self.max_recent_packets: self.recent_packets.pop(0) + def record_duplicate(self, packet: Packet, rssi: int = 0, snr: float = 0.0) -> None: + """Record a known-duplicate packet for UI/storage visibility without forwarding. + + Called by the raw_packet_subscriber path so that path variants blocked + by the Dispatcher's payload-based dedup still appear in the UI. + """ + self.rx_count += 1 + route_type = packet.header & PH_ROUTE_MASK + if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD): + self.recv_flood_count += 1 + self.flood_dup_count += 1 + elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): + self.recv_direct_count += 1 + self.direct_dup_count += 1 + + header_info = PacketHeaderUtils.parse_header(packet.header) + payload_type = header_info["payload_type"] + route_type_parsed = header_info["route_type"] + + original_path_hashes = packet.get_path_hashes_hex() + path_hash_size = packet.get_path_hash_size() + path_hash = self._path_hash_display(original_path_hashes) + src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type) + + packet_record = self._build_packet_record( + packet, payload_type, route_type_parsed, rssi, snr, + original_path_hashes, path_hash_size, path_hash, + src_hash, dst_hash, + transmitted=False, + drop_reason="Duplicate", + is_duplicate=True, + ) + + if self.storage: + try: + self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False) + except Exception as e: + logger.error(f"Failed to store duplicate record: {e}") + + # Group under original in recent_packets + if len(self.recent_packets) > 0: + for idx in range(len(self.recent_packets) - 1, -1, -1): + prev_pkt = self.recent_packets[idx] + if prev_pkt.get("packet_hash") == packet_record["packet_hash"]: + if "duplicates" not in prev_pkt: + prev_pkt["duplicates"] = [] + prev_pkt["duplicates"].append(packet_record) + break + else: + self.recent_packets.append(packet_record) + else: + self.recent_packets.append(packet_record) + + if len(self.recent_packets) > self.max_recent_packets: + self.recent_packets.pop(0) + def cleanup_cache(self): now = time.time() diff --git a/repeater/main.py b/repeater/main.py index 05d2907..2295fbe 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -306,6 +306,10 @@ class RepeaterDaemon: n, ) + # Subscribe to parsed packets (pre-dedup) so duplicate path variants + # still appear in the web UI even though the Dispatcher blocks them. + self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging) + # When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv) self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions @@ -717,6 +721,21 @@ class RepeaterDaemon: except Exception as e: logger.debug("Push RX raw to companion: %s", e) + def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None: + """Record duplicate packets for UI visibility. + + Called by Dispatcher's raw_packet_subscriber (pre-dedup) so we see + all path variants. Only records packets the engine has already seen; + novel packets are left for the normal handler path. + """ + if not self.repeater_handler: + return + if not self.repeater_handler.is_duplicate(pkt): + return # First variant — will reach engine via normal handler path + rssi = getattr(pkt, "_rssi", 0) or 0 + snr = getattr(pkt, "_snr", 0.0) or 0.0 + self.repeater_handler.record_duplicate(pkt, rssi=rssi, snr=snr) + async def deliver_control_data( self, snr: float,