Primo rilascio: Gateway SIP-DMR completo

This commit is contained in:
2026-08-08 16:01:06 +02:00
commit febef4f5f2
8 changed files with 730 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Ignora i file di configurazione reali (dati sensibili)
config.json
utenti.json
# Ignora gli ambienti virtuali di Python
venv/
env/
# Ignora i file di cache generati da Python
__pycache__/
*.pyc
# Ignora tutti i file di database SQLite
*.db
*.sqlite3
+136
View File
@@ -0,0 +1,136 @@
SIP-DMR Radio Gateway
Un gateway bidirezionale scritto in Python che permette di collegare centralini VoIP SIP (come FreePBX) a reti radio DMR (tramite protocollo OpenBridge).
Il sistema gestisce l'handshake SIP, la transcodifica audio in tempo reale tra PCM (G.711a) e AMBE, il controllo PTT tramite toni DTMF e il cambio dinamico del Talkgroup direttamente dal tastierino del telefono, simulando il comportamento di una vera radio.
🚀 Caratteristiche Principali
Integrazione Trasparente: Dialoga nativamente con FreePBX gestendo i ping OPTIONS (Trunk PJSIP).
Cambio Canale al Volo: Digita il numero del Talkgroup sul telefono e il gateway ti sposterà di stanza senza far cadere la chiamata.
Sicurezza IP: Rifiuta nativamente le connessioni SIP da IP non autorizzati.
Aggiornamento Display: Usa il protocollo re-INVITE per mostrare Caller ID (DMR ID) e Talkgroup attivi sui telefoni hardware compatibili.
🛠️ 1. Prerequisiti di Sistema (Debian / Ubuntu)
Prima di installare il gateway, assicurati di avere gli strumenti di compilazione e l'ambiente Python installati sul server:
Bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y git build-essential python3 python3-venv python3-pip
(Nota: su architetture a 64-bit potrebbe essere necessario installare libc6-dev-i386 per compilare correttamente l'emulatore AMBE).
📻 2. Installazione Motore Audio (md380-emu)
Il cuore della conversione audio è l'emulatore open-source md380-emu. Va scaricato, compilato e lasciato in esecuzione in background.
Bash
cd /opt
sudo git clone https://github.com/nostar/md380-emu.git
cd md380-emu
sudo make
Creazione del demone Systemd per l'emulatore:
Crea il file /etc/systemd/system/md380-emu.service:
Ini, TOML
[Unit]
Description=MD380 AMBE Emulator
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/md380-emu
ExecStart=/opt/md380-emu/emulator -p 2470
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Abilita e avvia il servizio:
Bash
sudo systemctl enable md380-emu --now
💻 3. Installazione del Gateway Python
Clona questo repository e configura l'ambiente virtuale:
Bash
cd /opt
git clone <URL_DEL_TUO_GITLAB> sip-dmr-gateway
cd sip-dmr-gateway
# Crea e attiva il Virtual Environment
python3 -m venv venv
source venv/bin/activate
# Installa le dipendenze
pip install -r requirements.txt
Copia i file di esempio per creare la tua configurazione reale:
Bash
cp config.example.json config.json
cp utenti.example.json utenti.json
Modifica config.json: Inserisci le credenziali del tuo server HBlink/OpenBridge, le porte e, soprattutto, l'IP autorizzato del tuo server FreePBX per la sicurezza.
⚙️ 4. Configurazione FreePBX
Affinché il centralino dialoghi correttamente con il Gateway, crea un Trunk PJSIP con queste regole:
Autenticazione: Nessuna (Identity via IP Match). L'IP match deve puntare all'IP del server dove gira questo script Python.
Qualify Frequency: Lascialo attivo (es. 60 secondi). Il gateway risponderà regolarmente con 200 OK.
Codecs: Disabilita tutto e lascia spuntato SOLO alaw (G.711a).
Advanced -> DTMF Mode: Imposta su RFC4733.
Advanced -> Trust RPID/PAI: Imposta su Yes.
Advanced -> Send RPID/PAI: Imposta su Both.
Crea poi una Rotta in Uscita (Outbound Route) che intercetti un prefisso a tua scelta (es. 99) e che lo strippi (lo elimini), inviando al Trunk solo il numero del Talkgroup.
📞 5. Utilizzo e Comandi DTMF (Tastierino)
Chiama la rotta configurata su FreePBX seguita dal Talkgroup desiderato (es. componi 99 222 per entrare sul TG 222). Una volta in linea, usa il tastierino del telefono per pilotare la radio:
Tasto * (Asterisco): Premi per abilitare il PTT e iniziare a trasmettere.
Tasto # (Cancelletto): Premi per disabilitare il PTT e tornare in ascolto.
Tasti 0-9 (Cambio Canale): Digita il numero di un nuovo Talkgroup (es. 9, 1) e rilascia i tasti. Dopo circa 2,5 secondi il sistema ti sposterà automaticamente sul nuovo TG (verrai avvisato da un aggiornamento sul display del telefono, se supportato).
🔄 6. Esecuzione come Servizio (Produzione)
Per far avviare il Gateway automaticamente al boot, crea il file /etc/systemd/system/sipdmr.service:
Ini, TOML
[Unit]
Description=Gateway SIP-DMR Core
After=network.target md380-emu.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/sip-dmr-gateway
ExecStart=/opt/sip-dmr-gateway/venv/bin/python3 /opt/sip-dmr-gateway/core.py
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=sipdmr
[Install]
WantedBy=multi-user.target
Abilita e avvia il gateway:
Bash
sudo systemctl enable sipdmr --now
Per visualizzare i log in tempo reale e monitorare il traffico radio:
Bash
journalctl -u sipdmr -f
+11
View File
@@ -0,0 +1,11 @@
{
"hblink_ip": "192.168.1.100",
"hblink_port": 62047,
"hblink_password": "LaTuaPasswordQui",
"peer_id": 12345,
"sip_port": 55060,
"rtp_port": 10000,
"vocoder_host": "127.0.0.1",
"vocoder_port": 2470,
"freepbx_ip": "192.168.1.50"
}
+480
View File
@@ -0,0 +1,480 @@
import json
from bitstring import BitArray
import asyncio
import struct
import time
import os
import audioop
from vocoder_engine import VocoderEngine
from openbridge_protocol import crea_pacchetto_ping, calcola_hmac
from dmr_utils3.ambe_utils import convert49BitTo72BitAMBE, convert72BitTo49BitAMBE
# ====================================================================
# CARICAMENTO CONFIGURAZIONI (JSON)
# ====================================================================
def carica_config():
try:
with open("config.json", "r") as f:
return json.load(f)
except Exception as e:
print(f"[ERRORE CRITICO] File config.json non trovato o errato ({e}). Chiusura.")
exit(1)
CONFIG = carica_config()
HBLINK_IP = CONFIG.get("hblink_ip", "127.0.0.1")
HBLINK_PORT = CONFIG.get("hblink_port", 62047)
HBLINK_PASSWORD = CONFIG.get("hblink_password", "TestVOIP")
PEER_ID = CONFIG.get("peer_id", 22241)
SIP_PORT = CONFIG.get("sip_port", 55060)
RTP_PORT = CONFIG.get("rtp_port", 10000)
VOCODER_HOST = CONFIG.get("vocoder_host", "127.0.0.1")
VOCODER_PORT = CONFIG.get("vocoder_port", 2470)
# --- NUOVA VARIABILE PER LA SICUREZZA IP ---
FREEPBX_IP = CONFIG.get("freepbx_ip", "127.0.0.1")
def carica_rubrica():
try:
with open("utenti.json", "r") as f:
return json.load(f)
except Exception as e:
return {}
UTENTI_SIP_DMR = carica_rubrica()
# ====================================================================
# CLASSE SESSIONE SIP (Con Buffer DTMF per il cambio TG)
# ====================================================================
class SessioneSIP:
def __init__(self, call_id, chiamante, destinatario_tg, rtp_addr, sip_addr, full_from, full_to, ip_vps):
self.call_id = call_id
self.chiamante = chiamante
self.tg_destinazione = str(destinatario_tg)
self.dmr_id = int(UTENTI_SIP_DMR.get(str(chiamante), 2223619))
self.rtp_client_addr = rtp_addr
self.sip_client_addr = sip_addr
self.ip_vps = ip_vps
self.rtp_seq = 0
self.rtp_timestamp = 0
self.dialog_to = full_from
self.dialog_from = full_to.split(';')[0] + ";tag=987654"
self.local_cseq = 10
self.ptt_attivo = False
self.buffer_ambe = []
self.seq_dmr = 0
self.stream_id = os.urandom(4)
self.ultimo_src_id = None
# --- VARIABILI PER IL CAMBIO TG TRAMITE DTMF ---
self.dtmf_buffer = ""
self.dtmf_task = None
self.ultimo_tempo_rtp_dtmf = 0
self.ultimo_evento_dtmf = None
class OpenBridgeClient(asyncio.DatagramProtocol):
def __init__(self, manager):
self.transport = None
self.manager = manager
def connection_made(self, transport):
self.transport = transport
print(f"[OpenBridge] Socket connesso a {HBLINK_IP}:{HBLINK_PORT}")
asyncio.create_task(self.keepalive_loop())
def datagram_received(self, data, addr):
if len(data) < 53:
return
if data.startswith(b"DMRD") and len(data) >= 73:
src_id = int.from_bytes(data[5:8], 'big')
tg_arrivo = str(int.from_bytes(data[8:11], 'big'))
# Il routing è dinamico: filtra in base al tg_destinazione attuale della sessione
sessioni_interessate = [s for s in self.manager.sessioni.values() if s.tg_destinazione == tg_arrivo]
if not sessioni_interessate: return
print("D", end="", flush=True)
payload_33 = data[20:53]
ambe1 = payload_33[0:9]
ambe2 = bytearray(9)
ambe2[0:4] = payload_33[9:13]
ambe2[4] = (payload_33[13] & 0xF0) | (payload_33[19] & 0x0F)
ambe2[5:9] = payload_33[20:24]
ambe3 = payload_33[24:33]
frame_uniti = ambe1 + bytes(ambe2) + ambe3
self.manager.ricevi_frame_dmr_multi(frame_uniti, sessioni_interessate, src_id)
async def keepalive_loop(self):
while True:
if self.transport:
pacchetto_ping = crea_pacchetto_ping(PEER_ID, HBLINK_PASSWORD)
self.transport.sendto(pacchetto_ping)
await asyncio.sleep(10)
def apri_flusso_chiamata(self, sess):
if not self.transport: return
flags = 0
payload_base = struct.pack("!3sB I 4s B I I", b"OPB", 0x80, PEER_ID, sess.stream_id, flags, sess.dmr_id, int(sess.tg_destinazione))
firma_hmac = calcola_hmac(HBLINK_PASSWORD, payload_base)
self.transport.sendto(payload_base + firma_hmac)
def invia_dmrd_voce(self, sess, payload_ambe):
if not self.transport: return
try:
magic = b"DMRD"
seq_byte = struct.pack("B", sess.seq_dmr)
src_bytes = sess.dmr_id.to_bytes(3, 'big')
dst_bytes = int(sess.tg_destinazione).to_bytes(3, 'big')
peer_bytes = PEER_ID.to_bytes(4, 'big')
flags_byte = struct.pack("B", 0x00)
header = magic + seq_byte + src_bytes + dst_bytes + peer_bytes + flags_byte + sess.stream_id
payload_ambe = payload_ambe.ljust(27, b'\x00')
ambe1 = payload_ambe[0:9]
ambe2 = payload_ambe[9:18]
ambe3 = payload_ambe[18:27]
payload_33 = bytearray(33)
payload_33[0:9] = ambe1
payload_33[9:13] = ambe2[0:4]
payload_33[13] = ambe2[4] & 0xF0
payload_33[19] = ambe2[4] & 0x0F
payload_33[20:24] = ambe2[5:9]
payload_33[24:33] = ambe3
pacchetto_base = header + bytes(payload_33)
firma_hmac = calcola_hmac(HBLINK_PASSWORD, pacchetto_base)
self.transport.sendto(pacchetto_base + firma_hmac)
except Exception:
pass
def chiudi_flusso_chiamata(self, sess):
if not self.transport: return
magic = b"DMRD"
flags_byte = struct.pack("B", 0x02)
src_bytes = sess.dmr_id.to_bytes(3, 'big')
dst_bytes = int(sess.tg_destinazione).to_bytes(3, 'big')
peer_bytes = PEER_ID.to_bytes(4, 'big')
for i in range(3):
seq_byte = struct.pack("B", (sess.seq_dmr + i) % 256)
header = magic + seq_byte + src_bytes + dst_bytes + peer_bytes + flags_byte + sess.stream_id
pacchetto_base = header + (b'\x00' * 33)
firma_hmac = calcola_hmac(HBLINK_PASSWORD, pacchetto_base)
self.transport.sendto(pacchetto_base + firma_hmac)
# ====================================================================
# GESTORE CENTRALE DELLE SESSIONI
# ====================================================================
class SessionManager:
def __init__(self):
self.vocoder = VocoderEngine(mode="emulator", host=VOCODER_HOST, port=VOCODER_PORT)
self.openbridge = None
self.rtp_transport = None
self.sip_transport = None
self.sessioni = {}
async def avvia(self):
await self.vocoder.avvia()
loop = asyncio.get_running_loop()
_, self.openbridge = await loop.create_datagram_endpoint(
lambda: OpenBridgeClient(self),
local_addr=('0.0.0.0', HBLINK_PORT),
remote_addr=(HBLINK_IP, HBLINK_PORT)
)
print(f"[Core] PBX Multi-Utente avviato. SIP su porta {SIP_PORT}...\n")
print(f"[Security] Accetto connessioni SIP SOLO da: {FREEPBX_IP}\n")
def trova_sessione_per_rtp(self, addr):
for sess in self.sessioni.values():
if sess.rtp_client_addr and sess.rtp_client_addr[0] == addr[0]:
sess.rtp_client_addr = addr
return sess
return None
def gestisci_nuova_chiamata(self, call_id, chiamante, destinatario, rtp_addr, sip_addr, full_from, full_to, ip_vps):
sess = SessioneSIP(call_id, chiamante, destinatario, rtp_addr, sip_addr, full_from, full_to, ip_vps)
self.sessioni[call_id] = sess
print(f"\n[Core] >>> SIP {chiamante} -> TG {destinatario} | ID: {sess.dmr_id}")
def termina_chiamata(self, call_id):
if call_id in self.sessioni:
sess = self.sessioni[call_id]
if sess.ptt_attivo and self.openbridge:
self.openbridge.chiudi_flusso_chiamata(sess)
del self.sessioni[call_id]
def gestisci_ptt(self, sess, stato):
if stato is True and not sess.ptt_attivo:
sess.ptt_attivo = True
sess.buffer_ambe = []
sess.seq_dmr = 0
sess.stream_id = os.urandom(4)
if self.openbridge: self.openbridge.apri_flusso_chiamata(sess)
elif stato is False and sess.ptt_attivo:
sess.ptt_attivo = False
if self.openbridge: self.openbridge.chiudi_flusso_chiamata(sess)
# --- LOGICA CAMBIO TG (DTMF 0-9) ---
def gestisci_dtmf_digit(self, sess, digit):
if sess.ptt_attivo: return # Evita di cambiare TG mentre sei in TX
sess.dtmf_buffer += str(digit)
# Resetta il task esistente se si preme un altro numero velocemente
if sess.dtmf_task:
sess.dtmf_task.cancel()
sess.dtmf_task = asyncio.create_task(self.attendi_cambio_tg(sess))
async def attendi_cambio_tg(self, sess):
try:
# Attende 2.5 secondi dalla pressione dell'ultimo tasto
await asyncio.sleep(2.5)
if sess.dtmf_buffer:
nuovo_tg = sess.dtmf_buffer
sess.dtmf_buffer = "" # Svuota il buffer
print(f"\n[Core] *** CAMBIO TG: {sess.chiamante} è passato al Talkgroup {nuovo_tg} ***")
sess.tg_destinazione = nuovo_tg
# Notifica il telefono del cambio avvenuto
self.notifica_display(sess, f"TG: {nuovo_tg}")
except asyncio.CancelledError:
pass # Il task è stato annullato perché l'utente ha premuto un altro tasto
# --- FUNZIONE DISPLAY GENERICA (Ora accetta stringhe personalizzate) ---
def notifica_display(self, sess, testo_display):
if not self.sip_transport or not sess.sip_client_addr:
return
sess.local_cseq += 1
branch = f"z9hG4bK-{os.urandom(4).hex()}"
client_sip_ip, client_sip_port = sess.sip_client_addr
sdp = (f"v=0\r\no=- 12345 12345 IN IP4 {sess.ip_vps}\r\ns=Talk\r\n"
f"c=IN IP4 {sess.ip_vps}\r\nt=0 0\r\nm=audio {RTP_PORT} RTP/AVP 8 101\r\n"
f"a=rtpmap:8 PCMA/8000\r\na=rtpmap:101 telephone-event/8000\r\na=sendrecv\r\n")
sip_reinvite = (
f"INVITE sip:{sess.chiamante}@{client_sip_ip}:{client_sip_port} SIP/2.0\r\n"
f"Via: SIP/2.0/UDP 127.0.0.1:{SIP_PORT};branch={branch}\r\n"
f"Max-Forwards: 70\r\n"
f"From: {sess.dialog_from}\r\n"
f"To: {sess.dialog_to}\r\n"
f"Call-ID: {sess.call_id}\r\n"
f"CSeq: {sess.local_cseq} INVITE\r\n"
f"Contact: <sip:gateway@127.0.0.1:{SIP_PORT}>\r\n"
f"Remote-Party-ID: \"{testo_display}\" <sip:000@127.0.0.1>;party=called;screen=yes;privacy=off\r\n"
f"P-Asserted-Identity: \"{testo_display}\" <sip:000@127.0.0.1>\r\n"
f"Content-Type: application/sdp\r\n"
f"Content-Length: {len(sdp)}\r\n"
f"\r\n"
f"{sdp}"
)
self.sip_transport.sendto(sip_reinvite.encode('utf-8'), sess.sip_client_addr)
def ricevi_frame_dmr_multi(self, ambe_frames, sessioni_interessate, src_id):
ascoltatori = [s for s in sessioni_interessate if not s.ptt_attivo]
if not ascoltatori or not self.rtp_transport: return
for sess in ascoltatori:
if sess.ultimo_src_id != src_id:
sess.ultimo_src_id = src_id
self.notifica_display(sess, f"RX: {src_id}")
asyncio.create_task(self.processa_audio_dmr(ambe_frames, ascoltatori))
async def processa_audio_dmr(self, ambe_27_bytes, ascoltatori):
try:
for i in range(0, len(ambe_27_bytes), 9):
frame_9 = ambe_27_bytes[i:i+9]
if len(frame_9) < 9: continue
try:
ambe_49_bits = convert72BitTo49BitAMBE(BitArray(bytes=frame_9))
ba_49 = ambe_49_bits if isinstance(ambe_49_bits, BitArray) else BitArray(ambe_49_bits)
if len(ba_49) < 56: ba_49.append(BitArray(uint=0, length=56 - len(ba_49)))
frame_7 = ba_49.bytes[:7]
except Exception: continue
pcm_data = await self.vocoder.converti_ambe_in_pcm(frame_7)
if not pcm_data or len(pcm_data) < 2: continue
if len(pcm_data) % 2 != 0: pcm_data = pcm_data[:-1]
pcma_data = audioop.lin2alaw(pcm_data, 2)
for sess in ascoltatori:
header = bytearray(12)
header[0] = 0x80; header[1] = 0x08
header[2:4] = struct.pack("!H", sess.rtp_seq)
header[4:8] = struct.pack("!I", sess.rtp_timestamp)
header[8:12] = struct.pack("!I", 0x12345678)
rtp_packet = header + pcma_data
if sess.rtp_client_addr:
self.rtp_transport.sendto(rtp_packet, sess.rtp_client_addr)
sess.rtp_seq = (sess.rtp_seq + 1) % 65536
sess.rtp_timestamp = (sess.rtp_timestamp + 160) % 4294967296
except Exception:
pass
async def processa_audio_rtp(self, sess, rtp_payload):
if not sess.ptt_attivo: return
try:
ambe_bytes = await asyncio.wait_for(self.vocoder.converti_rtp_in_ambe(rtp_payload), timeout=0.1)
if not ambe_bytes: return
dim_frame = 7 if len(ambe_bytes) % 7 == 0 else 9
for i in range(0, len(ambe_bytes), dim_frame):
frame = ambe_bytes[i:i+dim_frame]
if len(frame) == 7:
stringa_bit = BitArray(bytes=frame).bin
ambe_49_bits = [int(b) for b in stringa_bit[:49]]
fec_9_byte = convert49BitTo72BitAMBE(ambe_49_bits)
sess.buffer_ambe.append(bytes(fec_9_byte))
elif len(frame) == 9:
sess.buffer_ambe.append(frame)
while len(sess.buffer_ambe) >= 3:
payload_vocale = b''.join(sess.buffer_ambe[:3])
sess.buffer_ambe = sess.buffer_ambe[3:]
self.openbridge.invia_dmrd_voce(sess, payload_vocale)
sess.seq_dmr = (sess.seq_dmr + 1) % 256
except Exception:
pass
# ====================================================================
# PROTOCOLLI DI RETE SIP/RTP
# ====================================================================
class RtpProtocol(asyncio.DatagramProtocol):
def __init__(self, manager): self.manager = manager
def connection_made(self, transport): self.manager.rtp_transport = transport
def datagram_received(self, data, addr):
sess = self.manager.trova_sessione_per_rtp(addr)
if not sess or len(data) < 12: return
payload_type = data[1] & 0x7F
if payload_type == 8:
asyncio.create_task(self.manager.processa_audio_rtp(sess, data[12:]))
elif payload_type == 101:
evento_dtmf = data[12]
# Meccanismo anti-rimbalzo (Debounce) per ignorare i frame duplicati
ora = time.time()
if ora - sess.ultimo_tempo_rtp_dtmf < 0.2 and sess.ultimo_evento_dtmf == evento_dtmf:
return
sess.ultimo_tempo_rtp_dtmf = ora
sess.ultimo_evento_dtmf = evento_dtmf
# Esecuzione dei Comandi
if evento_dtmf == 10:
self.manager.gestisci_ptt(sess, True)
elif evento_dtmf == 11:
self.manager.gestisci_ptt(sess, False)
elif 0 <= evento_dtmf <= 9:
self.manager.gestisci_dtmf_digit(sess, evento_dtmf)
class SipProtocol(asyncio.DatagramProtocol):
def __init__(self, manager): self.manager = manager
def connection_made(self, transport):
self.transport = transport
self.manager.sip_transport = transport
def datagram_received(self, data, addr):
# --- SICUREZZA: BLOCCO IP ---
if addr[0] != FREEPBX_IP and FREEPBX_IP != "0.0.0.0":
# Decommenta la riga sotto se vuoi vedere i log dei tentativi bloccati
# print(f"[SECURITY] Rifiutato pacchetto SIP da IP non autorizzato: {addr[0]}")
return
# ----------------------------
msg = data.decode('utf-8', errors='ignore')
headers = {line.split(":", 1)[0].strip().upper(): line.split(":", 1)[1].strip() for line in msg.split('\r\n')[1:] if ":" in line}
call_id = headers.get('CALL-ID', '')
if msg.startswith("OPTIONS"):
resp = (f"SIP/2.0 200 OK\r\nVia: {headers.get('VIA', '')}\r\n"
f"From: {headers.get('FROM', '')}\r\nTo: {headers.get('TO', '')}\r\n"
f"Call-ID: {call_id}\r\nCSeq: {headers.get('CSEQ', '')}\r\n"
f"Contact: <sip:gateway@127.0.0.1:{SIP_PORT}>\r\n"
f"Content-Length: 0\r\n\r\n")
self.transport.sendto(resp.encode('utf-8'), addr)
elif msg.startswith("INVITE"):
client_rtp_port = 4000
for line in msg.split('\n'):
line = line.strip()
if line.startswith("m=audio "): client_rtp_port = int(line.split()[1])
ip_vps = "127.0.0.1"
if "@" in headers.get("TO", ""): ip_vps = headers.get("TO", "").split("@")[1].split(">")[0]
chiamante = headers.get("FROM", "").split("sip:")[1].split("@")[0]
destinatario = headers.get("TO", "").split("sip:")[1].split("@")[0]
full_from = headers.get('FROM', '')
full_to = headers.get('TO', '')
self.manager.gestisci_nuova_chiamata(call_id, chiamante, destinatario, (addr[0], client_rtp_port), addr, full_from, full_to, ip_vps)
sdp = (f"v=0\r\no=- 12345 12345 IN IP4 {ip_vps}\r\ns=Talk\r\n"
f"c=IN IP4 {ip_vps}\r\nt=0 0\r\nm=audio {RTP_PORT} RTP/AVP 8 101\r\n"
f"a=rtpmap:8 PCMA/8000\r\na=rtpmap:101 telephone-event/8000\r\na=sendrecv\r\n")
resp = (f"SIP/2.0 200 OK\r\nVia: {headers.get('VIA', '')}\r\n"
f"From: {full_from}\r\nTo: {full_to};tag=987654\r\n"
f"Call-ID: {call_id}\r\nCSeq: {headers.get('CSEQ', '')}\r\n"
f"Contact: <sip:gateway@{ip_vps}:{SIP_PORT}>\r\nContent-Type: application/sdp\r\n"
f"Content-Length: {len(sdp)}\r\n\r\n{sdp}")
self.transport.sendto(resp.encode('utf-8'), addr)
elif msg.startswith("SIP/2.0 200 OK"):
if "INVITE" in headers.get('CSEQ', ''):
cseq_num = headers.get('CSEQ', '').split()[0]
ack_uri = f"sip:{addr[0]}:{addr[1]}"
if "CONTACT" in headers:
contact_hdr = headers["CONTACT"]
if "<" in contact_hdr:
ack_uri = contact_hdr.split("<")[1].split(">")[0]
ack = (f"ACK {ack_uri} SIP/2.0\r\n"
f"Via: SIP/2.0/UDP 127.0.0.1:{SIP_PORT};branch=z9hG4bK-{os.urandom(4).hex()}\r\n"
f"Max-Forwards: 70\r\n"
f"From: {headers.get('FROM', '')}\r\n"
f"To: {headers.get('TO', '')}\r\n"
f"Call-ID: {call_id}\r\n"
f"CSeq: {cseq_num} ACK\r\n"
f"Content-Length: 0\r\n\r\n")
self.transport.sendto(ack.encode('utf-8'), addr)
elif msg.startswith("BYE"):
self.manager.termina_chiamata(call_id)
resp = (f"SIP/2.0 200 OK\r\nVia: {headers.get('VIA', '')}\r\n"
f"From: {headers.get('FROM', '')}\r\nTo: {headers.get('TO', '')}\r\n"
f"Call-ID: {call_id}\r\nCSeq: {headers.get('CSEQ', '')}\r\n"
f"Content-Length: 0\r\n\r\n")
self.transport.sendto(resp.encode('utf-8'), addr)
async def main():
loop = asyncio.get_running_loop()
manager = SessionManager()
await manager.avvia()
_, _ = await loop.create_datagram_endpoint(lambda: SipProtocol(manager), local_addr=('0.0.0.0', SIP_PORT))
_, _ = await loop.create_datagram_endpoint(lambda: RtpProtocol(manager), local_addr=('0.0.0.0', RTP_PORT))
try:
await asyncio.Event().wait()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
asyncio.run(main())
+35
View File
@@ -0,0 +1,35 @@
import hmac
import hashlib
import struct
import time
def calcola_hmac(passphrase: str, dati: bytes) -> bytes:
"""Calcola la firma HMAC-SHA1 richiesta dalle reti FreeDMR/HBLink."""
return hmac.new(passphrase.encode('utf-8'), dati, hashlib.sha1).digest()
def crea_pacchetto_ping(peer_id: int, passphrase: str) -> bytes:
"""
Crea un pacchetto PING OpenBridge formattato con autenticazione HMAC.
"""
# Header OpenBridge tipico: Magic 'OPB' (3 byte) + Tipo 0x01 (Ping) + PeerID (4 byte) + Timestamp (4 byte)
timestamp = int(time.time())
payload_base = struct.pack("!3sBI I", b"OPB", 0x01, peer_id, timestamp)
# Calcoliamo la firma HMAC-SHA1 della password sul payload base
firma_hmac = calcola_hmac(passphrase, payload_base)
# Restituiamo il pacchetto completo (Payload + HMAC)
return payload_base + firma_hmac
def crea_superheader_chiamata(peer_id: int, passphrase: str, src_id: int, dst_tg: int, slot: int, stream_id: bytes) -> bytes:
"""
Crea l'intestazione di inizio trasmissione (Call Start / SuperHeader) per HBLink.
"""
# Header: Magic 'OPB' + Tipo 0x80 (Start Call) + PeerID + StreamID + Slot + SrcID + DstTG
# (Formato compatibile con le specifiche OpenBridge/HomeBrew)
flags = (slot & 0x01) << 7
payload_base = struct.pack("!3sB I 4s B I I",
b"OPB", 0x80, peer_id, stream_id[:4], flags, src_id, dst_tg)
firma_hmac = calcola_hmac(passphrase, payload_base)
return payload_base + firma_hmac
+2
View File
@@ -0,0 +1,2 @@
bitstring
dmr_utils3
+4
View File
@@ -0,0 +1,4 @@
{
"101": 2220001,
"102": 2220002
}
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import audioop
class VocoderEngine:
def __init__(self, mode="emulator", host="127.0.0.1", port=2470):
self.mode = mode
self.host = host
self.port = port
self.transport = None
self.coda_risposte = asyncio.Queue()
# IL SEMAFORO ASINCRONO PER IL MULTI-UTENTE
self.lock = asyncio.Lock()
async def avvia(self):
loop = asyncio.get_running_loop()
class AmbedProtocol(asyncio.DatagramProtocol):
def __init__(self, coda): self.coda = coda
def connection_made(self, transport): pass
def datagram_received(self, data, addr): self.coda.put_nowait(data)
def error_received(self, exc): pass
self.transport, _ = await loop.create_datagram_endpoint(
lambda: AmbedProtocol(self.coda_risposte),
remote_addr=(self.host, self.port)
)
print(f"[Vocoder] Motore avviato. Modalità: {self.mode.upper()}")
print(f"[Vocoder] Connesso a {self.host}:{self.port}")
async def converti_rtp_in_ambe(self, rtp_payload_alaw):
pcm_data = audioop.alaw2lin(rtp_payload_alaw, 2)
if self.mode == "emulator":
async with self.lock: # <--- Aspetta il suo turno se occupato
self.transport.sendto(pcm_data)
try: return await asyncio.wait_for(self.coda_risposte.get(), timeout=0.1)
except asyncio.TimeoutError: return None
async def converti_ambe_in_pcm(self, ambe_bytes):
if self.mode == "emulator":
async with self.lock: # <--- Aspetta il suo turno se occupato
self.transport.sendto(ambe_bytes)
try: return await asyncio.wait_for(self.coda_risposte.get(), timeout=0.1)
except asyncio.TimeoutError: return None
def ferma(self):
if self.transport:
self.transport.close()
print("[Vocoder] Motore spento.")