feat: introduce half-duplex mode and push-to-talk functionality for LXST telephony, improve call statistics and API endpoints

This commit is contained in:
Ivan 2026-07-22 14:14:32 -05:00
parent 6fdda16606
commit b1ee794316
No known key found for this signature in database
20 changed files with 4682 additions and 3913 deletions

View file

@ -31,6 +31,7 @@ All notable changes to this project will be documented in this file.
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
- Post-install prompts for existing users after upgrades
- Coolify-oriented Docker Compose with resource limits for deployments
- LXST telephony half-duplex mode, live duplex switching, push-to-talk (packetizer squelch), and richer in-call stats (rates plus mute state on the active call)
### Changed

View file

@ -28,11 +28,21 @@ LXST runs over Reticulum Links. Address callees by identity/destination hash. No
2. Do not block the whole UI on real-time ACK over constrained links. Show recoverable call states.
3. Auth-guard WS mutators that start or answer calls when password auth is enabled.
4. Keep audio paths identity-scoped. No shared in-memory call tables across identities.
5. Prefer half duplex + PTT (LXST packetizer squelch) on scarce links instead of always full duplex.
## Verification
```bash
uv run pytest tests/backend/test_lxst_telephony_adversarial.py -q --tb=short
uv run pytest tests/backend/test_lxst_telephony_adversarial.py tests/backend/test_telephone_duplex_ptt.py -q --tb=short
```
Also: `reticulum-design-gates`, `auth-csrf-ws-security`, `identity-switch-teardown`.
## LXST 0.5 duplex / PTT
| Control | LXST API | MeshChatX surface |
| ------------------ | ------------------------------------------ | ---------------------------------------------------- |
| Full / half duplex | `Telephone.switch_mode`, `Profiles.MODE_*` | `POST /api/v1/telephone/switch-call-mode/{mode_id}` |
| PTT (half duplex) | `squelch_transmit` / `unsquelch_transmit` | `POST /api/v1/telephone/ptt` with `{"active": bool}` |
| Mute mic / speaker | `mute_transmit` / `mute_receive` | existing mute endpoints |
| Live stats | RNS Link counters on `active_call` | `/api/v1/telephone/status` `tx_*` / `rx_*` / `*_bps` |

View file

@ -15,10 +15,16 @@ From **Call** or a contact entry you can:
- **Dial** another identity by hash
- **Answer** or **decline** inbound rings
- **Hang up** an active session
- **Mute** transmit or receive paths
- **Mute** microphone (transmit) or speaker (receive)
- Switch **full duplex** or **half duplex** during a live call
- Use **push-to-talk** while in half duplex (hold the PTT control or Space)
Half duplex uses LXST packetizer squelch so idle airtime stays low on constrained links. Full duplex keeps both directions open.
Call state changes arrive over the WebSocket (`telephone_ringing`, `telephone_call_established`, `telephone_call_ended`, and related events).
While connected, the Call screen shows link stats (packets, bytes, approximate bitrates, path hops, and interface).
## Audio path
The frontend loads Codec2 assets for voice encoding (`Codec2Loader.js`). Browser and Electron builds use a Web Audio bridge at `/ws/telephone/audio`. Packaged desktop builds bundle the backend that negotiates LXST sessions.

Binary file not shown.

View file

@ -10807,6 +10807,12 @@ class ReticulumMeshChat:
"profile",
)
else None,
"call_mode_id": self.telephone_manager.get_active_mode_id(),
"is_half_duplex": self.telephone_manager.is_half_duplex(),
"is_ptt_active": bool(self.telephone_manager.ptt_active),
"is_transmit_squelched": self.telephone_manager.is_transmit_squelched(),
"is_mic_muted": self.telephone_manager.transmit_muted,
"is_speaker_muted": self.telephone_manager.receive_muted,
"is_recording": self.telephone_manager.is_recording,
"is_voicemail": self.voicemail_manager.is_recording,
"call_start_time": self.telephone_manager.call_start_time,
@ -10815,15 +10821,40 @@ class ReticulumMeshChat:
"rx_bytes": 0,
"tx_packets": 0,
"rx_packets": 0,
"tx_bps": 0,
"rx_bps": 0,
"path_hops": None,
"path_interface": None,
}
from LXST.Primitives.Telephony import Profiles
mode_id = active_call["call_mode_id"]
with contextlib.suppress(Exception):
active_call["call_mode_name"] = Profiles.mode_name(mode_id)
active_call["call_mode_abbrev"] = Profiles.mode_abbrevation(mode_id)
link = getattr(self.telephone_manager, "call_stats", {}).get("link")
if link:
active_call["tx_bytes"] = getattr(link, "txbytes", 0)
active_call["rx_bytes"] = getattr(link, "rxbytes", 0)
active_call["tx_packets"] = getattr(link, "tx", 0)
active_call["rx_packets"] = getattr(link, "rx", 0)
started_at = getattr(self.telephone_manager, "call_stats", {}).get(
"started_at",
)
if not started_at:
started_at = self.telephone_manager.call_start_time
elapsed = (
max(0.001, time.time() - float(started_at))
if started_at
else 0.0
)
if elapsed > 0:
active_call["tx_bps"] = int(
(active_call["tx_bytes"] * 8) / elapsed,
)
active_call["rx_bps"] = int(
(active_call["rx_bytes"] * 8) / elapsed,
)
# Best-effort direct link metadata fallback.
if active_call["path_hops"] is None:
for hop_attr in ["hops", "hop_count", "path_hops"]:
@ -10890,6 +10921,9 @@ class ReticulumMeshChat:
"active_call": active_call,
"is_mic_muted": self.telephone_manager.transmit_muted,
"is_speaker_muted": self.telephone_manager.receive_muted,
"preferred_call_mode_id": self.telephone_manager.resolve_call_mode_id(
self.telephone_manager.preferred_mode_id,
),
"missed_calls_unread_count": self.database.misc.get_unread_notification_count_by_type(
"telephone_missed_call",
),
@ -11030,6 +11064,87 @@ class ReticulumMeshChat:
await asyncio.to_thread(self.telephone_manager.unmute_receive)
return web.json_response({"message": "Speaker unmuted"})
@routes.get("/api/v1/telephone/call-modes")
async def telephone_call_modes(request):
from LXST.Primitives.Telephony import Profiles
modes = [
{
"id": mode_id,
"name": Profiles.mode_name(mode_id),
"abbrev": Profiles.mode_abbrevation(mode_id),
"is_half_duplex": mode_id == Profiles.MODE_HALF_DUPLEX,
}
for mode_id in Profiles.available_modes()
]
return web.json_response(
{
"default_call_mode_id": Profiles.DEFAULT_MODE,
"call_modes": modes,
},
)
@routes.post("/api/v1/telephone/switch-call-mode/{mode_id}")
async def telephone_switch_call_mode(request):
mode_id = request.match_info.get("mode_id")
try:
if self.telephone_manager.telephone is None:
return web.json_response(
{"message": "Telephone not initialized"},
status=400,
)
resolved = await asyncio.to_thread(
self.telephone_manager.apply_preferred_mode,
int(mode_id),
)
self.config.telephone_call_mode_id.set(resolved)
from LXST.Primitives.Telephony import Profiles
return web.json_response(
{
"message": f"Switched to mode {resolved}",
"mode_id": resolved,
"mode_name": Profiles.mode_name(resolved),
"is_half_duplex": resolved == Profiles.MODE_HALF_DUPLEX,
"is_ptt_active": bool(self.telephone_manager.ptt_active),
},
)
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
@routes.post("/api/v1/telephone/ptt")
async def telephone_ptt(request):
if self.telephone_manager.telephone is None:
return web.json_response(
{"message": "Telephone not initialized"},
status=400,
)
try:
data = await request.json()
except Exception:
data = {}
active = (
bool(data.get("active", False)) if isinstance(data, dict) else False
)
ok = await asyncio.to_thread(self.telephone_manager.set_ptt_active, active)
if not ok and active:
return web.json_response(
{
"message": "PTT requires an established half-duplex call",
"is_ptt_active": bool(self.telephone_manager.ptt_active),
"is_half_duplex": self.telephone_manager.is_half_duplex(),
},
status=400,
)
return web.json_response(
{
"message": "ok",
"is_ptt_active": bool(self.telephone_manager.ptt_active),
"is_half_duplex": self.telephone_manager.is_half_duplex(),
"is_transmit_squelched": self.telephone_manager.is_transmit_squelched(),
},
)
# get call history
@routes.get("/api/v1/telephone/history")
async def telephone_history(request):
@ -19688,6 +19803,17 @@ class ReticulumMeshChat:
profile_id,
)
if "telephone_call_mode_id" in data:
mode_id = self._coerce_int(data["telephone_call_mode_id"])
if mode_id is None:
mode_id = self.config.telephone_call_mode_id.get()
self.config.telephone_call_mode_id.set(mode_id)
if self.telephone_manager:
await asyncio.to_thread(
self.telephone_manager.apply_preferred_mode,
mode_id,
)
if "telephone_web_audio_enabled" in data:
self.config.telephone_web_audio_enabled.set(
self._parse_bool(data["telephone_web_audio_enabled"]),
@ -21429,6 +21555,7 @@ class ReticulumMeshChat:
"telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(),
"telephone_announce_enabled": ctx.config.telephone_announce_enabled.get(),
"telephone_audio_profile_id": ctx.config.telephone_audio_profile_id.get(),
"telephone_call_mode_id": ctx.config.telephone_call_mode_id.get(),
"telephone_web_audio_enabled": ctx.config.telephone_web_audio_enabled.get(),
"telephone_web_audio_allow_fallback": ctx.config.telephone_web_audio_allow_fallback.get(),
"call_recording_enabled": ctx.config.call_recording_enabled.get(),

View file

@ -276,6 +276,11 @@ class ConfigManager:
"telephone_audio_profile_id",
64, # LXST Profiles.DEFAULT_PROFILE (Medium Quality / Opus)
)
self.telephone_call_mode_id = self.IntConfig(
self,
"telephone_call_mode_id",
1, # LXST Profiles.MODE_FULL_DUPLEX
)
self.telephone_web_audio_enabled = self.BoolConfig(
self,
"telephone_web_audio_enabled",

View file

@ -1,194 +1,194 @@
[
{
"name": "aiohappyeyeballs",
"version": "2.6.1",
"author": "J. Nick Koston",
"license": "PSF-2.0"
},
{
"name": "aiohttp",
"version": "3.14.1",
"author": "—",
"license": "Apache-2.0 AND MIT"
},
{
"name": "aiohttp-session",
"version": "2.12.1",
"author": "Andrew Svetlov",
"license": "Apache 2"
},
{
"name": "aiosignal",
"version": "1.4.0",
"author": "aiohttp team <team@aiohttp.org>",
"license": "Apache 2.0"
},
{
"name": "attrs",
"version": "26.1.0",
"author": "Hynek Schlawack <hs@ox.cx>",
"license": "MIT"
},
{
"name": "audioop-lts",
"version": "0.2.2",
"author": "—",
"license": "PSF-2.0"
},
{
"name": "bcrypt",
"version": "5.0.0",
"author": "The Python Cryptographic Authority developers <cryptography-dev@python.org>",
"license": "Apache-2.0"
},
{
"name": "bleak",
"version": "3.0.2",
"author": "Henrik Blidh",
"license": "MIT"
},
{
"name": "cbor2",
"version": "6.1.1",
"author": "Alex Grönholm <alex.gronholm@nextday.fi>",
"license": "MIT"
},
{
"name": "cffi",
"version": "2.0.0",
"author": "Armin Rigo, Maciej Fijalkowski",
"license": "MIT"
},
{
"name": "cryptography",
"version": "49.0.0",
"author": "The Python Cryptographic Authority and individual contributors <cryptography-dev@python.org>",
"license": "Apache-2.0 OR BSD-3-Clause"
},
{
"name": "dbus-fast",
"version": "5.0.22",
"author": "Bluetooth Devices Authors",
"license": "MIT"
},
{
"name": "frozenlist",
"version": "1.8.0",
"author": "aiohttp team <team@aiohttp.org>",
"license": "Apache-2.0"
},
{
"name": "idna",
"version": "3.18",
"author": "Kim Davies <kim+pypi@gumleaf.org>",
"license": "BSD-3-Clause"
},
{
"name": "lxmf",
"version": "1.1.0",
"author": "Mark Qvist",
"license": "Reticulum License"
},
{
"name": "lxmfy",
"version": "2.0.1",
"author": "Quad4 <team@quad4.io>",
"license": "BSD-0-Clause"
},
{
"name": "lxst",
"version": "0.5.0",
"author": "Mark Qvist",
"license": "Other/Proprietary License"
},
{
"name": "miniaudio",
"version": "1.71",
"author": "Irmen de Jong <irmen@razorvine.net>",
"license": "MIT"
},
{
"name": "multidict",
"version": "6.7.1",
"author": "Andrew Svetlov",
"license": "Apache License 2.0"
},
{
"name": "numpy",
"version": "2.4.6",
"author": "Travis E. Oliphant et al.",
"license": "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0"
},
{
"name": "ply",
"version": "3.11",
"author": "David Beazley",
"license": "BSD"
},
{
"name": "propcache",
"version": "0.4.1",
"author": "Andrew Svetlov",
"license": "Apache-2.0"
},
{
"name": "psutil",
"version": "7.2.2",
"author": "Giampaolo Rodola",
"license": "BSD-3-Clause"
},
{
"name": "pycodec2",
"version": "4.1.1",
"author": "Grzegorz Milka",
"license": "OSI Approved :: BSD License"
},
{
"name": "pycparser",
"version": "3.0",
"author": "Eli Bendersky <eliben@gmail.com>",
"license": "BSD-3-Clause"
},
{
"name": "pyserial",
"version": "3.5",
"author": "Chris Liechti",
"license": "BSD"
},
{
"name": "reticulum-meshchatx",
"version": "4.8.0",
"author": "Quad4",
"license": "0BSD AND MIT"
},
{
"name": "rns",
"version": "1.4.0",
"author": "Mark Qvist",
"license": "Reticulum License"
},
{
"name": "rns-filesync",
"version": "1.0.0",
"author": "—",
"license": "BSD-2-Clause"
},
{
"name": "wasmtime",
"version": "46.0.1",
"author": "The Wasmtime Project Developers <hello@bytecodealliance.org>",
"license": "Apache-2.0 WITH LLVM-exception"
},
{
"name": "websockets",
"version": "16.0",
"author": "Aymeric Augustin <aymeric.augustin@m4x.org>",
"license": "BSD-3-Clause"
},
{
"name": "yarl",
"version": "1.23.0",
"author": "Andrew Svetlov",
"license": "Apache-2.0"
}
{
"name": "aiohappyeyeballs",
"version": "2.6.1",
"author": "J. Nick Koston",
"license": "PSF-2.0"
},
{
"name": "aiohttp",
"version": "3.14.1",
"author": "—",
"license": "Apache-2.0 AND MIT"
},
{
"name": "aiohttp-session",
"version": "2.12.1",
"author": "Andrew Svetlov",
"license": "Apache 2"
},
{
"name": "aiosignal",
"version": "1.4.0",
"author": "aiohttp team <team@aiohttp.org>",
"license": "Apache 2.0"
},
{
"name": "attrs",
"version": "26.1.0",
"author": "Hynek Schlawack <hs@ox.cx>",
"license": "MIT"
},
{
"name": "audioop-lts",
"version": "0.2.2",
"author": "—",
"license": "PSF-2.0"
},
{
"name": "bcrypt",
"version": "5.0.0",
"author": "The Python Cryptographic Authority developers <cryptography-dev@python.org>",
"license": "Apache-2.0"
},
{
"name": "bleak",
"version": "3.0.2",
"author": "Henrik Blidh",
"license": "MIT"
},
{
"name": "cbor2",
"version": "6.1.1",
"author": "Alex Grönholm <alex.gronholm@nextday.fi>",
"license": "MIT"
},
{
"name": "cffi",
"version": "2.0.0",
"author": "Armin Rigo, Maciej Fijalkowski",
"license": "MIT"
},
{
"name": "cryptography",
"version": "49.0.0",
"author": "The Python Cryptographic Authority and individual contributors <cryptography-dev@python.org>",
"license": "Apache-2.0 OR BSD-3-Clause"
},
{
"name": "dbus-fast",
"version": "5.0.22",
"author": "Bluetooth Devices Authors",
"license": "MIT"
},
{
"name": "frozenlist",
"version": "1.8.0",
"author": "aiohttp team <team@aiohttp.org>",
"license": "Apache-2.0"
},
{
"name": "idna",
"version": "3.18",
"author": "Kim Davies <kim+pypi@gumleaf.org>",
"license": "BSD-3-Clause"
},
{
"name": "lxmf",
"version": "1.1.0",
"author": "Mark Qvist",
"license": "Reticulum License"
},
{
"name": "lxmfy",
"version": "2.0.1",
"author": "Quad4 <team@quad4.io>",
"license": "BSD-0-Clause"
},
{
"name": "lxst",
"version": "0.5.0",
"author": "Mark Qvist",
"license": "Other/Proprietary License"
},
{
"name": "miniaudio",
"version": "1.71",
"author": "Irmen de Jong <irmen@razorvine.net>",
"license": "MIT"
},
{
"name": "multidict",
"version": "6.7.1",
"author": "Andrew Svetlov",
"license": "Apache License 2.0"
},
{
"name": "numpy",
"version": "2.4.6",
"author": "Travis E. Oliphant et al.",
"license": "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0"
},
{
"name": "ply",
"version": "3.11",
"author": "David Beazley",
"license": "BSD"
},
{
"name": "propcache",
"version": "0.4.1",
"author": "Andrew Svetlov",
"license": "Apache-2.0"
},
{
"name": "psutil",
"version": "7.2.2",
"author": "Giampaolo Rodola",
"license": "BSD-3-Clause"
},
{
"name": "pycodec2",
"version": "4.1.1",
"author": "Grzegorz Milka",
"license": "OSI Approved :: BSD License"
},
{
"name": "pycparser",
"version": "3.0",
"author": "Eli Bendersky <eliben@gmail.com>",
"license": "BSD-3-Clause"
},
{
"name": "pyserial",
"version": "3.5",
"author": "Chris Liechti",
"license": "BSD"
},
{
"name": "reticulum-meshchatx",
"version": "4.8.0",
"author": "Quad4",
"license": "0BSD AND MIT"
},
{
"name": "rns",
"version": "1.4.0",
"author": "Mark Qvist",
"license": "Reticulum License"
},
{
"name": "rns-filesync",
"version": "1.0.0",
"author": "—",
"license": "BSD-2-Clause"
},
{
"name": "wasmtime",
"version": "46.0.1",
"author": "The Wasmtime Project Developers <hello@bytecodealliance.org>",
"license": "Apache-2.0 WITH LLVM-exception"
},
{
"name": "websockets",
"version": "16.0",
"author": "Aymeric Augustin <aymeric.augustin@m4x.org>",
"license": "BSD-3-Clause"
},
{
"name": "yarl",
"version": "1.23.0",
"author": "Andrew Svetlov",
"license": "Apache-2.0"
}
]

File diff suppressed because it is too large Load diff

View file

@ -81,6 +81,9 @@ class TelephoneManager:
# Manual mute overrides in case LXST internal muting is buggy
self.transmit_muted = False
self.receive_muted = False
# Half-duplex PTT state. LXST squelches the packetizer when TX is idle.
self.ptt_active = False
self.call_stats = {}
self.initiation_status = None
self.initiation_target_hash = None
@ -90,6 +93,7 @@ class TelephoneManager:
self._status_poll_interval_s = 0.1
self.is_voicemail_session_active = False
self.preferred_profile_id = None
self.preferred_mode_id = None
self._caller_allowed = None
self._blocked_identity_hashes = None
# When True, LXST must not open PulseAudio LineSource/LineSink (Docker /
@ -165,6 +169,120 @@ class TelephoneManager:
self.telephone.switch_profile(self.preferred_profile_id)
return self.preferred_profile_id
def resolve_call_mode_id(self, mode_id=None):
"""Return a valid LXST call mode id (full or half duplex)."""
from LXST.Primitives.Telephony import Profiles
available = set(Profiles.available_modes())
mid = mode_id
if mid is None and self.config_manager:
with contextlib.suppress(Exception):
mid = self.config_manager.telephone_call_mode_id.get()
try:
mid = int(mid) if mid is not None else None
except (TypeError, ValueError):
mid = None
if mid not in available:
mid = Profiles.DEFAULT_MODE
return mid
def apply_preferred_mode(self, mode_id=None):
"""Store preferred duplex mode and apply it on an established call."""
self.preferred_mode_id = self.resolve_call_mode_id(mode_id)
if (
self.telephone
and self.telephone.active_call
and self.telephone.call_status == 6
):
self.switch_mode(self.preferred_mode_id)
return self.preferred_mode_id
def switch_mode(self, mode_id):
"""Switch live call duplex mode via LXST signalling and local squelch."""
from LXST.Primitives.Telephony import Profiles
resolved = self.resolve_call_mode_id(mode_id)
self.preferred_mode_id = resolved
if not (
self.telephone
and self.telephone.active_call
and self.telephone.call_status == 6
):
return resolved
with contextlib.suppress(Exception):
self.telephone.switch_mode(resolved)
# Keep local view aligned even when LXST is mocked in tests.
with contextlib.suppress(Exception):
self.telephone.active_call.call_mode = resolved
# Half duplex starts listen-only. Full duplex keeps continuous TX open.
self.ptt_active = False
if resolved == Profiles.MODE_HALF_DUPLEX:
with contextlib.suppress(Exception):
self.telephone.squelch_transmit(True)
else:
with contextlib.suppress(Exception):
self.telephone.unsquelch_transmit(True)
return resolved
def get_active_mode_id(self):
"""Return the active call mode, preferred mode, or LXST default."""
from LXST.Primitives.Telephony import Profiles
if self.telephone and self.telephone.active_call:
link_mode = getattr(self.telephone.active_call, "call_mode", None)
if link_mode in Profiles.available_modes():
return link_mode
mode = getattr(self.telephone, "active_mode", None)
if mode in Profiles.available_modes():
return mode
if self.preferred_mode_id in Profiles.available_modes():
return self.preferred_mode_id
return self.resolve_call_mode_id()
def is_half_duplex(self):
from LXST.Primitives.Telephony import Profiles
return self.get_active_mode_id() == Profiles.MODE_HALF_DUPLEX
def is_transmit_squelched(self):
"""True when LXST packetizer is squelched (half-duplex idle / PTT up)."""
if not self.telephone or not self.telephone.active_call:
return False
packetizer = getattr(self.telephone.active_call, "packetizer", None)
if packetizer is not None:
return bool(getattr(packetizer, "squelched", False))
return self.is_half_duplex() and not self.ptt_active
def set_ptt_active(self, active: bool):
"""Push-to-talk gate for half-duplex calls (unsquelch while held)."""
if not (
self.telephone
and self.telephone.active_call
and self.telephone.call_status == 6
):
self.ptt_active = False
return False
if not self.is_half_duplex():
self.ptt_active = False
return False
want_active = bool(active)
try:
if want_active:
self.telephone.unsquelch_transmit(True)
else:
self.telephone.squelch_transmit(True)
self.ptt_active = want_active
return True
except Exception as e:
RNS.log(f"TelephoneManager: PTT squelch failed: {e}", RNS.LOG_ERROR)
return False
def _reset_call_audio_controls(self):
self.transmit_muted = False
self.receive_muted = False
self.ptt_active = False
self.call_stats = {}
def init_telephone(self):
if self.telephone is not None:
return
@ -187,9 +305,10 @@ class TelephoneManager:
# Increase connection timeout for slower networks
self.telephone.set_connect_timeout(30)
# LXST switch_profile is a no-op without an established call. Remember the
# preferred profile and pass it into telephone.call() on outbound dial.
# LXST switch_profile / switch_mode are no-ops without an established call.
# Remember preferred values and pass them into telephone.call() on dial.
self.preferred_profile_id = self.resolve_audio_profile_id()
self.preferred_mode_id = self.resolve_call_mode_id()
self._install_link_table_cleanup()
self.refresh_call_policy()
@ -311,11 +430,13 @@ class TelephoneManager:
# Update start time to when it was actually established for duration calculation
self.call_start_time = time.time()
self.call_was_established = True
self.ptt_active = False
# Track per-call stats from the active link (uses RNS Link counters)
link = getattr(self.telephone, "active_call", None)
self.call_stats = {
"link": link,
"started_at": self.call_start_time,
}
if self.on_established_callback:
@ -328,6 +449,7 @@ class TelephoneManager:
# Ensure initiation status is cleared when call ends
self._update_initiation_status(None, None)
self._reset_call_audio_controls()
if self.on_ended_callback:
self.on_ended_callback(caller_identity)
@ -532,15 +654,18 @@ class TelephoneManager:
profile_id = self.resolve_audio_profile_id(self.preferred_profile_id)
self.preferred_profile_id = profile_id
mode_id = self.resolve_call_mode_id(self.preferred_mode_id)
self.preferred_mode_id = mode_id
# Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up. Pass preferred profile so
# Codec2/Opus selection actually applies (switch_profile alone is a no-op idle).
# if established elsewhere or timed out/hung up. Pass preferred profile and
# duplex mode so Codec2/Opus and half-duplex actually apply on dial.
call_task = asyncio.create_task(
asyncio.to_thread(
self.telephone.call,
destination_identity,
profile_id,
mode_id,
),
)

View file

@ -345,6 +345,20 @@ class WebAudioBridge:
return
if getattr(self.telephone_manager, "is_voicemail_session_active", False):
return
# Do not feed PCM while mic is muted or half-duplex PTT is released.
if getattr(self.telephone_manager, "transmit_muted", False) is True:
return
is_squelched = getattr(
self.telephone_manager,
"is_transmit_squelched",
None,
)
if callable(is_squelched):
try:
if is_squelched() is True:
return
except Exception:
pass
if not self.tx_source:
# Hostless LineSource stand-in still accepts PCM until swap.
audio_in = getattr(tele, "audio_input", None)

View file

@ -176,10 +176,30 @@
<MaterialDesignIcon icon-name="arrow-down" class="size-3" />
<span>{{ formatBytes(activeCall.rx_bytes || 0) }}</span>
</div>
<div class="col-span-2 text-center font-semibold uppercase tracking-wider">
{{ localHalfDuplex ? $t("call.half_duplex") : $t("call.full_duplex") }}
<span v-if="localHalfDuplex">
· {{ localPttActive ? $t("call.ptt_transmitting") : $t("call.ptt_listening") }}
</span>
</div>
</div>
<!-- Controls -->
<div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-2 px-2">
<!-- Duplex toggle -->
<button
v-if="activeCall && activeCall.status === 6"
type="button"
:title="localHalfDuplex ? $t('call.switch_to_full_duplex') : $t('call.switch_to_half_duplex')"
class="p-2.5 rounded-full transition-all duration-200 bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700"
@click="toggleDuplexMode"
>
<MaterialDesignIcon
:icon-name="localHalfDuplex ? 'access-point-network' : 'swap-horizontal'"
class="size-5"
/>
</button>
<!-- Mute Mic -->
<button
type="button"
@ -246,6 +266,20 @@
<MaterialDesignIcon icon-name="phone" class="size-5" />
</button>
</div>
<button
v-if="activeCall && activeCall.status === 6 && localHalfDuplex && !isEnded"
type="button"
class="mt-3 w-full flex items-center justify-center gap-2 rounded-xl py-3 text-sm font-bold text-white select-none touch-none"
:class="localPttActive ? 'bg-amber-500' : 'bg-blue-600'"
@pointerdown.prevent="setPttActive(true)"
@pointerup.prevent="setPttActive(false)"
@pointerleave="setPttActive(false)"
@pointercancel="setPttActive(false)"
>
<MaterialDesignIcon icon-name="microphone" class="size-5" />
<span>{{ localPttActive ? $t("call.ptt_transmitting") : $t("call.ptt_hold_to_talk") }}</span>
</button>
</div>
<!-- Ended State Voicemail Playback -->
@ -362,6 +396,8 @@ export default {
audioPlayer: null,
localMicMuted: false,
localSpeakerMuted: false,
localPttActive: false,
localHalfDuplex: false,
};
},
computed: {
@ -387,13 +423,24 @@ export default {
},
},
watch: {
activeCall(newCall, oldCall) {
if (newCall) {
if (!oldCall || newCall.hash !== oldCall.hash) {
this.localMicMuted = newCall.is_mic_muted;
this.localSpeakerMuted = newCall.is_speaker_muted;
activeCall: {
immediate: true,
handler(newCall, oldCall) {
if (newCall) {
if (!oldCall || newCall.hash !== oldCall.hash) {
this.localMicMuted = Boolean(newCall.is_mic_muted);
this.localSpeakerMuted = Boolean(newCall.is_speaker_muted);
this.localPttActive = Boolean(newCall.is_ptt_active);
this.localHalfDuplex = Boolean(newCall.is_half_duplex);
} else {
this.localPttActive = Boolean(newCall.is_ptt_active);
this.localHalfDuplex = Boolean(newCall.is_half_duplex);
}
} else {
this.localPttActive = false;
this.localHalfDuplex = false;
}
}
},
},
},
mounted() {
@ -493,6 +540,33 @@ export default {
ToastUtils.error(this.$t("call.failed_to_toggle_speaker"));
}
},
async toggleDuplexMode() {
if (!this.activeCall || this.activeCall.status !== 6) return;
const nextMode = this.localHalfDuplex ? 1 : 2;
try {
const response = await window.api.post(`/api/v1/telephone/switch-call-mode/${nextMode}`);
this.localPttActive = Boolean(response.data?.is_ptt_active);
this.localHalfDuplex = Boolean(response.data?.is_half_duplex);
} catch {
ToastUtils.error(this.$t("call.failed_to_switch_call_mode"));
}
},
async setPttActive(active) {
if (!this.activeCall || this.activeCall.status !== 6 || !this.localHalfDuplex) {
if (active) return;
}
const wantActive = Boolean(active);
if (this.localPttActive === wantActive) return;
this.localPttActive = wantActive;
try {
await window.api.post("/api/v1/telephone/ptt", { active: wantActive });
} catch {
this.localPttActive = !wantActive;
if (wantActive) {
ToastUtils.error(this.$t("call.failed_to_set_ptt"));
}
}
},
async playLatestVoicemail() {
if (this.isPlayingVoicemail) {
if (this.audioPlayer) {

View file

@ -368,7 +368,7 @@
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
TX Pkts
{{ $t("call.tx_packets") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatNumber(activeCall.tx_packets) }}
@ -378,7 +378,7 @@
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
RX Pkts
{{ $t("call.rx_packets") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatNumber(activeCall.rx_packets) }}
@ -388,7 +388,7 @@
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
TX Data Out
{{ $t("call.tx_data") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatBytes(activeCall.tx_bytes) }}
@ -398,12 +398,50 @@
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
RX Data In
{{ $t("call.rx_data") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatBytes(activeCall.rx_bytes) }}
</div>
</div>
<div
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
{{ $t("call.tx_rate") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatBitrate(activeCall.tx_bps) }}
</div>
</div>
<div
class="rounded-xl bg-gray-50 dark:bg-zinc-800/70 border border-gray-100 dark:border-zinc-700/70 px-2 py-1.5 text-left"
>
<div class="text-[10px] text-gray-500 dark:text-zinc-400">
{{ $t("call.rx_rate") }}
</div>
<div class="font-semibold text-gray-800 dark:text-zinc-100">
{{ formatBitrate(activeCall.rx_bps) }}
</div>
</div>
</div>
<div
v-if="activeCall && activeCall.status === 6"
class="mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-zinc-400"
>
{{
activeCall.is_half_duplex
? $t("call.half_duplex")
: $t("call.full_duplex")
}}
<span v-if="activeCall.is_half_duplex">
·
{{
localPttActive
? $t("call.ptt_transmitting")
: $t("call.ptt_listening")
}}
</span>
</div>
</div>
</template>
@ -457,10 +495,25 @@
</option>
</select>
<select
v-model="selectedCallModeId"
class="input-field rounded-xl! py-2! shadow-xs"
@change="switchCallMode(selectedCallModeId)"
>
<option
v-for="callMode in callModes"
:key="callMode.id"
:value="callMode.id"
>
{{ callMode.name }}
</option>
</select>
<div class="flex justify-center gap-4">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? $t('call.unmute_mic') : $t('call.mute_mic')"
:class="[
isMicMuted
? 'bg-red-500 text-white shadow-red-500/20'
@ -478,6 +531,11 @@
<!-- mute/unmute speaker -->
<button
type="button"
:title="
isSpeakerMuted
? $t('call.unmute_speaker')
: $t('call.mute_speaker')
"
:class="[
isSpeakerMuted
? 'bg-red-500 text-white shadow-red-500/20'
@ -492,6 +550,38 @@
/>
</button>
</div>
<button
v-if="isHalfDuplexCall"
type="button"
class="w-full flex items-center justify-center gap-2 rounded-2xl py-5 text-base font-bold text-white shadow-xl transition-all duration-150 select-none touch-none"
:class="
localPttActive
? 'bg-amber-500 shadow-amber-500/30 scale-[1.02]'
: 'bg-blue-600 shadow-blue-600/20 hover:bg-blue-500'
"
:title="$t('call.ptt_hold_hint')"
@pointerdown.prevent="setPttActive(true)"
@pointerup.prevent="setPttActive(false)"
@pointerleave="setPttActive(false)"
@pointercancel="setPttActive(false)"
>
<MaterialDesignIcon
:icon-name="localPttActive ? 'microphone' : 'access-point-network'"
class="size-7"
/>
<span>{{
localPttActive
? $t("call.ptt_transmitting")
: $t("call.ptt_hold_to_talk")
}}</span>
</button>
<div
v-if="isHalfDuplexCall"
class="text-center text-[11px] text-gray-500 dark:text-zinc-400"
>
{{ $t("call.ptt_spacebar_hint") }}
</div>
</div>
</div>
@ -724,6 +814,31 @@
</option>
</select>
</div>
<div class="flex flex-col gap-1">
<div
class="text-[10px] font-bold text-gray-500 uppercase tracking-widest px-1"
>
{{ $t("call.default_duplex") }}
</div>
<select
v-if="config"
v-model="config.telephone_call_mode_id"
class="input-field min-w-0 rounded-lg! border-gray-200! py-1! px-2! text-xs! dark:border-zinc-800! lg:min-w-[120px]"
@change="
updateConfig({
telephone_call_mode_id: config.telephone_call_mode_id,
})
"
>
<option
v-for="callMode in callModes"
:key="callMode.id"
:value="callMode.id"
>
{{ callMode.name }}
</option>
</select>
</div>
<!-- Web Audio Device Selection -->
<div v-if="webAudioBridgeEnabled" class="flex flex-col gap-2 mt-2">
@ -2378,6 +2493,10 @@ export default {
webAudioBridgeRequired: false,
audioProfiles: [],
selectedAudioProfileId: null,
callModes: [],
selectedCallModeId: 1,
localPttActive: false,
pttKeyHeld: false,
destinationHash: "",
callHistory: [],
callHistorySearch: "",
@ -2486,6 +2605,9 @@ export default {
isSpeakerMuted() {
return this.localSpeakerMuted;
},
isHalfDuplexCall() {
return Boolean(this.activeCall && this.activeCall.status === 6 && this.activeCall.is_half_duplex);
},
elapsedTime() {
if (!this.activeCall?.call_start_time) {
return null;
@ -2565,6 +2687,7 @@ export default {
mounted() {
this.getConfig();
this.getAudioProfiles();
this.getCallModes();
this.getStatus();
this.getHistory();
this.getVoicemails();
@ -2601,6 +2724,10 @@ export default {
this.$forceUpdate();
}, 1000);
window.addEventListener("keydown", this.onPttKeyDown);
window.addEventListener("keyup", this.onPttKeyUp);
window.addEventListener("blur", this.onPttWindowBlur);
// autofill destination hash and tab from query string
const destinationHash = this.$route.query.destination_hash;
if (destinationHash) {
@ -2620,6 +2747,12 @@ export default {
if (this.historyInterval) clearInterval(this.historyInterval);
if (this.elapsedTimeInterval) clearInterval(this.elapsedTimeInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
window.removeEventListener("keydown", this.onPttKeyDown);
window.removeEventListener("keyup", this.onPttKeyUp);
window.removeEventListener("blur", this.onPttWindowBlur);
if (this.localPttActive) {
this.setPttActive(false);
}
this.stopAudioVisualizer();
if (this.audioPlayer) {
this.audioPlayer.pause();
@ -2637,6 +2770,12 @@ export default {
formatNumber(value) {
return Utils.formatNumber(value || 0);
},
formatBitrate(bps) {
const rate = Number(bps) || 0;
if (rate < 1000) return `${Math.round(rate)} bps`;
if (rate < 1000000) return `${(rate / 1000).toFixed(1)} kbps`;
return `${(rate / 1000000).toFixed(2)} Mbps`;
},
formatDateTime(timestamp) {
return Utils.convertUnixMillisToLocalDateTimeString(timestamp);
},
@ -3543,18 +3682,47 @@ export default {
console.log(e);
}
},
async getCallModes() {
try {
const response = await window.api.get("/api/v1/telephone/call-modes");
const modes = Array.isArray(response.data.call_modes) ? response.data.call_modes : [];
this.callModes = modes;
if (response.data.default_call_mode_id != null) {
this.selectedCallModeId = response.data.default_call_mode_id;
}
} catch (e) {
console.log(e);
}
},
async getStatus() {
try {
const response = await window.api.get("/api/v1/telephone/status");
const oldCall = this.activeCall;
const newCall = response.data.active_call;
// Sync local mute state from backend
// Sync local mute / PTT / mode state from backend
if (newCall) {
if (!oldCall || newCall.hash !== oldCall.hash) {
this.localMicMuted = newCall.is_mic_muted;
this.localSpeakerMuted = newCall.is_speaker_muted;
this.localMicMuted = Boolean(newCall.is_mic_muted);
this.localSpeakerMuted = Boolean(newCall.is_speaker_muted);
this.localPttActive = Boolean(newCall.is_ptt_active);
} else if (!this.isMicMuting) {
this.localMicMuted = Boolean(
newCall.is_mic_muted ?? response.data.is_mic_muted ?? this.localMicMuted
);
this.localSpeakerMuted = Boolean(
newCall.is_speaker_muted ?? response.data.is_speaker_muted ?? this.localSpeakerMuted
);
if (!this.pttKeyHeld) {
this.localPttActive = Boolean(newCall.is_ptt_active);
}
}
if (newCall.call_mode_id != null) {
this.selectedCallModeId = newCall.call_mode_id;
}
} else {
this.localPttActive = false;
this.pttKeyHeld = false;
}
this.activeCall = newCall;
@ -4403,6 +4571,78 @@ export default {
ToastUtils.error(this.$t("call.failed_to_switch_audio_profile"));
}
},
async switchCallMode(modeId) {
try {
const response = await window.api.post(`/api/v1/telephone/switch-call-mode/${modeId}`);
const resolved = response.data?.mode_id;
if (resolved != null) {
this.selectedCallModeId = resolved;
}
this.localPttActive = Boolean(response.data?.is_ptt_active);
if (this.activeCall) {
this.activeCall.call_mode_id = resolved;
this.activeCall.is_half_duplex = Boolean(response.data?.is_half_duplex);
this.activeCall.is_ptt_active = this.localPttActive;
}
if (this.config) {
this.config.telephone_call_mode_id = resolved;
}
} catch {
ToastUtils.error(this.$t("call.failed_to_switch_call_mode"));
}
},
isEditableEventTarget(target) {
if (!target || !(target instanceof Element)) return false;
const tag = (target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
return Boolean(target.isContentEditable);
},
onPttKeyDown(event) {
if (event.code !== "Space" && event.key !== " ") return;
if (event.repeat) return;
if (this.isEditableEventTarget(event.target)) return;
if (!this.isHalfDuplexCall) return;
event.preventDefault();
this.pttKeyHeld = true;
this.setPttActive(true);
},
onPttKeyUp(event) {
if (event.code !== "Space" && event.key !== " ") return;
if (!this.pttKeyHeld && !this.localPttActive) return;
event.preventDefault();
this.pttKeyHeld = false;
this.setPttActive(false);
},
onPttWindowBlur() {
this.pttKeyHeld = false;
if (this.localPttActive) {
this.setPttActive(false);
}
},
async setPttActive(active) {
if (!this.isHalfDuplexCall && active) {
return;
}
const wantActive = Boolean(active);
if (this.localPttActive === wantActive) {
return;
}
this.localPttActive = wantActive;
if (this.activeCall) {
this.activeCall.is_ptt_active = wantActive;
}
try {
await window.api.post("/api/v1/telephone/ptt", { active: wantActive });
} catch {
this.localPttActive = !wantActive;
if (this.activeCall) {
this.activeCall.is_ptt_active = !wantActive;
}
if (wantActive) {
ToastUtils.error(this.$t("call.failed_to_set_ptt"));
}
}
},
async toggleMicrophone() {
try {
const isCurrentlyMuted = this.localMicMuted;

View file

@ -3547,6 +3547,24 @@
"minimize": "Minimize",
"active": "Active",
"default_quality": "Default Quality",
"default_duplex": "Default Duplex",
"full_duplex": "Full Duplex",
"half_duplex": "Half Duplex",
"switch_to_full_duplex": "Switch to full duplex",
"switch_to_half_duplex": "Switch to half duplex",
"ptt_hold_to_talk": "Hold to Talk",
"ptt_transmitting": "Transmitting",
"ptt_listening": "Listening",
"ptt_hold_hint": "Hold to unsquelch transmit on this half-duplex call",
"ptt_spacebar_hint": "Hold Space to talk",
"tx_packets": "TX Packets",
"rx_packets": "RX Packets",
"tx_data": "TX Data",
"rx_data": "RX Data",
"tx_rate": "TX Rate",
"rx_rate": "RX Rate",
"failed_to_switch_call_mode": "Failed to switch duplex mode",
"failed_to_set_ptt": "Failed to set push-to-talk",
"active_call": "Active Call",
"status": "Status",
"failed_to_answer_call": "Failed to answer call",

View file

@ -117,6 +117,7 @@ from tests.backend.http_api_response_schemas import (
TELEMETRY_TRACKING_SCHEMA,
TELEMETRY_TRUSTED_PEERS_SCHEMA,
TELEPHONE_AUDIO_PROFILES_SCHEMA,
TELEPHONE_CALL_MODES_SCHEMA,
TELEPHONE_CODEC2_STATUS_SCHEMA,
TELEPHONE_HISTORY_SCHEMA,
TELEPHONE_RECORDINGS_SCHEMA,
@ -563,6 +564,11 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/telephone/audio-profiles",
TELEPHONE_AUDIO_PROFILES_SCHEMA,
),
HttpJsonContract(
"GET",
"/api/v1/telephone/call-modes",
TELEPHONE_CALL_MODES_SCHEMA,
),
HttpJsonContract(
"GET",
"/api/v1/telephone/codec2/status",

View file

@ -865,6 +865,16 @@ TELEPHONE_AUDIO_PROFILES_SCHEMA: dict = {
"additionalProperties": True,
}
TELEPHONE_CALL_MODES_SCHEMA: dict = {
"type": "object",
"required": ["call_modes"],
"properties": {
"call_modes": _ARRAY,
"default_call_mode_id": _INTEGER,
},
"additionalProperties": True,
}
TELEPHONE_CODEC2_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["codec2_available"],

View file

@ -197,8 +197,9 @@ class TestAudioProfilePassthroughRegression:
tm.preferred_profile_id = Profiles.BANDWIDTH_LOW
seen = {}
def capture_call(identity, profile=None):
def capture_call(identity, profile=None, mode=None):
seen["profile"] = profile
seen["mode"] = mode
tm.telephone.call_status = 0
tm.telephone.call.side_effect = capture_call
@ -222,6 +223,7 @@ class TestAudioProfilePassthroughRegression:
await tm.initiate(destination_hash, timeout_seconds=1)
assert seen["profile"] == Profiles.BANDWIDTH_LOW
assert seen["mode"] == Profiles.MODE_FULL_DUPLEX
def test_init_does_not_rely_on_idle_switch_profile(self, tmp_path):
cfg = MagicMock()

View file

@ -68,9 +68,10 @@ async def test_initiate_passes_preferred_profile_to_lxst_call(tm):
tm.preferred_profile_id = Profiles.BANDWIDTH_LOW
seen = {}
def capture_call(identity, profile=None):
def capture_call(identity, profile=None, mode=None):
seen["identity"] = identity
seen["profile"] = profile
seen["mode"] = mode
tm.telephone.call_status = 0
tm.telephone.call.side_effect = capture_call
@ -93,6 +94,7 @@ async def test_initiate_passes_preferred_profile_to_lxst_call(tm):
await tm.initiate(destination_hash, timeout_seconds=1)
assert seen["profile"] == Profiles.BANDWIDTH_LOW
assert seen["mode"] == Profiles.MODE_FULL_DUPLEX
def test_init_telephone_stores_preferred_profile_not_idle_switch(tmp_path):

View file

@ -19,6 +19,7 @@ def test_telephone_call_mutators_are_post_not_get(mock_app):
"/api/v1/telephone/unmute-transmit",
"/api/v1/telephone/mute-receive",
"/api/v1/telephone/unmute-receive",
"/api/v1/telephone/ptt",
]
for path in mutators:
assert _handler(mock_app, "POST", path) is not None, path
@ -38,3 +39,13 @@ def test_telephone_call_mutators_are_post_not_get(mock_app):
_handler(mock_app, "GET", "/api/v1/telephone/switch-audio-profile/{profile_id}")
is None
)
assert (
_handler(mock_app, "POST", "/api/v1/telephone/switch-call-mode/{mode_id}")
is not None
)
assert (
_handler(mock_app, "GET", "/api/v1/telephone/switch-call-mode/{mode_id}")
is None
)
assert _handler(mock_app, "GET", "/api/v1/telephone/call-modes") is not None
assert _handler(mock_app, "POST", "/api/v1/telephone/call-modes") is None

View file

@ -0,0 +1,83 @@
# SPDX-License-Identifier: 0BSD
"""Half-duplex mode and PTT squelch controls for LXST telephony."""
from types import SimpleNamespace
from unittest.mock import MagicMock
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend.telephone_manager import TelephoneManager
def _manager_with_call(mode=Profiles.MODE_HALF_DUPLEX):
tm = TelephoneManager(identity=MagicMock())
packetizer = SimpleNamespace(squelched=mode == Profiles.MODE_HALF_DUPLEX)
active_call = SimpleNamespace(call_mode=mode, packetizer=packetizer)
telephone = MagicMock()
telephone.active_call = active_call
telephone.call_status = 6
telephone.active_mode = mode
tm.telephone = telephone
tm.preferred_mode_id = mode
return tm, telephone, packetizer
def test_resolve_call_mode_defaults_to_full_duplex():
tm = TelephoneManager(identity=MagicMock())
assert tm.resolve_call_mode_id(None) == Profiles.MODE_FULL_DUPLEX
assert tm.resolve_call_mode_id(999) == Profiles.MODE_FULL_DUPLEX
assert (
tm.resolve_call_mode_id(Profiles.MODE_HALF_DUPLEX) == Profiles.MODE_HALF_DUPLEX
)
def test_switch_mode_half_duplex_squelches_and_clears_ptt():
tm, telephone, _packetizer = _manager_with_call(Profiles.MODE_FULL_DUPLEX)
tm.ptt_active = True
resolved = tm.switch_mode(Profiles.MODE_HALF_DUPLEX)
assert resolved == Profiles.MODE_HALF_DUPLEX
telephone.switch_mode.assert_called_once_with(Profiles.MODE_HALF_DUPLEX)
telephone.squelch_transmit.assert_called_once_with(True)
assert tm.ptt_active is False
assert tm.is_half_duplex() is True
def test_set_ptt_active_unsquelches_only_in_half_duplex():
tm, telephone, packetizer = _manager_with_call(Profiles.MODE_HALF_DUPLEX)
assert tm.set_ptt_active(True) is True
telephone.unsquelch_transmit.assert_called_once_with(True)
assert tm.ptt_active is True
packetizer.squelched = False
assert tm.is_transmit_squelched() is False
assert tm.set_ptt_active(False) is True
telephone.squelch_transmit.assert_called_once_with(True)
assert tm.ptt_active is False
def test_set_ptt_active_rejected_in_full_duplex():
tm, telephone, _packetizer = _manager_with_call(Profiles.MODE_FULL_DUPLEX)
assert tm.set_ptt_active(True) is False
telephone.unsquelch_transmit.assert_not_called()
assert tm.ptt_active is False
def test_call_end_resets_mute_and_ptt_state():
tm, _telephone, _packetizer = _manager_with_call(Profiles.MODE_HALF_DUPLEX)
tm.transmit_muted = True
tm.receive_muted = True
tm.ptt_active = True
tm.call_stats = {"link": object()}
tm.on_telephone_call_ended(MagicMock())
assert tm.transmit_muted is False
assert tm.receive_muted is False
assert tm.ptt_active is False
assert tm.call_stats == {}

View file

@ -59,6 +59,16 @@ describe("CallPage.vue", () => {
}
if (url.includes("/api/v1/telephone/audio-profiles"))
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
if (url.includes("/api/v1/telephone/call-modes"))
return Promise.resolve({
data: {
default_call_mode_id: 1,
call_modes: [
{ id: 1, name: "Full Duplex", abbrev: "FDX", is_half_duplex: false },
{ id: 2, name: "Half Duplex", abbrev: "HDX", is_half_duplex: true },
],
},
});
if (url.includes("/api/v1/telephone/contacts/export")) {
return Promise.resolve({ data: { contacts: [] } });
}
@ -130,6 +140,30 @@ describe("CallPage.vue", () => {
expect(axiosMock.post).toHaveBeenCalledWith(expect.stringContaining("/api/v1/telephone/mute-transmit"));
});
it("switches duplex mode and drives PTT squelch", async () => {
const wrapper = mountCallPage();
await flushPromises();
wrapper.vm.activeCall = {
status: 6,
is_half_duplex: false,
call_mode_id: 1,
is_ptt_active: false,
};
axiosMock.post.mockResolvedValueOnce({
data: { mode_id: 2, is_half_duplex: true, is_ptt_active: false },
});
await wrapper.vm.switchCallMode(2);
expect(axiosMock.post).toHaveBeenCalledWith(expect.stringContaining("/api/v1/telephone/switch-call-mode/2"));
expect(wrapper.vm.activeCall.is_half_duplex).toBe(true);
axiosMock.post.mockResolvedValueOnce({ data: { is_ptt_active: true } });
await wrapper.vm.setPttActive(true);
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/telephone/ptt", { active: true });
expect(wrapper.vm.localPttActive).toBe(true);
});
it("renders tabs correctly", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
@ -185,14 +219,15 @@ describe("CallPage.vue", () => {
expect(wrapper.text()).toContain("3 hops");
expect(wrapper.text()).toContain("Default Interface");
expect(wrapper.text()).toContain("TX Pkts");
expect(wrapper.text()).toContain("call.tx_packets");
expect(wrapper.text()).toContain("303");
expect(wrapper.text()).toContain("RX Pkts");
expect(wrapper.text()).toContain("call.rx_packets");
expect(wrapper.text()).toContain("289");
expect(wrapper.text()).toContain("TX Data Out");
expect(wrapper.text()).toContain("call.tx_data");
expect(wrapper.text()).toContain("35 KB");
expect(wrapper.text()).toContain("RX Data In");
expect(wrapper.text()).toContain("call.rx_data");
expect(wrapper.text()).toContain("82 KB");
expect(wrapper.text()).toContain("call.full_duplex");
});
it("attempts to place a call when 'Call' button is clicked", async () => {