From 4ca07c18c23a04c347fbc2b261564f405ca3bffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Mon, 22 Jun 2026 18:09:55 -0400 Subject: [PATCH] fix: target voice CTABLE chips per hotspot and dedupe downlink logs Apply START/END only to peers that actually receive or transmit the call, block downlink display when the RF slot is busy with another TG, and skip duplicate MASTER TX log lines from inject-proxy fan-out. --- .../application/monitor_controller.py | 11 +- .../src/adn_monitor/application/rts_update.py | 144 +++++++++++++++++- monitor/tests/test_rts_display_slot.py | 74 +++++++++ monitor/tests/test_voice_log_dedupe.py | 25 +++ 4 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 monitor/tests/test_voice_log_dedupe.py diff --git a/monitor/src/adn_monitor/application/monitor_controller.py b/monitor/src/adn_monitor/application/monitor_controller.py index 62897e1..b6c86fa 100644 --- a/monitor/src/adn_monitor/application/monitor_controller.py +++ b/monitor/src/adn_monitor/application/monitor_controller.py @@ -640,10 +640,13 @@ def _handle_brdg_event_parts( log_message = _format_log_message_unknown_end(_now, parts, alias_svc) else: log_message = f"{_now[10:19]} Unknown voice bridge log message ({parts[0]})." - state.LOGBUF.append(log_message) - logger.info("(VOICE) %s", log_message) - if broadcast: - broadcast.broadcast("l" + log_message, "log") + from .rts_update import voice_event_skip_master_downlink_log + + if not voice_event_skip_master_downlink_log(parts, state.CTABLE): + state.LOGBUF.append(log_message) + logger.info("(VOICE) %s", log_message) + if broadcast: + broadcast.broadcast("l" + log_message, "log") elif parts[0] == "UNIT DATA HEADER" and parts[2] != "TX" and parts[5] not in config_global.get("OPB_FILTER", []): _u_ts = time.time() _u_utc = format_utc_naive_datetime(_u_ts) diff --git a/monitor/src/adn_monitor/application/rts_update.py b/monitor/src/adn_monitor/application/rts_update.py index ead9b2a..51cbd15 100644 --- a/monitor/src/adn_monitor/application/rts_update.py +++ b/monitor/src/adn_monitor/application/rts_update.py @@ -31,6 +31,7 @@ from .alias_service import AliasService from .monitor_controller import MonitorState from .tgstats import ( + _active_tgid_from_peer_ts, _apply_multi_mode_chips, _is_echo_service_live_tgid, _is_service_voice_tgid, @@ -121,6 +122,130 @@ def _static_tg_slot_for_peer(peer_row: dict, destination: int, event_slot: int) return event_slot +def _is_master_peer_row(system: str) -> bool: + """Inject-proxy CTABLE row (``SYSTEM-3``): one hotspot radio per upstream slot.""" + if "-" not in system: + return False + base, suffix = system.rsplit("-", 1) + return bool(base) and suffix.isdigit() + + +def voice_event_skip_master_downlink_log(parts: list[str], ctable: dict) -> bool: + """True when a MASTER downlink leg should update chips only (no duplicate log line). + + The server announces one call; remapped ``SYSTEM-N`` TX fan-out must not emit + one log row per connected hotspot. + """ + if len(parts) < 4 or parts[1] not in ("START", "END") or parts[2] != "TX": + return False + system = parts[3] + if system in ctable.get("OPENBRIDGES", {}): + return False + if _is_master_peer_row(system): + return True + return system in ctable.get("MASTERS", {}) + + +def _peer_static_lists_include_tg(peer_row: dict, destination: int) -> bool: + tg = str(destination) + ts1 = [str(x).strip() for x in (peer_row.get("TS1_STATIC") or []) if str(x).strip()] + ts2 = [str(x).strip() for x in (peer_row.get("TS2_STATIC") or []) if str(x).strip()] + return tg in ts1 or tg in ts2 + + +def _peer_slot_busy_other_tg(peer_ts: dict, destination: int) -> bool: + active = _active_tgid_from_peer_ts(peer_ts) + return active is not None and int(active) != int(destination) + + +def _peer_row_shows_destination(peer_row: dict, destination: int) -> bool: + for slot in (1, 2): + peer_ts = peer_row.get(slot) + if isinstance(peer_ts, dict) and _active_tgid_from_peer_ts(peer_ts) == destination: + return True + return False + + +def _voice_event_target_peers( + system: str, + peers: dict, + *, + action: str, + trx: str, + source_peer: int, + source_sub: int, + call_type: str, + event_slot: int, + destination: int, +) -> list[tuple[int | bytes, dict]]: + """Peers whose CTABLE chips this voice event should touch.""" + if action == "END": + if trx == "RX": + peer_id, peer_row = _resolve_master_peer(peers, source_peer) + if peer_row is not None and peer_id is not None: + return [(peer_id, peer_row)] + return [] + if _is_master_peer_row(system): + return [ + (peer_key, peer_row) + for peer_key, peer_row in peers.items() + if isinstance(peer_row, dict) and _peer_row_shows_destination(peer_row, destination) + ] + if _is_echo_service_live_tgid(destination): + svc_id, svc_row = _resolve_master_peer(peers, destination) + if svc_row is not None and svc_id is not None: + return [(svc_id, svc_row)] + sub_id, sub_row = _resolve_master_peer(peers, source_sub) + if sub_row is not None and sub_id is not None: + return [(sub_id, sub_row)] + return [] + return [ + (peer_key, peer_row) + for peer_key, peer_row in peers.items() + if isinstance(peer_row, dict) + and ( + _peer_row_shows_destination(peer_row, destination) + or ( + not _peer_keys_equal(source_peer, peer_key) + and _peer_static_lists_include_tg(peer_row, destination) + ) + ) + ] + + if trx == "RX": + peer_id, peer_row = _resolve_master_peer(peers, source_peer) + if peer_row is not None and peer_id is not None: + return [(peer_id, peer_row)] + return [] + + if _is_master_peer_row(system): + return [ + (peer_key, peer_row) + for peer_key, peer_row in peers.items() + if isinstance(peer_row, dict) + ] + + if _is_echo_service_live_tgid(destination): + svc_id, svc_row = _resolve_master_peer(peers, destination) + if svc_row is not None and svc_id is not None: + return [(svc_id, svc_row)] + sub_id, sub_row = _resolve_master_peer(peers, source_sub) + if sub_row is not None and sub_id is not None: + return [(sub_id, sub_row)] + return [] + + targets: list[tuple[int | bytes, dict]] = [] + for peer_key, peer_row in peers.items(): + if not isinstance(peer_row, dict): + continue + if _peer_keys_equal(source_peer, peer_key): + continue + if not _peer_static_lists_include_tg(peer_row, destination): + continue + targets.append((peer_key, peer_row)) + return targets + + def _peer_display_slot( peer_row: dict, peer_key, @@ -205,8 +330,17 @@ def rts_update_impl( _apply_voice_single_ts( state, ctable, system, time_slot, destination, source_peer, trx=trx ) - for peer in ctable["MASTERS"][system]["PEERS"]: - peer_row = ctable["MASTERS"][system]["PEERS"][peer] + for peer, peer_row in _voice_event_target_peers( + system, + ctable["MASTERS"][system]["PEERS"], + action=action, + trx=trx, + source_peer=source_peer, + source_sub=source_sub, + call_type=call_type, + event_slot=time_slot, + destination=destination, + ): display_slot = _peer_display_slot( peer_row, peer, @@ -219,6 +353,12 @@ def rts_update_impl( crxstatus = "RX" if _peer_keys_equal(source_peer, peer) else "TX" peer_ts = peer_row[display_slot] if action == "START": + if ( + peer_ts.get("TS") + and not _is_echo_service_live_tgid(destination) + and _peer_slot_busy_other_tg(peer_ts, destination) + ): + continue # Local PTT (TRX=RX / red): do not replace with another peer's downlink (TRX=TX / green). if ( peer_ts.get("TS") diff --git a/monitor/tests/test_rts_display_slot.py b/monitor/tests/test_rts_display_slot.py index 97a28ed..74c1c28 100644 --- a/monitor/tests/test_rts_display_slot.py +++ b/monitor/tests/test_rts_display_slot.py @@ -247,6 +247,80 @@ def test_echo_tx_downlink_uses_wire_slot_not_static_map() -> None: assert peer[2]["TS"] is False +def test_rx_start_only_updates_transmitting_peer_on_aggregate_master() -> None: + """RX must not mark every peer with the same static TG as TX (green).""" + state = MonitorState() + state.CTABLE = { + "MASTERS": { + "SYSTEM": { + "PEERS": { + 730001: { + "TS1_STATIC": [], + "TS2_STATIC": ["7144"], + 1: {"TS": False, "TRX": ""}, + 2: {"TS": False, "TRX": ""}, + }, + 730002: { + "TS1_STATIC": [], + "TS2_STATIC": ["7144"], + 1: {"TS": False, "TRX": ""}, + 2: {"TS": False, "TRX": ""}, + }, + } + } + }, + "PEERS": {}, + "OPENBRIDGES": {}, + } + alias = _alias() + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730002,730002,2,7144".split(","), + state, + alias, + lambda: "12:00", + ) + assert state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730002][2]["TS"] is True + assert state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730002][2]["TRX"] == "RX" + assert state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001][2]["TS"] is False + + +def test_tx_downlink_blocked_when_peer_slot_busy_other_tg() -> None: + """While QSO on TG 7141, downlink START for TG 71442 must not light the chip.""" + state = MonitorState() + state.CTABLE = { + "MASTERS": { + "SYSTEM-2": { + "PEERS": { + 714002301: { + "TS1_STATIC": [], + "TS2_STATIC": ["7141", "71442"], + 1: {"TS": False, "TRX": ""}, + 2: {"TS": False, "TRX": ""}, + } + } + } + }, + "PEERS": {}, + "OPENBRIDGES": {}, + } + alias = _alias() + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM-2,1,714002301,714002301,2,7141".split(","), + state, + alias, + lambda: "12:00", + ) + rts_update_impl( + "GROUP VOICE,START,TX,SYSTEM-2,2,730002,730002,2,71442".split(","), + state, + alias, + lambda: "12:01", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM-2"]["PEERS"][714002301] + assert peer[2]["TRX"] == "RX" + assert "7141" in peer[2]["TG"] + + def test_companion_tx_does_not_replace_own_active_qso_on_other_tg() -> None: """While TX on TG 7144 (RX chip), companion TX for another TG must not overwrite the slot.""" state = MonitorState() diff --git a/monitor/tests/test_voice_log_dedupe.py b/monitor/tests/test_voice_log_dedupe.py new file mode 100644 index 0000000..a3fc252 --- /dev/null +++ b/monitor/tests/test_voice_log_dedupe.py @@ -0,0 +1,25 @@ +# ADN Monitor - voice log dedupe tests +# +# Copyright (C) 2026 Rodrigo Pérez, CE5RPY + +from __future__ import annotations + +from adn_monitor.application.rts_update import voice_event_skip_master_downlink_log + + +def test_skip_master_peer_row_tx_start_log() -> None: + ctable = {"MASTERS": {"SYSTEM-2": {"PEERS": {}}}, "OPENBRIDGES": {}} + parts = "GROUP VOICE,START,TX,SYSTEM-2,1,730002,730002,2,71442".split(",") + assert voice_event_skip_master_downlink_log(parts, ctable) + + +def test_keep_obp_rx_start_log() -> None: + ctable = {"MASTERS": {}, "OPENBRIDGES": {"OBP-CL": {}}} + parts = "GROUP VOICE,START,RX,OBP-CL,1,7140023,7140023,1,71442".split(",") + assert not voice_event_skip_master_downlink_log(parts, ctable) + + +def test_keep_master_rx_start_log() -> None: + ctable = {"MASTERS": {"SYSTEM-2": {"PEERS": {}}}, "OPENBRIDGES": {}} + parts = "GROUP VOICE,START,RX,SYSTEM-2,1,714002301,714002301,2,7141".split(",") + assert not voice_event_skip_master_downlink_log(parts, ctable)