Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
1140 lines
39 KiB
TypeScript
1140 lines
39 KiB
TypeScript
/**
|
|
* CallManager — central registry and factory for all calls.
|
|
*
|
|
* Replaces the scattered state across sipproxy.ts (rtpSessions),
|
|
* calloriginator.ts (originatedCalls/callIdIndex), and
|
|
* webrtcbridge.ts (sessions).
|
|
*
|
|
* Responsibilities:
|
|
* - SIP message routing by Call-ID
|
|
* - Factory methods for each call scenario
|
|
* - Per-call provider selection
|
|
* - Status aggregation for the dashboard
|
|
* - Transfer and dynamic leg management
|
|
*/
|
|
|
|
import { Buffer } from 'node:buffer';
|
|
import { playAnnouncement, playAnnouncementToWebRtc, isAnnouncementReady } from '../announcement.ts';
|
|
import {
|
|
SipMessage,
|
|
buildSdp,
|
|
parseSdpEndpoint,
|
|
rewriteSdp,
|
|
rewriteSipUri,
|
|
generateTag,
|
|
} from '../sip/index.ts';
|
|
import type { IEndpoint } from '../sip/index.ts';
|
|
import type { IAppConfig, IProviderConfig } from '../config.ts';
|
|
import { getProvider, getProviderForOutbound, getDevicesForInbound } from '../config.ts';
|
|
import { RtpPortPool } from './rtp-port-pool.ts';
|
|
import { Call } from './call.ts';
|
|
import { SipLeg } from './sip-leg.ts';
|
|
import type { ISipLegConfig } from './sip-leg.ts';
|
|
import { WebRtcLeg } from './webrtc-leg.ts';
|
|
import type { IWebRtcLegConfig } from './webrtc-leg.ts';
|
|
import type { ICallStatus, ICallHistoryEntry } from './types.ts';
|
|
import type { ProviderState } from '../providerstate.ts';
|
|
import {
|
|
getRegisteredDevice,
|
|
isKnownDeviceAddress,
|
|
} from '../registrar.ts';
|
|
import { WebSocket } from 'ws';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CallManager config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ICallManagerConfig {
|
|
appConfig: IAppConfig;
|
|
sendSip: (buf: Buffer, dest: IEndpoint) => void;
|
|
log: (msg: string) => void;
|
|
broadcastWs: (type: string, data: unknown) => void;
|
|
getProviderState: (providerId: string) => ProviderState | undefined;
|
|
getAllBrowserDeviceIds: () => string[];
|
|
sendToBrowserDevice: (deviceId: string, data: unknown) => boolean;
|
|
getBrowserDeviceWs: (deviceId: string) => WebSocket | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CallManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class CallManager {
|
|
private calls = new Map<string, Call>();
|
|
/** Maps SIP Call-ID -> Call (for routing incoming SIP messages). */
|
|
private sipCallIdIndex = new Map<string, Call>();
|
|
private portPool: RtpPortPool;
|
|
private config: ICallManagerConfig;
|
|
private nextCallNum = 0;
|
|
|
|
/** Completed call history (most recent first, capped). */
|
|
private callHistory: ICallHistoryEntry[] = [];
|
|
private static readonly MAX_HISTORY = 100;
|
|
|
|
/** Standalone WebRTC legs created from webrtc-offer (before linked to a call). */
|
|
private standaloneWebRtcLegs = new Map<string, WebRtcLeg>();
|
|
|
|
/** Pending browser calls: callId -> { provider, number, ps, rtpAllocation }. */
|
|
private pendingBrowserCalls = new Map<string, {
|
|
provider: IProviderConfig;
|
|
number: string;
|
|
ps: ProviderState;
|
|
rtpPort: number;
|
|
rtpSock: import('node:dgram').Socket;
|
|
}>();
|
|
|
|
/** Passthrough calls: SIP Call-ID -> passthrough context. */
|
|
private passthroughCalls = new Map<string, {
|
|
call: Call;
|
|
providerAddress: string;
|
|
providerPort: number;
|
|
providerConfig: IProviderConfig;
|
|
ps: ProviderState;
|
|
deviceTarget: IEndpoint;
|
|
rtpPort: number;
|
|
rtpSock: import('node:dgram').Socket;
|
|
deviceMedia: IEndpoint | null;
|
|
providerMedia: IEndpoint | null;
|
|
}>();
|
|
|
|
constructor(config: ICallManagerConfig) {
|
|
this.config = config;
|
|
const { min, max } = config.appConfig.proxy.rtpPortRange;
|
|
this.portPool = new RtpPortPool(min, max, config.log);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SIP message routing
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Route an incoming SIP message to the correct call/leg.
|
|
* Returns true if handled, false if this message doesn't belong to any call.
|
|
*/
|
|
routeSipMessage(msg: SipMessage, rinfo: { address: string; port: number }): boolean {
|
|
// Check passthrough calls first — they need direction-based routing.
|
|
const pt = this.passthroughCalls.get(msg.callId);
|
|
if (pt) {
|
|
this.handlePassthroughMessage(pt, msg, rinfo);
|
|
return true;
|
|
}
|
|
|
|
// B2BUA calls — route by SIP Call-ID to the correct leg.
|
|
const call = this.sipCallIdIndex.get(msg.callId);
|
|
if (!call) return false;
|
|
|
|
const leg = call.getLegBySipCallId(msg.callId);
|
|
if (leg) {
|
|
leg.handleSipMessage(msg, { address: rinfo.address, port: rinfo.port });
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle a SIP message for a passthrough call.
|
|
* Determines direction by source IP and forwards to the other side with rewriting.
|
|
*/
|
|
private handlePassthroughMessage(
|
|
pt: (typeof this.passthroughCalls extends Map<string, infer V> ? V : never),
|
|
msg: SipMessage,
|
|
rinfo: { address: string; port: number },
|
|
): void {
|
|
const fromProvider = rinfo.address === pt.providerAddress;
|
|
const lanIp = this.config.appConfig.proxy.lanIp;
|
|
const lanPort = this.config.appConfig.proxy.lanPort;
|
|
const pub = pt.ps.publicIp || lanIp;
|
|
|
|
if (fromProvider) {
|
|
// Provider -> device: rewrite and forward.
|
|
if (msg.isRequest) {
|
|
if (msg.isDialogEstablishing) {
|
|
msg.prependHeader('Record-Route', `<sip:${lanIp}:${lanPort};lr>`);
|
|
}
|
|
if (msg.method === 'BYE') {
|
|
this.config.sendSip(msg.serialize(), pt.deviceTarget);
|
|
pt.call.hangup();
|
|
this.passthroughCalls.delete(msg.callId);
|
|
return;
|
|
}
|
|
// INVITE retransmits — just forward.
|
|
msg.setRequestUri(rewriteSipUri(msg.requestUri!, pt.deviceTarget.address, pt.deviceTarget.port));
|
|
}
|
|
// Rewrite SDP (provider media → proxy LAN IP).
|
|
if (msg.hasSdpBody) {
|
|
const { body, original } = rewriteSdp(msg.body, lanIp, pt.rtpPort);
|
|
msg.body = body;
|
|
msg.updateContentLength();
|
|
if (original) pt.providerMedia = original;
|
|
}
|
|
this.config.sendSip(msg.serialize(), pt.deviceTarget);
|
|
} else {
|
|
// Device -> provider: rewrite and forward.
|
|
if (msg.isRequest) {
|
|
if (msg.method === 'BYE') {
|
|
this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy);
|
|
pt.call.hangup();
|
|
this.passthroughCalls.delete(msg.callId);
|
|
return;
|
|
}
|
|
}
|
|
// Rewrite Contact.
|
|
const contact = msg.getHeader('Contact');
|
|
if (contact) {
|
|
const nc = rewriteSipUri(contact, pub, lanPort);
|
|
if (nc !== contact) msg.setHeader('Contact', nc);
|
|
}
|
|
// Rewrite SDP (device media → proxy public IP).
|
|
if (msg.hasSdpBody) {
|
|
const { body, original } = rewriteSdp(msg.body, pub, pt.rtpPort);
|
|
msg.body = body;
|
|
msg.updateContentLength();
|
|
if (original) pt.deviceMedia = original;
|
|
}
|
|
// Start silence on 200 OK to INVITE.
|
|
if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') {
|
|
if (pt.providerConfig.quirks.earlyMediaSilence && pt.providerMedia) {
|
|
this.startPassthroughSilence({ pktReceived: 0, pktSent: 0, rtpSock: pt.rtpSock } as any, pt.providerMedia, pt.providerConfig);
|
|
}
|
|
}
|
|
this.config.sendSip(msg.serialize(), pt.providerConfig.outboundProxy);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Outbound call (click-to-call from UI)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Start an outbound call from the dashboard.
|
|
* Dials the device first (leg A), then the provider (leg B) when device answers.
|
|
*/
|
|
createOutboundCall(number: string, deviceId?: string, providerId?: string): Call | null {
|
|
// Resolve provider.
|
|
const provider = providerId
|
|
? getProvider(this.config.appConfig, providerId)
|
|
: getProviderForOutbound(this.config.appConfig);
|
|
if (!provider) {
|
|
this.config.log('[call-mgr] no provider found');
|
|
return null;
|
|
}
|
|
|
|
const ps = this.config.getProviderState(provider.id);
|
|
if (!ps) {
|
|
this.config.log(`[call-mgr] provider state not found for ${provider.id}`);
|
|
return null;
|
|
}
|
|
|
|
const aor = ps.registeredAor;
|
|
if (!aor) {
|
|
this.config.log('[call-mgr] cannot originate — no registered AOR');
|
|
return null;
|
|
}
|
|
|
|
// Allocate RTP for device leg.
|
|
const rtpA = this.portPool.allocate();
|
|
if (!rtpA) {
|
|
this.config.log('[call-mgr] cannot originate — port pool exhausted');
|
|
return null;
|
|
}
|
|
|
|
const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`;
|
|
const call = new Call({
|
|
id: callId,
|
|
direction: 'outbound',
|
|
portPool: this.portPool,
|
|
log: this.config.log,
|
|
onChange: (c) => this.handleCallChange(c),
|
|
});
|
|
call.calleeNumber = number;
|
|
call.providerUsed = provider.displayName;
|
|
this.calls.set(callId, call);
|
|
|
|
const isBrowser = deviceId?.startsWith('browser-') ?? false;
|
|
|
|
if (isBrowser && deviceId) {
|
|
// Browser device — DON'T create WebRtcLeg yet.
|
|
// The browser will send webrtc-offer (creating a standalone WebRtcLeg),
|
|
// then webrtc-accept (which links the leg to this call and starts the provider).
|
|
this.pendingBrowserCalls.set(callId, {
|
|
provider,
|
|
number,
|
|
ps,
|
|
rtpPort: rtpA.port,
|
|
rtpSock: rtpA.sock,
|
|
});
|
|
|
|
// Notify browser of incoming call.
|
|
call.state = 'ringing';
|
|
this.config.sendToBrowserDevice(deviceId, {
|
|
type: 'webrtc-incoming',
|
|
callId,
|
|
from: number,
|
|
deviceId,
|
|
});
|
|
this.config.log(`[call-mgr] ${callId} notified browser device ${deviceId}`);
|
|
|
|
} else {
|
|
// SIP device — create SipLeg with INVITE.
|
|
const deviceTarget = this.resolveDeviceTarget(deviceId);
|
|
if (!deviceTarget) {
|
|
this.config.log('[call-mgr] cannot resolve device');
|
|
this.portPool.release(rtpA.port);
|
|
this.calls.delete(callId);
|
|
return null;
|
|
}
|
|
|
|
const sipLegConfig: ISipLegConfig = {
|
|
role: 'device',
|
|
lanIp: this.config.appConfig.proxy.lanIp,
|
|
lanPort: this.config.appConfig.proxy.lanPort,
|
|
getPublicIp: () => ps.publicIp,
|
|
sendSip: this.config.sendSip,
|
|
log: this.config.log,
|
|
sipTarget: deviceTarget,
|
|
rtpPort: rtpA.port,
|
|
rtpSock: rtpA.sock,
|
|
};
|
|
|
|
const legA = new SipLeg(`${callId}-dev`, sipLegConfig);
|
|
|
|
legA.onConnected = (leg) => {
|
|
this.config.log(`[call-mgr] ${callId} device answered — starting provider leg`);
|
|
|
|
// Play announcement to the device while dialing the provider.
|
|
if (isAnnouncementReady() && leg.remoteMedia) {
|
|
playAnnouncement(
|
|
(pkt) => leg.rtpSock.send(pkt, leg.remoteMedia!.port, leg.remoteMedia!.address),
|
|
leg.ssrc,
|
|
);
|
|
this.config.log(`[call-mgr] ${callId} playing announcement to device`);
|
|
}
|
|
|
|
// Start dialing provider in parallel with announcement.
|
|
this.startProviderLeg(call, provider, number, ps);
|
|
};
|
|
|
|
legA.onTerminated = (leg) => {
|
|
call.handleLegTerminated(leg.id);
|
|
};
|
|
|
|
legA.onStateChange = () => call.notifyLegStateChange(legA);
|
|
|
|
call.addLeg(legA);
|
|
|
|
const sipCallIdA = `${callId}-a`;
|
|
legA.sendInvite({
|
|
fromUri: `sip:${number}@${this.config.appConfig.proxy.lanIp}`,
|
|
fromDisplayName: `Mediated: ${number}`,
|
|
toUri: `sip:user@${deviceTarget.address}`,
|
|
callId: sipCallIdA,
|
|
});
|
|
this.sipCallIdIndex.set(sipCallIdA, call);
|
|
}
|
|
|
|
return call;
|
|
}
|
|
|
|
/**
|
|
* Browser accepted the call — link the standalone WebRtcLeg to the call
|
|
* and start the provider leg.
|
|
*/
|
|
acceptBrowserCall(callId: string, sessionId?: string): boolean {
|
|
const call = this.calls.get(callId);
|
|
if (!call) {
|
|
this.config.log(`[call-mgr] acceptBrowserCall: call ${callId} not found`);
|
|
return false;
|
|
}
|
|
|
|
const pending = this.pendingBrowserCalls.get(callId);
|
|
if (!pending) {
|
|
this.config.log(`[call-mgr] acceptBrowserCall: no pending browser call for ${callId}`);
|
|
return false;
|
|
}
|
|
|
|
// Find the standalone WebRtcLeg created from webrtc-offer.
|
|
let webrtcLeg: WebRtcLeg | null = null;
|
|
if (sessionId) {
|
|
webrtcLeg = this.standaloneWebRtcLegs.get(sessionId) ?? null;
|
|
}
|
|
|
|
if (!webrtcLeg) {
|
|
this.config.log(`[call-mgr] acceptBrowserCall: WebRTC session ${sessionId} not found — waiting`);
|
|
// The offer might not have been processed yet. Retry briefly.
|
|
return false;
|
|
}
|
|
|
|
// Remove from standalone tracking.
|
|
this.standaloneWebRtcLegs.delete(sessionId!);
|
|
this.pendingBrowserCalls.delete(callId);
|
|
|
|
// Attach the WebRtcLeg to the call.
|
|
webrtcLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id);
|
|
call.addLeg(webrtcLeg);
|
|
|
|
this.config.log(`[call-mgr] ${callId} browser linked (session=${sessionId}) — starting provider leg`);
|
|
|
|
// Play announcement to browser while dialing provider.
|
|
// Uses the WebRtcLeg's shared fromSipCounters so that when provider audio
|
|
// starts, seq/ts continue seamlessly (no jitter buffer discontinuity).
|
|
if (isAnnouncementReady()) {
|
|
const cancel = playAnnouncementToWebRtc(
|
|
(pkt) => webrtcLeg.sendDirectToBrowser(pkt),
|
|
webrtcLeg.fromSipSsrc,
|
|
webrtcLeg.fromSipCounters,
|
|
);
|
|
webrtcLeg.announcementCancel = cancel;
|
|
this.config.log(`[call-mgr] ${callId} playing announcement to browser`);
|
|
}
|
|
|
|
// Start the provider leg in parallel.
|
|
this.startProviderLeg(call, pending.provider, pending.number, pending.ps);
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Provider leg (leg B)
|
|
// -------------------------------------------------------------------------
|
|
|
|
private startProviderLeg(call: Call, provider: IProviderConfig, number: string, ps: ProviderState): void {
|
|
const rtpB = this.portPool.allocate();
|
|
if (!rtpB) {
|
|
this.config.log(`[call-mgr] ${call.id} cannot start provider leg — port pool exhausted`);
|
|
call.hangup();
|
|
return;
|
|
}
|
|
|
|
const pub = ps.publicIp || this.config.appConfig.proxy.lanIp;
|
|
const aor = ps.registeredAor!;
|
|
|
|
const sipLegConfig: ISipLegConfig = {
|
|
role: 'provider',
|
|
lanIp: this.config.appConfig.proxy.lanIp,
|
|
lanPort: this.config.appConfig.proxy.lanPort,
|
|
getPublicIp: () => ps.publicIp,
|
|
sendSip: this.config.sendSip,
|
|
log: this.config.log,
|
|
provider,
|
|
sipTarget: provider.outboundProxy,
|
|
rtpPort: rtpB.port,
|
|
rtpSock: rtpB.sock,
|
|
payloadTypes: provider.codecs,
|
|
getRegisteredAor: () => ps.registeredAor,
|
|
getSipPassword: () => provider.password,
|
|
};
|
|
|
|
const legB = new SipLeg(`${call.id}-prov`, sipLegConfig);
|
|
|
|
legB.onConnected = async (leg) => {
|
|
this.config.log(`[call-mgr] ${call.id} CONNECTED to provider`);
|
|
|
|
// Set up transcoding between WebRTC and SIP legs if needed.
|
|
const webrtcLeg = call.getLegByType('webrtc') as WebRtcLeg | null;
|
|
if (webrtcLeg && leg.remoteMedia) {
|
|
const sipPT = provider.codecs?.[0] ?? 9;
|
|
await webrtcLeg.setupTranscoders(sipPT);
|
|
// Browser→SIP: route transcoded audio through the SipLeg's socket
|
|
// so the provider never sees the WebRtcLeg's port (avoids symmetric RTP double-path).
|
|
webrtcLeg.remoteMedia = leg.remoteMedia;
|
|
webrtcLeg.onSendToProvider = (data, dest) => {
|
|
legB.rtpSock.send(data, dest.port, dest.address);
|
|
};
|
|
// SIP→browser: provider RTP arrives at SipLeg's socket → onRtpReceived →
|
|
// Call hub forwardRtp → WebRtcLeg.sendRtp → forwardToBrowser (transcodes to Opus).
|
|
this.config.log(`[call-mgr] ${call.id} WebRTC<->SIP bridge: sip:${legB.rtpPort} <-> provider ${leg.remoteMedia.address}:${leg.remoteMedia.port}`);
|
|
}
|
|
|
|
call.notifyLegStateChange(leg);
|
|
};
|
|
|
|
legB.onTerminated = (leg) => {
|
|
call.handleLegTerminated(leg.id);
|
|
};
|
|
|
|
legB.onStateChange = () => call.notifyLegStateChange(legB);
|
|
|
|
call.addLeg(legB);
|
|
|
|
const sipCallIdB = `${call.id}-b`;
|
|
const destUri = `sip:${number}@${provider.domain}`;
|
|
|
|
legB.sendInvite({
|
|
fromUri: aor,
|
|
toUri: destUri,
|
|
callId: sipCallIdB,
|
|
});
|
|
this.sipCallIdIndex.set(sipCallIdB, call);
|
|
|
|
call.state = 'ringing';
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Inbound call (provider -> device)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle an inbound INVITE from a provider.
|
|
* Uses passthrough routing (direction by source IP, not per-leg SIP Call-ID).
|
|
*/
|
|
createInboundCall(ps: ProviderState, invite: SipMessage, rinfo: IEndpoint): Call | null {
|
|
const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`;
|
|
const provider = ps.config;
|
|
const lanIp = this.config.appConfig.proxy.lanIp;
|
|
const lanPort = this.config.appConfig.proxy.lanPort;
|
|
|
|
const call = new Call({
|
|
id: callId,
|
|
direction: 'inbound',
|
|
portPool: this.portPool,
|
|
log: this.config.log,
|
|
onChange: (c) => this.handleCallChange(c),
|
|
});
|
|
call.providerUsed = provider.displayName;
|
|
|
|
const from = invite.getHeader('From');
|
|
call.callerNumber = from ? SipMessage.extractUri(from) || 'Unknown' : 'Unknown';
|
|
|
|
this.calls.set(callId, call);
|
|
|
|
// Allocate a single RTP relay port (shared by both directions, like old passthrough).
|
|
const rtpAlloc = this.portPool.allocate();
|
|
if (!rtpAlloc) {
|
|
this.config.log('[call-mgr] cannot handle inbound — port pool exhausted');
|
|
this.calls.delete(callId);
|
|
return null;
|
|
}
|
|
|
|
// Resolve target device.
|
|
const deviceConfigs = getDevicesForInbound(this.config.appConfig, provider.id);
|
|
const deviceTarget = this.resolveFirstDevice(deviceConfigs.map((d) => d.id));
|
|
if (!deviceTarget) {
|
|
this.config.log('[call-mgr] cannot handle inbound — no device target');
|
|
this.portPool.release(rtpAlloc.port);
|
|
this.calls.delete(callId);
|
|
return null;
|
|
}
|
|
|
|
// Set up bidirectional RTP relay (like old passthrough).
|
|
let deviceMedia: IEndpoint | null = null;
|
|
let providerMedia: IEndpoint | null = null;
|
|
|
|
rtpAlloc.sock.on('message', (data: Buffer, pktInfo: { address: string; port: number }) => {
|
|
// Forward based on source address.
|
|
if (deviceMedia && pktInfo.address === deviceMedia.address && pktInfo.port === deviceMedia.port) {
|
|
if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address);
|
|
} else if (providerMedia && pktInfo.address === providerMedia.address && pktInfo.port === providerMedia.port) {
|
|
if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address);
|
|
} else if (isKnownDeviceAddress(pktInfo.address)) {
|
|
if (!deviceMedia) deviceMedia = { address: pktInfo.address, port: pktInfo.port };
|
|
if (providerMedia) rtpAlloc.sock.send(data, providerMedia.port, providerMedia.address);
|
|
} else {
|
|
if (!providerMedia) providerMedia = { address: pktInfo.address, port: pktInfo.port };
|
|
if (deviceMedia) rtpAlloc.sock.send(data, deviceMedia.port, deviceMedia.address);
|
|
}
|
|
});
|
|
|
|
// Register as passthrough call (routes by source IP, not by leg).
|
|
const ptCtx = {
|
|
call,
|
|
providerAddress: rinfo.address,
|
|
providerPort: rinfo.port,
|
|
providerConfig: provider,
|
|
ps,
|
|
deviceTarget,
|
|
rtpPort: rtpAlloc.port,
|
|
rtpSock: rtpAlloc.sock,
|
|
deviceMedia,
|
|
providerMedia,
|
|
};
|
|
this.passthroughCalls.set(invite.callId, ptCtx);
|
|
|
|
// Rewrite and forward the INVITE to the device.
|
|
const fwdInvite = SipMessage.parse(invite.serialize())!;
|
|
fwdInvite.setRequestUri(rewriteSipUri(fwdInvite.requestUri!, deviceTarget.address, deviceTarget.port));
|
|
fwdInvite.prependHeader('Record-Route', `<sip:${lanIp}:${lanPort};lr>`);
|
|
|
|
if (fwdInvite.hasSdpBody) {
|
|
const { body, original } = rewriteSdp(fwdInvite.body, lanIp, rtpAlloc.port);
|
|
fwdInvite.body = body;
|
|
fwdInvite.updateContentLength();
|
|
if (original) ptCtx.providerMedia = original;
|
|
}
|
|
|
|
this.config.sendSip(fwdInvite.serialize(), deviceTarget);
|
|
|
|
// Notify browsers if configured.
|
|
if (this.config.appConfig.routing.ringBrowsers?.[provider.id]) {
|
|
const ids = this.config.getAllBrowserDeviceIds();
|
|
for (const deviceIdBrowser of ids) {
|
|
this.config.sendToBrowserDevice(deviceIdBrowser, {
|
|
type: 'webrtc-incoming',
|
|
callId,
|
|
from: call.callerNumber,
|
|
deviceId: deviceIdBrowser,
|
|
});
|
|
}
|
|
if (ids.length) {
|
|
this.config.log(`[call-mgr] notified ${ids.length} browser(s) of inbound call`);
|
|
}
|
|
}
|
|
|
|
call.state = 'ringing';
|
|
return call;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Passthrough call (device -> provider through proxy)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle an outbound SIP message from a device that doesn't match any existing call.
|
|
* Creates a passthrough Call if it's an INVITE, otherwise forwards raw.
|
|
*/
|
|
handlePassthroughOutbound(msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): boolean {
|
|
if (msg.method !== 'INVITE') return false;
|
|
|
|
const callId = `call-${Date.now()}-${(this.nextCallNum++).toString(36)}`;
|
|
const call = new Call({
|
|
id: callId,
|
|
direction: 'outbound',
|
|
portPool: this.portPool,
|
|
log: this.config.log,
|
|
onChange: (c) => this.handleCallChange(c),
|
|
});
|
|
call.providerUsed = provider.displayName;
|
|
|
|
// Extract callee from Request-URI.
|
|
const ruri = msg.requestUri || '';
|
|
call.calleeNumber = ruri;
|
|
|
|
this.calls.set(callId, call);
|
|
|
|
// Allocate RTP.
|
|
const rtpAlloc = this.portPool.allocate();
|
|
if (!rtpAlloc) {
|
|
this.config.log('[call-mgr] passthrough: port pool exhausted');
|
|
this.calls.delete(callId);
|
|
return false;
|
|
}
|
|
|
|
// Create a passthrough "call" using the original SIP Call-ID for indexing.
|
|
// Both the device and provider sides share the same SIP Call-ID.
|
|
this.sipCallIdIndex.set(msg.callId, call);
|
|
|
|
// Create device leg (tracks the device side).
|
|
const devLegConfig: ISipLegConfig = {
|
|
role: 'device',
|
|
lanIp: this.config.appConfig.proxy.lanIp,
|
|
lanPort: this.config.appConfig.proxy.lanPort,
|
|
getPublicIp: () => ps.publicIp,
|
|
sendSip: this.config.sendSip,
|
|
log: this.config.log,
|
|
sipTarget: { address: rinfo.address, port: rinfo.port },
|
|
rtpPort: rtpAlloc.port,
|
|
rtpSock: rtpAlloc.sock,
|
|
};
|
|
|
|
const devLeg = new SipLeg(`${callId}-dev`, devLegConfig);
|
|
devLeg.acceptIncoming(msg);
|
|
devLeg.onTerminated = (leg) => call.handleLegTerminated(leg.id);
|
|
call.addLeg(devLeg);
|
|
|
|
// Now forward the INVITE to the provider with SDP rewriting.
|
|
const pub = ps.publicIp || this.config.appConfig.proxy.lanIp;
|
|
|
|
if (msg.isDialogEstablishing) {
|
|
msg.prependHeader('Record-Route', `<sip:${this.config.appConfig.proxy.lanIp}:${this.config.appConfig.proxy.lanPort};lr>`);
|
|
}
|
|
|
|
// Rewrite Contact.
|
|
const contact = msg.getHeader('Contact');
|
|
if (contact) {
|
|
const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort);
|
|
if (nc !== contact) msg.setHeader('Contact', nc);
|
|
}
|
|
|
|
// Rewrite SDP.
|
|
if (msg.hasSdpBody) {
|
|
const { body, original } = rewriteSdp(msg.body, pub, rtpAlloc.port);
|
|
msg.body = body;
|
|
msg.updateContentLength();
|
|
if (original) {
|
|
devLeg.remoteMedia = original;
|
|
}
|
|
}
|
|
|
|
this.config.sendSip(msg.serialize(), provider.outboundProxy);
|
|
call.state = 'ringing';
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle an inbound SIP message from a provider for a passthrough call.
|
|
* Rewrites and forwards to the device.
|
|
*/
|
|
handlePassthroughInbound(call: Call, msg: SipMessage, rinfo: IEndpoint, ps: ProviderState): void {
|
|
const deviceLeg = call.getLegByType('sip-device') as SipLeg | null;
|
|
if (!deviceLeg) return;
|
|
|
|
const lanIp = this.config.appConfig.proxy.lanIp;
|
|
const lanPort = this.config.appConfig.proxy.lanPort;
|
|
|
|
// For responses, learn provider media from SDP.
|
|
if (msg.isResponse && msg.hasSdpBody && deviceLeg.rtpPort) {
|
|
const { body, original } = rewriteSdp(msg.body, lanIp, deviceLeg.rtpPort);
|
|
msg.body = body;
|
|
msg.updateContentLength();
|
|
if (original) {
|
|
// Provider's media endpoint — set on the provider side.
|
|
const provLeg = call.getLegByType('sip-provider') as SipLeg | null;
|
|
if (provLeg) provLeg.remoteMedia = original;
|
|
// For passthrough, the RTP relay needs both endpoints.
|
|
// Since we use a single RTP socket, we store provider media
|
|
// so the relay can forward.
|
|
}
|
|
}
|
|
|
|
// For requests (like BYE), detect call termination.
|
|
if (msg.isRequest) {
|
|
if (msg.isDialogEstablishing) {
|
|
msg.prependHeader('Record-Route', `<sip:${lanIp}:${lanPort};lr>`);
|
|
}
|
|
if (msg.method === 'BYE') {
|
|
// Forward BYE to device, then clean up call.
|
|
this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget);
|
|
call.hangup();
|
|
return;
|
|
}
|
|
// Rewrite Request-URI.
|
|
msg.setRequestUri(rewriteSipUri(msg.requestUri!, deviceLeg.config.sipTarget.address, deviceLeg.config.sipTarget.port));
|
|
}
|
|
|
|
// Start silence if this is a 200 OK to INVITE.
|
|
if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') {
|
|
// Silence and NAT priming happen in the SipLeg itself via quirks.
|
|
// For passthrough, we manually trigger it if provider has earlyMediaSilence.
|
|
const provider = ps.config;
|
|
if (provider.quirks.earlyMediaSilence && deviceLeg.rtpSock) {
|
|
const provMedia = parseSdpEndpoint(msg.body);
|
|
if (provMedia) {
|
|
this.startPassthroughSilence(deviceLeg, provMedia, provider);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.config.sendSip(msg.serialize(), deviceLeg.config.sipTarget);
|
|
}
|
|
|
|
/**
|
|
* Handle an outbound SIP response from device for a passthrough call.
|
|
*/
|
|
handlePassthroughOutboundResponse(call: Call, msg: SipMessage, rinfo: IEndpoint, provider: IProviderConfig, ps: ProviderState): void {
|
|
const pub = ps.publicIp || this.config.appConfig.proxy.lanIp;
|
|
const devLeg = call.getLegByType('sip-device') as SipLeg | null;
|
|
|
|
// Rewrite Contact.
|
|
const contact = msg.getHeader('Contact');
|
|
if (contact) {
|
|
const nc = rewriteSipUri(contact, pub, this.config.appConfig.proxy.lanPort);
|
|
if (nc !== contact) msg.setHeader('Contact', nc);
|
|
}
|
|
|
|
// Rewrite SDP (responses going to provider).
|
|
if (msg.hasSdpBody && devLeg) {
|
|
const { body, original } = rewriteSdp(msg.body, pub, devLeg.rtpPort);
|
|
msg.body = body;
|
|
msg.updateContentLength();
|
|
if (original) {
|
|
devLeg.remoteMedia = original;
|
|
}
|
|
}
|
|
|
|
// Detect BYE from device.
|
|
if (msg.isRequest && msg.method === 'BYE') {
|
|
this.config.sendSip(msg.serialize(), provider.outboundProxy);
|
|
call.hangup();
|
|
return;
|
|
}
|
|
|
|
// Start silence on 200 OK to INVITE.
|
|
if (msg.isResponse && msg.statusCode === 200 && msg.cseqMethod?.toUpperCase() === 'INVITE') {
|
|
if (provider.quirks.earlyMediaSilence && devLeg) {
|
|
const provMedia = devLeg.remoteMedia; // provider endpoint from SDP
|
|
// Silence will be started by the provider leg or passthrough helper.
|
|
}
|
|
}
|
|
|
|
this.config.sendSip(msg.serialize(), provider.outboundProxy);
|
|
}
|
|
|
|
private startPassthroughSilence(devLeg: SipLeg, provMedia: IEndpoint, provider: IProviderConfig): void {
|
|
const PT = provider.quirks.silencePayloadType ?? 9;
|
|
const MAX = provider.quirks.silenceMaxPackets ?? 250;
|
|
const PAYLOAD = 160;
|
|
let seq = Math.floor(Math.random() * 0xffff);
|
|
let rtpTs = Math.floor(Math.random() * 0xffffffff);
|
|
const ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
let count = 0;
|
|
|
|
const timer = setInterval(() => {
|
|
if (devLeg.pktReceived > 0 || devLeg.pktSent > 0 || count >= MAX) {
|
|
clearInterval(timer);
|
|
return;
|
|
}
|
|
const pkt = Buffer.alloc(12 + PAYLOAD);
|
|
pkt[0] = 0x80;
|
|
pkt[1] = PT;
|
|
pkt.writeUInt16BE(seq & 0xffff, 2);
|
|
pkt.writeUInt32BE(rtpTs >>> 0, 4);
|
|
pkt.writeUInt32BE(ssrc >>> 0, 8);
|
|
devLeg.rtpSock.send(pkt, provMedia.port, provMedia.address);
|
|
seq++;
|
|
rtpTs += PAYLOAD;
|
|
count++;
|
|
}, 20);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Hangup
|
|
// -------------------------------------------------------------------------
|
|
|
|
hangup(callId: string): boolean {
|
|
const call = this.calls.get(callId);
|
|
if (!call) return false;
|
|
call.hangup();
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Dynamic leg management
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Add a new SIP device leg to an existing call.
|
|
* This enables adding participants to a call dynamically.
|
|
*/
|
|
addDeviceToCall(callId: string, deviceId: string): boolean {
|
|
const call = this.calls.get(callId);
|
|
if (!call) return false;
|
|
|
|
const deviceTarget = this.resolveDeviceTarget(deviceId);
|
|
if (!deviceTarget) return false;
|
|
|
|
const rtpAlloc = this.portPool.allocate();
|
|
if (!rtpAlloc) return false;
|
|
|
|
// Find a provider state for SDP building.
|
|
const providerLeg = call.getLegByType('sip-provider') as SipLeg | null;
|
|
const provider = providerLeg?.config.provider;
|
|
|
|
const sipLegConfig: ISipLegConfig = {
|
|
role: 'device',
|
|
lanIp: this.config.appConfig.proxy.lanIp,
|
|
lanPort: this.config.appConfig.proxy.lanPort,
|
|
getPublicIp: () => null,
|
|
sendSip: this.config.sendSip,
|
|
log: this.config.log,
|
|
sipTarget: deviceTarget,
|
|
rtpPort: rtpAlloc.port,
|
|
rtpSock: rtpAlloc.sock,
|
|
};
|
|
|
|
const legId = `${callId}-dev${call.legCount}`;
|
|
const newLeg = new SipLeg(legId, sipLegConfig);
|
|
newLeg.onConnected = () => call.notifyLegStateChange(newLeg);
|
|
newLeg.onTerminated = (leg) => call.removeLeg(leg.id);
|
|
newLeg.onStateChange = () => call.notifyLegStateChange(newLeg);
|
|
|
|
call.addLeg(newLeg);
|
|
|
|
const sipCallId = `${callId}-${legId}`;
|
|
newLeg.sendInvite({
|
|
fromUri: `sip:${call.calleeNumber || 'conf'}@${this.config.appConfig.proxy.lanIp}`,
|
|
fromDisplayName: `Conf: ${call.calleeNumber || 'conference'}`,
|
|
toUri: `sip:user@${deviceTarget.address}`,
|
|
callId: sipCallId,
|
|
});
|
|
this.sipCallIdIndex.set(sipCallId, call);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Dial out via a provider and add the answered leg to an existing call.
|
|
* This enables adding external participants by phone number.
|
|
*/
|
|
addExternalToCall(callId: string, number: string, providerId?: string): boolean {
|
|
const call = this.calls.get(callId);
|
|
if (!call) return false;
|
|
|
|
// Resolve provider.
|
|
const provider = providerId
|
|
? getProvider(this.config.appConfig, providerId)
|
|
: getProviderForOutbound(this.config.appConfig);
|
|
if (!provider) {
|
|
this.config.log(`[call-mgr] addExternalToCall: no provider`);
|
|
return false;
|
|
}
|
|
|
|
const ps = this.config.getProviderState(provider.id);
|
|
if (!ps?.registeredAor) {
|
|
this.config.log(`[call-mgr] addExternalToCall: provider ${provider.id} not registered`);
|
|
return false;
|
|
}
|
|
|
|
const rtpAlloc = this.portPool.allocate();
|
|
if (!rtpAlloc) return false;
|
|
|
|
const sipLegConfig: ISipLegConfig = {
|
|
role: 'provider',
|
|
lanIp: this.config.appConfig.proxy.lanIp,
|
|
lanPort: this.config.appConfig.proxy.lanPort,
|
|
getPublicIp: () => ps.publicIp,
|
|
sendSip: this.config.sendSip,
|
|
log: this.config.log,
|
|
provider,
|
|
sipTarget: provider.outboundProxy,
|
|
rtpPort: rtpAlloc.port,
|
|
rtpSock: rtpAlloc.sock,
|
|
payloadTypes: provider.codecs,
|
|
getRegisteredAor: () => ps.registeredAor,
|
|
getSipPassword: () => provider.password,
|
|
};
|
|
|
|
const legId = `${callId}-ext${call.legCount}`;
|
|
const newLeg = new SipLeg(legId, sipLegConfig);
|
|
|
|
newLeg.onConnected = (leg) => {
|
|
this.config.log(`[call-mgr] ${callId} external ${number} answered`);
|
|
call.notifyLegStateChange(leg);
|
|
};
|
|
newLeg.onTerminated = (leg) => call.removeLeg(leg.id);
|
|
newLeg.onStateChange = () => call.notifyLegStateChange(newLeg);
|
|
|
|
call.addLeg(newLeg);
|
|
|
|
const sipCallId = `${callId}-${legId}`;
|
|
const destUri = `sip:${number}@${provider.domain}`;
|
|
newLeg.sendInvite({
|
|
fromUri: ps.registeredAor,
|
|
toUri: destUri,
|
|
callId: sipCallId,
|
|
});
|
|
this.sipCallIdIndex.set(sipCallId, call);
|
|
|
|
this.config.log(`[call-mgr] ${callId} dialing external ${number} via ${provider.displayName}`);
|
|
return true;
|
|
}
|
|
|
|
/** Remove a leg from a call by ID. */
|
|
removeLegFromCall(callId: string, legId: string): boolean {
|
|
const call = this.calls.get(callId);
|
|
if (!call) return false;
|
|
|
|
const leg = call.getLeg(legId);
|
|
if (!leg) return false;
|
|
|
|
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
|
|
(leg as SipLeg).sendHangup();
|
|
}
|
|
call.removeLeg(legId);
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Transfer
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Transfer a leg from one call to another.
|
|
* Detaches the leg from sourceCall and adds it to targetCall.
|
|
*/
|
|
transferLeg(sourceCallId: string, legId: string, targetCallId: string): boolean {
|
|
const sourceCall = this.calls.get(sourceCallId);
|
|
const targetCall = this.calls.get(targetCallId);
|
|
if (!sourceCall || !targetCall) return false;
|
|
|
|
const leg = sourceCall.detachLeg(legId);
|
|
if (!leg) return false;
|
|
|
|
targetCall.addLeg(leg);
|
|
this.config.log(`[call-mgr] transferred leg ${legId} from ${sourceCallId} to ${targetCallId}`);
|
|
|
|
// Clean up source call if empty.
|
|
if (sourceCall.legCount === 0) {
|
|
sourceCall.hangup();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// WebRTC signaling integration
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Handle a WebRTC offer from a browser.
|
|
* Creates a standalone WebRtcLeg (not yet attached to a call).
|
|
* The leg will be linked to a call when webrtc-accept arrives.
|
|
*/
|
|
async handleWebRtcOffer(sessionId: string, offerSdp: string, ws: WebSocket): Promise<boolean> {
|
|
// Check if there's already a WebRtcLeg for this session (in a call).
|
|
for (const call of this.calls.values()) {
|
|
for (const leg of call.getLegs()) {
|
|
if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) {
|
|
await (leg as WebRtcLeg).handleOffer(offerSdp);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a standalone WebRtcLeg (will be linked to a call on webrtc-accept).
|
|
const rtpAlloc = this.portPool.allocate();
|
|
if (!rtpAlloc) {
|
|
this.config.log(`[call-mgr] webrtc-offer: port pool exhausted`);
|
|
return false;
|
|
}
|
|
|
|
const webrtcLeg = new WebRtcLeg(`webrtc-${sessionId.slice(0, 8)}`, {
|
|
ws,
|
|
sessionId,
|
|
rtpPort: rtpAlloc.port,
|
|
rtpSock: rtpAlloc.sock,
|
|
log: this.config.log,
|
|
});
|
|
|
|
this.standaloneWebRtcLegs.set(sessionId, webrtcLeg);
|
|
await webrtcLeg.handleOffer(offerSdp);
|
|
|
|
this.config.log(`[call-mgr] standalone WebRtcLeg created for session ${sessionId.slice(0, 8)}`);
|
|
return true;
|
|
}
|
|
|
|
/** Route an ICE candidate to the correct WebRtcLeg (in a call or standalone). */
|
|
async handleWebRtcIce(sessionId: string, candidate: any): Promise<boolean> {
|
|
// Check standalone legs first (most common during setup).
|
|
const standalone = this.standaloneWebRtcLegs.get(sessionId);
|
|
if (standalone) {
|
|
await standalone.addIceCandidate(candidate);
|
|
return true;
|
|
}
|
|
|
|
// Check legs in active calls.
|
|
for (const call of this.calls.values()) {
|
|
for (const leg of call.getLegs()) {
|
|
if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) {
|
|
await (leg as WebRtcLeg).addIceCandidate(candidate);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Handle WebRTC hangup. */
|
|
handleWebRtcHangup(sessionId: string): void {
|
|
// Check standalone legs.
|
|
const standalone = this.standaloneWebRtcLegs.get(sessionId);
|
|
if (standalone) {
|
|
standalone.teardown();
|
|
if (standalone.rtpPort) this.portPool.release(standalone.rtpPort);
|
|
this.standaloneWebRtcLegs.delete(sessionId);
|
|
return;
|
|
}
|
|
|
|
// Check legs in active calls.
|
|
for (const call of this.calls.values()) {
|
|
for (const leg of call.getLegs()) {
|
|
if (leg.type === 'webrtc' && (leg as WebRtcLeg).sessionId === sessionId) {
|
|
call.removeLeg(leg.id);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Status
|
|
// -------------------------------------------------------------------------
|
|
|
|
getStatus(): ICallStatus[] {
|
|
const result: ICallStatus[] = [];
|
|
for (const call of this.calls.values()) {
|
|
if (call.state !== 'terminated') {
|
|
result.push(call.getStatus());
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
getHistory(): ICallHistoryEntry[] {
|
|
return this.callHistory;
|
|
}
|
|
|
|
getCall(callId: string): Call | null {
|
|
return this.calls.get(callId) ?? null;
|
|
}
|
|
|
|
getAllCalls(): Call[] {
|
|
return [...this.calls.values()];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private handleCallChange(call: Call): void {
|
|
this.config.broadcastWs('call-update', call.getStatus());
|
|
|
|
// Clean up terminated calls after a delay.
|
|
if (call.state === 'terminated') {
|
|
// Record in call history.
|
|
const status = call.getStatus();
|
|
this.callHistory.unshift({
|
|
id: status.id,
|
|
direction: status.direction,
|
|
callerNumber: status.callerNumber,
|
|
calleeNumber: status.calleeNumber,
|
|
providerUsed: status.providerUsed,
|
|
startedAt: status.createdAt,
|
|
duration: status.duration,
|
|
});
|
|
if (this.callHistory.length > CallManager.MAX_HISTORY) {
|
|
this.callHistory.length = CallManager.MAX_HISTORY;
|
|
}
|
|
|
|
// Remove SIP Call-ID index entries.
|
|
for (const [sipCallId, c] of this.sipCallIdIndex) {
|
|
if (c === call) this.sipCallIdIndex.delete(sipCallId);
|
|
}
|
|
// Remove from calls map after delay (so UI can show "terminated").
|
|
setTimeout(() => this.calls.delete(call.id), 5000);
|
|
}
|
|
}
|
|
|
|
private resolveDeviceTarget(deviceId?: string): IEndpoint | null {
|
|
if (!deviceId) {
|
|
// Default to first configured device.
|
|
const d = this.config.appConfig.devices[0];
|
|
if (!d) return null;
|
|
const reg = getRegisteredDevice(d.id);
|
|
return reg?.contact || { address: d.expectedAddress, port: 5060 };
|
|
}
|
|
const reg = getRegisteredDevice(deviceId);
|
|
if (reg?.contact) return reg.contact;
|
|
const dc = this.config.appConfig.devices.find((d) => d.id === deviceId);
|
|
if (dc) return { address: dc.expectedAddress, port: 5060 };
|
|
return null;
|
|
}
|
|
|
|
private resolveFirstDevice(deviceIds: string[]): IEndpoint | null {
|
|
for (const id of deviceIds) {
|
|
const result = this.resolveDeviceTarget(id);
|
|
if (result) return result;
|
|
}
|
|
// Fallback to first configured device.
|
|
return this.resolveDeviceTarget();
|
|
}
|
|
}
|