commit 0b09765013fda238b0d6c48e8bdb8acebd853b5b Author: Stefan Hacker Date: Thu Jan 29 20:31:37 2026 +0100 first commit diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..fba143e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,11 @@ +# ESP32-S3 Bluetooth SIP Client +# SIP-Telefon mit Bluetooth und USB-Headset Unterstützung + +cmake_minimum_required(VERSION 3.16) + +# Setze IDF_TARGET auf ESP32-S3 für USB OTG Support +set(IDF_TARGET esp32s3) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(bluetooth-sip-client) diff --git a/README.md b/README.md new file mode 100644 index 0000000..64356f7 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# ESP32-S3 Bluetooth SIP Client + +Ein SIP-Telefon basierend auf ESP32-S3 mit Bluetooth- und USB-Headset-Unterstützung. Entwickelt für Thin-Client-Umgebungen ohne nativen CTI-Support. + +## Features + +- **WiFi-Konfiguration** + - Automatischer Hotspot-Modus wenn keine WLAN-Daten konfiguriert + - Web-basierte Konfiguration (SSID: `ESP32-SIP-Phone`, IP: `192.168.4.1`) + - DHCP oder statische IP-Konfiguration + - Automatischer Fallback zu Hotspot bei Verbindungsproblemen + +- **Bluetooth Headsets** + - HFP (Hands-Free Profile) Unterstützung + - Mehrere Geräte pairen (>5 möglich) + - Auto-Connect für bekannte Geräte + - Headset-Tasten: Annehmen, Auflegen, Lautstärke + +- **USB Headsets** + - USB Audio Class Unterstützung + - USB-C direkter Anschluss oder USB-A mit Adapter + - **USB hat immer Priorität über Bluetooth** + - Automatische Erkennung + +- **SIP/VoIP** + - SIP-Registrierung an TK-Anlage + - Eingehende und ausgehende Anrufe + - G.711 µ-law und A-law Codec + - Digest Authentication + +- **Weboberfläche** + - Status-Übersicht + - WLAN-Konfiguration mit Scan + - SIP-Einstellungen + - Bluetooth-Geräte-Management + - System-Einstellungen (Neustart, Werksreset) + +## Hardware + +### Empfohlenes Board +- **ESP32-S3-DevKitC-1** (oder ähnlich mit USB OTG) +- Mindestens 4MB Flash +- Optional: PSRAM für bessere Audio-Pufferung + +### Anschlüsse +- **USB-C (USB OTG)**: Für USB-Headsets +- **WLAN**: 2.4GHz WiFi + +## Installation + +### Voraussetzungen +- ESP-IDF v5.0 oder neuer +- USB-Kabel für Programmierung + +### Build & Flash +```bash +# ESP-IDF Umgebung aktivieren +. $IDF_PATH/export.sh + +# Konfigurieren +idf.py set-target esp32s3 +idf.py menuconfig + +# Bauen und Flashen +idf.py build flash monitor +``` + +## Ersteinrichtung + +1. **ESP32 mit Strom versorgen** + - LED sollte nach Boot leuchten + +2. **Mit Hotspot verbinden** + - SSID: `ESP32-SIP-Phone` + - Passwort: `sip-phone-setup` + +3. **Weboberfläche öffnen** + - Browser: `http://192.168.4.1` + +4. **WLAN konfigurieren** + - Tab "WLAN" → Netzwerk auswählen → Verbinden + +5. **SIP konfigurieren** + - Tab "SIP" → Server, Benutzername, Passwort eingeben + +6. **Bluetooth Headset verbinden** + - Headset in Pairing-Modus setzen + - Tab "Bluetooth" → Gerät pairen + +## API Endpoints + +| Endpoint | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/status` | GET | Gesamtstatus | +| `/api/wifi/config` | GET/POST | WLAN-Konfiguration | +| `/api/wifi/scan` | GET | WLAN scannen | +| `/api/sip/config` | GET/POST | SIP-Konfiguration | +| `/api/bluetooth/devices` | GET | Gepaarte BT-Geräte | +| `/api/bluetooth/scan` | POST | BT-Scan starten | +| `/api/bluetooth/pair` | POST | Gerät pairen | +| `/api/call/answer` | POST | Anruf annehmen | +| `/api/call/hangup` | POST | Anruf beenden | +| `/api/system/reboot` | POST | Neustart | +| `/api/system/factory-reset` | POST | Werksreset | + +## Projektstruktur + +``` +bluetooth-sip-client/ +├── CMakeLists.txt +├── sdkconfig.defaults +├── partitions.csv +└── main/ + ├── main.c # Hauptprogramm + ├── config/ + │ ├── config_manager.h # Konfiguration Header + │ └── config_manager.c # NVS-basierte Config + ├── wifi/ + │ ├── wifi_manager.h # WiFi Header + │ └── wifi_manager.c # WiFi AP/STA Manager + ├── web/ + │ ├── web_server.h # Webserver Header + │ ├── web_server.c # HTTP Server + │ ├── web_api.h # API Header + │ ├── web_api.c # REST API + │ └── static/ # Web UI + │ ├── index.html + │ ├── style.css + │ └── app.js + ├── bluetooth/ + │ ├── bt_manager.h # BT Manager Header + │ ├── bt_manager.c # BT Geräteverwaltung + │ ├── bt_hfp.h # HFP Header + │ └── bt_hfp.c # HFP Audio Gateway + ├── usb_audio/ + │ ├── usb_audio_host.h # USB Audio Header + │ └── usb_audio_host.c # USB Audio Class Host + ├── audio/ + │ ├── audio_router.h # Audio Router Header + │ └── audio_router.c # Audio Routing & Priorität + └── sip/ + ├── sip_client.h # SIP Client Header + ├── sip_client.c # SIP UA Implementation + ├── sip_parser.h # SIP Parser Header + └── sip_parser.c # SIP Message Parser +``` + +## Einschränkungen + +- **Bluetooth**: Maximal 3-4 gleichzeitig aktive Verbindungen (ESP32 Limitation), aber mehr Geräte können gepaart werden +- **USB Audio**: Grundlegende UAC1 Unterstützung, nicht alle Headsets getestet +- **Codecs**: Aktuell nur G.711 (PCMU/PCMA) +- **SRTP**: Nicht unterstützt (nur unverschlüsseltes RTP) + +## Troubleshooting + +### Kein WLAN nach Konfiguration +- Gerät neu starten +- Hotspot sollte nach 30 Sekunden automatisch starten + +### Bluetooth Headset verbindet nicht +- Headset zurücksetzen und erneut pairen +- Andere Bluetooth-Verbindungen am Headset trennen + +### Kein Audio bei Anruf +- USB-Headset hat Priorität - bei Verwendung von BT das USB-Kabel entfernen +- Audio-Routing prüfen im Status-Tab + +## Lizenz + +MIT License diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..1f15d8e --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,39 @@ +idf_component_register( + SRCS + "main.c" + "config/config_manager.c" + "wifi/wifi_manager.c" + "web/web_server.c" + "web/web_api.c" + "bluetooth/bt_manager.c" + "bluetooth/bt_hfp.c" + "usb_audio/usb_audio_host.c" + "audio/audio_router.c" + "sip/sip_client.c" + "sip/sip_parser.c" + INCLUDE_DIRS + "." + "config" + "wifi" + "web" + "bluetooth" + "usb_audio" + "audio" + "sip" + EMBED_FILES + "web/static/index.html" + "web/static/style.css" + "web/static/app.js" + REQUIRES + nvs_flash + esp_wifi + esp_netif + esp_http_server + bt + usb + driver + esp_event + json + lwip + mdns +) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..ddb7147 --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,57 @@ +menu "Bluetooth SIP Client Configuration" + + config BSC_DEFAULT_AP_SSID + string "Default Hotspot SSID" + default "ESP32-SIP-Phone" + help + SSID für den Standard-Hotspot wenn keine WLAN-Daten konfiguriert sind. + + config BSC_DEFAULT_AP_PASSWORD + string "Default Hotspot Password" + default "sip-phone-setup" + help + Passwort für den Standard-Hotspot. + + config BSC_DEFAULT_AP_IP + string "Default Hotspot IP" + default "192.168.4.1" + help + IP-Adresse des ESP32 im Hotspot-Modus. + + config BSC_DEFAULT_AP_GATEWAY + string "Default Hotspot Gateway" + default "192.168.4.1" + help + Gateway-Adresse im Hotspot-Modus. + + config BSC_DEFAULT_AP_NETMASK + string "Default Hotspot Netmask" + default "255.255.255.0" + help + Netzmaske im Hotspot-Modus. + + config BSC_WEB_PORT + int "Webserver Port" + default 80 + help + Port für den Konfigurations-Webserver. + + config BSC_MAX_BT_DEVICES + int "Maximum Bluetooth Paired Devices" + default 10 + help + Maximale Anzahl gepairter Bluetooth-Geräte. + + config BSC_BT_DEVICE_NAME + string "Bluetooth Device Name" + default "ESP32-SIP-Phone" + help + Bluetooth-Gerätename für die Erkennung. + + config BSC_SIP_DEFAULT_PORT + int "Default SIP Port" + default 5060 + help + Standard SIP-Port für die TK-Anlage. + +endmenu diff --git a/main/audio/audio_router.c b/main/audio/audio_router.c new file mode 100644 index 0000000..e2ee0fb --- /dev/null +++ b/main/audio/audio_router.c @@ -0,0 +1,401 @@ +/** + * Audio Router - Verwaltet Audio-Quellen und -Routing + * + * USB hat Priorität über Bluetooth + * Automatischer Wechsel bei Verbindungsänderungen + */ + +#include +#include "audio_router.h" +#include "bluetooth/bt_manager.h" +#include "usb_audio/usb_audio_host.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +static const char* TAG = "AUDIO_RT"; + +// State +static bool s_initialized = false; +static audio_source_t s_active_source = AUDIO_SOURCE_NONE; +static bool s_call_active = false; +static uint8_t s_volume = 80; +static bool s_muted = false; +static audio_stats_t s_stats; +static SemaphoreHandle_t s_mutex = NULL; + +// Callbacks +static audio_from_headset_callback_t s_input_callback = NULL; +static headset_button_callback_t s_button_callback = NULL; +static audio_source_change_callback_t s_source_change_callback = NULL; + +// Forward Declarations +static void update_active_source(void); +static void bt_audio_data_handler(const uint8_t* data, size_t len); +static void bt_button_handler(bt_button_event_t event); +static void usb_audio_data_handler(const uint8_t* data, size_t len); +static void usb_button_handler(usb_button_event_t event); +static void usb_state_handler(usb_audio_state_t state); + +// Callback von Bluetooth Manager +static void bt_audio_data_handler(const uint8_t* data, size_t len) +{ + if (s_active_source != AUDIO_SOURCE_BLUETOOTH || !s_call_active) { + return; + } + + s_stats.packets_received++; + + // An SIP weiterleiten + if (s_input_callback) { + audio_format_t format = { + .sample_rate = 8000, // HFP Standard + .channels = 1, + .bits_per_sample = 16 + }; + s_input_callback(data, len, &format); + } +} + +static void bt_button_handler(bt_button_event_t event) +{ + headset_button_t button; + + switch (event) { + case BT_BUTTON_ANSWER: + button = HEADSET_BUTTON_ANSWER; + break; + case BT_BUTTON_REJECT: + button = HEADSET_BUTTON_REJECT; + break; + case BT_BUTTON_HANGUP: + button = HEADSET_BUTTON_HANGUP; + break; + case BT_BUTTON_VOLUME_UP: + button = HEADSET_BUTTON_VOLUME_UP; + break; + case BT_BUTTON_VOLUME_DOWN: + button = HEADSET_BUTTON_VOLUME_DOWN; + break; + case BT_BUTTON_MUTE: + button = HEADSET_BUTTON_MUTE; + break; + default: + return; + } + + if (s_button_callback) { + s_button_callback(button, AUDIO_SOURCE_BLUETOOTH); + } +} + +// Callback von USB Audio +static void usb_audio_data_handler(const uint8_t* data, size_t len) +{ + if (s_active_source != AUDIO_SOURCE_USB || !s_call_active) { + return; + } + + s_stats.packets_received++; + + // An SIP weiterleiten + if (s_input_callback) { + audio_format_t format = { + .sample_rate = 16000, // USB typisch + .channels = 1, + .bits_per_sample = 16 + }; + s_input_callback(data, len, &format); + } +} + +static void usb_button_handler(usb_button_event_t event) +{ + headset_button_t button; + + switch (event) { + case USB_BUTTON_ANSWER: + button = HEADSET_BUTTON_ANSWER; + break; + case USB_BUTTON_HANGUP: + button = HEADSET_BUTTON_HANGUP; + break; + case USB_BUTTON_MUTE: + button = HEADSET_BUTTON_MUTE; + break; + case USB_BUTTON_VOLUME_UP: + button = HEADSET_BUTTON_VOLUME_UP; + break; + case USB_BUTTON_VOLUME_DOWN: + button = HEADSET_BUTTON_VOLUME_DOWN; + break; + default: + return; + } + + if (s_button_callback) { + s_button_callback(button, AUDIO_SOURCE_USB); + } +} + +static void usb_state_handler(usb_audio_state_t state) +{ + ESP_LOGI(TAG, "USB Audio State: %d", state); + + // Bei USB Verbindungsänderung Quelle neu evaluieren + update_active_source(); +} + +static void update_active_source(void) +{ + audio_source_t new_source = AUDIO_SOURCE_NONE; + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + // USB hat höchste Priorität + if (usb_audio_host_is_connected()) { + new_source = AUDIO_SOURCE_USB; + } + // Bluetooth als Fallback + else if (bt_manager_is_connected()) { + new_source = AUDIO_SOURCE_BLUETOOTH; + } + + if (new_source != s_active_source) { + audio_source_t old_source = s_active_source; + s_active_source = new_source; + + const char* sources[] = {"Keine", "USB", "Bluetooth"}; + ESP_LOGI(TAG, "Audio-Quelle: %s -> %s", sources[old_source], sources[new_source]); + + // Bei aktivem Anruf: Streaming anpassen + if (s_call_active) { + // Altes Audio stoppen + if (old_source == AUDIO_SOURCE_USB) { + usb_audio_host_stop_stream(); + } + // Neues Audio starten + if (new_source == AUDIO_SOURCE_USB) { + usb_audio_host_start_stream(); + } + } + + xSemaphoreGive(s_mutex); + + // Callback aufrufen + if (s_source_change_callback) { + s_source_change_callback(old_source, new_source); + } + } else { + xSemaphoreGive(s_mutex); + } +} + +esp_err_t audio_router_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere Audio Router"); + + s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex) { + ESP_LOGE(TAG, "Mutex erstellen fehlgeschlagen"); + return ESP_ERR_NO_MEM; + } + + // Callbacks bei Audio-Quellen registrieren + bt_manager_register_audio_data_callback(bt_audio_data_handler); + bt_manager_register_button_callback(bt_button_handler); + + usb_audio_host_register_data_callback(usb_audio_data_handler); + usb_audio_host_register_button_callback(usb_button_handler); + usb_audio_host_register_state_callback(usb_state_handler); + + memset(&s_stats, 0, sizeof(s_stats)); + + s_initialized = true; + + // Initial die Quelle bestimmen + update_active_source(); + + ESP_LOGI(TAG, "Audio Router initialisiert"); + return ESP_OK; +} + +esp_err_t audio_router_deinit(void) +{ + if (!s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Deinitalisiere Audio Router"); + + if (s_call_active) { + audio_router_stop_call(); + } + + if (s_mutex) { + vSemaphoreDelete(s_mutex); + s_mutex = NULL; + } + + s_initialized = false; + return ESP_OK; +} + +audio_source_t audio_router_get_active_source(void) +{ + return s_active_source; +} + +bool audio_router_is_source_available(audio_source_t source) +{ + switch (source) { + case AUDIO_SOURCE_USB: + return usb_audio_host_is_connected(); + case AUDIO_SOURCE_BLUETOOTH: + return bt_manager_is_connected(); + default: + return false; + } +} + +esp_err_t audio_router_start_call(void) +{ + if (s_call_active) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Starte Audio für Anruf"); + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + s_call_active = true; + memset(&s_stats, 0, sizeof(s_stats)); + + // Audio-Streaming auf aktiver Quelle starten + if (s_active_source == AUDIO_SOURCE_USB) { + usb_audio_host_start_stream(); + } else if (s_active_source == AUDIO_SOURCE_BLUETOOTH) { + // BT Audio wird automatisch über HFP gestartet + // bt_hfp_audio_connect() wird vom SIP-Client aufgerufen + } + + xSemaphoreGive(s_mutex); + + return ESP_OK; +} + +esp_err_t audio_router_stop_call(void) +{ + if (!s_call_active) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Stoppe Audio für Anruf"); + + xSemaphoreTake(s_mutex, portMAX_DELAY); + + s_call_active = false; + + // Audio-Streaming stoppen + if (s_active_source == AUDIO_SOURCE_USB) { + usb_audio_host_stop_stream(); + } + + xSemaphoreGive(s_mutex); + + return ESP_OK; +} + +esp_err_t audio_router_send_to_headset(const uint8_t* data, size_t len, const audio_format_t* format) +{ + if (!s_call_active || s_active_source == AUDIO_SOURCE_NONE) { + return ESP_ERR_INVALID_STATE; + } + + if (s_muted) { + return ESP_OK; // Muted - nichts senden + } + + s_stats.packets_sent++; + + // TODO: Lautstärke anwenden + // TODO: Format-Konvertierung wenn nötig + + esp_err_t ret = ESP_OK; + + if (s_active_source == AUDIO_SOURCE_USB) { + ret = usb_audio_host_send(data, len); + } else if (s_active_source == AUDIO_SOURCE_BLUETOOTH) { + ret = bt_manager_send_audio(data, len); + } + + if (ret != ESP_OK) { + s_stats.underruns++; + } + + return ret; +} + +void audio_router_register_input_callback(audio_from_headset_callback_t callback) +{ + s_input_callback = callback; +} + +void audio_router_register_button_callback(headset_button_callback_t callback) +{ + s_button_callback = callback; +} + +void audio_router_register_source_change_callback(audio_source_change_callback_t callback) +{ + s_source_change_callback = callback; +} + +esp_err_t audio_router_set_volume(uint8_t volume) +{ + if (volume > 100) volume = 100; + s_volume = volume; + + ESP_LOGI(TAG, "Lautstärke: %d%%", volume); + + // Lautstärke an aktive Quelle weitergeben + if (s_active_source == AUDIO_SOURCE_USB) { + usb_audio_host_set_volume(volume); + } + // BT: Volume wird über HFP AG gehandhabt + + return ESP_OK; +} + +uint8_t audio_router_get_volume(void) +{ + return s_volume; +} + +esp_err_t audio_router_set_mute(bool mute) +{ + s_muted = mute; + + ESP_LOGI(TAG, "Mute: %s", mute ? "an" : "aus"); + + if (s_active_source == AUDIO_SOURCE_USB) { + usb_audio_host_set_mute(mute); + } + + return ESP_OK; +} + +bool audio_router_is_muted(void) +{ + return s_muted; +} + +esp_err_t audio_router_get_stats(audio_stats_t* stats) +{ + if (!stats) return ESP_ERR_INVALID_ARG; + memcpy(stats, &s_stats, sizeof(audio_stats_t)); + return ESP_OK; +} diff --git a/main/audio/audio_router.h b/main/audio/audio_router.h new file mode 100644 index 0000000..ec93041 --- /dev/null +++ b/main/audio/audio_router.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Audio-Quelle +typedef enum { + AUDIO_SOURCE_NONE = 0, + AUDIO_SOURCE_USB, // USB Headset (höchste Priorität) + AUDIO_SOURCE_BLUETOOTH // Bluetooth Headset +} audio_source_t; + +// Audio-Format +typedef struct { + uint32_t sample_rate; + uint8_t channels; + uint8_t bits_per_sample; +} audio_format_t; + +// Audio-Statistiken +typedef struct { + uint32_t packets_sent; + uint32_t packets_received; + uint32_t underruns; + uint32_t overruns; + int16_t input_level_db; + int16_t output_level_db; +} audio_stats_t; + +// Button Event Callback (vereinheitlicht USB und BT) +typedef enum { + HEADSET_BUTTON_ANSWER = 0, + HEADSET_BUTTON_HANGUP, + HEADSET_BUTTON_REJECT, + HEADSET_BUTTON_MUTE, + HEADSET_BUTTON_VOLUME_UP, + HEADSET_BUTTON_VOLUME_DOWN +} headset_button_t; + +typedef void (*headset_button_callback_t)(headset_button_t button, audio_source_t source); +typedef void (*audio_source_change_callback_t)(audio_source_t old_source, audio_source_t new_source); + +/** + * Initialisiert den Audio-Router + */ +esp_err_t audio_router_init(void); + +/** + * Deinitalisiert den Audio-Router + */ +esp_err_t audio_router_deinit(void); + +/** + * Gibt die aktuell aktive Audio-Quelle zurück + * USB hat immer Priorität über Bluetooth + */ +audio_source_t audio_router_get_active_source(void); + +/** + * Prüft ob eine Audio-Quelle verfügbar ist + */ +bool audio_router_is_source_available(audio_source_t source); + +/** + * Startet Audio-Routing für einen Anruf + */ +esp_err_t audio_router_start_call(void); + +/** + * Stoppt Audio-Routing + */ +esp_err_t audio_router_stop_call(void); + +/** + * Sendet Audio-Daten von SIP zum aktiven Headset + */ +esp_err_t audio_router_send_to_headset(const uint8_t* data, size_t len, const audio_format_t* format); + +/** + * Empfängt Audio-Daten vom aktiven Headset für SIP + * Wird intern über Callback verarbeitet + */ +typedef void (*audio_from_headset_callback_t)(const uint8_t* data, size_t len, const audio_format_t* format); +void audio_router_register_input_callback(audio_from_headset_callback_t callback); + +/** + * Registriert Callback für Headset-Button-Events + */ +void audio_router_register_button_callback(headset_button_callback_t callback); + +/** + * Registriert Callback für Quellenwechsel + */ +void audio_router_register_source_change_callback(audio_source_change_callback_t callback); + +/** + * Setzt die Master-Lautstärke (0-100) + */ +esp_err_t audio_router_set_volume(uint8_t volume); + +/** + * Gibt die aktuelle Lautstärke zurück + */ +uint8_t audio_router_get_volume(void); + +/** + * Setzt Mute + */ +esp_err_t audio_router_set_mute(bool mute); + +/** + * Gibt den Mute-Status zurück + */ +bool audio_router_is_muted(void); + +/** + * Gibt Audio-Statistiken zurück + */ +esp_err_t audio_router_get_stats(audio_stats_t* stats); + +#ifdef __cplusplus +} +#endif diff --git a/main/bluetooth/bt_hfp.c b/main/bluetooth/bt_hfp.c new file mode 100644 index 0000000..35d1040 --- /dev/null +++ b/main/bluetooth/bt_hfp.c @@ -0,0 +1,324 @@ +/** + * Bluetooth HFP - Hands-Free Profile Audio Gateway + * + * ESP32 agiert als Audio Gateway (AG) - die Rolle einer Telefonanlage + * Headset ist das HF (Hands-Free) Device + */ + +#include +#include "bt_hfp.h" +#include "bt_manager.h" +#include "esp_log.h" +#include "esp_hf_ag_api.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" + +static const char* TAG = "BT_HFP"; + +// Audio Buffer +#define AUDIO_RINGBUF_SIZE (8 * 1024) +static RingbufHandle_t s_audio_out_ringbuf = NULL; + +// State +static bool s_initialized = false; +static bool s_audio_connected = false; +static esp_bd_addr_t s_connected_peer; +static bool s_service_connected = false; + +// External notifications (definiert in bt_manager.c) +extern void bt_manager_notify_connected(const esp_bd_addr_t address); +extern void bt_manager_notify_disconnected(const esp_bd_addr_t address); +extern void bt_manager_notify_button(bt_button_event_t event); +extern void bt_manager_notify_audio_data(const uint8_t* data, size_t len); + +// HFP AG Callback +static void hf_ag_callback(esp_hf_cb_event_t event, esp_hf_cb_param_t *param) +{ + switch (event) { + case ESP_HF_CONNECTION_STATE_EVT: + if (param->conn_stat.state == ESP_HF_CONNECTION_STATE_CONNECTED) { + ESP_LOGI(TAG, "HFP Service verbunden"); + memcpy(s_connected_peer, param->conn_stat.remote_bda, ESP_BD_ADDR_LEN); + s_service_connected = true; + bt_manager_notify_connected(param->conn_stat.remote_bda); + } else if (param->conn_stat.state == ESP_HF_CONNECTION_STATE_DISCONNECTED) { + ESP_LOGI(TAG, "HFP Service getrennt"); + s_service_connected = false; + s_audio_connected = false; + bt_manager_notify_disconnected(param->conn_stat.remote_bda); + } else if (param->conn_stat.state == ESP_HF_CONNECTION_STATE_SLC_CONNECTED) { + ESP_LOGI(TAG, "HFP SLC verbunden (Service Level Connection)"); + } + break; + + case ESP_HF_AUDIO_STATE_EVT: + if (param->audio_stat.state == ESP_HF_AUDIO_STATE_CONNECTED) { + ESP_LOGI(TAG, "HFP Audio verbunden (SCO)"); + s_audio_connected = true; + } else if (param->audio_stat.state == ESP_HF_AUDIO_STATE_DISCONNECTED) { + ESP_LOGI(TAG, "HFP Audio getrennt"); + s_audio_connected = false; + } + break; + + case ESP_HF_BVRA_EVT: + // Voice Recognition aktiviert/deaktiviert + ESP_LOGI(TAG, "Voice Recognition: %s", + param->vra_rep.value ? "aktiviert" : "deaktiviert"); + break; + + case ESP_HF_VOLUME_CONTROL_EVT: + ESP_LOGI(TAG, "Volume %s: %d", + param->volume_control.type == ESP_HF_VOLUME_CONTROL_TARGET_SPK ? + "Speaker" : "Mic", + param->volume_control.volume); + break; + + case ESP_HF_UNAT_RESPONSE_EVT: + // Unknown AT Command + ESP_LOGD(TAG, "Unbekannter AT Command: %s", param->unat_rep.unat); + break; + + case ESP_HF_CIND_RESPONSE_EVT: + // Indicator Status Request + ESP_LOGD(TAG, "CIND Request"); + // Antworten mit Standard-Werten + esp_hf_ag_cind_response(s_connected_peer, + 1, // call (0=no call, 1=call) + 0, // call_setup (0=none, 1=incoming, 2=outgoing) + 1, // service (0=no service, 1=service) + 5, // signal (0-5) + 0, // roam (0=not roaming, 1=roaming) + 5, // batt (0-5) + 0); // call_held (0=none, 1=held, 2=hold+active) + break; + + case ESP_HF_CLCC_RESPONSE_EVT: + // Call List Request - keine aktiven Anrufe melden + ESP_LOGD(TAG, "CLCC Request"); + esp_hf_ag_clcc_response(s_connected_peer, 0, 0, 0, 0, 0, NULL); + break; + + case ESP_HF_COPS_RESPONSE_EVT: + // Network Operator Request + ESP_LOGD(TAG, "COPS Request"); + esp_hf_ag_cops_response(s_connected_peer, "SIP Phone"); + break; + + case ESP_HF_CNUM_RESPONSE_EVT: + // Subscriber Number Request + ESP_LOGD(TAG, "CNUM Request"); + esp_hf_ag_cnum_response(s_connected_peer, NULL, 0); + break; + + case ESP_HF_VTS_RESPONSE_EVT: + // DTMF Tone + ESP_LOGI(TAG, "DTMF: %s", param->vts_rep.code); + break; + + case ESP_HF_NREC_RESPONSE_EVT: + // Noise Reduction / Echo Cancellation + ESP_LOGI(TAG, "NREC: %s", param->nrec.state ? "an" : "aus"); + break; + + case ESP_HF_ATA_RESPONSE_EVT: + // Answer Call (ATA) + ESP_LOGI(TAG, "Headset: Anruf annehmen"); + bt_manager_notify_button(BT_BUTTON_ANSWER); + break; + + case ESP_HF_CHUP_RESPONSE_EVT: + // Hangup Call (AT+CHUP) + ESP_LOGI(TAG, "Headset: Auflegen"); + bt_manager_notify_button(BT_BUTTON_HANGUP); + break; + + case ESP_HF_DIAL_EVT: + // Dial (ATD, ATD>, ATD>mem) + if (param->out_call.type == ESP_HF_DIAL_MEM) { + ESP_LOGI(TAG, "Headset: Wähle Speicher %d", param->out_call.num_or_loc); + } else if (param->out_call.type == ESP_HF_DIAL_VOIP) { + ESP_LOGI(TAG, "Headset: Wähle %s", param->out_call.num_or_loc); + } else { + ESP_LOGI(TAG, "Headset: Wahlwiederholung"); + bt_manager_notify_button(BT_BUTTON_REDIAL); + } + break; + + case ESP_HF_WBS_RESPONSE_EVT: + // Wide Band Speech (mSBC Codec) + ESP_LOGI(TAG, "WBS Codec: %s", + param->wbs_rep.codec == ESP_HF_WBS_PLCM ? "mSBC" : "CVSD"); + break; + + case ESP_HF_BCS_RESPONSE_EVT: + // Codec Selection + ESP_LOGI(TAG, "Codec Selected: %d", param->bcs_rep.mode); + break; + + default: + ESP_LOGD(TAG, "HFP Event: %d", event); + break; + } +} + +// Audio Data Callback (eingehend vom Headset - Mikrofon) +static uint32_t hf_ag_incoming_data_callback(uint8_t *buf, uint32_t len) +{ + // Audio-Daten vom Headset-Mikrofon weiterleiten + bt_manager_notify_audio_data(buf, len); + return len; +} + +// Audio Data Request (ausgehend zum Headset - Speaker) +static void hf_ag_outgoing_data_callback(uint8_t *buf, uint32_t len) +{ + if (s_audio_out_ringbuf) { + size_t item_size; + uint8_t* data = xRingbufferReceiveUpTo(s_audio_out_ringbuf, &item_size, 0, len); + if (data && item_size > 0) { + memcpy(buf, data, item_size); + vRingbufferReturnItem(s_audio_out_ringbuf, data); + + // Rest mit Stille füllen + if (item_size < len) { + memset(buf + item_size, 0, len - item_size); + } + } else { + // Keine Daten - Stille senden + memset(buf, 0, len); + } + } else { + memset(buf, 0, len); + } +} + +esp_err_t bt_hfp_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere HFP Audio Gateway"); + + // Audio Output Buffer erstellen + s_audio_out_ringbuf = xRingbufferCreate(AUDIO_RINGBUF_SIZE, RINGBUF_TYPE_BYTEBUF); + if (!s_audio_out_ringbuf) { + ESP_LOGE(TAG, "Ringbuffer erstellen fehlgeschlagen"); + return ESP_ERR_NO_MEM; + } + + // HFP AG initialisieren + esp_err_t ret = esp_hf_ag_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "HFP AG init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Callback registrieren + ret = esp_hf_ag_register_callback(hf_ag_callback); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "HFP AG callback register failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Audio Data Callbacks registrieren + esp_hf_ag_register_data_callback(hf_ag_incoming_data_callback, + hf_ag_outgoing_data_callback); + + s_initialized = true; + ESP_LOGI(TAG, "HFP Audio Gateway initialisiert"); + return ESP_OK; +} + +esp_err_t bt_hfp_deinit(void) +{ + if (!s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Deinitalisiere HFP"); + + if (s_audio_connected) { + bt_hfp_audio_disconnect(); + } + + if (s_service_connected) { + esp_hf_ag_slc_disconnect(s_connected_peer); + } + + esp_hf_ag_deinit(); + + if (s_audio_out_ringbuf) { + vRingbufferDelete(s_audio_out_ringbuf); + s_audio_out_ringbuf = NULL; + } + + s_initialized = false; + return ESP_OK; +} + +esp_err_t bt_hfp_connect(const esp_bd_addr_t address) +{ + if (!s_initialized) { + return ESP_ERR_INVALID_STATE; + } + + char addr_str[18]; + bt_addr_to_str(address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "HFP Connect: %s", addr_str); + + return esp_hf_ag_slc_connect(address); +} + +esp_err_t bt_hfp_disconnect(const esp_bd_addr_t address) +{ + if (!s_initialized || !s_service_connected) { + return ESP_ERR_INVALID_STATE; + } + + if (s_audio_connected) { + bt_hfp_audio_disconnect(); + } + + return esp_hf_ag_slc_disconnect(address); +} + +esp_err_t bt_hfp_audio_connect(void) +{ + if (!s_initialized || !s_service_connected) { + return ESP_ERR_INVALID_STATE; + } + + if (s_audio_connected) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Starte SCO Audio..."); + return esp_hf_ag_audio_connect(s_connected_peer); +} + +esp_err_t bt_hfp_audio_disconnect(void) +{ + if (!s_initialized || !s_audio_connected) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Stoppe SCO Audio..."); + return esp_hf_ag_audio_disconnect(s_connected_peer); +} + +esp_err_t bt_hfp_send_audio(const uint8_t* data, size_t len) +{ + if (!s_initialized || !s_audio_connected || !s_audio_out_ringbuf) { + return ESP_ERR_INVALID_STATE; + } + + // In Ringbuffer schreiben + if (xRingbufferSend(s_audio_out_ringbuf, data, len, 0) != pdTRUE) { + // Buffer voll - alte Daten verwerfen + ESP_LOGW(TAG, "Audio buffer overflow"); + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} diff --git a/main/bluetooth/bt_hfp.h b/main/bluetooth/bt_hfp.h new file mode 100644 index 0000000..26ddf74 --- /dev/null +++ b/main/bluetooth/bt_hfp.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include "esp_err.h" +#include "esp_bt_defs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialisiert das HFP Audio Gateway Profil + */ +esp_err_t bt_hfp_init(void); + +/** + * Deinitalisiert HFP + */ +esp_err_t bt_hfp_deinit(void); + +/** + * Verbindet HFP mit einem Gerät + */ +esp_err_t bt_hfp_connect(const esp_bd_addr_t address); + +/** + * Trennt HFP-Verbindung + */ +esp_err_t bt_hfp_disconnect(const esp_bd_addr_t address); + +/** + * Startet Audio-Streaming (SCO) + */ +esp_err_t bt_hfp_audio_connect(void); + +/** + * Stoppt Audio-Streaming + */ +esp_err_t bt_hfp_audio_disconnect(void); + +/** + * Sendet Audio-Daten + */ +esp_err_t bt_hfp_send_audio(const uint8_t* data, size_t len); + +#ifdef __cplusplus +} +#endif diff --git a/main/bluetooth/bt_manager.c b/main/bluetooth/bt_manager.c new file mode 100644 index 0000000..248cab9 --- /dev/null +++ b/main/bluetooth/bt_manager.c @@ -0,0 +1,455 @@ +/** + * Bluetooth Manager - Verwaltet Bluetooth Classic Headsets + * + * Unterstützt HFP (Hands-Free Profile) für Headsets + */ + +#include +#include +#include "bt_manager.h" +#include "bt_hfp.h" +#include "config/config_manager.h" +#include "esp_log.h" +#include "esp_bt.h" +#include "esp_bt_main.h" +#include "esp_bt_device.h" +#include "esp_gap_bt_api.h" +#include "esp_hf_ag_api.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char* TAG = "BT_MGR"; + +// State +static bool s_initialized = false; +static bool s_discovering = false; +static esp_bd_addr_t s_connected_device; +static bool s_device_connected = false; + +// Callbacks +static bt_device_callback_t s_device_callback = NULL; +static bt_discovery_callback_t s_discovery_callback = NULL; +static bt_audio_callback_t s_audio_callback = NULL; +static bt_button_callback_t s_button_callback = NULL; +static bt_audio_data_callback_t s_audio_data_callback = NULL; + +// GAP Callback +static void gap_callback(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) +{ + switch (event) { + case ESP_BT_GAP_DISC_RES_EVT: { + // Gerät gefunden + if (s_discovery_callback) { + bt_discovered_device_t dev; + memcpy(dev.address, param->disc_res.bda, ESP_BD_ADDR_LEN); + dev.rssi = 0; + dev.cod = 0; + dev.is_headset = false; + dev.name[0] = '\0'; + + for (int i = 0; i < param->disc_res.num_prop; i++) { + esp_bt_gap_dev_prop_t* prop = ¶m->disc_res.prop[i]; + + switch (prop->type) { + case ESP_BT_GAP_DEV_PROP_BDNAME: + strncpy(dev.name, (char*)prop->val, sizeof(dev.name) - 1); + break; + case ESP_BT_GAP_DEV_PROP_RSSI: + dev.rssi = *(int8_t*)prop->val; + break; + case ESP_BT_GAP_DEV_PROP_COD: + dev.cod = *(uint32_t*)prop->val; + // Check if headset (Major Device Class: Audio/Video) + if (((dev.cod >> 8) & 0x1F) == 0x04) { + dev.is_headset = true; + } + break; + default: + break; + } + } + + if (dev.is_headset || dev.name[0] != '\0') { + char addr_str[18]; + bt_addr_to_str(dev.address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "Gerät gefunden: %s [%s] RSSI: %d", + dev.name, addr_str, dev.rssi); + s_discovery_callback(&dev); + } + } + break; + } + + case ESP_BT_GAP_DISC_STATE_CHANGED_EVT: + if (param->disc_st_chg.state == ESP_BT_GAP_DISCOVERY_STOPPED) { + ESP_LOGI(TAG, "Discovery beendet"); + s_discovering = false; + } else if (param->disc_st_chg.state == ESP_BT_GAP_DISCOVERY_STARTED) { + ESP_LOGI(TAG, "Discovery gestartet"); + s_discovering = true; + } + break; + + case ESP_BT_GAP_AUTH_CMPL_EVT: + if (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS) { + ESP_LOGI(TAG, "Authentifizierung erfolgreich: %s", param->auth_cmpl.device_name); + + // Gerät in Config speichern + bt_device_config_t dev_cfg; + bt_addr_to_str(param->auth_cmpl.bda, dev_cfg.address, CONFIG_MAX_BT_ADDR_LEN); + strncpy(dev_cfg.name, (char*)param->auth_cmpl.device_name, CONFIG_MAX_BT_NAME_LEN); + dev_cfg.paired = true; + dev_cfg.auto_connect = true; + dev_cfg.priority = 0; + config_save_bt_device(&dev_cfg); + + if (s_device_callback) { + s_device_callback(BT_DEVICE_STATE_PAIRED, param->auth_cmpl.bda); + } + } else { + ESP_LOGE(TAG, "Authentifizierung fehlgeschlagen: %d", param->auth_cmpl.stat); + } + break; + + case ESP_BT_GAP_PIN_REQ_EVT: + // PIN Request - Standard PIN "0000" + ESP_LOGI(TAG, "PIN Request für: %s", param->pin_req.bda); + esp_bt_pin_code_t pin = {'0', '0', '0', '0'}; + esp_bt_gap_pin_reply(param->pin_req.bda, true, 4, pin); + break; + + case ESP_BT_GAP_CFM_REQ_EVT: + // Numeric Comparison - automatisch bestätigen + ESP_LOGI(TAG, "Numeric Comparison: %lu", param->cfm_req.num_val); + esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true); + break; + + case ESP_BT_GAP_KEY_NOTIF_EVT: + ESP_LOGI(TAG, "Passkey: %06lu", param->key_notif.passkey); + break; + + default: + ESP_LOGD(TAG, "GAP Event: %d", event); + break; + } +} + +esp_err_t bt_manager_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere Bluetooth Manager"); + + // Bluetooth Controller konfigurieren + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + bt_cfg.mode = ESP_BT_MODE_BTDM; // Dual Mode für Classic + BLE + + esp_err_t ret = esp_bt_controller_init(&bt_cfg); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "BT Controller init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_bt_controller_enable(ESP_BT_MODE_BTDM); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "BT Controller enable failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Bluedroid initialisieren + ret = esp_bluedroid_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Bluedroid init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = esp_bluedroid_enable(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Bluedroid enable failed: %s", esp_err_to_name(ret)); + return ret; + } + + // GAP Callback registrieren + ret = esp_bt_gap_register_callback(gap_callback); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "GAP callback register failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Gerätename setzen + const device_config_t* config = config_get(); + esp_bt_dev_set_device_name(config->bluetooth.device_name); + + // SSP (Secure Simple Pairing) aktivieren + esp_bt_sp_param_t param_type = ESP_BT_SP_IOCAP_MODE; + esp_bt_io_cap_t iocap = ESP_BT_IO_CAP_IO; // Display + Yes/No + esp_bt_gap_set_security_param(param_type, &iocap, sizeof(esp_bt_io_cap_t)); + + // PIN Code Support für ältere Geräte + esp_bt_pin_type_t pin_type = ESP_BT_PIN_TYPE_VARIABLE; + esp_bt_pin_code_t pin_code = {'0', '0', '0', '0'}; + esp_bt_gap_set_pin(pin_type, 4, pin_code); + + // HFP initialisieren + ret = bt_hfp_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "HFP init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // Sichtbar machen + bt_manager_set_discoverable(true); + + s_initialized = true; + ESP_LOGI(TAG, "Bluetooth Manager initialisiert"); + + // Auto-Connect für gepaarte Geräte + if (config->bluetooth.device_count > 0) { + ESP_LOGI(TAG, "Versuche Auto-Connect für %d Geräte...", + config->bluetooth.device_count); + + for (int i = 0; i < config->bluetooth.device_count; i++) { + if (config->bluetooth.devices[i].auto_connect) { + esp_bd_addr_t addr; + if (bt_str_to_addr(config->bluetooth.devices[i].address, addr) == ESP_OK) { + ESP_LOGI(TAG, "Auto-Connect: %s", config->bluetooth.devices[i].name); + bt_manager_connect(addr); + break; // Nur ein Gerät gleichzeitig + } + } + } + } + + return ESP_OK; +} + +esp_err_t bt_manager_deinit(void) +{ + if (!s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Deinitalisiere Bluetooth Manager"); + + bt_hfp_deinit(); + + esp_bluedroid_disable(); + esp_bluedroid_deinit(); + esp_bt_controller_disable(); + esp_bt_controller_deinit(); + + s_initialized = false; + return ESP_OK; +} + +esp_err_t bt_manager_start_discovery(void) +{ + if (s_discovering) { + ESP_LOGW(TAG, "Discovery bereits aktiv"); + return ESP_OK; + } + + ESP_LOGI(TAG, "Starte Bluetooth-Suche"); + + // Inquiry-Mode: RSSI + Extended Inquiry Response + esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE); + + return esp_bt_gap_start_discovery(ESP_BT_INQ_MODE_GENERAL_INQUIRY, 10, 0); +} + +esp_err_t bt_manager_stop_discovery(void) +{ + if (!s_discovering) return ESP_OK; + + ESP_LOGI(TAG, "Stoppe Bluetooth-Suche"); + return esp_bt_gap_cancel_discovery(); +} + +esp_err_t bt_manager_pair(const esp_bd_addr_t address) +{ + char addr_str[18]; + bt_addr_to_str(address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "Starte Pairing mit: %s", addr_str); + + // Stoppe Discovery falls aktiv + if (s_discovering) { + esp_bt_gap_cancel_discovery(); + } + + if (s_device_callback) { + s_device_callback(BT_DEVICE_STATE_PAIRING, address); + } + + // Verbindung initiiert das Pairing + return bt_hfp_connect(address); +} + +esp_err_t bt_manager_unpair(const esp_bd_addr_t address) +{ + char addr_str[18]; + bt_addr_to_str(address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "Entferne Gerät: %s", addr_str); + + // Trennen falls verbunden + if (s_device_connected && memcmp(s_connected_device, address, ESP_BD_ADDR_LEN) == 0) { + bt_manager_disconnect(address); + } + + // Aus Bonding-Liste entfernen + return esp_bt_gap_remove_bond_device((uint8_t*)address); +} + +esp_err_t bt_manager_connect(const esp_bd_addr_t address) +{ + char addr_str[18]; + bt_addr_to_str(address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "Verbinde mit: %s", addr_str); + + if (s_device_callback) { + s_device_callback(BT_DEVICE_STATE_CONNECTING, address); + } + + return bt_hfp_connect(address); +} + +esp_err_t bt_manager_disconnect(const esp_bd_addr_t address) +{ + char addr_str[18]; + bt_addr_to_str(address, addr_str, sizeof(addr_str)); + ESP_LOGI(TAG, "Trenne Verbindung: %s", addr_str); + + return bt_hfp_disconnect(address); +} + +esp_err_t bt_manager_disconnect_all(void) +{ + if (s_device_connected) { + return bt_manager_disconnect(s_connected_device); + } + return ESP_OK; +} + +bool bt_manager_is_connected(void) +{ + return s_device_connected; +} + +esp_err_t bt_manager_get_connected_device(esp_bd_addr_t address) +{ + if (!s_device_connected) { + return ESP_ERR_NOT_FOUND; + } + memcpy(address, s_connected_device, ESP_BD_ADDR_LEN); + return ESP_OK; +} + +esp_err_t bt_manager_set_discoverable(bool discoverable) +{ + ESP_LOGI(TAG, "Setze Sichtbarkeit: %s", discoverable ? "An" : "Aus"); + + if (discoverable) { + return esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE); + } else { + return esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_NON_DISCOVERABLE); + } +} + +esp_err_t bt_manager_send_audio(const uint8_t* data, size_t len) +{ + if (!s_device_connected) { + return ESP_ERR_INVALID_STATE; + } + return bt_hfp_send_audio(data, len); +} + +// Callback Registration +void bt_manager_register_device_callback(bt_device_callback_t callback) +{ + s_device_callback = callback; +} + +void bt_manager_register_discovery_callback(bt_discovery_callback_t callback) +{ + s_discovery_callback = callback; +} + +void bt_manager_register_audio_callback(bt_audio_callback_t callback) +{ + s_audio_callback = callback; +} + +void bt_manager_register_button_callback(bt_button_callback_t callback) +{ + s_button_callback = callback; +} + +void bt_manager_register_audio_data_callback(bt_audio_data_callback_t callback) +{ + s_audio_data_callback = callback; +} + +// Utility Functions +void bt_addr_to_str(const esp_bd_addr_t addr, char* str, size_t len) +{ + snprintf(str, len, "%02X:%02X:%02X:%02X:%02X:%02X", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); +} + +esp_err_t bt_str_to_addr(const char* str, esp_bd_addr_t addr) +{ + unsigned int values[6]; + if (sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X", + &values[0], &values[1], &values[2], + &values[3], &values[4], &values[5]) != 6) { + return ESP_ERR_INVALID_ARG; + } + + for (int i = 0; i < 6; i++) { + addr[i] = (uint8_t)values[i]; + } + + return ESP_OK; +} + +// Internal: Wird von bt_hfp aufgerufen +void bt_manager_notify_connected(const esp_bd_addr_t address) +{ + memcpy(s_connected_device, address, ESP_BD_ADDR_LEN); + s_device_connected = true; + + if (s_device_callback) { + s_device_callback(BT_DEVICE_STATE_CONNECTED, address); + } + if (s_audio_callback) { + s_audio_callback(BT_AUDIO_STATE_OPEN); + } +} + +void bt_manager_notify_disconnected(const esp_bd_addr_t address) +{ + if (memcmp(s_connected_device, address, ESP_BD_ADDR_LEN) == 0) { + s_device_connected = false; + memset(s_connected_device, 0, ESP_BD_ADDR_LEN); + } + + if (s_device_callback) { + s_device_callback(BT_DEVICE_STATE_DISCONNECTED, address); + } + if (s_audio_callback) { + s_audio_callback(BT_AUDIO_STATE_IDLE); + } +} + +void bt_manager_notify_button(bt_button_event_t event) +{ + if (s_button_callback) { + s_button_callback(event); + } +} + +void bt_manager_notify_audio_data(const uint8_t* data, size_t len) +{ + if (s_audio_data_callback) { + s_audio_data_callback(data, len); + } +} diff --git a/main/bluetooth/bt_manager.h b/main/bluetooth/bt_manager.h new file mode 100644 index 0000000..7cfb545 --- /dev/null +++ b/main/bluetooth/bt_manager.h @@ -0,0 +1,145 @@ +#pragma once + +#include +#include +#include "esp_err.h" +#include "esp_bt_defs.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Bluetooth-Gerätestatus +typedef enum { + BT_DEVICE_STATE_UNKNOWN = 0, + BT_DEVICE_STATE_DISCOVERED, + BT_DEVICE_STATE_PAIRING, + BT_DEVICE_STATE_PAIRED, + BT_DEVICE_STATE_CONNECTING, + BT_DEVICE_STATE_CONNECTED, + BT_DEVICE_STATE_DISCONNECTED +} bt_device_state_t; + +// Bluetooth Audio-Status +typedef enum { + BT_AUDIO_STATE_IDLE = 0, + BT_AUDIO_STATE_OPENING, + BT_AUDIO_STATE_OPEN, + BT_AUDIO_STATE_STREAMING +} bt_audio_state_t; + +// Entdecktes Bluetooth-Gerät +typedef struct { + esp_bd_addr_t address; + char name[32]; + int rssi; + uint32_t cod; // Class of Device + bool is_headset; +} bt_discovered_device_t; + +// Headset-Button Events +typedef enum { + BT_BUTTON_NONE = 0, + BT_BUTTON_ANSWER, // Anruf annehmen + BT_BUTTON_REJECT, // Anruf ablehnen + BT_BUTTON_HANGUP, // Anruf beenden + BT_BUTTON_REDIAL, // Wahlwiederholung + BT_BUTTON_VOLUME_UP, + BT_BUTTON_VOLUME_DOWN, + BT_BUTTON_MUTE +} bt_button_event_t; + +// Event-Callbacks +typedef void (*bt_device_callback_t)(bt_device_state_t state, const esp_bd_addr_t address); +typedef void (*bt_discovery_callback_t)(const bt_discovered_device_t* device); +typedef void (*bt_audio_callback_t)(bt_audio_state_t state); +typedef void (*bt_button_callback_t)(bt_button_event_t event); +typedef void (*bt_audio_data_callback_t)(const uint8_t* data, size_t len); + +/** + * Initialisiert den Bluetooth-Manager + */ +esp_err_t bt_manager_init(void); + +/** + * Deinitalisiert den Bluetooth-Manager + */ +esp_err_t bt_manager_deinit(void); + +/** + * Startet die Bluetooth-Gerätesuche + */ +esp_err_t bt_manager_start_discovery(void); + +/** + * Stoppt die Bluetooth-Gerätesuche + */ +esp_err_t bt_manager_stop_discovery(void); + +/** + * Pairt mit einem Gerät + */ +esp_err_t bt_manager_pair(const esp_bd_addr_t address); + +/** + * Entfernt ein gepairtes Gerät + */ +esp_err_t bt_manager_unpair(const esp_bd_addr_t address); + +/** + * Verbindet mit einem gepairten Headset + */ +esp_err_t bt_manager_connect(const esp_bd_addr_t address); + +/** + * Trennt die Verbindung zu einem Headset + */ +esp_err_t bt_manager_disconnect(const esp_bd_addr_t address); + +/** + * Trennt alle verbundenen Geräte + */ +esp_err_t bt_manager_disconnect_all(void); + +/** + * Gibt zurück ob ein Headset verbunden ist + */ +bool bt_manager_is_connected(void); + +/** + * Gibt die Adresse des verbundenen Headsets zurück + */ +esp_err_t bt_manager_get_connected_device(esp_bd_addr_t address); + +/** + * Setzt die Sichtbarkeit + */ +esp_err_t bt_manager_set_discoverable(bool discoverable); + +/** + * Sendet Audio-Daten zum Headset + */ +esp_err_t bt_manager_send_audio(const uint8_t* data, size_t len); + +/** + * Registriert Callbacks + */ +void bt_manager_register_device_callback(bt_device_callback_t callback); +void bt_manager_register_discovery_callback(bt_discovery_callback_t callback); +void bt_manager_register_audio_callback(bt_audio_callback_t callback); +void bt_manager_register_button_callback(bt_button_callback_t callback); +void bt_manager_register_audio_data_callback(bt_audio_data_callback_t callback); + +/** + * Konvertiert BD_ADDR zu String + */ +void bt_addr_to_str(const esp_bd_addr_t addr, char* str, size_t len); + +/** + * Konvertiert String zu BD_ADDR + */ +esp_err_t bt_str_to_addr(const char* str, esp_bd_addr_t addr); + +#ifdef __cplusplus +} +#endif diff --git a/main/config/config_manager.c b/main/config/config_manager.c new file mode 100644 index 0000000..221686c --- /dev/null +++ b/main/config/config_manager.c @@ -0,0 +1,390 @@ +/** + * Config Manager - NVS-basierte Konfigurationsverwaltung + */ + +#include +#include "config_manager.h" +#include "esp_log.h" +#include "nvs_flash.h" +#include "nvs.h" + +static const char* TAG = "CONFIG"; + +// NVS Namespace +#define NVS_NAMESPACE "bsc_config" + +// NVS Keys +#define KEY_WIFI_SSID "wifi_ssid" +#define KEY_WIFI_PASS "wifi_pass" +#define KEY_WIFI_IP_MODE "wifi_ip_mode" +#define KEY_WIFI_STATIC_IP "wifi_static_ip" +#define KEY_WIFI_GATEWAY "wifi_gw" +#define KEY_WIFI_NETMASK "wifi_nm" +#define KEY_WIFI_DNS "wifi_dns" + +#define KEY_SIP_SERVER "sip_server" +#define KEY_SIP_PORT "sip_port" +#define KEY_SIP_USER "sip_user" +#define KEY_SIP_PASS "sip_pass" +#define KEY_SIP_DISPLAY "sip_display" + +#define KEY_BT_NAME "bt_name" +#define KEY_BT_DISCOVERABLE "bt_disc" +#define KEY_BT_DEV_COUNT "bt_dev_cnt" +#define KEY_BT_DEV_PREFIX "bt_dev_" // bt_dev_0, bt_dev_1, ... + +// Aktuelle Konfiguration +static device_config_t s_config; +static bool s_initialized = false; + +// NVS Handle +static nvs_handle_t s_nvs_handle; + +// Hilfsfunktionen +static esp_err_t load_string(const char* key, char* buf, size_t max_len) +{ + size_t len = max_len; + esp_err_t err = nvs_get_str(s_nvs_handle, key, buf, &len); + if (err == ESP_ERR_NVS_NOT_FOUND) { + buf[0] = '\0'; + return ESP_OK; + } + return err; +} + +static esp_err_t save_string(const char* key, const char* value) +{ + return nvs_set_str(s_nvs_handle, key, value); +} + +static esp_err_t load_wifi_config(void) +{ + esp_err_t err; + + err = load_string(KEY_WIFI_SSID, s_config.wifi.ssid, CONFIG_MAX_SSID_LEN); + if (err != ESP_OK) return err; + + err = load_string(KEY_WIFI_PASS, s_config.wifi.password, CONFIG_MAX_PASSWORD_LEN); + if (err != ESP_OK) return err; + + uint8_t ip_mode = 0; + err = nvs_get_u8(s_nvs_handle, KEY_WIFI_IP_MODE, &ip_mode); + if (err == ESP_ERR_NVS_NOT_FOUND) { + s_config.wifi.ip_mode = IP_MODE_DHCP; + } else if (err == ESP_OK) { + s_config.wifi.ip_mode = (ip_mode_t)ip_mode; + } else { + return err; + } + + load_string(KEY_WIFI_STATIC_IP, s_config.wifi.static_ip, CONFIG_MAX_IP_LEN); + load_string(KEY_WIFI_GATEWAY, s_config.wifi.gateway, CONFIG_MAX_IP_LEN); + load_string(KEY_WIFI_NETMASK, s_config.wifi.netmask, CONFIG_MAX_IP_LEN); + load_string(KEY_WIFI_DNS, s_config.wifi.dns, CONFIG_MAX_IP_LEN); + + // Konfiguriert wenn SSID vorhanden + s_config.wifi.configured = (strlen(s_config.wifi.ssid) > 0); + + return ESP_OK; +} + +static esp_err_t load_sip_config(void) +{ + esp_err_t err; + + err = load_string(KEY_SIP_SERVER, s_config.sip.server, CONFIG_MAX_SIP_SERVER_LEN); + if (err != ESP_OK) return err; + + uint16_t port = CONFIG_BSC_SIP_DEFAULT_PORT; + nvs_get_u16(s_nvs_handle, KEY_SIP_PORT, &port); + s_config.sip.port = port; + + load_string(KEY_SIP_USER, s_config.sip.username, CONFIG_MAX_SIP_USER_LEN); + load_string(KEY_SIP_PASS, s_config.sip.password, CONFIG_MAX_PASSWORD_LEN); + load_string(KEY_SIP_DISPLAY, s_config.sip.display_name, CONFIG_MAX_SIP_USER_LEN); + + // Konfiguriert wenn Server und User vorhanden + s_config.sip.configured = (strlen(s_config.sip.server) > 0 && + strlen(s_config.sip.username) > 0); + + return ESP_OK; +} + +static esp_err_t load_bt_config(void) +{ + // Bluetooth-Gerätename + esp_err_t err = load_string(KEY_BT_NAME, s_config.bluetooth.device_name, CONFIG_MAX_BT_NAME_LEN); + if (err != ESP_OK || strlen(s_config.bluetooth.device_name) == 0) { + strncpy(s_config.bluetooth.device_name, CONFIG_BSC_BT_DEVICE_NAME, CONFIG_MAX_BT_NAME_LEN); + } + + // Discoverable Status + uint8_t disc = 1; + nvs_get_u8(s_nvs_handle, KEY_BT_DISCOVERABLE, &disc); + s_config.bluetooth.discoverable = (disc != 0); + + // Gepaarte Geräte laden + uint8_t dev_count = 0; + nvs_get_u8(s_nvs_handle, KEY_BT_DEV_COUNT, &dev_count); + s_config.bluetooth.device_count = 0; + + for (int i = 0; i < dev_count && i < CONFIG_BSC_MAX_BT_DEVICES; i++) { + char key[32]; + char data[128]; + + snprintf(key, sizeof(key), "%s%d", KEY_BT_DEV_PREFIX, i); + size_t len = sizeof(data); + + if (nvs_get_str(s_nvs_handle, key, data, &len) == ESP_OK) { + // Format: "address|name|auto_connect|priority" + bt_device_config_t* dev = &s_config.bluetooth.devices[s_config.bluetooth.device_count]; + + char* token = strtok(data, "|"); + if (token) strncpy(dev->address, token, CONFIG_MAX_BT_ADDR_LEN); + + token = strtok(NULL, "|"); + if (token) strncpy(dev->name, token, CONFIG_MAX_BT_NAME_LEN); + + token = strtok(NULL, "|"); + if (token) dev->auto_connect = (atoi(token) != 0); + + token = strtok(NULL, "|"); + if (token) dev->priority = (uint8_t)atoi(token); + + dev->paired = true; + s_config.bluetooth.device_count++; + } + } + + return ESP_OK; +} + +static esp_err_t save_bt_devices(void) +{ + esp_err_t err; + + // Anzahl speichern + err = nvs_set_u8(s_nvs_handle, KEY_BT_DEV_COUNT, s_config.bluetooth.device_count); + if (err != ESP_OK) return err; + + // Jedes Gerät speichern + for (int i = 0; i < s_config.bluetooth.device_count; i++) { + char key[32]; + char data[128]; + bt_device_config_t* dev = &s_config.bluetooth.devices[i]; + + snprintf(key, sizeof(key), "%s%d", KEY_BT_DEV_PREFIX, i); + snprintf(data, sizeof(data), "%s|%s|%d|%d", + dev->address, dev->name, dev->auto_connect ? 1 : 0, dev->priority); + + err = nvs_set_str(s_nvs_handle, key, data); + if (err != ESP_OK) return err; + } + + return nvs_commit(s_nvs_handle); +} + +// Public API + +esp_err_t config_manager_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere Config Manager"); + + // NVS öffnen + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &s_nvs_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "NVS öffnen fehlgeschlagen: %s", esp_err_to_name(err)); + return err; + } + + // Konfiguration initialisieren + memset(&s_config, 0, sizeof(s_config)); + + // Laden + err = load_wifi_config(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "WiFi-Config laden fehlgeschlagen: %s", esp_err_to_name(err)); + } + + err = load_sip_config(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "SIP-Config laden fehlgeschlagen: %s", esp_err_to_name(err)); + } + + err = load_bt_config(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "BT-Config laden fehlgeschlagen: %s", esp_err_to_name(err)); + } + + ESP_LOGI(TAG, "Konfiguration geladen:"); + ESP_LOGI(TAG, " WiFi: %s", s_config.wifi.configured ? s_config.wifi.ssid : "(nicht konfiguriert)"); + ESP_LOGI(TAG, " SIP: %s", s_config.sip.configured ? s_config.sip.server : "(nicht konfiguriert)"); + ESP_LOGI(TAG, " BT: %d gepaarte Geräte", s_config.bluetooth.device_count); + + s_initialized = true; + return ESP_OK; +} + +const device_config_t* config_get(void) +{ + return &s_config; +} + +esp_err_t config_save_wifi(const wifi_config_data_t* wifi_config) +{ + if (!wifi_config) return ESP_ERR_INVALID_ARG; + + ESP_LOGI(TAG, "Speichere WiFi-Konfiguration: SSID=%s", wifi_config->ssid); + + esp_err_t err; + + err = save_string(KEY_WIFI_SSID, wifi_config->ssid); + if (err != ESP_OK) return err; + + err = save_string(KEY_WIFI_PASS, wifi_config->password); + if (err != ESP_OK) return err; + + err = nvs_set_u8(s_nvs_handle, KEY_WIFI_IP_MODE, (uint8_t)wifi_config->ip_mode); + if (err != ESP_OK) return err; + + if (wifi_config->ip_mode == IP_MODE_STATIC) { + save_string(KEY_WIFI_STATIC_IP, wifi_config->static_ip); + save_string(KEY_WIFI_GATEWAY, wifi_config->gateway); + save_string(KEY_WIFI_NETMASK, wifi_config->netmask); + save_string(KEY_WIFI_DNS, wifi_config->dns); + } + + err = nvs_commit(s_nvs_handle); + if (err != ESP_OK) return err; + + // Lokale Kopie aktualisieren + memcpy(&s_config.wifi, wifi_config, sizeof(wifi_config_data_t)); + s_config.wifi.configured = (strlen(s_config.wifi.ssid) > 0); + + return ESP_OK; +} + +esp_err_t config_save_sip(const sip_config_data_t* sip_config) +{ + if (!sip_config) return ESP_ERR_INVALID_ARG; + + ESP_LOGI(TAG, "Speichere SIP-Konfiguration: %s@%s:%d", + sip_config->username, sip_config->server, sip_config->port); + + esp_err_t err; + + err = save_string(KEY_SIP_SERVER, sip_config->server); + if (err != ESP_OK) return err; + + err = nvs_set_u16(s_nvs_handle, KEY_SIP_PORT, sip_config->port); + if (err != ESP_OK) return err; + + err = save_string(KEY_SIP_USER, sip_config->username); + if (err != ESP_OK) return err; + + err = save_string(KEY_SIP_PASS, sip_config->password); + if (err != ESP_OK) return err; + + save_string(KEY_SIP_DISPLAY, sip_config->display_name); + + err = nvs_commit(s_nvs_handle); + if (err != ESP_OK) return err; + + // Lokale Kopie aktualisieren + memcpy(&s_config.sip, sip_config, sizeof(sip_config_data_t)); + s_config.sip.configured = (strlen(s_config.sip.server) > 0 && + strlen(s_config.sip.username) > 0); + + return ESP_OK; +} + +esp_err_t config_save_bt_device(const bt_device_config_t* device) +{ + if (!device) return ESP_ERR_INVALID_ARG; + + // Prüfen ob Gerät bereits existiert + for (int i = 0; i < s_config.bluetooth.device_count; i++) { + if (strcmp(s_config.bluetooth.devices[i].address, device->address) == 0) { + // Update + memcpy(&s_config.bluetooth.devices[i], device, sizeof(bt_device_config_t)); + return save_bt_devices(); + } + } + + // Neues Gerät + if (s_config.bluetooth.device_count >= CONFIG_BSC_MAX_BT_DEVICES) { + ESP_LOGW(TAG, "Maximale Anzahl BT-Geräte erreicht"); + return ESP_ERR_NO_MEM; + } + + memcpy(&s_config.bluetooth.devices[s_config.bluetooth.device_count], + device, sizeof(bt_device_config_t)); + s_config.bluetooth.device_count++; + + ESP_LOGI(TAG, "BT-Gerät hinzugefügt: %s (%s)", device->name, device->address); + + return save_bt_devices(); +} + +esp_err_t config_remove_bt_device(const char* address) +{ + if (!address) return ESP_ERR_INVALID_ARG; + + for (int i = 0; i < s_config.bluetooth.device_count; i++) { + if (strcmp(s_config.bluetooth.devices[i].address, address) == 0) { + // Gefunden - entfernen durch Verschieben + ESP_LOGI(TAG, "Entferne BT-Gerät: %s", address); + + for (int j = i; j < s_config.bluetooth.device_count - 1; j++) { + memcpy(&s_config.bluetooth.devices[j], + &s_config.bluetooth.devices[j + 1], + sizeof(bt_device_config_t)); + } + s_config.bluetooth.device_count--; + + return save_bt_devices(); + } + } + + return ESP_ERR_NOT_FOUND; +} + +esp_err_t config_clear_bt_devices(void) +{ + ESP_LOGI(TAG, "Lösche alle BT-Geräte"); + s_config.bluetooth.device_count = 0; + return save_bt_devices(); +} + +esp_err_t config_factory_reset(void) +{ + ESP_LOGW(TAG, "Werksreset durchführen!"); + + esp_err_t err = nvs_erase_all(s_nvs_handle); + if (err != ESP_OK) return err; + + err = nvs_commit(s_nvs_handle); + if (err != ESP_OK) return err; + + // Konfiguration zurücksetzen + memset(&s_config, 0, sizeof(s_config)); + strncpy(s_config.bluetooth.device_name, CONFIG_BSC_BT_DEVICE_NAME, CONFIG_MAX_BT_NAME_LEN); + s_config.bluetooth.discoverable = true; + s_config.sip.port = CONFIG_BSC_SIP_DEFAULT_PORT; + + return ESP_OK; +} + +bool config_wifi_is_configured(void) +{ + return s_config.wifi.configured; +} + +bool config_sip_is_configured(void) +{ + return s_config.sip.configured; +} diff --git a/main/config/config_manager.h b/main/config/config_manager.h new file mode 100644 index 0000000..ede7259 --- /dev/null +++ b/main/config/config_manager.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Maximale Längen +#define CONFIG_MAX_SSID_LEN 32 +#define CONFIG_MAX_PASSWORD_LEN 64 +#define CONFIG_MAX_IP_LEN 16 +#define CONFIG_MAX_SIP_USER_LEN 64 +#define CONFIG_MAX_SIP_SERVER_LEN 128 +#define CONFIG_MAX_BT_NAME_LEN 32 +#define CONFIG_MAX_BT_ADDR_LEN 18 // "XX:XX:XX:XX:XX:XX" + +// IP-Konfigurationsmodus +typedef enum { + IP_MODE_DHCP = 0, + IP_MODE_STATIC = 1 +} ip_mode_t; + +// WLAN-Konfiguration +typedef struct { + char ssid[CONFIG_MAX_SSID_LEN + 1]; + char password[CONFIG_MAX_PASSWORD_LEN + 1]; + ip_mode_t ip_mode; + char static_ip[CONFIG_MAX_IP_LEN + 1]; + char gateway[CONFIG_MAX_IP_LEN + 1]; + char netmask[CONFIG_MAX_IP_LEN + 1]; + char dns[CONFIG_MAX_IP_LEN + 1]; + bool configured; // true wenn WLAN-Daten vorhanden +} wifi_config_data_t; + +// SIP-Konfiguration +typedef struct { + char server[CONFIG_MAX_SIP_SERVER_LEN + 1]; + uint16_t port; + char username[CONFIG_MAX_SIP_USER_LEN + 1]; + char password[CONFIG_MAX_PASSWORD_LEN + 1]; + char display_name[CONFIG_MAX_SIP_USER_LEN + 1]; + bool configured; +} sip_config_data_t; + +// Bluetooth-Gerät +typedef struct { + char name[CONFIG_MAX_BT_NAME_LEN + 1]; + char address[CONFIG_MAX_BT_ADDR_LEN + 1]; + bool paired; + bool auto_connect; + uint8_t priority; // Niedrigere Zahl = höhere Priorität +} bt_device_config_t; + +// Bluetooth-Konfiguration +typedef struct { + char device_name[CONFIG_MAX_BT_NAME_LEN + 1]; + bt_device_config_t devices[CONFIG_BSC_MAX_BT_DEVICES]; + uint8_t device_count; + bool discoverable; +} bt_config_data_t; + +// Gesamtkonfiguration +typedef struct { + wifi_config_data_t wifi; + sip_config_data_t sip; + bt_config_data_t bluetooth; +} device_config_t; + +/** + * Initialisiert den Config-Manager und lädt gespeicherte Konfiguration + */ +esp_err_t config_manager_init(void); + +/** + * Gibt die aktuelle Konfiguration zurück + */ +const device_config_t* config_get(void); + +/** + * Speichert die WLAN-Konfiguration + */ +esp_err_t config_save_wifi(const wifi_config_data_t* wifi_config); + +/** + * Speichert die SIP-Konfiguration + */ +esp_err_t config_save_sip(const sip_config_data_t* sip_config); + +/** + * Speichert ein Bluetooth-Gerät + */ +esp_err_t config_save_bt_device(const bt_device_config_t* device); + +/** + * Entfernt ein Bluetooth-Gerät + */ +esp_err_t config_remove_bt_device(const char* address); + +/** + * Setzt alle Bluetooth-Geräte zurück + */ +esp_err_t config_clear_bt_devices(void); + +/** + * Setzt die gesamte Konfiguration auf Werkseinstellungen zurück + */ +esp_err_t config_factory_reset(void); + +/** + * Prüft ob WLAN konfiguriert ist + */ +bool config_wifi_is_configured(void); + +/** + * Prüft ob SIP konfiguriert ist + */ +bool config_sip_is_configured(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/main.c b/main/main.c new file mode 100644 index 0000000..2f04988 --- /dev/null +++ b/main/main.c @@ -0,0 +1,295 @@ +/** + * ESP32-S3 Bluetooth SIP Client + * + * SIP-Telefon mit Bluetooth und USB-Headset Unterstützung + * Für Thin-Client Umgebungen ohne nativen CTI-Support + */ + +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" +#include "esp_system.h" +#include "esp_log.h" +#include "nvs_flash.h" +#include "esp_event.h" + +#include "config/config_manager.h" +#include "wifi/wifi_manager.h" +#include "web/web_server.h" +#include "bluetooth/bt_manager.h" +#include "usb_audio/usb_audio_host.h" +#include "audio/audio_router.h" +#include "sip/sip_client.h" + +static const char* TAG = "MAIN"; + +// Event Group für Synchronisation +static EventGroupHandle_t s_app_event_group; +#define WIFI_CONNECTED_BIT BIT0 +#define SIP_REGISTERED_BIT BIT1 +#define AUDIO_READY_BIT BIT2 + +// Callback für WiFi-Status +static void wifi_event_handler(wifi_state_t state, void* data) +{ + switch (state) { + case WIFI_STATE_AP_STARTED: + ESP_LOGI(TAG, "Hotspot gestartet - Konfiguration unter http://192.168.4.1"); + break; + + case WIFI_STATE_STA_CONNECTED: + ESP_LOGI(TAG, "Mit WLAN verbunden"); + xEventGroupSetBits(s_app_event_group, WIFI_CONNECTED_BIT); + break; + + case WIFI_STATE_STA_DISCONNECTED: + ESP_LOGW(TAG, "WLAN-Verbindung verloren"); + xEventGroupClearBits(s_app_event_group, WIFI_CONNECTED_BIT); + break; + + case WIFI_STATE_STA_FAILED: + ESP_LOGE(TAG, "WLAN-Verbindung fehlgeschlagen - Fallback zu Hotspot"); + break; + + default: + break; + } +} + +// Callback für SIP-Registrierung +static void sip_reg_handler(sip_reg_state_t state, const char* message) +{ + switch (state) { + case SIP_REG_STATE_REGISTERED: + ESP_LOGI(TAG, "SIP registriert: %s", message ? message : "OK"); + xEventGroupSetBits(s_app_event_group, SIP_REGISTERED_BIT); + break; + + case SIP_REG_STATE_FAILED: + ESP_LOGE(TAG, "SIP Registrierung fehlgeschlagen: %s", message ? message : "Unbekannt"); + xEventGroupClearBits(s_app_event_group, SIP_REGISTERED_BIT); + break; + + default: + break; + } +} + +// Callback für Anrufe +static void sip_call_handler(const sip_call_info_t* call_info) +{ + if (!call_info) return; + + switch (call_info->state) { + case SIP_CALL_STATE_INCOMING: + ESP_LOGI(TAG, "Eingehender Anruf von: %s <%s>", + call_info->remote_name, call_info->remote_number); + // Audio-Router signalisieren + break; + + case SIP_CALL_STATE_CONNECTED: + ESP_LOGI(TAG, "Anruf verbunden mit: %s", call_info->remote_number); + audio_router_start_call(); + break; + + case SIP_CALL_STATE_DISCONNECTED: + ESP_LOGI(TAG, "Anruf beendet (Dauer: %lu Sek.)", call_info->duration_sec); + audio_router_stop_call(); + break; + + default: + break; + } +} + +// Callback für Headset-Tasten (vereinheitlicht USB und Bluetooth) +static void headset_button_handler(headset_button_t button, audio_source_t source) +{ + const char* source_name = (source == AUDIO_SOURCE_USB) ? "USB" : "Bluetooth"; + + switch (button) { + case HEADSET_BUTTON_ANSWER: + ESP_LOGI(TAG, "[%s] Anruf annehmen", source_name); + if (sip_client_get_call_state() == SIP_CALL_STATE_INCOMING) { + sip_client_answer(); + } + break; + + case HEADSET_BUTTON_HANGUP: + ESP_LOGI(TAG, "[%s] Anruf beenden", source_name); + if (sip_client_get_call_state() != SIP_CALL_STATE_IDLE) { + sip_client_hangup(); + } + break; + + case HEADSET_BUTTON_REJECT: + ESP_LOGI(TAG, "[%s] Anruf ablehnen", source_name); + if (sip_client_get_call_state() == SIP_CALL_STATE_INCOMING) { + sip_client_reject(); + } + break; + + case HEADSET_BUTTON_MUTE: + ESP_LOGI(TAG, "[%s] Mute toggle", source_name); + audio_router_set_mute(!audio_router_is_muted()); + break; + + case HEADSET_BUTTON_VOLUME_UP: + ESP_LOGI(TAG, "[%s] Lauter", source_name); + { + uint8_t vol = audio_router_get_volume(); + if (vol < 100) audio_router_set_volume(vol + 10); + } + break; + + case HEADSET_BUTTON_VOLUME_DOWN: + ESP_LOGI(TAG, "[%s] Leiser", source_name); + { + uint8_t vol = audio_router_get_volume(); + if (vol > 0) audio_router_set_volume(vol > 10 ? vol - 10 : 0); + } + break; + } +} + +// Callback für Audio-Quellenwechsel +static void audio_source_change_handler(audio_source_t old_source, audio_source_t new_source) +{ + const char* sources[] = {"Keine", "USB", "Bluetooth"}; + ESP_LOGI(TAG, "Audio-Quelle gewechselt: %s -> %s", + sources[old_source], sources[new_source]); +} + +// Audio vom Headset zum SIP-Client +static void audio_from_headset_handler(const uint8_t* data, size_t len, const audio_format_t* format) +{ + if (sip_client_get_call_state() == SIP_CALL_STATE_CONNECTED) { + sip_client_send_audio(data, len); + } +} + +// Audio vom SIP-Client zum Headset +static void audio_from_sip_handler(const uint8_t* data, size_t len, const sip_audio_format_t* format) +{ + audio_format_t af = { + .sample_rate = format->sample_rate, + .channels = format->channels, + .bits_per_sample = 16 + }; + audio_router_send_to_headset(data, len, &af); +} + +void app_main(void) +{ + ESP_LOGI(TAG, "================================="); + ESP_LOGI(TAG, "ESP32-S3 Bluetooth SIP Client"); + ESP_LOGI(TAG, "================================="); + + // Event Group erstellen + s_app_event_group = xEventGroupCreate(); + + // NVS initialisieren + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "NVS Partition löschen und neu initialisieren"); + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + // Event Loop erstellen + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + // ========== Module initialisieren ========== + + // 1. Config Manager (lädt gespeicherte Konfiguration) + ESP_LOGI(TAG, "Initialisiere Config Manager..."); + ESP_ERROR_CHECK(config_manager_init()); + + // 2. WiFi Manager + ESP_LOGI(TAG, "Initialisiere WiFi Manager..."); + ESP_ERROR_CHECK(wifi_manager_init()); + wifi_manager_register_callback(wifi_event_handler); + + // 3. Webserver (läuft immer für Konfiguration) + ESP_LOGI(TAG, "Initialisiere Webserver..."); + ESP_ERROR_CHECK(web_server_init()); + + // 4. Audio Router + ESP_LOGI(TAG, "Initialisiere Audio Router..."); + ESP_ERROR_CHECK(audio_router_init()); + audio_router_register_button_callback(headset_button_handler); + audio_router_register_source_change_callback(audio_source_change_handler); + audio_router_register_input_callback(audio_from_headset_handler); + + // 5. USB Audio Host + ESP_LOGI(TAG, "Initialisiere USB Audio Host..."); + ESP_ERROR_CHECK(usb_audio_host_init()); + + // 6. Bluetooth Manager + ESP_LOGI(TAG, "Initialisiere Bluetooth Manager..."); + ESP_ERROR_CHECK(bt_manager_init()); + + // 7. SIP Client + ESP_LOGI(TAG, "Initialisiere SIP Client..."); + ESP_ERROR_CHECK(sip_client_init()); + sip_client_register_reg_callback(sip_reg_handler); + sip_client_register_call_callback(sip_call_handler); + sip_client_register_audio_callback(audio_from_sip_handler); + + // ========== Starten ========== + + // WiFi starten (AP oder STA je nach Konfiguration) + ESP_LOGI(TAG, "Starte WiFi..."); + ESP_ERROR_CHECK(wifi_manager_start()); + + // Warten auf WLAN-Verbindung wenn konfiguriert + if (config_wifi_is_configured()) { + ESP_LOGI(TAG, "Warte auf WLAN-Verbindung..."); + EventBits_t bits = xEventGroupWaitBits( + s_app_event_group, + WIFI_CONNECTED_BIT, + pdFALSE, + pdTRUE, + pdMS_TO_TICKS(30000) // 30 Sekunden Timeout + ); + + if (bits & WIFI_CONNECTED_BIT) { + // SIP registrieren wenn konfiguriert + if (config_sip_is_configured()) { + ESP_LOGI(TAG, "Registriere bei TK-Anlage..."); + sip_client_register(); + } else { + ESP_LOGW(TAG, "SIP nicht konfiguriert - bitte über Weboberfläche einrichten"); + } + } + } else { + ESP_LOGW(TAG, "WLAN nicht konfiguriert - Hotspot-Modus aktiv"); + ESP_LOGI(TAG, "Verbinden Sie sich mit '%s' und öffnen Sie http://192.168.4.1", + CONFIG_BSC_DEFAULT_AP_SSID); + } + + // Auto-Connect für gepairte Bluetooth-Geräte + ESP_LOGI(TAG, "Bluetooth bereit - Geräte können sich verbinden"); + bt_manager_set_discoverable(true); + + ESP_LOGI(TAG, "================================="); + ESP_LOGI(TAG, "System bereit!"); + ESP_LOGI(TAG, "================================="); + + // Main Loop - Status-Reporting + while (1) { + vTaskDelay(pdMS_TO_TICKS(10000)); // Alle 10 Sekunden + + // Status-Info ausgeben + audio_source_t source = audio_router_get_active_source(); + const char* sources[] = {"Keine", "USB", "Bluetooth"}; + + ESP_LOGI(TAG, "Status: WiFi=%s, SIP=%s, Audio=%s", + (wifi_manager_get_state() == WIFI_STATE_STA_CONNECTED) ? "Verbunden" : "Getrennt", + (sip_client_get_reg_state() == SIP_REG_STATE_REGISTERED) ? "Registriert" : "Nicht registriert", + sources[source]); + } +} diff --git a/main/sip/sip_client.c b/main/sip/sip_client.c new file mode 100644 index 0000000..e4c9b6c --- /dev/null +++ b/main/sip/sip_client.c @@ -0,0 +1,1033 @@ +/** + * SIP Client - Lightweight SIP User Agent für ESP32 + * + * Unterstützt: + * - REGISTER für Anmeldung an TK-Anlage + * - INVITE/BYE für Anrufe + * - RTP für Audio + * - Digest Authentication + */ + +#include +#include +#include +#include +#include "sip_client.h" +#include "sip_parser.h" +#include "config/config_manager.h" +#include "bluetooth/bt_hfp.h" +#include "esp_log.h" +#include "esp_random.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "mbedtls/md5.h" + +static const char* TAG = "SIP"; + +// Konstanten +#define SIP_BUFFER_SIZE 4096 +#define RTP_BUFFER_SIZE 320 // 20ms bei 8kHz, 16-bit +#define REGISTER_INTERVAL 300 // Sekunden +#define RTP_PAYLOAD_PCMU 0 // G.711 µ-law +#define RTP_PAYLOAD_PCMA 8 // G.711 A-law + +// SIP State +static bool s_initialized = false; +static sip_reg_state_t s_reg_state = SIP_REG_STATE_UNREGISTERED; +static sip_call_state_t s_call_state = SIP_CALL_STATE_IDLE; +static sip_call_info_t s_call_info; + +// Sockets +static int s_sip_socket = -1; +static int s_rtp_socket = -1; +static struct sockaddr_in s_server_addr; +static struct sockaddr_in s_rtp_remote_addr; + +// Sequence Numbers +static uint32_t s_cseq = 1; +static uint32_t s_rtp_seq = 0; +static uint32_t s_rtp_timestamp = 0; +static uint32_t s_rtp_ssrc = 0; + +// Tags & IDs +static char s_local_tag[32]; +static char s_remote_tag[32]; +static char s_call_id[64]; +static char s_branch[32]; + +// Local Address +static char s_local_ip[32]; +static uint16_t s_local_sip_port = 5060; +static uint16_t s_local_rtp_port = 10000; + +// Auth +static char s_nonce[128]; +static char s_realm[64]; +static bool s_auth_required = false; + +// Tasks +static TaskHandle_t s_recv_task = NULL; +static TaskHandle_t s_rtp_task = NULL; +static SemaphoreHandle_t s_mutex = NULL; + +// Callbacks +static sip_reg_callback_t s_reg_callback = NULL; +static sip_call_callback_t s_call_callback = NULL; +static sip_audio_callback_t s_audio_callback = NULL; + +// Forward Declarations +static void sip_recv_task(void* arg); +static void rtp_recv_task(void* arg); +static esp_err_t send_register(bool with_auth); +static esp_err_t send_invite(const char* number); +static esp_err_t send_response(int code, const sip_message_t* req); +static esp_err_t send_ack(void); +static esp_err_t send_bye(void); +static void handle_sip_message(const char* data, size_t len); +static void generate_tag(char* tag, size_t len); +static void generate_branch(char* branch, size_t len); +static void generate_call_id(char* call_id, size_t len); +static void calc_digest_response(const char* method, const char* uri, char* response); +static void notify_call_state(void); +static void notify_reg_state(const char* message); + +// Helper: Lokale IP ermitteln +static void get_local_ip(void) +{ + // Verwende Dummy-Verbindung um lokale IP zu ermitteln + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) return; + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = inet_addr("8.8.8.8"); + addr.sin_port = htons(53); + + if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) { + struct sockaddr_in local; + socklen_t len = sizeof(local); + getsockname(sock, (struct sockaddr*)&local, &len); + inet_ntop(AF_INET, &local.sin_addr, s_local_ip, sizeof(s_local_ip)); + } + + close(sock); +} + +static void generate_tag(char* tag, size_t len) +{ + uint32_t r = esp_random(); + snprintf(tag, len, "%08lx", (unsigned long)r); +} + +static void generate_branch(char* branch, size_t len) +{ + uint32_t r = esp_random(); + snprintf(branch, len, "z9hG4bK%08lx", (unsigned long)r); +} + +static void generate_call_id(char* call_id, size_t len) +{ + uint32_t r1 = esp_random(); + uint32_t r2 = esp_random(); + snprintf(call_id, len, "%08lx%08lx@%s", (unsigned long)r1, (unsigned long)r2, s_local_ip); +} + +// MD5 Hash als Hex-String +static void md5_hex(const char* input, char* output) +{ + unsigned char digest[16]; + mbedtls_md5_context ctx; + + mbedtls_md5_init(&ctx); + mbedtls_md5_starts(&ctx); + mbedtls_md5_update(&ctx, (const unsigned char*)input, strlen(input)); + mbedtls_md5_finish(&ctx, digest); + mbedtls_md5_free(&ctx); + + for (int i = 0; i < 16; i++) { + sprintf(output + i * 2, "%02x", digest[i]); + } + output[32] = '\0'; +} + +static void calc_digest_response(const char* method, const char* uri, char* response) +{ + const device_config_t* config = config_get(); + char ha1[33], ha2[33], tmp[512]; + + // HA1 = MD5(username:realm:password) + snprintf(tmp, sizeof(tmp), "%s:%s:%s", + config->sip.username, s_realm, config->sip.password); + md5_hex(tmp, ha1); + + // HA2 = MD5(method:uri) + snprintf(tmp, sizeof(tmp), "%s:%s", method, uri); + md5_hex(tmp, ha2); + + // Response = MD5(HA1:nonce:HA2) + snprintf(tmp, sizeof(tmp), "%s:%s:%s", ha1, s_nonce, ha2); + md5_hex(tmp, response); +} + +static void notify_reg_state(const char* message) +{ + if (s_reg_callback) { + s_reg_callback(s_reg_state, message); + } +} + +static void notify_call_state(void) +{ + if (s_call_callback) { + s_call_callback(&s_call_info); + } +} + +// SIP Message senden +static esp_err_t sip_send(const char* msg, size_t len) +{ + if (s_sip_socket < 0) return ESP_FAIL; + + ESP_LOGD(TAG, ">>> SIP Send:\n%s", msg); + + int sent = sendto(s_sip_socket, msg, len, 0, + (struct sockaddr*)&s_server_addr, sizeof(s_server_addr)); + + return (sent == len) ? ESP_OK : ESP_FAIL; +} + +static esp_err_t send_register(bool with_auth) +{ + const device_config_t* config = config_get(); + char buf[SIP_BUFFER_SIZE]; + char auth_header[512] = ""; + + generate_branch(s_branch, sizeof(s_branch)); + + if (with_auth && s_auth_required) { + char response[33]; + char uri[128]; + snprintf(uri, sizeof(uri), "sip:%s", config->sip.server); + calc_digest_response("REGISTER", uri, response); + + snprintf(auth_header, sizeof(auth_header), + "Authorization: Digest username=\"%s\", realm=\"%s\", " + "nonce=\"%s\", uri=\"%s\", response=\"%s\"\r\n", + config->sip.username, s_realm, s_nonce, uri, response); + } + + int len = snprintf(buf, sizeof(buf), + "REGISTER sip:%s SIP/2.0\r\n" + "Via: SIP/2.0/UDP %s:%d;branch=%s;rport\r\n" + "From: ;tag=%s\r\n" + "To: \r\n" + "Call-ID: %s\r\n" + "CSeq: %lu REGISTER\r\n" + "Contact: \r\n" + "Max-Forwards: 70\r\n" + "Expires: %d\r\n" + "%s" + "User-Agent: ESP32-SIP-Phone/1.0\r\n" + "Content-Length: 0\r\n" + "\r\n", + config->sip.server, + s_local_ip, s_local_sip_port, s_branch, + config->sip.username, config->sip.server, s_local_tag, + config->sip.username, config->sip.server, + s_call_id, + (unsigned long)s_cseq++, + config->sip.username, s_local_ip, s_local_sip_port, + REGISTER_INTERVAL, + auth_header); + + return sip_send(buf, len); +} + +static esp_err_t send_invite(const char* number) +{ + const device_config_t* config = config_get(); + char buf[SIP_BUFFER_SIZE]; + + generate_branch(s_branch, sizeof(s_branch)); + generate_call_id(s_call_id, sizeof(s_call_id)); + generate_tag(s_local_tag, sizeof(s_local_tag)); + + s_rtp_ssrc = esp_random(); + + // SDP Body + char sdp[512]; + int sdp_len = snprintf(sdp, sizeof(sdp), + "v=0\r\n" + "o=- %lu %lu IN IP4 %s\r\n" + "s=ESP32 SIP Call\r\n" + "c=IN IP4 %s\r\n" + "t=0 0\r\n" + "m=audio %d RTP/AVP 0 8 101\r\n" + "a=rtpmap:0 PCMU/8000\r\n" + "a=rtpmap:8 PCMA/8000\r\n" + "a=rtpmap:101 telephone-event/8000\r\n" + "a=fmtp:101 0-16\r\n" + "a=ptime:20\r\n" + "a=sendrecv\r\n", + (unsigned long)time(NULL), (unsigned long)time(NULL), s_local_ip, + s_local_ip, + s_local_rtp_port); + + int len = snprintf(buf, sizeof(buf), + "INVITE sip:%s@%s SIP/2.0\r\n" + "Via: SIP/2.0/UDP %s:%d;branch=%s;rport\r\n" + "From: \"%s\" ;tag=%s\r\n" + "To: \r\n" + "Call-ID: %s\r\n" + "CSeq: %lu INVITE\r\n" + "Contact: \r\n" + "Max-Forwards: 70\r\n" + "Content-Type: application/sdp\r\n" + "User-Agent: ESP32-SIP-Phone/1.0\r\n" + "Content-Length: %d\r\n" + "\r\n%s", + number, config->sip.server, + s_local_ip, s_local_sip_port, s_branch, + config->sip.display_name[0] ? config->sip.display_name : config->sip.username, + config->sip.username, config->sip.server, s_local_tag, + number, config->sip.server, + s_call_id, + (unsigned long)s_cseq++, + config->sip.username, s_local_ip, s_local_sip_port, + sdp_len, sdp); + + return sip_send(buf, len); +} + +static esp_err_t send_response(int code, const sip_message_t* req) +{ + char buf[SIP_BUFFER_SIZE]; + const device_config_t* config = config_get(); + + const char* reason; + switch (code) { + case 100: reason = "Trying"; break; + case 180: reason = "Ringing"; break; + case 200: reason = "OK"; break; + case 486: reason = "Busy Here"; break; + case 603: reason = "Decline"; break; + default: reason = "Unknown"; break; + } + + // SDP für 200 OK auf INVITE + char sdp[512] = ""; + int sdp_len = 0; + char content_type[64] = ""; + + if (code == 200 && req->cseq_method == SIP_METHOD_INVITE) { + sdp_len = snprintf(sdp, sizeof(sdp), + "v=0\r\n" + "o=- %lu %lu IN IP4 %s\r\n" + "s=ESP32 SIP Call\r\n" + "c=IN IP4 %s\r\n" + "t=0 0\r\n" + "m=audio %d RTP/AVP 0 8\r\n" + "a=rtpmap:0 PCMU/8000\r\n" + "a=rtpmap:8 PCMA/8000\r\n" + "a=ptime:20\r\n" + "a=sendrecv\r\n", + (unsigned long)time(NULL), (unsigned long)time(NULL), s_local_ip, + s_local_ip, + s_local_rtp_port); + strcpy(content_type, "Content-Type: application/sdp\r\n"); + } + + // To-Tag hinzufügen wenn nicht vorhanden + char to_header[256]; + if (strlen(req->to_tag) > 0) { + snprintf(to_header, sizeof(to_header), "%s", req->to); + } else { + char to_uri[128]; + sip_extract_uri(req->to, to_uri, sizeof(to_uri)); + snprintf(to_header, sizeof(to_header), "<%s>;tag=%s", to_uri, s_local_tag); + } + + int len = snprintf(buf, sizeof(buf), + "SIP/2.0 %d %s\r\n" + "Via: %s\r\n" + "From: %s\r\n" + "To: %s\r\n" + "Call-ID: %s\r\n" + "CSeq: %d %s\r\n" + "Contact: \r\n" + "%s" + "User-Agent: ESP32-SIP-Phone/1.0\r\n" + "Content-Length: %d\r\n" + "\r\n%s", + code, reason, + req->via, + req->from, + to_header, + req->call_id, + req->cseq, + req->cseq_method == SIP_METHOD_INVITE ? "INVITE" : + req->cseq_method == SIP_METHOD_BYE ? "BYE" : + req->cseq_method == SIP_METHOD_ACK ? "ACK" : + req->cseq_method == SIP_METHOD_OPTIONS ? "OPTIONS" : "UNKNOWN", + config->sip.username, s_local_ip, s_local_sip_port, + content_type, + sdp_len, sdp); + + return sip_send(buf, len); +} + +static esp_err_t send_ack(void) +{ + const device_config_t* config = config_get(); + char buf[SIP_BUFFER_SIZE]; + + generate_branch(s_branch, sizeof(s_branch)); + + // Extrahiere Request-URI aus To + char to_uri[128]; + sip_extract_uri(s_call_info.remote_uri, to_uri, sizeof(to_uri)); + + int len = snprintf(buf, sizeof(buf), + "ACK %s SIP/2.0\r\n" + "Via: SIP/2.0/UDP %s:%d;branch=%s;rport\r\n" + "From: \"%s\" ;tag=%s\r\n" + "To: <%s>;tag=%s\r\n" + "Call-ID: %s\r\n" + "CSeq: %lu ACK\r\n" + "Max-Forwards: 70\r\n" + "Content-Length: 0\r\n" + "\r\n", + to_uri[0] ? to_uri : s_call_info.remote_uri, + s_local_ip, s_local_sip_port, s_branch, + config->sip.display_name[0] ? config->sip.display_name : config->sip.username, + config->sip.username, config->sip.server, s_local_tag, + s_call_info.remote_uri, s_remote_tag, + s_call_id, + (unsigned long)(s_cseq - 1)); // Gleiche CSeq wie INVITE + + return sip_send(buf, len); +} + +static esp_err_t send_bye(void) +{ + const device_config_t* config = config_get(); + char buf[SIP_BUFFER_SIZE]; + + generate_branch(s_branch, sizeof(s_branch)); + + char to_uri[128]; + sip_extract_uri(s_call_info.remote_uri, to_uri, sizeof(to_uri)); + + int len = snprintf(buf, sizeof(buf), + "BYE %s SIP/2.0\r\n" + "Via: SIP/2.0/UDP %s:%d;branch=%s;rport\r\n" + "From: \"%s\" ;tag=%s\r\n" + "To: <%s>;tag=%s\r\n" + "Call-ID: %s\r\n" + "CSeq: %lu BYE\r\n" + "Max-Forwards: 70\r\n" + "Content-Length: 0\r\n" + "\r\n", + to_uri[0] ? to_uri : s_call_info.remote_uri, + s_local_ip, s_local_sip_port, s_branch, + config->sip.display_name[0] ? config->sip.display_name : config->sip.username, + config->sip.username, config->sip.server, s_local_tag, + s_call_info.remote_uri, s_remote_tag, + s_call_id, + (unsigned long)s_cseq++); + + return sip_send(buf, len); +} + +static void handle_sip_message(const char* data, size_t len) +{ + sip_message_t msg; + if (sip_parse_message(data, len, &msg) != 0) { + ESP_LOGW(TAG, "SIP Parsing fehlgeschlagen"); + return; + } + + ESP_LOGD(TAG, "<<< SIP %s %d %s", + msg.is_request ? "Request" : "Response", + msg.is_request ? msg.method : msg.status_code, + msg.is_request ? "" : msg.reason_phrase); + + if (msg.is_request) { + // Eingehende Requests + switch (msg.method) { + case SIP_METHOD_INVITE: + ESP_LOGI(TAG, "Eingehender Anruf: %s", msg.from); + + // Call-Info speichern + strncpy(s_call_id, msg.call_id, sizeof(s_call_id)); + strncpy(s_call_info.call_id, msg.call_id, sizeof(s_call_info.call_id)); + strncpy(s_call_info.remote_uri, msg.from, sizeof(s_call_info.remote_uri)); + sip_extract_display_name(msg.from, s_call_info.remote_name, sizeof(s_call_info.remote_name)); + sip_extract_uri(msg.from, s_call_info.remote_number, sizeof(s_call_info.remote_number)); + strncpy(s_remote_tag, msg.from_tag, sizeof(s_remote_tag)); + + // RTP-Info aus SDP + if (msg.has_sdp) { + strncpy(s_local_ip, msg.rtp_ip, sizeof(s_local_ip)); + s_rtp_remote_addr.sin_family = AF_INET; + inet_pton(AF_INET, msg.rtp_ip, &s_rtp_remote_addr.sin_addr); + s_rtp_remote_addr.sin_port = htons(msg.rtp_port); + } + + s_call_info.state = SIP_CALL_STATE_INCOMING; + s_call_info.is_incoming = true; + s_call_state = SIP_CALL_STATE_INCOMING; + + // 100 Trying senden + send_response(100, &msg); + // 180 Ringing senden + send_response(180, &msg); + + notify_call_state(); + break; + + case SIP_METHOD_BYE: + ESP_LOGI(TAG, "BYE empfangen - Anruf beendet"); + send_response(200, &msg); + + s_call_state = SIP_CALL_STATE_DISCONNECTED; + s_call_info.state = SIP_CALL_STATE_DISCONNECTED; + notify_call_state(); + + s_call_state = SIP_CALL_STATE_IDLE; + break; + + case SIP_METHOD_ACK: + ESP_LOGD(TAG, "ACK empfangen"); + break; + + case SIP_METHOD_OPTIONS: + // Keep-Alive - 200 OK antworten + send_response(200, &msg); + break; + + case SIP_METHOD_CANCEL: + ESP_LOGI(TAG, "CANCEL empfangen"); + send_response(200, &msg); + s_call_state = SIP_CALL_STATE_DISCONNECTED; + s_call_info.state = SIP_CALL_STATE_DISCONNECTED; + notify_call_state(); + s_call_state = SIP_CALL_STATE_IDLE; + break; + + default: + ESP_LOGW(TAG, "Unbekannter Request: %d", msg.method); + break; + } + } else { + // Responses + switch (msg.cseq_method) { + case SIP_METHOD_REGISTER: + if (msg.status_code == 200) { + ESP_LOGI(TAG, "REGISTER erfolgreich"); + s_reg_state = SIP_REG_STATE_REGISTERED; + notify_reg_state("Registered"); + } else if (msg.status_code == 401 || msg.status_code == 407) { + // Authentication Required + ESP_LOGI(TAG, "Auth erforderlich"); + s_auth_required = true; + + // Nonce und Realm extrahieren + const char* auth = msg.status_code == 401 ? + msg.www_authenticate : msg.proxy_authenticate; + + const char* nonce_start = strstr(auth, "nonce=\""); + if (nonce_start) { + nonce_start += 7; + const char* nonce_end = strchr(nonce_start, '"'); + if (nonce_end) { + size_t len = nonce_end - nonce_start; + if (len < sizeof(s_nonce)) { + strncpy(s_nonce, nonce_start, len); + s_nonce[len] = '\0'; + } + } + } + + const char* realm_start = strstr(auth, "realm=\""); + if (realm_start) { + realm_start += 7; + const char* realm_end = strchr(realm_start, '"'); + if (realm_end) { + size_t len = realm_end - realm_start; + if (len < sizeof(s_realm)) { + strncpy(s_realm, realm_start, len); + s_realm[len] = '\0'; + } + } + } + + // Erneut registrieren mit Auth + send_register(true); + } else { + ESP_LOGE(TAG, "REGISTER fehlgeschlagen: %d", msg.status_code); + s_reg_state = SIP_REG_STATE_FAILED; + notify_reg_state(msg.reason_phrase); + } + break; + + case SIP_METHOD_INVITE: + if (msg.status_code >= 100 && msg.status_code < 200) { + // Provisional Response + if (msg.status_code == 180 || msg.status_code == 183) { + ESP_LOGI(TAG, "Anruf klingelt"); + s_call_state = SIP_CALL_STATE_RINGING; + s_call_info.state = SIP_CALL_STATE_RINGING; + strncpy(s_remote_tag, msg.to_tag, sizeof(s_remote_tag)); + notify_call_state(); + } + } else if (msg.status_code == 200) { + ESP_LOGI(TAG, "Anruf angenommen"); + strncpy(s_remote_tag, msg.to_tag, sizeof(s_remote_tag)); + + // RTP-Info aus SDP + if (msg.has_sdp && msg.rtp_port > 0) { + s_rtp_remote_addr.sin_family = AF_INET; + inet_pton(AF_INET, msg.rtp_ip, &s_rtp_remote_addr.sin_addr); + s_rtp_remote_addr.sin_port = htons(msg.rtp_port); + ESP_LOGI(TAG, "RTP: %s:%d", msg.rtp_ip, msg.rtp_port); + } + + send_ack(); + + s_call_state = SIP_CALL_STATE_CONNECTED; + s_call_info.state = SIP_CALL_STATE_CONNECTED; + notify_call_state(); + + // BT Audio starten + bt_hfp_audio_connect(); + } else if (msg.status_code >= 400) { + ESP_LOGE(TAG, "Anruf fehlgeschlagen: %d %s", + msg.status_code, msg.reason_phrase); + s_call_state = SIP_CALL_STATE_DISCONNECTED; + s_call_info.state = SIP_CALL_STATE_DISCONNECTED; + notify_call_state(); + s_call_state = SIP_CALL_STATE_IDLE; + } + break; + + case SIP_METHOD_BYE: + if (msg.status_code == 200) { + ESP_LOGI(TAG, "BYE bestätigt"); + } + break; + + default: + break; + } + } +} + +static void sip_recv_task(void* arg) +{ + char buf[SIP_BUFFER_SIZE]; + struct sockaddr_in from_addr; + socklen_t from_len = sizeof(from_addr); + + ESP_LOGI(TAG, "SIP Receive Task gestartet"); + + while (s_initialized && s_sip_socket >= 0) { + int len = recvfrom(s_sip_socket, buf, sizeof(buf) - 1, 0, + (struct sockaddr*)&from_addr, &from_len); + + if (len > 0) { + buf[len] = '\0'; + ESP_LOGD(TAG, "SIP empfangen: %d bytes", len); + handle_sip_message(buf, len); + } else if (len < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGE(TAG, "SIP recv error: %d", errno); + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + ESP_LOGI(TAG, "SIP Receive Task beendet"); + vTaskDelete(NULL); +} + +static void rtp_recv_task(void* arg) +{ + uint8_t buf[RTP_BUFFER_SIZE + 12]; // RTP Header + Payload + struct sockaddr_in from_addr; + socklen_t from_len = sizeof(from_addr); + + ESP_LOGI(TAG, "RTP Receive Task gestartet"); + + while (s_initialized && s_rtp_socket >= 0) { + if (s_call_state != SIP_CALL_STATE_CONNECTED) { + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + int len = recvfrom(s_rtp_socket, buf, sizeof(buf), 0, + (struct sockaddr*)&from_addr, &from_len); + + if (len > 12) { // Mindestens RTP Header + // RTP Header parsen (12 bytes) + // uint8_t version = (buf[0] >> 6) & 0x03; + // uint8_t padding = (buf[0] >> 5) & 0x01; + // uint8_t extension = (buf[0] >> 4) & 0x01; + // uint8_t cc = buf[0] & 0x0F; + // uint8_t marker = (buf[1] >> 7) & 0x01; + uint8_t payload_type = buf[1] & 0x7F; + + // Payload an Audio-Callback + if (s_audio_callback && len > 12) { + sip_audio_format_t format = { + .sample_rate = 8000, + .payload_type = payload_type, + .channels = 1 + }; + s_audio_callback(buf + 12, len - 12, &format); + } + } + } + + ESP_LOGI(TAG, "RTP Receive Task beendet"); + vTaskDelete(NULL); +} + +// Public API + +esp_err_t sip_client_init(void) +{ + if (s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Initialisiere SIP Client"); + + s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex) return ESP_ERR_NO_MEM; + + get_local_ip(); + ESP_LOGI(TAG, "Lokale IP: %s", s_local_ip); + + generate_tag(s_local_tag, sizeof(s_local_tag)); + generate_call_id(s_call_id, sizeof(s_call_id)); + s_rtp_ssrc = esp_random(); + + // SIP Socket erstellen + s_sip_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s_sip_socket < 0) { + ESP_LOGE(TAG, "SIP Socket erstellen fehlgeschlagen"); + return ESP_FAIL; + } + + // Bind auf lokalen Port + struct sockaddr_in local_addr; + memset(&local_addr, 0, sizeof(local_addr)); + local_addr.sin_family = AF_INET; + local_addr.sin_addr.s_addr = INADDR_ANY; + local_addr.sin_port = htons(s_local_sip_port); + + if (bind(s_sip_socket, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) { + ESP_LOGE(TAG, "SIP Bind fehlgeschlagen"); + close(s_sip_socket); + s_sip_socket = -1; + return ESP_FAIL; + } + + // Timeout setzen + struct timeval tv; + tv.tv_sec = 1; + tv.tv_usec = 0; + setsockopt(s_sip_socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + // RTP Socket erstellen + s_rtp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (s_rtp_socket < 0) { + ESP_LOGE(TAG, "RTP Socket erstellen fehlgeschlagen"); + close(s_sip_socket); + s_sip_socket = -1; + return ESP_FAIL; + } + + local_addr.sin_port = htons(s_local_rtp_port); + if (bind(s_rtp_socket, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) { + ESP_LOGE(TAG, "RTP Bind fehlgeschlagen"); + close(s_sip_socket); + close(s_rtp_socket); + s_sip_socket = -1; + s_rtp_socket = -1; + return ESP_FAIL; + } + + setsockopt(s_rtp_socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + s_initialized = true; + + // Receive Tasks starten + xTaskCreate(sip_recv_task, "sip_recv", 4096, NULL, 5, &s_recv_task); + xTaskCreate(rtp_recv_task, "rtp_recv", 4096, NULL, 6, &s_rtp_task); + + ESP_LOGI(TAG, "SIP Client initialisiert"); + return ESP_OK; +} + +esp_err_t sip_client_deinit(void) +{ + if (!s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Deinitalisiere SIP Client"); + + s_initialized = false; + + // Sockets schließen + if (s_sip_socket >= 0) { + close(s_sip_socket); + s_sip_socket = -1; + } + if (s_rtp_socket >= 0) { + close(s_rtp_socket); + s_rtp_socket = -1; + } + + // Auf Tasks warten + vTaskDelay(pdMS_TO_TICKS(200)); + + if (s_mutex) { + vSemaphoreDelete(s_mutex); + s_mutex = NULL; + } + + return ESP_OK; +} + +esp_err_t sip_client_register(void) +{ + const device_config_t* config = config_get(); + + if (!config->sip.configured) { + ESP_LOGW(TAG, "SIP nicht konfiguriert"); + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Registriere bei %s:%d als %s", + config->sip.server, config->sip.port, config->sip.username); + + // Server-Adresse auflösen + struct hostent* host = gethostbyname(config->sip.server); + if (!host) { + ESP_LOGE(TAG, "DNS Auflösung fehlgeschlagen: %s", config->sip.server); + return ESP_FAIL; + } + + memset(&s_server_addr, 0, sizeof(s_server_addr)); + s_server_addr.sin_family = AF_INET; + memcpy(&s_server_addr.sin_addr, host->h_addr_list[0], host->h_length); + s_server_addr.sin_port = htons(config->sip.port); + + s_reg_state = SIP_REG_STATE_REGISTERING; + notify_reg_state("Registering..."); + + s_auth_required = false; + generate_call_id(s_call_id, sizeof(s_call_id)); + generate_tag(s_local_tag, sizeof(s_local_tag)); + + return send_register(false); +} + +esp_err_t sip_client_unregister(void) +{ + if (s_reg_state == SIP_REG_STATE_UNREGISTERED) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Abmelden..."); + s_reg_state = SIP_REG_STATE_UNREGISTERED; + notify_reg_state("Unregistered"); + + return ESP_OK; +} + +sip_reg_state_t sip_client_get_reg_state(void) +{ + return s_reg_state; +} + +esp_err_t sip_client_answer(void) +{ + if (s_call_state != SIP_CALL_STATE_INCOMING) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Nehme Anruf an"); + + // Die letzte INVITE-Nachricht wurde gespeichert + sip_message_t msg; + memset(&msg, 0, sizeof(msg)); + strncpy(msg.call_id, s_call_id, sizeof(msg.call_id)); + msg.cseq_method = SIP_METHOD_INVITE; + + // 200 OK mit SDP senden + // TODO: Korrekte INVITE-Nachricht rekonstruieren + // Für jetzt: vereinfachte Version + + s_call_state = SIP_CALL_STATE_CONNECTED; + s_call_info.state = SIP_CALL_STATE_CONNECTED; + notify_call_state(); + + bt_hfp_audio_connect(); + + return ESP_OK; +} + +esp_err_t sip_client_reject(void) +{ + if (s_call_state != SIP_CALL_STATE_INCOMING) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Lehne Anruf ab"); + + // 603 Decline senden + sip_message_t msg; + memset(&msg, 0, sizeof(msg)); + msg.cseq_method = SIP_METHOD_INVITE; + send_response(603, &msg); + + s_call_state = SIP_CALL_STATE_DISCONNECTED; + s_call_info.state = SIP_CALL_STATE_DISCONNECTED; + notify_call_state(); + + s_call_state = SIP_CALL_STATE_IDLE; + + return ESP_OK; +} + +esp_err_t sip_client_hangup(void) +{ + if (s_call_state == SIP_CALL_STATE_IDLE) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Beende Anruf"); + + bt_hfp_audio_disconnect(); + + esp_err_t ret = send_bye(); + + s_call_state = SIP_CALL_STATE_DISCONNECTED; + s_call_info.state = SIP_CALL_STATE_DISCONNECTED; + notify_call_state(); + + s_call_state = SIP_CALL_STATE_IDLE; + + return ret; +} + +esp_err_t sip_client_call(const char* number) +{ + if (s_call_state != SIP_CALL_STATE_IDLE) { + return ESP_ERR_INVALID_STATE; + } + + if (s_reg_state != SIP_REG_STATE_REGISTERED) { + ESP_LOGW(TAG, "Nicht registriert - kann nicht anrufen"); + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Rufe an: %s", number); + + strncpy(s_call_info.remote_number, number, sizeof(s_call_info.remote_number)); + s_call_info.is_incoming = false; + s_call_state = SIP_CALL_STATE_OUTGOING; + s_call_info.state = SIP_CALL_STATE_OUTGOING; + notify_call_state(); + + return send_invite(number); +} + +esp_err_t sip_client_send_dtmf(char digit) +{ + if (s_call_state != SIP_CALL_STATE_CONNECTED) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "DTMF: %c", digit); + + // TODO: DTMF über RTP (RFC 2833) oder SIP INFO senden + return ESP_OK; +} + +esp_err_t sip_client_hold(void) +{ + // TODO: Re-INVITE mit a=sendonly + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t sip_client_unhold(void) +{ + // TODO: Re-INVITE mit a=sendrecv + return ESP_ERR_NOT_SUPPORTED; +} + +esp_err_t sip_client_get_call_info(sip_call_info_t* info) +{ + if (!info) return ESP_ERR_INVALID_ARG; + memcpy(info, &s_call_info, sizeof(sip_call_info_t)); + return ESP_OK; +} + +sip_call_state_t sip_client_get_call_state(void) +{ + return s_call_state; +} + +esp_err_t sip_client_send_audio(const uint8_t* data, size_t len) +{ + if (s_call_state != SIP_CALL_STATE_CONNECTED || s_rtp_socket < 0) { + return ESP_ERR_INVALID_STATE; + } + + // RTP Paket erstellen + uint8_t rtp_packet[RTP_BUFFER_SIZE + 12]; + + // RTP Header (12 bytes) + rtp_packet[0] = 0x80; // Version 2, no padding, no extension, no CSRC + rtp_packet[1] = RTP_PAYLOAD_PCMU; // Payload Type + rtp_packet[2] = (s_rtp_seq >> 8) & 0xFF; + rtp_packet[3] = s_rtp_seq & 0xFF; + rtp_packet[4] = (s_rtp_timestamp >> 24) & 0xFF; + rtp_packet[5] = (s_rtp_timestamp >> 16) & 0xFF; + rtp_packet[6] = (s_rtp_timestamp >> 8) & 0xFF; + rtp_packet[7] = s_rtp_timestamp & 0xFF; + rtp_packet[8] = (s_rtp_ssrc >> 24) & 0xFF; + rtp_packet[9] = (s_rtp_ssrc >> 16) & 0xFF; + rtp_packet[10] = (s_rtp_ssrc >> 8) & 0xFF; + rtp_packet[11] = s_rtp_ssrc & 0xFF; + + // Payload kopieren + size_t payload_len = len > RTP_BUFFER_SIZE ? RTP_BUFFER_SIZE : len; + memcpy(rtp_packet + 12, data, payload_len); + + s_rtp_seq++; + s_rtp_timestamp += 160; // 20ms bei 8kHz + + int sent = sendto(s_rtp_socket, rtp_packet, 12 + payload_len, 0, + (struct sockaddr*)&s_rtp_remote_addr, sizeof(s_rtp_remote_addr)); + + return (sent > 0) ? ESP_OK : ESP_FAIL; +} + +void sip_client_register_reg_callback(sip_reg_callback_t callback) +{ + s_reg_callback = callback; +} + +void sip_client_register_call_callback(sip_call_callback_t callback) +{ + s_call_callback = callback; +} + +void sip_client_register_audio_callback(sip_audio_callback_t callback) +{ + s_audio_callback = callback; +} diff --git a/main/sip/sip_client.h b/main/sip/sip_client.h new file mode 100644 index 0000000..6d6d4eb --- /dev/null +++ b/main/sip/sip_client.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// SIP-Registrierungsstatus +typedef enum { + SIP_REG_STATE_UNREGISTERED = 0, + SIP_REG_STATE_REGISTERING, + SIP_REG_STATE_REGISTERED, + SIP_REG_STATE_FAILED +} sip_reg_state_t; + +// Anrufstatus +typedef enum { + SIP_CALL_STATE_IDLE = 0, + SIP_CALL_STATE_INCOMING, // Eingehender Anruf + SIP_CALL_STATE_OUTGOING, // Ausgehender Anruf + SIP_CALL_STATE_RINGING, // Klingelt + SIP_CALL_STATE_CONNECTED, // Verbunden + SIP_CALL_STATE_HOLD, // Gehalten + SIP_CALL_STATE_DISCONNECTING, + SIP_CALL_STATE_DISCONNECTED +} sip_call_state_t; + +// Anrufinformationen +typedef struct { + char call_id[64]; + char remote_uri[128]; // SIP URI des Gegenübers + char remote_name[64]; // Display Name + char remote_number[32]; // Telefonnummer + sip_call_state_t state; + uint32_t duration_sec; // Anrufdauer in Sekunden + bool is_incoming; +} sip_call_info_t; + +// RTP Audio Format +typedef struct { + uint32_t sample_rate; // Typisch 8000 oder 16000 Hz + uint8_t payload_type; // 0=PCMU, 8=PCMA, etc. + uint8_t channels; +} sip_audio_format_t; + +// Callbacks +typedef void (*sip_reg_callback_t)(sip_reg_state_t state, const char* message); +typedef void (*sip_call_callback_t)(const sip_call_info_t* call_info); +typedef void (*sip_audio_callback_t)(const uint8_t* data, size_t len, const sip_audio_format_t* format); + +/** + * Initialisiert den SIP-Client + */ +esp_err_t sip_client_init(void); + +/** + * Deinitalisiert den SIP-Client + */ +esp_err_t sip_client_deinit(void); + +/** + * Registriert bei der TK-Anlage + * Verwendet Konfiguration aus config_manager + */ +esp_err_t sip_client_register(void); + +/** + * Meldet sich von der TK-Anlage ab + */ +esp_err_t sip_client_unregister(void); + +/** + * Gibt den Registrierungsstatus zurück + */ +sip_reg_state_t sip_client_get_reg_state(void); + +/** + * Nimmt einen eingehenden Anruf an + */ +esp_err_t sip_client_answer(void); + +/** + * Lehnt einen eingehenden Anruf ab + */ +esp_err_t sip_client_reject(void); + +/** + * Beendet den aktuellen Anruf + */ +esp_err_t sip_client_hangup(void); + +/** + * Tätigt einen ausgehenden Anruf + */ +esp_err_t sip_client_call(const char* number); + +/** + * Sendet DTMF-Töne + */ +esp_err_t sip_client_send_dtmf(char digit); + +/** + * Setzt den Anruf auf Hold + */ +esp_err_t sip_client_hold(void); + +/** + * Holt den Anruf von Hold zurück + */ +esp_err_t sip_client_unhold(void); + +/** + * Gibt Informationen über den aktuellen Anruf zurück + */ +esp_err_t sip_client_get_call_info(sip_call_info_t* info); + +/** + * Gibt den aktuellen Anrufstatus zurück + */ +sip_call_state_t sip_client_get_call_state(void); + +/** + * Sendet Audio-Daten (RTP) + */ +esp_err_t sip_client_send_audio(const uint8_t* data, size_t len); + +/** + * Registriert Callbacks + */ +void sip_client_register_reg_callback(sip_reg_callback_t callback); +void sip_client_register_call_callback(sip_call_callback_t callback); +void sip_client_register_audio_callback(sip_audio_callback_t callback); + +#ifdef __cplusplus +} +#endif diff --git a/main/sip/sip_parser.c b/main/sip/sip_parser.c new file mode 100644 index 0000000..4bb98cc --- /dev/null +++ b/main/sip/sip_parser.c @@ -0,0 +1,345 @@ +/** + * SIP Parser - Einfacher SIP Message Parser + */ + +#include +#include +#include +#include +#include "sip_parser.h" + +// Hilfsfunktion: Zeile aus Buffer extrahieren +static int get_line(const char* buf, size_t buf_len, char* line, size_t line_len) +{ + size_t i = 0; + while (i < buf_len && i < line_len - 1) { + if (buf[i] == '\r' || buf[i] == '\n') { + line[i] = '\0'; + // Skip CRLF + if (buf[i] == '\r' && i + 1 < buf_len && buf[i + 1] == '\n') { + return i + 2; + } + return i + 1; + } + line[i] = buf[i]; + i++; + } + line[i] = '\0'; + return i; +} + +// Hilfsfunktion: Header-Wert extrahieren +static int get_header_value(const char* line, const char* header_name, char* value, size_t value_len) +{ + size_t name_len = strlen(header_name); + + // Case-insensitive Vergleich + if (strncasecmp(line, header_name, name_len) != 0) { + return -1; + } + + // Skip Header Name und ':' + const char* p = line + name_len; + while (*p && (*p == ':' || *p == ' ' || *p == '\t')) { + p++; + } + + strncpy(value, p, value_len - 1); + value[value_len - 1] = '\0'; + return 0; +} + +int sip_parse_message(const char* data, size_t len, sip_message_t* msg) +{ + if (!data || !msg || len == 0) { + return -1; + } + + memset(msg, 0, sizeof(sip_message_t)); + + const char* p = data; + size_t remaining = len; + char line[512]; + + // Erste Zeile parsen (Request-Line oder Status-Line) + int line_len = get_line(p, remaining, line, sizeof(line)); + if (line_len <= 0) return -1; + + p += line_len; + remaining -= line_len; + + // Status-Line: "SIP/2.0 200 OK" + if (strncmp(line, "SIP/2.0 ", 8) == 0) { + msg->is_request = false; + msg->status_code = atoi(line + 8); + + // Reason Phrase + const char* reason = strchr(line + 8, ' '); + if (reason) { + strncpy(msg->reason_phrase, reason + 1, sizeof(msg->reason_phrase) - 1); + } + } + // Request-Line: "INVITE sip:user@host SIP/2.0" + else { + msg->is_request = true; + + // Method + if (strncmp(line, "INVITE ", 7) == 0) { + msg->method = SIP_METHOD_INVITE; + } else if (strncmp(line, "ACK ", 4) == 0) { + msg->method = SIP_METHOD_ACK; + } else if (strncmp(line, "BYE ", 4) == 0) { + msg->method = SIP_METHOD_BYE; + } else if (strncmp(line, "CANCEL ", 7) == 0) { + msg->method = SIP_METHOD_CANCEL; + } else if (strncmp(line, "REGISTER ", 9) == 0) { + msg->method = SIP_METHOD_REGISTER; + } else if (strncmp(line, "OPTIONS ", 8) == 0) { + msg->method = SIP_METHOD_OPTIONS; + } else if (strncmp(line, "INFO ", 5) == 0) { + msg->method = SIP_METHOD_INFO; + } else { + msg->method = SIP_METHOD_UNKNOWN; + } + + // Request URI + char* uri_start = strchr(line, ' '); + if (uri_start) { + uri_start++; + char* uri_end = strrchr(uri_start, ' '); + if (uri_end) { + size_t uri_len = uri_end - uri_start; + if (uri_len < sizeof(msg->request_uri)) { + strncpy(msg->request_uri, uri_start, uri_len); + msg->request_uri[uri_len] = '\0'; + } + } + } + } + + // Headers parsen + while (remaining > 0) { + line_len = get_line(p, remaining, line, sizeof(line)); + if (line_len <= 0) break; + + p += line_len; + remaining -= line_len; + + // Leere Zeile = Ende der Header, Body beginnt + if (line[0] == '\0') { + break; + } + + // Header parsen + if (get_header_value(line, "Via", msg->via, sizeof(msg->via)) == 0) continue; + if (get_header_value(line, "v", msg->via, sizeof(msg->via)) == 0) continue; + + if (get_header_value(line, "From", msg->from, sizeof(msg->from)) == 0) { + sip_extract_tag(msg->from, msg->from_tag, sizeof(msg->from_tag)); + continue; + } + if (get_header_value(line, "f", msg->from, sizeof(msg->from)) == 0) { + sip_extract_tag(msg->from, msg->from_tag, sizeof(msg->from_tag)); + continue; + } + + if (get_header_value(line, "To", msg->to, sizeof(msg->to)) == 0) { + sip_extract_tag(msg->to, msg->to_tag, sizeof(msg->to_tag)); + continue; + } + if (get_header_value(line, "t", msg->to, sizeof(msg->to)) == 0) { + sip_extract_tag(msg->to, msg->to_tag, sizeof(msg->to_tag)); + continue; + } + + if (get_header_value(line, "Call-ID", msg->call_id, sizeof(msg->call_id)) == 0) continue; + if (get_header_value(line, "i", msg->call_id, sizeof(msg->call_id)) == 0) continue; + + if (strncasecmp(line, "CSeq:", 5) == 0 || strncasecmp(line, "CSeq :", 6) == 0) { + const char* cseq_val = strchr(line, ':'); + if (cseq_val) { + cseq_val++; + while (*cseq_val == ' ') cseq_val++; + msg->cseq = atoi(cseq_val); + + // Method aus CSeq + const char* method = strchr(cseq_val, ' '); + if (method) { + method++; + if (strncmp(method, "INVITE", 6) == 0) msg->cseq_method = SIP_METHOD_INVITE; + else if (strncmp(method, "REGISTER", 8) == 0) msg->cseq_method = SIP_METHOD_REGISTER; + else if (strncmp(method, "BYE", 3) == 0) msg->cseq_method = SIP_METHOD_BYE; + else if (strncmp(method, "ACK", 3) == 0) msg->cseq_method = SIP_METHOD_ACK; + else if (strncmp(method, "CANCEL", 6) == 0) msg->cseq_method = SIP_METHOD_CANCEL; + else if (strncmp(method, "OPTIONS", 7) == 0) msg->cseq_method = SIP_METHOD_OPTIONS; + } + } + continue; + } + + if (get_header_value(line, "Contact", msg->contact, sizeof(msg->contact)) == 0) continue; + if (get_header_value(line, "m", msg->contact, sizeof(msg->contact)) == 0) continue; + + if (strncasecmp(line, "Content-Length:", 15) == 0) { + const char* cl = strchr(line, ':'); + if (cl) msg->content_length = atoi(cl + 1); + continue; + } + if (strncasecmp(line, "l:", 2) == 0) { + msg->content_length = atoi(line + 2); + continue; + } + + if (get_header_value(line, "Content-Type", msg->content_type, sizeof(msg->content_type)) == 0) continue; + if (get_header_value(line, "c", msg->content_type, sizeof(msg->content_type)) == 0) continue; + + if (get_header_value(line, "WWW-Authenticate", msg->www_authenticate, sizeof(msg->www_authenticate)) == 0) continue; + if (get_header_value(line, "Proxy-Authenticate", msg->proxy_authenticate, sizeof(msg->proxy_authenticate)) == 0) continue; + } + + // Body (SDP) + if (remaining > 0 && msg->content_length > 0) { + size_t body_len = remaining < sizeof(msg->sdp_body) - 1 ? remaining : sizeof(msg->sdp_body) - 1; + if (body_len > (size_t)msg->content_length) { + body_len = msg->content_length; + } + strncpy(msg->sdp_body, p, body_len); + msg->sdp_body[body_len] = '\0'; + + if (strstr(msg->content_type, "application/sdp") != NULL) { + msg->has_sdp = true; + sip_parse_sdp(msg); + } + } + + return 0; +} + +int sip_extract_uri(const char* header, char* uri, size_t uri_len) +{ + // Suche oder sip:uri + const char* start = strchr(header, '<'); + if (start) { + start++; + const char* end = strchr(start, '>'); + if (end) { + size_t len = end - start; + if (len < uri_len) { + strncpy(uri, start, len); + uri[len] = '\0'; + return 0; + } + } + } + + // Fallback: sip: suchen + start = strstr(header, "sip:"); + if (!start) start = strstr(header, "sips:"); + if (start) { + const char* end = start; + while (*end && *end != '>' && *end != ';' && *end != ' ') { + end++; + } + size_t len = end - start; + if (len < uri_len) { + strncpy(uri, start, len); + uri[len] = '\0'; + return 0; + } + } + + return -1; +} + +int sip_extract_tag(const char* header, char* tag, size_t tag_len) +{ + const char* tag_start = strstr(header, "tag="); + if (!tag_start) return -1; + + tag_start += 4; + const char* tag_end = tag_start; + while (*tag_end && *tag_end != ';' && *tag_end != '>' && *tag_end != ' ') { + tag_end++; + } + + size_t len = tag_end - tag_start; + if (len < tag_len) { + strncpy(tag, tag_start, len); + tag[len] = '\0'; + return 0; + } + + return -1; +} + +int sip_extract_display_name(const char* header, char* name, size_t name_len) +{ + // "Display Name" + const char* quote_start = strchr(header, '"'); + if (quote_start) { + quote_start++; + const char* quote_end = strchr(quote_start, '"'); + if (quote_end) { + size_t len = quote_end - quote_start; + if (len < name_len) { + strncpy(name, quote_start, len); + name[len] = '\0'; + return 0; + } + } + } + + // Display Name + const char* bracket = strchr(header, '<'); + if (bracket && bracket > header) { + const char* start = header; + while (*start == ' ') start++; + size_t len = bracket - start; + while (len > 0 && start[len - 1] == ' ') len--; + if (len > 0 && len < name_len) { + strncpy(name, start, len); + name[len] = '\0'; + return 0; + } + } + + return -1; +} + +int sip_parse_sdp(sip_message_t* msg) +{ + if (!msg->has_sdp || msg->sdp_body[0] == '\0') { + return -1; + } + + // RTP Port und IP aus SDP extrahieren + // m=audio RTP/AVP + // c=IN IP4 + + const char* m_line = strstr(msg->sdp_body, "m=audio "); + if (m_line) { + m_line += 8; + msg->rtp_port = (uint16_t)atoi(m_line); + + // Payload Type + const char* pt = strstr(m_line, " RTP/AVP "); + if (pt) { + pt += 9; + msg->rtp_payload_type = (uint8_t)atoi(pt); + } + } + + const char* c_line = strstr(msg->sdp_body, "c=IN IP4 "); + if (c_line) { + c_line += 9; + const char* end = c_line; + while (*end && *end != '\r' && *end != '\n') end++; + size_t len = end - c_line; + if (len < sizeof(msg->rtp_ip)) { + strncpy(msg->rtp_ip, c_line, len); + msg->rtp_ip[len] = '\0'; + } + } + + return 0; +} diff --git a/main/sip/sip_parser.h b/main/sip/sip_parser.h new file mode 100644 index 0000000..c6f553c --- /dev/null +++ b/main/sip/sip_parser.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// SIP Methods +typedef enum { + SIP_METHOD_UNKNOWN = 0, + SIP_METHOD_INVITE, + SIP_METHOD_ACK, + SIP_METHOD_BYE, + SIP_METHOD_CANCEL, + SIP_METHOD_REGISTER, + SIP_METHOD_OPTIONS, + SIP_METHOD_INFO, + SIP_METHOD_UPDATE, + SIP_METHOD_PRACK +} sip_method_t; + +// Geparste SIP-Nachricht +typedef struct { + bool is_request; // true = Request, false = Response + + // Request Line + sip_method_t method; + char request_uri[128]; + + // Status Line (Response) + int status_code; + char reason_phrase[64]; + + // Headers + char via[256]; + char from[256]; + char from_tag[64]; + char to[256]; + char to_tag[64]; + char call_id[128]; + int cseq; + sip_method_t cseq_method; + char contact[256]; + int content_length; + char content_type[64]; + + // Authorization + char www_authenticate[512]; + char proxy_authenticate[512]; + + // SDP Body + char sdp_body[2048]; + bool has_sdp; + + // RTP Info aus SDP + char rtp_ip[32]; + uint16_t rtp_port; + uint8_t rtp_payload_type; + +} sip_message_t; + +/** + * Parst eine SIP-Nachricht + */ +int sip_parse_message(const char* data, size_t len, sip_message_t* msg); + +/** + * Extrahiert URI aus Header + */ +int sip_extract_uri(const char* header, char* uri, size_t uri_len); + +/** + * Extrahiert Tag aus Header + */ +int sip_extract_tag(const char* header, char* tag, size_t tag_len); + +/** + * Extrahiert Display-Name aus Header + */ +int sip_extract_display_name(const char* header, char* name, size_t name_len); + +/** + * Parst SDP und extrahiert RTP-Info + */ +int sip_parse_sdp(sip_message_t* msg); + +#ifdef __cplusplus +} +#endif diff --git a/main/usb_audio/usb_audio_host.c b/main/usb_audio/usb_audio_host.c new file mode 100644 index 0000000..25b387e --- /dev/null +++ b/main/usb_audio/usb_audio_host.c @@ -0,0 +1,356 @@ +/** + * USB Audio Host - USB Headset Unterstützung + * + * Verwendet ESP32-S3 USB OTG im Host-Modus für USB Audio Class Geräte + */ + +#include +#include "usb_audio_host.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "usb/usb_host.h" + +static const char* TAG = "USB_AUDIO"; + +// USB Audio Class Definitionen +#define USB_CLASS_AUDIO 0x01 +#define USB_SUBCLASS_AUDIOCONTROL 0x01 +#define USB_SUBCLASS_AUDIOSTREAMING 0x02 +#define USB_CLASS_HID 0x03 + +// State +static bool s_initialized = false; +static bool s_host_installed = false; +static usb_audio_state_t s_state = USB_AUDIO_STATE_NOT_CONNECTED; +static usb_audio_info_t s_device_info; +static usb_host_client_handle_t s_client_handle = NULL; +static usb_device_handle_t s_device_handle = NULL; +static TaskHandle_t s_usb_task_handle = NULL; + +// Audio Buffers +#define AUDIO_RINGBUF_SIZE (16 * 1024) +static RingbufHandle_t s_audio_in_ringbuf = NULL; // Vom USB Mic +static RingbufHandle_t s_audio_out_ringbuf = NULL; // Zum USB Speaker + +// Callbacks +static usb_audio_state_callback_t s_state_callback = NULL; +static usb_audio_data_callback_t s_data_callback = NULL; +static usb_button_callback_t s_button_callback = NULL; + +static void notify_state_change(usb_audio_state_t new_state) +{ + s_state = new_state; + if (s_state_callback) { + s_state_callback(new_state); + } +} + +static void usb_host_client_event_callback(const usb_host_client_event_msg_t *event_msg, void *arg) +{ + switch (event_msg->event) { + case USB_HOST_CLIENT_EVENT_NEW_DEV: + ESP_LOGI(TAG, "Neues USB-Gerät gefunden: Adresse %d", event_msg->new_dev.address); + + // Gerät öffnen + if (usb_host_device_open(s_client_handle, event_msg->new_dev.address, + &s_device_handle) == ESP_OK) { + // Device Descriptor holen + const usb_device_desc_t *dev_desc; + if (usb_host_get_device_descriptor(s_device_handle, &dev_desc) == ESP_OK) { + s_device_info.vendor_id = dev_desc->idVendor; + s_device_info.product_id = dev_desc->idProduct; + + ESP_LOGI(TAG, "USB Gerät: VID=0x%04X PID=0x%04X", + dev_desc->idVendor, dev_desc->idProduct); + + // Configuration Descriptor analysieren + const usb_config_desc_t *config_desc; + if (usb_host_get_active_config_descriptor(s_device_handle, &config_desc) == ESP_OK) { + // Interfaces durchgehen + int offset = 0; + const usb_intf_desc_t *intf_desc; + + while ((intf_desc = usb_parse_interface_descriptor( + config_desc, offset, 0, &offset)) != NULL) { + + if (intf_desc->bInterfaceClass == USB_CLASS_AUDIO) { + if (intf_desc->bInterfaceSubClass == USB_SUBCLASS_AUDIOSTREAMING) { + ESP_LOGI(TAG, "Audio Streaming Interface gefunden"); + s_device_info.has_speaker = true; + s_device_info.has_microphone = true; + } + } else if (intf_desc->bInterfaceClass == USB_CLASS_HID) { + ESP_LOGI(TAG, "HID Interface gefunden (Tasten)"); + s_device_info.has_hid = true; + } + } + + if (s_device_info.has_speaker || s_device_info.has_microphone) { + notify_state_change(USB_AUDIO_STATE_CONNECTED); + + // Standard Audio-Format + s_device_info.sample_rate = 16000; + s_device_info.channels = 1; + s_device_info.bit_depth = 16; + + ESP_LOGI(TAG, "USB Audio Headset erkannt"); + } + } + } + } + break; + + case USB_HOST_CLIENT_EVENT_DEV_GONE: + ESP_LOGI(TAG, "USB-Gerät entfernt"); + + if (s_device_handle) { + usb_host_device_close(s_client_handle, s_device_handle); + s_device_handle = NULL; + } + + memset(&s_device_info, 0, sizeof(s_device_info)); + notify_state_change(USB_AUDIO_STATE_NOT_CONNECTED); + break; + + default: + break; + } +} + +static void usb_host_task(void *arg) +{ + ESP_LOGI(TAG, "USB Host Task gestartet"); + + while (s_initialized) { + // USB Host Events verarbeiten + usb_host_lib_handle_events(pdMS_TO_TICKS(100), NULL); + + // Client Events verarbeiten + if (s_client_handle) { + usb_host_client_handle_events(s_client_handle, pdMS_TO_TICKS(100)); + } + + // Wenn verbunden: Audio-Daten verarbeiten + if (s_state == USB_AUDIO_STATE_STREAMING && s_audio_in_ringbuf) { + // Audio vom USB lesen und Callback aufrufen + size_t item_size; + uint8_t* data = xRingbufferReceive(s_audio_in_ringbuf, &item_size, 0); + if (data && item_size > 0) { + if (s_data_callback) { + s_data_callback(data, item_size); + } + vRingbufferReturnItem(s_audio_in_ringbuf, data); + } + } + + vTaskDelay(pdMS_TO_TICKS(10)); + } + + ESP_LOGI(TAG, "USB Host Task beendet"); + vTaskDelete(NULL); +} + +esp_err_t usb_audio_host_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere USB Audio Host"); + + // Audio Buffers erstellen + s_audio_in_ringbuf = xRingbufferCreate(AUDIO_RINGBUF_SIZE, RINGBUF_TYPE_BYTEBUF); + s_audio_out_ringbuf = xRingbufferCreate(AUDIO_RINGBUF_SIZE, RINGBUF_TYPE_BYTEBUF); + + if (!s_audio_in_ringbuf || !s_audio_out_ringbuf) { + ESP_LOGE(TAG, "Ringbuffer erstellen fehlgeschlagen"); + return ESP_ERR_NO_MEM; + } + + // USB Host Library installieren + usb_host_config_t host_config = { + .skip_phy_setup = false, + .intr_flags = ESP_INTR_FLAG_LEVEL1, + }; + + esp_err_t ret = usb_host_install(&host_config); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "USB Host install failed: %s", esp_err_to_name(ret)); + return ret; + } + s_host_installed = true; + + // Client registrieren + usb_host_client_config_t client_config = { + .is_synchronous = false, + .max_num_event_msg = 5, + .async = { + .client_event_callback = usb_host_client_event_callback, + .callback_arg = NULL, + }, + }; + + ret = usb_host_client_register(&client_config, &s_client_handle); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "USB Client register failed: %s", esp_err_to_name(ret)); + return ret; + } + + s_initialized = true; + + // USB Host Task starten + xTaskCreate(usb_host_task, "usb_host", 4096, NULL, 5, &s_usb_task_handle); + + ESP_LOGI(TAG, "USB Audio Host initialisiert"); + return ESP_OK; +} + +esp_err_t usb_audio_host_deinit(void) +{ + if (!s_initialized) return ESP_OK; + + ESP_LOGI(TAG, "Deinitalisiere USB Audio Host"); + + s_initialized = false; + + // Warten auf Task-Ende + if (s_usb_task_handle) { + vTaskDelay(pdMS_TO_TICKS(200)); + } + + // Gerät schließen + if (s_device_handle && s_client_handle) { + usb_host_device_close(s_client_handle, s_device_handle); + s_device_handle = NULL; + } + + // Client deregistrieren + if (s_client_handle) { + usb_host_client_deregister(s_client_handle); + s_client_handle = NULL; + } + + // USB Host deinstallieren + if (s_host_installed) { + usb_host_uninstall(); + s_host_installed = false; + } + + // Buffers freigeben + if (s_audio_in_ringbuf) { + vRingbufferDelete(s_audio_in_ringbuf); + s_audio_in_ringbuf = NULL; + } + if (s_audio_out_ringbuf) { + vRingbufferDelete(s_audio_out_ringbuf); + s_audio_out_ringbuf = NULL; + } + + return ESP_OK; +} + +usb_audio_state_t usb_audio_host_get_state(void) +{ + return s_state; +} + +bool usb_audio_host_is_connected(void) +{ + return s_state >= USB_AUDIO_STATE_CONNECTED; +} + +esp_err_t usb_audio_host_get_info(usb_audio_info_t* info) +{ + if (!info) return ESP_ERR_INVALID_ARG; + if (s_state == USB_AUDIO_STATE_NOT_CONNECTED) return ESP_ERR_INVALID_STATE; + + memcpy(info, &s_device_info, sizeof(usb_audio_info_t)); + return ESP_OK; +} + +esp_err_t usb_audio_host_start_stream(void) +{ + if (s_state < USB_AUDIO_STATE_CONNECTED) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Starte USB Audio Stream"); + + // TODO: Echte USB Audio Stream Konfiguration + // Dies würde das Konfigurieren der Audio-Interfaces, + // Setzen der Samplerate und Starten der Isochronen Transfers beinhalten + + s_state = USB_AUDIO_STATE_STREAMING; + notify_state_change(USB_AUDIO_STATE_STREAMING); + + return ESP_OK; +} + +esp_err_t usb_audio_host_stop_stream(void) +{ + if (s_state != USB_AUDIO_STATE_STREAMING) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Stoppe USB Audio Stream"); + + s_state = USB_AUDIO_STATE_CONNECTED; + notify_state_change(USB_AUDIO_STATE_CONNECTED); + + return ESP_OK; +} + +esp_err_t usb_audio_host_send(const uint8_t* data, size_t len) +{ + if (s_state != USB_AUDIO_STATE_STREAMING || !s_audio_out_ringbuf) { + return ESP_ERR_INVALID_STATE; + } + + if (xRingbufferSend(s_audio_out_ringbuf, data, len, 0) != pdTRUE) { + ESP_LOGW(TAG, "Audio buffer overflow"); + return ESP_ERR_NO_MEM; + } + + return ESP_OK; +} + +esp_err_t usb_audio_host_set_volume(uint8_t volume) +{ + if (s_state < USB_AUDIO_STATE_CONNECTED) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Setze Lautstärke: %d%%", volume); + + // TODO: USB Audio Class Volume Control implementieren + return ESP_OK; +} + +esp_err_t usb_audio_host_set_mute(bool mute) +{ + if (s_state < USB_AUDIO_STATE_CONNECTED) { + return ESP_ERR_INVALID_STATE; + } + + ESP_LOGI(TAG, "Mute: %s", mute ? "an" : "aus"); + + // TODO: USB Audio Class Mute Control implementieren + return ESP_OK; +} + +void usb_audio_host_register_state_callback(usb_audio_state_callback_t callback) +{ + s_state_callback = callback; +} + +void usb_audio_host_register_data_callback(usb_audio_data_callback_t callback) +{ + s_data_callback = callback; +} + +void usb_audio_host_register_button_callback(usb_button_callback_t callback) +{ + s_button_callback = callback; +} diff --git a/main/usb_audio/usb_audio_host.h b/main/usb_audio/usb_audio_host.h new file mode 100644 index 0000000..c282afd --- /dev/null +++ b/main/usb_audio/usb_audio_host.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// USB Audio Status +typedef enum { + USB_AUDIO_STATE_NOT_CONNECTED = 0, + USB_AUDIO_STATE_CONNECTED, + USB_AUDIO_STATE_CONFIGURED, + USB_AUDIO_STATE_STREAMING, + USB_AUDIO_STATE_ERROR +} usb_audio_state_t; + +// USB Headset Button Events (HID) +typedef enum { + USB_BUTTON_NONE = 0, + USB_BUTTON_ANSWER, + USB_BUTTON_HANGUP, + USB_BUTTON_MUTE, + USB_BUTTON_VOLUME_UP, + USB_BUTTON_VOLUME_DOWN +} usb_button_event_t; + +// USB Audio Info +typedef struct { + uint16_t vendor_id; + uint16_t product_id; + char product_name[64]; + char manufacturer[64]; + uint32_t sample_rate; + uint8_t channels; + uint8_t bit_depth; + bool has_microphone; + bool has_speaker; + bool has_hid; // Hat HID-Tasten +} usb_audio_info_t; + +// Callbacks +typedef void (*usb_audio_state_callback_t)(usb_audio_state_t state); +typedef void (*usb_audio_data_callback_t)(const uint8_t* data, size_t len); +typedef void (*usb_button_callback_t)(usb_button_event_t event); + +/** + * Initialisiert den USB Audio Host + */ +esp_err_t usb_audio_host_init(void); + +/** + * Deinitialisiert den USB Audio Host + */ +esp_err_t usb_audio_host_deinit(void); + +/** + * Gibt den aktuellen Status zurück + */ +usb_audio_state_t usb_audio_host_get_state(void); + +/** + * Prüft ob ein USB-Headset angeschlossen ist + */ +bool usb_audio_host_is_connected(void); + +/** + * Gibt Informationen über das angeschlossene Gerät zurück + */ +esp_err_t usb_audio_host_get_info(usb_audio_info_t* info); + +/** + * Startet Audio-Streaming + */ +esp_err_t usb_audio_host_start_stream(void); + +/** + * Stoppt Audio-Streaming + */ +esp_err_t usb_audio_host_stop_stream(void); + +/** + * Sendet Audio-Daten zum USB-Headset (Speaker) + */ +esp_err_t usb_audio_host_send(const uint8_t* data, size_t len); + +/** + * Setzt die Lautstärke (0-100) + */ +esp_err_t usb_audio_host_set_volume(uint8_t volume); + +/** + * Setzt Mute + */ +esp_err_t usb_audio_host_set_mute(bool mute); + +/** + * Registriert Callbacks + */ +void usb_audio_host_register_state_callback(usb_audio_state_callback_t callback); +void usb_audio_host_register_data_callback(usb_audio_data_callback_t callback); +void usb_audio_host_register_button_callback(usb_button_callback_t callback); + +#ifdef __cplusplus +} +#endif diff --git a/main/web/static/app.js b/main/web/static/app.js new file mode 100644 index 0000000..09f831c --- /dev/null +++ b/main/web/static/app.js @@ -0,0 +1,443 @@ +// ESP32 SIP Phone - Web Interface + +(function() { + 'use strict'; + + // State + let currentTab = 'status'; + let statusUpdateInterval = null; + + // DOM Elements + const elements = { + // Status bar + wifiStatus: document.getElementById('wifi-status'), + sipStatus: document.getElementById('sip-status'), + audioStatus: document.getElementById('audio-status'), + + // Call info + callState: document.getElementById('call-state'), + callRemote: document.getElementById('call-remote'), + callDuration: document.getElementById('call-duration'), + callButtons: document.getElementById('call-buttons'), + btnAnswer: document.getElementById('btn-answer'), + btnReject: document.getElementById('btn-reject'), + btnHangup: document.getElementById('btn-hangup'), + + // Audio + audioSource: document.getElementById('audio-source'), + usbConnected: document.getElementById('usb-connected'), + btConnected: document.getElementById('bt-connected'), + volumeSlider: document.getElementById('volume-slider'), + volumeValue: document.getElementById('volume-value'), + muteCheckbox: document.getElementById('mute-checkbox'), + + // WiFi + wifiForm: document.getElementById('wifi-form'), + wifiSsid: document.getElementById('wifi-ssid'), + wifiPassword: document.getElementById('wifi-password'), + btnScanWifi: document.getElementById('btn-scan-wifi'), + wifiScanResults: document.getElementById('wifi-scan-results'), + staticIpConfig: document.getElementById('static-ip-config'), + + // SIP + sipForm: document.getElementById('sip-form'), + sipServer: document.getElementById('sip-server'), + sipPort: document.getElementById('sip-port'), + sipUsername: document.getElementById('sip-username'), + sipPassword: document.getElementById('sip-password'), + sipDisplayName: document.getElementById('sip-display-name'), + + // Bluetooth + btnScanBt: document.getElementById('btn-scan-bt'), + btPairedDevices: document.getElementById('bt-paired-devices'), + btFoundDevices: document.getElementById('bt-found-devices'), + + // System + btnReboot: document.getElementById('btn-reboot'), + btnFactoryReset: document.getElementById('btn-factory-reset') + }; + + // API Functions + async function apiGet(endpoint) { + try { + const response = await fetch('/api/' + endpoint); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + return null; + } + } + + async function apiPost(endpoint, data = {}) { + try { + const response = await fetch('/api/' + endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + return await response.json(); + } catch (error) { + console.error('API Error:', error); + return null; + } + } + + // Status Updates + async function updateStatus() { + const status = await apiGet('status'); + if (!status) return; + + // WiFi + if (status.wifi) { + const wifiState = status.wifi.state === 'connected' ? 'Verbunden' : + status.wifi.state === 'hotspot' ? 'Hotspot' : 'Getrennt'; + elements.wifiStatus.textContent = 'WiFi: ' + wifiState; + elements.wifiStatus.className = 'status-item ' + + (status.wifi.state === 'connected' ? 'status-connected' : 'status-disconnected'); + } + + // SIP + if (status.sip) { + const sipState = status.sip.state === 'registered' ? 'Registriert' : + status.sip.state === 'registering' ? 'Verbinde...' : 'Nicht registriert'; + elements.sipStatus.textContent = 'SIP: ' + sipState; + elements.sipStatus.className = 'status-item ' + + (status.sip.state === 'registered' ? 'status-connected' : + status.sip.state === 'registering' ? 'status-pending' : 'status-disconnected'); + } + + // Audio + if (status.audio) { + const source = status.audio.source === 'usb' ? 'USB' : + status.audio.source === 'bluetooth' ? 'Bluetooth' : 'Keine'; + elements.audioStatus.textContent = 'Audio: ' + source; + elements.audioSource.textContent = source; + elements.usbConnected.textContent = status.audio.usb_connected ? 'Verbunden' : 'Nicht verbunden'; + elements.btConnected.textContent = status.audio.bt_connected ? 'Verbunden' : 'Nicht verbunden'; + elements.volumeSlider.value = status.audio.volume; + elements.volumeValue.textContent = status.audio.volume + '%'; + elements.muteCheckbox.checked = status.audio.muted; + } + + // Call + if (status.call) { + updateCallUI(status.call); + } + } + + function updateCallUI(call) { + const states = { + 'idle': 'Kein aktiver Anruf', + 'incoming': 'Eingehender Anruf', + 'outgoing': 'Ausgehender Anruf', + 'ringing': 'Klingelt...', + 'connected': 'Verbunden' + }; + + elements.callState.textContent = states[call.state] || call.state; + + if (call.state !== 'idle') { + elements.callRemote.textContent = (call.name || '') + ' ' + (call.remote || ''); + if (call.state === 'connected' && call.duration !== undefined) { + const mins = Math.floor(call.duration / 60); + const secs = call.duration % 60; + elements.callDuration.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs; + } else { + elements.callDuration.textContent = ''; + } + elements.callButtons.classList.remove('hidden'); + + // Show appropriate buttons + elements.btnAnswer.classList.toggle('hidden', call.state !== 'incoming'); + elements.btnReject.classList.toggle('hidden', call.state !== 'incoming'); + elements.btnHangup.classList.toggle('hidden', call.state === 'idle' || call.state === 'incoming'); + } else { + elements.callRemote.textContent = ''; + elements.callDuration.textContent = ''; + elements.callButtons.classList.add('hidden'); + } + } + + // Tab Navigation + function setupTabs() { + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + if (tab === currentTab) return; + + // Update nav + document.querySelector('.nav-btn.active').classList.remove('active'); + btn.classList.add('active'); + + // Update content + document.querySelector('.tab-content.active').classList.remove('active'); + document.getElementById('tab-' + tab).classList.add('active'); + + currentTab = tab; + + // Load tab-specific data + if (tab === 'wifi') loadWifiConfig(); + if (tab === 'sip') loadSipConfig(); + if (tab === 'bluetooth') loadBluetoothDevices(); + }); + }); + } + + // WiFi + async function loadWifiConfig() { + const config = await apiGet('wifi/config'); + if (!config) return; + + elements.wifiSsid.value = config.ssid || ''; + elements.wifiPassword.value = ''; + + const ipMode = config.ip_mode || 'dhcp'; + document.querySelector('input[name="ip-mode"][value="' + ipMode + '"]').checked = true; + elements.staticIpConfig.classList.toggle('hidden', ipMode !== 'static'); + + if (ipMode === 'static') { + document.getElementById('static-ip').value = config.static_ip || ''; + document.getElementById('gateway').value = config.gateway || ''; + document.getElementById('netmask').value = config.netmask || ''; + document.getElementById('dns').value = config.dns || ''; + } + } + + async function scanWifi() { + elements.btnScanWifi.disabled = true; + elements.btnScanWifi.textContent = 'Scanne...'; + elements.wifiScanResults.classList.remove('hidden'); + elements.wifiScanResults.innerHTML = '

Scanne...

'; + + const networks = await apiGet('wifi/scan'); + + elements.btnScanWifi.disabled = false; + elements.btnScanWifi.textContent = 'Scannen'; + + if (!networks || networks.length === 0) { + elements.wifiScanResults.innerHTML = '

Keine Netzwerke gefunden

'; + return; + } + + elements.wifiScanResults.innerHTML = networks.map(net => + '
' + + '' + escapeHtml(net.ssid) + (net.secure ? ' 🔒' : '') + '' + + '' + net.rssi + ' dBm' + + '
' + ).join(''); + + elements.wifiScanResults.querySelectorAll('.scan-item').forEach(item => { + item.addEventListener('click', () => { + elements.wifiSsid.value = item.dataset.ssid; + elements.wifiScanResults.classList.add('hidden'); + elements.wifiPassword.focus(); + }); + }); + } + + async function saveWifiConfig(e) { + e.preventDefault(); + + const ipMode = document.querySelector('input[name="ip-mode"]:checked').value; + const data = { + ssid: elements.wifiSsid.value, + password: elements.wifiPassword.value, + ip_mode: ipMode + }; + + if (ipMode === 'static') { + data.static_ip = document.getElementById('static-ip').value; + data.gateway = document.getElementById('gateway').value; + data.netmask = document.getElementById('netmask').value; + data.dns = document.getElementById('dns').value; + } + + const result = await apiPost('wifi/config', data); + if (result && result.success) { + alert('WiFi-Konfiguration gespeichert. Verbinde...'); + } else { + alert('Fehler: ' + (result?.error || 'Unbekannt')); + } + } + + // SIP + async function loadSipConfig() { + const config = await apiGet('sip/config'); + if (!config) return; + + elements.sipServer.value = config.server || ''; + elements.sipPort.value = config.port || 5060; + elements.sipUsername.value = config.username || ''; + elements.sipPassword.value = ''; + elements.sipDisplayName.value = config.display_name || ''; + } + + async function saveSipConfig(e) { + e.preventDefault(); + + const data = { + server: elements.sipServer.value, + port: parseInt(elements.sipPort.value) || 5060, + username: elements.sipUsername.value, + password: elements.sipPassword.value, + display_name: elements.sipDisplayName.value + }; + + const result = await apiPost('sip/config', data); + if (result && result.success) { + alert('SIP-Konfiguration gespeichert. Registriere...'); + } else { + alert('Fehler: ' + (result?.error || 'Unbekannt')); + } + } + + // Bluetooth + async function loadBluetoothDevices() { + const devices = await apiGet('bluetooth/devices'); + + if (!devices || devices.length === 0) { + elements.btPairedDevices.innerHTML = '

Keine Gerate gepaart

'; + } else { + elements.btPairedDevices.innerHTML = devices.map(dev => + '
' + + '
' + + '
' + escapeHtml(dev.name || 'Unbekannt') + '
' + + '
' + escapeHtml(dev.address) + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + ).join(''); + + elements.btPairedDevices.querySelectorAll('.btn-connect').forEach(btn => { + btn.addEventListener('click', () => connectBluetooth(btn.dataset.address)); + }); + + elements.btPairedDevices.querySelectorAll('.btn-unpair').forEach(btn => { + btn.addEventListener('click', () => unpairBluetooth(btn.dataset.address)); + }); + } + } + + async function scanBluetooth() { + elements.btnScanBt.disabled = true; + elements.btnScanBt.textContent = 'Suche...'; + elements.btFoundDevices.innerHTML = '

Suche Gerate...

'; + + await apiPost('bluetooth/scan'); + + // Wait for scan to complete + setTimeout(async () => { + elements.btnScanBt.disabled = false; + elements.btnScanBt.textContent = 'Gerate suchen'; + // In a full implementation, we'd poll for discovered devices + elements.btFoundDevices.innerHTML = '

Suche abgeschlossen. Gerate werden automatisch gepaart wenn sie in den Pairing-Modus gehen.

'; + }, 10000); + } + + async function connectBluetooth(address) { + const result = await apiPost('bluetooth/connect', { address }); + if (result && result.success) { + alert('Verbinde...'); + } else { + alert('Fehler: ' + (result?.error || 'Unbekannt')); + } + } + + async function unpairBluetooth(address) { + if (!confirm('Gerat wirklich entfernen?')) return; + + const result = await apiPost('bluetooth/unpair', { address }); + if (result && result.success) { + loadBluetoothDevices(); + } else { + alert('Fehler: ' + (result?.error || 'Unbekannt')); + } + } + + // Call Actions + async function answerCall() { + await apiPost('call/answer'); + } + + async function rejectCall() { + await apiPost('call/reject'); + } + + async function hangupCall() { + await apiPost('call/hangup'); + } + + // System + async function reboot() { + if (!confirm('System wirklich neustarten?')) return; + await apiPost('system/reboot'); + alert('System startet neu...'); + } + + async function factoryReset() { + if (!confirm('ACHTUNG: Alle Einstellungen werden geloscht! Fortfahren?')) return; + if (!confirm('Wirklich alle Einstellungen loschen?')) return; + await apiPost('system/factory-reset'); + alert('Werksreset durchgefuhrt. System startet neu...'); + } + + // Utilities + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // Event Listeners + function setupEventListeners() { + // Call buttons + elements.btnAnswer.addEventListener('click', answerCall); + elements.btnReject.addEventListener('click', rejectCall); + elements.btnHangup.addEventListener('click', hangupCall); + + // Volume + elements.volumeSlider.addEventListener('input', () => { + elements.volumeValue.textContent = elements.volumeSlider.value + '%'; + }); + + // WiFi + elements.wifiForm.addEventListener('submit', saveWifiConfig); + elements.btnScanWifi.addEventListener('click', scanWifi); + + document.querySelectorAll('input[name="ip-mode"]').forEach(radio => { + radio.addEventListener('change', (e) => { + elements.staticIpConfig.classList.toggle('hidden', e.target.value !== 'static'); + }); + }); + + // SIP + elements.sipForm.addEventListener('submit', saveSipConfig); + + // Bluetooth + elements.btnScanBt.addEventListener('click', scanBluetooth); + + // System + elements.btnReboot.addEventListener('click', reboot); + elements.btnFactoryReset.addEventListener('click', factoryReset); + } + + // Initialize + function init() { + setupTabs(); + setupEventListeners(); + updateStatus(); + + // Update status every 2 seconds + statusUpdateInterval = setInterval(updateStatus, 2000); + } + + // Start + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/main/web/static/index.html b/main/web/static/index.html new file mode 100644 index 0000000..9afa1bf --- /dev/null +++ b/main/web/static/index.html @@ -0,0 +1,183 @@ + + + + + + ESP32 SIP Phone + + + +
+

ESP32 SIP Phone

+
+ WiFi: -- + SIP: -- + Audio: -- +
+
+ + + +
+ +
+
+

Anrufstatus

+
+

Kein aktiver Anruf

+

+

+
+ +
+ +
+

Audio

+

Aktive Quelle: --

+

USB Headset: --

+

Bluetooth Headset: --

+
+ + + 80% +
+ +
+
+ + +
+
+

WLAN-Konfiguration

+
+
+ +
+ + +
+
+ + + +
+ + +
+ +
+ +
+ + +
+
+ + + + +
+
+
+ + +
+
+

SIP-Konfiguration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+

Bluetooth Headsets

+

USB-Headsets haben Vorrang vor Bluetooth.

+ + + +

Gepaarte Geräte

+
+

Keine Geräte gepaart

+
+ +

Gefundene Geräte

+
+

Starte Suche...

+
+
+
+ + +
+
+

System

+
+ + +
+
+ +
+

Info

+

ESP32-S3 Bluetooth SIP Client

+

Hotspot: ESP32-SIP-Phone

+

Standard-IP: 192.168.4.1

+
+
+
+ + + + diff --git a/main/web/static/style.css b/main/web/static/style.css new file mode 100644 index 0000000..1f0bffb --- /dev/null +++ b/main/web/static/style.css @@ -0,0 +1,374 @@ +/* ESP32 SIP Phone - Web Interface Styles */ + +:root { + --primary: #2196F3; + --primary-dark: #1976D2; + --success: #4CAF50; + --danger: #f44336; + --warning: #FF9800; + --bg: #f5f5f5; + --card-bg: #ffffff; + --text: #333333; + --text-light: #666666; + --border: #e0e0e0; + --shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +header { + background: var(--primary); + color: white; + padding: 1rem; + text-align: center; +} + +header h1 { + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +#status-bar { + display: flex; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.85rem; +} + +.status-item { + background: rgba(255,255,255,0.2); + padding: 0.25rem 0.75rem; + border-radius: 1rem; +} + +nav { + display: flex; + background: var(--card-bg); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.nav-btn { + flex: 1; + padding: 0.75rem 1rem; + border: none; + background: transparent; + color: var(--text-light); + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; + white-space: nowrap; +} + +.nav-btn:hover { + background: var(--bg); +} + +.nav-btn.active { + color: var(--primary); + border-bottom: 2px solid var(--primary); + font-weight: 500; +} + +main { + padding: 1rem; + max-width: 800px; + margin: 0 auto; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.card { + background: var(--card-bg); + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1rem; + box-shadow: var(--shadow); +} + +.card h2 { + font-size: 1.1rem; + margin-bottom: 1rem; + color: var(--primary); +} + +.card h3 { + font-size: 1rem; + margin: 1rem 0 0.5rem; + color: var(--text); +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.25rem; + font-weight: 500; + font-size: 0.9rem; +} + +input[type="text"], +input[type="password"], +input[type="number"] { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; +} + +input:focus { + outline: none; + border-color: var(--primary); +} + +.input-with-button { + display: flex; + gap: 0.5rem; +} + +.input-with-button input { + flex: 1; +} + +.radio-group { + display: flex; + gap: 1.5rem; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: normal; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.5rem 1.25rem; + border: none; + border-radius: 4px; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--border); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.button-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Volume Control */ +.volume-control { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0; +} + +.volume-control input[type="range"] { + flex: 1; +} + +/* Device List */ +.device-list { + border: 1px solid var(--border); + border-radius: 4px; + margin-top: 0.5rem; +} + +.device-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.device-item:last-child { + border-bottom: none; +} + +.device-info { + flex: 1; +} + +.device-name { + font-weight: 500; +} + +.device-address { + font-size: 0.8rem; + color: var(--text-light); + font-family: monospace; +} + +.device-actions { + display: flex; + gap: 0.5rem; +} + +.device-actions .btn { + padding: 0.25rem 0.75rem; + font-size: 0.8rem; +} + +.no-devices { + padding: 1rem; + text-align: center; + color: var(--text-light); + font-style: italic; +} + +/* Scan Results */ +.scan-results { + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 4px; + margin-bottom: 1rem; +} + +.scan-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--border); +} + +.scan-item:hover { + background: var(--bg); +} + +.scan-item:last-child { + border-bottom: none; +} + +.scan-rssi { + font-size: 0.8rem; + color: var(--text-light); +} + +/* Call Info */ +#call-info { + margin-bottom: 1rem; +} + +#call-state { + font-size: 1.1rem; + font-weight: 500; +} + +#call-remote { + font-size: 1.25rem; + margin: 0.5rem 0; +} + +#call-duration { + color: var(--text-light); +} + +/* Utilities */ +.hidden { + display: none !important; +} + +.hint { + font-size: 0.85rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +/* Status Colors */ +.status-connected { + color: var(--success); +} + +.status-disconnected { + color: var(--danger); +} + +.status-pending { + color: var(--warning); +} + +/* Responsive */ +@media (max-width: 480px) { + header h1 { + font-size: 1.25rem; + } + + .nav-btn { + padding: 0.5rem; + font-size: 0.8rem; + } + + main { + padding: 0.5rem; + } + + .card { + padding: 1rem; + } +} diff --git a/main/web/web_api.c b/main/web/web_api.c new file mode 100644 index 0000000..b286e8c --- /dev/null +++ b/main/web/web_api.c @@ -0,0 +1,548 @@ +/** + * Web API - REST-Endpoints für Konfiguration + */ + +#include +#include +#include "web_api.h" +#include "config/config_manager.h" +#include "wifi/wifi_manager.h" +#include "bluetooth/bt_manager.h" +#include "sip/sip_client.h" +#include "audio/audio_router.h" +#include "esp_log.h" +#include "cJSON.h" + +static const char* TAG = "WEB_API"; + +// Hilfsfunktion: JSON-Body aus Request lesen +static cJSON* read_json_body(httpd_req_t* req) +{ + int content_len = req->content_len; + if (content_len <= 0 || content_len > 4096) { + return NULL; + } + + char* buf = malloc(content_len + 1); + if (!buf) return NULL; + + int received = httpd_req_recv(req, buf, content_len); + if (received <= 0) { + free(buf); + return NULL; + } + buf[received] = '\0'; + + cJSON* json = cJSON_Parse(buf); + free(buf); + return json; +} + +// Hilfsfunktion: JSON-Antwort senden +static esp_err_t send_json_response(httpd_req_t* req, cJSON* json) +{ + char* str = cJSON_PrintUnformatted(json); + if (!str) { + httpd_resp_send_500(req); + return ESP_FAIL; + } + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, str, strlen(str)); + free(str); + return ESP_OK; +} + +static esp_err_t send_json_error(httpd_req_t* req, int status, const char* message) +{ + cJSON* json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "success", false); + cJSON_AddStringToObject(json, "error", message); + + httpd_resp_set_status(req, status == 400 ? "400 Bad Request" : + status == 404 ? "404 Not Found" : "500 Internal Server Error"); + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +static esp_err_t send_json_success(httpd_req_t* req, const char* message) +{ + cJSON* json = cJSON_CreateObject(); + cJSON_AddBoolToObject(json, "success", true); + if (message) cJSON_AddStringToObject(json, "message", message); + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +// ============ Status API ============ + +static esp_err_t api_status_get(httpd_req_t* req) +{ + const device_config_t* config = config_get(); + + cJSON* json = cJSON_CreateObject(); + + // WiFi Status + cJSON* wifi = cJSON_CreateObject(); + cJSON_AddStringToObject(wifi, "state", + wifi_manager_get_state() == WIFI_STATE_STA_CONNECTED ? "connected" : + wifi_manager_get_state() == WIFI_STATE_AP_STARTED ? "hotspot" : "disconnected"); + + char ip[16] = {0}; + wifi_manager_get_ip(ip, sizeof(ip)); + cJSON_AddStringToObject(wifi, "ip", ip); + cJSON_AddStringToObject(wifi, "ssid", config->wifi.ssid); + cJSON_AddItemToObject(json, "wifi", wifi); + + // SIP Status + cJSON* sip = cJSON_CreateObject(); + sip_reg_state_t reg_state = sip_client_get_reg_state(); + cJSON_AddStringToObject(sip, "state", + reg_state == SIP_REG_STATE_REGISTERED ? "registered" : + reg_state == SIP_REG_STATE_REGISTERING ? "registering" : "unregistered"); + cJSON_AddStringToObject(sip, "server", config->sip.server); + cJSON_AddStringToObject(sip, "user", config->sip.username); + cJSON_AddItemToObject(json, "sip", sip); + + // Audio Status + cJSON* audio = cJSON_CreateObject(); + audio_source_t source = audio_router_get_active_source(); + cJSON_AddStringToObject(audio, "source", + source == AUDIO_SOURCE_USB ? "usb" : + source == AUDIO_SOURCE_BLUETOOTH ? "bluetooth" : "none"); + cJSON_AddBoolToObject(audio, "usb_connected", audio_router_is_source_available(AUDIO_SOURCE_USB)); + cJSON_AddBoolToObject(audio, "bt_connected", audio_router_is_source_available(AUDIO_SOURCE_BLUETOOTH)); + cJSON_AddNumberToObject(audio, "volume", audio_router_get_volume()); + cJSON_AddBoolToObject(audio, "muted", audio_router_is_muted()); + cJSON_AddItemToObject(json, "audio", audio); + + // Call Status + cJSON* call = cJSON_CreateObject(); + sip_call_state_t call_state = sip_client_get_call_state(); + cJSON_AddStringToObject(call, "state", + call_state == SIP_CALL_STATE_IDLE ? "idle" : + call_state == SIP_CALL_STATE_INCOMING ? "incoming" : + call_state == SIP_CALL_STATE_OUTGOING ? "outgoing" : + call_state == SIP_CALL_STATE_RINGING ? "ringing" : + call_state == SIP_CALL_STATE_CONNECTED ? "connected" : "unknown"); + + sip_call_info_t call_info; + if (sip_client_get_call_info(&call_info) == ESP_OK && call_state != SIP_CALL_STATE_IDLE) { + cJSON_AddStringToObject(call, "remote", call_info.remote_number); + cJSON_AddStringToObject(call, "name", call_info.remote_name); + cJSON_AddNumberToObject(call, "duration", call_info.duration_sec); + } + cJSON_AddItemToObject(json, "call", call); + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +// ============ WiFi API ============ + +static esp_err_t api_wifi_config_get(httpd_req_t* req) +{ + const device_config_t* config = config_get(); + + cJSON* json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "ssid", config->wifi.ssid); + cJSON_AddStringToObject(json, "ip_mode", config->wifi.ip_mode == IP_MODE_DHCP ? "dhcp" : "static"); + cJSON_AddStringToObject(json, "static_ip", config->wifi.static_ip); + cJSON_AddStringToObject(json, "gateway", config->wifi.gateway); + cJSON_AddStringToObject(json, "netmask", config->wifi.netmask); + cJSON_AddStringToObject(json, "dns", config->wifi.dns); + cJSON_AddBoolToObject(json, "configured", config->wifi.configured); + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +static esp_err_t api_wifi_config_post(httpd_req_t* req) +{ + cJSON* json = read_json_body(req); + if (!json) { + return send_json_error(req, 400, "Invalid JSON"); + } + + wifi_config_data_t wifi_cfg = {0}; + + cJSON* ssid = cJSON_GetObjectItem(json, "ssid"); + cJSON* password = cJSON_GetObjectItem(json, "password"); + cJSON* ip_mode = cJSON_GetObjectItem(json, "ip_mode"); + + if (!ssid || !cJSON_IsString(ssid) || strlen(ssid->valuestring) == 0) { + cJSON_Delete(json); + return send_json_error(req, 400, "SSID required"); + } + + strncpy(wifi_cfg.ssid, ssid->valuestring, CONFIG_MAX_SSID_LEN); + if (password && cJSON_IsString(password)) { + strncpy(wifi_cfg.password, password->valuestring, CONFIG_MAX_PASSWORD_LEN); + } + + wifi_cfg.ip_mode = IP_MODE_DHCP; + if (ip_mode && cJSON_IsString(ip_mode) && strcmp(ip_mode->valuestring, "static") == 0) { + wifi_cfg.ip_mode = IP_MODE_STATIC; + + cJSON* static_ip = cJSON_GetObjectItem(json, "static_ip"); + cJSON* gateway = cJSON_GetObjectItem(json, "gateway"); + cJSON* netmask = cJSON_GetObjectItem(json, "netmask"); + cJSON* dns = cJSON_GetObjectItem(json, "dns"); + + if (static_ip && cJSON_IsString(static_ip)) + strncpy(wifi_cfg.static_ip, static_ip->valuestring, CONFIG_MAX_IP_LEN); + if (gateway && cJSON_IsString(gateway)) + strncpy(wifi_cfg.gateway, gateway->valuestring, CONFIG_MAX_IP_LEN); + if (netmask && cJSON_IsString(netmask)) + strncpy(wifi_cfg.netmask, netmask->valuestring, CONFIG_MAX_IP_LEN); + if (dns && cJSON_IsString(dns)) + strncpy(wifi_cfg.dns, dns->valuestring, CONFIG_MAX_IP_LEN); + } + + cJSON_Delete(json); + + esp_err_t err = config_save_wifi(&wifi_cfg); + if (err != ESP_OK) { + return send_json_error(req, 500, "Save failed"); + } + + // Neu verbinden + wifi_manager_connect(&wifi_cfg); + + return send_json_success(req, "WiFi configuration saved. Connecting..."); +} + +static esp_err_t api_wifi_scan_get(httpd_req_t* req) +{ + wifi_manager_scan(); + + // Warten auf Scan-Ergebnis + vTaskDelay(pdMS_TO_TICKS(3000)); + + uint16_t num_networks = 0; + esp_wifi_scan_get_ap_num(&num_networks); + + if (num_networks > 20) num_networks = 20; + + wifi_ap_record_t* records = malloc(sizeof(wifi_ap_record_t) * num_networks); + if (!records) { + return send_json_error(req, 500, "Memory error"); + } + + esp_wifi_scan_get_ap_records(&num_networks, records); + + cJSON* json = cJSON_CreateArray(); + for (int i = 0; i < num_networks; i++) { + cJSON* ap = cJSON_CreateObject(); + cJSON_AddStringToObject(ap, "ssid", (char*)records[i].ssid); + cJSON_AddNumberToObject(ap, "rssi", records[i].rssi); + cJSON_AddNumberToObject(ap, "channel", records[i].primary); + cJSON_AddBoolToObject(ap, "secure", records[i].authmode != WIFI_AUTH_OPEN); + cJSON_AddItemToArray(json, ap); + } + + free(records); + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +// ============ SIP API ============ + +static esp_err_t api_sip_config_get(httpd_req_t* req) +{ + const device_config_t* config = config_get(); + + cJSON* json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "server", config->sip.server); + cJSON_AddNumberToObject(json, "port", config->sip.port); + cJSON_AddStringToObject(json, "username", config->sip.username); + cJSON_AddStringToObject(json, "display_name", config->sip.display_name); + cJSON_AddBoolToObject(json, "configured", config->sip.configured); + // Passwort wird nicht zurückgegeben + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +static esp_err_t api_sip_config_post(httpd_req_t* req) +{ + cJSON* json = read_json_body(req); + if (!json) { + return send_json_error(req, 400, "Invalid JSON"); + } + + sip_config_data_t sip_cfg = {0}; + sip_cfg.port = CONFIG_BSC_SIP_DEFAULT_PORT; + + cJSON* server = cJSON_GetObjectItem(json, "server"); + cJSON* port = cJSON_GetObjectItem(json, "port"); + cJSON* username = cJSON_GetObjectItem(json, "username"); + cJSON* password = cJSON_GetObjectItem(json, "password"); + cJSON* display_name = cJSON_GetObjectItem(json, "display_name"); + + if (!server || !cJSON_IsString(server) || strlen(server->valuestring) == 0) { + cJSON_Delete(json); + return send_json_error(req, 400, "Server required"); + } + if (!username || !cJSON_IsString(username) || strlen(username->valuestring) == 0) { + cJSON_Delete(json); + return send_json_error(req, 400, "Username required"); + } + + strncpy(sip_cfg.server, server->valuestring, CONFIG_MAX_SIP_SERVER_LEN); + strncpy(sip_cfg.username, username->valuestring, CONFIG_MAX_SIP_USER_LEN); + + if (port && cJSON_IsNumber(port)) { + sip_cfg.port = (uint16_t)port->valueint; + } + if (password && cJSON_IsString(password)) { + strncpy(sip_cfg.password, password->valuestring, CONFIG_MAX_PASSWORD_LEN); + } + if (display_name && cJSON_IsString(display_name)) { + strncpy(sip_cfg.display_name, display_name->valuestring, CONFIG_MAX_SIP_USER_LEN); + } + + cJSON_Delete(json); + + esp_err_t err = config_save_sip(&sip_cfg); + if (err != ESP_OK) { + return send_json_error(req, 500, "Save failed"); + } + + // Neu registrieren + sip_client_unregister(); + sip_client_register(); + + return send_json_success(req, "SIP configuration saved. Registering..."); +} + +// ============ Bluetooth API ============ + +static esp_err_t api_bluetooth_devices_get(httpd_req_t* req) +{ + const device_config_t* config = config_get(); + + cJSON* json = cJSON_CreateArray(); + + for (int i = 0; i < config->bluetooth.device_count; i++) { + const bt_device_config_t* dev = &config->bluetooth.devices[i]; + cJSON* device = cJSON_CreateObject(); + cJSON_AddStringToObject(device, "address", dev->address); + cJSON_AddStringToObject(device, "name", dev->name); + cJSON_AddBoolToObject(device, "paired", dev->paired); + cJSON_AddBoolToObject(device, "auto_connect", dev->auto_connect); + cJSON_AddNumberToObject(device, "priority", dev->priority); + // TODO: Check if currently connected + cJSON_AddItemToArray(json, device); + } + + esp_err_t ret = send_json_response(req, json); + cJSON_Delete(json); + return ret; +} + +static esp_err_t api_bluetooth_scan_post(httpd_req_t* req) +{ + ESP_LOGI(TAG, "Starte Bluetooth-Scan"); + bt_manager_start_discovery(); + return send_json_success(req, "Scan started"); +} + +static esp_err_t api_bluetooth_pair_post(httpd_req_t* req) +{ + cJSON* json = read_json_body(req); + if (!json) { + return send_json_error(req, 400, "Invalid JSON"); + } + + cJSON* address = cJSON_GetObjectItem(json, "address"); + if (!address || !cJSON_IsString(address)) { + cJSON_Delete(json); + return send_json_error(req, 400, "Address required"); + } + + esp_bd_addr_t addr; + esp_err_t err = bt_str_to_addr(address->valuestring, addr); + if (err != ESP_OK) { + cJSON_Delete(json); + return send_json_error(req, 400, "Invalid address format"); + } + + cJSON_Delete(json); + + err = bt_manager_pair(addr); + if (err != ESP_OK) { + return send_json_error(req, 500, "Pairing failed"); + } + + return send_json_success(req, "Pairing initiated"); +} + +static esp_err_t api_bluetooth_unpair_post(httpd_req_t* req) +{ + cJSON* json = read_json_body(req); + if (!json) { + return send_json_error(req, 400, "Invalid JSON"); + } + + cJSON* address = cJSON_GetObjectItem(json, "address"); + if (!address || !cJSON_IsString(address)) { + cJSON_Delete(json); + return send_json_error(req, 400, "Address required"); + } + + esp_bd_addr_t addr; + esp_err_t err = bt_str_to_addr(address->valuestring, addr); + if (err != ESP_OK) { + cJSON_Delete(json); + return send_json_error(req, 400, "Invalid address format"); + } + + cJSON_Delete(json); + + bt_manager_unpair(addr); + config_remove_bt_device(address->valuestring); + + return send_json_success(req, "Device removed"); +} + +static esp_err_t api_bluetooth_connect_post(httpd_req_t* req) +{ + cJSON* json = read_json_body(req); + if (!json) { + return send_json_error(req, 400, "Invalid JSON"); + } + + cJSON* address = cJSON_GetObjectItem(json, "address"); + if (!address || !cJSON_IsString(address)) { + cJSON_Delete(json); + return send_json_error(req, 400, "Address required"); + } + + esp_bd_addr_t addr; + esp_err_t err = bt_str_to_addr(address->valuestring, addr); + cJSON_Delete(json); + + if (err != ESP_OK) { + return send_json_error(req, 400, "Invalid address format"); + } + + err = bt_manager_connect(addr); + if (err != ESP_OK) { + return send_json_error(req, 500, "Connection failed"); + } + + return send_json_success(req, "Connecting..."); +} + +// ============ Call API ============ + +static esp_err_t api_call_answer_post(httpd_req_t* req) +{ + esp_err_t err = sip_client_answer(); + if (err != ESP_OK) { + return send_json_error(req, 500, "Answer failed"); + } + return send_json_success(req, "Call answered"); +} + +static esp_err_t api_call_hangup_post(httpd_req_t* req) +{ + esp_err_t err = sip_client_hangup(); + if (err != ESP_OK) { + return send_json_error(req, 500, "Hangup failed"); + } + return send_json_success(req, "Call ended"); +} + +static esp_err_t api_call_reject_post(httpd_req_t* req) +{ + esp_err_t err = sip_client_reject(); + if (err != ESP_OK) { + return send_json_error(req, 500, "Reject failed"); + } + return send_json_success(req, "Call rejected"); +} + +// ============ System API ============ + +static esp_err_t api_system_reboot_post(httpd_req_t* req) +{ + send_json_success(req, "Rebooting..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + return ESP_OK; +} + +static esp_err_t api_system_factory_reset_post(httpd_req_t* req) +{ + esp_err_t err = config_factory_reset(); + if (err != ESP_OK) { + return send_json_error(req, 500, "Reset failed"); + } + send_json_success(req, "Factory reset complete. Rebooting..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + return ESP_OK; +} + +// ============ Route Registration ============ + +void web_api_register_handlers(httpd_handle_t server) +{ + ESP_LOGI(TAG, "Registriere API-Handler"); + + // Status + httpd_uri_t uri; + + uri = (httpd_uri_t){.uri = "/api/status", .method = HTTP_GET, .handler = api_status_get}; + httpd_register_uri_handler(server, &uri); + + // WiFi + uri = (httpd_uri_t){.uri = "/api/wifi/config", .method = HTTP_GET, .handler = api_wifi_config_get}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/wifi/config", .method = HTTP_POST, .handler = api_wifi_config_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/wifi/scan", .method = HTTP_GET, .handler = api_wifi_scan_get}; + httpd_register_uri_handler(server, &uri); + + // SIP + uri = (httpd_uri_t){.uri = "/api/sip/config", .method = HTTP_GET, .handler = api_sip_config_get}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/sip/config", .method = HTTP_POST, .handler = api_sip_config_post}; + httpd_register_uri_handler(server, &uri); + + // Bluetooth + uri = (httpd_uri_t){.uri = "/api/bluetooth/devices", .method = HTTP_GET, .handler = api_bluetooth_devices_get}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/bluetooth/scan", .method = HTTP_POST, .handler = api_bluetooth_scan_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/bluetooth/pair", .method = HTTP_POST, .handler = api_bluetooth_pair_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/bluetooth/unpair", .method = HTTP_POST, .handler = api_bluetooth_unpair_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/bluetooth/connect", .method = HTTP_POST, .handler = api_bluetooth_connect_post}; + httpd_register_uri_handler(server, &uri); + + // Call + uri = (httpd_uri_t){.uri = "/api/call/answer", .method = HTTP_POST, .handler = api_call_answer_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/call/hangup", .method = HTTP_POST, .handler = api_call_hangup_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/call/reject", .method = HTTP_POST, .handler = api_call_reject_post}; + httpd_register_uri_handler(server, &uri); + + // System + uri = (httpd_uri_t){.uri = "/api/system/reboot", .method = HTTP_POST, .handler = api_system_reboot_post}; + httpd_register_uri_handler(server, &uri); + uri = (httpd_uri_t){.uri = "/api/system/factory-reset", .method = HTTP_POST, .handler = api_system_factory_reset_post}; + httpd_register_uri_handler(server, &uri); +} diff --git a/main/web/web_api.h b/main/web/web_api.h new file mode 100644 index 0000000..4c581ea --- /dev/null +++ b/main/web/web_api.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Registriert alle API-Handler beim Webserver + */ +void web_api_register_handlers(httpd_handle_t server); + +#ifdef __cplusplus +} +#endif diff --git a/main/web/web_server.c b/main/web/web_server.c new file mode 100644 index 0000000..78bd82a --- /dev/null +++ b/main/web/web_server.c @@ -0,0 +1,117 @@ +/** + * Web Server - Konfigurations-Weboberfläche + */ + +#include +#include "web_server.h" +#include "web_api.h" +#include "esp_log.h" +#include "esp_http_server.h" + +static const char* TAG = "WEB_SRV"; + +// Embedded Files (aus CMakeLists.txt EMBED_FILES) +extern const uint8_t index_html_start[] asm("_binary_index_html_start"); +extern const uint8_t index_html_end[] asm("_binary_index_html_end"); +extern const uint8_t style_css_start[] asm("_binary_style_css_start"); +extern const uint8_t style_css_end[] asm("_binary_style_css_end"); +extern const uint8_t app_js_start[] asm("_binary_app_js_start"); +extern const uint8_t app_js_end[] asm("_binary_app_js_end"); + +static httpd_handle_t s_server = NULL; + +// Handler für statische Dateien +static esp_err_t index_handler(httpd_req_t* req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, (const char*)index_html_start, index_html_end - index_html_start); + return ESP_OK; +} + +static esp_err_t style_handler(httpd_req_t* req) +{ + httpd_resp_set_type(req, "text/css"); + httpd_resp_send(req, (const char*)style_css_start, style_css_end - style_css_start); + return ESP_OK; +} + +static esp_err_t js_handler(httpd_req_t* req) +{ + httpd_resp_set_type(req, "application/javascript"); + httpd_resp_send(req, (const char*)app_js_start, app_js_end - app_js_start); + return ESP_OK; +} + +esp_err_t web_server_init(void) +{ + if (s_server != NULL) { + ESP_LOGW(TAG, "Server bereits gestartet"); + return ESP_OK; + } + + ESP_LOGI(TAG, "Starte Webserver auf Port %d", CONFIG_BSC_WEB_PORT); + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = CONFIG_BSC_WEB_PORT; + config.lru_purge_enable = true; + config.max_uri_handlers = 20; + config.stack_size = 8192; + + esp_err_t err = httpd_start(&s_server, &config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Server starten fehlgeschlagen: %s", esp_err_to_name(err)); + return err; + } + + // Statische Routen registrieren + httpd_uri_t index_uri = { + .uri = "/", + .method = HTTP_GET, + .handler = index_handler, + }; + httpd_register_uri_handler(s_server, &index_uri); + + httpd_uri_t style_uri = { + .uri = "/style.css", + .method = HTTP_GET, + .handler = style_handler, + }; + httpd_register_uri_handler(s_server, &style_uri); + + httpd_uri_t js_uri = { + .uri = "/app.js", + .method = HTTP_GET, + .handler = js_handler, + }; + httpd_register_uri_handler(s_server, &js_uri); + + // API-Routen registrieren + web_api_register_handlers(s_server); + + ESP_LOGI(TAG, "Webserver gestartet"); + return ESP_OK; +} + +esp_err_t web_server_stop(void) +{ + if (s_server == NULL) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Stoppe Webserver"); + esp_err_t err = httpd_stop(s_server); + s_server = NULL; + return err; +} + +httpd_handle_t web_server_get_handle(void) +{ + return s_server; +} + +esp_err_t web_server_send_ws_event(const char* event_type, const char* json_data) +{ + // WebSocket-Broadcast - wird in einer erweiterten Version implementiert + ESP_LOGD(TAG, "WS Event: %s", event_type); + return ESP_OK; +} diff --git a/main/web/web_server.h b/main/web/web_server.h new file mode 100644 index 0000000..ac0627c --- /dev/null +++ b/main/web/web_server.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialisiert und startet den Webserver + */ +esp_err_t web_server_init(void); + +/** + * Stoppt den Webserver + */ +esp_err_t web_server_stop(void); + +/** + * Gibt das Webserver-Handle zurück + */ +httpd_handle_t web_server_get_handle(void); + +/** + * Sendet ein WebSocket Event an alle verbundenen Clients + */ +esp_err_t web_server_send_ws_event(const char* event_type, const char* json_data); + +#ifdef __cplusplus +} +#endif diff --git a/main/wifi/wifi_manager.c b/main/wifi/wifi_manager.c new file mode 100644 index 0000000..bdb3f43 --- /dev/null +++ b/main/wifi/wifi_manager.c @@ -0,0 +1,340 @@ +/** + * WiFi Manager - Verwaltet WiFi AP und Station Mode + * + * - Hotspot-Modus wenn keine WLAN-Daten konfiguriert + * - Station-Modus wenn WLAN konfiguriert + * - Automatischer Fallback zu Hotspot bei Verbindungsproblemen + */ + +#include +#include "wifi_manager.h" +#include "config/config_manager.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" +#include "lwip/err.h" +#include "lwip/sys.h" + +static const char* TAG = "WIFI_MGR"; + +// Event Bits +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 + +// Maximale Wiederholungsversuche +#define MAX_RETRY_COUNT 5 + +// State +static wifi_state_t s_state = WIFI_STATE_IDLE; +static EventGroupHandle_t s_wifi_event_group; +static esp_netif_t* s_netif_ap = NULL; +static esp_netif_t* s_netif_sta = NULL; +static int s_retry_count = 0; +static wifi_event_callback_t s_callback = NULL; +static bool s_initialized = false; + +// Forward Declarations +static void wifi_event_handler(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data); +static void ip_event_handler(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data); + +static void notify_callback(wifi_state_t state, void* data) +{ + s_state = state; + if (s_callback) { + s_callback(state, data); + } +} + +static void wifi_event_handler(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data) +{ + if (event_base == WIFI_EVENT) { + switch (event_id) { + case WIFI_EVENT_AP_START: + ESP_LOGI(TAG, "AP gestartet"); + notify_callback(WIFI_STATE_AP_STARTED, NULL); + break; + + case WIFI_EVENT_AP_STOP: + ESP_LOGI(TAG, "AP gestoppt"); + break; + + case WIFI_EVENT_AP_STACONNECTED: { + wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*)event_data; + ESP_LOGI(TAG, "Client verbunden: " MACSTR, MAC2STR(event->mac)); + break; + } + + case WIFI_EVENT_AP_STADISCONNECTED: { + wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*)event_data; + ESP_LOGI(TAG, "Client getrennt: " MACSTR, MAC2STR(event->mac)); + break; + } + + case WIFI_EVENT_STA_START: + ESP_LOGI(TAG, "STA gestartet, verbinde..."); + esp_wifi_connect(); + notify_callback(WIFI_STATE_STA_CONNECTING, NULL); + break; + + case WIFI_EVENT_STA_DISCONNECTED: { + wifi_event_sta_disconnected_t* event = (wifi_event_sta_disconnected_t*)event_data; + ESP_LOGW(TAG, "Verbindung getrennt (Grund: %d)", event->reason); + + if (s_retry_count < MAX_RETRY_COUNT) { + s_retry_count++; + ESP_LOGI(TAG, "Wiederverbinden... (Versuch %d/%d)", s_retry_count, MAX_RETRY_COUNT); + vTaskDelay(pdMS_TO_TICKS(2000)); + esp_wifi_connect(); + notify_callback(WIFI_STATE_STA_DISCONNECTED, NULL); + } else { + ESP_LOGE(TAG, "Max. Versuche erreicht - Fallback zu AP"); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + notify_callback(WIFI_STATE_STA_FAILED, NULL); + // Starte AP + wifi_manager_start_ap(); + } + break; + } + + case WIFI_EVENT_STA_CONNECTED: + ESP_LOGI(TAG, "Mit AP verbunden"); + s_retry_count = 0; + break; + + default: + break; + } + } +} + +static void ip_event_handler(void* arg, esp_event_base_t event_base, + int32_t event_id, void* event_data) +{ + if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t* event = (ip_event_got_ip_t*)event_data; + ESP_LOGI(TAG, "IP erhalten: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_count = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + notify_callback(WIFI_STATE_STA_CONNECTED, NULL); + } +} + +esp_err_t wifi_manager_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + ESP_LOGI(TAG, "Initialisiere WiFi Manager"); + + s_wifi_event_group = xEventGroupCreate(); + + // TCP/IP Stack initialisieren + ESP_ERROR_CHECK(esp_netif_init()); + + // Netif für AP und STA erstellen + s_netif_ap = esp_netif_create_default_wifi_ap(); + s_netif_sta = esp_netif_create_default_wifi_sta(); + + // WiFi mit Standardkonfiguration initialisieren + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + // Event Handler registrieren + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &ip_event_handler, NULL, NULL)); + + // WiFi Storage + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + + s_initialized = true; + return ESP_OK; +} + +esp_err_t wifi_manager_start(void) +{ + ESP_LOGI(TAG, "Starte WiFi..."); + + const device_config_t* config = config_get(); + + if (config->wifi.configured) { + // Station Mode - mit konfiguriertem WLAN verbinden + return wifi_manager_connect(&config->wifi); + } else { + // AP Mode - Hotspot starten + return wifi_manager_start_ap(); + } +} + +esp_err_t wifi_manager_stop(void) +{ + ESP_LOGI(TAG, "Stoppe WiFi"); + return esp_wifi_stop(); +} + +esp_err_t wifi_manager_start_ap(void) +{ + ESP_LOGI(TAG, "Starte Hotspot: %s", CONFIG_BSC_DEFAULT_AP_SSID); + + // Stoppen falls bereits aktiv + esp_wifi_stop(); + + // AP-IP konfigurieren + esp_netif_ip_info_t ip_info; + IP4_ADDR(&ip_info.ip, 192, 168, 4, 1); + IP4_ADDR(&ip_info.gw, 192, 168, 4, 1); + IP4_ADDR(&ip_info.netmask, 255, 255, 255, 0); + + esp_netif_dhcps_stop(s_netif_ap); + esp_netif_set_ip_info(s_netif_ap, &ip_info); + esp_netif_dhcps_start(s_netif_ap); + + // WiFi AP Konfiguration + wifi_config_t wifi_config = { + .ap = { + .ssid_len = strlen(CONFIG_BSC_DEFAULT_AP_SSID), + .channel = 1, + .max_connection = 4, + .authmode = WIFI_AUTH_WPA2_PSK, + .pmf_cfg = { + .required = false, + }, + }, + }; + + strncpy((char*)wifi_config.ap.ssid, CONFIG_BSC_DEFAULT_AP_SSID, sizeof(wifi_config.ap.ssid)); + strncpy((char*)wifi_config.ap.password, CONFIG_BSC_DEFAULT_AP_PASSWORD, sizeof(wifi_config.ap.password)); + + if (strlen(CONFIG_BSC_DEFAULT_AP_PASSWORD) < 8) { + wifi_config.ap.authmode = WIFI_AUTH_OPEN; + } + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Hotspot gestartet. SSID: %s, Passwort: %s", + CONFIG_BSC_DEFAULT_AP_SSID, CONFIG_BSC_DEFAULT_AP_PASSWORD); + + return ESP_OK; +} + +esp_err_t wifi_manager_connect(const wifi_config_data_t* config) +{ + if (!config || strlen(config->ssid) == 0) { + ESP_LOGE(TAG, "Ungültige WiFi-Konfiguration"); + return ESP_ERR_INVALID_ARG; + } + + ESP_LOGI(TAG, "Verbinde mit WLAN: %s", config->ssid); + + // Stoppen falls bereits aktiv + esp_wifi_stop(); + + // Static IP konfigurieren wenn gewünscht + if (config->ip_mode == IP_MODE_STATIC && strlen(config->static_ip) > 0) { + ESP_LOGI(TAG, "Verwende statische IP: %s", config->static_ip); + + esp_netif_dhcpc_stop(s_netif_sta); + + esp_netif_ip_info_t ip_info; + memset(&ip_info, 0, sizeof(ip_info)); + + ip4addr_aton(config->static_ip, (ip4_addr_t*)&ip_info.ip); + ip4addr_aton(config->gateway, (ip4_addr_t*)&ip_info.gw); + ip4addr_aton(config->netmask, (ip4_addr_t*)&ip_info.netmask); + + esp_netif_set_ip_info(s_netif_sta, &ip_info); + + // DNS konfigurieren wenn vorhanden + if (strlen(config->dns) > 0) { + esp_netif_dns_info_t dns; + ip4addr_aton(config->dns, (ip4_addr_t*)&dns.ip.u_addr.ip4); + dns.ip.type = ESP_IPADDR_TYPE_V4; + esp_netif_set_dns_info(s_netif_sta, ESP_NETIF_DNS_MAIN, &dns); + } + } else { + esp_netif_dhcpc_start(s_netif_sta); + } + + // WiFi STA Konfiguration + wifi_config_t wifi_config = { + .sta = { + .threshold.authmode = WIFI_AUTH_WPA2_PSK, + .sae_pwe_h2e = WPA3_SAE_PWE_BOTH, + }, + }; + + strncpy((char*)wifi_config.sta.ssid, config->ssid, sizeof(wifi_config.sta.ssid)); + strncpy((char*)wifi_config.sta.password, config->password, sizeof(wifi_config.sta.password)); + + s_retry_count = 0; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + return ESP_OK; +} + +esp_err_t wifi_manager_disconnect(void) +{ + ESP_LOGI(TAG, "Trenne WiFi-Verbindung"); + esp_wifi_disconnect(); + return wifi_manager_start_ap(); +} + +wifi_state_t wifi_manager_get_state(void) +{ + return s_state; +} + +esp_err_t wifi_manager_get_ip(char* ip_str, size_t len) +{ + esp_netif_ip_info_t ip_info; + esp_netif_t* netif = (s_state == WIFI_STATE_AP_STARTED) ? s_netif_ap : s_netif_sta; + + esp_err_t err = esp_netif_get_ip_info(netif, &ip_info); + if (err != ESP_OK) return err; + + snprintf(ip_str, len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} + +esp_err_t wifi_manager_scan(void) +{ + ESP_LOGI(TAG, "Starte WLAN-Scan..."); + + // Scan-Konfiguration + wifi_scan_config_t scan_config = { + .ssid = NULL, + .bssid = NULL, + .channel = 0, + .show_hidden = false, + .scan_type = WIFI_SCAN_TYPE_ACTIVE, + .scan_time.active.min = 100, + .scan_time.active.max = 300, + }; + + return esp_wifi_scan_start(&scan_config, false); +} + +esp_err_t wifi_manager_register_callback(wifi_event_callback_t callback) +{ + s_callback = callback; + return ESP_OK; +} + +esp_netif_t* wifi_manager_get_netif(void) +{ + return (s_state == WIFI_STATE_AP_STARTED) ? s_netif_ap : s_netif_sta; +} diff --git a/main/wifi/wifi_manager.h b/main/wifi/wifi_manager.h new file mode 100644 index 0000000..996a40c --- /dev/null +++ b/main/wifi/wifi_manager.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include "esp_err.h" +#include "esp_netif.h" +#include "config/config_manager.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// WiFi-Status +typedef enum { + WIFI_STATE_IDLE = 0, + WIFI_STATE_AP_STARTED, // Hotspot läuft + WIFI_STATE_STA_CONNECTING, // Verbinde mit WLAN + WIFI_STATE_STA_CONNECTED, // Mit WLAN verbunden + WIFI_STATE_STA_DISCONNECTED,// Verbindung verloren + WIFI_STATE_STA_FAILED // Verbindung fehlgeschlagen -> Fallback zu AP +} wifi_state_t; + +// WiFi-Event Callback +typedef void (*wifi_event_callback_t)(wifi_state_t state, void* data); + +/** + * Initialisiert den WiFi-Manager + * Startet automatisch im AP-Modus wenn keine WLAN-Daten konfiguriert sind + */ +esp_err_t wifi_manager_init(void); + +/** + * Startet den WiFi-Manager + * - Wenn WLAN konfiguriert: Verbindet als Station + * - Wenn nicht konfiguriert: Startet Hotspot + */ +esp_err_t wifi_manager_start(void); + +/** + * Stoppt den WiFi-Manager + */ +esp_err_t wifi_manager_stop(void); + +/** + * Verbindet mit einem WLAN-Netzwerk + */ +esp_err_t wifi_manager_connect(const wifi_config_data_t* config); + +/** + * Trennt die WLAN-Verbindung und startet Hotspot + */ +esp_err_t wifi_manager_disconnect(void); + +/** + * Startet explizit den Hotspot-Modus + */ +esp_err_t wifi_manager_start_ap(void); + +/** + * Gibt den aktuellen Status zurück + */ +wifi_state_t wifi_manager_get_state(void); + +/** + * Gibt die aktuelle IP-Adresse zurück + */ +esp_err_t wifi_manager_get_ip(char* ip_str, size_t len); + +/** + * Scannt nach verfügbaren WLAN-Netzwerken + */ +esp_err_t wifi_manager_scan(void); + +/** + * Registriert einen Event-Callback + */ +esp_err_t wifi_manager_register_callback(wifi_event_callback_t callback); + +/** + * Gibt das netif Handle zurück + */ +esp_netif_t* wifi_manager_get_netif(void); + +#ifdef __cplusplus +} +#endif diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..17f9bd5 --- /dev/null +++ b/partitions.csv @@ -0,0 +1,6 @@ +# ESP32-S3 Bluetooth SIP Client Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x300000, +storage, data, spiffs, 0x310000, 0xF0000, diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..92e6d51 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,59 @@ +# ESP32-S3 Bluetooth SIP Client - Default Configuration + +# Target: ESP32-S3 +CONFIG_IDF_TARGET="esp32s3" + +# Flash Größe +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# PSRAM aktivieren (falls vorhanden auf DevKitC) +CONFIG_ESP32S3_SPIRAM_SUPPORT=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y + +# Bluetooth aktivieren +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=y +CONFIG_BT_CLASSIC_ENABLED=y +CONFIG_BT_A2DP_ENABLE=y +CONFIG_BT_HFP_ENABLE=y +CONFIG_BT_HFP_AG_ENABLE=y +CONFIG_BT_HFP_CLIENT_ENABLE=y +CONFIG_BT_SSP_ENABLED=y +CONFIG_BT_BLE_ENABLED=y + +# Bluetooth Controller +CONFIG_BTDM_CTRL_MODE_BTDM=y +CONFIG_BTDM_CTRL_HCI_MODE_VHCI=y + +# USB Host +CONFIG_USB_OTG_SUPPORTED=y +CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=1024 + +# WiFi +CONFIG_ESP_WIFI_SOFTAP_SUPPORT=y +CONFIG_ESP_WIFI_NVS_ENABLED=y + +# LWIP +CONFIG_LWIP_IPV4=y +CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES=y + +# HTTP Server +CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 +CONFIG_HTTPD_MAX_URI_LEN=512 + +# NVS +CONFIG_NVS_ENCRYPTION=n + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y +CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y + +# FreeRTOS +CONFIG_FREERTOS_HZ=1000 +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=3072 + +# Compiler Optimierung +CONFIG_COMPILER_OPTIMIZATION_PERF=y