diff --git a/monitor/src/adn_monitor/application/rts_update.py b/monitor/src/adn_monitor/application/rts_update.py index ad0d84f..12e972d 100644 --- a/monitor/src/adn_monitor/application/rts_update.py +++ b/monitor/src/adn_monitor/application/rts_update.py @@ -31,6 +31,8 @@ SIMPLEX_VOICE_SLOT, normalize_ua_voice_slot, peer_downlink_display_slots, + peer_dynamic_tg_active_on_slot, + peer_is_simplex, ) from ..domain.value_objects import ServerMode from .alias_service import AliasService @@ -145,11 +147,31 @@ def voice_event_skip_master_downlink_log(parts: list[str], ctable: dict) -> bool return system in ctable.get("MASTERS", {}) -def _peer_static_lists_include_tg(peer_row: dict, destination: int) -> bool: +def _peer_subscribed_to_tg(peer_row: dict, destination: int) -> bool: + """True when this peer cares about ``destination`` at all -- static + OPTIONS on either slot, or dynamically active (SINGLE=0/1) on either. + Static vs dynamic makes no difference to whether a downlink event should + even consider this peer a chip-update target.""" 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 + if tg in ts1 or tg in ts2: + return True + return ( + peer_dynamic_tg_active_on_slot(peer_row, destination, 1) + or peer_dynamic_tg_active_on_slot(peer_row, destination, 2) + ) + + +def _peer_subscribed_to_tg_on_slot(peer_row: dict, destination: int, slot: int) -> bool: + """True when this peer cares about ``destination`` on this *specific* + slot -- static OPTIONS for that slot, or dynamically active there.""" + tg = str(destination) + key = "TS1_STATIC" if slot == 1 else "TS2_STATIC" + static_list = [str(x).strip() for x in (peer_row.get(key) or []) if str(x).strip()] + if tg in static_list: + return True + return peer_dynamic_tg_active_on_slot(peer_row, destination, slot) def _peer_slot_busy_other_tg(peer_ts: dict, destination: int) -> bool: @@ -215,7 +237,7 @@ def _voice_event_target_peers( _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) + and _peer_subscribed_to_tg(peer_row, destination) ) ) ] @@ -254,7 +276,7 @@ def _voice_event_target_peers( continue if _peer_keys_equal(source_peer, peer_key): continue - if not _peer_static_lists_include_tg(peer_row, destination): + if not _peer_subscribed_to_tg(peer_row, destination): continue targets.append((peer_key, peer_row)) return targets @@ -423,6 +445,44 @@ def rts_update_impl( continue clear_voice_ts_for_destination(peer_row, destination) + # Same repeater, other slot: the transmitting peer is also + # subscribed (static or dynamic) to this TG on the slot it did NOT + # just transmit on -- show it there too as its own TX-style chip. + # Purely additive: a separate block from the loop above, so it can't + # change how any other peer (or this peer's own RX chip) is handled. + if ( + call_type == "GROUP VOICE" + and trx == "RX" + and not _is_echo_service_live_tgid(destination) + ): + src_peer_id, src_peer_row = _resolve_master_peer( + ctable["MASTERS"][system]["PEERS"], source_peer, + ) + if src_peer_row is not None and not peer_is_simplex(src_peer_row): + other_slot = 2 if time_slot == 1 else 1 + if _peer_subscribed_to_tg_on_slot(src_peer_row, destination, other_slot): + peer_ts = src_peer_row[other_slot] + if action == "START": + if peer_ts.get("TS") and ( + _peer_slot_busy_other_tg(peer_ts, destination) + or peer_ts.get("TRX") == "RX" + ): + pass # busy with another TG, or a real local PTT -- leave it alone + else: + peer_ts["TIMEOUT"] = timeout + peer_ts["TS"] = True + peer_ts["TYPE"] = call_type + peer_ts["SUB"] = f"{sub_short} ({source_sub})" + peer_ts["CALL"] = sub_call + peer_ts["SRC"] = src_peer_id + peer_ts["DEST"] = tg_dest + peer_ts["TG"] = tg_short + peer_ts["TRX"] = "TX" + peer_ts["ANNOUNCEMENT"] = is_announcement + elif action == "END": + if not _peer_slot_busy_other_tg(peer_ts, destination): + clear_voice_ts_for_destination(src_peer_row, destination) + server_mode = getattr(state, "server_mode", ServerMode.LEGACY) if system in ctable.get("OPENBRIDGES", {}): streams = ctable["OPENBRIDGES"][system]["STREAMS"] diff --git a/monitor/src/adn_monitor/domain/peer_rf.py b/monitor/src/adn_monitor/domain/peer_rf.py index d7a0be0..798cdf7 100644 --- a/monitor/src/adn_monitor/domain/peer_rf.py +++ b/monitor/src/adn_monitor/domain/peer_rf.py @@ -105,6 +105,23 @@ def peer_downlink_display_slot( return event_slot +def peer_dynamic_tg_active_on_slot(peer_row: dict[str, Any], destination: int, slot: int) -> bool: + """True when this TG is currently tracked as dynamic on ``slot`` -- + SINGLE=1 exclusive session (``SINGLE_TS{slot}``) or SINGLE=0 keyed + multi-TG set (``UA_MULTI_TS{slot}``), both already synced onto peer_row + from the server's ua_sessions/ua_multi_tgs report fields.""" + tg = str(destination) + single = peer_row.get(f"SINGLE_TS{slot}") + if isinstance(single, dict) and str(single.get("TGID") or "") == tg: + return True + multi = peer_row.get(f"UA_MULTI_TS{slot}") + if isinstance(multi, list): + for entry in multi: + if isinstance(entry, dict) and str(entry.get("TGID") or "") == tg: + return True + return False + + def peer_downlink_display_slots( peer_row: dict[str, Any], destination: int, @@ -112,15 +129,21 @@ def peer_downlink_display_slots( ) -> list[int]: """CTABLE chip slot(s) to update for one downlink event to this peer. - Normally a single slot (see ``peer_downlink_display_slot``). When the TG - is duplicated on both static OPTIONS slots, adn-server's - ``iter_downlink_voice_slots`` delivers to this peer independently on - TS1 *and* TS2 from the one incoming event -- both chips must reflect - that, not just whichever slot the source happened to transmit on. + Normally a single slot (see ``peer_downlink_display_slot``). A duplex + peer genuinely subscribed to this TG on both slots at once -- static on + both, static on one and dynamically active (SINGLE=0/1) on the other, or + dynamically active on both -- must light up both chips, matching + adn-server's ``iter_downlink_voice_slots`` delivering independently to + each slot from the one incoming event. Static vs dynamic makes no + difference to any of this. """ + if peer_is_simplex(peer_row): + return [peer_downlink_display_slot(peer_row, destination, event_slot)] 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()] - if tg in ts1 and tg in ts2: + active_on_1 = tg in ts1 or peer_dynamic_tg_active_on_slot(peer_row, destination, 1) + active_on_2 = tg in ts2 or peer_dynamic_tg_active_on_slot(peer_row, destination, 2) + if active_on_1 and active_on_2: return [1, 2] return [peer_downlink_display_slot(peer_row, destination, event_slot)] diff --git a/monitor/tests/domain/test_peer_rf.py b/monitor/tests/domain/test_peer_rf.py index b293c50..207dde8 100644 --- a/monitor/tests/domain/test_peer_rf.py +++ b/monitor/tests/domain/test_peer_rf.py @@ -6,6 +6,7 @@ RF_MODE_SIMPLEX, normalize_ua_voice_slot, peer_downlink_display_slot, + peer_downlink_display_slots, peer_is_simplex, peer_rf_mode, ) @@ -42,3 +43,72 @@ def test_peer_downlink_display_slot_tg_on_both_static_slots_follows_event_slot() peer_simplex = {"RF_MODE": "simplex", "TS1_STATIC": ["730"], "TS2_STATIC": ["730"]} assert peer_downlink_display_slot(peer_simplex, 730, 1) == 1 assert peer_downlink_display_slot(peer_simplex, 730, 2) == 2 + + +def test_peer_downlink_display_slots_static_on_one_dynamic_multi_on_other(): + """SINGLE=0 dynamic keying (UA_MULTI_TS2) on the slot opposite a static + match: both slots must light up.""" + peer = { + "RF_MODE": "duplex", + "TS1_STATIC": ["730"], + "TS2_STATIC": [], + "UA_MULTI_TS2": [{"TGID": "730", "TO": ""}], + } + assert peer_downlink_display_slots(peer, 730, 2) == [1, 2] + + +def test_peer_downlink_display_slots_static_on_one_dynamic_single_on_other(): + """SINGLE=1 exclusive session (SINGLE_TS2) on the slot opposite a static + match: both slots must light up.""" + peer = { + "RF_MODE": "duplex", + "TS1_STATIC": ["730"], + "TS2_STATIC": [], + "SINGLE_TS2": {"TGID": "730", "TO": ""}, + } + assert peer_downlink_display_slots(peer, 730, 2) == [1, 2] + + +def test_peer_downlink_display_slots_static_on_one_no_dynamic_elsewhere(): + """Plain single-static case (no dynamic activity on the other slot) must + stay single-slot -- this is the case a naive event_slot-mismatch heuristic + would wrongly treat as dual-slot.""" + peer = {"RF_MODE": "duplex", "TS1_STATIC": ["730"], "TS2_STATIC": []} + assert peer_downlink_display_slots(peer, 730, 2) == [1] + + +def test_peer_downlink_display_slots_dynamic_multi_on_both_no_static(): + """No static OPTIONS match at all -- TG independently keyed (SINGLE=0) + dynamic on both slots -- must still light up both.""" + peer = { + "RF_MODE": "duplex", + "TS1_STATIC": [], + "TS2_STATIC": [], + "UA_MULTI_TS1": [{"TGID": "730", "TO": ""}], + "UA_MULTI_TS2": [{"TGID": "730", "TO": ""}], + } + assert peer_downlink_display_slots(peer, 730, 2) == [1, 2] + + +def test_peer_downlink_display_slots_dynamic_multi_on_one_no_static(): + """Dynamic (SINGLE=0) on only one slot, no static anywhere -- must stay + single-slot, following the event.""" + peer = { + "RF_MODE": "duplex", + "TS1_STATIC": [], + "TS2_STATIC": [], + "UA_MULTI_TS2": [{"TGID": "730", "TO": ""}], + } + assert peer_downlink_display_slots(peer, 730, 2) == [2] + + +def test_peer_downlink_display_slots_simplex_ignores_dynamic_other_slot(): + """Simplex hardware can't genuinely be on two slots at once -- the + dynamic-other-slot check must not apply.""" + peer = { + "RF_MODE": "simplex", + "TS1_STATIC": ["730"], + "TS2_STATIC": [], + "UA_MULTI_TS2": [{"TGID": "730", "TO": ""}], + } + assert peer_downlink_display_slots(peer, 730, 1) == [2] diff --git a/monitor/tests/test_rts_display_slot.py b/monitor/tests/test_rts_display_slot.py index ebb10a5..7cc06d4 100644 --- a/monitor/tests/test_rts_display_slot.py +++ b/monitor/tests/test_rts_display_slot.py @@ -112,6 +112,47 @@ def test_tg_on_both_static_slots_lights_up_both_chips() -> None: assert peer[2]["TRX"] == "TX" +def test_tg_static_on_one_slot_and_dynamic_on_other_lights_up_both() -> None: + """Duplex peer: TG static on TS1, and *also* dynamically keyed (SINGLE=0) + on TS2 -- both must light up, not just the statically-configured slot. + Distinguishing this from the plain single-static case (which must NOT + light the other slot, see test_wire_ts2_colors_ts1_when_tg_only_in_ts1_static) + requires the dynamic UA_MULTI_TS2 data, already synced onto peer_row from + the server's ua_multi_tgs report field.""" + state = _state_with_peer(peer_id=730001, ts1_static=["730"], ts2_static=[]) + state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001]["UA_MULTI_TS2"] = [ + {"TGID": "730", "TO": ""}, + ] + rts_update_impl( + "GROUP VOICE,START,TX,SYSTEM,1,730002,730002,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[1]["TS"] is True + assert peer[2]["TS"] is True + + +def test_tg_dynamic_on_both_slots_no_static_lights_up_both() -> None: + """No static OPTIONS at all -- TG independently keyed (SINGLE=0) dynamic + on both slots -- must light up both, matching the real-world case where + the hotspot receives the call on both slots too.""" + state = _state_with_peer(peer_id=730001, ts1_static=[], ts2_static=[]) + peer_row = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + peer_row["UA_MULTI_TS1"] = [{"TGID": "730", "TO": ""}] + peer_row["UA_MULTI_TS2"] = [{"TGID": "730", "TO": ""}] + rts_update_impl( + "GROUP VOICE,START,TX,SYSTEM,1,730002,730002,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[1]["TS"] is True + assert peer[2]["TS"] is True + + def test_transmitter_rx_uses_wire_slot_not_options() -> None: state = _state_with_peer(peer_id=730002, ts1_static=[], ts2_static=["73010"]) rts_update_impl( @@ -686,3 +727,111 @@ def test_simplex_single_mode_ua_chip_on_ts2() -> None: peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][peer_id] assert peer["SINGLE_TS2"]["TGID"] == 730444 assert peer["SINGLE_TS1"]["TGID"] == "" + + +def test_transmitting_peer_echoes_to_own_other_static_slot() -> None: + """New behavior: TG static on TS1 only, peer transmits (RX) on slot 2 -- + slot 1 must show TX (self-echo), slot 2 shows RX (its own PTT).""" + state = _state_with_peer(peer_id=730001, ts1_static=["730"], ts2_static=[]) + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[2]["TS"] is True + assert peer[2]["TRX"] == "RX" + assert peer[1]["TS"] is True + assert peer[1]["TRX"] == "TX" + + +def test_transmitting_peer_echoes_to_own_dynamically_subscribed_other_slot() -> None: + """Same self-echo, but the other slot's subscription is dynamic + (UA_MULTI_TGS), not static -- static vs dynamic treated identically. + + Must seed state.UA_MULTI_TGS (not peer_row["UA_MULTI_TS1"] directly): + this event's trx="RX" triggers _apply_voice_single_ts -> _apply_multi_mode_chips, + which rebuilds peer_row["UA_MULTI_TS{slot}"] from state.UA_MULTI_TGS on every + RX event, so a directly-seeded peer_row field would just get overwritten. + """ + state = _state_with_peer(peer_id=730001, ts1_static=[], ts2_static=[]) + state.UA_MULTI_TGS = {("SYSTEM", 730001, 1): {730}} + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[2]["TS"] is True + assert peer[2]["TRX"] == "RX" + assert peer[1]["TS"] is True + assert peer[1]["TRX"] == "TX" + + +def test_transmitting_peer_echoes_symmetrically_on_opposite_slot() -> None: + """Same behavior, transmitting on the opposite slot: TG static on TS2, + transmits on slot 1 -- slot 2 shows TX.""" + state = _state_with_peer(peer_id=730001, ts1_static=[], ts2_static=["730"]) + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,1,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[1]["TS"] is True + assert peer[1]["TRX"] == "RX" + assert peer[2]["TS"] is True + assert peer[2]["TRX"] == "TX" + + +def test_transmitting_peer_no_self_echo_without_other_slot_subscription() -> None: + """No TG configured on the other slot at all -- no self-echo, matching + existing (unchanged) single-slot behavior.""" + state = _state_with_peer(peer_id=730001, ts1_static=[], ts2_static=[]) + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[2]["TS"] is True + assert peer[1]["TS"] is False + + +def test_transmitting_peer_self_echo_clears_on_end() -> None: + """END clears the echoed chip on the other slot too.""" + state = _state_with_peer(peer_id=730001, ts1_static=["730"], ts2_static=[]) + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + rts_update_impl( + "GROUP VOICE,END,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[1]["TS"] is False + + +def test_simplex_transmitting_peer_gets_no_self_echo() -> None: + """Simplex hardware has one real RF path -- no self-echo to "the other + slot", unlike a genuinely duplex peer.""" + state = _state_with_peer(peer_id=730001, ts1_static=["730"], ts2_static=["730"]) + state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001]["RF_MODE"] = "simplex" + rts_update_impl( + "GROUP VOICE,START,RX,SYSTEM,1,730001,730001,2,730".split(","), + state, + _alias(), + lambda: "12:00", + ) + peer = state.CTABLE["MASTERS"]["SYSTEM"]["PEERS"][730001] + assert peer[2]["TS"] is True + assert peer[1]["TS"] is False