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 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
+ ]