mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: introduce half-duplex mode and push-to-talk functionality for LXST telephony, improve call statistics and API endpoints
This commit is contained in:
parent
6fdda16606
commit
b1ee794316
20 changed files with 4682 additions and 3913 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
tests/backend/test_telephone_duplex_ptt.py
Normal file
83
tests/backend/test_telephone_duplex_ptt.py
Normal 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 == {}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue