diff --git a/.env.example b/.env.example index 9e78dda..594c269 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,17 @@ SIP_DOMAIN=sip.plusnet.de SIP_PASS= SIP_PROXY=voice01.sip.plusnet.de +# --- Durchwahl 3: eigenes Endgeraet vor dem Anrufbeantworter klingeln lassen --- +# Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim +# Anrufbeantworter. Manche Provider (z.B. Fonial/Plusnet) klingeln bei einer +# Durchwahl PARALLEL an mehreren registrierten Zielen - ohne Wartezeit +# gewinnt AgentOne das Rennen immer, weil es sofort abnimmt (answer() ist die +# erste Aktion im Dialplan). Die DDI kommt bei Plusnet nicht im +# destination_number an (der ist bei diesem Trunk unbrauchbar), sondern im +# Header X-ORIGINAL-DDI-URI - siehe telefon/freeswitch/dw3_warten.xml.tpl. +# Nur Ziffern, kein "+". +DW3_DDI= + # --- Ansage --- # piper = lokal, natuerlich, Modelle unter ~/piper-voices # say = lokal, macOS-Bordmittel (Rueckfall wenn Piper fehlt) @@ -117,3 +128,9 @@ FS_ETC=/opt/homebrew/etc/freeswitch # Log von FreeSWITCH; der Leitstand liest daraus die Anruf-Ereignisse. # Apple Silicon: /opt/homebrew/... , Intel-Mac: /usr/local/... #FREESWITCH_LOG=/opt/homebrew/var/log/freeswitch/freeswitch.log + +# Pipecat-Testleitung (dialog/pipecat_bootstrap.py, Nebenstelle 7501) - +# WebSocket-Ziel fuer mod_audio_stream. token= muss ASTRA_TELEFON_SECRET +# auf der Astra-Maschine entsprechen. +ASTRA_TELEFON_WS_URL=wss://astra-host.example.ts.net/telefon/inbound?token=changeme +PIPECAT_BOOTSTRAP_PORT=8095 diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..354ab0c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,289 @@ +# Handoff — Live-Telefonagent (laufend, zuletzt aktualisiert 2026-09-14) + +## Stand in einem Satz + +Voller Weg funktioniert Ende-zu-Ende: externer Anruf auf Durchwahl 9 (Plusnet → FreeSWITCH → `dialog/pipecat_bootstrap.py` → WebSocket → Astra) erreicht Astra, und Astra liefert jetzt auch eine echte, fertige Antwort statt nur "Einen Moment bitte" (Fix 2026-09-13, siehe unten) - kein offener Blocker mehr bekannt. + +## 2026-09-14 — Leitung nach Neustart ~80 min tot: Watchdog bekam nichts hoch + +**Symptom:** Nach einem Neustart des Mac mini (~12:39) blieb die Leitung +(PROFIL=aufzug-notdienst) tot, bis ~13:58 von Hand gestartet wurde. Der +launchd-Watchdog (`~/Library/LaunchAgents/net.dillenberg.fonagent.plist`, +alle 5 min `starten.sh`) versuchte es 11-mal: jeweils "Backgrounding.", +dann "fs_cli antwortet nicht", exit 1. `freeswitch.log` blieb unberührt, +kein Crash-Report. Laut `autostart.log` hat der Watchdog FreeSWITCH **noch +nie** erfolgreich gestartet - bis dahin lief es offenbar immer manuell. + +Drei Ursachen, drei Fixes: + +### 1. FreeSWITCH stirbt unter launchd an SIGPIPE (Commit `f785ab3`) + +macOS' Datenschutz "Lokales Netzwerk" gilt für launchd-Prozesse, nicht für +Terminal-Shells: jedes Senden an eine LAN-Adresse (192.168.x) endet dort mit +`EPIPE`, Internet und 127.0.0.1 gehen. FreeSWITCH fragt beim Start per NAT-PMP +den Router (`192.168.2.1:5351`) - auch mit `-nonatmap`, das schaltet nur das +Port-Mapping ab, nicht die Erkennung. So früh ignoriert FreeSWITCH SIGPIPE noch +nicht -> Prozess tot ~0,5 s nach dem Backgrounding. + +Belegt mit einer zweiten FreeSWITCH-Instanz (eigene Ports, keine Gateways) über +einen temporären LaunchAgent: `-nc -rp -nonatmap` stirbt reproduzierbar, +`-nf` endet mit exit 141 (SIGPIPE), mit `-nonat` läuft sie. Unified Log im +Todesmoment: `UserEventAgent: Got local network blocked notification`. Ein +Python-UDP-Test unter launchd: Router -> Errno 32, 9.9.9.9 -> ok. + +**Fix:** `-nonat` im FreeSWITCH-Aufruf in `starten.sh`. Kein Verlust - der +Router hat NAT-PMP nie beantwortet (`nat_map status`: UNKNOWN), die externe IP +kommt per STUN. + +**Grenze, die bleibt:** Alles, was der Watchdog startet, erreicht **keine +LAN-Geräte**. Heute unkritisch (Trunks übers Internet, Sockets auf 127.0.0.1, +keine lokalen Telefone registriert). Kommen lokale SIP-Telefone o. Ä. dazu, +bricht das unter launchd wieder - dann nicht lange suchen. + +**Noch nicht unter echten Bedingungen verifiziert:** `starten.sh` selbst hat +FreeSWITCH mit `-nonat` noch nicht per Watchdog gestartet (solange es läuft, +überspringt das Skript es). Test: FreeSWITCH stoppen, dann +`launchctl kickstart gui/$(id -u)/net.dillenberg.fonagent` - ~1 min Ausfall. + +### 2. Pipecat-Bootstrap brach mit ModuleNotFoundError ab (Commit `907f511`) + +`starten.sh` startete `dialog.pipecat_bootstrap` mit System-`python3`, dem +`gevent`/`greenswitch` fehlen. Jetzt `.venv/bin/python`. Die übrigen +`pipe.*`-Dienste laufen weiterhin mit System-`python3` (dort vollständig). + +### 3. Störungswache lief genau beim Ausfall nicht (Commit `6db89af`) + +`pipe.monitor` wurde erst nach Trunk UP gestartet - kam FreeSWITCH nicht hoch, +gab es auch keine Wache und keinen Alarm. Jetzt startet sie als Erstes in +`starten.sh`, noch vor der Vorabprüfung. `pipe.dienste` meldet "FreeSWITCH +läuft nicht" bzw. "Trunk DOWN" selbst. + +**Offen:** Der Alarm ist nur eine lokale macOS-Benachrichtigung (`pipe/alarm.py`, +osascript) auf dem Mac mini - wenn dort niemand vor dem Bildschirm sitzt, sieht +ihn keiner. Für eine Notdienst-Leitung bräuchte es einen Push-Kanal nach außen. + +## 2026-09-13 (Tag) — Zwei Bugs gefunden und behoben + +### 1. Durchwahl 9 landete bei der Praxis-Zentrale statt bei Astra + +**Symptom:** externer Anruf auf +49 2103 78916179 (Durchwahl 9, "rufagent" im +Fonial-Portal, Status Online) klingelte durch, aber es meldete sich die +Praxis-Ansage (`00_praxis_ab.xml`) statt Astra. + +**Untersuchung:** `${sip_h_X-ORIGINAL-DDI-URI}` kam korrekt mit +`sip:+49210378916179@ipfonie.de` an — die DDI-Erkennung selbst war NICHT das +Problem (anders als zunächst vermutet). Es gab schlicht **keine Dialplan-Regel, +die auf diese DDI reagiert** — nur `00_dw3_warten.xml` (reagiert exklusiv auf +Durchwahl 3) existierte, alles andere fällt durch zu `00_praxis_ab.xml` +(`^.*$`, kein `continue="true"`, matcht daher wortwörtlich jede Nummer). + +**Erster Fix-Versuch schlug fehl, zweiter Grund gefunden:** eine neue Regel +in einer Datei namens `00_rufagent_astra.xml` wurde nach `reloadxml` immer +noch nicht erreicht — der Dialplan-Trace (`fs_cli -x "originate ... &echo()"`- +Anrufe mit `call_debug=true` oder `[INFO]`-Log direkt mitlesen) zeigte, dass +die Regel im geparsten Dialplan gar nicht auftauchte. Grund: `public.xml` +bindet den ganzen Ordner per `` +**alphabetisch** ein, und `00_praxis_ab.xml`s Catch-all kommt alphabetisch +VOR `00_rufagent_astra.xml` (`p` < `r`) und beendet die Dialplan-Auswertung, +bevor die eigene Regel überhaupt geladen wird. + +**Fix:** Datei umbenannt zu `00_dw9_rufagent.xml` (`d` < `p`, sortiert direkt +hinter `00_dw3_warten.xml` und vor `00_praxis_ab.xml`). Als Vorlage ins Repo +übernommen: `telefon/freeswitch/dw9_rufagent.xml.tpl` + `DW9_DDI` in `.env` ++ neuer Block in `telefon/freeswitch/einrichten.sh` (analog zum +DW3-Block) — die Live-Config kommt jetzt wieder komplett aus dem Repo, +reproduzierbar per `einrichten.sh`. + +**Lehre für weitere Durchwahlen:** Jede neue `dialplan/public/*.xml`-Regel, +die vor `00_praxis_ab.xml` greifen soll, MUSS einen Dateinamen bekommen, der +alphabetisch vor `p` einsortiert (Konvention: `00_dwN_.xml`, `N` als +Ziffer direkt nach `00_dw`). Sonst wird sie stillschweigend nie erreicht - +kein Fehler, kein Log-Eintrag, sie taucht im Dialplan-Trace einfach nicht auf. + +### 2. Telephone.app kann eine DDI-Registrierung stehlen + +Telephone.app (macOS-Softphone) lief auf minim4-1 mit den SIP-Zugangsdaten +einer der Fonial-Durchwahlen für manuelles SIP-Debugging (siehe +`/tmp/telephone_call.pcap`-Mitschnitt dort). Läuft es gleichzeitig mit +FreeSWITCHs eigener Registrierung derselben Durchwahl, ist nicht +deterministisch, wer den Anruf bekommt - siehe auch die Warnung in +`telefon/starten.sh` ("Telephone.app LAEUFT - beenden, sonst +Registrierungskonflikt"). Vor jedem externen Testanruf prüfen: +`pgrep -xl Telephone` auf der Maschine, auf der es installiert ist (hier: +minim4-1, nicht minim4-2!), und bei Bedarf beenden. + +## GELÖST 2026-09-13: keine fertige Antwort am Telefon + +Realer externer Testanruf (2026-09-13, ~12:14 Uhr) mit echtem Sprecher: + +- Audio kommt sauber an (Peak ~3000, kein Stille-Artefakt wie bei + Loopback/Selbstanruf-Tests). +- STT transkribiert korrekt: "kannst du mich hören?", danach "Hallo." +- Astra spielt beide Male nur den Zwischenbescheid **"Einen Moment bitte."** + - Danach läuft die eigentliche LLM-Antwort (`granite4.2:8b` über Ollama, + `NativeOllamaService`) im Astra-Log als `'phase': 'running'` weiter, + wird aber nie fertig: + - 1. Versuch: nach 4814 ms durch neue erkannte Nutzer-Sprache + (`VADUserTurnStartStrategy`) abgebrochen (`'phase': 'cancelled'`) - + der Anrufer hat vermutlich aus Ungeduld nochmal "Hallo" gesagt. + - 2. Versuch: nach 6002 ms abgebrochen, weil der Anrufer aufgelegt hat + (`Anruf beendet` direkt danach im Log, keine erneute Nutzer-Sprache). + - In beiden Fällen: **keine einzige echte Antwort hat es bis zur TTS + geschafft**, nur der Zwischenbescheid. + +**Hypothese:** Kein Barge-in/Echo-Problem (anders als in der vorherigen +Handoff-Version vermutet) - hier ist die reale Sprache eindeutig neu, kein +Echo-Artefakt. Verdacht: `granite4.2:8b` per Ollama ist für die +Telefon-Pipeline schlicht zu langsam (>5s ohne fertige Antwort), der +Anrufer gibt vorher auf. Zu prüfen morgen: +- Ollama-Antwortzeit isoliert messen (gleicher Prompt, gleiches Modell, + ohne Telefon-Pipeline drumherum) - liegt es am Modell/Backend oder an + Rechenlast durch parallel laufende STT/TTS-Modelle auf demselben Mac? +- Ob `ASTRA_OLLAMA_URL`/`ASTRA_OLLAMA_BACKENDS` für die Telefonie-Pipeline + auf ein schnelleres/entlastetes Backend zeigen sollte (siehe README + "Ollama-Backend wählen"). +- Ob ein kürzerer/schnellerer Zwischenbescheid-Rhythmus (z. B. alle 2-3s + ein weiteres "Moment noch") das gefühlte Hängen entschärfen würde, während + am eigentlichen Latenzproblem gearbeitet wird. + +**Root Cause bestätigt:** `granite4.2:8b` war zwischen dem Server-Start +(01:50 Uhr) und dem Testanruf (12:14 Uhr) aus Ollama verdrängt worden, trotz +`keep_alive: -1` in `astra/core.py::build_request` - `ollama ps` zeigte +davor `"models": []`. Direkt gemessen: kalt `load_duration` ~12,2s, warm +Gesamtdauer <0,9s. Der Anrufer wurde ungeduldig/legte auf, lange bevor die +eigentliche Generierung (nach dem Laden nur ~1s) fertig war. + +**Fix:** `astra/server.py::telefon_inbound` feuert jetzt beim +Verbindungsaufbau parallel zur Begrüßung einen Wegwerf-Generate-Aufruf an +Ollama (`_warm_ollama`, fire-and-forget, Fehler werden ignoriert) - der +Kaltstart-Tax landet dadurch während die Begrüßung läuft, nicht während der +ersten echten Antwort. Verifiziert mit echtem externen Testanruf: LLM-Zeit +1355 ms, vollständige Antwort kam durch, keine Abbrüche mehr. Commit +`a66629a` in `martin-voice-interface`. + +**Falls das Problem wiederkehrt** (z. B. weil ein anderer Ollama-Verbraucher +zwischen Anrufen ein anderes Modell lädt und `granite4.2:8b` trotzdem wieder +verdrängt wird): `curl http://127.0.0.1:11434/api/ps` vor einem Testanruf +prüfen, ob das Modell geladen ist. + +## Weiterhin offen (aus der vorherigen Handoff-Fassung, unverändert) + +### Sitzungs-Hänger-Bug (Astra `busy: true` bleibt hängen) + +Noch nicht erneut beobachtet/verifiziert heute, aber nicht aktiv behoben - +weiter im Auge behalten: `sessions`-Gate in `astra/server.py` könnte bei +einem nicht sauber geschlossenen WebSocket (z. B. hartes `hupall`) hängen +bleiben, siehe `run_telephony_call`/`_run_pipeline`s Cleanup-Pfad. + +## Werkzeuge/Referenzen + +- **Test-Nummer** (externe echte Rufnummer, Astra-Testleitung): + **+49 2103 78916179** (Durchwahl 9 / "rufagent" im Fonial-Portal). Vor dem + Test: Telephone.app auf minim4-1 beenden, falls es läuft + (`pgrep -xl Telephone`). +- **Loopback (ext. 7501, kein echtes Telefon)**: nur zum Prüfen der + Signalisierung/Verkabelung geeignet, liefert IMMER Stille (Peak ~1) - kein + Ersatz für einen echten Audiotest. + `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'originate loopback/7501 &park()'"` +- **Selbstanruf** (`dialog.anrufen 2`): unzuverlässig, liefert ebenfalls nur + Stille (bestätigt 2026-09-13) - nicht mehr nötig jetzt, wo DW9 extern + erreichbar ist. +- **Nach jedem Testanruf auflegen**: + `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'hupall NORMAL_CLEARING'"` +- **FreeSWITCH-Dialplan neu laden** (nach Änderungen in `dialplan/public/`, + kein Neustart nötig): + `ssh minim4-2 "export PATH=/opt/homebrew/bin:\$PATH; fs_cli -P 8022 -p ClueCon -x 'reloadxml'"` +- **Astras Live-Log**: `/private/tmp/astra_server.log` auf minim4-1 (NICHT + `.runtime/server.log` - das ist ein alter, verwaister Lauf). Bei Zweifel, + wohin ein laufender Prozess loggt: `lsof -p -a -d 0,1,2`. +- **Bootstrap-Log**: `fonagent-one/telefon/pipecat_bootstrap.log` auf + minim4-2. +- **ESL-Port** auf minim4-2 ist **8022**, nicht der Standard 8021. +- **Astra neu starten** (siehe vorherige Handoff-Fassung im Git-Log für den + vollständigen Befehl mit `ASTRA_TELEFON_SECRET`/`ASTRA_LOG_LEVEL`). + +## Zugangsdaten/Config + +- `.env` auf minim4-2 im Projekt (gitignored) — SIP-Zugänge für `plusnet`, + `agentzwei`, `agentzwei_sbc`, `ASTRA_TELEFON_WS_URL`, jetzt auch + `DW9_DDI=49210378916179`. +- `ASTRA_TELEFON_SECRET` — nur als Laufzeit-Env-Var gesetzt, nicht in einer + Datei auf minim4-1 abgelegt. Muss auf beiden Seiten übereinstimmen. + +## GELÖST 2026-09-13 (Folgefehler): Begrüßung komplett stumm nach dem Cap-Fix + +Der Cap-Fix oben (Puffer auf 12s angehoben) legte einen zweiten, tieferen Bug +frei: `TTSStoppedFrame` erreicht `serialize()` in diesem Transport **nie** - +per Frame-Type-Tracing bestätigt, `FastAPIWebsocketOutputTransport` reicht an +den Serializer nur `OutputAudioRawFrame` und (bei Barge-in) `InterruptionFrame` +durch, `TTSStoppedFrame` wird intern fürs "Bot hat aufgehört zu sprechen" +verbraucht und nie weitergereicht. Der `if isinstance(frame, TTSStoppedFrame)`- +Zweig war also von Anfang an toter Code - geflusht wurde bisher ausschließlich +über die (zu klein bemessene) Cap. Mit der korrigierten, viel höheren Cap +erreichte eine normal lange Antwort (3-4s) diese nie mehr - und ohne den +(toten) TTSStoppedFrame-Pfad flusste dann gar nichts mehr: Astra loggte +Begrüßung + Transkript ganz normal, aber bei FreeSWITCH kam kein einziges +Playback-Event an. + +**Fix:** Flush jetzt über ein Inaktivitäts-Timeout (300ms ohne neuen +Audio-Frame) statt über `TTSStoppedFrame`. Da der Idle-Flush aus einem +Hintergrund-Task feuert (nicht aus `serialize()`s Rückgabewert), braucht er +einen eigenen Sendeweg - der Serializer nimmt jetzt zusätzlich zu `notify` +ein `send`-Callable (`websocket.send_text`). Commit `0a0c8d5` in +`martin-voice-interface`. + +Live verifiziert (Loopback-Test): 2,88s gesendet, FreeSWITCH spielt 3,02s +vollständig ab ("done playing file") - keine Stille, kein Abschneiden mehr. + +## GELÖST 2026-09-13 (weitere Folgefehler): zu lange Antworten + hackige Wiedergabe + +Nach dem Idle-Flush-Fix zwei weitere Probleme live gemeldet: + +1. **Antworten zu lang** - der Prompt allein ("1-2 kurze Sätze") wurde vom + Modell ignoriert, Antworten liefen auf 2-3 Sätze und lang genug, um die + 12s-Puffergrenze zu reißen. Fix: harte `num_predict`-Grenze + (`Settings.telefon_max_tokens`, Standard 40, `ASTRA_TELEFON_MAX_TOKENS` + env-einstellbar) statt nur einer Bitte im Prompt. Commit `4e6f266`. +2. **Wiedergabe "hackig"** - der 300ms-Idle-Flush (siehe vorheriger Eintrag) + feuerte bei Mehrsatz-Antworten fast immer VOR dem nächsten Satz, weil die + Pause zwischen zwei TTS-Satz-Chunks (das LLM muss den nächsten Satz erst + zu Ende streamen) regelmäßig über 300ms liegt - jeder Satz kam so als + eigene, einzeln abgespielte Nachricht statt als eine zusammenhängende + Antwort. Fix: `flush_now()` auf dem Serializer, ausgelöst über + `run_telephony_call`s `notify()`-Hook genau dann, wenn die Pipeline + meldet, dass der Bot wirklich fertig gesprochen hat + (`BotStoppedSpeakingFrame` via den `'state': 'listening'`-Callback) - das + ist das einzige verlässliche "Antwort fertig"-Signal, da `TTSStoppedFrame` + selbst `serialize()` nie erreicht (siehe voriger Eintrag). Der + Idle-Timeout ist jetzt nur noch Fallback (4s statt 300ms). Commit + `42acca4`. + +Live verifiziert: Begrüßung flusht jetzt ~2ms nach echtem Sprechende (statt +vorher 300ms Verzögerung), vollständig, keine Aufteilung. + +**Offen für nächsten Test:** ob Mehrsatz-Antworten jetzt tatsächlich flüssig +klingen (nur mit Loopback getestet, das produziert keine echte Sprache für +einen mehrteiligen Dialog) - braucht einen echten Anruf mit echtem Gespräch. + +## GELÖST 2026-09-13 (weiterer Folgefehler): Anruf trennt nach ~50s ohne Antwort + +Erster echter Mehrsatz-Dialog lief mehrere Runden gut (kurze Antworten, +zusammenhängende Wiedergabe) - dann zweimal hintereinander: VAD hat ausgelöst +(`min_volume=0.3` reagiert absichtlich auch auf leise Signale, siehe +vorheriger Eintrag), aber Nemotron hat für die ganze Runde nichts +transkribiert (`Nemotron result: final, 0 characters`). Ein leeres Transkript +erreicht `LLMUserAggregator` nie als echte Nutzer-Nachricht - keine +LLM-Anfrage, keine Antwort, keine Rückmeldung. Für den Anrufer wirkt das wie +eine tote Leitung; nach zwei solchen Runden hat er aufgelegt. + +**Fix:** `NemotronSTTService` bekommt jetzt optional `no_speech_phrases` +(`astra.services.NO_SPEECH_PHRASES`, z. B. "Wie bitte?", "Kannst du das +wiederholen?") und spricht bei leerem finalem Transkript eine davon über +`TTSSpeakFrame`. Nur für die Telefonie verdrahtet (Browser hat schon einen +sichtbaren "listening"-Zustand). Commit `c8946d0`. + +**Noch offen:** ob `min_volume=0.3` grundsätzlich zu locker ist (löst zu oft +auf Nicht-Sprache aus) - dieser Fix behandelt nur das Symptom (Anrufer bekommt +immer eine Reaktion), nicht die Ursache. Bei wiederholtem Auftreten: +`ASTRA_TELEFON_VAD_MIN_VOLUME` schrittweise erhöhen und beobachten, ob echte +leise Sprache dann noch erkannt wird. diff --git a/README.md b/README.md index 8f1f0ff..111c480 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,6 @@ ab (lokal und optional in Nextcloud). Für welchen Einsatzbereich (Hausarztpraxi > Musterhausen" ist ein Platzhalter. Echte Zugangsdaten und Anrufdaten liegen > außerhalb des Repositorys (`.env`, `ablage/`, `telefon/`). -Wer das Projekt auf einem Linux einrichten möchte, eine Kleine Hilfe: [docs/MITGABE.md](docs/MITGABE.md) -sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. - ## Stufen 1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage. @@ -239,6 +236,35 @@ Der SIP-Zugang steht in der `.env` (`SIP_USER`, `SIP_DOMAIN`, `SIP_PASS`). Das Einrichtungsskript trägt das Passwort in die FreeSWITCH-Konfiguration ein und setzt deren Rechte auf 600 — im Git liegen nur Vorlagen mit Platzhaltern. +### Durchwahl parallel zu einem Endgerät klingeln lassen + +Manche Provider (z. B. Fonial/Plusnet) klingeln bei einer Durchwahl **parallel** +an allen dafür registrierten Zielen — bei uns also gleichzeitig an AgentOne und +an einem echten Telefon/Softphone. Ohne Gegenmaßnahme gewinnt AgentOne das +Rennen aber immer: `answer()` ist im Dialplan die erste Aktion, es gibt keine +Verzögerung davor. Das Telefon klingelt dann faktisch nie, weil der Anruf schon +beim Assistenten gelandet ist, bevor am Gerät überhaupt ein Klingelzeichen +ankommt. + +`DW3_DDI` in der `.env` schaltet eine Wartezeit vor `answer()` frei +(`telefon/freeswitch/dw3_warten.xml.tpl`, deployed als `00_dw3_warten.xml` – +lädt alphabetisch vor `00_praxis_ab.xml`): erkennt der Dialplan die passende +Durchwahl, wartet er 20 Sekunden, bevor überhaupt abgenommen wird. Nimmt in der +Zeit jemand am registrierten Endgerät ab, bricht der Provider den Versuch bei +AgentOne per SIP-`CANCEL` ab — der Dialplan kommt gar nicht mehr bis zum +`answer()`. Nimmt niemand ab, läuft er nach den 20 Sekunden normal weiter zu +Ansage und Aufnahme, identisch zum Standard-Anrufbeantworter. + +Wichtig: die Durchwahl selbst kommt bei diesem Trunk nicht im +`destination_number` an (der zeigt bei Plusnet nur eine interne Trunk-Kennung, +unabhängig von der gewählten Nummer), sondern im SIP-Header +`X-ORIGINAL-DDI-URI` der eingehenden Einladung — nur der ist brauchbar, um DW3 +von anderen Anrufen zu unterscheiden. `DW3_DDI` erwartet die volle Rufnummer +inklusive Durchwahl-Ziffer(n), nur Ziffern, kein `+`. + +Leer lassen = keine Sonderbehandlung, jeder Anruf landet sofort beim +Anrufbeantworter wie bisher. + Text ändern: `profile/$PROFIL/ansage.txt` bearbeiten (siehe [Branchen-Profil](#kategorien--branchen-profil)), `ansage_bauen.sh` erneut ausführen. Für den Praxisbetrieb muss die Ansage den Notruf 112 nennen und auf diff --git a/dialog/__init__.py b/dialog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dialog/anrufen.py b/dialog/anrufen.py new file mode 100644 index 0000000..0c0302b --- /dev/null +++ b/dialog/anrufen.py @@ -0,0 +1,68 @@ +"""Outbound-Call-Trigger fuer die Pipecat-Testleitung. + +Ruft eine Nummer ueber den Plusnet-Trunk an und uebergibt sie nach Abheben an +dieselbe Bruecke wie die interne Testnebenstelle 7501 +(dialog/pipecat_bootstrap.py, Port 8095) - answer()+uuid_audio_stream+park() +laeuft dort identisch, unabhaengig davon ob der Anruf rein- oder rausgeht. Der +Dialplan-Umweg (Nebenstelle 7501) entfaellt hier: `&socket(...)` haengt den +ausgehenden Kanal direkt nach Abheben an den Bootstrap, ohne Dialplan-Lookup. + +Nutzung: python3 -m dialog.anrufen +""" + +import os +import sys + +import greenswitch + +FS_HOST = "127.0.0.1" +FS_PORT = int(os.environ.get("FS_PORT", "8022")) +FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon") +BOOTSTRAP_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095")) +# DW9 ("rufagent") - the only account confirmed capable of outbound so far. +# Needs "proxy" (R-URI domain, must be sip.plusnet.de - Plusnet 403s otherwise) +# split from "outbound-proxy" (actual packet routing, sbc.sip.plusnet.de) in +# its gateway XML - see sip_profiles/external/agentzwei_sbc.xml. +GATEWAY = os.environ.get("ANRUF_GATEWAY", "agentzwei_sbc") +CALLER_ID = os.environ.get("ANRUF_CALLER_ID", "+49210378916179") + + +def normalisiere(nummer: str) -> str: + """Wie pipe/kontakte.py: nur Ziffern, Deutschland-Annahme 0 -> 49, + kein '+' - so erwartet es der Trunk auch fuer DW3_DDI in der .env.""" + ziffern = "".join(zeichen for zeichen in nummer if zeichen.isdigit() or zeichen == "+") + if ziffern.startswith("+"): + return ziffern[1:] + if ziffern.startswith("00"): + return ziffern[2:] + if ziffern.startswith("0"): + return "49" + ziffern[1:] + return ziffern + + +def main(): + if len(sys.argv) != 2: + sys.exit("Nutzung: python3 -m dialog.anrufen ") + ziel = normalisiere(sys.argv[1]) + # The &socket(...) part MUST stay in single quotes: originate splits its + # arguments on spaces, so an unquoted "&socket(127.0.0.1:8095 async full)" + # reaches the channel as plain "socket(127.0.0.1:8095)" - "async full" + # silently dropped (seen in freeswitch.log's own EXECUTE line, 2026-09-13). + # Without "full", mod_event_socket refuses every command past sendmsg on + # an outbound listener, so pipecat_bootstrap's CUSTOM-event subscription + # ("event plain CUSTOM mod_audio_stream::play") is answered with + # "-ERR command not found" and no audio is ever played back. + befehl = ( + f"originate {{origination_caller_id_number={CALLER_ID}}}" + f"sofia/gateway/{GATEWAY}/{ziel} " + f"'&socket({FS_HOST}:{BOOTSTRAP_PORT} async full)'" + ) + print(f"Rufe {ziel} an ueber: {befehl}") + fs = greenswitch.InboundESL(host=FS_HOST, port=FS_PORT, password=FS_ESL_PASSWORD) + fs.connect() + antwort = fs.send(f"api {befehl}") + print(antwort.data) + + +if __name__ == "__main__": + main() diff --git a/dialog/pipecat_bootstrap.py b/dialog/pipecat_bootstrap.py new file mode 100644 index 0000000..327f599 --- /dev/null +++ b/dialog/pipecat_bootstrap.py @@ -0,0 +1,187 @@ +"""Outbound-ESL bootstrap for the Pipecat phone-agent test line. + +FreeSWITCH's dialplan extension "agentzwei_pipecat_test" (destination_number +7501, dialplan/default/06_agentzwei_pipecat_test.xml, internal test-only, not +reachable from the PSTN) - and now also dialog/anrufen.py's outbound calls - +hand the channel here via `socket(127.0.0.1:PORT async full)`. This process's +job: answer(), tell mod_audio_stream to start streaming the call's audio to +Astra's /telefon/inbound, then stay on the line for as long as the call lasts +to actually play back what Astra sends - the conversation logic itself lives +entirely in Astra's Pipecat pipeline, over the WebSocket that mod_audio_stream +opens outbound to Astra. + +mod_audio_stream has no dialplan application, only the ESL API command +`uuid_audio_stream` (confirmed against the module's own source and by +testing). Confirmed by testing (2026-09-12): issuing `uuid_audio_stream` from +this same process - even over a second, separate ESL connection - reliably +fails the WebSocket connect for real external/gateway calls (instant +"connection error", no such issue for the loopback test line). Matches a +known mod_audio_stream issue (amigniter/mod_audio_stream#92): "It's an issue +with using uuid_audio_stream on the same call session uuid as the Python +script that's listening for the WebSocket, separating these processes +resolves the problem." So the command is shelled out to `fs_cli` as a +genuinely separate OS process, not issued from within this one at all. + +Second gap found by testing (2026-09-13): mod_audio_stream does NOT play +audio back on its own. Astra's `streamAudio` message only makes it write a +temp WAV file and fire a `mod_audio_stream::play` CUSTOM event containing that +path (audio_streamer_glue.cpp:290-303) - something has to catch that event and +actually play the file. Nothing in the module or the dialplan does this, so +this process stays on the call (instead of returning right after park()) and +plays each file back as the events arrive, until the call hangs up - with the +`playback` application over this same outbound socket. Unlike uuid_audio_stream +that is not an "api" command but an execute-style application call, exactly +like the answer()/park() that already work here, so it does not need the +separate-process detour. + +Getting those CUSTOM events delivered at all took two subscriptions plus one +workaround; see run() and event_body_json() for the details. +""" + +import json +import logging +import os +import subprocess +import sys + +import gevent +import greenswitch + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("pipecat_bootstrap") + +BIND_ADDRESS = os.environ.get("PIPECAT_BOOTSTRAP_BIND", "127.0.0.1") +BIND_PORT = int(os.environ.get("PIPECAT_BOOTSTRAP_PORT", "8095")) +ASTRA_WS_URL = os.environ.get("ASTRA_TELEFON_WS_URL", "") +STREAM_MIX_TYPE = "mono" +STREAM_SAMPLE_RATE = "16k" # matches NemotronSTTService's hard 16 kHz requirement on the Astra side +# Upper bound so a greenlet can never outlive a call FreeSWITCH itself forgot +# to tell us ended - failure mode is "one stray idle greenlet", not a runaway. +MAX_CALL_SECONDS = 30 * 60 + +FS_HOST = "127.0.0.1" +FS_PORT = os.environ.get("FS_PORT", "8022") +FS_ESL_PASSWORD = os.environ.get("FS_ESL_PASSWORD", "ClueCon") +FS_CLI = os.environ.get("FS_CLI_BIN", "/opt/homebrew/bin/fs_cli") + + +def fs_cli(command: str) -> str: + """Run one `api` command via `fs_cli` as its own OS process - see module + docstring for why commands for this call must not go over this process's + own outbound-socket connection.""" + result = subprocess.run( + [FS_CLI, "-H", FS_HOST, "-P", FS_PORT, "-p", FS_ESL_PASSWORD, "-x", command], + capture_output=True, text=True, timeout=10, + ) + return (result.stdout or result.stderr).strip() + + +def event_body_json(event) -> dict | None: + """Read a FreeSWITCH event's body (the part after the blank line) as JSON. + + greenswitch's ESLEvent.parse_data() only knows "key: value" lines, so an + event body - mod_audio_stream sends bare JSON, which has no ": " in it - + is never split off as its own field. It ends up appended to the value of + whatever header came last, which in plain format is always the body's own + "Content-Length". So `headers["Content-Length"]` reads e.g. + '84\\n\\n{"audioDataType":"wav","file":"/tmp/....wav"}' - the payload is + there, just glued to a number. Cut at the first "{" and parse that. + """ + raw = event.headers.get("Content-Length") or "" + start = raw.find("{") + if start == -1: + return None + try: + payload = json.loads(raw[start:]) + except (ValueError, TypeError): + return None + return payload if isinstance(payload, dict) else None + + +class PipecatBridge: + """One instance per call, per greenswitch's OutboundESLServer contract.""" + + def __init__(self, session): + self.session = session + self.call_id = getattr(session, "uuid", "?") + self.hung_up = gevent.event.Event() + + def on_play_event(self, event): + payload = event_body_json(event) + if payload is None: + logger.warning( + "Anruf %s: mod_audio_stream::play-Event ohne lesbaren JSON-Rumpf: %r", + self.call_id, event.headers, + ) + return + path = payload.get("file") + if not path: + logger.warning("Anruf %s: play-Event ohne Dateipfad: %r", self.call_id, payload) + return + # Same mechanism Logpy-AgentOne already uses successfully in + # production (dialplan `playback` app) - over this session's own + # connection, unlike uuid_audio_stream this is an "execute"-style + # application call (like answer()/park(), already proven to work + # here), not an "api" command. + self.session.playback(path, block=False) + logger.info("Anruf %s: spiele %s ab", self.call_id, path) + + def on_hangup(self, event): + self.hung_up.set() + + def run(self): + try: + # Two separate subscriptions, both required, in this order: + # + # `myevents` turns on this session's own event feed, but it does + # NOT mean "all events" - mod_event_socket hardcodes a whitelist + # of ~23 event ids there (mod_event_socket.c, parse_command(), + # "myevents" branch), all of them CHANNEL_*/DTMF/TALK. CUSTOM is + # not in it, so no module's CUSTOM event can ever arrive on a + # myevents-only socket, no matter its subclass. + # + # `event plain CUSTOM ` adds exactly that: it sets + # event_list[CUSTOM] plus a subclass entry in the listener's + # event_hash. It is purely additive - it does not reset the + # whitelist myevents just installed (only `noevents`/`nixevent` + # clear entries), so the CHANNEL_* events keep coming. The + # LFLAG_MYEVENTS flag stays set too, so the CUSTOM events we now + # get are still filtered down to this channel's own Unique-ID. + # + # Needs the socket to be "full" (dialplan/originate say + # `socket(host:port async full)`): mod_event_socket drops every + # command past this point for a non-full outbound listener. + self.session.myevents() + self.session.send("event plain CUSTOM mod_audio_stream::play") + self.session.linger() + self.session.register_handle("mod_audio_stream::play", self.on_play_event) + self.session.register_handle("CHANNEL_HANGUP", self.on_hangup) + self.session.answer() + antwort = fs_cli( + f"uuid_audio_stream {self.call_id} start {ASTRA_WS_URL} " + f"{STREAM_MIX_TYPE} {STREAM_SAMPLE_RATE} {self.call_id}" + ) + logger.info("Anruf %s: uuid_audio_stream gestartet, Antwort: %r", self.call_id, antwort) + self.session.park() + # Stay on the line - on_play_event needs to keep firing for the + # whole call, not just the first bot utterance. + self.hung_up.wait(timeout=MAX_CALL_SECONDS) + except Exception: + logger.exception("Anruf %s: Bootstrap fehlgeschlagen", self.call_id) + finally: + self.session.stop() + + +def main(): + if not ASTRA_WS_URL: + sys.exit("ASTRA_TELEFON_WS_URL fehlt in der Umgebung (.env) - Abbruch.") + logger.info("Pipecat-Bootstrap hoert auf %s:%s, Ziel: %s", BIND_ADDRESS, BIND_PORT, ASTRA_WS_URL) + server = greenswitch.OutboundESLServer( + bind_address=BIND_ADDRESS, bind_port=BIND_PORT, + application=PipecatBridge, max_connections=5, + ) + server.listen() + + +if __name__ == "__main__": + main() diff --git a/docs/MITGABE.md b/docs/MITGABE.md deleted file mode 100644 index cc7753f..0000000 --- a/docs/MITGABE.md +++ /dev/null @@ -1,129 +0,0 @@ -# Mitgabe für die Einrichtung auf einem zweiten Rechner - -Diese Datei ergänzt README (Einrichtung) und HANDOFF (Stand). Sie enthält das, -was auf der anderen Seite nicht im Repository ankommt: die Erfahrungswerte aus -dem ersten Volltest, die Stellen, an denen die Vorlagen unvollständig sind, und -die Reihenfolge, in der man prüft, ob es wirklich läuft. - -## 1. Was im Repo *nicht* liegt - -| Fehlt | Woher | -|---|---| -| `.env` | aus `.env.example` kopieren, Werte eintragen (Abschnitt 3) | -| Whisper-Modell (~1,6 GB) | `~/whisper-models/ggml-large-v3-turbo.bin`, Download siehe README | -| Ollama-Modell (~5,2 GB) | `ollama pull qwen3:8b` | -| Piper-Stimme | `~/piper-voices/…`, nur für die Ansage (Stufe 2) nötig | -| `ablage/`, `telefon/eingang/`, `dashboard.html` | Anruf- und Patientendaten, bleiben absichtlich lokal | -| FreeSWITCH-Konfiguration mit echtem SIP-Passwort | `telefon/freeswitch/einrichten.sh` schreibt sie aus der `.env` | - -Alle Namen, Rufnummern und Beispieldaten im Repo sind erfunden. „Praxis -Musterhausen" ist Platzhalter — auch im Screenshot. - -## 2. Der kurze Weg: Stufe 1 ohne Telefonie - -Telefonie (FreeSWITCH, SIP-Trunk) ist für die Erprobung nicht nötig und macht -die meiste Arbeit. Für einen ersten Durchlauf reicht: - -```bash -brew install ffmpeg whisper-cpp -brew install ollama && ollama serve & -ollama pull qwen3:8b -mkdir -p ~/whisper-models && cd ~/whisper-models -curl -LO https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin -cd ~/praxis-telefon-agent && cp .env.example .env -python3 -m pipe.process_call test/rezept.wav --nummer 021031234567 -``` - -Läuft das durch (~25 s), liegt das Ergebnis unter -`ablage/JJJJ-MM-TT/HH-MM-SS_021031234567/`. Nextcloud, Deck, Leitstand und -Telefonie sind alle abschaltbar und kommen erst danach dran. - -## 3. Was die `.env.example` verschweigt - -Die Vorlage deckt nicht alle Schalter ab, die `pipe/config.py` liest. Die -Voreinstellungen greifen still — mit diesen Folgen: - -| Variable | Voreinstellung | Warum das wichtig ist | -|---|---|---| -| `STT_BACKEND` | `whispercpp` | `WHISPER_MODELL`/`WHISPER_COMPUTE` aus der Vorlage gelten **nur** für `faster`. In der Voreinstellung werden sie ignoriert. | -| `WHISPERCPP_MODELL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Liegt das Modell woanders, bricht die Erkennung ab. | -| `WHISPERCPP_BIN` | `whisper-cli` | Muss im `PATH` liegen (Homebrew `whisper-cpp`). | -| `WHISPER_THREADS` | `8` | Auf kleineren Maschinen herunterdrehen. | -| `NEXTCLOUD_DECK` | *aus* | **Häufigste Verwirrung:** `DECK_TEILEN` und `DECK_STAPEL` stehen in der Vorlage, der Hauptschalter nicht. Ohne `NEXTCLOUD_DECK=1` entsteht kein Board. | -| `NEXTCLOUD_USERID` | = `NEXTCLOUD_USER` | Bei E-Mail-Login weicht die interne User-ID ab, dann zeigt der WebDAV-Pfad ins Leere. | -| `FREESWITCH_LOG` | `/opt/homebrew/var/log/freeswitch/…` | Apple-Silicon-Homebrew. Auf Intel-Macs `/usr/local/...`. Sonst bleiben die Telefon-Ereignisse im Leitstand leer. | -| `ABLAGE_LOKAL`, `TELEFON_ORDNER` | im Projektordner | Für eine Ablage auf einem anderen Volume hier umbiegen. | -| `WHISPER_PROMPT` | Praxis-Kontext | Steuert, wie zuverlässig Namen und Rufnummern erkannt werden. Bei anderem Einsatzzweck anpassen. | - -`starten.sh` nutzt außerdem `FS_ETC` (Voreinstellung `/opt/homebrew/etc/freeswitch`) -und `FS_PORT` (`8099`, weil 8021 auf dem Ursprungsrechner belegt war). Auf einer -frischen Maschine ist 8021 meist frei — dann entweder `FS_PORT=8021` setzen oder -den Port in `autoload_configs/event_socket.conf.xml` auf 8099 lassen. Beides -muss zusammenpassen, sonst antwortet `fs_cli` nicht. - -## 4. Prüfleiter — in dieser Reihenfolge - -1. **Erkennung:** `python3 -m pipe.process_call test/termin.wav --nummer 0…` - → `meta.json` enthält ein plausibles `transkript`. -2. **Kategorisierung:** `test/rezept.wav` → `kategorie: rezept`, - `test/notfall.wav` → `dringlichkeit: notfall`. Bleibt `kategorie` leer, - läuft Ollama nicht oder das Modell fehlt. -3. **Name und Rückrufnummer** stehen als eigene Felder in `meta.json` — nicht - nur im Fließtext von `anliegen_kurz` (siehe Falle 1 unten). -4. **Nextcloud:** Zugangsdaten eintragen, erneut einen Anruf verarbeiten, dann - `python3 -m pipe.resync` — meldet 0 Fehler. -5. **Deck:** `NEXTCLOUD_DECK=1` **und** `DECK_TEILEN` setzen. Ohne Freigabe legt - die Pipe Karten an, die Praxis sieht ein leeres Board. -6. **Leitstand:** `python3 -m pipe.server`, dann . -7. **Telefonie** zuletzt: `./telefon/starten.sh` prüft selbst vor und bricht mit - Klartext ab. - -## 5. Fallen, die uns beim ersten Volltest getroffen haben - -1. **Name und Rückrufnummer gingen still verloren.** Beide Felder fehlten in der - `required`-Liste des JSON-Schemas; das Modell ließ sie einfach weg, obwohl es - sie kannte. Behoben — aber beim Anpassen des Schemas in `pipe/categorize.py` - sofort wieder einbaubar. Immer gegen `test/notfall.wav` gegenprüfen. -2. **WebDAV ohne Wiederholung.** Ein einzelner träger Antwortlauf schickt den - Anruf in den manuellen Nachsync. Jetzt 3 Versuche mit Backoff, Timeout 60 s. -3. **Ordnername aus der geratenen statt der CLIP-Nummer.** Die Nummer kommt aus - der Telefonanlage, nicht aus dem Transkript. -4. **Stumme ffmpeg-/whisper-Fehler.** Jetzt mit Klartext — bei eigenen - Änderungen nicht wieder auf nackte `CalledProcessError` zurückfallen. -5. **Telephone.app streitet um dieselbe SIP-Registrierung.** Vor dem Start - beenden; `starten.sh` prüft das. -6. **Rotierende Provider-Adressen.** Löst der SIP-Hostname auf mehrere IPs auf, - bricht die Registrierung dauernd ab. `SIP_PROXY` auf einen Host setzen, der - auf genau eine IP zeigt. -7. **Leerzeichen im Projektpfad brechen die Aufnahme.** FreeSWITCHs `record` - zerlegt seine Argumente an Leerzeichen (`record - `). Liegt das Projekt z. B. unter - `~/Desktop/Server AI/…`, landet die Aufnahme als `…/Server.PCMA` neben dem - Projekt, der Eingang bleibt leer und es sieht aus wie ein Watcher-Fehler — - die Ansage läuft ja. `einrichten.sh` bricht deshalb bei Leerzeichen ab und - nennt den Symlink-Ausweg. Getroffen auf der zweiten Maschine, 23.08.2026. -8. **FreeSWITCH liest aus dem Cellar**, wenn `-conf` fehlt — dann überschreibt - jedes Homebrew-Upgrade Trunk und Dialplan. - -## 6. Was bewusst noch fehlt (vor Produktivbetrieb klären) - -- **Notfall löst keinen Alarm aus.** `dringlichkeit: notfall` bekommt ein rotes - Label und steht im Dashboard oben — kein Ton, keine Push, keine Mail. Eine - Pipe, die Notfälle stumm ablegt, ist für eine Praxis gefährlicher als gar - keine. Das ist der nächste Schritt, vor der Telefonie. -- **Keine Löschfristen**, keine Verschlüsselung at rest. -- **Leitstand im Netz** (`LEITSTAND_HOST=0.0.0.0`) verlangt `LEITSTAND_PASS` — - die Seiten zeigen Transkripte und Aufnahmen von Patienten. -- **Ansage** nennt 112 und weist auf die Aufzeichnung hin. Beides ist bei einer - Arztpraxis Pflicht und darf beim Umtexten nicht wegfallen. -- **Mehrsprachigkeit:** `WHISPER_SPRACHE=de` ist fest. Für EN/AR auf `auto`. - -## 7. Nützliche Rückmeldung - -Wenn auf der anderen Seite etwas abweicht, sind vor allem diese Punkte für uns -hier interessant: - -- andere Hardware/OS-Version und wie lange ein Anruf dann braucht (hier ~24 s), -- ob `qwen3:8b` bei 16 GB RAM neben laufender Telefonie noch passt, -- jede Stelle, an der README oder `.env.example` nicht gereicht haben — die - gehört dann dort hinein, nicht in diese Datei. diff --git a/docs/agentone-screen-1.jpg b/docs/agentone-screen-1.jpg deleted file mode 100644 index 52f9c6c..0000000 Binary files a/docs/agentone-screen-1.jpg and /dev/null differ diff --git a/docs/logpy-agentone-1-1.jpg b/docs/logpy-agentone-1-1.jpg new file mode 100644 index 0000000..4da4c82 Binary files /dev/null and b/docs/logpy-agentone-1-1.jpg differ diff --git a/requirements.txt b/requirements.txt index ff5a861..3e805da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,7 @@ piper-tts>=1.2 certifi # Nur nötig für STT_BACKEND=faster; der Standard ist whisper.cpp. faster-whisper>=1.2 + +# Outbound-ESL-Bootstrap fuer die Pipecat-Testleitung (dialog/pipecat_bootstrap.py): +# nimmt FreeSWITCHs socket()-Uebergabe an, macht answer()+uuid_audio_stream+park(). +greenswitch>=0.0.19 diff --git a/telefon/freeswitch/dw3_warten.xml.tpl b/telefon/freeswitch/dw3_warten.xml.tpl new file mode 100644 index 0000000..f3b1471 --- /dev/null +++ b/telefon/freeswitch/dw3_warten.xml.tpl @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/telefon/freeswitch/dw9_rufagent.xml.tpl b/telefon/freeswitch/dw9_rufagent.xml.tpl new file mode 100644 index 0000000..7d10bf3 --- /dev/null +++ b/telefon/freeswitch/dw9_rufagent.xml.tpl @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/telefon/freeswitch/einrichten.sh b/telefon/freeswitch/einrichten.sh index 4da1b8c..85184b3 100755 --- a/telefon/freeswitch/einrichten.sh +++ b/telefon/freeswitch/einrichten.sh @@ -44,6 +44,8 @@ fuelle() { # -e "s|@@SIP_PASS@@|$SIP_PASS|g" \ -e "s|@@SIP_DOMAIN@@|$SIP_DOMAIN|g" \ -e "s|@@SIP_PROXY@@|${SIP_PROXY:-$SIP_DOMAIN}|g" \ + -e "s|@@DW3_DDI@@|${DW3_DDI:-}|g" \ + -e "s|@@DW9_DDI@@|${DW9_DDI:-}|g" \ -e "s|@@PROJEKT@@|$PROJEKT|g" "$1" > "$2" } @@ -55,3 +57,20 @@ echo "Trunk -> $FS_ETC/sip_profiles/external/plusnet.xml (Rechte 600)" echo "Dialplan -> $FS_ETC/dialplan/public/00_praxis_ab.xml" echo "Ansage -> $PROJEKT/telefon/ansage.wav" echo "Eingang -> $PROJEKT/telefon/eingang" + +if [ -n "${DW3_DDI:-}" ]; then + fuelle "$PROJEKT/telefon/freeswitch/dw3_warten.xml.tpl" "$FS_ETC/dialplan/public/00_dw3_warten.xml" + echo "DW3 -> $FS_ETC/dialplan/public/00_dw3_warten.xml (DDI: $DW3_DDI, 20s Wartezeit)" +else + echo "DW3 -> uebersprungen (DW3_DDI leer in .env)" +fi + +# DW9 (rufagent) -> Astra-Telefonagent. Dateiname "00_dw9_..." ist Absicht: +# muss alphabetisch vor 00_praxis_ab.xml einsortieren, siehe +# dw9_rufagent.xml.tpl fuer die Begruendung (2026-09-13 gefunden). +if [ -n "${DW9_DDI:-}" ]; then + fuelle "$PROJEKT/telefon/freeswitch/dw9_rufagent.xml.tpl" "$FS_ETC/dialplan/public/00_dw9_rufagent.xml" + echo "DW9 -> $FS_ETC/dialplan/public/00_dw9_rufagent.xml (DDI: $DW9_DDI, -> Astra)" +else + echo "DW9 -> uebersprungen (DW9_DDI leer in .env)" +fi diff --git a/telefon/starten.sh b/telefon/starten.sh index caef1c8..e2b13f7 100755 --- a/telefon/starten.sh +++ b/telefon/starten.sh @@ -32,6 +32,19 @@ for log in telefon/autostart.log telefon/watcher.log telefon/leitstand.log telef fi done +# Stoerungswache als Erstes - sie muss gerade dann laufen, wenn der Rest nicht +# hochkommt (Vorabpruefung schlaegt fehl, FreeSWITCH stirbt, Trunk registriert +# nicht). Frueher startete sie erst nach Trunk UP und fehlte damit genau bei +# Ausfaellen: 2026-09-14 war die Leitung nach einem Neustart ~80 min tot, ohne +# Alarm. Sie meldet FreeSWITCH/Trunk selbst (pipe.dienste), beim normalen +# Hochfahren hoechstens einmal "gestoert" und kurz darauf "wieder ok". +if pgrep -f "[p]ipe.monitor" >/dev/null; then + echo "Stoerungswache laeuft bereits." +else + nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 & + echo "Stoerungswache gestartet (Log: telefon/monitor.log)" +fi + fehler=0 melde() { printf ' %-38s %s\n' "$1" "$2"; } @@ -79,7 +92,14 @@ else # -rp: Echtzeit-Priorität. Auf einem Rechner, der nebenher Whisper und ein # Sprachmodell laufen lässt, kommt FreeSWITCH sonst nicht rechtzeitig dran und # das Audio stückelt - RTP will alle 20 ms ein Paket losschicken. - freeswitch -nc -rp -nonatmap \ + # -nonat: keine NAT-PMP/UPnP-Erkennung am Router (192.168.2.1:5351). Unter + # launchd (Watchdog) blockiert macOS' "Lokales Netzwerk"-Datenschutz jedes + # Senden ins LAN mit EPIPE; so frueh im Start ignoriert FreeSWITCH SIGPIPE + # noch nicht und stirbt 0,5 s nach dem Backgrounding - ohne Log, ohne + # Crash-Report. Aus einer Terminal-Shell passiert das nicht, deshalb lief es + # manuell. Der Router antwortet ohnehin nicht (nat_map status: UNKNOWN); die + # externe IP kommt per STUN (vars.xml), Trunks und Sockets brauchen kein LAN. + freeswitch -nc -rp -nonat -nonatmap \ -conf "$FS_ETC" \ -log /opt/homebrew/var/log/freeswitch \ -db /opt/homebrew/var/lib/freeswitch/db \ @@ -123,11 +143,17 @@ if [ "${status:-}" = "UP" ]; then sleep 2 tail -1 telefon/leitstand.log fi - if pgrep -f "[p]ipe.monitor" >/dev/null; then - echo "Stoerungswache laeuft bereits." - else - nohup python3 -u -m pipe.monitor >> telefon/monitor.log 2>&1 & - echo "Stoerungswache gestartet (Log: telefon/monitor.log)" + # Pipecat-Testleitung (Nebenstelle 7501) - nur wenn konfiguriert, siehe + # dialog/pipecat_bootstrap.py und dialplan/default/06_agentzwei_pipecat_test.xml. + if [ -n "${ASTRA_TELEFON_WS_URL:-}" ]; then + if pgrep -f "[d]ialog.pipecat_bootstrap" >/dev/null; then + echo "Pipecat-Bootstrap laeuft bereits." + else + # Aus der Projekt-venv: greenswitch/gevent fehlen im System-python3, + # dort bricht der Bootstrap sofort mit ModuleNotFoundError ab. + nohup .venv/bin/python -u -m dialog.pipecat_bootstrap >> telefon/pipecat_bootstrap.log 2>&1 & + echo "Pipecat-Bootstrap gestartet (Log: telefon/pipecat_bootstrap.log)" + fi fi sleep 2 echo