medut 1.0.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- medut/__init__.py +1346 -0
- medut/_loader.py +208 -0
- medut/_medut_clean.h +841 -0
- medut/events.py +531 -0
- medut/medut.dll +0 -0
- medut-1.0.0.dist-info/METADATA +192 -0
- medut-1.0.0.dist-info/RECORD +10 -0
- medut-1.0.0.dist-info/WHEEL +5 -0
- medut-1.0.0.dist-info/licenses/LICENSE +29 -0
- medut-1.0.0.dist-info/top_level.txt +1 -0
medut/__init__.py
ADDED
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Medut — simple, flat Python SDK for SIP/RTP.
|
|
3
|
+
|
|
4
|
+
import medut as sdk
|
|
5
|
+
|
|
6
|
+
sdk.configure(transport="wss", media_enc="dtls_srtp")
|
|
7
|
+
|
|
8
|
+
alice = sdk.create_account("alice@pbx.example.com", "secret")
|
|
9
|
+
alice.register()
|
|
10
|
+
|
|
11
|
+
@sdk.on("registered")
|
|
12
|
+
def _(ev):
|
|
13
|
+
sdk.call("120")
|
|
14
|
+
|
|
15
|
+
@sdk.on("incoming_call")
|
|
16
|
+
def _(ev):
|
|
17
|
+
ev.call.answer()
|
|
18
|
+
|
|
19
|
+
@sdk.on("ended")
|
|
20
|
+
def _(ev):
|
|
21
|
+
sdk.stop()
|
|
22
|
+
|
|
23
|
+
sdk.run()
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import queue
|
|
28
|
+
import signal
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
from typing import Callable, Optional
|
|
32
|
+
|
|
33
|
+
from ._loader import ffi, lib
|
|
34
|
+
from .events import (
|
|
35
|
+
IceStateEvent,
|
|
36
|
+
RegStateEvent, IncomingCallEvent, CallStateEvent, CallDtmfEvent,
|
|
37
|
+
SdpNegotiationEvent, SipTraceEvent, MediaStatsEvent, LogEvent,
|
|
38
|
+
RegistrarWarningEvent, TransferRequestEvent, TransferFailedEvent, MwiEvent,
|
|
39
|
+
MessageEvent, PresenceStateEvent, QualityAlertEvent, NetworkEvent,
|
|
40
|
+
CallStats,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_log = logging.getLogger("medut")
|
|
44
|
+
|
|
45
|
+
# ── Push provider constants (no string equivalent in the C API) ───────────────
|
|
46
|
+
PUSH_PROVIDER_NONE = 0
|
|
47
|
+
PUSH_PROVIDER_APNS = 1
|
|
48
|
+
PUSH_PROVIDER_APNS_SANDBOX = 2
|
|
49
|
+
PUSH_PROVIDER_FCM = 3
|
|
50
|
+
|
|
51
|
+
# ── Valid names for @sdk.on() ─────────────────────────────────────────────────
|
|
52
|
+
_VALID_NAMES = frozenset({
|
|
53
|
+
# umbrella
|
|
54
|
+
"reg_state", "call_state",
|
|
55
|
+
# reg sub-states
|
|
56
|
+
"registering", "registered", "unregistered", "reg_failed", "reconnecting",
|
|
57
|
+
# call sub-states
|
|
58
|
+
"calling", "ringing", "established", "held", "ended", "cancelled", "call_failed",
|
|
59
|
+
# direct events
|
|
60
|
+
"incoming_call", "dtmf", "sdp_negotiation", "sip_trace", "media_stats",
|
|
61
|
+
"log", "registrar_warning", "transfer_request", "transfer_failed",
|
|
62
|
+
"mwi", "message",
|
|
63
|
+
"presence_state", "quality_alert", "network",
|
|
64
|
+
# wildcard
|
|
65
|
+
"*",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# ── String → int maps ─────────────────────────────────────────────────────────
|
|
69
|
+
_STR_TRANSPORT = {"udp": 0, "tcp": 1, "tls": 2, "ws": 3, "wss": 4}
|
|
70
|
+
_STR_MEDIA_ENC = {"none": 0, "sdes": 1, "dtls_srtp": 2}
|
|
71
|
+
_STR_REL100 = {"disabled": 0, "enabled": 1, "required": 2}
|
|
72
|
+
_STR_DTMF = {"rfc4733": 0, "sip_info": 1, "auto": 2}
|
|
73
|
+
|
|
74
|
+
# ── Module-level state ────────────────────────────────────────────────────────
|
|
75
|
+
_config: dict = {}
|
|
76
|
+
_config_locked = False
|
|
77
|
+
_init_done = False
|
|
78
|
+
_init_lock = threading.Lock()
|
|
79
|
+
_handlers: dict = {} # name → list[Callable]
|
|
80
|
+
_accounts: list = []
|
|
81
|
+
_accounts_by_handle: dict = {} # int(cdata handle) → Account
|
|
82
|
+
_accounts_lock = threading.Lock()
|
|
83
|
+
_event_q = queue.SimpleQueue()
|
|
84
|
+
_dispatcher: Optional[threading.Thread] = None
|
|
85
|
+
_stop_evt = threading.Event()
|
|
86
|
+
_cdata_keepalive: list = [] # keep C char[] / struct refs alive
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def _s(cptr) -> Optional[str]:
|
|
92
|
+
if cptr == ffi.NULL:
|
|
93
|
+
return None
|
|
94
|
+
return ffi.string(cptr).decode("utf-8", errors="replace")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _str_array(arr) -> list:
|
|
98
|
+
result, i = [], 0
|
|
99
|
+
while arr[i] != ffi.NULL:
|
|
100
|
+
result.append(_s(arr[i]))
|
|
101
|
+
i += 1
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _check(rc, what):
|
|
106
|
+
if rc != 0:
|
|
107
|
+
raise RuntimeError(f"Medut.{what} failed (code {rc}): {strerror(rc)}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ensure_init():
|
|
111
|
+
global _init_done, _config_locked
|
|
112
|
+
if _init_done:
|
|
113
|
+
return
|
|
114
|
+
with _init_lock:
|
|
115
|
+
if _init_done:
|
|
116
|
+
return
|
|
117
|
+
_config_locked = True
|
|
118
|
+
try:
|
|
119
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
120
|
+
except AttributeError:
|
|
121
|
+
pass
|
|
122
|
+
cfg = ffi.new("medut_config_t *")
|
|
123
|
+
lib.medut_config_init(cfg)
|
|
124
|
+
# configure() documents the enum options in their string form, and
|
|
125
|
+
# create_account() already translates the global "transport" that way.
|
|
126
|
+
# Without the same translation here the string reaches setattr() as a
|
|
127
|
+
# char[] destined for an int field, which cffi rejects outright — so
|
|
128
|
+
# configure(transport="udp") raised a TypeError from inside
|
|
129
|
+
# _ensure_init() rather than doing what it says.
|
|
130
|
+
_config_enums = {"transport": _STR_TRANSPORT, "media_enc": _STR_MEDIA_ENC}
|
|
131
|
+
for key, table in _config_enums.items():
|
|
132
|
+
if isinstance(_config.get(key), str):
|
|
133
|
+
_config[key] = table[_config[key]]
|
|
134
|
+
for key, val in _config.items():
|
|
135
|
+
if isinstance(val, str):
|
|
136
|
+
ref = ffi.new("char[]", val.encode())
|
|
137
|
+
_cdata_keepalive.append(ref)
|
|
138
|
+
setattr(cfg, key, ref)
|
|
139
|
+
elif isinstance(val, bool):
|
|
140
|
+
setattr(cfg, key, int(val))
|
|
141
|
+
else:
|
|
142
|
+
setattr(cfg, key, val)
|
|
143
|
+
cfg.event_cb = _global_event_cb
|
|
144
|
+
cfg.event_userdata = ffi.NULL
|
|
145
|
+
_cdata_keepalive.append(cfg) # C may hold pointers into this struct
|
|
146
|
+
_check(lib.medut_init(cfg), "init")
|
|
147
|
+
_init_done = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Indexed by medut_ice_state_t / medut_ice_cand_type_t.
|
|
151
|
+
_ICE_STATE = ("new", "gathering", "checking", "connected", "completed",
|
|
152
|
+
"disconnected", "failed", "closed")
|
|
153
|
+
_ICE_CAND = ("host", "srflx", "prflx", "relay", "none")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _raw_stats_to_event(s) -> MediaStatsEvent:
|
|
157
|
+
return MediaStatsEvent(
|
|
158
|
+
call=None,
|
|
159
|
+
packets_sent = s.packets_sent,
|
|
160
|
+
packets_received = s.packets_received,
|
|
161
|
+
packets_lost = s.packets_lost,
|
|
162
|
+
packets_lost_rx = s.packets_lost_rx,
|
|
163
|
+
bytes_sent = s.bytes_sent,
|
|
164
|
+
bytes_received = s.bytes_received,
|
|
165
|
+
tx_errors = s.tx_errors,
|
|
166
|
+
rx_errors = s.rx_errors,
|
|
167
|
+
loss_pct = s.loss_pct,
|
|
168
|
+
loss_pct_rx = s.loss_pct_rx,
|
|
169
|
+
jitter_ms = s.jitter_ms,
|
|
170
|
+
tx_jitter_ms = s.tx_jitter_ms,
|
|
171
|
+
rtt_ms = s.rtt_ms,
|
|
172
|
+
jitter_buffer_ms = s.jitter_buffer_ms,
|
|
173
|
+
jitter_buffer_load = s.jitter_buffer_load,
|
|
174
|
+
late_packets = s.late_packets,
|
|
175
|
+
discarded_packets = s.discarded_packets,
|
|
176
|
+
jitter_buffer_target_ms = s.jitter_buffer_target_ms,
|
|
177
|
+
jitter_buffer_adaptive = bool(s.jitter_buffer_adaptive),
|
|
178
|
+
plc_frames = s.plc_frames,
|
|
179
|
+
plc_ratio = s.plc_ratio,
|
|
180
|
+
bandwidth_kbps_tx = s.bandwidth_kbps_tx,
|
|
181
|
+
bandwidth_kbps_rx = s.bandwidth_kbps_rx,
|
|
182
|
+
avg_bandwidth_kbps_tx = s.avg_bandwidth_kbps_tx,
|
|
183
|
+
avg_bandwidth_kbps_rx = s.avg_bandwidth_kbps_rx,
|
|
184
|
+
mos_lq = s.mos_lq,
|
|
185
|
+
mos_cq = s.mos_cq,
|
|
186
|
+
mos_lq_rx = s.mos_lq_rx,
|
|
187
|
+
mos_cq_rx = s.mos_cq_rx,
|
|
188
|
+
mos_method = s.mos_method,
|
|
189
|
+
codec_name = _s(s.codec_name) or "",
|
|
190
|
+
codec_clock_rate = s.codec_clock_rate,
|
|
191
|
+
codec_sample_rate = s.codec_sample_rate,
|
|
192
|
+
codec_channels = s.codec_channels,
|
|
193
|
+
payload_type = s.payload_type,
|
|
194
|
+
audio_level_dbov = s.audio_level_dbov,
|
|
195
|
+
mic_level_dbov = s.mic_level_dbov,
|
|
196
|
+
ssrc_tx = s.ssrc_tx,
|
|
197
|
+
ssrc_rx = s.ssrc_rx,
|
|
198
|
+
remote_addr = ffi.string(s.remote_addr).decode("utf-8", errors="replace"),
|
|
199
|
+
mos_lq_min = s.mos_lq_min,
|
|
200
|
+
mos_lq_avg = s.mos_lq_avg,
|
|
201
|
+
stats_tick = s.stats_tick,
|
|
202
|
+
call_duration_ms = s.call_duration_ms,
|
|
203
|
+
is_final = bool(s.is_final),
|
|
204
|
+
ice_state = _ICE_STATE[s.ice_state] if 0 <= s.ice_state < len(_ICE_STATE) else "new",
|
|
205
|
+
ice_local_type = _ICE_CAND[s.ice_local_type] if 0 <= s.ice_local_type < len(_ICE_CAND) else "none",
|
|
206
|
+
ice_remote_type = _ICE_CAND[s.ice_remote_type] if 0 <= s.ice_remote_type < len(_ICE_CAND) else "none",
|
|
207
|
+
ice_rtt_ms = s.ice_rtt_ms,
|
|
208
|
+
ice_generation = s.ice_generation,
|
|
209
|
+
ice_local_addr = _s(s.ice_local_addr) or "",
|
|
210
|
+
ice_remote_addr = _s(s.ice_remote_addr) or "",
|
|
211
|
+
fec_active = bool(s.fec_active),
|
|
212
|
+
fec_level_pct = s.fec_level_pct,
|
|
213
|
+
fec_recovered = s.fec_recovered,
|
|
214
|
+
plc_concealed = s.plc_concealed,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _list_audio_devices(fn) -> list:
|
|
219
|
+
buf = ffi.new("medut_audio_device_t[32]")
|
|
220
|
+
n = fn(buf, 32)
|
|
221
|
+
out = []
|
|
222
|
+
for i in range(max(n, 0)):
|
|
223
|
+
out.append({
|
|
224
|
+
"name": ffi.string(buf[i].name).decode("utf-8", errors="replace"),
|
|
225
|
+
"description": ffi.string(buf[i].description).decode("utf-8", errors="replace"),
|
|
226
|
+
"is_default": bool(buf[i].is_default),
|
|
227
|
+
})
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── C event callback → global queue ──────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
@ffi.callback("void(const medut_event_t *, void *)")
|
|
234
|
+
def _global_event_cb(ev_ptr, _userdata):
|
|
235
|
+
ev = ev_ptr[0]
|
|
236
|
+
typ = ev.type
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Index by the C enum value, so "reconnecting" sits at 5 where the
|
|
240
|
+
# header appends it — not next to "failed", where it reads better.
|
|
241
|
+
_REG_STATES = ("unregistered", "registering", "registered",
|
|
242
|
+
"failed", "unregistering", "reconnecting")
|
|
243
|
+
_CALL_STATES = ("calling", "ringing", "established", "held",
|
|
244
|
+
"ended", "cancelled", "failed")
|
|
245
|
+
_PRESENCE = ("unknown", "open", "closed", "busy")
|
|
246
|
+
_QUALITY = ("mos", "loss", "jitter", "rtt", "media_stall")
|
|
247
|
+
_NET_STAGE = ("change_detected", "down", "up", "transport_reset",
|
|
248
|
+
"reregistering", "call_migrating",
|
|
249
|
+
"call_migrate_accepted", "call_migrated",
|
|
250
|
+
"call_migration_failed", "call_deferred",
|
|
251
|
+
"handover_failed", "call_ice_stale",
|
|
252
|
+
"call_ice_failed")
|
|
253
|
+
|
|
254
|
+
acct_handle = ffi.NULL
|
|
255
|
+
call_handle = ffi.NULL
|
|
256
|
+
|
|
257
|
+
if typ == 0: # LOG
|
|
258
|
+
obj = LogEvent(message=_s(ev.u.log.message) or "")
|
|
259
|
+
|
|
260
|
+
elif typ == 1: # REG_STATE
|
|
261
|
+
acct_handle = ev.u.reg.account
|
|
262
|
+
obj = RegStateEvent(
|
|
263
|
+
state = _REG_STATES[ev.u.reg.state],
|
|
264
|
+
error = ev.u.reg.error,
|
|
265
|
+
error_str = _s(ev.u.reg.error_str),
|
|
266
|
+
retry_attempt = ev.u.reg.retry_attempt,
|
|
267
|
+
retry_delay_ms= ev.u.reg.retry_delay_ms,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
elif typ == 2: # INCOMING_CALL
|
|
271
|
+
acct_handle = ev.u.incoming.account
|
|
272
|
+
call_handle = ev.u.incoming.call
|
|
273
|
+
obj = IncomingCallEvent(
|
|
274
|
+
call=None,
|
|
275
|
+
from_uri = _s(ev.u.incoming.from_uri) or "",
|
|
276
|
+
display_name= _s(ev.u.incoming.display_name),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
elif typ == 3: # CALL_STATE
|
|
280
|
+
acct_handle = ev.u.call_state.account
|
|
281
|
+
call_handle = ev.u.call_state.call
|
|
282
|
+
obj = CallStateEvent(
|
|
283
|
+
call=None,
|
|
284
|
+
state = _CALL_STATES[ev.u.call_state.state],
|
|
285
|
+
error = ev.u.call_state.error,
|
|
286
|
+
reason = _s(ev.u.call_state.reason),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
elif typ == 4: # DTMF
|
|
290
|
+
call_handle = ev.u.dtmf.call
|
|
291
|
+
obj = CallDtmfEvent(call=None, digit=chr(ev.u.dtmf.digit))
|
|
292
|
+
|
|
293
|
+
elif typ == 5: # SDP_NEGOTIATION
|
|
294
|
+
call_handle = ev.u.sdp.call
|
|
295
|
+
obj = SdpNegotiationEvent(
|
|
296
|
+
call=None,
|
|
297
|
+
local_sdp = _s(ev.u.sdp.local_sdp) or "",
|
|
298
|
+
remote_sdp = _s(ev.u.sdp.remote_sdp) or "",
|
|
299
|
+
negotiated_codec = _s(ev.u.sdp.negotiated_codec),
|
|
300
|
+
negotiated_crypto = _s(ev.u.sdp.negotiated_crypto),
|
|
301
|
+
rejected_codecs = _str_array(ev.u.sdp.rejected_codecs),
|
|
302
|
+
warnings = _str_array(ev.u.sdp.warnings),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
elif typ == 6: # SIP_TRACE
|
|
306
|
+
obj = SipTraceEvent(
|
|
307
|
+
direction = "tx" if ev.u.sip_trace.dir == 1 else "rx",
|
|
308
|
+
transport = _s(ev.u.sip_trace.transport) or "",
|
|
309
|
+
remote_addr= _s(ev.u.sip_trace.remote_addr) or "",
|
|
310
|
+
raw_message= _s(ev.u.sip_trace.raw_message) or "",
|
|
311
|
+
timestamp_us=ev.u.sip_trace.timestamp_us,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
elif typ == 7: # MEDIA_STATS
|
|
315
|
+
call_handle = ev.u.stats.call
|
|
316
|
+
obj = _raw_stats_to_event(ev.u.stats)
|
|
317
|
+
|
|
318
|
+
elif typ == 8: # REGISTRAR_WARNING
|
|
319
|
+
obj = RegistrarWarningEvent(message=_s(ev.u.reg_warn.message) or "")
|
|
320
|
+
|
|
321
|
+
elif typ == 9: # TRANSFER_REQUEST
|
|
322
|
+
acct_handle = ev.u.transfer_req.account
|
|
323
|
+
call_handle = ev.u.transfer_req.call
|
|
324
|
+
obj = TransferRequestEvent(
|
|
325
|
+
call=None,
|
|
326
|
+
refer_to_uri = _s(ev.u.transfer_req.refer_to_uri) or "",
|
|
327
|
+
has_replaces = bool(ev.u.transfer_req.has_replaces),
|
|
328
|
+
auto_followed = bool(ev.u.transfer_req.auto_followed),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
elif typ == 10: # MWI
|
|
332
|
+
acct_handle = ev.u.mwi.account
|
|
333
|
+
obj = MwiEvent(
|
|
334
|
+
messages_waiting=bool(ev.u.mwi.messages_waiting),
|
|
335
|
+
new_voice = ev.u.mwi.new_voice,
|
|
336
|
+
old_voice = ev.u.mwi.old_voice,
|
|
337
|
+
new_urgent = ev.u.mwi.new_urgent,
|
|
338
|
+
old_urgent = ev.u.mwi.old_urgent,
|
|
339
|
+
raw_body = _s(ev.u.mwi.raw_body),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
elif typ == 11: # MESSAGE
|
|
343
|
+
acct_handle = ev.u.msg.account
|
|
344
|
+
obj = MessageEvent(
|
|
345
|
+
from_uri = _s(ev.u.msg.from_uri) or "",
|
|
346
|
+
body = _s(ev.u.msg.body) or "",
|
|
347
|
+
content_type= _s(ev.u.msg.content_type) or "text/plain",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
elif typ == 12: # PRESENCE_STATE
|
|
351
|
+
acct_handle = ev.u.presence.account
|
|
352
|
+
obj = PresenceStateEvent(
|
|
353
|
+
target_uri = _s(ev.u.presence.target_uri) or "",
|
|
354
|
+
status = _PRESENCE[ev.u.presence.status],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
elif typ == 13: # QUALITY_ALERT
|
|
358
|
+
call_handle = ev.u.quality_alert.call
|
|
359
|
+
q = ev.u.quality_alert
|
|
360
|
+
obj = QualityAlertEvent(
|
|
361
|
+
call = None,
|
|
362
|
+
issue = _QUALITY[q.issue],
|
|
363
|
+
value = q.value,
|
|
364
|
+
threshold = q.threshold,
|
|
365
|
+
recovering = bool(q.recovering),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
elif typ == 14: # NETWORK
|
|
369
|
+
n = ev.u.network
|
|
370
|
+
call_handle = n.call
|
|
371
|
+
acct_handle = n.account
|
|
372
|
+
obj = NetworkEvent(
|
|
373
|
+
stage = _NET_STAGE[n.event],
|
|
374
|
+
call = None,
|
|
375
|
+
local_addr = _s(n.local_addr) or "",
|
|
376
|
+
attempt = n.attempt,
|
|
377
|
+
max_attempts = n.max_attempts,
|
|
378
|
+
elapsed_ms = n.elapsed_ms,
|
|
379
|
+
ice = bool(n.ice),
|
|
380
|
+
error = n.error,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
elif typ == 16: # ICE_STATE
|
|
384
|
+
i = ev.u.ice
|
|
385
|
+
call_handle = i.call
|
|
386
|
+
obj = IceStateEvent(
|
|
387
|
+
state = _ICE_STATE[i.state] if 0 <= i.state < len(_ICE_STATE) else "new",
|
|
388
|
+
prev = _ICE_STATE[i.prev] if 0 <= i.prev < len(_ICE_STATE) else "new",
|
|
389
|
+
call = None,
|
|
390
|
+
generation = i.generation,
|
|
391
|
+
local_type = _ICE_CAND[i.local_type] if 0 <= i.local_type < len(_ICE_CAND) else "none",
|
|
392
|
+
remote_type = _ICE_CAND[i.remote_type] if 0 <= i.remote_type < len(_ICE_CAND) else "none",
|
|
393
|
+
local_addr = _s(i.local_addr) or "",
|
|
394
|
+
remote_addr = _s(i.remote_addr) or "",
|
|
395
|
+
rtt_ms = float(i.rtt_ms),
|
|
396
|
+
error = i.error,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
elif typ == 15: # TRANSFER_FAILED
|
|
400
|
+
acct_handle = ev.u.transfer_failed.account
|
|
401
|
+
call_handle = ev.u.transfer_failed.call
|
|
402
|
+
obj = TransferFailedEvent(
|
|
403
|
+
call = None,
|
|
404
|
+
reason = _s(ev.u.transfer_failed.reason) or "",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
else:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
# Attach Call wrapper
|
|
411
|
+
if call_handle != ffi.NULL and hasattr(obj, "call"):
|
|
412
|
+
obj.call = Call(call_handle)
|
|
413
|
+
|
|
414
|
+
# Attach account reference (dynamic attribute — dataclasses allow it)
|
|
415
|
+
if acct_handle != ffi.NULL:
|
|
416
|
+
acct = _accounts_by_handle.get(int(ffi.cast("uintptr_t", acct_handle)))
|
|
417
|
+
if acct is not None:
|
|
418
|
+
obj.account = acct
|
|
419
|
+
|
|
420
|
+
_event_q.put(obj)
|
|
421
|
+
|
|
422
|
+
except Exception:
|
|
423
|
+
pass # never let Python exceptions escape into C
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ── Event dispatcher ──────────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
def _sub_event_name(obj) -> Optional[str]:
|
|
429
|
+
if isinstance(obj, RegStateEvent):
|
|
430
|
+
return "reg_failed" if obj.state == "failed" else obj.state
|
|
431
|
+
if isinstance(obj, CallStateEvent):
|
|
432
|
+
return "call_failed" if obj.state == "failed" else obj.state
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _dispatch_event(obj):
|
|
437
|
+
base = obj.type
|
|
438
|
+
sub = _sub_event_name(obj)
|
|
439
|
+
fired = set()
|
|
440
|
+
for name in ([base, sub, "*"] if sub else [base, "*"]):
|
|
441
|
+
if name is None:
|
|
442
|
+
continue
|
|
443
|
+
for h in list(_handlers.get(name, ())):
|
|
444
|
+
hid = id(h)
|
|
445
|
+
if hid in fired:
|
|
446
|
+
continue
|
|
447
|
+
fired.add(hid)
|
|
448
|
+
try:
|
|
449
|
+
h(obj)
|
|
450
|
+
except Exception:
|
|
451
|
+
_log.exception("Medut handler %r raised", h)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _dispatcher_loop():
|
|
455
|
+
while not _stop_evt.is_set():
|
|
456
|
+
try:
|
|
457
|
+
obj = _event_q.get(timeout=0.1)
|
|
458
|
+
except queue.Empty:
|
|
459
|
+
continue
|
|
460
|
+
if obj is None:
|
|
461
|
+
break
|
|
462
|
+
_dispatch_event(obj)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
def configure(**kwargs):
|
|
468
|
+
"""Set global SDK options. Must be called before the first create_account().
|
|
469
|
+
|
|
470
|
+
Common options:
|
|
471
|
+
log_level — int (0=error, 1=warning (default), 2=info, 3=debug)
|
|
472
|
+
stats_interval_ms — how often media_stats events fire (default 2000;
|
|
473
|
+
0 disables RTCP accounting entirely, which also
|
|
474
|
+
disables quality alerts, media-stall detection and
|
|
475
|
+
adaptive bitrate)
|
|
476
|
+
verify_server — bool, validate TLS certificates (default True)
|
|
477
|
+
transport — "udp" | "tcp" | "tls" | "ws" | "wss"
|
|
478
|
+
|
|
479
|
+
Network handover (Wi-Fi <-> 4G/5G) — see network_changed():
|
|
480
|
+
net_monitor_interval_s — interface poll period, 0 = off (default 10)
|
|
481
|
+
net_settle_ms — debounce before acting (default 1500)
|
|
482
|
+
net_reinvite_calls — re-INVITE active calls (default True)
|
|
483
|
+
net_verify_ms — wait for RTP before retrying (default 4000)
|
|
484
|
+
net_max_attempts — retry ceiling (default 6)
|
|
485
|
+
net_hangup_on_migration_failure — default False
|
|
486
|
+
net_ice_handover — 0 best-effort (default), 1 fail fast. An ICE
|
|
487
|
+
call cannot re-gather candidates mid-call, so
|
|
488
|
+
1 reports failure after one attempt instead of
|
|
489
|
+
retrying an offer that cannot succeed.
|
|
490
|
+
|
|
491
|
+
Degraded links — the address stays put and the link goes bad. Handover
|
|
492
|
+
cannot see this; these can:
|
|
493
|
+
media_stall_ms — warn after this long with no inbound RTP,
|
|
494
|
+
as a quality_alert with issue "media_stall"
|
|
495
|
+
(default 4000; 0 = off). Non-fatal.
|
|
496
|
+
rtp_timeout_s — END the call after this long with no inbound
|
|
497
|
+
RTP (default 0 = never). The fatal version.
|
|
498
|
+
keepalive_interval — SIP OPTIONS probe period in ms (default 30000).
|
|
499
|
+
Refreshes the NAT binding and detects a
|
|
500
|
+
black-holed path; see keepalive_now().
|
|
501
|
+
keepalive_reregister — re-REGISTER when a probe fails (default True)
|
|
502
|
+
dns_srv_failover — walk the SRV target list on retry (default True)
|
|
503
|
+
adaptive_bitrate — lower the Opus bitrate under loss and raise it
|
|
504
|
+
on recovery, with no re-INVITE (default True)
|
|
505
|
+
adapt_min_bitrate — bps floor (default 12000)
|
|
506
|
+
adapt_max_bitrate — bps ceiling (default 32000)
|
|
507
|
+
adapt_loss_down_pct — step down above this loss %% (default 5.0)
|
|
508
|
+
adapt_loss_up_pct — step up below this loss %% (default 1.0)
|
|
509
|
+
adapt_recover_ticks — clean stats ticks before a step up (default 5)
|
|
510
|
+
opus_expected_loss_pct — Opus in-band FEC redundancy %% (default 0 =
|
|
511
|
+
switched with the link: on at 4%% reported
|
|
512
|
+
loss or 2%% twice running, off after 10 s
|
|
513
|
+
under 1%%, 5-25%% while on; see fec_active in
|
|
514
|
+
stats). Non-zero pins it.
|
|
515
|
+
reg_retry_jitter — randomise retry delays by this fraction
|
|
516
|
+
(default 0.2), so a fleet coming back from an
|
|
517
|
+
outage does not hit the registrar in one burst
|
|
518
|
+
sip_timer_b_ms — fail an unanswered outgoing INVITE after this
|
|
519
|
+
long (default 32000; 8000-12000 to fail fast)
|
|
520
|
+
sip_timer_f_ms — fail an unanswered REGISTER after this long
|
|
521
|
+
(default 32000)
|
|
522
|
+
"""
|
|
523
|
+
global _config_locked
|
|
524
|
+
if _config_locked:
|
|
525
|
+
raise RuntimeError("medut.configure() must be called before create_account()")
|
|
526
|
+
_config.update(kwargs)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def on(name: str):
|
|
530
|
+
"""Decorator — register a handler for a named event.
|
|
531
|
+
|
|
532
|
+
Valid names:
|
|
533
|
+
reg_state, call_state
|
|
534
|
+
registering, registered, unregistered, reg_failed, reconnecting
|
|
535
|
+
calling, ringing, established, held, ended, cancelled, call_failed
|
|
536
|
+
incoming_call, dtmf, sdp_negotiation, sip_trace, media_stats,
|
|
537
|
+
log, registrar_warning, transfer_request, transfer_failed, mwi, message,
|
|
538
|
+
presence_state, quality_alert, network
|
|
539
|
+
* (wildcard — every event)
|
|
540
|
+
|
|
541
|
+
Each handler receives a single event object whose fields depend on the
|
|
542
|
+
event type (see events.py). Call objects are at ev.call; account at ev.account.
|
|
543
|
+
|
|
544
|
+
@sdk.on("incoming_call")
|
|
545
|
+
def _(ev):
|
|
546
|
+
print(f"call from {ev.from_uri}")
|
|
547
|
+
ev.call.answer()
|
|
548
|
+
|
|
549
|
+
@sdk.on("established")
|
|
550
|
+
def _(ev):
|
|
551
|
+
ev.call.poll_stats(interval=2.0, on_update=lambda s: s.print())
|
|
552
|
+
"""
|
|
553
|
+
if name not in _VALID_NAMES:
|
|
554
|
+
raise ValueError(
|
|
555
|
+
f"medut.on({name!r}): unknown event name. "
|
|
556
|
+
f"Valid names: {sorted(_VALID_NAMES)}"
|
|
557
|
+
)
|
|
558
|
+
def decorator(fn: Callable) -> Callable:
|
|
559
|
+
_handlers.setdefault(name, []).append(fn)
|
|
560
|
+
return fn
|
|
561
|
+
return decorator
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def run():
|
|
565
|
+
"""Start the event dispatcher and block until stop() is called or Ctrl-C.
|
|
566
|
+
|
|
567
|
+
Installs a SIGINT handler that calls stop() unless one is already set.
|
|
568
|
+
On exit: drains remaining events, destroys accounts, shuts down the C SDK.
|
|
569
|
+
"""
|
|
570
|
+
global _dispatcher
|
|
571
|
+
|
|
572
|
+
if not _handlers:
|
|
573
|
+
raise RuntimeError("medut.run() called with no @sdk.on() handlers registered")
|
|
574
|
+
|
|
575
|
+
_stop_evt.clear()
|
|
576
|
+
_dispatcher = threading.Thread(
|
|
577
|
+
target=_dispatcher_loop, name="Medut-dispatcher", daemon=True)
|
|
578
|
+
_dispatcher.start()
|
|
579
|
+
|
|
580
|
+
old_sigint = signal.getsignal(signal.SIGINT)
|
|
581
|
+
installed = False
|
|
582
|
+
if old_sigint in (signal.SIG_DFL, None):
|
|
583
|
+
def _on_sigint(sig, frame):
|
|
584
|
+
stop()
|
|
585
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
586
|
+
installed = True
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
_stop_evt.wait()
|
|
590
|
+
finally:
|
|
591
|
+
if installed:
|
|
592
|
+
signal.signal(signal.SIGINT, old_sigint)
|
|
593
|
+
|
|
594
|
+
# Wake the dispatcher so it can exit the get() block
|
|
595
|
+
_event_q.put(None)
|
|
596
|
+
if _dispatcher.is_alive():
|
|
597
|
+
_dispatcher.join(timeout=0.3)
|
|
598
|
+
|
|
599
|
+
with _accounts_lock:
|
|
600
|
+
accs = list(_accounts)
|
|
601
|
+
for acc in accs:
|
|
602
|
+
try:
|
|
603
|
+
acc.destroy()
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
if _init_done:
|
|
608
|
+
lib.medut_shutdown()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def stop():
|
|
612
|
+
"""Signal run() to finish cleanly. Safe to call from any thread or handler."""
|
|
613
|
+
_stop_evt.set()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def create_account(uri: str, password: str,
|
|
617
|
+
transport=None,
|
|
618
|
+
push_provider: int = PUSH_PROVIDER_NONE,
|
|
619
|
+
push_token: Optional[str] = None,
|
|
620
|
+
push_param: Optional[str] = None,
|
|
621
|
+
audio_codecs: Optional[list] = None,
|
|
622
|
+
**kwargs) -> "Account":
|
|
623
|
+
"""Create and return a SIP account. Initializes the SDK on first call.
|
|
624
|
+
|
|
625
|
+
Common kwargs:
|
|
626
|
+
transport — "udp" | "tcp" | "tls" | "ws" | "wss"
|
|
627
|
+
media_enc — "none" | "sdes" | "dtls_srtp"
|
|
628
|
+
ice_enabled — bool
|
|
629
|
+
stun_server — "stun:host:port"
|
|
630
|
+
turn_server / turn_user / turn_pass
|
|
631
|
+
ice_servers — list of {"url": "stun:…"|"turn:…", "user": …, "pass": …}
|
|
632
|
+
(or (url, user, pass) tuples); every entry is used on
|
|
633
|
+
every gather, including an ICE restart on handover
|
|
634
|
+
ice_transport_policy — "all" (default) | "relay" (TURN only)
|
|
635
|
+
display_name, auth_user, server_url, server_host, server_port
|
|
636
|
+
rtcp_mux — bool
|
|
637
|
+
rel100 — "disabled" | "enabled" | "required"
|
|
638
|
+
dtmf_mode — "rfc4733" | "sip_info" | "auto"
|
|
639
|
+
audio_codecs — list of codec name strings, e.g. ["opus", "pcmu"]
|
|
640
|
+
extra_headers — dict of SIP headers added to all requests
|
|
641
|
+
"""
|
|
642
|
+
_ensure_init()
|
|
643
|
+
|
|
644
|
+
if transport is None:
|
|
645
|
+
transport = _config.get("transport", 0)
|
|
646
|
+
if isinstance(transport, str):
|
|
647
|
+
transport = _STR_TRANSPORT[transport]
|
|
648
|
+
|
|
649
|
+
if isinstance(kwargs.get("media_enc"), str):
|
|
650
|
+
kwargs["media_enc"] = _STR_MEDIA_ENC[kwargs["media_enc"]]
|
|
651
|
+
if "rtcp_mux" in kwargs:
|
|
652
|
+
kwargs["rtcp_mux_set"] = True
|
|
653
|
+
rel100_val = kwargs.pop("rel100", None)
|
|
654
|
+
if isinstance(rel100_val, str):
|
|
655
|
+
rel100_val = _STR_REL100[rel100_val]
|
|
656
|
+
dtmf_val = kwargs.pop("dtmf_mode", None)
|
|
657
|
+
if isinstance(dtmf_val, str):
|
|
658
|
+
dtmf_val = _STR_DTMF[dtmf_val]
|
|
659
|
+
if dtmf_val is not None:
|
|
660
|
+
kwargs["dtmf_mode"] = dtmf_val
|
|
661
|
+
extra_headers = kwargs.pop("extra_headers", {})
|
|
662
|
+
ice_servers = kwargs.pop("ice_servers", None)
|
|
663
|
+
policy = kwargs.pop("ice_transport_policy", None)
|
|
664
|
+
if isinstance(policy, str):
|
|
665
|
+
kwargs["ice_transport_policy"] = _STR_ICE_POLICY[policy]
|
|
666
|
+
elif policy is not None:
|
|
667
|
+
kwargs["ice_transport_policy"] = policy
|
|
668
|
+
|
|
669
|
+
cfg = ffi.new("medut_account_config_t *")
|
|
670
|
+
keepalive = [cfg]
|
|
671
|
+
if ice_servers:
|
|
672
|
+
arr, refs = _ice_servers_array(ice_servers)
|
|
673
|
+
keepalive += [arr] + refs
|
|
674
|
+
cfg.ice_servers = arr
|
|
675
|
+
cfg.ice_server_count = len(ice_servers)
|
|
676
|
+
uri_ref = ffi.new("char[]", uri.encode())
|
|
677
|
+
pass_ref = ffi.new("char[]", password.encode())
|
|
678
|
+
keepalive += [uri_ref, pass_ref]
|
|
679
|
+
cfg.uri = uri_ref
|
|
680
|
+
cfg.password = pass_ref
|
|
681
|
+
cfg.transport = transport
|
|
682
|
+
cfg.push_provider = push_provider
|
|
683
|
+
|
|
684
|
+
if push_token is not None:
|
|
685
|
+
ref = ffi.new("char[]", push_token.encode())
|
|
686
|
+
cfg.push_token = ref; keepalive.append(ref)
|
|
687
|
+
if push_param is not None:
|
|
688
|
+
ref = ffi.new("char[]", push_param.encode())
|
|
689
|
+
cfg.push_param = ref; keepalive.append(ref)
|
|
690
|
+
|
|
691
|
+
if audio_codecs:
|
|
692
|
+
str_codecs = [c for c in audio_codecs if isinstance(c, str)]
|
|
693
|
+
int_codecs = [c for c in audio_codecs if isinstance(c, int)]
|
|
694
|
+
if str_codecs:
|
|
695
|
+
count = min(len(str_codecs), 8)
|
|
696
|
+
for i, name in enumerate(str_codecs[:count]):
|
|
697
|
+
encoded = name.encode()[:31]
|
|
698
|
+
for j, b in enumerate(encoded):
|
|
699
|
+
cfg.audio_codec_names[i][j] = bytes([b])
|
|
700
|
+
cfg.audio_codec_names[i][len(encoded)] = b'\x00'
|
|
701
|
+
cfg.audio_codec_name_count = count
|
|
702
|
+
elif int_codecs:
|
|
703
|
+
count = min(len(int_codecs), 8)
|
|
704
|
+
for i, c in enumerate(int_codecs[:count]):
|
|
705
|
+
cfg.audio_codecs[i] = c
|
|
706
|
+
cfg.audio_codec_count = count
|
|
707
|
+
|
|
708
|
+
for key, val in kwargs.items():
|
|
709
|
+
if isinstance(val, str):
|
|
710
|
+
ref = ffi.new("char[]", val.encode())
|
|
711
|
+
keepalive.append(ref)
|
|
712
|
+
setattr(cfg, key, ref)
|
|
713
|
+
elif isinstance(val, bool):
|
|
714
|
+
setattr(cfg, key, int(val))
|
|
715
|
+
else:
|
|
716
|
+
setattr(cfg, key, val)
|
|
717
|
+
|
|
718
|
+
h = ffi.new("medut_account_handle_t *")
|
|
719
|
+
_check(lib.medut_account_create(cfg, h), "account_create")
|
|
720
|
+
|
|
721
|
+
account = Account(h[0], uri, keepalive)
|
|
722
|
+
if rel100_val is not None:
|
|
723
|
+
lib.medut_account_set_100rel(account._h, rel100_val)
|
|
724
|
+
for k, v in extra_headers.items():
|
|
725
|
+
account.add_header(k, v)
|
|
726
|
+
|
|
727
|
+
with _accounts_lock:
|
|
728
|
+
_accounts.append(account)
|
|
729
|
+
_accounts_by_handle[int(ffi.cast("uintptr_t", h[0]))] = account
|
|
730
|
+
|
|
731
|
+
return account
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _normalize_target(target: str, account: "Account") -> str:
|
|
735
|
+
"""Auto-prefix sip: and auto-append @domain from the account's URI."""
|
|
736
|
+
if not target.startswith("sip:"):
|
|
737
|
+
target = "sip:" + target
|
|
738
|
+
if "@" not in target:
|
|
739
|
+
acct_uri = account._uri
|
|
740
|
+
if acct_uri.startswith("sip:"):
|
|
741
|
+
acct_uri = acct_uri[4:]
|
|
742
|
+
if "@" in acct_uri:
|
|
743
|
+
domain = acct_uri.split("@", 1)[1].split(":")[0]
|
|
744
|
+
target = f"{target}@{domain}"
|
|
745
|
+
return target
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def call(target: str, account: Optional["Account"] = None) -> "Call":
|
|
749
|
+
"""Place an outbound call.
|
|
750
|
+
|
|
751
|
+
target — full SIP URI, bare extension ("120"), or phone number ("+15551234")
|
|
752
|
+
account — which account to dial from; required when multiple accounts exist
|
|
753
|
+
|
|
754
|
+
call = sdk.call("120") # single-account shorthand
|
|
755
|
+
call = sdk.call("+15551234", account=acc) # explicit
|
|
756
|
+
"""
|
|
757
|
+
if account is None:
|
|
758
|
+
with _accounts_lock:
|
|
759
|
+
n = len(_accounts)
|
|
760
|
+
if n == 0:
|
|
761
|
+
raise RuntimeError(
|
|
762
|
+
"medut.call() requires an account; none have been created")
|
|
763
|
+
if n > 1:
|
|
764
|
+
raise RuntimeError(
|
|
765
|
+
f"medut.call() is ambiguous: {n} accounts registered, "
|
|
766
|
+
"pass account=...")
|
|
767
|
+
account = _accounts[0]
|
|
768
|
+
return account.call(_normalize_target(target, account))
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def version() -> str:
|
|
772
|
+
"""Return the Medut library version string."""
|
|
773
|
+
_ensure_init()
|
|
774
|
+
return ffi.string(lib.medut_version()).decode()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def strerror(err: int) -> str:
|
|
778
|
+
"""Return a human-readable string for a MEDUT_ERR_* code."""
|
|
779
|
+
return ffi.string(lib.medut_strerror(err)).decode("utf-8", errors="replace")
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ── Audio (module-level) ─────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
def list_input_devices() -> list:
|
|
785
|
+
"""Return a list of dicts with keys: name, description, is_default."""
|
|
786
|
+
return _list_audio_devices(lib.medut_audio_list_input_devices)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def list_output_devices() -> list:
|
|
790
|
+
"""Return a list of dicts with keys: name, description, is_default."""
|
|
791
|
+
return _list_audio_devices(lib.medut_audio_list_output_devices)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def set_input_device(name: str):
|
|
795
|
+
lib.medut_audio_set_input_device(name.encode())
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def set_output_device(name: str):
|
|
799
|
+
lib.medut_audio_set_output_device(name.encode())
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def set_aec(enable: bool):
|
|
803
|
+
"""Enable or disable acoustic echo cancellation."""
|
|
804
|
+
lib.medut_set_aec(int(enable))
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def set_aec_mode(mode: int):
|
|
808
|
+
"""0=off, 1=suppressor, 2=webrtc. AEC_WEBRTC requires a desktop build."""
|
|
809
|
+
_check(lib.medut_set_aec_mode(mode), "set_aec_mode")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def set_aec_suppression_level(level: float):
|
|
813
|
+
"""Suppressor aggressiveness: 0.0=none, 1.0=maximum (default)."""
|
|
814
|
+
lib.medut_set_aec_suppression_level(float(level))
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def set_ns(enable: bool):
|
|
818
|
+
"""Enable or disable noise suppression."""
|
|
819
|
+
lib.medut_set_ns(int(enable))
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def set_agc(enable: bool):
|
|
823
|
+
"""Enable or disable automatic gain control."""
|
|
824
|
+
lib.medut_set_agc(int(enable))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def set_mic_gain(db: float):
|
|
828
|
+
"""Microphone (TX) gain in dB. Range: -20 to +20. 0 = unity."""
|
|
829
|
+
lib.medut_set_mic_gain_db(float(db))
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def set_speaker_gain(db: float):
|
|
833
|
+
"""Speaker (RX) gain in dB. Range: -20 to +20. 0 = unity."""
|
|
834
|
+
lib.medut_set_speaker_gain_db(float(db))
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# ── App-owned audio device ───────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def use_external_audio(enable: bool):
|
|
841
|
+
"""Hand the microphone and speaker to the app.
|
|
842
|
+
|
|
843
|
+
The SDK then opens no capture or playback device of its own; you supply and
|
|
844
|
+
consume PCM with external_audio_push/pull. SIP, ICE, SRTP, codecs and the
|
|
845
|
+
jitter buffer stay with the SDK. Takes effect immediately, including on a
|
|
846
|
+
call that is already up.
|
|
847
|
+
|
|
848
|
+
Not sticky across shutdown/configure: the device is re-derived from the
|
|
849
|
+
platform on init, so ask again after a restart.
|
|
850
|
+
|
|
851
|
+
Echo cancellation follows the device — the platform cancellers the SDK
|
|
852
|
+
relies on belong to the drivers being displaced, so you own AEC too.
|
|
853
|
+
"""
|
|
854
|
+
_ensure_init()
|
|
855
|
+
_check(lib.medut_audio_use_external(bool(enable)), "use_external_audio")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def external_audio_format() -> Optional[tuple]:
|
|
859
|
+
"""(sample_rate, channels, ptime_ms) once the call has media, else None.
|
|
860
|
+
|
|
861
|
+
There is no "media is up" event to wait on, so poll this: it reports both
|
|
862
|
+
that the device opened and that a re-INVITE changed the codec.
|
|
863
|
+
"""
|
|
864
|
+
srate = ffi.new("uint32_t *")
|
|
865
|
+
ch = ffi.new("uint8_t *")
|
|
866
|
+
ptime = ffi.new("uint32_t *")
|
|
867
|
+
if lib.medut_audio_external_format(srate, ch, ptime) != 0:
|
|
868
|
+
return None
|
|
869
|
+
return srate[0], ch[0], ptime[0]
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def external_audio_active() -> bool:
|
|
873
|
+
"""True while a call is capturing or playing through the app-owned device."""
|
|
874
|
+
return bool(lib.medut_audio_external_is_active())
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def external_audio_set_voice_path(active: bool):
|
|
878
|
+
"""Say whether your capture runs through the platform's voice path.
|
|
879
|
+
|
|
880
|
+
True when your device has already cancelled echo and run its own noise
|
|
881
|
+
suppression and AGC (iOS VoiceProcessingIO, Android VOICE_COMMUNICATION):
|
|
882
|
+
the SDK's NS and AGC then stay off rather than stacking on top. The
|
|
883
|
+
suppressor is unaffected — on an app-owned device it runs only when asked
|
|
884
|
+
for with set_aec_mode(SUPPRESSOR). Call it when your device starts and
|
|
885
|
+
stops, not from the audio callback. A raw microphone (the default) is
|
|
886
|
+
False.
|
|
887
|
+
"""
|
|
888
|
+
lib.medut_audio_external_set_voice_path(bool(active))
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def external_audio_push(pcm) -> int:
|
|
892
|
+
"""Push captured microphone audio. This is what the far end hears.
|
|
893
|
+
|
|
894
|
+
`pcm` is any S16LE buffer — bytes, bytearray, memoryview, array('h'),
|
|
895
|
+
numpy int16 — at the rate and channel count from external_audio_format().
|
|
896
|
+
Any size is accepted; the stack re-frames internally.
|
|
897
|
+
|
|
898
|
+
Returns 0, or errno.ENODEV between calls, which is not worth acting on.
|
|
899
|
+
"""
|
|
900
|
+
buf = ffi.from_buffer("int16_t[]", pcm)
|
|
901
|
+
return lib.medut_audio_external_push(buf, len(buf))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def external_audio_pull(nsamp: int) -> bytes:
|
|
905
|
+
"""Take decoded audio to play. This is what the local user hears.
|
|
906
|
+
|
|
907
|
+
Always returns exactly `nsamp` S16LE samples — silence when no call is up —
|
|
908
|
+
so the result can go straight to the speaker without checking.
|
|
909
|
+
"""
|
|
910
|
+
buf = ffi.new("int16_t[]", nsamp)
|
|
911
|
+
lib.medut_audio_external_pull(buf, nsamp)
|
|
912
|
+
return bytes(ffi.buffer(buf))
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def network_changed():
|
|
916
|
+
"""Tell the SDK the network may have changed (Wi-Fi <-> 4G/5G, VPN, dock).
|
|
917
|
+
|
|
918
|
+
Call this from the platform's connectivity callback. The SDK re-binds its
|
|
919
|
+
SIP transports, re-REGISTERs, and re-INVITEs active calls onto the new
|
|
920
|
+
local address, reporting progress as "network" events.
|
|
921
|
+
"""
|
|
922
|
+
_ensure_init()
|
|
923
|
+
return lib.medut_network_changed()
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def network_set_monitor_interval(seconds: int):
|
|
927
|
+
"""Interface poll period in seconds; 0 disables polling entirely."""
|
|
928
|
+
_ensure_init()
|
|
929
|
+
return lib.medut_network_set_monitor_interval(int(seconds))
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
_STR_ICE_POLICY = {"all": 0, "relay": 1}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _ice_servers_array(servers):
|
|
936
|
+
"""Build an medut_ice_server_t[] from dicts or (url, user, pass) tuples."""
|
|
937
|
+
arr = ffi.new("medut_ice_server_t[]", max(len(servers), 1))
|
|
938
|
+
refs = []
|
|
939
|
+
for i, s in enumerate(servers):
|
|
940
|
+
if isinstance(s, dict):
|
|
941
|
+
url, user, pw = s.get("url"), s.get("user"), s.get("pass")
|
|
942
|
+
elif isinstance(s, str):
|
|
943
|
+
url, user, pw = s, None, None
|
|
944
|
+
else:
|
|
945
|
+
url, user, pw = (tuple(s) + (None, None))[:3]
|
|
946
|
+
for field, val in (("url", url), ("user", user), ("pass", pw)):
|
|
947
|
+
if val:
|
|
948
|
+
ref = ffi.new("char[]", val.encode())
|
|
949
|
+
refs.append(ref)
|
|
950
|
+
setattr(arr[i], field, ref)
|
|
951
|
+
return arr, refs
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def network_set_interface_handle(ifname: str, handle: int):
|
|
955
|
+
"""Android: tell the SDK which platform network an interface belongs to,
|
|
956
|
+
so ICE can gather on it with sockets pinned to that network."""
|
|
957
|
+
_ensure_init()
|
|
958
|
+
return lib.medut_network_set_interface_handle(ifname.encode(), handle)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def network_clear_interface_handle(ifname: str):
|
|
962
|
+
_ensure_init()
|
|
963
|
+
return lib.medut_network_clear_interface_handle(ifname.encode())
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def network_set_handover_policy(reinvite_calls: bool = True,
|
|
967
|
+
hangup_on_failure: bool = False):
|
|
968
|
+
"""Re-INVITE active calls on handover; optionally hang up on failure."""
|
|
969
|
+
_ensure_init()
|
|
970
|
+
return lib.medut_network_set_handover_policy(int(reinvite_calls),
|
|
971
|
+
int(hangup_on_failure))
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def network_local_addr() -> str:
|
|
975
|
+
"""Local IP the SDK is currently using, or "" when there is none."""
|
|
976
|
+
_ensure_init()
|
|
977
|
+
buf = ffi.new("char[64]")
|
|
978
|
+
if lib.medut_network_local_addr(buf, 64) != 0:
|
|
979
|
+
return ""
|
|
980
|
+
return ffi.string(buf).decode()
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def network_is_up() -> bool:
|
|
984
|
+
"""False while the device has no usable (non-loopback) local address."""
|
|
985
|
+
_ensure_init()
|
|
986
|
+
return bool(lib.medut_network_is_up())
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def set_jitter_buffer(min_ms: int, max_ms: int):
|
|
990
|
+
lib.medut_set_jitter_buffer(min_ms, max_ms)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def set_adaptive_bitrate(enabled: bool, min_bps: int = 0, max_bps: int = 0):
|
|
994
|
+
"""Turn link-adaptive bitrate on or off at runtime, with optional bounds.
|
|
995
|
+
|
|
996
|
+
Pass 0 for either bound to keep the configured value. Disabling leaves
|
|
997
|
+
every call at its current rate; use call.set_bitrate(0) to restore the
|
|
998
|
+
negotiated one.
|
|
999
|
+
"""
|
|
1000
|
+
_ensure_init()
|
|
1001
|
+
lib.medut_set_adaptive_bitrate(enabled, min_bps, max_bps)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def pcap_start(path: str):
|
|
1005
|
+
"""Start capturing SIP/RTP to a pcap file."""
|
|
1006
|
+
_check(lib.medut_pcap_start(path.encode()), "pcap_start")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def pcap_stop():
|
|
1010
|
+
"""Stop pcap capture and finalize the file."""
|
|
1011
|
+
lib.medut_pcap_stop()
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
# ── Call ──────────────────────────────────────────────────────────────────────
|
|
1015
|
+
|
|
1016
|
+
class Call:
|
|
1017
|
+
"""Wrapper around a medut_call_handle_t.
|
|
1018
|
+
|
|
1019
|
+
Obtained from sdk.call(...), account.call(...), or ev.call in a handler.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
def __init__(self, handle):
|
|
1023
|
+
self._h = handle
|
|
1024
|
+
self._poll_stop = threading.Event()
|
|
1025
|
+
self._poll_thread: Optional[threading.Thread] = None
|
|
1026
|
+
|
|
1027
|
+
def answer(self):
|
|
1028
|
+
_check(lib.medut_call_answer(self._h), "answer")
|
|
1029
|
+
|
|
1030
|
+
def hangup(self):
|
|
1031
|
+
lib.medut_call_hangup(self._h)
|
|
1032
|
+
|
|
1033
|
+
def hold(self):
|
|
1034
|
+
_check(lib.medut_call_hold(self._h), "hold")
|
|
1035
|
+
|
|
1036
|
+
def resume(self):
|
|
1037
|
+
_check(lib.medut_call_resume(self._h), "resume")
|
|
1038
|
+
|
|
1039
|
+
def is_held(self) -> bool:
|
|
1040
|
+
return bool(lib.medut_call_is_held(self._h))
|
|
1041
|
+
|
|
1042
|
+
def send_dtmf(self, digit: str):
|
|
1043
|
+
_check(lib.medut_call_send_dtmf(self._h, digit.encode()), "send_dtmf")
|
|
1044
|
+
|
|
1045
|
+
def transfer(self, uri: str):
|
|
1046
|
+
_check(lib.medut_call_transfer(self._h, uri.encode()), "transfer")
|
|
1047
|
+
|
|
1048
|
+
def transfer_accept(self) -> "Call":
|
|
1049
|
+
"""Follow an incoming REFER; returns the new Call to the target.
|
|
1050
|
+
|
|
1051
|
+
Answer a ``transfer_request`` event with this or :meth:`transfer_reject`
|
|
1052
|
+
— exactly one. Until you do, the transferor is still waiting on the
|
|
1053
|
+
NOTIFY that says what happened, and will sit there until its
|
|
1054
|
+
subscription expires.
|
|
1055
|
+
|
|
1056
|
+
Do not implement a transfer by hanging up and dialling: that breaks the
|
|
1057
|
+
REFER subscription, and the far end never learns it worked. This keeps
|
|
1058
|
+
the two calls linked so the SDK reports the outcome for you.
|
|
1059
|
+
|
|
1060
|
+
The original call stays up; end it when the new one connects.
|
|
1061
|
+
"""
|
|
1062
|
+
out = ffi.new("medut_call_handle_t *")
|
|
1063
|
+
_check(lib.medut_call_transfer_accept(self._h, out),
|
|
1064
|
+
"transfer_accept")
|
|
1065
|
+
return Call(out[0])
|
|
1066
|
+
|
|
1067
|
+
def transfer_reject(self, scode: int = 603, reason: str = "Declined"):
|
|
1068
|
+
"""Refuse an incoming REFER, leaving this call up.
|
|
1069
|
+
|
|
1070
|
+
``scode`` is the SIP status the transferor is told, 400-699; 603
|
|
1071
|
+
Decline is the usual "the user said no", 486 for busy.
|
|
1072
|
+
"""
|
|
1073
|
+
_check(lib.medut_call_transfer_reject(self._h, scode,
|
|
1074
|
+
reason.encode()),
|
|
1075
|
+
"transfer_reject")
|
|
1076
|
+
|
|
1077
|
+
def info(self) -> dict:
|
|
1078
|
+
"""Return the call's metadata: peer, URIs, direction, duration.
|
|
1079
|
+
|
|
1080
|
+
Complements :meth:`stats`, which is the per-tick media numbers. Safe
|
|
1081
|
+
to call at any point in the call's life, including after it ends.
|
|
1082
|
+
"""
|
|
1083
|
+
i = ffi.new("medut_call_info_t *")
|
|
1084
|
+
_check(lib.medut_call_get_info(self._h, i), "get_info")
|
|
1085
|
+
_TRANSPORTS = ("udp", "tcp", "tls", "ws", "wss")
|
|
1086
|
+
_STATES = ("calling", "ringing", "established", "held",
|
|
1087
|
+
"ended", "cancelled", "failed")
|
|
1088
|
+
def _fixed(arr) -> str:
|
|
1089
|
+
return ffi.string(arr).decode("utf-8", errors="replace")
|
|
1090
|
+
return {
|
|
1091
|
+
"peer_uri": _fixed(i.peer_uri),
|
|
1092
|
+
"peer_display_name": _fixed(i.peer_display_name),
|
|
1093
|
+
"local_uri": _fixed(i.local_uri),
|
|
1094
|
+
"contact_uri": _fixed(i.contact_uri),
|
|
1095
|
+
"call_id": _fixed(i.call_id),
|
|
1096
|
+
"diverter_uri": _fixed(i.diverter_uri),
|
|
1097
|
+
"is_outgoing": bool(i.is_outgoing),
|
|
1098
|
+
"is_remote_hold": bool(i.is_remote_hold),
|
|
1099
|
+
"sip_status": int(i.sip_status),
|
|
1100
|
+
"duration_ms": int(i.duration_ms),
|
|
1101
|
+
"setup_duration_ms": int(i.setup_duration_ms),
|
|
1102
|
+
"line_number": int(i.line_number),
|
|
1103
|
+
"transport": _TRANSPORTS[i.transport]
|
|
1104
|
+
if 0 <= i.transport < len(_TRANSPORTS)
|
|
1105
|
+
else "unknown",
|
|
1106
|
+
"state": _STATES[i.state]
|
|
1107
|
+
if 0 <= i.state < len(_STATES) else "unknown",
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
def mute(self, muted: bool = True):
|
|
1111
|
+
lib.medut_audio_mute(self._h, muted)
|
|
1112
|
+
|
|
1113
|
+
def is_muted(self) -> bool:
|
|
1114
|
+
return bool(lib.medut_audio_is_muted(self._h))
|
|
1115
|
+
|
|
1116
|
+
def mute_rx(self, muted: bool = True):
|
|
1117
|
+
lib.medut_audio_mute_rx(self._h, muted)
|
|
1118
|
+
|
|
1119
|
+
def set_dscp_rtp(self, dscp: int):
|
|
1120
|
+
_check(lib.medut_call_set_dscp_rtp(self._h, dscp), "set_dscp_rtp")
|
|
1121
|
+
|
|
1122
|
+
def set_rtp_timeout(self, seconds: int):
|
|
1123
|
+
"""End this call after *seconds* with no inbound RTP; 0 = never.
|
|
1124
|
+
|
|
1125
|
+
Per-call override of configure(rtp_timeout_s=...). Only sendrecv
|
|
1126
|
+
streams are checked, so a held call is never torn down by it.
|
|
1127
|
+
"""
|
|
1128
|
+
_check(lib.medut_call_set_rtp_timeout(self._h, seconds),
|
|
1129
|
+
"set_rtp_timeout")
|
|
1130
|
+
|
|
1131
|
+
def set_bitrate(self, bitrate_bps: int):
|
|
1132
|
+
"""Set the audio encoder bitrate; 0 restores the negotiated rate.
|
|
1133
|
+
|
|
1134
|
+
Applied through the codec's encoder-update path — no re-INVITE and no
|
|
1135
|
+
audio gap — so it does nothing for a fixed-rate codec like G.711.
|
|
1136
|
+
With configure(adaptive_bitrate=True) the controller will override
|
|
1137
|
+
this on its next decision.
|
|
1138
|
+
"""
|
|
1139
|
+
_check(lib.medut_call_set_bitrate(self._h, bitrate_bps),
|
|
1140
|
+
"set_bitrate")
|
|
1141
|
+
|
|
1142
|
+
def stats(self) -> CallStats:
|
|
1143
|
+
"""Return a fresh CallStats snapshot (synchronous, one-shot)."""
|
|
1144
|
+
s = ffi.new("medut_ev_media_stats_t *")
|
|
1145
|
+
lib.medut_call_get_stats(self._h, s)
|
|
1146
|
+
cs = CallStats()
|
|
1147
|
+
cs._update(_raw_stats_to_event(s[0]))
|
|
1148
|
+
return cs
|
|
1149
|
+
|
|
1150
|
+
def fetch_stats(self, target: CallStats) -> CallStats:
|
|
1151
|
+
"""Update *target* in-place with the current stats and return it."""
|
|
1152
|
+
s = ffi.new("medut_ev_media_stats_t *")
|
|
1153
|
+
lib.medut_call_get_stats(self._h, s)
|
|
1154
|
+
target._update(_raw_stats_to_event(s[0]))
|
|
1155
|
+
return target
|
|
1156
|
+
|
|
1157
|
+
def poll_stats(self, interval: float, on_update: Callable):
|
|
1158
|
+
"""Poll stats at a fixed rate, calling on_update(stats) each tick.
|
|
1159
|
+
|
|
1160
|
+
Runs in a background daemon thread; call stop_polling() to cancel,
|
|
1161
|
+
or it stops automatically when is_final is set on the stats.
|
|
1162
|
+
|
|
1163
|
+
call.poll_stats(interval=2.0, on_update=lambda s: s.print())
|
|
1164
|
+
"""
|
|
1165
|
+
self._poll_stop.clear()
|
|
1166
|
+
stats = CallStats()
|
|
1167
|
+
|
|
1168
|
+
def _loop():
|
|
1169
|
+
while not self._poll_stop.wait(interval):
|
|
1170
|
+
self.fetch_stats(stats)
|
|
1171
|
+
try:
|
|
1172
|
+
on_update(stats)
|
|
1173
|
+
except Exception:
|
|
1174
|
+
_log.exception("Medut poll_stats on_update raised")
|
|
1175
|
+
if stats.is_final:
|
|
1176
|
+
break
|
|
1177
|
+
|
|
1178
|
+
self._poll_thread = threading.Thread(target=_loop, daemon=True)
|
|
1179
|
+
self._poll_thread.start()
|
|
1180
|
+
|
|
1181
|
+
def stop_polling(self):
|
|
1182
|
+
"""Stop a poll_stats() loop started on this call."""
|
|
1183
|
+
self._poll_stop.set()
|
|
1184
|
+
|
|
1185
|
+
def record_start(self, path: str):
|
|
1186
|
+
"""Record mixed call audio (RX+TX) to a WAV file."""
|
|
1187
|
+
_check(lib.medut_call_record_start(self._h, path.encode()), "record_start")
|
|
1188
|
+
|
|
1189
|
+
def record_stop(self):
|
|
1190
|
+
"""Stop recording and finalize WAV headers."""
|
|
1191
|
+
lib.medut_call_record_stop(self._h)
|
|
1192
|
+
|
|
1193
|
+
@property
|
|
1194
|
+
def handle(self):
|
|
1195
|
+
return self._h
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
# ── Account ───────────────────────────────────────────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
class Account:
|
|
1201
|
+
"""A SIP account. Create via sdk.create_account()."""
|
|
1202
|
+
|
|
1203
|
+
def __init__(self, handle, uri: str, keepalive: list):
|
|
1204
|
+
self._h = handle
|
|
1205
|
+
self._uri = uri
|
|
1206
|
+
self._keepalive = keepalive # C cdata refs that must not be GC'd
|
|
1207
|
+
|
|
1208
|
+
def register(self):
|
|
1209
|
+
"""Send a SIP REGISTER to activate this account."""
|
|
1210
|
+
_check(lib.medut_account_register(self._h), "register")
|
|
1211
|
+
|
|
1212
|
+
def refresh_registration(self):
|
|
1213
|
+
"""Refresh the registration in place: what to call when a push wakes
|
|
1214
|
+
the app. A registered account re-sends its REGISTER from the client
|
|
1215
|
+
holding the binding (RFC 8599 s4.1.3); any other account registers."""
|
|
1216
|
+
_check(lib.medut_account_refresh_registration(self._h),
|
|
1217
|
+
"refresh_registration")
|
|
1218
|
+
|
|
1219
|
+
def unregister(self):
|
|
1220
|
+
"""Send an unregistration (Expires: 0)."""
|
|
1221
|
+
lib.medut_account_unregister(self._h)
|
|
1222
|
+
|
|
1223
|
+
def call(self, uri: str) -> Call:
|
|
1224
|
+
"""Place an outbound call to a full SIP URI."""
|
|
1225
|
+
ch = ffi.new("medut_call_handle_t *")
|
|
1226
|
+
_check(lib.medut_call_invite(self._h, uri.encode(), ch), "call_invite")
|
|
1227
|
+
return Call(ch[0])
|
|
1228
|
+
|
|
1229
|
+
def send_message(self, to: str, body: str, content_type: str = "text/plain"):
|
|
1230
|
+
_check(
|
|
1231
|
+
lib.medut_message_send(self._h, to.encode(), body.encode(),
|
|
1232
|
+
content_type.encode()),
|
|
1233
|
+
"message_send")
|
|
1234
|
+
|
|
1235
|
+
def publish_presence(self, status: int):
|
|
1236
|
+
lib.medut_account_publish_presence(self._h, status)
|
|
1237
|
+
|
|
1238
|
+
def subscribe_presence(self, target_uri: str):
|
|
1239
|
+
_check(lib.medut_account_subscribe_presence(self._h, target_uri.encode()),
|
|
1240
|
+
"subscribe_presence")
|
|
1241
|
+
|
|
1242
|
+
def add_header(self, name: str, value: str):
|
|
1243
|
+
_check(lib.medut_account_add_header(self._h, name.encode(), value.encode()),
|
|
1244
|
+
"add_header")
|
|
1245
|
+
|
|
1246
|
+
def add_register_header(self, name: str, value: str):
|
|
1247
|
+
"""Add a header sent only on REGISTER (not on INVITE/BYE)."""
|
|
1248
|
+
_check(lib.medut_account_add_register_header(
|
|
1249
|
+
self._h, name.encode(), value.encode()), "add_register_header")
|
|
1250
|
+
|
|
1251
|
+
def set_push_token(self, push_token: Optional[str]) -> int:
|
|
1252
|
+
"""Update the push notification token at runtime. Pass None to clear."""
|
|
1253
|
+
tok = push_token.encode() if push_token is not None else ffi.NULL
|
|
1254
|
+
return lib.medut_account_set_push_token(self._h, tok)
|
|
1255
|
+
|
|
1256
|
+
def set_retry_policy(self, initial_ms: int, max_ms: int,
|
|
1257
|
+
backoff: float, max_attempts: int = 0):
|
|
1258
|
+
_check(lib.medut_account_set_retry_policy(
|
|
1259
|
+
self._h, initial_ms, max_ms, backoff, max_attempts),
|
|
1260
|
+
"set_retry_policy")
|
|
1261
|
+
|
|
1262
|
+
def cancel_retry(self):
|
|
1263
|
+
lib.medut_account_cancel_retry(self._h)
|
|
1264
|
+
|
|
1265
|
+
def retry_now(self):
|
|
1266
|
+
lib.medut_account_retry_now(self._h)
|
|
1267
|
+
|
|
1268
|
+
def set_ice_servers(self, servers, policy="all"):
|
|
1269
|
+
"""Replace this account's STUN/TURN list (see create_account's
|
|
1270
|
+
ice_servers); takes effect on the next gather or ICE restart, which is
|
|
1271
|
+
how short-lived TURN credentials are refreshed. An empty list clears
|
|
1272
|
+
the per-account list so the global one applies again."""
|
|
1273
|
+
arr, refs = _ice_servers_array(servers or [])
|
|
1274
|
+
pol = _STR_ICE_POLICY[policy] if isinstance(policy, str) else policy
|
|
1275
|
+
rc = lib.medut_account_set_ice_servers(self._h, arr if servers else ffi.NULL,
|
|
1276
|
+
len(servers or []), pol)
|
|
1277
|
+
_check(rc, "account_set_ice_servers")
|
|
1278
|
+
return rc
|
|
1279
|
+
|
|
1280
|
+
def keepalive_now(self):
|
|
1281
|
+
"""Send a reachability probe (SIP OPTIONS) for this account now.
|
|
1282
|
+
|
|
1283
|
+
Worth doing from an app-foreground or push-wake handler: it answers
|
|
1284
|
+
"is my registration still reachable?" before the user tries to place a
|
|
1285
|
+
call. Nothing is reported on success; on failure the account goes to
|
|
1286
|
+
reg_failed and, with configure(keepalive_reregister=True), re-registers.
|
|
1287
|
+
"""
|
|
1288
|
+
_check(lib.medut_account_keepalive_now(self._h), "keepalive_now")
|
|
1289
|
+
|
|
1290
|
+
def set_100rel(self, mode: int):
|
|
1291
|
+
lib.medut_account_set_100rel(self._h, mode)
|
|
1292
|
+
|
|
1293
|
+
def destroy(self):
|
|
1294
|
+
if self._h is None:
|
|
1295
|
+
return
|
|
1296
|
+
with _accounts_lock:
|
|
1297
|
+
try:
|
|
1298
|
+
_accounts.remove(self)
|
|
1299
|
+
except ValueError:
|
|
1300
|
+
pass
|
|
1301
|
+
_accounts_by_handle.pop(int(ffi.cast("uintptr_t", self._h)), None)
|
|
1302
|
+
lib.medut_account_destroy(self._h)
|
|
1303
|
+
self._h = None
|
|
1304
|
+
|
|
1305
|
+
def __enter__(self):
|
|
1306
|
+
return self
|
|
1307
|
+
|
|
1308
|
+
def __exit__(self, *_):
|
|
1309
|
+
self.destroy()
|
|
1310
|
+
|
|
1311
|
+
def __repr__(self):
|
|
1312
|
+
return f"Account({self._uri!r})"
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
# ── Public surface ────────────────────────────────────────────────────────────
|
|
1316
|
+
|
|
1317
|
+
__all__ = [
|
|
1318
|
+
# Core API
|
|
1319
|
+
"configure", "create_account", "call", "on", "run", "stop",
|
|
1320
|
+
"version", "strerror",
|
|
1321
|
+
# Audio
|
|
1322
|
+
"list_input_devices", "list_output_devices",
|
|
1323
|
+
"set_input_device", "set_output_device",
|
|
1324
|
+
"set_aec", "set_aec_mode", "set_aec_suppression_level",
|
|
1325
|
+
"set_ns", "set_agc", "set_mic_gain", "set_speaker_gain",
|
|
1326
|
+
"set_jitter_buffer", "pcap_start", "pcap_stop",
|
|
1327
|
+
# App-owned audio device
|
|
1328
|
+
"use_external_audio", "external_audio_format", "external_audio_active",
|
|
1329
|
+
"external_audio_push", "external_audio_pull",
|
|
1330
|
+
"external_audio_set_voice_path",
|
|
1331
|
+
# Network handover
|
|
1332
|
+
"network_changed", "network_set_monitor_interval",
|
|
1333
|
+
"network_set_interface_handle", "network_clear_interface_handle",
|
|
1334
|
+
"network_set_handover_policy", "network_local_addr", "network_is_up",
|
|
1335
|
+
# Classes
|
|
1336
|
+
"Account", "Call", "CallStats",
|
|
1337
|
+
# Event dataclasses
|
|
1338
|
+
"RegStateEvent", "IncomingCallEvent", "CallStateEvent", "CallDtmfEvent",
|
|
1339
|
+
"SdpNegotiationEvent", "SipTraceEvent", "MediaStatsEvent", "LogEvent",
|
|
1340
|
+
"RegistrarWarningEvent", "TransferRequestEvent", "TransferFailedEvent",
|
|
1341
|
+
"MwiEvent",
|
|
1342
|
+
"MessageEvent", "PresenceStateEvent", "QualityAlertEvent", "NetworkEvent",
|
|
1343
|
+
# Push provider constants (no string form in the C API)
|
|
1344
|
+
"PUSH_PROVIDER_NONE", "PUSH_PROVIDER_APNS",
|
|
1345
|
+
"PUSH_PROVIDER_APNS_SANDBOX", "PUSH_PROVIDER_FCM",
|
|
1346
|
+
]
|