Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions monitor/src/adn_monitor/application/rts_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
)
)
]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
35 changes: 29 additions & 6 deletions monitor/src/adn_monitor/domain/peer_rf.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,45 @@ 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,
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.
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)]
70 changes: 70 additions & 0 deletions monitor/tests/domain/test_peer_rf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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]
149 changes: 149 additions & 0 deletions monitor/tests/test_rts_display_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Loading