diff --git a/monitor/src/adn_monitor/application/rts_update.py b/monitor/src/adn_monitor/application/rts_update.py index 3fa0d81..ad0d84f 100644 --- a/monitor/src/adn_monitor/application/rts_update.py +++ b/monitor/src/adn_monitor/application/rts_update.py @@ -30,7 +30,7 @@ from ..domain.peer_rf import ( SIMPLEX_VOICE_SLOT, normalize_ua_voice_slot, - peer_downlink_display_slot, + peer_downlink_display_slots, ) from ..domain.value_objects import ServerMode from .alias_service import AliasService @@ -116,9 +116,9 @@ def _apply_voice_single_ts( _apply_multi_mode_chips(state, system, peer_id, peer_row) -def _static_tg_slot_for_peer(peer_row: dict, destination: int, event_slot: int) -> int: - """Map wire timeslot to the peer chip that lists this TG in OPTIONS (TS1/TS2).""" - return peer_downlink_display_slot(peer_row, destination, event_slot) +def _static_tg_slots_for_peer(peer_row: dict, destination: int, event_slot: int) -> list[int]: + """Chip slot(s) to update -- both, when this peer has the TG on TS1+TS2.""" + return peer_downlink_display_slots(peer_row, destination, event_slot) def _is_master_peer_row(system: str) -> bool: @@ -260,7 +260,7 @@ def _voice_event_target_peers( return targets -def _peer_display_slot( +def _peer_display_slots( peer_row: dict, peer_key, *, @@ -269,20 +269,21 @@ def _peer_display_slot( event_slot: int, destination: int, trx: str = "", -) -> int: - """Timeslot index for CTABLE peer chips (1 or 2).""" +) -> list[int]: + """Timeslot index(es) for CTABLE peer chips -- usually one, both when this + peer has the TG on both static OPTIONS slots (see peer_downlink_display_slots).""" if _peer_keys_equal(source_peer, peer_key): - return event_slot + return [event_slot] if ( call_type == "GROUP VOICE" and trx == "TX" and _is_echo_service_live_tgid(destination) ): # Echo/service downlink (bridge TX leg): use wire slot, not OPTIONS static map. - return event_slot + return [event_slot] if call_type == "GROUP VOICE": - return _static_tg_slot_for_peer(peer_row, destination, event_slot) - return event_slot + return _static_tg_slots_for_peer(peer_row, destination, event_slot) + return [event_slot] def rts_update_impl( @@ -375,7 +376,7 @@ def rts_update_impl( destination=destination, dest_peer_id=dest_peer_id, ): - display_slot = _peer_display_slot( + display_slots = _peer_display_slots( peer_row, peer, call_type=call_type, @@ -385,41 +386,42 @@ def rts_update_impl( trx=trx, ) 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") - and peer_ts.get("TRX") == "RX" - and not _peer_keys_equal(source_peer, peer) - and not _is_echo_service_live_tgid(destination) - ): - continue - 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"] = peer - peer_ts["DEST"] = tg_dest - peer_ts["TG"] = tg_short - peer_ts["TRX"] = crxstatus - peer_ts["ANNOUNCEMENT"] = is_announcement - elif action == "END": - if ( - trx == "TX" - and peer_ts.get("TS") - and not _is_echo_service_live_tgid(destination) - and _peer_slot_busy_other_tg(peer_ts, destination) - ): - continue - clear_voice_ts_for_destination(peer_row, destination) + for display_slot in display_slots: + 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") + and peer_ts.get("TRX") == "RX" + and not _peer_keys_equal(source_peer, peer) + and not _is_echo_service_live_tgid(destination) + ): + continue + 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"] = peer + peer_ts["DEST"] = tg_dest + peer_ts["TG"] = tg_short + peer_ts["TRX"] = crxstatus + peer_ts["ANNOUNCEMENT"] = is_announcement + elif action == "END": + if ( + trx == "TX" + and peer_ts.get("TS") + and not _is_echo_service_live_tgid(destination) + and _peer_slot_busy_other_tg(peer_ts, destination) + ): + continue + clear_voice_ts_for_destination(peer_row, destination) server_mode = getattr(state, "server_mode", ServerMode.LEGACY) if system in ctable.get("OPENBRIDGES", {}): diff --git a/monitor/src/adn_monitor/domain/peer_rf.py b/monitor/src/adn_monitor/domain/peer_rf.py index 4e5901f..d7a0be0 100644 --- a/monitor/src/adn_monitor/domain/peer_rf.py +++ b/monitor/src/adn_monitor/domain/peer_rf.py @@ -90,10 +90,37 @@ def peer_downlink_display_slot( ts2 = [str(x).strip() for x in (peer_row.get("TS2_STATIC") or []) if str(x).strip()] in_ts1 = tg in ts1 in_ts2 = tg in ts2 + if in_ts1 and in_ts2: + # TG duplicated on both static slots: each downlink event carries its + # own real wire slot (server now delivers both independently), so the + # chip must follow event_slot -- collapsing to SIMPLEX_VOICE_SLOT here + # would make the TS1 delivery overwrite the same chip as TS2. + return event_slot if peer_is_simplex(peer_row) and (in_ts1 or in_ts2): return SIMPLEX_VOICE_SLOT - if in_ts1 and not in_ts2: + if in_ts1: return 1 - if in_ts2 and not in_ts1: + if in_ts2: return 2 return event_slot + + +def peer_downlink_display_slots( + peer_row: dict[str, Any], + destination: int, + event_slot: int, +) -> 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. + """ + 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: + 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 24fc649..b293c50 100644 --- a/monitor/tests/domain/test_peer_rf.py +++ b/monitor/tests/domain/test_peer_rf.py @@ -28,3 +28,17 @@ def test_normalize_ua_voice_slot_simplex_uses_ts2(): def test_peer_downlink_display_slot_simplex_static_on_ts2(): peer = {"RF_MODE": "simplex", "TS1_STATIC": ["730"], "TS2_STATIC": []} assert peer_downlink_display_slot(peer, 730, 1) == 2 + + +def test_peer_downlink_display_slot_tg_on_both_static_slots_follows_event_slot(): + """Duplex-capable hotspot with the same TG on TS1+TS2: each downlink event + (server now delivers both independently) must chip its own real slot, not + collapse onto SIMPLEX_VOICE_SLOT -- else the TS2 delivery overwrites the + TS1 chip and the UI only ever shows one slot active.""" + peer = {"RF_MODE": "duplex", "TS1_STATIC": ["730"], "TS2_STATIC": ["730"]} + assert peer_downlink_display_slot(peer, 730, 1) == 1 + assert peer_downlink_display_slot(peer, 730, 2) == 2 + + 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 diff --git a/monitor/tests/test_rts_display_slot.py b/monitor/tests/test_rts_display_slot.py index d85a789..ebb10a5 100644 --- a/monitor/tests/test_rts_display_slot.py +++ b/monitor/tests/test_rts_display_slot.py @@ -94,6 +94,24 @@ def test_wire_ts2_colors_ts1_when_tg_only_in_ts1_static() -> None: assert peer[2]["TS"] is False +def test_tg_on_both_static_slots_lights_up_both_chips() -> None: + """Duplex hotspot with the TG on TS1+TS2: adn-server delivers to it on + both slots independently (e.g. an OBP-sourced call, always reported on + slot 1) -- both chips must light up, not just the event's own slot.""" + state = _state_with_peer(peer_id=730001, ts1_static=["730"], ts2_static=["730"]) + rts_update_impl( + "GROUP VOICE,START,TX,SYSTEM,1,730002,730002,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"] == "TX" + assert peer[2]["TS"] is True + assert peer[2]["TRX"] == "TX" + + 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(