initial implementation of lxst phone calling

This commit is contained in:
liamcottle 2025-11-23 21:27:56 +13:00
parent 011876bec5
commit 99ece9040e
4 changed files with 425 additions and 8 deletions

View file

@ -1,6 +1,8 @@
#!/usr/bin/env python
import argparse
import asyncio
import base64
import io
import json
import os
@ -8,29 +10,28 @@ import platform
import sys
import threading
import time
import webbrowser
from datetime import datetime, timezone
from typing import Callable, List
import LXMF
import LXST
import RNS
import RNS.vendor.umsgpack as msgpack
import LXMF
from LXMF import LXMRouter
from LXST import Telephone
from aiohttp import web, WSMessage, WSMsgType, WSCloseCode
import asyncio
import base64
import webbrowser
from peewee import SqliteDatabase
from serial.tools import list_ports
import database
from src.backend.announce_handler import AnnounceHandler
from src.backend.async_utils import AsyncUtils
from src.backend.audio_call_manager import AudioCall, AudioCallManager
from src.backend.colour_utils import ColourUtils
from src.backend.interface_config_parser import InterfaceConfigParser
from src.backend.interface_editor import InterfaceEditor
from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField
from src.backend.audio_call_manager import AudioCall, AudioCallManager
from src.backend.sideband_commands import SidebandCommands
@ -151,6 +152,11 @@ class ReticulumMeshChat:
self.audio_call_manager = AudioCallManager(identity=self.identity)
self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call)
# init telephone
# todo check if user wants to enable telephone
self.telephone = None
self.init_telephone()
# start background thread for auto announce loop
thread = threading.Thread(target=asyncio.run, args=(self.announce_loop(),))
thread.daemon = True
@ -161,6 +167,34 @@ class ReticulumMeshChat:
thread.daemon = True
thread.start()
# init telephone
def init_telephone(self):
self.telephone = Telephone(identity=self.identity)
self.telephone.set_ringing_callback(self.on_telephone_ringing)
self.telephone.set_established_callback(self.on_telephone_call_established)
self.telephone.set_ended_callback(self.on_telephone_call_ended)
# handle receiving a new telephone call
def on_telephone_ringing(self, caller_identity: RNS.Identity):
print("on_telephone_ringing: {}".format(caller_identity.hash.hex()))
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
"type": "telephone_ringing",
})))
# handle telephone call established
def on_telephone_call_established(self, caller_identity: RNS.Identity):
print("on_telephone_call_established: {}".format(caller_identity.hash.hex()))
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
"type": "telephone_call_established",
})))
# handle telephone call ended
def on_telephone_call_ended(self, caller_identity: RNS.Identity):
print("on_telephone_ended: {}".format(caller_identity.hash.hex()))
AsyncUtils.run_async(self.websocket_broadcast(json.dumps({
"type": "telephone_call_ended",
})))
# gets app version from package.json
def get_app_version(self) -> str:
with open(get_file_path("package.json")) as f:
@ -309,6 +343,122 @@ class ReticulumMeshChat:
"status": "ok",
})
# serve telephone status
@routes.get("/api/v1/telephone/status")
async def index(request):
# make sure telephone is enabled
if self.telephone is None:
return web.json_response({
"message": "Telephone is disabled",
}, status=503)
# get active call info
active_call = None
telephone_active_call = self.telephone.active_call
if telephone_active_call is not None:
# get remote identity hash
remote_identity_hash = None
remote_identity = telephone_active_call.get_remote_identity()
if remote_identity is not None:
remote_identity_hash = remote_identity.hash.hex()
active_call = {
"hash": telephone_active_call.hash.hex(),
"status": self.telephone.call_status,
"is_incoming": telephone_active_call.is_incoming,
"is_outgoing": telephone_active_call.is_outgoing,
"remote_identity_hash": remote_identity_hash,
}
return web.json_response({
"is_busy": self.telephone.busy,
"call_status": self.telephone.call_status,
"active_call": active_call,
})
# answer incoming telephone call
@routes.get("/api/v1/telephone/answer")
async def index(request):
# get incoming caller identity
caller_identity = self.telephone.active_call.get_remote_identity()
# answer call
AsyncUtils.run_async(asyncio.to_thread(self.telephone.answer, caller_identity))
return web.json_response({
"message": "Answering call...",
})
# hangup active telephone call
@routes.get("/api/v1/telephone/hangup")
async def index(request):
AsyncUtils.run_async(asyncio.to_thread(self.telephone.hangup))
return web.json_response({
"message": "Hanging up call...",
})
# initiate a telephone call
@routes.get("/api/v1/telephone/call/{identity_hash}")
async def index(request):
# get path params
identity_hash_hex = request.match_info.get("identity_hash", "")
timeout_seconds = int(request.query.get("timeout", 15))
# convert hash to bytes
identity_hash = bytes.fromhex(identity_hash_hex)
# find destination identity
destination_identity = RNS.Identity.recall(identity_hash, from_identity_hash=True)
if destination_identity is None:
# couldn't find as identity hash, try finding as destination hash
destination_identity = RNS.Identity.recall(identity_hash, from_identity_hash=False)
# ensure identity was found
if destination_identity is None:
return web.json_response({
"message": "Call Failed: Destination identity not found.",
}, status=503)
# create telephony destination, only used to determine if we have a path
telephony_destination = RNS.Destination(
destination_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
LXST.APP_NAME,
LXST.Primitives.Telephony.PRIMITIVE_NAME
)
telephony_destination_hash = telephony_destination.hash
# determine when to timeout
timeout_after_seconds = time.time() + timeout_seconds
# check if we have a path to the destination
if not RNS.Transport.has_path(telephony_destination_hash):
# we don't have a path, so we need to request it
RNS.Transport.request_path(telephony_destination_hash)
# wait until we have a path, or give up after the configured timeout
while not RNS.Transport.has_path(telephony_destination_hash) and time.time() < timeout_after_seconds:
await asyncio.sleep(0.1)
# if we still don't have a path, call will fail, so bail out
if not RNS.Transport.has_path(telephony_destination_hash):
return web.json_response({
"message": "Call Failed: Could not find path to destination.",
}, status=503)
# initiate call
AsyncUtils.run_async(asyncio.to_thread(self.telephone.call, destination_identity, None))
return web.json_response({
"message": "Calling...",
})
# fetch com ports
@routes.get("/api/v1/comports")
async def index(request):
@ -2075,6 +2225,10 @@ class ReticulumMeshChat:
# send announce for audio call
self.audio_call_manager.announce(app_data=self.config.display_name.get().encode("utf-8"))
# send announce for telephone if enabled
if self.telephone is not None:
self.telephone.announce()
# tell websocket clients we just announced
await self.send_announced_to_websocket_clients()

View file

@ -1,6 +1,7 @@
aiohttp>=3.12.14
cx_freeze>=7.0.0
lxmf>=0.9.2
lxst>=0.4.0
peewee>=3.18.1
rns>=1.0.2
websockets>=14.2

View file

@ -1,10 +1,10 @@
import axios from 'axios';
import {createApp} from 'vue';
import "./style.css";
import CallPage from "./components/call/CallPage.vue";
import TelephonePage from "./components/telephone/TelephonePage.vue";
// provide axios globally
window.axios = axios;
createApp(CallPage)
createApp(TelephonePage)
.mount('#app');

View file

@ -0,0 +1,262 @@
<template>
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{'dark': config?.theme === 'dark'}">
<div class="mx-auto my-auto w-full max-w-xl p-4">
<div class="w-full space-y-2">
<!-- dialer -->
<div class="border rounded-xl bg-white shadow w-full overflow-hidden dark:border-zinc-900">
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:bg-zinc-800 dark:text-white">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 dark:text-white">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
</div>
<div class="my-auto">Start a new Call</div>
</div>
<div class="flex border-b border-gray-300 text-gray-900 p-2 space-x-2 dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-800">
<div class="flex-1">
<input v-model="destinationHash" type="text" placeholder="Enter Destination Hash" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
</div>
<button @click="initiateCall(destinationHash)" :disabled="isInitiatingCall" type="button" :class="[ isInitiatingCall ? 'bg-gray-400 focus-visible:outline-gray-500' : 'bg-green-500 hover:bg-green-400 focus-visible:outline-green-500' ]" class="my-auto inline-flex items-center gap-x-1 rounded-md p-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
<span v-if="isInitiatingCall">
<span>Calling...</span>
</span>
<span v-else>Initiate Call</span>
</button>
</div>
<div class="flex p-1 dark:bg-zinc-700 dark:border-zinc-600">
<div>
<div class='dark:text-white'>My Identity Hash</div>
<div class="text-sm text-gray-700 dark:text-zinc-100">{{ myIdentityHash || "Unknown" }}</div>
</div>
<div class="ml-auto my-auto mr-1">
<a @click="announce" href="javascript:void(0)" class="rounded-full">
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-full">
<div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
</div>
<div class="my-auto mx-1 text-sm">Announce</div>
</div>
</a>
</div>
</div>
</div>
<!-- active call -->
<div v-if="activeCall" class="border rounded-xl bg-white shadow w-full overflow-hidden dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100">
<div class="flex border-b border-gray-300 text-gray-700 p-2 dark:text-zinc-100">
<div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
<div class="my-auto">
<span v-if="activeCall.is_incoming">Incoming Call</span>
<span v-if="activeCall.is_outgoing">Outgoing Call</span>
</div>
</div>
<div class="divide-y">
<div class="flex p-2">
<div class="mr-2 my-auto">
<div class="bg-gray-100 p-2 rounded-full">
<svg v-if="activeCall.is_outgoing" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.5 4.56l-3.22 3.22a.75.75 0 1 1-1.06-1.06l3.22-3.22h-2.69a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V4.56Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M3.5 2A1.5 1.5 0 0 0 2 3.5V5c0 1.149.15 2.263.43 3.326a13.022 13.022 0 0 0 9.244 9.244c1.063.28 2.177.43 3.326.43h1.5a1.5 1.5 0 0 0 1.5-1.5v-1.148a1.5 1.5 0 0 0-1.175-1.465l-3.223-.716a1.5 1.5 0 0 0-1.767 1.052l-.267.933c-.117.41-.555.643-.95.48a11.542 11.542 0 0 1-6.254-6.254c-.163-.395.07-.833.48-.95l.933-.267a1.5 1.5 0 0 0 1.052-1.767l-.716-3.223A1.5 1.5 0 0 0 4.648 2H3.5ZM16.72 2.22a.75.75 0 1 1 1.06 1.06L14.56 6.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22Z" />
</svg>
</div>
</div>
<div>
<div>{{ activeCall.remote_identity_hash || "Unknown" }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-100">
<span>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6">Established...</span>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</div>
</div>
<div class="flex space-x-2 ml-auto my-auto mx-2">
<!-- answer call -->
<button v-if="activeCall.is_incoming && activeCall.status === 4" title="Answer Call" @click="answerCall" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-green-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
</svg>
</button>
<!-- hangup call -->
<button title="Hangup Call" @click="hangupCall" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 rotate-[135deg] translate-y-0.5">
<path fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TelephonePage',
data() {
return {
config: null,
myIdentityHash: null,
activeCall: null,
destinationHash: null,
isInitiatingCall: false,
};
},
mounted: function() {
// update config
this.getConfig();
this.getTelephoneStatus();
// update telephone status every second
setInterval(() => {
this.getTelephoneStatus();
}, 1000);
// parse url params
var queryParams = new URLSearchParams(location.search);
var queryDestinationHash = queryParams.get("destination_hash");
// autofill destination hash to call when query param provided in url
if(queryDestinationHash){
this.destinationHash = queryDestinationHash;
}
},
methods: {
async initiateCall(destinationHash) {
// do nothing if already initiating call
if(this.isInitiatingCall){
alert("Call is already initiating...");
return;
}
// make sure call hash provided
if(!destinationHash) {
alert("Enter destination hash to call.");
return;
}
// show loading
this.isInitiatingCall = true;
try {
// initiate call
await axios.get(`/api/v1/telephone/call/${destinationHash}`, {
params: {
timeout: 15, // how long to attempt to initiate call
},
});
// fetch status
await this.getTelephoneStatus();
} catch(e) {
console.log(e);
// show error message from response, or fallback to default
const message = e.response?.data?.message ?? "failed to initiate call";
alert(message);
} finally {
// hide loading
this.isInitiatingCall = false;
}
},
async answerCall() {
try {
// answer call
await axios.get(`/api/v1/telephone/answer`);
// update ui
await this.getTelephoneStatus();
} catch(e) {
// ignore error answering call
}
},
async hangupCall() {
try {
// hangup call
await axios.get(`/api/v1/telephone/hangup`);
// update ui
await this.getTelephoneStatus();
} catch(e) {
// ignore error hanging up call
}
},
async getConfig() {
try {
// fetch calls
const response = await axios.get("/api/v1/config");
// update ui
this.config = response.data.config;
this.myIdentityHash = response.data.config.identity_hash;
} catch(e) {
// do nothing on error
console.error(e);
}
},
async getTelephoneStatus() {
try {
// fetch calls
const response = await axios.get("/api/v1/telephone/status");
// update ui
this.activeCall = response.data.active_call;
} catch(e) {
// do nothing on error
console.error(e);
}
},
async announce() {
try {
await window.axios.get(`/api/v1/announce`);
} catch(e) {
alert("failed to announce");
console.log(e);
}
},
},
}
</script>