Prima release ufficiale di Web-DMR
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
|
||||||
+322124
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DMR Web Receiver</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; background-color: #2c3e50; color: white; margin-top: 40px; }
|
||||||
|
.container { max-width: 420px; margin: 0 auto; background: #34495e; padding: 25px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); }
|
||||||
|
.display { background: #1abc9c; padding: 20px; border-radius: 5px; font-size: 24px; font-family: monospace; margin-bottom: 20px; color: #fff; font-weight: bold; transition: background 0.3s; }
|
||||||
|
|
||||||
|
.tg-box { display: flex; gap: 10px; margin-bottom: 15px; }
|
||||||
|
input[type="number"] { flex: 1; padding: 12px; font-size: 16px; border-radius: 5px; border: none; outline: none; text-align: center; font-weight: bold; }
|
||||||
|
.set-btn { background-color: #e67e22; color: white; border: none; padding: 12px 18px; border-radius: 5px; font-size: 15px; cursor: pointer; font-weight: bold; }
|
||||||
|
.set-btn:hover { background-color: #d35400; }
|
||||||
|
|
||||||
|
.quick-tg { display: flex; gap: 8px; justify-content: center; margin-bottom: 20px; }
|
||||||
|
.quick-btn { background: #7f8c8d; color: white; border: none; padding: 6px 12px; border-radius: 4px; font-size: 13px; cursor: pointer; }
|
||||||
|
.quick-btn:hover { background: #95a5a6; }
|
||||||
|
|
||||||
|
/* --- STILE PER IL CONTROLLO VOLUME --- */
|
||||||
|
.volume-box { margin-bottom: 20px; text-align: left; background: #2c3e50; padding: 10px 15px; border-radius: 5px; }
|
||||||
|
.volume-box label { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 8px; color: #bdc3c7; font-weight: bold; }
|
||||||
|
input[type="range"] { width: 100%; cursor: pointer; accent-color: #3498db; }
|
||||||
|
|
||||||
|
.connect-btn { background-color: #3498db; color: white; padding: 15px 24px; border: none; border-radius: 5px; font-size: 18px; cursor: pointer; margin-bottom: 15px; width: 100%; font-weight: bold; }
|
||||||
|
.connect-btn:hover { background-color: #2980b9; }
|
||||||
|
.status { font-size: 14px; color: #bdc3c7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>ARI FVG Web-Receiver</h2>
|
||||||
|
|
||||||
|
<!-- Display Caller ID -->
|
||||||
|
<div class="display" id="displayScreen">IN ASCOLTO</div>
|
||||||
|
|
||||||
|
<!-- Campo di inserimento TG Libero -->
|
||||||
|
<div class="tg-box">
|
||||||
|
<input type="number" id="tgInput" placeholder="Inserisci TG" value="222" min="1" max="9999999">
|
||||||
|
<button class="set-btn" id="setTgBtn">Sintonizza</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scorciatoie Rapide -->
|
||||||
|
<div class="quick-tg">
|
||||||
|
<button class="quick-btn" onclick="setQuickTg(112)">TG 112 ARI FVG PC</button>
|
||||||
|
<button class="quick-btn" onclick="setQuickTg(22231)">TG 22231 BM</button>
|
||||||
|
<button class="quick-btn" onclick="setQuickTg(22200)">TG 22200 FreeDMR</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTROLLO VOLUME -->
|
||||||
|
<div class="volume-box">
|
||||||
|
<label for="volumeSlider"><span>Volume</span> <span id="volumeValue">100%</span></label>
|
||||||
|
<input type="range" id="volumeSlider" min="0" max="2" step="0.05" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottone Avvio -->
|
||||||
|
<button class="connect-btn" id="connectBtn">📡 Avvia Ascolto DMR</button>
|
||||||
|
|
||||||
|
<div class="status" id="statusText">Clicca il pulsante per attivare l'audio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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,3 @@
|
|||||||
|
aiohttp
|
||||||
|
aiortc
|
||||||
|
websockets
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from aiohttp import web
|
||||||
|
from bitstring import BitArray
|
||||||
|
|
||||||
|
from vocoder_engine import VocoderEngine
|
||||||
|
from openbridge_protocol import crea_pacchetto_ping
|
||||||
|
from dmr_utils3.ambe_utils import convert72BitTo49BitAMBE
|
||||||
|
|
||||||
|
# Importazione compatibilità audioop per Python 3.13+
|
||||||
|
try:
|
||||||
|
import audioop
|
||||||
|
except ImportError:
|
||||||
|
import audioop_lts as audioop
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# CONFIGURAZIONE
|
||||||
|
# ====================================================================
|
||||||
|
MAX_UTENTI_WEB = 5 # Limite massimo di utenti in ascolto contemporaneo
|
||||||
|
|
||||||
|
def carica_config():
|
||||||
|
try:
|
||||||
|
with open("config.json", "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"hblink_ip": "127.0.0.1",
|
||||||
|
"hblink_port": 62047,
|
||||||
|
"hblink_password": "TestVOIP",
|
||||||
|
"peer_id": 22242
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG = carica_config()
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# DATABASE NOMINATIVI DMR
|
||||||
|
# ====================================================================
|
||||||
|
DMR_DB = {}
|
||||||
|
|
||||||
|
def carica_database_dmr():
|
||||||
|
global DMR_DB
|
||||||
|
DMR_DB = {}
|
||||||
|
|
||||||
|
if os.path.exists("DMRIds.json"):
|
||||||
|
try:
|
||||||
|
with open("DMRIds.json", "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict) and "users" in data:
|
||||||
|
for entry in data["users"]:
|
||||||
|
uid = int(entry.get("id", 0))
|
||||||
|
call = entry.get("callsign", "")
|
||||||
|
fname = entry.get("fname", "")
|
||||||
|
if uid and call:
|
||||||
|
DMR_DB[uid] = f"{call} - {fname}".strip(" -")
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
for k, v in data.items():
|
||||||
|
try:
|
||||||
|
uid = int(k)
|
||||||
|
if isinstance(v, str): DMR_DB[uid] = v
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
DMR_DB[uid] = f"{v.get('callsign', '')} - {v.get('fname', '')}".strip(" -")
|
||||||
|
except ValueError: continue
|
||||||
|
print(f"[DMR-DB] Caricati con successo {len(DMR_DB)} nominativi da DMRIds.json!")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DMR-DB] Errore lettura DMRIds.json: {e}")
|
||||||
|
|
||||||
|
for dat_file in ["DMRIds.dat", "StripDMRIDs.dat"]:
|
||||||
|
if os.path.exists(dat_file):
|
||||||
|
try:
|
||||||
|
count = 0
|
||||||
|
with open(dat_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"): continue
|
||||||
|
if ";" in line: parts = [p.strip() for p in line.split(";") if p.strip()]
|
||||||
|
elif "," in line: parts = [p.strip() for p in line.split(",") if p.strip()]
|
||||||
|
else: parts = line.split()
|
||||||
|
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
uid = int(parts[0])
|
||||||
|
call = parts[1]
|
||||||
|
fname = parts[2] if len(parts) > 2 else ""
|
||||||
|
lname = parts[3] if len(parts) > 3 else ""
|
||||||
|
nome_completo = f"{fname} {lname}".strip()
|
||||||
|
DMR_DB[uid] = f"{call} - {nome_completo}" if nome_completo else call
|
||||||
|
count += 1
|
||||||
|
except ValueError: continue
|
||||||
|
print(f"[DMR-DB] Caricati con successo {count} nominativi da {dat_file}!")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DMR-DB] Errore lettura {dat_file}: {e}")
|
||||||
|
|
||||||
|
print("[DMR-DB] Nessun file DMRIds valido trovato.")
|
||||||
|
|
||||||
|
def cerca_nominativo(dmr_id):
|
||||||
|
if dmr_id in DMR_DB:
|
||||||
|
return f"{DMR_DB[dmr_id]} ({dmr_id})"
|
||||||
|
return f"ID: {dmr_id}"
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# PROTOCOLLO OPENBRIDGE (Solo Ricezione)
|
||||||
|
# ====================================================================
|
||||||
|
class OpenBridgeRxClient(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self, server_app):
|
||||||
|
self.transport = None
|
||||||
|
self.server_app = server_app
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
self.transport = transport
|
||||||
|
print(f"[OpenBridge] Client RX connesso a {CONFIG['hblink_ip']}:{CONFIG['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'))
|
||||||
|
|
||||||
|
# Ricava l'elenco dei TG attualmente richiesti da almeno un utente Web
|
||||||
|
tg_attivi = {client_data["tg"] for client_data in self.server_app.clients.values()}
|
||||||
|
|
||||||
|
# Processa l'audio solo se c'è almeno un utente in ascolto su quel TG
|
||||||
|
if tg_arrivo in tg_attivi:
|
||||||
|
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.server_app.ricevi_frame_dmr(frame_uniti, src_id, tg_arrivo)
|
||||||
|
|
||||||
|
async def keepalive_loop(self):
|
||||||
|
while True:
|
||||||
|
if self.transport:
|
||||||
|
pacchetto_ping = crea_pacchetto_ping(CONFIG['peer_id'], CONFIG['hblink_password'])
|
||||||
|
self.transport.sendto(pacchetto_ping)
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# GESTORE DEL SERVER MULTI-UTENTE
|
||||||
|
# ====================================================================
|
||||||
|
class WebDmrServer:
|
||||||
|
def __init__(self):
|
||||||
|
self.vocoder = VocoderEngine(port_start=2475, port_end=2479)
|
||||||
|
self.openbridge = None
|
||||||
|
|
||||||
|
# Dizionario client: ws -> {"tg": "222"}
|
||||||
|
self.clients = {}
|
||||||
|
|
||||||
|
# Dizionario stati indipendenti per TG: "222" -> {"rx_in_corso": False, ...}
|
||||||
|
self.tg_states = {}
|
||||||
|
|
||||||
|
def ricevi_frame_dmr(self, ambe_frames, src_id, tg):
|
||||||
|
if tg not in self.tg_states:
|
||||||
|
self.tg_states[tg] = {"rx_in_corso": False, "ultimo_src_id": None, "ultimo_frame_time": 0}
|
||||||
|
|
||||||
|
stato_tg = self.tg_states[tg]
|
||||||
|
stato_tg["ultimo_frame_time"] = time.time()
|
||||||
|
|
||||||
|
# Notifica i client in ascolto su questo TG se inizia una nuova trasmissione
|
||||||
|
if not stato_tg["rx_in_corso"] or stato_tg["ultimo_src_id"] != src_id:
|
||||||
|
stato_tg["rx_in_corso"] = True
|
||||||
|
stato_tg["ultimo_src_id"] = src_id
|
||||||
|
|
||||||
|
info_utente = cerca_nominativo(src_id)
|
||||||
|
print(f"[DMR-RX] Inizio trasmissione su TG {tg}: {info_utente}")
|
||||||
|
|
||||||
|
asyncio.create_task(self.broadcast_json_to_tg(tg, {
|
||||||
|
"action": "display",
|
||||||
|
"text": info_utente,
|
||||||
|
"active": True
|
||||||
|
}))
|
||||||
|
|
||||||
|
asyncio.create_task(self.processa_audio_dmr(ambe_frames, tg))
|
||||||
|
|
||||||
|
async def watchdog_rx(self):
|
||||||
|
"""Monitora il silenzio e ripristina lo stato per i singoli TG"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
ora = time.time()
|
||||||
|
for tg, stato_tg in list(self.tg_states.items()):
|
||||||
|
if stato_tg["rx_in_corso"] and (ora - stato_tg["ultimo_frame_time"] > 1.2):
|
||||||
|
stato_tg["rx_in_corso"] = False
|
||||||
|
stato_tg["ultimo_src_id"] = None
|
||||||
|
print(f"[DMR-RX] Chiamata terminata su TG {tg}")
|
||||||
|
await self.broadcast_json_to_tg(tg, {
|
||||||
|
"action": "display",
|
||||||
|
"text": "IN ASCOLTO",
|
||||||
|
"active": False
|
||||||
|
})
|
||||||
|
|
||||||
|
async def processa_audio_dmr(self, ambe_27_bytes, tg):
|
||||||
|
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 = convert72BitTo49BitAMBE(BitArray(bytes=frame_9))
|
||||||
|
ba_49 = ambe_49 if isinstance(ambe_49, BitArray) else BitArray(ambe_49)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Identificatore Univoco del Vocoder in base al TG (es. WEB_RX_222)
|
||||||
|
pcm_data = await self.vocoder.converti_ambe_in_pcm(f"WEB_RX_{tg}", frame_7)
|
||||||
|
if pcm_data and len(pcm_data) >= 2:
|
||||||
|
if len(pcm_data) % 2 != 0: pcm_data = pcm_data[:-1]
|
||||||
|
|
||||||
|
pcm_data = audioop.mul(pcm_data, 2, 1.4)
|
||||||
|
|
||||||
|
# Spediamo i byte PCM SOLO agli utenti in ascolto su quel TG
|
||||||
|
for ws, client_data in list(self.clients.items()):
|
||||||
|
if client_data["tg"] == tg:
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(pcm_data)
|
||||||
|
except Exception:
|
||||||
|
self.clients.pop(ws, None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Audio RX Error su TG {tg}]: {e}")
|
||||||
|
|
||||||
|
async def broadcast_json_to_tg(self, tg, messaggio_dict):
|
||||||
|
"""Invia un messaggio di testo JSON solo agli utenti su un TG specifico"""
|
||||||
|
msg_str = json.dumps(messaggio_dict)
|
||||||
|
for ws, client_data in list(self.clients.items()):
|
||||||
|
if client_data["tg"] == tg:
|
||||||
|
try:
|
||||||
|
await ws.send_str(msg_str)
|
||||||
|
except Exception:
|
||||||
|
self.clients.pop(ws, None)
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# ENDPOINT
|
||||||
|
# ====================================================================
|
||||||
|
server_app = WebDmrServer()
|
||||||
|
|
||||||
|
async def index(request):
|
||||||
|
content = open("index.html", "r").read()
|
||||||
|
return web.Response(content_type="text/html", text=content)
|
||||||
|
|
||||||
|
async def websocket_handler(request):
|
||||||
|
ws = web.WebSocketResponse(heartbeat=30.0)
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
# --- CONTROLLO LIMITE UTENTI ---
|
||||||
|
if len(server_app.clients) >= MAX_UTENTI_WEB:
|
||||||
|
print(f"[WebSocket] Connessione rifiutata: limite di {MAX_UTENTI_WEB} utenti raggiunto.")
|
||||||
|
await ws.send_str(json.dumps({
|
||||||
|
"action": "error",
|
||||||
|
"text": "Server pieno! Raggiunto il limite massimo di 5 ascoltatori contemporanei."
|
||||||
|
}))
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
# Aggiungi il nuovo client con TG di default "222"
|
||||||
|
server_app.clients[ws] = {"tg": "222"}
|
||||||
|
print(f"[WebSocket] Nuovo client connesso (Totali: {len(server_app.clients)}/{MAX_UTENTI_WEB})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type == web.WSMsgType.TEXT:
|
||||||
|
dati = json.loads(msg.data)
|
||||||
|
|
||||||
|
if dati.get("action") == "cambia_tg":
|
||||||
|
nuovo_tg = str(dati.get("tg", "222"))
|
||||||
|
server_app.clients[ws]["tg"] = nuovo_tg
|
||||||
|
print(f"[Cambio TG Web]: Un utente si è sintonizzato sul TG {nuovo_tg}")
|
||||||
|
|
||||||
|
# --- SINCRONIZZAZIONE LATE-JOINER ---
|
||||||
|
# Se c'è già una conversazione attiva su questo TG, invia subito l'ID
|
||||||
|
stato_tg = server_app.tg_states.get(nuovo_tg)
|
||||||
|
if stato_tg and stato_tg.get("rx_in_corso"):
|
||||||
|
info_utente = cerca_nominativo(stato_tg["ultimo_src_id"])
|
||||||
|
asyncio.create_task(ws.send_str(json.dumps({
|
||||||
|
"action": "display",
|
||||||
|
"text": info_utente,
|
||||||
|
"active": True
|
||||||
|
})))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
server_app.clients.pop(ws, None)
|
||||||
|
print(f"[WebSocket] Client disconnesso (Rimanenti: {len(server_app.clients)}/{MAX_UTENTI_WEB})")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# AVVIO SERVER
|
||||||
|
# ====================================================================
|
||||||
|
async def on_startup(app):
|
||||||
|
carica_database_dmr()
|
||||||
|
await server_app.vocoder.avvia()
|
||||||
|
asyncio.create_task(server_app.watchdog_rx())
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
_, server_app.openbridge = await loop.create_datagram_endpoint(
|
||||||
|
lambda: OpenBridgeRxClient(server_app),
|
||||||
|
local_addr=('0.0.0.0', CONFIG["hblink_port"]),
|
||||||
|
remote_addr=(CONFIG["hblink_ip"], CONFIG["hblink_port"])
|
||||||
|
)
|
||||||
|
print("\n[Init] Ricevitore Web-DMR Multi-Utente avviato!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = web.Application()
|
||||||
|
app.on_startup.append(on_startup)
|
||||||
|
|
||||||
|
app.router.add_get("/", index)
|
||||||
|
app.router.add_get("/ws", websocket_handler)
|
||||||
|
app.router.add_static("/static/", path="static", name="static")
|
||||||
|
|
||||||
|
print("=========================================")
|
||||||
|
print(" DVSwitch Web-Audio Receiver in ascolto...")
|
||||||
|
print("=========================================")
|
||||||
|
web.run_app(app, port=8080)
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
let ws = null;
|
||||||
|
let audioCtx = null;
|
||||||
|
let gainNode = null; // Il controller del volume
|
||||||
|
let nextStartTime = 0;
|
||||||
|
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const tgInput = document.getElementById('tgInput');
|
||||||
|
const setTgBtn = document.getElementById('setTgBtn');
|
||||||
|
const displayScreen = document.getElementById('displayScreen');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
// --- CONTROLLI VOLUME ---
|
||||||
|
const volumeSlider = document.getElementById('volumeSlider');
|
||||||
|
const volumeValue = document.getElementById('volumeValue');
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', (e) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
volumeValue.innerText = Math.round(val * 100) + '%';
|
||||||
|
|
||||||
|
// Modifica il volume in tempo reale
|
||||||
|
if (gainNode) {
|
||||||
|
gainNode.gain.value = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- RIPRODUZIONE AUDIO PCM ---
|
||||||
|
function playPCM320(arrayBuffer) {
|
||||||
|
if (!audioCtx || !gainNode) return;
|
||||||
|
|
||||||
|
const int16Array = new Int16Array(arrayBuffer);
|
||||||
|
const float32Array = new Float32Array(int16Array.length);
|
||||||
|
for (let i = 0; i < int16Array.length; i++) {
|
||||||
|
float32Array[i] = int16Array[i] / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = audioCtx.createBuffer(1, float32Array.length, 8000);
|
||||||
|
buffer.getChannelData(0).set(float32Array);
|
||||||
|
|
||||||
|
const source = audioCtx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
|
||||||
|
// Colleghiamo l'audio al controller del volume invece che direttamente all'uscita
|
||||||
|
source.connect(gainNode);
|
||||||
|
|
||||||
|
const currentTime = audioCtx.currentTime;
|
||||||
|
if (nextStartTime < currentTime) {
|
||||||
|
nextStartTime = currentTime + 0.02;
|
||||||
|
}
|
||||||
|
source.start(nextStartTime);
|
||||||
|
nextStartTime += buffer.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONNESSIONE WEBSOCKET ---
|
||||||
|
function startAudio() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
|
||||||
|
// Creazione del nodo Volume
|
||||||
|
gainNode = audioCtx.createGain();
|
||||||
|
gainNode.connect(audioCtx.destination);
|
||||||
|
gainNode.gain.value = parseFloat(volumeSlider.value);
|
||||||
|
}
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
audioCtx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connectBtn.style.display = 'none';
|
||||||
|
inviaCambioTG(tgInput.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
playPCM320(event.data);
|
||||||
|
} else if (typeof event.data === 'string') {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// --- GESTIONE ERRORE SERVER PIENO ---
|
||||||
|
if (data.action === "error") {
|
||||||
|
displayScreen.innerText = "SERVER PIENO";
|
||||||
|
displayScreen.style.background = "#e74c3c"; // Rosso[cite: 5]
|
||||||
|
statusText.innerText = "❌ " + data.text;
|
||||||
|
statusText.style.color = "#e74c3c";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === "display") {
|
||||||
|
displayScreen.innerText = data.text;
|
||||||
|
statusText.style.color = "#bdc3c7"; // Ripristina il colore di default del testo[cite: 5]
|
||||||
|
if (data.active) {
|
||||||
|
displayScreen.style.background = "#e67e22"; // Arancione durante RX[cite: 5]
|
||||||
|
} else {
|
||||||
|
displayScreen.style.background = "#1abc9c"; // Verde in Standby[cite: 5]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Se il display non è in stato di errore "SERVER PIENO", mostra la riconnessione standard[cite: 5]
|
||||||
|
if (displayScreen.innerText !== "SERVER PIENO") {
|
||||||
|
statusText.innerText = "Connessione persa. Riconnessione...";
|
||||||
|
statusText.style.color = "#bdc3c7";
|
||||||
|
connectBtn.style.display = 'block';
|
||||||
|
connectBtn.innerText = "Riconnetti Ricevitore";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FUNZIONE PER CAMBIARE TG ---
|
||||||
|
function inviaCambioTG(nuovoTG) {
|
||||||
|
if (!nuovoTG || nuovoTG <= 0) return;
|
||||||
|
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ action: "cambia_tg", tg: nuovoTG }));
|
||||||
|
displayScreen.innerText = "SINTONIA...";
|
||||||
|
statusText.innerText = "Sintonizzato su TG " + nuovoTG;
|
||||||
|
} else {
|
||||||
|
statusText.innerText = "Pronto a sintonizzare TG " + nuovoTG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scelta Rapida dai tasti prefissati
|
||||||
|
function setQuickTg(valore) {
|
||||||
|
tgInput.value = valore;
|
||||||
|
inviaCambioTG(valore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EVENTI ---
|
||||||
|
connectBtn.addEventListener('click', startAudio);
|
||||||
|
setTgBtn.addEventListener('click', () => inviaCambioTG(tgInput.value));
|
||||||
|
|
||||||
|
// Invio anche premendo "Invio" sulla tastiera dentro la casella di testo
|
||||||
|
tgInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
inviaCambioTG(tgInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import audioop
|
||||||
|
|
||||||
|
class UdpClientProtocol(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self):
|
||||||
|
self.transport = None
|
||||||
|
self.future = None
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
if self.future and not self.future.done():
|
||||||
|
self.future.set_result(data)
|
||||||
|
|
||||||
|
class VocoderEngine:
|
||||||
|
# Qui aggiungiamo i parametri port_start e port_end
|
||||||
|
def __init__(self, mode="emulator", host="127.0.0.1", port_start=2470, port_end=2479):
|
||||||
|
self.host = host
|
||||||
|
self.ports = list(range(port_start, port_end + 1))
|
||||||
|
self.pool = {} # porta -> (transport, protocol, lock)
|
||||||
|
self.stream_alloc = {} # stream_id -> porta
|
||||||
|
self.last_seen = {} # stream_id -> timestamp ultimo pacchetto
|
||||||
|
|
||||||
|
async def avvia(self):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for p in self.ports:
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: UdpClientProtocol(),
|
||||||
|
remote_addr=(self.host, p)
|
||||||
|
)
|
||||||
|
# Aggiungiamo un Lock esclusivo per ogni porta UDP
|
||||||
|
self.pool[p] = (transport, protocol, asyncio.Lock())
|
||||||
|
print(f"[Vocoder] Pool UDP Multi-Porta Sincronizzato: {self.ports}")
|
||||||
|
|
||||||
|
def pulisci_stream_inattivi(self):
|
||||||
|
"""Libera automaticamente le porte dei Talkgroup/SIP muti da più di 2.5 secondi"""
|
||||||
|
ora = time.time()
|
||||||
|
inattivi = [s for s, t in self.last_seen.items() if ora - t > 2.5]
|
||||||
|
for s in inattivi:
|
||||||
|
if s in self.stream_alloc:
|
||||||
|
del self.stream_alloc[s]
|
||||||
|
del self.last_seen[s]
|
||||||
|
|
||||||
|
def assegna_porta(self, stream_id):
|
||||||
|
self.pulisci_stream_inattivi()
|
||||||
|
self.last_seen[stream_id] = time.time()
|
||||||
|
|
||||||
|
if stream_id in self.stream_alloc:
|
||||||
|
return self.stream_alloc[stream_id]
|
||||||
|
|
||||||
|
porte_usate = set(self.stream_alloc.values())
|
||||||
|
porte_libere = [p for p in self.ports if p not in porte_usate]
|
||||||
|
|
||||||
|
if porte_libere:
|
||||||
|
porta_scelta = porte_libere[0]
|
||||||
|
else:
|
||||||
|
# Se tutte le 3 porte sono saturate contemporaneamente, usiamo la prima come fallback
|
||||||
|
porta_scelta = self.ports[0]
|
||||||
|
|
||||||
|
self.stream_alloc[stream_id] = porta_scelta
|
||||||
|
return porta_scelta
|
||||||
|
|
||||||
|
def rilascia_porta(self, stream_id):
|
||||||
|
if stream_id in self.stream_alloc:
|
||||||
|
del self.stream_alloc[stream_id]
|
||||||
|
if stream_id in self.last_seen:
|
||||||
|
del self.last_seen[stream_id]
|
||||||
|
|
||||||
|
async def _scambia_dati(self, porta, dati):
|
||||||
|
transport, protocol, lock = self.pool[porta]
|
||||||
|
|
||||||
|
# Garantisce che un solo pacchetto alla volta usi questa porta UDP!
|
||||||
|
async with lock:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
protocol.future = loop.create_future()
|
||||||
|
transport.sendto(dati)
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(protocol.future, timeout=0.08)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return b""
|
||||||
|
finally:
|
||||||
|
protocol.future = None
|
||||||
|
|
||||||
|
async def converti_ambe_in_pcm(self, stream_id, ambe_bytes):
|
||||||
|
porta = self.assegna_porta(stream_id)
|
||||||
|
# Invio dati RAW
|
||||||
|
return await self._scambia_dati(porta, ambe_bytes)
|
||||||
|
|
||||||
|
async def converti_rtp_in_ambe(self, stream_id, pcm_bytes):
|
||||||
|
porta = self.assegna_porta(stream_id)
|
||||||
|
|
||||||
|
# Se riceviamo 160 byte dal SIP, li convertiamo in PCM lineare 16-bit (320 byte)
|
||||||
|
if len(pcm_bytes) == 160:
|
||||||
|
# Uso alaw2lin per PCMA (A-Law, standard europeo).
|
||||||
|
# (Se il tuo centralino usa u-Law, ti basterà cambiare in ulaw2lin)
|
||||||
|
pcm_bytes = audioop.alaw2lin(pcm_bytes, 2)
|
||||||
|
|
||||||
|
return await self._scambia_dati(porta, pcm_bytes)
|
||||||
Reference in New Issue
Block a user