Primo rilascio: Gateway SIP-DMR completo
This commit is contained in:
+15
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
bitstring
|
||||
dmr_utils3
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"101": 2220001,
|
||||
"102": 2220002
|
||||
}
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user