From d67db6ee4708f2eb06e6cda6e3c9fe09ce9d3c36 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 00:33:33 -0500 Subject: [PATCH 01/16] impliment device registration --- cloud/main.go | 50 ++++---- dashboard/src/components/BlePairingDialog.js | 12 ++ dashboard/src/components/BlePairingFlow.js | 3 +- dashboard/src/components/ClaimCodePrompt.js | 126 +++++++++++++++++++ include/Cloud.h | 3 + src/BLE.cpp | 9 ++ src/Cloud.cpp | 10 +- src/Cloud_start.cpp | 8 +- src/WebSocketClient.cpp | 33 ++++- 9 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 dashboard/src/components/ClaimCodePrompt.js diff --git a/cloud/main.go b/cloud/main.go index 204b04c..8d8e280 100644 --- a/cloud/main.go +++ b/cloud/main.go @@ -736,33 +736,39 @@ func (dc *deviceConn) closeWithReason(code int, reason string) { } func (s *server) publicBase(r *http.Request) string { + var base string if strings.TrimSpace(s.publicBaseURL) != "" { - return strings.TrimRight(strings.TrimSpace(s.publicBaseURL), "/") - } - - // Infer from reverse-proxy headers when available. - proto := r.Header.Get("X-Forwarded-Proto") - if proto == "" { - if r.TLS != nil { - proto = "https" - } else { - proto = "http" + base = strings.TrimRight(strings.TrimSpace(s.publicBaseURL), "/") + } else { + // Infer from reverse-proxy headers when available. + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "https" // Force HTTPS even if not detected + } } - } - host := r.Header.Get("X-Forwarded-Host") - if host == "" { - host = r.Host + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + base = proto + "://" + host } - // Convert http(s) -> ws(s) for convenience. - switch strings.ToLower(proto) { - case "https": - return "wss://" + host - case "http": - return "ws://" + host - default: - return "ws://" + host + // Convert https:// -> wss:// for WebSocket URLs (only support secure connections) + if strings.HasPrefix(base, "https://") { + return "wss://" + strings.TrimPrefix(base, "https://") + } + + // If someone configured http://, reject it - we only support secure connections + if strings.HasPrefix(base, "http://") { + // Log a warning but still upgrade to wss for security + return "wss://" + strings.TrimPrefix(base, "http://") } + + // Already wss:// or unknown format + return base } func authOK(r *http.Request, token string) bool { diff --git a/dashboard/src/components/BlePairingDialog.js b/dashboard/src/components/BlePairingDialog.js index 5d3265c..74a73d5 100644 --- a/dashboard/src/components/BlePairingDialog.js +++ b/dashboard/src/components/BlePairingDialog.js @@ -184,6 +184,17 @@ export default function BlePairingDialog({ const deviceId = identity?.hostname || identity?.deviceId || dev?.name || dev?.id; const id = String(deviceId); + + // Extract cloud configuration from device identity + const cloudConfig = identity?.cloud?.enabled + ? { + enabled: true, + baseUrl: identity.cloud.baseUrl, + tunnel: identity.cloud.tunnel, + wsUrl: identity.cloud.wsUrl || null, // Full UI WebSocket URL from device + } + : { enabled: false }; + const record = { id, name: @@ -191,6 +202,7 @@ export default function BlePairingDialog({ hostname: identity?.hostname || null, deviceId: String(deviceId), authToken: identity?.token || null, + cloudTunnel: cloudConfig, lastSeenAtMs: Date.now(), }; diff --git a/dashboard/src/components/BlePairingFlow.js b/dashboard/src/components/BlePairingFlow.js index d71b750..1ec1deb 100644 --- a/dashboard/src/components/BlePairingFlow.js +++ b/dashboard/src/components/BlePairingFlow.js @@ -201,12 +201,13 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { selectedDevice?.id; const id = String(deviceId); - // Use cloud config from device if available, otherwise disable + // Use cloud config from device if available const cloudConfig = deviceIdentity?.cloud?.enabled ? { enabled: true, baseUrl: deviceIdentity.cloud.baseUrl, tunnel: deviceIdentity.cloud.tunnel, + wsUrl: deviceIdentity.cloud.wsUrl || null, // Full UI WebSocket URL from device } : { enabled: false }; diff --git a/dashboard/src/components/ClaimCodePrompt.js b/dashboard/src/components/ClaimCodePrompt.js new file mode 100644 index 0000000..0283ffd --- /dev/null +++ b/dashboard/src/components/ClaimCodePrompt.js @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Alert, + Box, + Typography, +} from "@mui/material"; +import { redeemClaimCode } from "../utils/apiUtils"; + +/** + * Prompts user to enter the claim code displayed on their device + * after BLE pairing, so we can get the cloud tunnel WebSocket URL. + */ +export default function ClaimCodePrompt({ open, device, onSuccess, onSkip }) { + const [code, setCode] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const handleSubmit = async () => { + if (!code.trim()) { + setError("Please enter a claim code"); + return; + } + + setBusy(true); + setError(""); + + try { + const cloudBaseUrl = + device?.cloudTunnel?.baseUrl || "https://cloud.espwifi.io"; + const tunnel = device?.cloudTunnel?.tunnel || "ws_control"; + + const result = await redeemClaimCode( + code.trim().toUpperCase(), + cloudBaseUrl, + tunnel + ); + + if (!result?.ui_ws_url) { + throw new Error("Invalid response from cloud broker"); + } + + // Update device record with cloud tunnel info + const updatedDevice = { + ...device, + cloudTunnel: { + enabled: true, + baseUrl: cloudBaseUrl, + wsUrl: result.ui_ws_url, + authToken: result.ui_auth_token, + tunnel, + needsClaim: false, + }, + }; + + onSuccess?.(updatedDevice); + } catch (err) { + console.error("[ClaimCodePrompt] Error redeeming claim code:", err); + setError(err.message || "Failed to redeem claim code"); + } finally { + setBusy(false); + } + }; + + const handleSkip = () => { + onSkip?.(); + }; + + return ( + + + Enter Device Claim Code + + + + + Your device has been paired via Bluetooth and is now connecting to + the cloud. To access it remotely, please enter the 6-character claim + code displayed on your device's screen or serial output. + + + + The claim code allows you to securely connect to your device through + the cloud tunnel. If you don't see a claim code, check your device's + serial monitor or OLED display. + + + setCode(e.target.value.toUpperCase())} + placeholder="ABC123" + autoComplete="off" + inputProps={{ maxLength: 6 }} + disabled={busy} + error={!!error} + helperText={error || "6-character alphanumeric code"} + autoFocus + onKeyPress={(e) => { + if (e.key === "Enter") { + handleSubmit(); + } + }} + /> + + + + + + + + ); +} diff --git a/include/Cloud.h b/include/Cloud.h index a802771..d3143f5 100644 --- a/include/Cloud.h +++ b/include/Cloud.h @@ -55,6 +55,8 @@ class Cloud { const char *getDeviceId() const { return config_.deviceId; } const char *getTunnel() const { return config_.tunnel; } const char *getClaimCode() const { return claimCode_; } + const char *getUiWebSocketUrl() const { return uiWsUrl_; } + bool isRegistered() const { return registered_; } private: Config config_; @@ -63,6 +65,7 @@ class Cloud { char wsUrl_[512] = {0}; // Increased from 256 to 512 for longer URLs char claimCode_[16] = {0}; + char uiWsUrl_[512] = {0}; // UI WebSocket URL from cloud broker bool registered_ = false; // WebSocket event handlers diff --git a/src/BLE.cpp b/src/BLE.cpp index 9d67902..95d8061 100644 --- a/src/BLE.cpp +++ b/src/BLE.cpp @@ -317,6 +317,15 @@ void ESPWiFi::startBLEServices() { espwifi->config["cloud"]["baseUrl"].as(); resp["cloud"]["tunnel"] = espwifi->config["cloud"]["tunnel"].as(); + + // Include UI WebSocket URL if device is registered with cloud + if (espwifi->cloud.isConnected() && + espwifi->cloud.isRegistered()) { + const char *uiWsUrl = espwifi->cloud.getUiWebSocketUrl(); + if (uiWsUrl && uiWsUrl[0] != '\0') { + resp["cloud"]["wsUrl"] = uiWsUrl; + } + } } else { resp["cloud"]["enabled"] = false; } diff --git a/src/Cloud.cpp b/src/Cloud.cpp index b07b24a..4cdc3ee 100644 --- a/src/Cloud.cpp +++ b/src/Cloud.cpp @@ -143,8 +143,16 @@ void Cloud::handleMessage(const uint8_t *data, size_t len, bool isBinary) { // Handle cloud-specific messages if (strcmp(type, "registered") == 0) { registered_ = true; + + // Store UI WebSocket URL from broker + const char *uiUrl = doc["ui_ws_url"]; + if (uiUrl) { + strncpy(uiWsUrl_, uiUrl, sizeof(uiWsUrl_) - 1); + uiWsUrl_[sizeof(uiWsUrl_) - 1] = '\0'; + } + ESP_LOGI(TAG, "Device registered with cloud"); - ESP_LOGI(TAG, "UI WebSocket URL: %s", doc["ui_ws_url"].as()); + ESP_LOGI(TAG, "UI WebSocket URL: %s", uiWsUrl_); ESP_LOGI(TAG, "Share claim code with users: %s", claimCode_); return; } diff --git a/src/Cloud_start.cpp b/src/Cloud_start.cpp index d7c8f4a..91db6dd 100644 --- a/src/Cloud_start.cpp +++ b/src/Cloud_start.cpp @@ -11,11 +11,17 @@ void ESPWiFi::startCloud() { // Get configuration const char *baseUrl = config["cloud"]["baseUrl"]; - const char *deviceId = config["cloud"]["deviceId"]; const char *tunnel = config["cloud"]["tunnel"]; bool autoReconnect = config["cloud"]["autoReconnect"]; uint32_t reconnectDelay = config["cloud"]["reconnectDelay"]; + // Use current hostname as device ID (override config if needed) + std::string hostname = config["hostname"].as(); + if (hostname.empty()) { + hostname = genHostname(); + } + const char *deviceId = hostname.c_str(); + // Get auth token from device config (used to authenticate UI connections) const char *authToken = config["auth"]["token"] | nullptr; diff --git a/src/WebSocketClient.cpp b/src/WebSocketClient.cpp index 2a7dc2c..dd96360 100644 --- a/src/WebSocketClient.cpp +++ b/src/WebSocketClient.cpp @@ -3,6 +3,7 @@ #include #include +#include "esp_crt_bundle.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -146,13 +147,33 @@ bool WebSocketClient::begin(const Config &config) { wsConfig.headers = authHeader_; } - // TLS configuration - if (config.certPem) { - wsConfig.cert_pem = config.certPem; - } + // TLS configuration for wss:// connections + if (strncmp(config.uri, "wss://", 6) == 0) { + wsConfig.transport = WEBSOCKET_TRANSPORT_OVER_SSL; + + if (config.certPem) { + // Use provided certificate + wsConfig.cert_pem = config.certPem; + } else { + // Use ESP-IDF's built-in certificate bundle for verification + // This includes common CA certificates for HTTPS + wsConfig.crt_bundle_attach = esp_crt_bundle_attach; + wsConfig.skip_cert_common_name_check = false; + } - if (config.disableCertVerify) { - wsConfig.skip_cert_common_name_check = true; + // Allow override to disable certificate verification + if (config.disableCertVerify) { + wsConfig.skip_cert_common_name_check = true; + wsConfig.crt_bundle_attach = NULL; + } + } else { + // Non-TLS connections + if (config.certPem) { + wsConfig.cert_pem = config.certPem; + } + if (config.disableCertVerify) { + wsConfig.skip_cert_common_name_check = true; + } } // Buffer size From e690916c1f95ad662437e61297d710b92a3ff6b5 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 00:45:02 -0500 Subject: [PATCH 02/16] ui uri return --- dashboard/.dockerignore | 49 ++++++++++++++--------- dashboard/{.env.local => .env.local.save} | 0 dashboard/src/utils/connectionUtils.js | 15 ++----- 3 files changed, 33 insertions(+), 31 deletions(-) rename dashboard/{.env.local => .env.local.save} (100%) diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore index 5c00871..fdd5459 100644 --- a/dashboard/.dockerignore +++ b/dashboard/.dockerignore @@ -1,30 +1,39 @@ -# Dependencies -node_modules -npm-debug.log -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* +# Local development files - should not be in Docker images +.env.local +.env.development.local +.env.test.local +.env.production.local -# Build outputs +# Development artifacts +node_modules build dist - -# CRA/webpack caches .cache -# Local env files (keep these out of images) -.env* - -# Allow a single optional build-time env file if you choose to use it. -# (Better practice is passing build args / CI secrets, but this enables local builds.) -!.env.prod +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ -# OS / editor noise +# OS files .DS_Store -.idea -.vscode +Thumbs.db -# If you run dashboard builds that output to the firmware folder -../data +# Git +.git +.gitignore +# Testing +coverage +.nyc_output + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +# Misc +*.log diff --git a/dashboard/.env.local b/dashboard/.env.local.save similarity index 100% rename from dashboard/.env.local rename to dashboard/.env.local.save diff --git a/dashboard/src/utils/connectionUtils.js b/dashboard/src/utils/connectionUtils.js index 60214bb..b60f8a8 100644 --- a/dashboard/src/utils/connectionUtils.js +++ b/dashboard/src/utils/connectionUtils.js @@ -103,19 +103,12 @@ export function resolveWebSocketUrl(endpoint, config, opts = {}) { const preferTunnel = opts.preferTunnel !== false; // default true - if (preferTunnel && isTunnelReady(config)) { - const baseUrl = config?.cloudTunnel?.baseUrl || ""; - const deviceId = config?.hostname || config?.deviceName || ""; - const token = config?.auth?.token || getAuthToken() || ""; - return buildTunnelWsUrl({ - baseUrl, - deviceId, - tunnelKey: def.tunnelKey, - token, - }); + // If device provided a cloud tunnel URL, use it + if (preferTunnel && config?.cloudTunnel?.wsUrl) { + return config.cloudTunnel.wsUrl; } - // LAN/local WS + // Otherwise, use local/LAN WebSocket const mdnsHostname = config?.hostname || config?.deviceName || null; return buildWebSocketUrl(def.localPath, mdnsHostname); } From 66694c5c3d67ee5594b6c17d6cc653412c2e5a10 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 01:52:14 -0500 Subject: [PATCH 03/16] updated claim process and token generation --- dashboard/src/components/BlePairingFlow.js | 240 +++++++++++++++++- dashboard/src/components/ClaimCodeEntry.js | 118 ++------- ...loudTunnelInfoCard.js => CloudInfoCard.js} | 85 ++----- .../deviceSettings/DeviceSettingsInfoTab.js | 6 +- dashboard/src/utils/connectionUtils.js | 22 +- include/ESPWiFi.h | 1 + src/BLE.cpp | 36 +++ src/Cloud.cpp | 59 ++--- src/Cloud_start.cpp | 16 +- src/ControlSocket.cpp | 51 ++++ src/DefaultConfig.cpp | 2 + src/Info.cpp | 27 +- src/Log.cpp | 15 +- src/WebSocketClient.cpp | 4 + 14 files changed, 470 insertions(+), 212 deletions(-) rename dashboard/src/components/deviceSettings/{CloudTunnelInfoCard.js => CloudInfoCard.js} (79%) diff --git a/dashboard/src/components/BlePairingFlow.js b/dashboard/src/components/BlePairingFlow.js index 1ec1deb..275f1c0 100644 --- a/dashboard/src/components/BlePairingFlow.js +++ b/dashboard/src/components/BlePairingFlow.js @@ -16,6 +16,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import WifiIcon from "@mui/icons-material/Wifi"; import Visibility from "@mui/icons-material/Visibility"; import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import ClaimCodeEntry from "./ClaimCodeEntry"; const DIS_UUID = 0x180a; const CONTROL_UUID = 0xfff1; @@ -65,11 +66,14 @@ async function writeJsonAndRead(characteristic, obj) { */ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { const supported = useMemo(() => hasWebBluetooth(), []); - const [phase, setPhase] = useState("idle"); // idle | scanning | configuring | done + const [phase, setPhase] = useState("idle"); // idle | scanning | configuring | quick_connect | done const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [statusText, setStatusText] = useState(""); + // Claim code entry dialog + const [showClaimEntry, setShowClaimEntry] = useState(false); + // Device info after selection const [selectedDevice, setSelectedDevice] = useState(null); const [deviceIdentity, setDeviceIdentity] = useState(null); @@ -111,7 +115,18 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { setSelectedDevice(dev); setDeviceIdentity(identity); setBleControl(control); - setPhase("configuring"); + + // Check if device is already connected to WiFi and registered with cloud + const hasCloudUrl = identity?.cloud?.enabled && identity?.cloud?.wsUrl; + + if (hasCloudUrl) { + // Device is already configured and cloud-connected - offer quick connect + setPhase("quick_connect"); + } else { + // Device needs WiFi configuration + setPhase("configuring"); + } + setStatusText(""); setBusy(false); } catch (e) { @@ -270,6 +285,80 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { } }; + const handleQuickConnect = async () => { + setError(""); + setBusy(true); + setStatusText("Saving device to your dashboard..."); + + try { + // Close BLE connection + try { + selectedDevice?.gatt?.disconnect?.(); + } catch { + // ignore + } + + // Create device record with cloud info + const deviceId = + deviceIdentity?.hostname || + deviceIdentity?.deviceId || + selectedDevice?.name || + selectedDevice?.id; + const id = String(deviceId); + + const cloudConfig = { + enabled: true, + baseUrl: deviceIdentity.cloud.baseUrl, + tunnel: deviceIdentity.cloud.tunnel, + wsUrl: deviceIdentity.cloud.wsUrl, + }; + + const record = { + id, + name: deviceIdentity?.deviceName || selectedDevice?.name || String(id), + hostname: deviceIdentity?.hostname || null, + deviceId: String(deviceId), + authToken: deviceIdentity?.token || null, + cloudTunnel: cloudConfig, + lastSeenAtMs: Date.now(), + }; + + setPhase("done"); + setStatusText("Device added successfully!"); + setBusy(false); + + // Notify parent + setTimeout(() => { + onDeviceProvisioned?.(record, { + identity: deviceIdentity, + }); + }, 1000); + } catch (e) { + try { + selectedDevice?.gatt?.disconnect?.(); + } catch { + // ignore + } + setError(e?.message || String(e)); + setBusy(false); + setStatusText(""); + } + }; + + const handleClaimCodeSuccess = (deviceRecord) => { + console.log("[BlePairingFlow] Device claimed:", deviceRecord); + setShowClaimEntry(false); + + // Notify parent and close + onDeviceProvisioned?.(deviceRecord, { + viaClaim: true, + }); + + // Show success message + setPhase("done"); + setStatusText("Device claimed successfully!"); + }; + return ( )} - {phase === "configuring" && ( + {(phase === "configuring" || phase === "quick_connect") && ( @@ -326,6 +415,7 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { {phase === "idle" && "Welcome to ESPWiFi"} {phase === "scanning" && "Selecting Device"} {phase === "configuring" && "Configure WiFi"} + {phase === "quick_connect" && "Device Already Configured"} {phase === "done" && "Setup Complete!"} @@ -336,9 +426,8 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { variant="body1" sx={{ textAlign: "center", opacity: 0.9, maxWidth: "400px" }} > - Let's get started by connecting to your ESPWiFi device. We'll scan - for nearby devices using Bluetooth, then configure WiFi - credentials. + Connect to your ESPWiFi device using Bluetooth for initial setup, + or enter a claim code to pair an already-configured device. - Scan for ESPWiFi Devices + Scan for Devices + + + {onClose && ( + + )} + - Requirements: + Bluetooth pairing requirements:
  • ESPWiFi device powered on and in AP mode
  • @@ -476,6 +596,85 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { )} + {/* QUICK CONNECT PHASE - Device already configured */} + {phase === "quick_connect" && ( + <> + + + Device found:{" "} + {deviceIdentity?.deviceName || + selectedDevice?.name || + "Unknown"} + + + + + + This device is already connected to WiFi and registered with the + cloud. + + + + + Would you like to add this device to your dashboard and connect + via cloud? + + + + + + + + + + + )} + {/* DONE PHASE - Success message */} {phase === "done" && ( <> @@ -486,6 +685,23 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { Your ESPWiFi device has been configured and is connecting to your WiFi network.
    + + {onClose && ( + + )} )} @@ -521,6 +737,14 @@ export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { Once configured, your device will connect to WiFi. )} + + {/* Claim Code Entry Dialog */} + setShowClaimEntry(false)} + onDeviceClaimed={handleClaimCodeSuccess} + existingDevices={[]} + />
    ); } diff --git a/dashboard/src/components/ClaimCodeEntry.js b/dashboard/src/components/ClaimCodeEntry.js index 7fb73c5..42f3e8a 100644 --- a/dashboard/src/components/ClaimCodeEntry.js +++ b/dashboard/src/components/ClaimCodeEntry.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Dialog, DialogTitle, @@ -12,7 +12,6 @@ import { CircularProgress, IconButton, } from "@mui/material"; -import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner"; import CloseIcon from "@mui/icons-material/Close"; import { redeemClaimCode } from "../utils/apiUtils"; @@ -21,7 +20,6 @@ import { redeemClaimCode } from "../utils/apiUtils"; * * Supports: * - Manual entry of 6-character claim code - * - QR code scanning (if browser supports it) * - Automatic device pairing via cloud tunnel */ export default function ClaimCodeEntry({ @@ -33,33 +31,12 @@ export default function ClaimCodeEntry({ const [claimCode, setClaimCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [showScanner, setShowScanner] = useState(false); - const [hasCamera, setHasCamera] = useState(false); // Get cloud base URL from an existing device, or use default const cloudBaseUrl = existingDevices.find((d) => d?.cloudTunnel?.baseUrl)?.cloudTunnel ?.baseUrl || "https://cloud.espwifi.io"; - // Check for camera/QR scanner support - useEffect(() => { - if (!open) return; - - // Check if browser supports getUserMedia (camera access) - const checkCamera = async () => { - try { - if (navigator.mediaDevices?.getUserMedia) { - // Don't actually request camera yet, just check if API exists - setHasCamera(true); - } - } catch (e) { - setHasCamera(false); - } - }; - - checkCamera(); - }, [open]); - const handleSubmit = async () => { setError(""); @@ -118,19 +95,10 @@ export default function ClaimCodeEntry({ } }; - const handleScanQR = () => { - // TODO: Implement QR code scanner - // This would use the browser's camera to scan a QR code containing the claim code - // For now, show a placeholder message - setShowScanner(true); - setError("QR scanner not yet implemented. Please enter code manually."); - }; - const handleClose = () => { setClaimCode(""); setError(""); setBusy(false); - setShowScanner(false); onClose?.(); }; @@ -158,64 +126,32 @@ export default function ClaimCodeEntry({ pair it for remote access via the cloud. - {showScanner ? ( - - - QR Scanner placeholder - - - ) : ( - <> - { - // Auto-uppercase and limit to 6 characters - const val = e.target.value - .toUpperCase() - .replace(/[^A-Z0-9]/g, ""); - setClaimCode(val.slice(0, 6)); - }} - placeholder="ABC123" - autoComplete="off" - fullWidth - autoFocus - disabled={busy} - inputProps={{ - maxLength: 6, - style: { - textTransform: "uppercase", - letterSpacing: "0.1em", - fontSize: "1.5rem", - textAlign: "center", - }, - }} - helperText="6 alphanumeric characters (e.g., ABC123)" - /> - - {hasCamera && ( - - )} - - )} + { + // Auto-uppercase and limit to 6 characters + const val = e.target.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); + setClaimCode(val.slice(0, 6)); + }} + placeholder="ABC123" + autoComplete="off" + fullWidth + autoFocus + disabled={busy} + inputProps={{ + maxLength: 6, + style: { + textTransform: "uppercase", + letterSpacing: "0.1em", + fontSize: "1.5rem", + textAlign: "center", + }, + }} + helperText="6 alphanumeric characters (e.g., ABC123)" + /> {busy && ( diff --git a/dashboard/src/components/deviceSettings/CloudTunnelInfoCard.js b/dashboard/src/components/deviceSettings/CloudInfoCard.js similarity index 79% rename from dashboard/src/components/deviceSettings/CloudTunnelInfoCard.js rename to dashboard/src/components/deviceSettings/CloudInfoCard.js index d37fbbb..8f0aadd 100644 --- a/dashboard/src/components/deviceSettings/CloudTunnelInfoCard.js +++ b/dashboard/src/components/deviceSettings/CloudInfoCard.js @@ -1,8 +1,8 @@ /** - * @file CloudTunnelInfoCard.js - * @brief Cloud tunnel settings + status card + * @file CloudInfoCard.js + * @brief Cloud settings + status card * - * Shows cloud tunnel configuration and runtime connection status as reported by + * Shows cloud configuration and runtime connection status as reported by * /api/info, and allows enabling/disabling the feature. */ @@ -18,7 +18,6 @@ import { FormControlLabel, IconButton, Switch, - TextField, Tooltip, Typography, } from "@mui/material"; @@ -32,20 +31,17 @@ import MaskedValueField from "../common/MaskedValueField"; import { safeGetItem } from "../../utils/storageUtils"; import QRCode from "qrcode"; -export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { +export default function CloudInfoCard({ config, deviceInfo, onSave }) { const [isEditing, setIsEditing] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); const [qrOpen, setQrOpen] = useState(false); const [tempEnabled, setTempEnabled] = useState(false); - const [tempMaxFps, setTempMaxFps] = useState(0); useEffect(() => { - setTempEnabled(Boolean(config?.cloudTunnel?.enabled)); - const v = config?.cloudTunnel?.maxFps; - setTempMaxFps(typeof v === "number" ? v : parseInt(v || 0, 10) || 0); - }, [config?.cloudTunnel?.enabled, config?.cloudTunnel?.maxFps]); + setTempEnabled(Boolean(config?.cloud?.enabled)); + }, [config?.cloud?.enabled]); - const info = deviceInfo?.cloudTunnel || {}; + const info = deviceInfo?.cloud || {}; const endpoints = info?.endpoints || {}; const pairing = deviceInfo?.pairing || {}; const claimCode = String(pairing?.claim_code || "").trim(); @@ -82,17 +78,16 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { const handleSave = () => { const next = { - cloudTunnel: { - ...(config?.cloudTunnel || {}), + cloud: { + ...(config?.cloud || {}), enabled: tempEnabled, - maxFps: Number.isFinite(tempMaxFps) ? tempMaxFps : 0, - // SCT toggle = tunnel all available websockets. + // Cloud toggle = tunnel all available websockets. tunnelAll: tempEnabled ? true : false, - // Allow non-control tunnels when SCT is enabled. + // Allow non-control tunnels when cloud is enabled. }, }; - // If SCT is enabled and the device has a camera, enable it so /ws/camera can tunnel. + // If cloud is enabled and the device has a camera, enable it so /ws/camera can tunnel. if (tempEnabled && config?.camera?.installed !== false) { next.camera = { ...(config?.camera || {}), enabled: true }; } @@ -102,9 +97,7 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { }; const handleCancel = () => { - setTempEnabled(Boolean(config?.cloudTunnel?.enabled)); - const v = config?.cloudTunnel?.maxFps; - setTempMaxFps(typeof v === "number" ? v : parseInt(v || 0, 10) || 0); + setTempEnabled(Boolean(config?.cloud?.enabled)); setIsEditing(false); }; @@ -154,7 +147,7 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { - {(info?.enabled || config?.cloudTunnel?.enabled) && ( - - )} {Object.keys(endpoints || {}).length > 0 && ( {Object.entries(endpoints).map(([name, ep]) => { @@ -291,21 +274,7 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { color="primary" /> } - label="Enable Cloud Tunnel" - /> - - { - const n = parseInt(e.target.value || "0", 10); - setTempMaxFps(Number.isFinite(n) ? n : 0); - }} - helperText="Applies only when viewing camera via cloud tunnel with no LAN clients. 0 = no cap." - inputProps={{ min: 0, step: 1 }} + label="Enable Cloud" /> @@ -332,10 +301,10 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { return ( <> + setAboutOpen(true)}> @@ -392,21 +361,19 @@ export default function CloudTunnelInfoCard({ config, deviceInfo, onSave }) { maxWidth="sm" fullWidth > - ESPWiFi Secure Cloud Tunnel + ESPWiFi Cloud - ESPWiFi Secure Cloud Tunnel keeps an outbound WebSocket connection - from the device to wss://tnl.espwifi.io Secure Cloud Tunnel server - so the dashboard can reach the device’s WebSocket endpoints over the - public internet (no VPN or port-forwarding required). This is a - secure and private connection and provides authentication and - encryption. + ESPWiFi Cloud keeps an outbound WebSocket connection from the device + to wss://tnl.espwifi.io cloud server so the dashboard can reach the + device's WebSocket endpoints over the public internet (no VPN or + port-forwarding required). This is a secure and private connection + and provides authentication and encryption. - The cloud tunnel server is hosted by ESPWiFi.io and is free to use. - It is not required to use the cloud tunnel, but it is recommended if - you want to access the device’s WebSocket endpoints over the public - internet. + The cloud server is hosted by ESPWiFi.io and is free to use. It is + not required to use the cloud, but it is recommended if you want to + access the device's WebSocket endpoints over the public internet. diff --git a/dashboard/src/components/deviceSettings/DeviceSettingsInfoTab.js b/dashboard/src/components/deviceSettings/DeviceSettingsInfoTab.js index 3f21960..e5261c3 100644 --- a/dashboard/src/components/deviceSettings/DeviceSettingsInfoTab.js +++ b/dashboard/src/components/deviceSettings/DeviceSettingsInfoTab.js @@ -14,7 +14,7 @@ import NetworkInfoCard from "./NetworkInfoCard"; import ChipInfoCard from "./ChipInfoCard"; import WifiConfigInfoCard from "./WifiConfigInfoCard"; import AuthInfoCard from "./AuthInfoCard"; -import CloudTunnelInfoCard from "./CloudTunnelInfoCard"; +import CloudInfoCard from "./CloudInfoCard"; import MemoryInfoCard from "./MemoryInfoCard"; import StorageInfoCard from "./StorageInfoCard"; import WifiPowerInfoCard from "./WifiPowerInfoCard"; @@ -114,8 +114,8 @@ export default function DeviceSettingsInfoTab({ {/* Authentication */} - {/* Cloud Tunnel */} - int { + (void)conn_handle; + (void)attr_handle; + if (!ctxt || ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) { + return BLE_ATT_ERR_UNLIKELY; + } + + // Generate a fresh claim code valid for 10 minutes + std::string code = + espwifi->getClaimCode(true); // rotate=true for fresh code + uint32_t expiresInMs = espwifi->claimExpiresInMs(); + + espwifi->log( + INFO, "πŸ”΅ Claim Code Generated via BLE: %s (expires in %u seconds)", + code.c_str(), expiresInMs / 1000); + + // Return just the claim code (simple 8-char string) + return os_mbuf_append(ctxt->om, code.c_str(), code.size()) == 0 + ? 0 + : BLE_ATT_ERR_INSUFFICIENT_RES; + }); + // Optional: keep a custom control characteristic under DIS for provisioning. (void)registerBleCharacteristic16( 0x180A, GattServices::controlCharUUID.value, @@ -310,6 +338,14 @@ void ESPWiFi::startBLEServices() { const esp_app_desc_t *app = esp_app_get_description(); resp["fw"] = (app && app->version[0] != '\0') ? app->version : espwifi->version(); + + // Include auth token if available + const char *authToken = + espwifi->config["auth"]["token"].as(); + if (authToken && authToken[0] != '\0') { + resp["token"] = authToken; + } + // Include cloud configuration for dashboard pairing if (espwifi->config["cloud"]["enabled"] | false) { resp["cloud"]["enabled"] = true; diff --git a/src/Cloud.cpp b/src/Cloud.cpp index 4cdc3ee..b121178 100644 --- a/src/Cloud.cpp +++ b/src/Cloud.cpp @@ -132,43 +132,44 @@ void Cloud::handleMessage(const uint8_t *data, size_t len, bool isBinary) { return; } + // Check if message has a 'type' field (cloud control messages) const char *type = doc["type"]; - if (type == nullptr) { - ESP_LOGW(TAG, "Message missing 'type' field"); - return; - } - - ESP_LOGD(TAG, "Received message type: %s", type); - // Handle cloud-specific messages - if (strcmp(type, "registered") == 0) { - registered_ = true; - - // Store UI WebSocket URL from broker - const char *uiUrl = doc["ui_ws_url"]; - if (uiUrl) { - strncpy(uiWsUrl_, uiUrl, sizeof(uiWsUrl_) - 1); - uiWsUrl_[sizeof(uiWsUrl_) - 1] = '\0'; + // Handle cloud-specific messages (messages with 'type' field) + if (type != nullptr) { + if (strcmp(type, "registered") == 0) { + registered_ = true; + + // Store UI WebSocket URL from broker + const char *uiUrl = doc["ui_ws_url"]; + if (uiUrl) { + strncpy(uiWsUrl_, uiUrl, sizeof(uiWsUrl_) - 1); + uiWsUrl_[sizeof(uiWsUrl_) - 1] = '\0'; + } + + ESP_LOGI(TAG, "Device registered with cloud"); + ESP_LOGI(TAG, "UI WebSocket URL: %s", uiWsUrl_); + ESP_LOGI(TAG, "Share claim code with users: %s", claimCode_); + return; } - ESP_LOGI(TAG, "Device registered with cloud"); - ESP_LOGI(TAG, "UI WebSocket URL: %s", uiWsUrl_); - ESP_LOGI(TAG, "Share claim code with users: %s", claimCode_); - return; - } - - if (strcmp(type, "ui_connected") == 0) { - ESP_LOGI(TAG, "UI client connected"); - // Optionally notify application that UI is connected - } + if (strcmp(type, "ui_connected") == 0) { + ESP_LOGI(TAG, "UI client connected"); + // Optionally notify application that UI is connected + return; + } - if (strcmp(type, "ui_disconnected") == 0) { - ESP_LOGI(TAG, "UI client disconnected"); - // Optionally notify application that UI is disconnected + if (strcmp(type, "ui_disconnected") == 0) { + ESP_LOGI(TAG, "UI client disconnected"); + // Optionally notify application that UI is disconnected + return; + } } - // Forward all messages to application handler + // Forward all device control messages (with or without 'type') to application + // handler These are standard device commands like get_rssi, list_files, etc. if (onMessage_) { + ESP_LOGD(TAG, "Forwarding message to application handler"); onMessage_(doc); } } diff --git a/src/Cloud_start.cpp b/src/Cloud_start.cpp index 91db6dd..52c0110 100644 --- a/src/Cloud_start.cpp +++ b/src/Cloud_start.cpp @@ -55,13 +55,17 @@ void ESPWiFi::startCloud() { return; } - // Forward all other messages to local control socket handler - // This allows cloud UI to use same commands as local UI - log(DEBUG, "☁️ Received message from cloud UI: %s", type ? type : "unknown"); + // Forward all other messages (device control commands) to the processing + // logic This allows cloud UI to use same commands as local UI + const char *cmd = message["cmd"]; + log(DEBUG, "☁️ Processing cloud message: cmd=%s", cmd ? cmd : "(none)"); - // Note: We need to bridge messages between cloud and local control socket - // For now, just log them. Full integration requires bidirectional - // forwarding. + // Process the command using the same logic as local WebSocket + JsonDocument response; + handleCloudControlMessage(message, response); + + // Send response back through cloud tunnel + cloud.sendMessage(response); }); // Initialize and connect diff --git a/src/ControlSocket.cpp b/src/ControlSocket.cpp index c089c3d..2910b1b 100644 --- a/src/ControlSocket.cpp +++ b/src/ControlSocket.cpp @@ -328,3 +328,54 @@ void ESPWiFi::startControlWebSocket() { log(INFO, "πŸŽ›οΈ Control WebSocket started: /ws/control"); #endif } + +// Handle control messages from cloud tunnel +// This processes the same commands as the local WebSocket but sends responses +// back via cloud +void ESPWiFi::handleCloudControlMessage(const JsonDocument &req, + JsonDocument &resp) { + // For now, just handle the most common commands + // TODO: Refactor ctrlOnMessage to share logic with this method + const char *cmd = req["cmd"] | ""; + resp["ok"] = true; + resp["cmd"] = cmd; + + if (strcmp(cmd, "ping") == 0) { + resp["type"] = "pong"; + } else if (strcmp(cmd, "get_rssi") == 0) { + wifi_ap_record_t ap_info; + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { + char ssid_str[33]; + memcpy(ssid_str, ap_info.ssid, 32); + ssid_str[32] = '\0'; + resp["connected"] = true; + resp["ssid"] = std::string(ssid_str); + resp["rssi"] = ap_info.rssi; + } else { + resp["connected"] = false; + resp["rssi"] = 0; + } + } else if (strcmp(cmd, "get_status") == 0) { + resp["ip"] = ipAddress(); + resp["hostname"] = config["hostname"].as(); + resp["wifiMode"] = config["wifi"]["mode"].as(); + } else if (strcmp(cmd, "get_info") == 0) { + JsonDocument infoDoc = buildInfoJson(false); + resp["info"] = infoDoc.as(); + } else if (strcmp(cmd, "list_files") == 0) { + std::string fs = req["fs"] | "lfs"; + std::string path = req["path"] | "/"; + std::string errorMsg; + + JsonDocument filesDoc; + if (listFiles(fs, path, filesDoc, errorMsg)) { + resp["files"] = filesDoc["files"]; + } else { + resp["ok"] = false; + resp["error"] = errorMsg; + } + } else { + resp["ok"] = false; + resp["error"] = "unknown_cmd"; + } +} diff --git a/src/DefaultConfig.cpp b/src/DefaultConfig.cpp index 2520626..2b2db8c 100644 --- a/src/DefaultConfig.cpp +++ b/src/DefaultConfig.cpp @@ -65,6 +65,8 @@ JsonDocument ESPWiFi::defaultConfig() { doc["auth"]["password"] = "admin"; // - username: username for authentication doc["auth"]["username"] = "admin"; + // - token: generated token for API/WebSocket authentication + doc["auth"]["token"] = generateToken(); // CORS (auth.cors) // - enabled: controls whether CORS headers are emitted diff --git a/src/Info.cpp b/src/Info.cpp index 0c905bf..ddc0843 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -47,20 +47,17 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { std::string deviceName = config["deviceName"].as(); jsonDoc["mdns"] = deviceName + ".local"; - // Cloud tunnel status (config + runtime) + // Cloud status (config + runtime) { - JsonObject ct = jsonDoc["cloudTunnel"].to(); - ct["enabled"] = config["cloudTunnel"]["enabled"].isNull() + JsonObject ct = jsonDoc["cloud"].to(); + ct["enabled"] = config["cloud"]["enabled"].isNull() ? false - : config["cloudTunnel"]["enabled"].as(); - const char *baseUrlC = config["cloudTunnel"]["baseUrl"].as(); + : config["cloud"]["enabled"].as(); + const char *baseUrlC = config["cloud"]["baseUrl"].as(); ct["baseUrl"] = (baseUrlC != nullptr) ? std::string(baseUrlC) : ""; - ct["tunnelAll"] = config["cloudTunnel"]["tunnelAll"].isNull() + ct["tunnelAll"] = config["cloud"]["tunnelAll"].isNull() ? false - : config["cloudTunnel"]["tunnelAll"].as(); - ct["maxFps"] = config["cloudTunnel"]["maxFps"].isNull() - ? 0 - : config["cloudTunnel"]["maxFps"].as(); + : config["cloud"]["tunnelAll"].as(); #ifdef CONFIG_HTTPD_WS_SUPPORT JsonObject endpoints = ct["endpoints"].to(); @@ -68,6 +65,16 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { JsonObject control = endpoints["control"].to(); control["uri"] = "/ws/control"; control["started"] = ctrlSocStarted; + + // Add cloud connection status and UI WebSocket URL + control["cloudEnabled"] = cloud.isConnected(); + control["cloudConnected"] = cloud.isConnected() && cloud.isRegistered(); + if (cloud.isConnected() && cloud.isRegistered()) { + const char *uiWsUrl = cloud.getUiWebSocketUrl(); + if (uiWsUrl && uiWsUrl[0] != '\0') { + control["ui_ws_url"] = std::string(uiWsUrl); + } + } #endif } diff --git a/src/Log.cpp b/src/Log.cpp index 904a783..caff466 100644 --- a/src/Log.cpp +++ b/src/Log.cpp @@ -96,6 +96,9 @@ static const char *espwifiIconForIdfTagView(const char *tag, size_t tagLen) { if (tagEquals(tag, tagLen, "esp-x509-crt-bundle")) { return "πŸ”"; } + if (tagEquals(tag, tagLen, "cloud") || tagEquals(tag, tagLen, "Cloud")) { + return "☁️"; + } return ""; } @@ -122,12 +125,14 @@ int ESPWiFi::idfLogVprintfHook(const char *format, va_list args) { (void)vsnprintf(line, sizeof(line), format, argsCopy); va_end(argsCopy); + // espwifi->writeLog(line); + // Format and log the IDF message - LogLevel lvl = DEBUG; - std::string formatted = espwifi->formatIDFtoESPWiFi(line, &lvl); - if (!formatted.empty()) { - espwifi->logImpl(lvl, formatted); - } + // LogLevel lvl = DEBUG; + // std::string formatted = espwifi->formatIDFtoESPWiFi(line, &lvl); + // if (!formatted.empty()) { + // espwifi->logImpl(lvl, formatted); + // } } return 0; diff --git a/src/WebSocketClient.cpp b/src/WebSocketClient.cpp index dd96360..984e6ad 100644 --- a/src/WebSocketClient.cpp +++ b/src/WebSocketClient.cpp @@ -179,6 +179,10 @@ bool WebSocketClient::begin(const Config &config) { // Buffer size wsConfig.buffer_size = config.bufferSize; + // Task stack size - increase to prevent stack overflow with TLS + wsConfig.task_stack = + 8192; // Default is usually 4096, double it for cloud connections + // Ping/Pong settings if (config.pingInterval > 0) { wsConfig.ping_interval_sec = config.pingInterval / 1000; From b0d12f7906e4a6ce5498866d9802c11452178561 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 02:01:31 -0500 Subject: [PATCH 04/16] cloud connection working --- src/Cloud_start.cpp | 2 +- src/ControlSocket.cpp | 8 ++++++++ src/Info.cpp | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Cloud_start.cpp b/src/Cloud_start.cpp index 52c0110..05e79a2 100644 --- a/src/Cloud_start.cpp +++ b/src/Cloud_start.cpp @@ -58,7 +58,7 @@ void ESPWiFi::startCloud() { // Forward all other messages (device control commands) to the processing // logic This allows cloud UI to use same commands as local UI const char *cmd = message["cmd"]; - log(DEBUG, "☁️ Processing cloud message: cmd=%s", cmd ? cmd : "(none)"); + log(VERBOSE, "☁️ Processing cloud message: cmd=%s", cmd ? cmd : "(none)"); // Process the command using the same logic as local WebSocket JsonDocument response; diff --git a/src/ControlSocket.cpp b/src/ControlSocket.cpp index 2910b1b..13d3c24 100644 --- a/src/ControlSocket.cpp +++ b/src/ControlSocket.cpp @@ -42,6 +42,9 @@ static void ctrlOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, resp["wifiMode"] = espwifi->config["wifi"]["mode"].as(); } else if (strcmp(cmd, "get_config") == 0) { resp["config"] = espwifi->config; + // Add claim code info + resp["cloud_claim_code"] = espwifi->getClaimCode(false); + resp["cloud_claim_expires_in_ms"] = espwifi->claimExpiresInMs(); } else if (strcmp(cmd, "get_info") == 0) { JsonDocument infoDoc = espwifi->buildInfoJson(false); resp["info"] = infoDoc.as(); @@ -359,6 +362,11 @@ void ESPWiFi::handleCloudControlMessage(const JsonDocument &req, resp["ip"] = ipAddress(); resp["hostname"] = config["hostname"].as(); resp["wifiMode"] = config["wifi"]["mode"].as(); + } else if (strcmp(cmd, "get_config") == 0) { + resp["config"] = config; + // Add claim code info + resp["cloud_claim_code"] = getClaimCode(false); + resp["cloud_claim_expires_in_ms"] = claimExpiresInMs(); } else if (strcmp(cmd, "get_info") == 0) { JsonDocument infoDoc = buildInfoJson(false); resp["info"] = infoDoc.as(); diff --git a/src/Info.cpp b/src/Info.cpp index ddc0843..6fbda8c 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -59,6 +59,10 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { ? false : config["cloud"]["tunnelAll"].as(); + // Claim info + ct["claimCode"] = getClaimCode(false); + ct["claimExpiresInMs"] = claimExpiresInMs(); + #ifdef CONFIG_HTTPD_WS_SUPPORT JsonObject endpoints = ct["endpoints"].to(); From 0d08de12490f4805a7863f113a030b5db8ab85f2 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 02:46:59 -0500 Subject: [PATCH 05/16] ws-ssl --- include/Cloud.h | 40 ++--- include/CloudCtl.h | 36 +++++ include/CloudMedia.h | 26 +++ include/ESPWiFi.h | 10 +- src/BLE.cpp | 6 +- src/Camera.cpp | 19 ++- src/Cloud.cpp | 171 +++++++------------- src/CloudCtl.cpp | 71 ++++++++ src/{Cloud_start.cpp => CloudCtl_start.cpp} | 10 +- src/CloudMedia.cpp | 40 +++++ src/CloudMedia_start.cpp | 67 ++++++++ src/DefaultConfig.cpp | 2 + src/ESPWiFi.cpp | 3 +- src/Info.cpp | 25 ++- 14 files changed, 368 insertions(+), 158 deletions(-) create mode 100644 include/CloudCtl.h create mode 100644 include/CloudMedia.h create mode 100644 src/CloudCtl.cpp rename src/{Cloud_start.cpp => CloudCtl_start.cpp} (92%) create mode 100644 src/CloudMedia.cpp create mode 100644 src/CloudMedia_start.cpp diff --git a/include/Cloud.h b/include/Cloud.h index d3143f5..99a1b59 100644 --- a/include/Cloud.h +++ b/include/Cloud.h @@ -6,14 +6,12 @@ #include /** - * @brief Cloud - Connects ESP32 devices to ESPWiFi Cloud + * @brief Cloud - Base class for ESP32 cloud connections to ESPWiFi Cloud * (cloud.espwifi.io) * - * Establishes a persistent WebSocket connection to the cloud broker, allowing: - * - Remote access to device from anywhere - * - Bidirectional messaging between UI and device - * - Automatic reconnection on network issues - * - Device registration and claiming + * Provides common connection management, authentication, and device + * registration. Specialized by CloudCtl (JSON control) and CloudMedia (binary + * streaming). */ class Cloud { public: @@ -22,17 +20,16 @@ class Cloud { const char *deviceId = nullptr; // Device MAC or unique ID const char *authToken = nullptr; // Device authentication token const char *tunnel = - "ws_control"; // Tunnel identifier (e.g., "ws_control", "ws_camera") + "ws_control"; // Tunnel identifier (e.g., "ws_control", "ws_media") bool autoReconnect = true; uint32_t reconnectDelay = 5000; // ms bool enabled = false; + bool disableCertVerify = + false; // Disable TLS cert verification (for testing) }; - // Message handler callback - using OnMessageCb = std::function; - Cloud(); - ~Cloud(); + virtual ~Cloud(); // Initialize and connect to cloud bool begin(const Config &config); @@ -43,14 +40,6 @@ class Cloud { bool reconnect(); bool isConnected() const; - // Send message to cloud (forwarded to connected UI clients) - bool sendMessage(const JsonDocument &message); - bool sendText(const char *message); - bool sendBinary(const uint8_t *data, size_t len); - - // Set message handler (receives messages from UI clients) - void onMessage(OnMessageCb callback) { onMessage_ = callback; } - // Get device info const char *getDeviceId() const { return config_.deviceId; } const char *getTunnel() const { return config_.tunnel; } @@ -58,20 +47,19 @@ class Cloud { const char *getUiWebSocketUrl() const { return uiWsUrl_; } bool isRegistered() const { return registered_; } -private: +protected: Config config_; WebSocketClient ws_; - OnMessageCb onMessage_ = nullptr; - char wsUrl_[512] = {0}; // Increased from 256 to 512 for longer URLs + char wsUrl_[512] = {0}; // WebSocket URL for cloud connection char claimCode_[16] = {0}; char uiWsUrl_[512] = {0}; // UI WebSocket URL from cloud broker bool registered_ = false; - // WebSocket event handlers - void handleConnect(); - void handleDisconnect(); - void handleMessage(const uint8_t *data, size_t len, bool isBinary); + // WebSocket event handlers (can be overridden by derived classes) + virtual void handleConnect(); + virtual void handleDisconnect(); + virtual void handleMessage(const uint8_t *data, size_t len, bool isBinary); // Generate claim code for device pairing void generateClaimCode(); diff --git a/include/CloudCtl.h b/include/CloudCtl.h new file mode 100644 index 0000000..d0cfb63 --- /dev/null +++ b/include/CloudCtl.h @@ -0,0 +1,36 @@ +#ifndef CLOUDCTL_H +#define CLOUDCTL_H + +#include "Cloud.h" +#include +#include + +/** + * @brief CloudCtl - Cloud connection for JSON control messages + * + * Extends Cloud base class to handle bidirectional JSON messaging + * for device control commands and responses. + */ +class CloudCtl : public Cloud { +public: + // Message handler callback for JSON messages + using OnMessageCb = std::function; + + CloudCtl(); + ~CloudCtl() override; + + // Send JSON message to cloud (forwarded to connected UI clients) + bool sendMessage(const JsonDocument &message); + bool sendText(const char *message); + + // Set message handler (receives JSON messages from UI clients) + void onMessage(OnMessageCb callback) { onMessage_ = callback; } + +protected: + OnMessageCb onMessage_ = nullptr; + + // Override to handle JSON messages + void handleMessage(const uint8_t *data, size_t len, bool isBinary) override; +}; + +#endif // CLOUDCTL_H diff --git a/include/CloudMedia.h b/include/CloudMedia.h new file mode 100644 index 0000000..185186b --- /dev/null +++ b/include/CloudMedia.h @@ -0,0 +1,26 @@ +#ifndef CLOUDMEDIA_H +#define CLOUDMEDIA_H + +#include "Cloud.h" + +/** + * @brief CloudMedia - Cloud connection for binary media streaming + * + * Extends Cloud base class to handle high-bandwidth binary streaming + * for camera frames, audio, and other media data. + * Optimized for throughput - no JSON parsing overhead. + */ +class CloudMedia : public Cloud { +public: + CloudMedia(); + ~CloudMedia() override; + + // Send binary data to cloud (forwarded to connected UI clients) + bool sendBinary(const uint8_t *data, size_t len); + +protected: + // Override to ignore incoming messages (media is one-way: device -> UI) + void handleMessage(const uint8_t *data, size_t len, bool isBinary) override; +}; + +#endif // CLOUDMEDIA_H diff --git a/include/ESPWiFi.h b/include/ESPWiFi.h index cf34f40..b4e4bbe 100644 --- a/include/ESPWiFi.h +++ b/include/ESPWiFi.h @@ -59,6 +59,10 @@ #include #include +// Cloud classes +#include "CloudCtl.h" +#include "CloudMedia.h" + // Forward declarations for BLE types (to avoid pulling in NimBLE headers) #ifdef CONFIG_BT_NIMBLE_ENABLED struct ble_gap_event; @@ -293,7 +297,8 @@ class ESPWiFi { #endif // ---- Cloud Client (remote access) - void startCloud(); + void startCloudCtl(); + void startCloudMedia(); // ---- Power Management void powerConfigHandler(); @@ -466,7 +471,8 @@ class ESPWiFi { esp_event_handler_instance_t ip_event_instance = nullptr; // ---- Cloud Client - Cloud cloud; + CloudCtl cloudCtl; // Control WebSocket tunnel (JSON messages) + CloudMedia cloudMedia; // Media WebSocket tunnel (binary streaming) // ---- Deferred config operations (avoid heavy work in HTTP handlers) bool configNeedsSave = false; diff --git a/src/BLE.cpp b/src/BLE.cpp index 2bab9f1..4ec483d 100644 --- a/src/BLE.cpp +++ b/src/BLE.cpp @@ -355,9 +355,9 @@ void ESPWiFi::startBLEServices() { espwifi->config["cloud"]["tunnel"].as(); // Include UI WebSocket URL if device is registered with cloud - if (espwifi->cloud.isConnected() && - espwifi->cloud.isRegistered()) { - const char *uiWsUrl = espwifi->cloud.getUiWebSocketUrl(); + if (espwifi->cloudCtl.isConnected() && + espwifi->cloudCtl.isRegistered()) { + const char *uiWsUrl = espwifi->cloudCtl.getUiWebSocketUrl(); if (uiWsUrl && uiWsUrl[0] != '\0') { resp["cloud"]["wsUrl"] = uiWsUrl; } diff --git a/src/Camera.cpp b/src/Camera.cpp index e600474..a1bde44 100644 --- a/src/Camera.cpp +++ b/src/Camera.cpp @@ -938,16 +938,21 @@ void ESPWiFi::streamCamera() { return; } - // Check for subscribers + // Check for subscribers (LAN + Cloud UI) + // UI controls camera start/stop via WebSocket subscription const size_t lanSubs = (size_t)cameraStreamSubCount_; - if (lanSubs == 0) { + const bool cloudUiConnected = + cloudMedia.isConnected() && cloudMedia.isRegistered(); + + // If no subscribers, deinit camera and return + if (lanSubs == 0 && !cloudUiConnected) { if (camera != nullptr) { deinitCamera(); } return; } - // Initialize camera if needed + // Initialize camera if needed (only when we have subscribers) if (camera == nullptr) { if (!initCamera()) { return; @@ -969,7 +974,7 @@ void ESPWiFi::streamCamera() { return; } - // Send to all subscribers + // Send to all LAN subscribers for (size_t i = 0; i < lanSubs; i++) { int fd = cameraStreamSubFds_[i]; if (fd > 0) { @@ -977,6 +982,12 @@ void ESPWiFi::streamCamera() { } } + // Send to cloud tunnel if UI is connected via cloud and registered + // (Cloud media tunnel only sends when there's an active UI connection) + if (cloudMedia.isConnected() && cloudMedia.isRegistered()) { + cloudMedia.sendBinary(fb->buf, fb->len); + } + esp_camera_fb_return(fb); #endif } \ No newline at end of file diff --git a/src/Cloud.cpp b/src/Cloud.cpp index b121178..4f115ef 100644 --- a/src/Cloud.cpp +++ b/src/Cloud.cpp @@ -45,6 +45,7 @@ bool Cloud::begin(const Config &config) { wsConfig.reconnectDelay = config_.reconnectDelay; wsConfig.pingInterval = 30000; // 30 seconds wsConfig.bufferSize = 8192; + wsConfig.disableCertVerify = config_.disableCertVerify; // Set up callbacks ws_.onConnect([this]() { handleConnect(); }); @@ -53,161 +54,105 @@ bool Cloud::begin(const Config &config) { handleMessage(data, len, isBinary); }); - // Start connection - return ws_.begin(wsConfig); -} - -bool Cloud::connect() { return ws_.connect(); } - -void Cloud::disconnect() { - ws_.disconnect(); - registered_ = false; -} - -bool Cloud::reconnect() { return ws_.reconnect(); } - -bool Cloud::isConnected() const { return ws_.isConnected() && registered_; } - -bool Cloud::sendMessage(const JsonDocument &message) { - if (!ws_.isConnected()) { - ESP_LOGW(TAG, "Not connected to cloud"); + // Start WebSocket connection + if (!ws_.begin(wsConfig)) { + ESP_LOGE(TAG, "Failed to start WebSocket client"); return false; } - // Use std::string for ESP-IDF (not Arduino String) - std::string output; - serializeJson(message, output); - return sendText(output.c_str()); + return true; } -bool Cloud::sendText(const char *message) { - if (!ws_.isConnected()) { - return false; - } +bool Cloud::connect() { return ws_.connect(); } - esp_err_t err = ws_.sendText(message); - return (err == ESP_OK); -} +void Cloud::disconnect() { ws_.disconnect(); } -bool Cloud::sendBinary(const uint8_t *data, size_t len) { - if (!ws_.isConnected()) { - return false; - } +bool Cloud::reconnect() { return ws_.reconnect(); } - esp_err_t err = ws_.sendBinary(data, len); - return (err == ESP_OK); -} +bool Cloud::isConnected() const { return ws_.isConnected(); } void Cloud::handleConnect() { - ESP_LOGI(TAG, "Connected to cloud at %s", config_.baseUrl); - registered_ = false; - - // Note: The cloud server will send a "registered" message - // after we connect, which we'll handle in handleMessage + ESP_LOGI(TAG, "Connected to cloud broker"); + registered_ = + false; // Will be set to true when we receive "registered" message } void Cloud::handleDisconnect() { - ESP_LOGI(TAG, "Disconnected from cloud"); + ESP_LOGI(TAG, "Disconnected from cloud broker"); registered_ = false; + uiWsUrl_[0] = '\0'; } void Cloud::handleMessage(const uint8_t *data, size_t len, bool isBinary) { if (isBinary) { - // Forward binary messages to handler if set - if (onMessage_) { - JsonDocument doc; - doc["type"] = "binary"; - doc["length"] = len; - onMessage_(doc); - } + ESP_LOGW(TAG, "Received unexpected binary message (%zu bytes)", len); return; } // Parse JSON message JsonDocument doc; DeserializationError error = deserializeJson(doc, data, len); - if (error) { - ESP_LOGW(TAG, "Failed to parse message: %s", error.c_str()); + ESP_LOGE(TAG, "Failed to parse message: %s", error.c_str()); return; } - // Check if message has a 'type' field (cloud control messages) const char *type = doc["type"]; + if (type == nullptr) { + ESP_LOGW(TAG, "Message missing 'type' field"); + return; + } - // Handle cloud-specific messages (messages with 'type' field) - if (type != nullptr) { - if (strcmp(type, "registered") == 0) { - registered_ = true; - - // Store UI WebSocket URL from broker - const char *uiUrl = doc["ui_ws_url"]; - if (uiUrl) { - strncpy(uiWsUrl_, uiUrl, sizeof(uiWsUrl_) - 1); - uiWsUrl_[sizeof(uiWsUrl_) - 1] = '\0'; - } - - ESP_LOGI(TAG, "Device registered with cloud"); - ESP_LOGI(TAG, "UI WebSocket URL: %s", uiWsUrl_); - ESP_LOGI(TAG, "Share claim code with users: %s", claimCode_); - return; - } - - if (strcmp(type, "ui_connected") == 0) { - ESP_LOGI(TAG, "UI client connected"); - // Optionally notify application that UI is connected - return; - } - - if (strcmp(type, "ui_disconnected") == 0) { - ESP_LOGI(TAG, "UI client disconnected"); - // Optionally notify application that UI is disconnected - return; + // Handle registration confirmation + if (strcmp(type, "registered") == 0) { + registered_ = true; + const char *uiWsUrl = doc["ui_ws_url"]; + if (uiWsUrl != nullptr) { + snprintf(uiWsUrl_, sizeof(uiWsUrl_), "%s", uiWsUrl); + ESP_LOGI(TAG, "Device registered, UI WebSocket URL: %s", uiWsUrl_); + } else { + ESP_LOGW(TAG, "Registered but no ui_ws_url provided"); } + return; } - // Forward all device control messages (with or without 'type') to application - // handler These are standard device commands like get_rssi, list_files, etc. - if (onMessage_) { - ESP_LOGD(TAG, "Forwarding message to application handler"); - onMessage_(doc); - } + // Other message types handled by derived classes } void Cloud::generateClaimCode() { - // Generate a random 6-character alphanumeric claim code - const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Avoid 0, O, 1, I - const size_t charsLen = sizeof(chars) - 1; - - for (size_t i = 0; i < 6; i++) { - uint32_t random = esp_random(); - claimCode_[i] = chars[random % charsLen]; + // Generate 8-character alphanumeric claim code + const char charset[] = + "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Excluding ambiguous chars + const size_t charsetLen = strlen(charset); + + for (size_t i = 0; i < 8; i++) { + uint32_t rand = esp_random(); + claimCode_[i] = charset[rand % charsetLen]; } - claimCode_[6] = '\0'; + claimCode_[8] = '\0'; } void Cloud::buildWsUrl() { - // Convert https:// to wss:// - char wsBaseUrl[256]; - if (strncmp(config_.baseUrl, "https://", 8) == 0) { - snprintf(wsBaseUrl, sizeof(wsBaseUrl), "wss://%s", config_.baseUrl + 8); - } else if (strncmp(config_.baseUrl, "http://", 7) == 0) { - snprintf(wsBaseUrl, sizeof(wsBaseUrl), "ws://%s", config_.baseUrl + 7); - } else { - // Assume wss:// if no protocol specified - snprintf(wsBaseUrl, sizeof(wsBaseUrl), "wss://%s", config_.baseUrl); + // Format: + // wss://cloud.espwifi.io/ws/device/{deviceId}?tunnel={tunnel}&claim={claimCode} + std::string protocol = "wss"; + std::string host = config_.baseUrl; + + // Extract protocol and host from baseUrl + if (strncmp(host.c_str(), "https://", 8) == 0) { + protocol = "wss"; + host = host.substr(8); + } else if (strncmp(host.c_str(), "http://", 7) == 0) { + protocol = "ws"; + host = host.substr(7); } - // Build: - // wss://cloud.espwifi.io/ws/device/{deviceId}?tunnel={tunnel}&claim={claimCode} - if (config_.tunnel && strlen(config_.tunnel) > 0) { - snprintf(wsUrl_, sizeof(wsUrl_), - "%s/ws/device/%s?tunnel=%s&claim=%s&announce=1", wsBaseUrl, - config_.deviceId, config_.tunnel, claimCode_); - } else { - snprintf(wsUrl_, sizeof(wsUrl_), "%s/ws/device/%s?claim=%s&announce=1", - wsBaseUrl, config_.deviceId, claimCode_); + // Remove trailing slash + if (!host.empty() && host.back() == '/') { + host.pop_back(); } - ESP_LOGD(TAG, "WebSocket URL: %s", wsUrl_); + snprintf(wsUrl_, sizeof(wsUrl_), "%s://%s/ws/device/%s?tunnel=%s&claim=%s", + protocol.c_str(), host.c_str(), config_.deviceId, config_.tunnel, + claimCode_); } diff --git a/src/CloudCtl.cpp b/src/CloudCtl.cpp new file mode 100644 index 0000000..f19654c --- /dev/null +++ b/src/CloudCtl.cpp @@ -0,0 +1,71 @@ +#include "CloudCtl.h" +#include "esp_log.h" +#include + +static const char *TAG = "CloudCtl"; + +CloudCtl::CloudCtl() : Cloud() {} + +CloudCtl::~CloudCtl() {} + +bool CloudCtl::sendMessage(const JsonDocument &message) { + if (!ws_.isConnected()) { + ESP_LOGW(TAG, "Not connected to cloud"); + return false; + } + std::string output; + serializeJson(message, output); + return sendText(output.c_str()); +} + +bool CloudCtl::sendText(const char *message) { + if (!ws_.isConnected()) { + ESP_LOGW(TAG, "Not connected to cloud"); + return false; + } + return ws_.sendText(message); +} + +void CloudCtl::handleMessage(const uint8_t *data, size_t len, bool isBinary) { + if (isBinary) { + ESP_LOGW(TAG, "Received unexpected binary message"); + return; + } + + // Parse JSON message + JsonDocument doc; + DeserializationError error = deserializeJson(doc, data, len); + if (error) { + ESP_LOGE(TAG, "Failed to parse message: %s", error.c_str()); + return; + } + + const char *type = doc["type"]; + + // Handle cloud-specific messages (messages with 'type' field) + if (type != nullptr) { + // Check for registration message (handled by base class) + if (strcmp(type, "registered") == 0) { + Cloud::handleMessage(data, len, isBinary); + return; + } + + // UI connection status messages + if (strcmp(type, "ui_connected") == 0) { + ESP_LOGI(TAG, "UI client connected via cloud"); + return; + } + + if (strcmp(type, "ui_disconnected") == 0) { + ESP_LOGI(TAG, "UI client disconnected from cloud"); + return; + } + } + + // Forward all device control messages (with or without 'type') to application + // handler + if (onMessage_) { + ESP_LOGD(TAG, "Forwarding message to application handler"); + onMessage_(doc); + } +} diff --git a/src/Cloud_start.cpp b/src/CloudCtl_start.cpp similarity index 92% rename from src/Cloud_start.cpp rename to src/CloudCtl_start.cpp index 05e79a2..554c95a 100644 --- a/src/Cloud_start.cpp +++ b/src/CloudCtl_start.cpp @@ -1,7 +1,7 @@ #include "ESPWiFi.h" #include "esp_mac.h" -void ESPWiFi::startCloud() { +void ESPWiFi::startCloudCtl() { // Check if cloud client is enabled in config bool enabled = config["cloud"]["enabled"] | false; if (!enabled) { @@ -41,7 +41,7 @@ void ESPWiFi::startCloud() { cfg.reconnectDelay = reconnectDelay; // Set message handler - forward cloud messages to control socket handler - cloud.onMessage([this](JsonDocument &message) { + cloudCtl.onMessage([this](JsonDocument &message) { const char *type = message["type"]; // Handle cloud-specific messages @@ -65,16 +65,16 @@ void ESPWiFi::startCloud() { handleCloudControlMessage(message, response); // Send response back through cloud tunnel - cloud.sendMessage(response); + cloudCtl.sendMessage(response); }); // Initialize and connect - if (!cloud.begin(cfg)) { + if (!cloudCtl.begin(cfg)) { log(ERROR, "☁️ Failed to initialize cloud client"); return; } log(INFO, "☁️ Cloud client started"); log(INFO, "☁️ Claim code: %s (share with users to pair device)", - cloud.getClaimCode()); + cloudCtl.getClaimCode()); } diff --git a/src/CloudMedia.cpp b/src/CloudMedia.cpp new file mode 100644 index 0000000..6fd4bec --- /dev/null +++ b/src/CloudMedia.cpp @@ -0,0 +1,40 @@ +#include "CloudMedia.h" +#include "esp_log.h" + +static const char *TAG = "CloudMedia"; + +CloudMedia::CloudMedia() : Cloud() {} + +CloudMedia::~CloudMedia() {} + +bool CloudMedia::sendBinary(const uint8_t *data, size_t len) { + if (!ws_.isConnected()) { + ESP_LOGV(TAG, "Not connected to cloud"); + return false; + } + + if (!registered_) { + ESP_LOGV(TAG, "Not registered with cloud"); + return false; + } + + return ws_.sendBinary(data, len); +} + +void CloudMedia::handleMessage(const uint8_t *data, size_t len, bool isBinary) { + // Handle registration messages from base class + if (!isBinary) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, data, len); + if (!error) { + const char *type = doc["type"]; + if (type != nullptr && strcmp(type, "registered") == 0) { + Cloud::handleMessage(data, len, isBinary); + return; + } + } + } + + // Media tunnel is one-way (device -> UI), ignore other messages + ESP_LOGV(TAG, "Ignoring incoming message on media tunnel"); +} diff --git a/src/CloudMedia_start.cpp b/src/CloudMedia_start.cpp new file mode 100644 index 0000000..f6f83f1 --- /dev/null +++ b/src/CloudMedia_start.cpp @@ -0,0 +1,67 @@ +#include "ESPWiFi.h" +#include "esp_mac.h" + +void ESPWiFi::startCloudMedia() { + // Check if cloud client is enabled in config + bool enabled = config["cloud"]["enabled"] | false; + if (!enabled) { + log(INFO, "☁️ Media cloud tunnel disabled (cloud not enabled)"); + return; + } + + // Check if control tunnel is connected first + if (!cloudCtl.isConnected()) { + log(INFO, + "☁️ Media cloud tunnel disabled (control tunnel not connected yet)"); + return; + } + + // Check if media tunnel is explicitly enabled + bool mediaTunnelEnabled = + config["cloud"]["mediaEnabled"] | true; // Default true if cloud enabled + if (!mediaTunnelEnabled) { + log(INFO, "☁️ Media cloud tunnel disabled in config"); + return; + } + + // Get configuration + const char *baseUrl = config["cloud"]["baseUrl"]; + bool autoReconnect = config["cloud"]["autoReconnect"]; + uint32_t reconnectDelay = config["cloud"]["reconnectDelay"]; + + // Use current hostname as device ID + std::string hostname = config["hostname"].as(); + if (hostname.empty()) { + hostname = genHostname(); + } + const char *deviceId = hostname.c_str(); + + // Get auth token from device config + const char *authToken = config["auth"]["token"] | nullptr; + + log(INFO, "☁️ Starting media cloud tunnel"); + log(INFO, "☁️ Base URL: %s", baseUrl); + log(INFO, "☁️ Device ID: %s", deviceId); + log(INFO, "☁️ Tunnel: ws_media"); + + // Configure media cloud client + CloudMedia::Config cfg; + cfg.enabled = true; + cfg.baseUrl = baseUrl; + cfg.deviceId = deviceId; + cfg.authToken = authToken; + cfg.tunnel = "ws_media"; // Separate tunnel for media (camera, audio, etc) + cfg.autoReconnect = autoReconnect; + cfg.reconnectDelay = reconnectDelay; + + // No message handler needed for media tunnel - it only sends binary frames + // Media frames will be sent via cloudMedia.sendBinary() + + // Initialize and connect + if (!cloudMedia.begin(cfg)) { + log(ERROR, "☁️ Failed to initialize media cloud tunnel"); + return; + } + + log(INFO, "☁️ Media cloud tunnel started"); +} diff --git a/src/DefaultConfig.cpp b/src/DefaultConfig.cpp index 2b2db8c..c80962a 100644 --- a/src/DefaultConfig.cpp +++ b/src/DefaultConfig.cpp @@ -41,6 +41,8 @@ JsonDocument ESPWiFi::defaultConfig() { doc["cloud"]["baseUrl"] = "https://cloud.espwifi.io"; doc["cloud"]["deviceId"] = getHostname(); // Use hostname as device ID doc["cloud"]["tunnel"] = "ws_control"; + doc["cloud"]["mediaEnabled"] = + true; // Enable separate media tunnel (camera, audio, etc) doc["cloud"]["autoReconnect"] = true; doc["cloud"]["reconnectDelay"] = 5000; diff --git a/src/ESPWiFi.cpp b/src/ESPWiFi.cpp index 79af1d8..39ae097 100644 --- a/src/ESPWiFi.cpp +++ b/src/ESPWiFi.cpp @@ -13,7 +13,8 @@ void ESPWiFi::start() { startWebServer(); startControlWebSocket(); startCameraWebSocket(); - startCloud(); + startCloudCtl(); + startCloudMedia(); srvAll(); } diff --git a/src/Info.cpp b/src/Info.cpp index 6fbda8c..11c7228 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -71,14 +71,31 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { control["started"] = ctrlSocStarted; // Add cloud connection status and UI WebSocket URL - control["cloudEnabled"] = cloud.isConnected(); - control["cloudConnected"] = cloud.isConnected() && cloud.isRegistered(); - if (cloud.isConnected() && cloud.isRegistered()) { - const char *uiWsUrl = cloud.getUiWebSocketUrl(); + control["cloudEnabled"] = cloudCtl.isConnected(); + control["cloudConnected"] = + cloudCtl.isConnected() && cloudCtl.isRegistered(); + if (cloudCtl.isConnected() && cloudCtl.isRegistered()) { + const char *uiWsUrl = cloudCtl.getUiWebSocketUrl(); if (uiWsUrl && uiWsUrl[0] != '\0') { control["ui_ws_url"] = std::string(uiWsUrl); } } + + // Camera endpoint info + JsonObject camera = endpoints["camera"].to(); + camera["uri"] = "/ws/camera"; + camera["started"] = cameraSocStarted; + + // Add media cloud tunnel info + camera["cloudEnabled"] = cloudMedia.isConnected(); + camera["cloudConnected"] = + cloudMedia.isConnected() && cloudMedia.isRegistered(); + if (cloudMedia.isConnected() && cloudMedia.isRegistered()) { + const char *mediaWsUrl = cloudMedia.getUiWebSocketUrl(); + if (mediaWsUrl && mediaWsUrl[0] != '\0') { + camera["ui_ws_url"] = std::string(mediaWsUrl); + } + } #endif } From 4fad18f27e15f91506fff6fbfaaee2cec038d959 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 03:11:05 -0500 Subject: [PATCH 06/16] getting closer to proper cloud registration --- src/Cloud.cpp | 5 +++-- src/CloudMedia_start.cpp | 15 +++++++-------- src/DefaultConfig.cpp | 4 ++-- src/Info.cpp | 24 ++++++++++++++++++------ 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/Cloud.cpp b/src/Cloud.cpp index 4f115ef..7ecc96f 100644 --- a/src/Cloud.cpp +++ b/src/Cloud.cpp @@ -152,7 +152,8 @@ void Cloud::buildWsUrl() { host.pop_back(); } - snprintf(wsUrl_, sizeof(wsUrl_), "%s://%s/ws/device/%s?tunnel=%s&claim=%s", + snprintf(wsUrl_, sizeof(wsUrl_), + "%s://%s/ws/device/%s?tunnel=%s&claim=%s&announce=1&token=%s", protocol.c_str(), host.c_str(), config_.deviceId, config_.tunnel, - claimCode_); + claimCode_, config_.authToken ? config_.authToken : ""); } diff --git a/src/CloudMedia_start.cpp b/src/CloudMedia_start.cpp index f6f83f1..ef5510c 100644 --- a/src/CloudMedia_start.cpp +++ b/src/CloudMedia_start.cpp @@ -9,16 +9,10 @@ void ESPWiFi::startCloudMedia() { return; } - // Check if control tunnel is connected first - if (!cloudCtl.isConnected()) { - log(INFO, - "☁️ Media cloud tunnel disabled (control tunnel not connected yet)"); - return; - } - // Check if media tunnel is explicitly enabled bool mediaTunnelEnabled = - config["cloud"]["mediaEnabled"] | true; // Default true if cloud enabled + config["cloud"]["mediaEnabled"] | + false; // Default false initially due to resource constraints if (!mediaTunnelEnabled) { log(INFO, "☁️ Media cloud tunnel disabled in config"); return; @@ -44,6 +38,11 @@ void ESPWiFi::startCloudMedia() { log(INFO, "☁️ Device ID: %s", deviceId); log(INFO, "☁️ Tunnel: ws_media"); + // Wait for control tunnel TLS handshake to complete + // (avoids memory contention during simultaneous TLS connections) + log(INFO, "☁️ Waiting 3s for control tunnel to establish..."); + feedWatchDog(3000); + // Configure media cloud client CloudMedia::Config cfg; cfg.enabled = true; diff --git a/src/DefaultConfig.cpp b/src/DefaultConfig.cpp index c80962a..a1f2302 100644 --- a/src/DefaultConfig.cpp +++ b/src/DefaultConfig.cpp @@ -41,8 +41,8 @@ JsonDocument ESPWiFi::defaultConfig() { doc["cloud"]["baseUrl"] = "https://cloud.espwifi.io"; doc["cloud"]["deviceId"] = getHostname(); // Use hostname as device ID doc["cloud"]["tunnel"] = "ws_control"; - doc["cloud"]["mediaEnabled"] = - true; // Enable separate media tunnel (camera, audio, etc) + doc["cloud"]["mediaEnabled"] = false; // Disabled by default due to resource + // constraints (camera, audio, etc) doc["cloud"]["autoReconnect"] = true; doc["cloud"]["reconnectDelay"] = 5000; diff --git a/src/Info.cpp b/src/Info.cpp index 11c7228..a10bb2a 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -59,9 +59,15 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { ? false : config["cloud"]["tunnelAll"].as(); - // Claim info - ct["claimCode"] = getClaimCode(false); - ct["claimExpiresInMs"] = claimExpiresInMs(); + // Cloud claim code from cloudCtl (for cloud broker pairing) + if (cloudCtl.isConnected()) { + const char *claimCode = cloudCtl.getClaimCode(); + if (claimCode && claimCode[0] != '\0') { + ct["claimCode"] = std::string(claimCode); + } + } + // For now, claim expiry is not tracked in Cloud class + // ct["claimExpiresInMs"] = 600000; // 10 minutes #ifdef CONFIG_HTTPD_WS_SUPPORT JsonObject endpoints = ct["endpoints"].to(); @@ -99,11 +105,17 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { #endif } - // Pairing / claim code (for iOS flows) + // Pairing / claim code (cloud broker claim code for iOS flows) { JsonObject p = jsonDoc["pairing"].to(); - p["claim_code"] = getClaimCode(false); - p["claim_expires_in_ms"] = claimExpiresInMs(); + if (cloudCtl.isConnected()) { + const char *claimCode = cloudCtl.getClaimCode(); + if (claimCode && claimCode[0] != '\0') { + p["claim_code"] = std::string(claimCode); + } + } + // For now, claim expiry is not tracked in Cloud class + // p["claim_expires_in_ms"] = 600000; // 10 minutes } maybeYield(); From 2c0b8e0213e7581ae3755bf1d9b2dfeab4d42ab6 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 19:14:28 -0500 Subject: [PATCH 07/16] fixing-ws-after-play --- dashboard/{.env.local.save => .env.local} | 2 +- dashboard/src/App.js | 390 ++++----- dashboard/src/components/BlePairingDialog.js | 372 --------- dashboard/src/components/BlePairingFlow.js | 750 ------------------ dashboard/src/components/BluetoothButton.js | 83 +- .../src/components/BluetoothSettingsModal.js | 613 ++++++++++++-- dashboard/src/components/FileBrowser.js | 37 +- dashboard/src/components/LogsSettingsModal.js | 4 +- dashboard/src/components/MusicPlayerModule.js | 52 +- dashboard/src/components/RSSIButton.js | 84 +- dashboard/src/components/RSSISettingsModal.js | 141 +--- dashboard/src/components/SettingsButtonBar.js | 110 +-- include/ESPWiFi.h | 32 +- src/BLE.cpp | 742 ++++++----------- src/CloudCtl_start.cpp | 14 +- src/Config.cpp | 26 +- src/DefaultConfig.cpp | 2 - src/ESPWiFi.cpp | 6 +- src/Log.cpp | 20 +- src/WebSocketClient.cpp | 4 +- src/net/WebServer.cpp | 4 +- src/net/WiFi.cpp | 66 +- src/routes/srvAll.cpp | 6 +- 23 files changed, 1066 insertions(+), 2494 deletions(-) rename dashboard/{.env.local.save => .env.local} (62%) delete mode 100644 dashboard/src/components/BlePairingDialog.js delete mode 100644 dashboard/src/components/BlePairingFlow.js diff --git a/dashboard/.env.local.save b/dashboard/.env.local similarity index 62% rename from dashboard/.env.local.save rename to dashboard/.env.local index 9e20cfc..ac36470 100644 --- a/dashboard/.env.local.save +++ b/dashboard/.env.local @@ -1,3 +1,3 @@ REACT_APP_API_HOST=espwifi.local -REACT_APP_TEST_HOSTED_MODE=true +REACT_APP_TEST_HOSTED_MODE=false REACT_APP_API_PORT=80 diff --git a/dashboard/src/App.js b/dashboard/src/App.js index 64faddc..4c82ac5 100644 --- a/dashboard/src/App.js +++ b/dashboard/src/App.js @@ -12,28 +12,13 @@ import Container from "@mui/material/Container"; import Box from "@mui/material/Box"; import LinearProgress from "@mui/material/LinearProgress"; import Typography from "@mui/material/Typography"; -import { - getApiUrl, - buildWebSocketUrl, - isHostedFromEspWiFiIo, -} from "./utils/apiUtils"; +import { getApiUrl, buildWebSocketUrl } from "./utils/apiUtils"; import { getRSSIThemeColor } from "./utils/rssiUtils"; -import { resolveWebSocketUrl } from "./utils/connectionUtils"; -import { - loadDevices, - saveDevices, - loadSelectedDeviceId, - saveSelectedDeviceId, - upsertDevice, - removeDevice as removeDeviceFromRegistry, - touchSelected, -} from "./utils/deviceRegistry"; // Lazy-load heavier UI chunks so first paint is faster const Modules = lazy(() => import("./components/Modules")); const Login = lazy(() => import("./components/Login")); const SettingsButtonBar = lazy(() => import("./components/SettingsButtonBar")); -const BlePairingFlow = lazy(() => import("./components/BlePairingFlow")); // Define the theme const theme = createTheme({ @@ -139,48 +124,26 @@ function App() { const [cloudRssi, setCloudRssi] = useState(null); const [cloudLogs, setCloudLogs] = useState(""); const [cloudLogsError, setCloudLogsError] = useState(""); + const heartbeatIntervalRef = useRef(null); + const heartbeatTimeoutRef = useRef(null); // Music player state for banner color const [musicPlaybackState, setMusicPlaybackState] = useState({ isPlaying: false, isPaused: false, }); + // Use a ref to track music state in the connection check interval + // This avoids recreating the WebSocket when music state changes + const musicPlaybackStateRef = useRef(musicPlaybackState); - // Device registry state for espwifi.io hosted mode - const [devices, setDevices] = useState([]); - const [selectedDeviceId, setSelectedDeviceId] = useState(null); - const [showInitialSetup, setShowInitialSetup] = useState(false); - const isHostedMode = useMemo(() => { - const hosted = isHostedFromEspWiFiIo(); - console.log("[App] isHostedMode:", hosted); - return hosted; - }, []); + // Keep ref in sync with state + useEffect(() => { + musicPlaybackStateRef.current = musicPlaybackState; + }, [musicPlaybackState]); const apiURL = getApiUrl(); const headerRef = useRef(null); - const lastRssiTimeRef = useRef(Date.now()); - const connectionCheckRef = useRef(null); - - // Load device registry on mount (only in hosted mode) - useEffect(() => { - console.log("[App] Loading device registry, isHostedMode:", isHostedMode); - if (!isHostedMode) return; - const loadedDevices = loadDevices(); - console.log("[App] Loaded devices:", loadedDevices); - setDevices(loadedDevices); - const loadedSelectedId = loadSelectedDeviceId(); - if ( - loadedSelectedId && - loadedDevices.some((d) => d.id === loadedSelectedId) - ) { - setSelectedDeviceId(loadedSelectedId); - console.log("[App] Selected existing device:", loadedSelectedId); - } else if (loadedDevices.length === 0) { - // No devices configured - show initial setup - console.log("[App] No devices found, showing initial setup"); - setShowInitialSetup(true); - } - }, [isHostedMode]); + const pendingCommandsRef = useRef(new Map()); // Map of cmd -> {resolve, reject, timeout} // Measure header height and expose as CSS var to avoid brittle vh math on mobile. useEffect(() => { @@ -249,46 +212,13 @@ function App() { }, []); // Direct connection to control WebSocket - // When a device is selected and has cloud tunnel info, use tunnel URL - // Otherwise, use local WebSocket connection const controlWsUrl = useMemo(() => { - console.log("[App] Computing control WebSocket URL"); - console.log("[App] isHostedMode:", isHostedMode); - console.log("[App] selectedDeviceId:", selectedDeviceId); - console.log("[App] devices:", devices); - - // In hosted mode with a selected device, use cloud tunnel if available - if (isHostedMode && selectedDeviceId) { - const selectedDevice = devices.find((d) => d.id === selectedDeviceId); - console.log("[App] Selected device:", selectedDevice); - - if (selectedDevice) { - // Build a minimal config object for resolveWebSocketUrl - const deviceConfig = { - hostname: selectedDevice.hostname || selectedDevice.deviceId, - deviceName: selectedDevice.name || selectedDevice.deviceId, - auth: { - token: selectedDevice.authToken, - }, - cloudTunnel: selectedDevice.cloudTunnel || { - enabled: false, - }, - }; - - const url = resolveWebSocketUrl("control", deviceConfig, { - preferTunnel: true, - }); - console.log("[App] Resolved WebSocket URL:", url); - return url; - } - } - - // Fallback to local connection + // Use local WebSocket connection if (process.env.REACT_APP_API_HOST) { return buildWebSocketUrl("/ws/control", process.env.REACT_APP_API_HOST); } return buildWebSocketUrl("/ws/control"); - }, [isHostedMode, selectedDeviceId, devices]); + }, []); // Once boot phase is complete, connect the control socket. useEffect(() => { @@ -317,22 +247,101 @@ function App() { controlWsRef.current = null; } - // Clear any existing connection check - if (connectionCheckRef.current) { - clearInterval(connectionCheckRef.current); - connectionCheckRef.current = null; - } - try { const ws = new WebSocket(uiUrl); // Needed for camera streaming over /ws/control. ws.binaryType = "arraybuffer"; controlWsRef.current = ws; + // Add a sendCommand method to the WebSocket for components to use + ws.sendCommand = (cmd, timeoutMs = 10000) => { + return new Promise((resolve, reject) => { + if (ws.readyState !== WebSocket.OPEN) { + reject(new Error("WebSocket not connected")); + return; + } + + const timeout = setTimeout(() => { + pendingCommandsRef.current.delete(cmd.cmd); + reject(new Error("Command timeout")); + }, timeoutMs); + + pendingCommandsRef.current.set(cmd.cmd, { + resolve, + reject, + timeout, + }); + ws.send(JSON.stringify(cmd)); + }); + }; + ws.onopen = () => { console.log("[App] Control WebSocket connected"); setControlConnected(true); - lastRssiTimeRef.current = Date.now(); + + // Clear any existing heartbeat + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + + // Start heartbeat: poll get_rssi every 3 seconds + const startHeartbeat = () => { + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + } + + const sendHeartbeat = () => { + const currentWs = controlWsRef.current; + if (!currentWs || currentWs.readyState !== WebSocket.OPEN) { + return; + } + + try { + // Set a timeout to detect if we don't get a response + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + } + + heartbeatTimeoutRef.current = setTimeout(() => { + console.warn( + "[App] Heartbeat timeout - no RSSI response, reconnecting..." + ); + const wsToClose = controlWsRef.current; + if (wsToClose && wsToClose.readyState === WebSocket.OPEN) { + try { + wsToClose.close(1006, "Heartbeat timeout"); + } catch { + // ignore + } + } + }, 6000); // Allow 6 seconds for response (2x heartbeat interval) + + currentWs.send(JSON.stringify({ cmd: "get_rssi" })); + } catch (err) { + console.error("[App] Heartbeat send failed:", err); + // Close connection on send failure + try { + currentWs.close(1006, "Heartbeat send failed"); + } catch { + // ignore + } + } + }; + + // Send initial heartbeat immediately + sendHeartbeat(); + + // Then poll every 3 seconds + heartbeatIntervalRef.current = setInterval(sendHeartbeat, 3000); + }; + + startHeartbeat(); + try { ws.send(JSON.stringify({ cmd: "ping" })); ws.send(JSON.stringify({ cmd: "get_config" })); @@ -340,31 +349,24 @@ function App() { } catch { // ignore } - - // Start connection health check - // We rely on RSSI polling (every 1s) as our heartbeat - // If we don't get any RSSI response for 5 seconds, connection is dead - connectionCheckRef.current = setInterval(() => { - const timeSinceLastRssi = Date.now() - lastRssiTimeRef.current; - if (timeSinceLastRssi > 5000 && ws.readyState === WebSocket.OPEN) { - console.warn( - "[App] No RSSI response for 5s, connection appears dead" - ); - // Immediately update state before closing - setControlConnected(false); - setCloudDeviceConfig(null); - setCloudDeviceInfo(null); - setCloudRssi(null); - ws.close(); - } - }, 2000); }; ws.onmessage = (evt) => { try { const msg = JSON.parse(evt?.data || "{}"); - // Update last message time on any message (indicates connection is alive) - lastRssiTimeRef.current = Date.now(); + // Check if this message is a response to a pending command + if (msg?.cmd && pendingCommandsRef.current.has(msg.cmd)) { + const pending = pendingCommandsRef.current.get(msg.cmd); + clearTimeout(pending.timeout); + pendingCommandsRef.current.delete(msg.cmd); + + if (msg.ok === false) { + pending.reject(new Error(msg.error || "Command failed")); + } else { + pending.resolve(msg); + } + return; // Don't process further + } if (msg?.cmd === "get_config" && msg?.config) { setConfig(msg.config); @@ -375,6 +377,12 @@ function App() { setCloudDeviceInfo(msg.info); } if (msg?.cmd === "get_rssi") { + // Clear heartbeat timeout since we got a response + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + if (typeof msg?.rssi === "number") { setCloudRssi(msg.rssi); } else { @@ -448,19 +456,36 @@ function App() { }; ws.onclose = (evt) => { console.log("[App] Control WebSocket closed, code:", evt?.code); + + // Clear heartbeat + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + controlWsRef.current = null; setControlConnected(false); setCloudDeviceConfig(null); setCloudDeviceInfo(null); setCloudRssi(null); - // Clear connection check - if (connectionCheckRef.current) { - clearInterval(connectionCheckRef.current); - connectionCheckRef.current = null; + // Reject all pending commands + for (const [, pending] of pendingCommandsRef.current.entries()) { + clearTimeout(pending.timeout); + pending.reject(new Error("WebSocket closed")); + } + pendingCommandsRef.current.clear(); + + if (evt?.code === 1000) { + console.log("[App] Clean close (1000), not reconnecting"); + return; } - if (evt?.code === 1000) return; + console.log("[App] Scheduling reconnect in 2 seconds..."); controlRetryRef.current = setTimeout(connect, 2000); }; } catch { @@ -470,14 +495,20 @@ function App() { connect(); return () => { + // Clear heartbeat + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + if (controlRetryRef.current) { clearTimeout(controlRetryRef.current); controlRetryRef.current = null; } - if (connectionCheckRef.current) { - clearInterval(connectionCheckRef.current); - connectionCheckRef.current = null; - } if (controlWsRef.current) { try { controlWsRef.current.close(1000, "Cleanup"); @@ -503,74 +534,6 @@ function App() { setAuthenticated(true); }; - /** - * Handle device provisioning from initial setup - * Called when a new device is successfully configured via BLE - */ - const handleDeviceProvisioned = useCallback( - (deviceRecord, details) => { - const updatedDevices = upsertDevice(devices, deviceRecord); - setDevices(updatedDevices); - saveDevices(updatedDevices); - setSelectedDeviceId(deviceRecord.id); - saveSelectedDeviceId(deviceRecord.id); - setShowInitialSetup(false); - }, - [devices] - ); - - /** - * Handle device selection from device picker - */ - const handleSelectDevice = useCallback( - (device) => { - if (!device || !device.id) return; - const updatedDevices = touchSelected(devices, device.id); - setDevices(updatedDevices); - saveDevices(updatedDevices); - setSelectedDeviceId(device.id); - saveSelectedDeviceId(device.id); - }, - [devices] - ); - - /** - * Handle device removal from registry - */ - const handleRemoveDevice = useCallback( - (device) => { - if (!device || !device.id) return; - const updatedDevices = removeDeviceFromRegistry(devices, device.id); - setDevices(updatedDevices); - saveDevices(updatedDevices); - if (selectedDeviceId === device.id) { - setSelectedDeviceId(null); - saveSelectedDeviceId(null); - // If this was the last device, show initial setup again - if (updatedDevices.length === 0) { - setShowInitialSetup(true); - } - } - }, - [devices, selectedDeviceId] - ); - - /** - * Handle pairing a new device - */ - const handlePairNewDevice = useCallback( - (deviceRecord, details) => { - // If called without arguments (from device picker), show initial setup - if (!deviceRecord) { - setShowInitialSetup(true); - return; - } - // Otherwise, handle the device provisioning - handleDeviceProvisioned(deviceRecord, details); - }, - [handleDeviceProvisioned] - ); - /** * Update local config only (no device API calls) * Used for immediate UI updates before saving to device @@ -666,41 +629,6 @@ function App() { const effectiveConfig = cloudDeviceConfig || localConfig; - // Show initial setup screen when hosted from espwifi.io and no devices configured - if (isHostedMode && showInitialSetup) { - return ( - - - ESPWiFi - - - }> - setShowInitialSetup(false)} - /> - - - ); - } - return ( {/* Show saving progress bar at the top when saving */} @@ -789,21 +717,6 @@ function App() { controlRssi={cloudRssi} logsText={cloudLogs} logsError={cloudLogsError} - showDevicePicker={isHostedMode} - devices={devices} - selectedDeviceId={selectedDeviceId} - onSelectDevice={handleSelectDevice} - onRemoveDevice={handleRemoveDevice} - onPairNewDevice={handlePairNewDevice} - onRequestRssi={() => { - const ws = controlWsRef.current; - if (!ws || ws.readyState !== 1) return; - try { - ws.send(JSON.stringify({ cmd: "get_rssi" })); - } catch { - // ignore - } - }} onRequestLogs={() => { const ws = controlWsRef.current; if (!ws || ws.readyState !== 1) return; @@ -829,10 +742,11 @@ function App() { // ignore } }} - cloudMode={isHostedMode} + cloudMode={false} controlConnected={controlConnected} deviceInfoOverride={cloudDeviceInfo} controlWs={controlWsRef.current} + musicPlaybackState={musicPlaybackState} /> diff --git a/dashboard/src/components/BlePairingDialog.js b/dashboard/src/components/BlePairingDialog.js deleted file mode 100644 index 74a73d5..0000000 --- a/dashboard/src/components/BlePairingDialog.js +++ /dev/null @@ -1,372 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Typography, - Box, - Alert, - TextField, - CircularProgress, -} from "@mui/material"; - -const DIS_UUID = 0x180a; -const CONTROL_UUID = 0xfff1; - -function hasWebBluetooth() { - return Boolean(navigator?.bluetooth); -} - -function encodeUtf8(s) { - return new TextEncoder().encode(s); -} - -function decodeUtf8(buf) { - return new TextDecoder("utf-8").decode(buf); -} - -async function writeJsonAndRead(characteristic, obj) { - const payload = JSON.stringify(obj); - await characteristic.writeValue(encodeUtf8(payload)); - const v = await characteristic.readValue(); - const txt = decodeUtf8(v.buffer); - return JSON.parse(txt); -} - -export default function BlePairingDialog({ - open, - onClose, - onPaired, - isInitialSetup = false, -}) { - const supported = useMemo(() => hasWebBluetooth(), []); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [ssid, setSsid] = useState(""); - const [password, setPassword] = useState(""); - const [phase, setPhase] = useState("idle"); // idle | scan | wifi | connecting | done - const [statusText, setStatusText] = useState(""); - - const handlePair = async () => { - setError(""); - if (!supported) return; - setBusy(true); - setPhase("scan"); - setStatusText(""); - let dev = null; - try { - dev = await navigator.bluetooth.requestDevice({ - filters: [{ services: [DIS_UUID] }], - optionalServices: [DIS_UUID], - }); - - const server = await dev.gatt.connect(); - const service = await server.getPrimaryService(DIS_UUID); - const control = await service.getCharacteristic(CONTROL_UUID); - - setStatusText("Reading device info…"); - const identity = await writeJsonAndRead(control, { cmd: "get_identity" }); - - // For initial setup mode, always prompt for WiFi credentials - if (isInitialSetup) { - setStatusText(""); - setPhase("wifi"); - setBusy(false); - return; - } - - // If device is in AP / not online yet, we need to provide WiFi creds. - setStatusText("Checking WiFi status…"); - const status = await writeJsonAndRead(control, { cmd: "get_status" }); - const ip = status?.ip || "0.0.0.0"; - const wifiMode = (status?.wifiMode || "").toLowerCase(); - const needsWifi = - ip === "0.0.0.0" || wifiMode === "ap" || wifiMode === "accesspoint"; - - if (needsWifi) { - // Defer to the WiFi credentials phase; keep BLE connected while dialog stays open. - setPhase("wifi"); - setBusy(false); - setStatusText(""); - return; - } - - // Device is already online, just get identity - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - - const deviceId = identity?.hostname || identity?.deviceId || null; - const id = deviceId || identity?.hostname || dev?.name || dev?.id; - if (!id) throw new Error("BLE device did not provide an id/hostname"); - - const record = { - id: String(id), - name: - identity?.deviceName || identity?.model || dev?.name || String(id), - hostname: identity?.hostname || null, - deviceId: deviceId || String(id), - authToken: identity?.token || null, - lastSeenAtMs: Date.now(), - }; - - onPaired?.(record, { identity }); - onClose?.(); - } catch (e) { - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - setError(e?.message || String(e)); - } finally { - setBusy(false); - setPhase("idle"); - setStatusText(""); - } - }; - - const handleProvisionWifi = async () => { - setError(""); - if (!supported) return; - if (!ssid.trim()) { - setError("SSID is required"); - return; - } - setBusy(true); - setPhase("connecting"); - setStatusText("Sending WiFi credentials…"); - let dev = null; - try { - // We need to re-open the chooser because we intentionally returned early - // from handlePair() (browsers don't allow keeping the device object across - // arbitrary dialog lifetimes reliably). - dev = await navigator.bluetooth.requestDevice({ - filters: [{ services: [DIS_UUID] }], - optionalServices: [DIS_UUID], - }); - const server = await dev.gatt.connect(); - const service = await server.getPrimaryService(DIS_UUID); - const control = await service.getCharacteristic(CONTROL_UUID); - - const identity = await writeJsonAndRead(control, { cmd: "get_identity" }); - - setStatusText("Configuring WiFi client mode…"); - const result = await writeJsonAndRead(control, { - cmd: "set_wifi", - ssid: ssid.trim(), - password, - }); - - // Check if successful - if (result?.ok === false) { - throw new Error(result?.error || "Failed to configure WiFi"); - } - - // For initial setup, device will restart WiFi automatically - if (isInitialSetup) { - setStatusText("Configuration saved, device applying settings…"); - // Give device time to apply settings - await new Promise((r) => setTimeout(r, 3000)); - - // Close BLE connection - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - - // Create a basic device record - const deviceId = - identity?.hostname || identity?.deviceId || dev?.name || dev?.id; - const id = String(deviceId); - - // Extract cloud configuration from device identity - const cloudConfig = identity?.cloud?.enabled - ? { - enabled: true, - baseUrl: identity.cloud.baseUrl, - tunnel: identity.cloud.tunnel, - wsUrl: identity.cloud.wsUrl || null, // Full UI WebSocket URL from device - } - : { enabled: false }; - - const record = { - id, - name: - identity?.deviceName || identity?.model || dev?.name || String(id), - hostname: identity?.hostname || null, - deviceId: String(deviceId), - authToken: identity?.token || null, - cloudTunnel: cloudConfig, - lastSeenAtMs: Date.now(), - }; - - onPaired?.(record, { identity }); - onClose?.(); - return; - } - - // Original flow for non-initial setup: Poll for IP and wait for connection - const deadline = Date.now() + 45_000; - let ip = "0.0.0.0"; - while (Date.now() < deadline) { - setStatusText("Waiting for device to connect to WiFi…"); - const st = await writeJsonAndRead(control, { cmd: "get_status" }); - ip = st?.ip || "0.0.0.0"; - if (ip && ip !== "0.0.0.0") break; - await new Promise((r) => setTimeout(r, 2000)); - } - - if (!ip || ip === "0.0.0.0") { - throw new Error("Timed out waiting for WiFi connection"); - } - - // Device connected successfully - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - - const deviceId = identity?.hostname || identity?.deviceId || null; - const id = deviceId || identity?.hostname || dev?.name || dev?.id; - if (!id) throw new Error("BLE device did not provide an id/hostname"); - - const record = { - id: String(id), - name: - identity?.deviceName || identity?.model || dev?.name || String(id), - hostname: identity?.hostname || null, - deviceId: deviceId || String(id), - authToken: identity?.token || null, - lastSeenAtMs: Date.now(), - }; - - onPaired?.(record, { identity }); - onClose?.(); - } catch (e) { - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - setError(e?.message || String(e)); - } finally { - setBusy(false); - setPhase("idle"); - setStatusText(""); - } - }; - - return ( - - - {isInitialSetup ? "Set Up ESPWiFi Device" : "Pair device (BLE)"} - - - {!supported && ( - - Web Bluetooth isn't available in this browser. Use Chrome on Android - or Desktop Chrome for BLE pairing, or we can add a manual pairing - fallback (token/QR). - - )} - {supported && ( - - {phase === "idle" && ( - - {isInitialSetup - ? "Click 'Start BLE scan' to search for your ESPWiFi device. You'll be prompted to enter WiFi credentials." - : "This will open the browser's BLE chooser. Select your ESPWiFi device to configure it."} - - )} - {phase === "wifi" && ( - - - {isInitialSetup - ? "Enter your WiFi network credentials. The device will switch to client mode and connect to your network." - : "Device isn't configured yet. Enter WiFi credentials to connect."} - - setSsid(e.target.value)} - autoComplete="off" - fullWidth - /> - setPassword(e.target.value)} - type="password" - autoComplete="off" - fullWidth - /> - - )} - {busy && statusText && ( - - - - {statusText} - - - )} - - )} - {error && ( - - {error} - - )} - - - - {phase === "wifi" ? ( - - ) : ( - - )} - - - ); -} diff --git a/dashboard/src/components/BlePairingFlow.js b/dashboard/src/components/BlePairingFlow.js deleted file mode 100644 index 275f1c0..0000000 --- a/dashboard/src/components/BlePairingFlow.js +++ /dev/null @@ -1,750 +0,0 @@ -import React, { useState, useMemo } from "react"; -import { - Container, - Box, - Paper, - Typography, - Button, - Alert, - TextField, - CircularProgress, - IconButton, - InputAdornment, -} from "@mui/material"; -import BluetoothSearchingIcon from "@mui/icons-material/BluetoothSearching"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import WifiIcon from "@mui/icons-material/Wifi"; -import Visibility from "@mui/icons-material/Visibility"; -import VisibilityOff from "@mui/icons-material/VisibilityOff"; -import ClaimCodeEntry from "./ClaimCodeEntry"; - -const DIS_UUID = 0x180a; -const CONTROL_UUID = 0xfff1; - -function hasWebBluetooth() { - return Boolean(navigator?.bluetooth); -} - -function encodeUtf8(s) { - return new TextEncoder().encode(s); -} - -function decodeUtf8(buf) { - return new TextDecoder("utf-8").decode(buf); -} - -async function writeJsonAndRead(characteristic, obj) { - const payload = JSON.stringify(obj); - - // Redact sensitive fields for logging - const safeObj = { ...obj }; - if (safeObj.password !== undefined) { - safeObj.password = "***"; - } - const safePayload = JSON.stringify(safeObj); - - console.log( - "[BLE] Writing JSON payload:", - safePayload, - "length:", - payload.length - ); - - await characteristic.writeValue(encodeUtf8(payload)); - - const v = await characteristic.readValue(); - const txt = decodeUtf8(v.buffer); - console.log("[BLE] Read response:", txt); - - return JSON.parse(txt); -} - -/** - * BLE Pairing Flow component - * Streamlined flow to scan for and provision ESPWiFi devices via Bluetooth - * Used for both initial setup and adding additional devices - */ -export default function BlePairingFlow({ onDeviceProvisioned, onClose }) { - const supported = useMemo(() => hasWebBluetooth(), []); - const [phase, setPhase] = useState("idle"); // idle | scanning | configuring | quick_connect | done - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [statusText, setStatusText] = useState(""); - - // Claim code entry dialog - const [showClaimEntry, setShowClaimEntry] = useState(false); - - // Device info after selection - const [selectedDevice, setSelectedDevice] = useState(null); - const [deviceIdentity, setDeviceIdentity] = useState(null); - const [bleControl, setBleControl] = useState(null); // Keep BLE connection alive - - // WiFi credentials - const [ssid, setSsid] = useState(""); - const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); - - const handleStartScan = async () => { - setError(""); - if (!supported) { - setError("Web Bluetooth is not supported in this browser"); - return; - } - - setBusy(true); - setPhase("scanning"); - setStatusText("Opening Bluetooth device picker..."); - - let dev = null; - try { - // Open Bluetooth device picker - dev = await navigator.bluetooth.requestDevice({ - filters: [{ services: [DIS_UUID] }], - optionalServices: [DIS_UUID], - }); - - setStatusText("Connecting to device..."); - const server = await dev.gatt.connect(); - const service = await server.getPrimaryService(DIS_UUID); - const control = await service.getCharacteristic(CONTROL_UUID); - - setStatusText("Reading device information..."); - const identity = await writeJsonAndRead(control, { cmd: "get_identity" }); - - // Keep BLE connection and control characteristic alive for configuration - setSelectedDevice(dev); - setDeviceIdentity(identity); - setBleControl(control); - - // Check if device is already connected to WiFi and registered with cloud - const hasCloudUrl = identity?.cloud?.enabled && identity?.cloud?.wsUrl; - - if (hasCloudUrl) { - // Device is already configured and cloud-connected - offer quick connect - setPhase("quick_connect"); - } else { - // Device needs WiFi configuration - setPhase("configuring"); - } - - setStatusText(""); - setBusy(false); - } catch (e) { - try { - dev?.gatt?.disconnect?.(); - } catch { - // ignore - } - setError(e?.message || String(e)); - setBusy(false); - setPhase("idle"); - setStatusText(""); - } - }; - - const handleConfigure = async () => { - setError(""); - - // Validate SSID - if (!ssid || !ssid.trim()) { - setError("WiFi SSID is required"); - return; - } - - if (!bleControl) { - setError("BLE connection lost. Please scan again."); - handleReset(); - return; - } - - // Check if device is still connected - if (!selectedDevice?.gatt?.connected) { - setError("Device disconnected. Please scan again."); - handleReset(); - return; - } - - setBusy(true); - setStatusText("Configuring WiFi client mode..."); - - try { - // Send WiFi credentials via BLE - // Note: We intentionally do NOT send cloud configuration here. - // The device reads cloud settings from DefaultConfig.cpp (cloud.enabled = true by default). - // This keeps the device autonomous and reduces BLE complexity. - const payload = { - cmd: "set_wifi", - ssid: ssid.trim(), - password: password || "", - }; - - console.log("[InitialSetup] Sending WiFi config:", { - ...payload, - password: password ? "***" : "(empty)", - }); - - const result = await writeJsonAndRead(bleControl, payload); - - console.log("[InitialSetup] Received response:", result); - - // Check if successful - if (result?.ok === false) { - const errorMsg = result?.error || "Failed to configure WiFi"; - console.error("[InitialSetup] Config failed:", errorMsg); - throw new Error(errorMsg); - } - - setStatusText( - "Configuration saved, device will restart automatically..." - ); - - // Device will restart WiFi automatically - give it time - await new Promise((r) => setTimeout(r, 3000)); - - // Close BLE connection - try { - selectedDevice?.gatt?.disconnect?.(); - } catch { - // ignore - } - - // Create device record - const deviceId = - deviceIdentity?.hostname || - deviceIdentity?.deviceId || - selectedDevice?.name || - selectedDevice?.id; - const id = String(deviceId); - - // Use cloud config from device if available - const cloudConfig = deviceIdentity?.cloud?.enabled - ? { - enabled: true, - baseUrl: deviceIdentity.cloud.baseUrl, - tunnel: deviceIdentity.cloud.tunnel, - wsUrl: deviceIdentity.cloud.wsUrl || null, // Full UI WebSocket URL from device - } - : { enabled: false }; - - const record = { - id, - name: - deviceIdentity?.deviceName || - deviceIdentity?.model || - selectedDevice?.name || - String(id), - hostname: deviceIdentity?.hostname || null, - deviceId: String(deviceId), - authToken: deviceIdentity?.token || null, - cloudTunnel: cloudConfig, - lastSeenAtMs: Date.now(), - }; - - setPhase("done"); - setStatusText("Device configured successfully!"); - setBusy(false); - - // Notify parent - setTimeout(() => { - onDeviceProvisioned?.(record, { - identity: deviceIdentity, - }); - }, 1000); - } catch (e) { - try { - selectedDevice?.gatt?.disconnect?.(); - } catch { - // ignore - } - setError(e?.message || String(e)); - setBusy(false); - setStatusText(""); - } - }; - - const handleReset = () => { - // Disconnect BLE if still connected - try { - selectedDevice?.gatt?.disconnect?.(); - } catch { - // ignore - } - - setPhase("idle"); - setSelectedDevice(null); - setDeviceIdentity(null); - setBleControl(null); - setSsid(""); - setPassword(""); - setError(""); - setStatusText(""); - - // If onClose is provided, call it when canceling - if (onClose) { - onClose(); - } - }; - - const handleQuickConnect = async () => { - setError(""); - setBusy(true); - setStatusText("Saving device to your dashboard..."); - - try { - // Close BLE connection - try { - selectedDevice?.gatt?.disconnect?.(); - } catch { - // ignore - } - - // Create device record with cloud info - const deviceId = - deviceIdentity?.hostname || - deviceIdentity?.deviceId || - selectedDevice?.name || - selectedDevice?.id; - const id = String(deviceId); - - const cloudConfig = { - enabled: true, - baseUrl: deviceIdentity.cloud.baseUrl, - tunnel: deviceIdentity.cloud.tunnel, - wsUrl: deviceIdentity.cloud.wsUrl, - }; - - const record = { - id, - name: deviceIdentity?.deviceName || selectedDevice?.name || String(id), - hostname: deviceIdentity?.hostname || null, - deviceId: String(deviceId), - authToken: deviceIdentity?.token || null, - cloudTunnel: cloudConfig, - lastSeenAtMs: Date.now(), - }; - - setPhase("done"); - setStatusText("Device added successfully!"); - setBusy(false); - - // Notify parent - setTimeout(() => { - onDeviceProvisioned?.(record, { - identity: deviceIdentity, - }); - }, 1000); - } catch (e) { - try { - selectedDevice?.gatt?.disconnect?.(); - } catch { - // ignore - } - setError(e?.message || String(e)); - setBusy(false); - setStatusText(""); - } - }; - - const handleClaimCodeSuccess = (deviceRecord) => { - console.log("[BlePairingFlow] Device claimed:", deviceRecord); - setShowClaimEntry(false); - - // Notify parent and close - onDeviceProvisioned?.(deviceRecord, { - viaClaim: true, - }); - - // Show success message - setPhase("done"); - setStatusText("Device claimed successfully!"); - }; - - return ( - - - {/* Icon changes based on phase */} - {phase === "idle" && ( - - )} - {phase === "scanning" && ( - - )} - {(phase === "configuring" || phase === "quick_connect") && ( - - )} - {phase === "done" && ( - - )} - - - {phase === "idle" && "Welcome to ESPWiFi"} - {phase === "scanning" && "Selecting Device"} - {phase === "configuring" && "Configure WiFi"} - {phase === "quick_connect" && "Device Already Configured"} - {phase === "done" && "Setup Complete!"} - - - {/* IDLE PHASE - Initial welcome */} - {phase === "idle" && ( - <> - - Connect to your ESPWiFi device using Bluetooth for initial setup, - or enter a claim code to pair an already-configured device. - - - - {!supported && ( - - Web Bluetooth isn't available in this browser. Use Chrome on - Android or Desktop Chrome for BLE pairing. - - )} - - - - - - {onClose && ( - - )} - - - - Bluetooth pairing requirements: - - -
  • ESPWiFi device powered on and in AP mode
  • -
  • Bluetooth enabled on this device
  • -
  • Chrome, Edge, or Bluetooth-compatible browser
  • -
    -
    -
    - - )} - - {/* CONFIGURING PHASE - WiFi credentials */} - {phase === "configuring" && ( - <> - - - Device selected:{" "} - {deviceIdentity?.deviceName || - selectedDevice?.name || - "Unknown"} - - - - - Enter your WiFi network credentials. The device will switch to - client mode and restart to connect to your network. - - - - setSsid(e.target.value)} - autoComplete="off" - fullWidth - autoFocus - disabled={busy} - required - error={!ssid && error.includes("SSID")} - helperText={ - !ssid && error.includes("SSID") ? "SSID is required" : "" - } - /> - setPassword(e.target.value)} - type={showPassword ? "text" : "password"} - autoComplete="off" - fullWidth - disabled={busy} - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - onMouseDown={(e) => e.preventDefault()} - edge="end" - disabled={busy} - > - {showPassword ? : } - - - ), - }} - /> - - - - - - - - )} - - {/* QUICK CONNECT PHASE - Device already configured */} - {phase === "quick_connect" && ( - <> - - - Device found:{" "} - {deviceIdentity?.deviceName || - selectedDevice?.name || - "Unknown"} - - - - - - This device is already connected to WiFi and registered with the - cloud. - - - - - Would you like to add this device to your dashboard and connect - via cloud? - - - - - - - - - - - )} - - {/* DONE PHASE - Success message */} - {phase === "done" && ( - <> - - Your ESPWiFi device has been configured and is connecting to your - WiFi network. - - - {onClose && ( - - )} - - )} - - {/* Status/Error messages */} - {busy && statusText && ( - - - - {statusText} - - - )} - - {error && ( - - {error} - - )} -
    - - {phase === "idle" && ( - - Once configured, your device will connect to WiFi. - - )} - - {/* Claim Code Entry Dialog */} - setShowClaimEntry(false)} - onDeviceClaimed={handleClaimCodeSuccess} - existingDevices={[]} - /> -
    - ); -} diff --git a/dashboard/src/components/BluetoothButton.js b/dashboard/src/components/BluetoothButton.js index abce18e..693bc69 100644 --- a/dashboard/src/components/BluetoothButton.js +++ b/dashboard/src/components/BluetoothButton.js @@ -1,14 +1,9 @@ import React, { useState } from "react"; -import { Fab, Tooltip, Dialog, DialogContent, IconButton } from "@mui/material"; +import { Fab, Tooltip } from "@mui/material"; import BluetoothSearchingIcon from "@mui/icons-material/BluetoothSearching"; -import CloseIcon from "@mui/icons-material/Close"; -import BlePairingFlow from "./BlePairingFlow"; +import BluetoothSettingsModal from "./BluetoothSettingsModal"; -export default function BluetoothButton({ - config, - deviceOnline, - onDevicePaired, -}) { +export default function BluetoothButton({ config, onDevicePaired }) { const [modalOpen, setModalOpen] = useState(false); const handleClick = () => { @@ -19,37 +14,19 @@ export default function BluetoothButton({ setModalOpen(false); }; - const handleDevicePaired = (deviceRecord, details) => { - setModalOpen(false); - onDevicePaired?.(deviceRecord, details); - }; - - const isDisabled = !config; - return ( <> - + @@ -58,42 +35,16 @@ export default function BluetoothButton({ - - - - - - - - + {modalOpen && ( + {}} + saveConfigToDevice={() => {}} + deviceOnline={Boolean(config)} + /> + )} ); } diff --git a/dashboard/src/components/BluetoothSettingsModal.js b/dashboard/src/components/BluetoothSettingsModal.js index 98efab5..bb7f358 100644 --- a/dashboard/src/components/BluetoothSettingsModal.js +++ b/dashboard/src/components/BluetoothSettingsModal.js @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Box, Typography, @@ -12,14 +12,18 @@ import { Paper, Collapse, TextField, + IconButton, + InputAdornment, } from "@mui/material"; import BluetoothIcon from "@mui/icons-material/Bluetooth"; +import BluetoothSearchingIcon from "@mui/icons-material/BluetoothSearching"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import ReadIcon from "@mui/icons-material/Visibility"; import WriteIcon from "@mui/icons-material/Edit"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import SettingsModal from "./SettingsModal"; -import { buildApiUrl, getFetchOptions } from "../utils/apiUtils"; export default function BluetoothSettingsModal({ open, @@ -28,7 +32,6 @@ export default function BluetoothSettingsModal({ saveConfig, saveConfigToDevice, deviceOnline, - bleStatus, }) { const [connectedDevice, setConnectedDevice] = useState(null); const [services, setServices] = useState([]); @@ -37,55 +40,145 @@ export default function BluetoothSettingsModal({ const [writeValue, setWriteValue] = useState(""); const [loading, setLoading] = useState(false); const [characteristicValues, setCharacteristicValues] = useState({}); + const [notificationLog, setNotificationLog] = useState([]); + const [pairedDevices, setPairedDevices] = useState([]); + const [showPassword, setShowPassword] = useState(false); - // Check if BLE is actually running (not just configured) - const isBLERunning = bleStatus?.status !== "not_initialized"; + // ESPWiFi Control Characteristic UUID + const ESPWIFI_CONTROL_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"; + const ESPWIFI_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb"; - // Check if Web Bluetooth is supported - const isWebBluetoothSupported = () => { - return navigator.bluetooth !== undefined; + // Load paired devices from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem("bleConnectedDevices"); + if (stored) { + const devices = JSON.parse(stored); + setPairedDevices(devices); + } + } catch (err) { + console.error("Failed to load paired devices:", err); + } + }, []); + + // Save device to paired list + const savePairedDevice = (device) => { + try { + const deviceInfo = { + id: device.id, + name: device.name || "Unknown Device", + lastConnected: Date.now(), + }; + + setPairedDevices((prev) => { + const filtered = prev.filter((d) => d.id !== device.id); + const updated = [deviceInfo, ...filtered].slice(0, 10); // Keep last 10 + localStorage.setItem("bleConnectedDevices", JSON.stringify(updated)); + return updated; + }); + } catch (err) { + console.error("Failed to save paired device:", err); + } }; - const handleToggleESPBLE = async () => { - if (!deviceOnline) return; + // Remove device from paired list + const removePairedDevice = (deviceId) => { + setPairedDevices((prev) => { + const updated = prev.filter((d) => d.id !== deviceId); + localStorage.setItem("bleConnectedDevices", JSON.stringify(updated)); + return updated; + }); + }; + // Handle ESPWiFi-specific commands + const handleESPWiFiCommand = async (cmd, params) => { + setError(null); setLoading(true); + try { - // Use actual runtime status, not config - const endpoint = isBLERunning ? "/api/ble/stop" : "/api/ble/start"; - const url = buildApiUrl(endpoint); - const options = getFetchOptions({ method: "POST" }); - const response = await fetch(url, options); - - if (response.ok) { - const data = await response.json(); - console.log("BLE toggle response:", data); - - // Update config to match the action - const newEnabled = !isBLERunning; - const updated = { - ...(config || {}), - ble: { - ...(config?.ble || {}), - enabled: newEnabled, - }, - }; - saveConfig?.(updated); - if (saveConfigToDevice) { - await Promise.resolve(saveConfigToDevice(updated)); + // Find the ESPWiFi control characteristic + const espWifiService = services.find( + (s) => s.uuid === ESPWIFI_SERVICE_UUID + ); + if (!espWifiService) { + throw new Error("ESPWiFi service not found"); + } + + const controlChar = espWifiService.characteristics.find( + (c) => c.uuid === ESPWIFI_CONTROL_UUID + ); + if (!controlChar) { + throw new Error("ESPWiFi control characteristic not found"); + } + + // Build the command JSON + const command = { cmd, ...params }; + const payload = JSON.stringify(command); + console.log("[ESPWiFi] Sending command:", payload); + + // Write the command + const encoder = new TextEncoder(); + await controlChar.characteristic.writeValue(encoder.encode(payload)); + + // Read the response (for characteristics that support read) + if (controlChar.properties.includes("read")) { + const response = await controlChar.characteristic.readValue(); + const decoder = new TextDecoder("utf-8"); + const responseText = decoder.decode(response); + console.log("[ESPWiFi] Response:", responseText); + + try { + const responseJson = JSON.parse(responseText); + + // Add to notification log + setNotificationLog((prev) => [ + { + timestamp: new Date().toLocaleTimeString(), + data: responseJson, + raw: responseText, + }, + ...prev.slice(0, 9), + ]); + + // Update characteristic values display + const key = `${ESPWIFI_SERVICE_UUID}-${ESPWIFI_CONTROL_UUID}`; + setCharacteristicValues((prev) => ({ + ...prev, + [key]: { + hex: Array.from(new Uint8Array(response.buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "), + text: responseText, + timestamp: new Date().toLocaleTimeString(), + }, + })); + + if (!responseJson.ok) { + setError(responseJson.error || "Command failed"); + } + } catch (e) { + setError("Failed to parse response JSON"); } - setError(null); - } else { - const data = await response.json(); - setError(data.message || "Failed to toggle BLE"); } + + // If characteristic supports notify, wait for notification + if (controlChar.properties.includes("notify")) { + // Response will come via notification handler + console.log("[ESPWiFi] Waiting for notification response..."); + } + + setLoading(false); } catch (err) { - setError("Network error: " + err.message); - } finally { + setError("Command failed: " + err.message); setLoading(false); } }; + // Check if Web Bluetooth is supported + const isWebBluetoothSupported = () => { + return navigator.bluetooth !== undefined; + }; + const handleScan = async () => { if (!isWebBluetoothSupported()) { setError( @@ -105,6 +198,7 @@ export default function BluetoothSettingsModal({ "generic_access", "device_information", "battery_service", + ESPWIFI_SERVICE_UUID, ], }); @@ -120,6 +214,82 @@ export default function BluetoothSettingsModal({ } }; + const handleConnectToPaired = async (pairedDevice) => { + setError(""); + setLoading(true); + + try { + // Try to get previously authorized devices (Chrome 85+) + if (navigator.bluetooth.getDevices) { + const devices = await navigator.bluetooth.getDevices(); + console.log("[BLE] Found authorized devices:", devices); + + const device = devices.find( + (d) => d.id === pairedDevice.id || d.name === pairedDevice.name + ); + + if (device) { + console.log( + "[BLE] Found matching device, connecting directly:", + device.name + ); + + // If already connected, disconnect first to ensure clean reconnection + if (device.gatt && device.gatt.connected) { + console.log( + "[BLE] Device already connected, using existing connection" + ); + await connectToDevice(device); + return; + } + + // Connect directly without picker + try { + await connectToDevice(device); + console.log("[BLE] Successfully reconnected without picker"); + return; + } catch (connectErr) { + console.error("[BLE] Direct connection failed:", connectErr); + // Don't fall through to picker, just show error + throw connectErr; + } + } else { + console.log( + "[BLE] Device not in authorized list (ID:", + pairedDevice.id, + "Name:", + pairedDevice.name, + ")" + ); + } + } else { + console.log("[BLE] getDevices() not supported, will show picker"); + } + + // Fallback: If getDevices not available or device not found, show picker + console.log("[BLE] Opening device picker as fallback..."); + const device = await navigator.bluetooth.requestDevice({ + acceptAllDevices: true, + optionalServices: [ + "generic_access", + "device_information", + "battery_service", + ESPWIFI_SERVICE_UUID, + ], + }); + + console.log("[BLE] Selected device from picker:", device.name); + await connectToDevice(device); + } catch (err) { + if (err.name === "NotFoundError") { + setError("No device selected"); + } else { + setError("Connection failed: " + err.message); + } + setLoading(false); + } + }; + const connectToDevice = async (device) => { try { setLoading(true); @@ -162,6 +332,77 @@ export default function BluetoothSettingsModal({ characteristics: charsData, service: service, // Store actual service object }); + + // Auto-subscribe to ESPWiFi control characteristic notifications + if (service.uuid === ESPWIFI_SERVICE_UUID) { + const controlChar = characteristics.find( + (c) => c.uuid === ESPWIFI_CONTROL_UUID + ); + if (controlChar && controlChar.properties.notify) { + try { + await controlChar.startNotifications(); + console.log( + "[ESPWiFi] Subscribed to control characteristic notifications" + ); + + controlChar.addEventListener( + "characteristicvaluechanged", + (event) => { + const value = event.target.value; + const decoder = new TextDecoder("utf-8"); + const text = decoder.decode(value); + + console.log("[ESPWiFi] Notification received:", text); + + try { + const response = JSON.parse(text); + + // Add to notification log + setNotificationLog((prev) => [ + { + timestamp: new Date().toLocaleTimeString(), + data: response, + raw: text, + }, + ...prev.slice(0, 9), // Keep last 10 notifications + ]); + + // Update display + const key = `${ESPWIFI_SERVICE_UUID}-${ESPWIFI_CONTROL_UUID}`; + setCharacteristicValues((prev) => ({ + ...prev, + [key]: { + hex: Array.from(new Uint8Array(value.buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "), + text: text, + timestamp: new Date().toLocaleTimeString(), + subscribed: true, + }, + })); + } catch (e) { + console.error("[ESPWiFi] Failed to parse notification:", e); + // Log unparseable notifications too + setNotificationLog((prev) => [ + { + timestamp: new Date().toLocaleTimeString(), + data: null, + raw: text, + error: "Failed to parse JSON", + }, + ...prev.slice(0, 9), + ]); + } + } + ); + } catch (err) { + console.error( + "[ESPWiFi] Failed to subscribe to notifications:", + err + ); + } + } + } } setConnectedDevice({ @@ -171,6 +412,10 @@ export default function BluetoothSettingsModal({ server: server, }); setServices(servicesData); + + // Save to paired devices list + savePairedDevice(device); + setLoading(false); } catch (err) { setError("Connection failed: " + err.message); @@ -186,6 +431,7 @@ export default function BluetoothSettingsModal({ setServices([]); setExpandedService(null); setCharacteristicValues({}); + setNotificationLog([]); }; const handleRead = async (serviceUuid, charUuid, characteristic) => { @@ -355,24 +601,11 @@ export default function BluetoothSettingsModal({ "00002a29-0000-1000-8000-00805f9b34fb": "Manufacturer Name", "00002a37-0000-1000-8000-00805f9b34fb": "Heart Rate Measurement", "00002a38-0000-1000-8000-00805f9b34fb": "Body Sensor Location", + "0000fff1-0000-1000-8000-00805f9b34fb": "ESPWiFi Control", }; return characteristics[uuid] || `Custom Characteristic`; }; - const getStatusChip = () => { - const statusMap = { - not_initialized: { label: "Not Initialized", color: "default" }, - initialized: { label: "Initialized", color: "info" }, - advertising: { label: "Advertising", color: "warning" }, - connected: { label: "Connected", color: "success" }, - }; - - const status = bleStatus?.status || "not_initialized"; - const { label, color } = statusMap[status] || statusMap.not_initialized; - - return ; - }; - return ( + Web Bluetooth } > - {/* ESP32 BLE Status Section */} - {deviceOnline && ( - - - {config?.deviceName || "ESP32"} BLE Advertising - - - Status: - {getStatusChip()} - {bleStatus?.address && ( - - {bleStatus.address} - - )} - - - - - )} - {error && ( setError(null)}> {error} @@ -442,21 +652,77 @@ export default function BluetoothSettingsModal({ Connect to BLE Device - Click the button below to scan and connect to a BLE device using - your browser. + Scan for and connect to BLE devices using Web Bluetooth. View GATT + services, read characteristics, and interact with BLE devices. + + {/* Paired Devices List */} + {pairedDevices.length > 0 && ( + + + + Previously Paired Devices + + {pairedDevices.map((device) => ( + + + + {device.name} + + + Last connected:{" "} + {new Date(device.lastConnected).toLocaleString()} + + + + + + + + ))} + + )} )} @@ -488,6 +754,177 @@ export default function BluetoothSettingsModal({ + {/* ESPWiFi Specialized Controls */} + {services.some( + (s) => s.uuid === "0000180a-0000-1000-8000-00805f9b34fb" + ) && ( + <> + + ESPWiFi Controls + + + {/* Notification Log */} + {notificationLog.length > 0 && ( + + + Notifications ({notificationLog.length}) + + + + {notificationLog.map((log, idx) => ( + + + {log.timestamp} + + + {log.error + ? `ERROR: ${log.error}\n${log.raw}` + : JSON.stringify(log.data, null, 2)} + + + ))} + + + )} + + {/* WiFi Configuration */} + + + Configure WiFi + + + setWriteValue(e.target.value)} + placeholder="MyNetwork" + disabled={loading} + /> + { + if (e.key === "Enter") { + const password = e.target.value; + handleESPWiFiCommand("set_wifi", { + ssid: writeValue, + password, + }); + } + }} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(e) => e.preventDefault()} + edge="end" + size="small" + > + {showPassword ? ( + + ) : ( + + )} + + + ), + }} + /> + + + + + {/* Get Info */} + + + Device Information + + + + + + + )} + Services & Characteristics ({services.length}) diff --git a/dashboard/src/components/FileBrowser.js b/dashboard/src/components/FileBrowser.js index 4ac117d..1b6a020 100644 --- a/dashboard/src/components/FileBrowser.js +++ b/dashboard/src/components/FileBrowser.js @@ -99,36 +99,17 @@ const FileBrowserComponent = ({ config, deviceOnline, controlWs }) => { // Helper to send WebSocket command and wait for response const sendWsCommand = useCallback( (cmd) => { - return new Promise((resolve, reject) => { - if (!controlWs || controlWs.readyState !== WebSocket.OPEN) { - reject(new Error("WebSocket not connected")); - return; - } + if (!controlWs || controlWs.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("WebSocket not connected")); + } - const timeout = setTimeout(() => { - reject(new Error("WebSocket command timed out")); - }, 10000); - - const handleMessage = (event) => { - try { - const response = JSON.parse(event.data); - if (response.cmd === cmd.cmd) { - clearTimeout(timeout); - controlWs.removeEventListener("message", handleMessage); - if (response.ok === false) { - reject(new Error(response.error || "Command failed")); - } else { - resolve(response); - } - } - } catch (err) { - // Ignore parse errors for other messages - } - }; + if (typeof controlWs.sendCommand !== "function") { + return Promise.reject( + new Error("WebSocket sendCommand method not available") + ); + } - controlWs.addEventListener("message", handleMessage); - controlWs.send(JSON.stringify(cmd)); - }); + return controlWs.sendCommand(cmd, 10000); }, [controlWs] ); diff --git a/dashboard/src/components/LogsSettingsModal.js b/dashboard/src/components/LogsSettingsModal.js index 40d1c31..9d08885 100644 --- a/dashboard/src/components/LogsSettingsModal.js +++ b/dashboard/src/components/LogsSettingsModal.js @@ -904,9 +904,7 @@ export default function LogsSettingsModal({ {logLoading && logs === "" ? ( Loading logs... ) : logs === "" ? ( - - No logs available. Click "Refresh" to fetch. - + Loading logs... ) : ( <> {logs} diff --git a/dashboard/src/components/MusicPlayerModule.js b/dashboard/src/components/MusicPlayerModule.js index 0f5160d..fde3aa1 100644 --- a/dashboard/src/components/MusicPlayerModule.js +++ b/dashboard/src/components/MusicPlayerModule.js @@ -126,36 +126,15 @@ export default function MusicPlayerModule({ // Helper to send WebSocket command and wait for response const sendWsCommand = async (cmd) => { - return new Promise((resolve, reject) => { - if (!controlWs || controlWs.readyState !== WebSocket.OPEN) { - reject(new Error("WebSocket not connected")); - return; - } + if (!controlWs || controlWs.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } - const timeout = setTimeout(() => { - reject(new Error("WebSocket command timed out")); - }, 10000); - - const handleMessage = (event) => { - try { - const response = JSON.parse(event.data); - if (response.cmd === cmd.cmd) { - clearTimeout(timeout); - controlWs.removeEventListener("message", handleMessage); - if (response.ok === false) { - reject(new Error(response.error || "Command failed")); - } else { - resolve(response); - } - } - } catch (err) { - // Ignore parse errors for other messages - } - }; + if (typeof controlWs.sendCommand !== "function") { + throw new Error("WebSocket sendCommand method not available"); + } - controlWs.addEventListener("message", handleMessage); - controlWs.send(JSON.stringify(cmd)); - }); + return await controlWs.sendCommand(cmd, 10000); }; // Load music files from SD card using WebSocket @@ -190,9 +169,9 @@ export default function MusicPlayerModule({ ); setMusicFiles(audioFiles); - console.log("🎡 Loaded music files:", audioFiles); + console.log("🎡 Loaded music files"); } catch (error) { - console.error("🎡 Error loading music files:", error); + console.warn("🎡 Error loading music files:", error.message || error); setMusicFiles([]); } finally { setLoading(false); @@ -200,9 +179,14 @@ export default function MusicPlayerModule({ }; // Load music files on mount and when config changes + // Add a small delay to ensure server is ready after WebSocket connection useEffect(() => { if (deviceOnline && controlWs && controlWs.readyState === WebSocket.OPEN) { - loadMusicFiles(); + // Delay slightly to ensure server is ready + const timer = setTimeout(() => { + loadMusicFiles(); + }, 500); + return () => clearTimeout(timer); } }, [deviceOnline, config?.musicDir, controlWs]); @@ -214,14 +198,14 @@ export default function MusicPlayerModule({ const filePath = `${musicDir}/${file.name}`; // Encode the file path properly - split by '/' and encode each segment - const mdnsHostname = globalConfig?.hostname || globalConfig?.deviceName; const encodedPath = filePath .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); // Use /sd prefix for SD card file system - let fileUrl = buildApiUrl(`/sd${encodedPath}`, mdnsHostname); + // Don't pass mdnsHostname - use relative path so it uses current browser location + let fileUrl = buildApiUrl(`/sd${encodedPath}`); // Add auth token as query parameter const token = getAuthToken(); @@ -276,6 +260,8 @@ export default function MusicPlayerModule({ audioRef.current.currentTime = 0; setProgress(0); setCurrentTime(0); + setIsPlaying(false); + setIsPaused(false); }; // Handle skip next diff --git a/dashboard/src/components/RSSIButton.js b/dashboard/src/components/RSSIButton.js index 170e248..a4a8081 100644 --- a/dashboard/src/components/RSSIButton.js +++ b/dashboard/src/components/RSSIButton.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { Fab, Tooltip } from "@mui/material"; import SignalCellularAltIcon from "@mui/icons-material/SignalCellularAlt"; import SignalCellularAlt1BarIcon from "@mui/icons-material/SignalCellularAlt1Bar"; @@ -13,59 +13,24 @@ export default function RSSIButton({ onRSSIDataChange, getRSSIColor, controlRssi = null, - onRequestRssi = null, + musicPlaybackState = { isPlaying: false, isPaused: false }, }) { const [modalOpen, setModalOpen] = useState(false); const [rssiValue, setRssiValue] = useState(null); - const pollRef = useRef(null); - // Cleanup polling on unmount - useEffect(() => { - return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }; - }, []); - - // RSSI comes from /ws/control via {cmd:"get_rssi"}. - // Poll while device is online (lightweight; no extra websocket). - useEffect(() => { - if (!deviceOnline) { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - setRssiValue(null); - if (onRSSIDataChange) onRSSIDataChange(null, false); - return; - } - - // Kick once immediately, then poll. - if (typeof onRequestRssi === "function") { - onRequestRssi(); - } - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = setInterval(() => { - if (typeof onRequestRssi === "function") onRequestRssi(); - }, 1000); - - return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }; - }, [deviceOnline, onRequestRssi, onRSSIDataChange]); - - // Consume latest RSSI from control channel + // Consume latest RSSI from control channel (polling is handled in App.js) useEffect(() => { if (typeof controlRssi === "number") { setRssiValue(controlRssi); if (onRSSIDataChange) onRSSIDataChange(controlRssi, true); + } else if (controlRssi === null) { + // Clear RSSI value when device is offline + if (!deviceOnline) { + setRssiValue(null); + if (onRSSIDataChange) onRSSIDataChange(null, false); + } } - }, [controlRssi, onRSSIDataChange]); + }, [controlRssi, deviceOnline, onRSSIDataChange]); // Get the appropriate signal icon based on RSSI value const getRSSIIconComponent = (rssiValue) => { @@ -80,6 +45,8 @@ export default function RSSIButton({ // Get RSSI status text const getRSSIStatusText = (rssiValue) => { + if (musicPlaybackState.isPlaying) + return "RSSI disabled during music playback"; if (!deviceOnline) return "Control socket disconnected"; if (rssiValue === null || rssiValue === undefined) return "Connected, waiting for data..."; @@ -92,7 +59,7 @@ export default function RSSIButton({ }; const handleClick = () => { - if (deviceOnline) { + if (deviceOnline && !musicPlaybackState.isPlaying) { setModalOpen(true); } }; @@ -101,22 +68,23 @@ export default function RSSIButton({ setModalOpen(false); }; + // Button is disabled if device offline OR music is playing + const isDisabled = !deviceOnline || musicPlaybackState.isPlaying; + const button = ( @@ -125,7 +93,7 @@ export default function RSSIButton({ ); // Wrap disabled buttons in a span to fix MUI Tooltip warning - if (!deviceOnline) { + if (isDisabled) { return ( {button} @@ -142,15 +110,9 @@ export default function RSSIButton({ config={config} saveConfig={saveConfig} saveConfigToDevice={saveConfigToDevice} - deviceOnline={deviceOnline} open={modalOpen} onClose={handleCloseModal} - onRSSIDataChange={(value, connected) => { - setRssiValue(value); - if (onRSSIDataChange) { - onRSSIDataChange(value, connected); - } - }} + rssiValue={rssiValue} /> )} diff --git a/dashboard/src/components/RSSISettingsModal.js b/dashboard/src/components/RSSISettingsModal.js index dbeea4b..2d3566b 100644 --- a/dashboard/src/components/RSSISettingsModal.js +++ b/dashboard/src/components/RSSISettingsModal.js @@ -1,8 +1,7 @@ -import React, { useState, useEffect, useRef } from "react"; +import React from "react"; import { Typography, Box } from "@mui/material"; import SignalCellularAltIcon from "@mui/icons-material/SignalCellularAlt"; import SettingsModal from "./SettingsModal"; -import { buildWebSocketUrl } from "../utils/apiUtils"; export default function RSSISettingsModal({ config, @@ -10,122 +9,8 @@ export default function RSSISettingsModal({ saveConfigToDevice, open = false, onClose, - onRSSIDataChange, + rssiValue = null, }) { - // RSSI data state - const [rssiValue, setRssiValue] = useState(null); - const wsRef = useRef(null); - - // WebSocket connection for RSSI data - always connect - useEffect(() => { - // Add a delay to allow the backend to start the RSSI service - const connectTimeout = setTimeout(() => { - // Construct WebSocket URL - // Prefer explicit hostname (usually DNS/mDNS-safe) over display name. - const mdnsHostname = config?.hostname || config?.deviceName; - const wsUrl = buildWebSocketUrl("/ws/rssi", mdnsHostname); - - // Connect to RSSI WebSocket - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, true); - } - }; - - ws.onmessage = (event) => { - try { - // RSSI data comes as plain text (just the number) - const rssiValue = parseInt(event.data); - if (!isNaN(rssiValue)) { - setRssiValue(rssiValue); - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, true); - } - } - } catch (error) { - console.error("Error parsing RSSI data:", error); - } - }; - - ws.onerror = (error) => { - console.error("RSSI WebSocket error:", error); - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, false); - } - }; - - ws.onclose = (event) => { - wsRef.current = null; - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, false); - } - - // Only retry if it's not a normal closure - if (event.code !== 1000) { - setTimeout(() => { - // Double-check before retrying - if (!wsRef.current) { - // Retry connection - const retryWs = new WebSocket(wsUrl); - wsRef.current = retryWs; - - retryWs.onopen = () => { - // WebSocket retry connected - }; - retryWs.onmessage = (event) => { - try { - // RSSI data comes as plain text (just the number) - const rssiValue = parseInt(event.data); - if (!isNaN(rssiValue)) { - setRssiValue(rssiValue); - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, true); - } - } - } catch (error) { - console.error("Error parsing RSSI data:", error); - } - }; - retryWs.onerror = (error) => { - console.error("RSSI WebSocket retry error:", error); - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, false); - } - }; - retryWs.onclose = () => { - wsRef.current = null; - if (onRSSIDataChange) { - onRSSIDataChange(rssiValue, false); - } - }; - } - }, 2000); - } - }; - }, 1000); // 1 second delay - - return () => { - clearTimeout(connectTimeout); - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, []); - - // Cleanup WebSocket on unmount - useEffect(() => { - return () => { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, []); - const handleCloseModal = () => { if (onClose) onClose(); }; @@ -145,7 +30,7 @@ export default function RSSISettingsModal({ }} > - RSSI + RSSI {rssiValue !== null ? `${rssiValue} dBm` : "N/A"} } actions={null} @@ -168,26 +53,6 @@ export default function RSSISettingsModal({ > Received Signal Strength Indicator (RSSI) -

    - WebSocket URL: ws:// - {config?.hostname - ? `${config.hostname}` - : config?.deviceName - ? `${config.deviceName}` - : window.location.hostname} - :{window.location.port || 80}/ws/rssi - - - RSSI (Received Signal Strength Indicator) is a device-reported measure - of radio signal power at the receiver. It’s often shown in dBm - (negative values), where numbers closer to 0 mean stronger signal: - around βˆ’30 dBm is excellent, about βˆ’67 dBm is solid for Wi‑Fi/VoIP, - βˆ’75 to βˆ’85 dBm is weak, and below βˆ’90 dBm is likely unusable. RSSI - isn’t an absolute standardβ€”scales vary by chipsetβ€”so treat it as a - relative indicator for link quality, range, and placement.
    diff --git a/dashboard/src/components/SettingsButtonBar.js b/dashboard/src/components/SettingsButtonBar.js index 98bd3e4..1c81130 100644 --- a/dashboard/src/components/SettingsButtonBar.js +++ b/dashboard/src/components/SettingsButtonBar.js @@ -19,7 +19,6 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import RSSIButton from "./RSSIButton"; import CameraButton from "./CameraButton"; import DeviceSettingsButton from "./DeviceSettingsButton"; -import DevicePickerButton from "./DevicePickerButton"; import LogsButton from "./LogsButton"; import FileBrowserButton from "./FileBrowserButton"; import BluetoothButton from "./BluetoothButton"; @@ -31,7 +30,6 @@ const ENABLED_KEY = "settingsButtonEnabled.v1"; // Button IDs that cannot be removed from the bar const NON_REMOVABLE_IDS = new Set([ - "devicePicker", // RSSI is a core status signal; keep it visible by default. "rssi", "deviceSettings", @@ -40,9 +38,9 @@ const NON_REMOVABLE_IDS = new Set([ // Default button order priority for initial setup const DEFAULT_ORDER_PRIORITY = [ - "devicePicker", "rssi", "deviceSettings", + "bluetooth", "addModule", "files", "logs", @@ -50,7 +48,6 @@ const DEFAULT_ORDER_PRIORITY = [ // Button label mapping for display const BUTTON_LABELS = { - devicePicker: "Devices", deviceSettings: "Device Settings", rssi: "RSSI", bluetooth: "Bluetooth", @@ -101,43 +98,30 @@ function insertAfter(list, id, afterId) { /** * Ensure non-removable buttons are in preferred positions * @param {string[]} list - Current button list - * @param {boolean} showDevicePicker - If false, do NOT force the Devices button * @returns {string[]} List with non-removables positioned correctly */ -function ensureNonRemovablesInPreferredPositions( - list, - showDevicePicker = true -) { +function ensureNonRemovablesInPreferredPositions(list) { let next = Array.isArray(list) ? [...list] : []; - if (showDevicePicker) { - // Devices button should lead when possible - if (!next.includes("devicePicker")) { - next = ["devicePicker", ...next]; - } - // RSSI next (when available) - if (!next.includes("rssi")) { - next = insertAfter(next, "rssi", "devicePicker"); - } - } else { - // If persisted state included it, strip it. - next = next.filter((id) => id !== "devicePicker"); - // If no device picker, prefer RSSI at the front. - if (!next.includes("rssi")) { - next = ["rssi", ...next]; - } + + // RSSI at the front when available + if (!next.includes("rssi")) { + next = ["rssi", ...next]; } + // Settings after RSSI when possible if (!next.includes("deviceSettings")) { next = next.includes("rssi") ? insertAfter(next, "deviceSettings", "rssi") : ["deviceSettings", ...next]; } + // Add after Settings when possible if (!next.includes("addModule")) { next = next.includes("deviceSettings") ? insertAfter(next, "addModule", "deviceSettings") : [...next, "addModule"]; } + return next; } @@ -246,20 +230,14 @@ export default function SettingsButtonBar({ getRSSIColor, getRSSIIcon, controlRssi = null, - onRequestRssi = null, logsText = "", logsError = "", onRequestLogs = null, - devices, - selectedDeviceId, - onSelectDevice, - onRemoveDevice, - onPairNewDevice, - showDevicePicker = true, cloudMode = false, controlConnected = false, deviceInfoOverride = null, controlWs = null, + musicPlaybackState = { isPlaying: false, isPaused: false }, }) { // Track previous visible IDs to detect newly available buttons const prevVisibleIdsRef = useRef(new Set()); @@ -269,12 +247,12 @@ export default function SettingsButtonBar({ * * When control socket is disconnected (deviceOnline=false), all buttons * except devicePicker should be disabled to prevent operations that require - * the WebSocket connection (e.g., RSSI polling, logs, settings changes). + * the WebSocket connection (e.g., logs, settings changes). */ const availableButtons = useMemo(() => { const items = []; const wifiMode = config?.wifi?.mode || "client"; - // Don't mount RSSIButton until we have config; it auto-connects a WS. + // RSSI button is available when config exists and not in access point mode const rssiAvailable = Boolean(config) && wifiMode !== "accessPoint"; // In paired/tunnel mode, use the control socket to support settings/logs. // Keep other HTTP-backed features (files/modules) disabled for now. @@ -282,28 +260,14 @@ export default function SettingsButtonBar({ const httpCapable = deviceOnline && !cloudMode; const logsCapable = (cloudMode && controlConnected) || deviceOnline; - if (showDevicePicker) { - items.push({ - id: "devicePicker", - render: () => ( - - ), - }); - } - items.push({ id: "deviceSettings", render: () => ( ), }); } - if (config && config?.bluetooth?.installed !== false) { - items.push({ - id: "bluetooth", - render: () => ( - { - // Handle device paired from BLE - if (onPairNewDevice) { - onPairNewDevice(deviceRecord, details); - } - }} - /> - ), - }); - } + // Always show Bluetooth button - never disabled (keep enabled even during music playback) + items.push({ + id: "bluetooth", + render: () => , + }); // Show camera button if camera is installed and there's at least one camera module const hasCameraModule = config?.modules?.some((m) => m.type === "camera"); @@ -357,7 +309,7 @@ export default function SettingsButtonBar({ render: () => ( ), @@ -369,7 +321,7 @@ export default function SettingsButtonBar({ render: () => ( ( ), @@ -395,7 +347,7 @@ export default function SettingsButtonBar({ render: () => ( om || !s) { - return BLE_ATT_ERR_UNLIKELY; - } - return os_mbuf_append(ctxt->om, s, strlen(s)) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; -} - // ---- ESPWiFi control characteristic request/response -// -// We keep a small last-response buffer that the client can read after writing a -// JSON command. This avoids adding another notify characteristic for now. -static char s_controlResp[512] = "ok"; -static size_t s_controlRespLen = 2; - -static void setControlRespStr(const char *s) { - if (!s) { - s_controlResp[0] = '\0'; - s_controlRespLen = 0; +// Helpers +static void sendJsonNotify(uint16_t conn_handle, uint16_t attr_handle, + const JsonDocument &doc) { + if (conn_handle == 0 || attr_handle == 0) { return; } - const size_t n = strnlen(s, sizeof(s_controlResp) - 1); - memcpy(s_controlResp, s, n); - s_controlResp[n] = '\0'; - s_controlRespLen = n; -} -static void setControlRespJson(const JsonDocument &doc) { - const size_t cap = sizeof(s_controlResp); - size_t written = serializeJson(doc, s_controlResp, cap); - if (written == 0) { - setControlRespStr("{\"ok\":false,\"error\":\"resp_too_large\"}"); - return; + // Increased buffer size to accommodate larger responses like config + // BLE MTU can be negotiated up to 512 bytes (including overhead) + char buf[480]; // Leave some room for BLE protocol overhead + size_t len = serializeJson(doc, buf, sizeof(buf)); + if (len == 0 || len >= sizeof(buf)) { + const char *err = "{\"ok\":false,\"error\":\"resp_too_large\"}"; + len = strlen(err); + memcpy(buf, err, len); } - // serializeJson does not NUL-terminate if it exactly fills. - if (written >= cap) { - s_controlResp[cap - 1] = '\0'; - s_controlRespLen = cap - 1; - } else { - s_controlResp[written] = '\0'; - s_controlRespLen = written; - } -} -static void notifyControlResp(uint16_t conn_handle, uint16_t attr_handle) { - // Best-effort: only works if the client subscribed. - if (conn_handle == 0 || attr_handle == 0) { - return; + struct os_mbuf *om = ble_hs_mbuf_from_flat(buf, len); + if (om) { + (void)ble_gatts_notify_custom(conn_handle, attr_handle, om); } - struct os_mbuf *om = ble_hs_mbuf_from_flat(s_controlResp, s_controlRespLen); - if (!om) { - return; - } - (void)ble_gatts_notify_custom(conn_handle, attr_handle, om); } static bool readOmToBuf(struct os_mbuf *om, char *out, size_t outCap, @@ -146,296 +105,103 @@ static bool readOmToBuf(struct os_mbuf *om, char *out, size_t outCap, return true; } +// BLE Control characteristic handler +static int bleControlHandler(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) { + if (!arg || !ctxt || ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_UNLIKELY; + } + + ESPWiFi *espwifi = static_cast(arg); + + JsonDocument resp; + resp["ok"] = false; + + // Parse JSON command + char reqBuf[320]; + size_t reqLen = 0; + if (!readOmToBuf(ctxt->om, reqBuf, sizeof(reqBuf), &reqLen) || reqLen == 0) { + resp["error"] = "empty"; + sendJsonNotify(conn_handle, attr_handle, resp); + return BLE_ATT_ERR_UNLIKELY; + } + + JsonDocument req; + DeserializationError err = deserializeJson(req, reqBuf, reqLen); + if (err) { + resp["error"] = "bad_json"; + sendJsonNotify(conn_handle, attr_handle, resp); + return BLE_ATT_ERR_UNLIKELY; + } + + const char *cmd = req["cmd"]; + espwifi->log(INFO, "πŸ”΅ BLE cmd='%s'", cmd ? cmd : "(none)"); + + // Commands + if (strcmp(cmd, "get_info") == 0) { + // Full config is often too large for BLE. Return essential fields only. + resp["ok"] = true; + JsonObject cfg = resp["config"].to(); + cfg["deviceName"] = espwifi->config["deviceName"]; + cfg["hostname"] = espwifi->config["hostname"]; + cfg["wifi"]["mode"] = espwifi->config["wifi"]["mode"]; + cfg["wifi"]["started"] = espwifi->config["wifi"]["started"]; + cfg["wifi"]["client"]["ssid"] = espwifi->config["wifi"]["client"]["ssid"]; + cfg["wifi"]["client"]["password"] = + espwifi->config["wifi"]["client"]["password"]; + sendJsonNotify(conn_handle, attr_handle, resp); + return 0; + } + + if (strcmp(cmd, "set_wifi") == 0) { + // Alias for set_wifi_credentials (more consistent naming) + const char *ssid = req["ssid"]; + const char *password = req["password"]; + if (!ssid) { + resp["error"] = "missing_ssid"; + sendJsonNotify(conn_handle, attr_handle, resp); + return BLE_ATT_ERR_UNLIKELY; + } + espwifi->config["wifi"]["ssid"] = ssid; + espwifi->config["wifi"]["mode"] = "client"; + espwifi->config["wifi"]["password"] = password ? password : ""; + espwifi->log(INFO, "πŸ”΅ BLE WiFi set: %s", ssid); + bool saved = espwifi->saveConfig(); + if (!saved) { + resp["error"] = "failed_to_save_config"; + sendJsonNotify(conn_handle, attr_handle, resp); + return BLE_ATT_ERR_UNLIKELY; + } + resp["ok"] = true; + sendJsonNotify(conn_handle, attr_handle, resp); + espwifi->stopWiFi(); + espwifi->feedWatchDog(99); + espwifi->startWiFi(); + return 0; + } + + resp["error"] = "unknown_cmd"; + sendJsonNotify(conn_handle, attr_handle, resp); + return 0; +} + } // namespace // ============================================================================ -// ESPWiFi BLE GATT registry wrappers (registerRoute-style) +// ESPWiFi BLE GATT registry // ============================================================================ void ESPWiFi::startBLEServices() { - // Hook point: define your BLE services/characteristics here. - // - // Recommendation: clear the registry first so repeated start/stop cycles - // don't accumulate duplicated characteristics. - // - // Example: - // clearBleServices(); - // registerBleService16(0x180A); // Device Information - // addBleCharacteristic16(0x180A, 0xFFF2, - // BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE, - // myAccessCb, this); - // - // NOTE: Changes take effect for the current startBLE() call because we call - // this before `ble_gatts_count_cfg()` / `ble_gatts_add_svcs()`. - - // Device Information Service (DIS) must use standard characteristics. - // You can still include your provisioning/control characteristic here for - // Web Bluetooth filtering convenience. - (void)registerBleService16(0x180A); - - // Standard DIS characteristics (read-only strings) - (void)registerBleCharacteristic16( - 0x180A, kDisManufacturerNameString, BLE_GATT_CHR_F_READ, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)espwifi; - (void)conn_handle; - (void)attr_handle; - return bleReadString(ctxt, "ESPWiFi"); - }); - - (void)registerBleCharacteristic16( - 0x180A, kDisModelNumberString, BLE_GATT_CHR_F_READ, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)conn_handle; - (void)attr_handle; - if (!ctxt) { - return BLE_ATT_ERR_UNLIKELY; - } - std::string model = - espwifi->config["deviceName"].isNull() - ? std::string("ESP32") - : espwifi->config["deviceName"].as(); - return os_mbuf_append(ctxt->om, model.c_str(), model.size()) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; - }); - - (void)registerBleCharacteristic16( - 0x180A, kDisSerialNumberString, BLE_GATT_CHR_F_READ, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)conn_handle; - (void)attr_handle; - if (!ctxt) { - return BLE_ATT_ERR_UNLIKELY; - } - std::string serial = espwifi->getBLEAddress(); - return os_mbuf_append(ctxt->om, serial.c_str(), serial.size()) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; - }); - - (void)registerBleCharacteristic16( - 0x180A, kDisFirmwareRevisionString, BLE_GATT_CHR_F_READ, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)conn_handle; - (void)attr_handle; - if (!ctxt) { - return BLE_ATT_ERR_UNLIKELY; - } - const esp_app_desc_t *app = esp_app_get_description(); - std::string fw = (app && app->version[0] != '\0') - ? std::string(app->version) - : espwifi->version(); - return os_mbuf_append(ctxt->om, fw.c_str(), fw.size()) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; - }); - - // Simple claim code characteristic (0x2A2B) for easy access via nRF Connect - // Just read this characteristic to get a fresh 10-minute claim code - (void)registerBleCharacteristic16( - 0x180A, 0x2A2B, // Using 0x2A2B (unused DIS characteristic UUID) - BLE_GATT_CHR_F_READ, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)conn_handle; - (void)attr_handle; - if (!ctxt || ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR) { - return BLE_ATT_ERR_UNLIKELY; - } - - // Generate a fresh claim code valid for 10 minutes - std::string code = - espwifi->getClaimCode(true); // rotate=true for fresh code - uint32_t expiresInMs = espwifi->claimExpiresInMs(); - - espwifi->log( - INFO, "πŸ”΅ Claim Code Generated via BLE: %s (expires in %u seconds)", - code.c_str(), expiresInMs / 1000); - - // Return just the claim code (simple 8-char string) - return os_mbuf_append(ctxt->om, code.c_str(), code.size()) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; - }); - - // Optional: keep a custom control characteristic under DIS for provisioning. - (void)registerBleCharacteristic16( - 0x180A, GattServices::controlCharUUID.value, - BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY, - [](ESPWiFi *espwifi, uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt) -> int { - (void)espwifi; - (void)conn_handle; - (void)attr_handle; - if (!ctxt) { - return BLE_ATT_ERR_UNLIKELY; - } - - if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) { - if (s_controlRespLen == 0) { - return bleReadString(ctxt, "ok"); - } - return os_mbuf_append(ctxt->om, s_controlResp, s_controlRespLen) == 0 - ? 0 - : BLE_ATT_ERR_INSUFFICIENT_RES; - } - - if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) { - // Parse a small JSON request and stage config updates to be applied - // by runSystem() (handleConfigUpdate), not in this BLE stack - // callback. - char reqBuf[320]; - size_t reqLen = 0; - if (!readOmToBuf(ctxt->om, reqBuf, sizeof(reqBuf), &reqLen) || - reqLen == 0) { - espwifi->log(ERROR, "πŸ”΅ BLE Control: Empty request"); - setControlRespStr("{\"ok\":false,\"error\":\"empty\"}"); - return 0; - } - - espwifi->log(DEBUG, "πŸ”΅ BLE Control Write: %zu bytes: %.*s", reqLen, - (int)reqLen, reqBuf); - - // ArduinoJson v7 uses heap allocation automatically - // The default constructor uses the global heap allocator - JsonDocument req; - DeserializationError err = deserializeJson(req, reqBuf, reqLen); - if (err) { - espwifi->log(ERROR, "πŸ”΅ BLE Control: JSON parse error: %s", - err.c_str()); - JsonDocument resp; - resp["ok"] = false; - resp["error"] = "bad_json"; - resp["detail"] = err.c_str(); - setControlRespJson(resp); - return 0; - } - - const char *cmd = req["cmd"]; - if (!cmd) - cmd = ""; - espwifi->log(INFO, "πŸ”΅ BLE Control: cmd='%s'", cmd); - - JsonDocument resp; - resp["ok"] = true; - resp["cmd"] = cmd; - - if (strcmp(cmd, "get_identity") == 0) { - resp["deviceName"] = - espwifi->config["deviceName"].as(); - std::string hostname = - espwifi->config["hostname"].as(); - if (hostname.empty()) { - hostname = espwifi->genHostname(); - } - resp["hostname"] = hostname; - resp["bleAddress"] = espwifi->getBLEAddress(); - const esp_app_desc_t *app = esp_app_get_description(); - resp["fw"] = (app && app->version[0] != '\0') ? app->version - : espwifi->version(); - - // Include auth token if available - const char *authToken = - espwifi->config["auth"]["token"].as(); - if (authToken && authToken[0] != '\0') { - resp["token"] = authToken; - } - - // Include cloud configuration for dashboard pairing - if (espwifi->config["cloud"]["enabled"] | false) { - resp["cloud"]["enabled"] = true; - resp["cloud"]["baseUrl"] = - espwifi->config["cloud"]["baseUrl"].as(); - resp["cloud"]["tunnel"] = - espwifi->config["cloud"]["tunnel"].as(); - - // Include UI WebSocket URL if device is registered with cloud - if (espwifi->cloudCtl.isConnected() && - espwifi->cloudCtl.isRegistered()) { - const char *uiWsUrl = espwifi->cloudCtl.getUiWebSocketUrl(); - if (uiWsUrl && uiWsUrl[0] != '\0') { - resp["cloud"]["wsUrl"] = uiWsUrl; - } - } - } else { - resp["cloud"]["enabled"] = false; - } - } else if (strcmp(cmd, "set_wifi") == 0) { - const char *ssid = req["ssid"]; - const char *password = req["password"]; - if (!password) - password = ""; - - espwifi->log(INFO, "πŸ”΅ BLE set_wifi: ssid=%s (%s), password=%s", - ssid ? ssid : "(null)", - ssid ? (ssid[0] == '\0' ? "empty" : "set") : "null", - password[0] != '\0' ? "***" : "(empty)"); - - if (!ssid || ssid[0] == '\0') { - espwifi->log(ERROR, "πŸ”΅ BLE set_wifi: SSID is null or empty"); - resp["ok"] = false; - resp["error"] = "ssid_required"; - } else { - espwifi->log( - INFO, - "πŸ”΅ BLE set_wifi: Configuring WiFi client mode, SSID='%s'", - ssid); - - JsonDocument updates; - updates["wifi"]["enabled"] = true; - updates["wifi"]["mode"] = "client"; - updates["wifi"]["client"]["ssid"] = ssid; - updates["wifi"]["client"]["password"] = password; - - JsonDocument next = espwifi->mergeJson(espwifi->config, updates); - espwifi->configUpdate = next; - espwifi->requestConfigSave(); - - espwifi->log(INFO, "πŸ”΅ BLE set_wifi: Config queued for save, " - "WiFi restart pending"); - resp["wifi_restart_queued"] = true; - } - } else if (strcmp(cmd, "get_status") == 0) { - resp["wifiMode"] = - espwifi->config["wifi"]["mode"].as(); - resp["ip"] = espwifi->ipAddress(); - resp["bleStatus"] = espwifi->getBLEStatus(); - resp["claim_code"] = espwifi->getClaimCode(false); - resp["claim_expires_in_ms"] = espwifi->claimExpiresInMs(); - } else if (strcmp(cmd, "get_claim") == 0) { - const bool rotate = req["rotate"] | false; - resp["code"] = espwifi->getClaimCode(rotate); - resp["expires_in_ms"] = espwifi->claimExpiresInMs(); - } else { - espwifi->log(WARNING, "πŸ”΅ BLE Control: Unknown command '%s'", cmd); - resp["ok"] = false; - resp["error"] = "unknown_cmd"; - } - - setControlRespJson(resp); - - // Log the response being sent - char respPreview[128]; - size_t respLen = - serializeJson(resp, respPreview, sizeof(respPreview)); - if (respLen < sizeof(respPreview)) { - espwifi->log(DEBUG, "πŸ”΅ BLE Control Response: %s", respPreview); - } else { - espwifi->log(DEBUG, "πŸ”΅ BLE Control Response: (truncated) %.*s...", - 100, respPreview); - } - - notifyControlResp(conn_handle, attr_handle); - return 0; - } - - return BLE_ATT_ERR_UNLIKELY; - }); + clearBleServices(); + + const uint16_t disServiceUUID = 0x180A; + (void)registerBleService16(disServiceUUID); + + // Control characteristic - write with notify for responses + // Pass 'this' as arg so handler can access the ESPWiFi instance + (void)addBleCharacteristic16( + disServiceUUID, GattServices::controlCharUUID.value, + BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY, bleControlHandler, this); } bool ESPWiFi::registerBleService16(uint16_t svcUuid16) { @@ -454,44 +220,7 @@ bool ESPWiFi::addBleCharacteristic16(uint16_t svcUuid16, uint16_t chrUuid16, arg, minKeySize); } -bool ESPWiFi::registerBleCharacteristic16(uint16_t svcUuid16, - uint16_t chrUuid16, uint16_t flags, - BleRouteHandler handler, - uint8_t minKeySize) { - if (!handler) { - return false; - } - - // Ensure service exists (idempotent) WITHOUT resetting characteristics. - (void)gattServices.ensureService16(svcUuid16); - - if (bleRouteCtxCount_ >= kMaxBleRouteContexts) { - log(ERROR, "πŸ”΅ BLE GATT route ctx pool exhausted (%u)", - (unsigned)kMaxBleRouteContexts); - return false; - } - - BleRouteCtx *ctx = &bleRouteCtx_[bleRouteCtxCount_++]; - *ctx = BleRouteCtx{this, handler, svcUuid16, chrUuid16}; - - return addBleCharacteristic16(svcUuid16, chrUuid16, flags, - bleGattAccessTrampoline, ctx, minKeySize); -} - -void ESPWiFi::clearBleServices() { - gattServices.clear(); - bleRouteCtxCount_ = 0; -} - -int ESPWiFi::bleGattAccessTrampoline(uint16_t conn_handle, uint16_t attr_handle, - struct ble_gatt_access_ctxt *ctxt, - void *arg) { - BleRouteCtx *ctx = static_cast(arg); - if (!ctx || !ctx->self || !ctx->handler) { - return BLE_ATT_ERR_UNLIKELY; - } - return ctx->handler(ctx->self, conn_handle, attr_handle, ctxt); -} +void ESPWiFi::clearBleServices() { gattServices.clear(); } bool ESPWiFi::applyBleServiceRegistry(bool restartNow) { if (!restartNow) { @@ -574,142 +303,6 @@ esp_err_t ESPWiFi::startBLEAdvertising() { return ESP_OK; } -/** - * @brief Initialize and start BLE provisioning - * - * Initializes the NimBLE stack, configures GATT services, and starts - * advertising. This function is idempotent - calling it multiple times is safe. - * - * @return true if BLE is started (or was already running), false on failure - * - * @note Does not abort on failure per ESP32 robustness best practices - * @note Cannot run simultaneously with Classic Bluetooth (A2DP) - */ -bool ESPWiFi::startBLE() { - // Early return if already started - if (bleStarted) { - log(DEBUG, "πŸ”΅ BLE Already running"); - return true; - } - -#ifdef CONFIG_BT_A2DP_ENABLE - // Check if Bluetooth A2DP is running (mutual exclusion) - if (btStarted) { - log(WARNING, "πŸ”΅ Cannot start BLE: Bluetooth A2DP is running"); - return false; - } -#endif - - // Re-entrancy guard - static bool initInProgress = false; - if (initInProgress) { - log(WARNING, "πŸ”΅ BLE initialization already in progress"); - return false; - } - initInProgress = true; - - log(INFO, "πŸ”΅ Starting BLE Provisioning"); - - esp_err_t ret; - - // Configure the host callbacks BEFORE nimble_port_init (required) - ble_hs_cfg.sync_cb = [](void) { - extern ESPWiFi espwifi; - espwifi.bleHostSyncHandler(&espwifi); - }; - - ble_hs_cfg.reset_cb = [](int reason) { - extern ESPWiFi espwifi; - espwifi.bleHostResetHandler(reason, &espwifi); - }; - - // Configure BLE security for encrypted connections - // "Just Works" pairing - no PIN required, but connection is encrypted - ble_hs_cfg.sm_bonding = 1; // Enable bonding (stores keys) - ble_hs_cfg.sm_mitm = 0; // No Man-in-the-Middle protection (Just Works) - ble_hs_cfg.sm_sc = 1; // Secure Connections (LE Secure Connections) - ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; // Just Works pairing - ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC; - ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC; - - log(INFO, "πŸ”΅ πŸ” BLE Security: Just Works pairing enabled (encrypted " - "connection) ✨"); - - // Initialize NimBLE host - but only if not already initialized - // Double-init causes ESP_ERR_INVALID_STATE - if (!nimbleInitialized) { - log(DEBUG, "πŸ”΅ Initializing NimBLE stack"); - ret = nimble_port_init(); - if (ret == ESP_ERR_INVALID_STATE) { - // On some ESP-IDF versions this can mean NimBLE/controller was already - // initialized elsewhere. Treat it as already-initialized. - log(WARNING, - "πŸ”΅ NimBLE port already initialized (ESP_ERR_INVALID_STATE), " - "continuing"); - } else if (ret != ESP_OK) { - log(ERROR, "πŸ”΅ Failed to initialize NimBLE port: %s", - esp_err_to_name(ret)); - - initInProgress = false; - return false; - } - - nimbleInitialized = true; - - // NimBLE initialized successfully - // Allow time for WiFi/BT coexistence to stabilize if WiFi is running - if (isWiFiInitialized()) { - log(DEBUG, "πŸ”΅ WiFi coexistence: allowing stabilization period"); - vTaskDelay(pdMS_TO_TICKS(200)); - feedWatchDog(); - } - } else { - log(DEBUG, - "πŸ”΅ NimBLE stack already initialized, skipping nimble_port_init"); - } - - // Set device name from config - std::string deviceName = config["deviceName"].as(); - ret = ble_svc_gap_device_name_set(deviceName.c_str()); - if (ret != 0) { - log(WARNING, "πŸ”΅ Failed to set BLE device name: %d", ret); - // Continue anyway, not fatal - } - - // Initialize GATT services - ble_svc_gap_init(); - ble_svc_gatt_init(); - - // Allow user code to register services/characteristics for this start. - startBLEServices(); - - // Register application services/characteristics - const ble_gatt_svc_def *svcs = gattServices.serviceDefs(this); - int rc = ble_gatts_count_cfg(svcs); - if (rc != 0) { - log(ERROR, "πŸ”΅ Failed to count GATT services, rc=%d", rc); - initInProgress = false; - return false; - } - rc = ble_gatts_add_svcs(svcs); - if (rc != 0) { - log(ERROR, "πŸ”΅ Failed to add GATT services, rc=%d", rc); - initInProgress = false; - return false; - } - - feedWatchDog(); - - // Start the host task - nimble_port_freertos_init(bleHostTaskStatic); - - bleStarted = true; - initInProgress = false; - log(INFO, "πŸ”΅ BLE Provisioning Started"); - - return true; -} - /** * @brief Stop and deinitialize BLE provisioning * @@ -806,6 +399,7 @@ std::string ESPWiFi::getBLEAddress() { } #endif // CONFIG_BT_NIMBLE_ENABLED + // ======================================================= /** * @brief Handle BLE configuration changes * @@ -824,4 +418,124 @@ void ESPWiFi::bleConfigHandler() { #endif } +/** + * @brief Initialize and start BLE provisioning + * + * Initializes the NimBLE stack, configures GATT services, and starts + * advertising. This function is idempotent - calling it multiple times is safe. + * + * @return true if BLE is started (or was already running), false on failure + * + * @note Does not abort on failure per ESP32 robustness best practices + * @note Cannot run simultaneously with Classic Bluetooth (A2DP) + */ +bool ESPWiFi::startBLE() { +#ifdef CONFIG_BT_NIMBLE_ENABLED + // Early return if already started + if (bleStarted) { + log(DEBUG, "πŸ”΅ BLE Already running"); + return true; + } + + log(INFO, "πŸ”΅ Starting Bluetooth Low Energy"); + + esp_err_t ret; + + // Configure the host callbacks BEFORE nimble_port_init (required) + ble_hs_cfg.sync_cb = [](void) { + extern ESPWiFi espwifi; + espwifi.log(INFO, "πŸ”΅ BLE Host and Controller synced"); + espwifi.log(INFO, "πŸ”΅ BLE Address: %s", espwifi.getBLEAddress().c_str()); + espwifi.startBLEAdvertising(); + }; + + ble_hs_cfg.reset_cb = [](int reason) { + extern ESPWiFi espwifi; + espwifi.log(WARNING, "πŸ”΅ BLE Host reset, reason=%d", reason); + }; + + // Configure BLE security for encrypted connections + // "Just Works" pairing - no PIN required, but connection is encrypted + ble_hs_cfg.sm_bonding = 1; // Enable bonding (stores keys) + ble_hs_cfg.sm_mitm = 0; // No Man-in-the-Middle protection (Just Works) + ble_hs_cfg.sm_sc = 1; // Secure Connections (LE Secure Connections) + ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; // Just Works pairing + ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC; + ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC; + + // Initialize NimBLE host - but only if not already initialized + // Double-init causes ESP_ERR_INVALID_STATE + if (!nimbleInitialized) { + log(DEBUG, "πŸ”΅ Initializing NimBLE stack"); + ret = nimble_port_init(); + if (ret == ESP_ERR_INVALID_STATE) { + // On some ESP-IDF versions this can mean NimBLE/controller was already + // initialized elsewhere. Treat it as already-initialized. + log(WARNING, + "πŸ”΅ NimBLE port already initialized (ESP_ERR_INVALID_STATE), " + "continuing"); + } else if (ret != ESP_OK) { + log(ERROR, "πŸ”΅ Failed to initialize NimBLE port: %s", + esp_err_to_name(ret)); + return false; + } + + nimbleInitialized = true; + log(INFO, "πŸ”΅ πŸ” BLE Security: Just Works pairing enabled (encrypted " + "connection)"); + + // NimBLE initialized successfully + // Allow time for WiFi/BT coexistence to stabilize if WiFi is running + if (isWiFiInitialized()) { + log(DEBUG, "πŸ”΅ WiFi coexistence: allowing stabilization period"); + feedWatchDog(200); + } + } else { + log(DEBUG, + "πŸ”΅ NimBLE stack already initialized, skipping nimble_port_init"); + } + + // Set device name from config + std::string deviceName = config["deviceName"].as(); + ret = ble_svc_gap_device_name_set(deviceName.c_str()); + if (ret != 0) { + log(WARNING, "πŸ”΅ Failed to set BLE device name: %d", ret); + // Continue anyway, not fatal + } + + // Initialize GATT services + ble_svc_gap_init(); + ble_svc_gatt_init(); + + // register services/characteristics. + startBLEServices(); + + // Register application services/characteristics + const ble_gatt_svc_def *svcs = gattServices.serviceDefs(this); + int rc = ble_gatts_count_cfg(svcs); + if (rc != 0) { + log(ERROR, "πŸ”΅ Failed to count GATT services, rc=%d", rc); + return false; + } + rc = ble_gatts_add_svcs(svcs); + if (rc != 0) { + log(ERROR, "πŸ”΅ Failed to add GATT services, rc=%d", rc); + return false; + } + + feedWatchDog(); + + // Start the host task + nimble_port_freertos_init(bleHostTaskStatic); + + bleStarted = true; + log(INFO, "πŸ”΅ BLE Initialization complete (advertising will start when host " + "syncs)"); + + return true; +#else + return false; +#endif // CONFIG_BT_NIMBLE_ENABLED +} + #endif // ESPWiFi_BLE_H diff --git a/src/CloudCtl_start.cpp b/src/CloudCtl_start.cpp index 554c95a..cd59d36 100644 --- a/src/CloudCtl_start.cpp +++ b/src/CloudCtl_start.cpp @@ -23,13 +23,25 @@ void ESPWiFi::startCloudCtl() { const char *deviceId = hostname.c_str(); // Get auth token from device config (used to authenticate UI connections) - const char *authToken = config["auth"]["token"] | nullptr; + const char *authToken = config["auth"]["token"].as(); + if (!authToken || authToken[0] == '\0') { + log(ERROR, + "☁️ Auth token not found in config - cloud will not work properly"); + authToken = nullptr; + } else { + log(INFO, "☁️ Auth token: %s", authToken); + } log(INFO, "☁️ Starting cloud client"); log(INFO, "☁️ Base URL: %s", baseUrl); log(INFO, "☁️ Device ID: %s", deviceId); log(INFO, "☁️ Tunnel: %s", tunnel); + // Delay cloud startup to reduce memory pressure during boot + // (BLE, HTTP, mDNS all initialize first and consume heap) + log(INFO, "☁️ Waiting 5s for system to stabilize..."); + feedWatchDog(999); + // Configure cloud client Cloud::Config cfg; cfg.enabled = true; diff --git a/src/Config.cpp b/src/Config.cpp index fed4c21..dddc1e9 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -57,6 +57,15 @@ void ESPWiFi::readConfig() { } corsConfigHandler(); + + // Ensure auth token exists (generate if missing after config load) + if (config["auth"]["token"].isNull() || + std::string(config["auth"]["token"].as()).empty()) { + log(INFO, "βš™οΈ Generating missing auth token"); + config["auth"]["token"] = generateToken(); + saveConfig(); // Persist the new token + } + if (configLoaded) { log(INFO, "βš™οΈ Config Read from LittleFS (size: %zu bytes)", measureJson(config)); @@ -143,12 +152,12 @@ std::string ESPWiFi::prettyConfig() { return prettyConfig; } -void ESPWiFi::saveConfig() { +bool ESPWiFi::saveConfig() { initLittleFS(); if (lfs == nullptr) { log(ERROR, "βš™οΈ No filesystem available for saving config"); - return; + return false; } feedWatchDog(); // Yield before JSON operations @@ -157,14 +166,14 @@ void ESPWiFi::saveConfig() { size_t size = measureJson(config); if (size == 0) { log(ERROR, "βš™οΈ Failed to measure config JSON size"); - return; + return false; } // Allocate buffer with extra space for safety char *buffer = (char *)malloc(size + 32); if (!buffer) { log(ERROR, "βš™οΈ Failed to allocate memory for config (size: %zu)", size); - return; + return false; } feedWatchDog(); // Yield after allocation @@ -173,20 +182,20 @@ void ESPWiFi::saveConfig() { if (written == 0) { log(ERROR, "βš™οΈ Failed to serialize config JSON"); free(buffer); - return; + return false; } - feedWatchDog(); // Yield after serialization + feedWatchDog(); bool success = writeFile(configFile, (const uint8_t *)buffer, written); free(buffer); if (!success) { log(ERROR, "βš™οΈ Failed to write config file"); - return; + return false; } - feedWatchDog(); // Yield after file write + feedWatchDog(); if (config["log"]["enabled"].as()) { log(INFO, "βš™οΈ Config Saved: %s", configFile); @@ -194,6 +203,7 @@ void ESPWiFi::saveConfig() { // log(DEBUG, "βš™οΈ\n%s", prettyConfig()); configNeedsSave = false; + return success; } JsonDocument ESPWiFi::mergeJson(const JsonDocument &base, diff --git a/src/DefaultConfig.cpp b/src/DefaultConfig.cpp index a1f2302..810b795 100644 --- a/src/DefaultConfig.cpp +++ b/src/DefaultConfig.cpp @@ -48,7 +48,6 @@ JsonDocument ESPWiFi::defaultConfig() { // BLE Provisioning doc["ble"]["enabled"] = true; - doc["ble"]["passkey"] = 123456; // Logging: verbose, access, debug, info, warning, error doc["log"]["file"] = "/espwifi.log"; @@ -57,7 +56,6 @@ JsonDocument ESPWiFi::defaultConfig() { doc["log"]["useSD"] = true; doc["log"]["maskedKeys"] = JsonArray(); doc["log"]["maskedKeys"].add("password"); - doc["log"]["maskedKeys"].add("passkey"); doc["log"]["maskedKeys"].add("token"); // Auth diff --git a/src/ESPWiFi.cpp b/src/ESPWiFi.cpp index 39ae097..0c2222a 100644 --- a/src/ESPWiFi.cpp +++ b/src/ESPWiFi.cpp @@ -8,13 +8,15 @@ void ESPWiFi::start() { initFilesystem(); startLogging(); readConfig(); + initNVS(); + startBLE(); startWiFi(); startMDNS(); startWebServer(); startControlWebSocket(); startCameraWebSocket(); - startCloudCtl(); - startCloudMedia(); + // startCloudCtl(); + // startCloudMedia(); srvAll(); } diff --git a/src/Log.cpp b/src/Log.cpp index caff466..2bf6071 100644 --- a/src/Log.cpp +++ b/src/Log.cpp @@ -105,10 +105,10 @@ static const char *espwifiIconForIdfTagView(const char *tag, size_t tagLen) { int ESPWiFi::idfLogVprintfHook(const char *format, va_list args) { // Original vprintf behavior - int ret = vprintf(format, args); - if (ret < 0) { - return ret; - } + // int ret = vprintf(format, args); + // if (ret < 0) { + // return ret; + // } // Static member function for ESP-IDF's vprintf hook // Captures and processes ESP-IDF log messages @@ -125,14 +125,12 @@ int ESPWiFi::idfLogVprintfHook(const char *format, va_list args) { (void)vsnprintf(line, sizeof(line), format, argsCopy); va_end(argsCopy); - // espwifi->writeLog(line); - // Format and log the IDF message - // LogLevel lvl = DEBUG; - // std::string formatted = espwifi->formatIDFtoESPWiFi(line, &lvl); - // if (!formatted.empty()) { - // espwifi->logImpl(lvl, formatted); - // } + LogLevel lvl = DEBUG; + std::string formatted = espwifi->formatIDFtoESPWiFi(line, &lvl); + if (!formatted.empty()) { + espwifi->logImpl(lvl, formatted); + } } return 0; diff --git a/src/WebSocketClient.cpp b/src/WebSocketClient.cpp index 984e6ad..79f523d 100644 --- a/src/WebSocketClient.cpp +++ b/src/WebSocketClient.cpp @@ -179,9 +179,9 @@ bool WebSocketClient::begin(const Config &config) { // Buffer size wsConfig.buffer_size = config.bufferSize; - // Task stack size - increase to prevent stack overflow with TLS + // Task stack size - reduce to fit alongside BLE during startup wsConfig.task_stack = - 8192; // Default is usually 4096, double it for cloud connections + 6144; // Reduced from 8192 to allow BLE and cloud to coexist // Ping/Pong settings if (config.pingInterval > 0) { diff --git a/src/net/WebServer.cpp b/src/net/WebServer.cpp index 7a3e6a6..11135aa 100644 --- a/src/net/WebServer.cpp +++ b/src/net/WebServer.cpp @@ -650,7 +650,7 @@ esp_err_t ESPWiFi::sendFileResponse(httpd_req_t *req, // Full file request: use chunked encoding for progressive streaming httpd_resp_set_status(req, "200 OK"); - log(DEBUG, "πŸ—„οΈ Serving File: %s (%ld bytes) using chunked encoding", + log(ACCESS, "πŸ—„οΈ Serving File: %s (%ld bytes) using chunked encoding", filePath.c_str(), fileSize); // Use smaller chunk size to reduce memory pressure (2KB instead of 4KB) @@ -707,7 +707,7 @@ esp_err_t ESPWiFi::sendFileResponse(httpd_req_t *req, log(ERROR, "Failed to finalize chunked transfer: %s", esp_err_to_name(ret)); } else { - log(DEBUG, "πŸ—„οΈ File transfer completed: %s (%zu bytes)", + log(ACCESS, "πŸ—„οΈ File transfer completed: %s (%zu bytes)", filePath.c_str(), totalSent); } feedWatchDog(); diff --git a/src/net/WiFi.cpp b/src/net/WiFi.cpp index d75acc3..2b20bf7 100644 --- a/src/net/WiFi.cpp +++ b/src/net/WiFi.cpp @@ -122,11 +122,6 @@ void ESPWiFi::stopWiFi() { void ESPWiFi::startWiFi() { if (!config["wifi"]["enabled"].as()) { log(INFO, "πŸ“Ά WiFi Disabled"); -#ifdef CONFIG_BT_NIMBLE_ENABLED - // For now, BLE should start every boot (even if WiFi is disabled) so the UI - // can always pair / provision. - startBLE(); -#endif return; } initNVS(); @@ -231,9 +226,6 @@ void ESPWiFi::startClient() { connectSubroutine(); } - // printf("."); - // fflush(stdout); - if (current_netif != nullptr) { esp_netif_ip_info_t ip_info; if (esp_netif_get_ip_info(current_netif, &ip_info) == ESP_OK) { @@ -244,7 +236,7 @@ void ESPWiFi::startClient() { } } - vTaskDelay(pdMS_TO_TICKS(30)); + feedWatchDog(30); } printf("\n"); @@ -256,14 +248,7 @@ void ESPWiFi::startClient() { return; } - // WiFi connected successfully. - // For now, BLE should start every boot (client or AP) so the UI can always - // pair / provision / enable cloud tunnel. -#ifdef CONFIG_BT_NIMBLE_ENABLED - // Give WiFi a moment to stabilize before enabling BLE (coexistence). - vTaskDelay(pdMS_TO_TICKS(200)); - startBLE(); -#endif + config["wifi"]["started"] = true; std::string hostname = getHostname(); log(INFO, "πŸ“Ά\tHostname: %s", hostname.c_str()); @@ -434,15 +419,15 @@ int ESPWiFi::selectBestChannel() { void ESPWiFi::startAP() { // std::string ssid = genHostname(); - std::string ssid = config["wifi"]["accessPoint"]["ssid"].as(); std::string password = config["wifi"]["accessPoint"]["password"].as(); log(INFO, "πŸ“‘ Starting Access Point"); - log(INFO, "πŸ“Ά\tSSID: %s", ssid.c_str()); + log(INFO, "πŸ“Ά\tSSID: %s", genHostname().c_str()); log(INFO, "πŸ“Ά\tPassword: %s", password.c_str()); setWiFiAutoReconnect(false); // No STA auto-reconnect in AP mode + initNVS(); if (!event_loop_initialized) { esp_err_t ret = esp_event_loop_create_default(); @@ -469,7 +454,7 @@ void ESPWiFi::startAP() { esp_wifi_stop(); (void)esp_wifi_deinit(); wifi_initialized = false; - vTaskDelay(pdMS_TO_TICKS(100)); + feedWatchDog(100); } int bestChannel = @@ -522,20 +507,12 @@ void ESPWiFi::startAP() { return; } + config["wifi"]["started"] = true; + char ip_str[16]; snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); log(INFO, "πŸ“Ά\tIP Address: %s", ip_str); - // Start BLE provisioning when in AP mode (if enabled in config) - // MUST be done after WiFi is fully initialized to avoid BT/WiFi coexistence - // issues -#ifdef CONFIG_BT_NIMBLE_ENABLED - // For now, BLE should start every boot (AP mode included) so the UI can - // always pair / provision. - vTaskDelay(pdMS_TO_TICKS(200)); - startBLE(); -#endif - #ifdef LED_BUILTIN gpio_set_direction((gpio_num_t)LED_BUILTIN, GPIO_MODE_OUTPUT); gpio_set_level((gpio_num_t)LED_BUILTIN, 0); // Turn on LED to indicate AP mode @@ -560,7 +537,6 @@ std::string ESPWiFi::ipAddress() { std::string ESPWiFi::getHostname() { std::string hostname; - // // Attempt to get hostname from network interface if (current_netif != nullptr) { const char *hostname_ptr = nullptr; @@ -570,37 +546,11 @@ std::string ESPWiFi::getHostname() { config["hostname"] = hostname; } } - - // // "deviceName-XXXXXX" where XXXXXX is last 6 hex digits of MAC - // uint8_t mac[6]; - // esp_err_t ret = esp_read_mac(mac, ESP_MAC_WIFI_STA); - // if (ret == ESP_OK) { - // char macSuffix[7]; - // snprintf(macSuffix, sizeof(macSuffix), "%02x%02x%02x", mac[3], mac[4], - // mac[5]); - // hostname = - // config["deviceName"].as() + "-" + - // std::string(macSuffix); - // config["hostname"] = hostname; - // return hostname; - // } - return hostname; } std::string ESPWiFi::genHostname() { - // // Attempt to get hostname from network interface - // if (current_netif != nullptr) { - // const char *hostname_ptr = nullptr; - // esp_err_t err = esp_netif_get_hostname(current_netif, &hostname_ptr); - // if (err == ESP_OK && hostname_ptr != nullptr && strlen(hostname_ptr) > 0) - // { - // config["hostname"] = std::string(hostname_ptr); - // return std::string(hostname_ptr); - // } - // } - - std::string hostname; + std::string hostname = config["deviceName"].as(); // "deviceName-XXXXXX" where XXXXXX is last 6 hex digits of MAC uint8_t mac[6]; esp_err_t ret = esp_read_mac(mac, ESP_MAC_WIFI_STA); diff --git a/src/routes/srvAll.cpp b/src/routes/srvAll.cpp index 7805feb..db0d755 100644 --- a/src/routes/srvAll.cpp +++ b/src/routes/srvAll.cpp @@ -5,12 +5,12 @@ void ESPWiFi::srvAll() { srvRoot(); - srvAuth(); + // srvAuth(); // srvLog(); srvFiles(); // srvGPIO(); - srvBLE(); - srvBluetooth(); + // srvBLE(); + // srvBluetooth(); srvWildcard(); } From 64f468e494afb49730e4e0c1c542912c8e4bf6da Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Tue, 13 Jan 2026 19:35:43 -0500 Subject: [PATCH 08/16] fixing-ws-after-play --- dashboard/src/App.js | 4 ++-- include/WebSocket.h | 4 +++- src/ControlSocket.cpp | 22 +++++++++++++--------- src/WebSocket.cpp | 36 +++++++++++++++++++++++++++++------- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/dashboard/src/App.js b/dashboard/src/App.js index 4c82ac5..0f26cda 100644 --- a/dashboard/src/App.js +++ b/dashboard/src/App.js @@ -485,8 +485,8 @@ function App() { return; } - console.log("[App] Scheduling reconnect in 2 seconds..."); - controlRetryRef.current = setTimeout(connect, 2000); + console.log("[App] Scheduling reconnect..."); + controlRetryRef.current = setTimeout(connect, 600); }; } catch { setControlConnected(false); diff --git a/include/WebSocket.h b/include/WebSocket.h index 5396efa..60cffd1 100644 --- a/include/WebSocket.h +++ b/include/WebSocket.h @@ -36,6 +36,7 @@ class WebSocket { static constexpr size_t kMaxClients = 8; int clientFds_[kMaxClients] = {0}; size_t clientCount_ = 0; + size_t maxClients_ = kMaxClients; // Per-endpoint client limit size_t maxMessageLen_ = 1024; size_t maxBroadcastLen_ = 8192; @@ -68,11 +69,12 @@ class WebSocket { // - server: ESP-IDF httpd handle // - userCtx: User context passed to callbacks // - authCheck: Optional auth function (nullptr = no auth) + // - maxClients: Maximum clients allowed (default: 8, use 1 for single-client) bool begin(const char *uri, httpd_handle_t server, void *userCtx, OnMessageCb onMessage = nullptr, OnConnectCb onConnect = nullptr, OnDisconnectCb onDisconnect = nullptr, size_t maxMessageLen = 1024, size_t maxBroadcastLen = 8192, bool requireAuth = false, - AuthCheckFn authCheck = nullptr); + AuthCheckFn authCheck = nullptr, size_t maxClients = 8); operator bool() const { return started_; } diff --git a/src/ControlSocket.cpp b/src/ControlSocket.cpp index 13d3c24..6b50256 100644 --- a/src/ControlSocket.cpp +++ b/src/ControlSocket.cpp @@ -13,11 +13,12 @@ static void ctrlOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, const uint8_t *data, size_t len, void *userCtx) { ESPWiFi *espwifi = static_cast(userCtx); + // Validate inputs if (!ws || !espwifi || !data || len == 0) { return; } if (type != HTTPD_WS_TYPE_TEXT) { - // Keep control socket simple: accept text JSON only. + // Keep control socket simple: accept text JSON only (hardened) return; } @@ -268,6 +269,7 @@ static void ctrlOnConnect(WebSocket *ws, int clientFd, void *userCtx) { ESPWiFi *espwifi = static_cast(userCtx); if (!ws || !espwifi) return; + JsonDocument hello; hello["type"] = "hello"; hello["ok"] = true; @@ -315,14 +317,16 @@ void ESPWiFi::startControlWebSocket() { if (ctrlSocStarted) return; - ctrlSocStarted = ctrlSoc.begin("/ws/control", webServer, this, - /*onMessage*/ ctrlOnMessage, - /*onConnect*/ ctrlOnConnect, - /*onDisconnect*/ ctrlOnDisconnect, - /*maxMessageLen*/ 2048, - /*maxBroadcastLen*/ 160 * 1024, - /*requireAuth*/ false, - /*authCheck*/ wsAuthCheck); + ctrlSocStarted = ctrlSoc.begin( + "/ws/control", webServer, this, + /*onMessage*/ ctrlOnMessage, + /*onConnect*/ ctrlOnConnect, + /*onDisconnect*/ ctrlOnDisconnect, + /*maxMessageLen*/ 2048, + /*maxBroadcastLen*/ 160 * 1024, + /*requireAuth*/ true, // Enforce auth for control socket (hardening) + /*authCheck*/ wsAuthCheck); + // maxClients defaults to 8, allowing multiple clients if (!ctrlSocStarted) { log(ERROR, "πŸŽ›οΈ Control WebSocket failed to start"); return; diff --git a/src/WebSocket.cpp b/src/WebSocket.cpp index 17d7366..bb698ea 100644 --- a/src/WebSocket.cpp +++ b/src/WebSocket.cpp @@ -86,17 +86,25 @@ void WebSocket::addClient_(int fd) { if (fd < 0) { return; } + // Check if already tracked for (size_t i = 0; i < clientCount_; ++i) { if (clientFds_[i] == fd) { return; // already tracked } } - if (clientCount_ >= kMaxClients) { - // Drop oldest to stay bounded - for (size_t i = 1; i < kMaxClients; ++i) { - clientFds_[i - 1] = clientFds_[i]; + // Check per-endpoint limit + if (clientCount_ >= maxClients_) { + // At limit: drop oldest client to make room (enforces maxClients_) + if (maxClients_ > 1) { + // Multi-client mode: shift array + for (size_t i = 1; i < maxClients_; ++i) { + clientFds_[i - 1] = clientFds_[i]; + } + clientFds_[maxClients_ - 1] = fd; + } else { + // Single-client mode: replace (old client should be closed by handler) + clientFds_[0] = fd; } - clientFds_[kMaxClients - 1] = fd; return; } clientFds_[clientCount_++] = fd; @@ -131,7 +139,7 @@ esp_err_t WebSocket::handleWsRequest(httpd_req_t *req) { if (req->method == HTTP_GET) { const int fd = httpd_req_to_sockfd(req); - // Optional auth check + // Optional auth check (happens before connection is accepted) if (requireAuth_ && authCheck_ != nullptr) { if (!authCheck_(req, userCtx_)) { // Close session immediately (handshake already upgraded) @@ -140,6 +148,14 @@ esp_err_t WebSocket::handleWsRequest(httpd_req_t *req) { } } + // For single-client mode: close existing connection before adding new one + // This allows specific endpoints to enforce single-client limits if needed + if (maxClients_ == 1 && clientCount_ >= 1 && server_ != nullptr) { + // Close existing client to enforce single-client limit + (void)httpd_sess_trigger_close(server_, clientFds_[0]); + clientCount_ = 0; // Reset count, new client will be added below + } + addClient_(fd); // Log connection @@ -230,7 +246,7 @@ bool WebSocket::begin(const char *uri, httpd_handle_t server, void *userCtx, OnMessageCb onMessage, OnConnectCb onConnect, OnDisconnectCb onDisconnect, size_t maxMessageLen, size_t maxBroadcastLen, bool requireAuth, - AuthCheckFn authCheck) { + AuthCheckFn authCheck, size_t maxClients) { #ifndef CONFIG_HTTPD_WS_SUPPORT (void)uri; (void)server; @@ -242,6 +258,7 @@ bool WebSocket::begin(const char *uri, httpd_handle_t server, void *userCtx, (void)maxBroadcastLen; (void)requireAuth; (void)authCheck; + (void)maxClients; return false; #else if (uri == nullptr || server == nullptr) { @@ -265,6 +282,11 @@ bool WebSocket::begin(const char *uri, httpd_handle_t server, void *userCtx, if (maxBroadcastLen_ > 262144) { maxBroadcastLen_ = 262144; } + // Set per-endpoint client limit (clamped to kMaxClients) + maxClients_ = (maxClients == 0) ? 1 : maxClients; + if (maxClients_ > kMaxClients) { + maxClients_ = kMaxClients; + } // Copy URI uri_[0] = '\0'; From cf9f90cde9244bd938f3c737827df24ead31a3a7 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Wed, 14 Jan 2026 00:26:53 -0500 Subject: [PATCH 09/16] local --- components/ESP32-A2DP | 1 - dashboard/src/components/MusicPlayerModule.js | 10 +- include/ESPWiFi.h | 8 +- {src => include}/GattServiceDef.h | 0 {src => include}/GattServices.h | 0 include/WebSocketClient.h | 7 ++ partitions/espwifi-camera.csv | 10 +- partitions/espwifi_esp32.csv | 6 +- platformio.ini | 2 +- sdkconfigs/esp32.sdkconfig | 43 ++++---- src/Bluetooth.cpp | 2 +- src/Camera.cpp | 1 - src/ControlSocket.cpp | 11 ++- src/ESPWiFi.cpp | 4 +- src/Info.cpp | 4 +- src/WebSocketClient.cpp | 97 ++++++++++++++++++- src/net/WiFi.cpp | 2 +- 17 files changed, 162 insertions(+), 46 deletions(-) delete mode 160000 components/ESP32-A2DP rename {src => include}/GattServiceDef.h (100%) rename {src => include}/GattServices.h (100%) diff --git a/components/ESP32-A2DP b/components/ESP32-A2DP deleted file mode 160000 index a207fda..0000000 --- a/components/ESP32-A2DP +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a207fda36fb835e3dfed41210091e17493a2d1d5 diff --git a/dashboard/src/components/MusicPlayerModule.js b/dashboard/src/components/MusicPlayerModule.js index fde3aa1..0ba0bcd 100644 --- a/dashboard/src/components/MusicPlayerModule.js +++ b/dashboard/src/components/MusicPlayerModule.js @@ -254,10 +254,18 @@ export default function MusicPlayerModule({ }; // Handle stop - const handleStop = () => { + const handleStop = async () => { if (!audioRef.current) return; + + // Stop local audio playback and abort HTTP requests audioRef.current.pause(); audioRef.current.currentTime = 0; + + // Clear the source to abort ongoing HTTP range requests + // This prevents the device from continuing to serve the file + audioRef.current.src = ""; + audioRef.current.load(); // Reset the audio element + setProgress(0); setCurrentTime(0); setIsPlaying(false); diff --git a/include/ESPWiFi.h b/include/ESPWiFi.h index 4b01871..554d36c 100644 --- a/include/ESPWiFi.h +++ b/include/ESPWiFi.h @@ -291,10 +291,8 @@ class ESPWiFi { void startControlWebSocket(); void handleCloudControlMessage(const JsonDocument &req, JsonDocument &resp); -#if ESPWiFi_HAS_CAMERA // ---- Camera WebSocket (for LAN streaming) void startCameraWebSocket(); -#endif // ---- Cloud Client (remote access) void startCloudCtl(); @@ -340,6 +338,7 @@ class ESPWiFi { // Declared unconditionally because config handling calls this even when BLE // is compiled out; implementation is a stub when CONFIG_BT_NIMBLE_ENABLED is // disabled. + bool startBLE(); void bleConfigHandler(); #ifdef CONFIG_BT_NIMBLE_ENABLED using BleAccessCallback = int (*)(uint16_t conn_handle, uint16_t attr_handle, @@ -348,7 +347,6 @@ class ESPWiFi { // Route-style BLE characteristic handler. void *ble = nullptr; - bool startBLE(); void deinitBLE(); uint8_t getBLEStatus(); std::string getBLEAddress(); @@ -388,7 +386,8 @@ class ESPWiFi { WebSocket ctrlSoc; bool ctrlSocStarted = false; -// ---- Camera + // ---- Camera + void streamCamera(); #if ESPWiFi_HAS_CAMERA WebSocket cameraSoc; bool cameraSocStarted = false; @@ -409,7 +408,6 @@ class ESPWiFi { // Camera API (always declared; stubs compile when camera is disabled) bool initCamera(); void deinitCamera(); - void streamCamera(); void clearCameraBuffer(); void updateCameraSettings(); void cameraConfigHandler(); diff --git a/src/GattServiceDef.h b/include/GattServiceDef.h similarity index 100% rename from src/GattServiceDef.h rename to include/GattServiceDef.h diff --git a/src/GattServices.h b/include/GattServices.h similarity index 100% rename from src/GattServices.h rename to include/GattServices.h diff --git a/include/WebSocketClient.h b/include/WebSocketClient.h index 2a07b8c..0340376 100644 --- a/include/WebSocketClient.h +++ b/include/WebSocketClient.h @@ -55,6 +55,13 @@ class WebSocketClient { uint32_t maxReconnectAttempts_ = 0; // 0 = infinite uint32_t reconnectAttempts_ = 0; + // Stored configuration for reconnection + size_t bufferSize_ = 4096; + uint32_t pingInterval_ = 10000; + uint32_t timeout_ = 10000; + bool disableCertVerify_ = false; + const char *certPem_ = nullptr; + // Authentication char authHeader_[256] = {0}; diff --git a/partitions/espwifi-camera.csv b/partitions/espwifi-camera.csv index 6f4ae4a..3537648 100644 --- a/partitions/espwifi-camera.csv +++ b/partitions/espwifi-camera.csv @@ -1,11 +1,11 @@ # Name, Type, SubType, Offset, Size, Flags -# ESP32-S3 with Camera - 4MB flash detected (XIAO ESP32S3) +# ESP32-S3 with Camera - 8MB flash (XIAO ESP32S3 Sense) # Optimized for camera with PSRAM support -# Total: 2MB app + ~1.75MB LittleFS + coredump + NVS/PHY -# LittleFS slightly smaller to accommodate larger app with camera +# Total: 3MB app + ~4.5MB LittleFS + coredump + NVS/PHY +# Increased sizes to utilize full 8MB flash capacity nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, -factory, app, factory, 0x10000, 2048K, -littlefsp, data, littlefs, , 0x1B0000, +factory, app, factory, 0x10000, 3072K, +littlefsp, data, littlefs, , 0x480000, coredump, data, coredump, , 0x10000, diff --git a/partitions/espwifi_esp32.csv b/partitions/espwifi_esp32.csv index e6b5c34..b8a069e 100644 --- a/partitions/espwifi_esp32.csv +++ b/partitions/espwifi_esp32.csv @@ -1,7 +1,7 @@ # Name, Type, SubType, Offset, Size, Flags -# 4MB flash Total: 2MB app + ~1.875MB LittleFS + coredump + NVS/PHY +# 4MB flash Total: 2.875MB app + 1MB LittleFS + coredump + NVS/PHY nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, -factory, app, factory, 0x10000, 2048K, -littlefsp, data, littlefs, , 0x1E0000, +factory, app, factory, 0x10000, 2944K, +littlefsp, data, littlefs, , 0x100000, coredump, data, coredump, , 0x10000, \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index aaa71d3..f841aa4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${common.build_flags_common} -D ESPWiFi_SDCARD_MODEL_ESP32_2432S028R -D CONFIG_IDF_TARGET_ESP32 - -D ESPWiFi_BT_ENABLED + ; -D ESPWiFi_BT_ENABLED [env:esp32-c3] framework = espidf diff --git a/sdkconfigs/esp32.sdkconfig b/sdkconfigs/esp32.sdkconfig index 044302a..b7fb8d0 100644 --- a/sdkconfigs/esp32.sdkconfig +++ b/sdkconfigs/esp32.sdkconfig @@ -20,6 +20,10 @@ CONFIG_ESP32_DEFAULT_CPU_FREQ_160=y CONFIG_ESP32_REV_MIN_3=y # CONFIG_FREERTOS_UNICORE is not set +# Flash Configuration (ESP-IDF / esptoolpy) +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" + # Bluetooth (Classic / Bluedroid - A2DP Source) # ========================================================== @@ -33,30 +37,30 @@ CONFIG_ESP32_REV_MIN_3=y # 2. Enable NimBLE: CONFIG_BT_NIMBLE_ENABLED=y # 3. Disable A2DP: CONFIG_BT_A2DP_ENABLE=n # -CONFIG_BT_ENABLED=y -CONFIG_BT_BLUEDROID_ENABLED=y +# CONFIG_BT_ENABLED=y +# CONFIG_BT_BLUEDROID_ENABLED=y # CONFIG_BT_NIMBLE_ENABLED is not set # CONFIG_BT_CONTROLLER_ONLY is not set -CONFIG_BT_CONTROLLER_ENABLED=y +# CONFIG_BT_CONTROLLER_ENABLED=y # CONFIG_BT_CONTROLLER_DISABLED is not set -CONFIG_BT_BTC_TASK_STACK_SIZE=3072 -CONFIG_BT_BLUEDROID_PINNED_TO_CORE_0=y +# CONFIG_BT_BTC_TASK_STACK_SIZE=3072 +# CONFIG_BT_BLUEDROID_PINNED_TO_CORE_0=y # CONFIG_BT_BLUEDROID_PINNED_TO_CORE_1 is not set -CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0 -CONFIG_BT_BTU_TASK_STACK_SIZE=4352 +# CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0 +# CONFIG_BT_BTU_TASK_STACK_SIZE=4352 # CONFIG_BT_BLUEDROID_MEM_DEBUG is not set -CONFIG_BT_BLUEDROID_ESP_COEX_VSC=y -CONFIG_BT_CLASSIC_ENABLED=y -CONFIG_BT_ENC_KEY_SIZE_CTRL_VSC=y +# CONFIG_BT_BLUEDROID_ESP_COEX_VSC=y +# CONFIG_BT_CLASSIC_ENABLED=y +# CONFIG_BT_ENC_KEY_SIZE_CTRL_VSC=y # CONFIG_BT_ENC_KEY_SIZE_CTRL_NONE is not set # CONFIG_BT_CLASSIC_BQB_ENABLED is not set # A2DP / AVRCP (required for ESP32-A2DP source) -CONFIG_BT_A2DP_ENABLE=y +# CONFIG_BT_A2DP_ENABLE=y # CONFIG_BT_A2DP_USE_EXTERNAL_CODEC is not set -CONFIG_BT_AVRCP_ENABLED=y -CONFIG_BT_SMP_ENABLE=y +# CONFIG_BT_AVRCP_ENABLED=y +# CONFIG_BT_SMP_ENABLE=y # Explicitly disable non-audio BT profiles/features (RAM saver) # Keep: Classic BT + A2DP Source + AVRCP + SMP (pairing) @@ -66,11 +70,11 @@ CONFIG_BT_SMP_ENABLE=y # which can break builds if cover-art is toggled off. # # Classic profiles we don't use -CONFIG_BT_SPP_ENABLED=n -CONFIG_BT_HFP_ENABLE=n -CONFIG_BT_HID_ENABLED=n -CONFIG_BT_PBAC_ENABLED=n -CONFIG_BT_GOEPC_ENABLED=n +# CONFIG_BT_SPP_ENABLED=n +# CONFIG_BT_HFP_ENABLE=n +# CONFIG_BT_HID_ENABLED=n +# CONFIG_BT_PBAC_ENABLED=n +# CONFIG_BT_GOEPC_ENABLED=n # # BLE + GATT (not used; we run BR/EDR only) # CONFIG_BT_BLE_ENABLED=n @@ -80,6 +84,5 @@ CONFIG_BT_GOEPC_ENABLED=n # CONFIG_BT_BLE_BLUFI_ENABLE=n # Controller mode: Classic only (saves RAM/flash) -# CONFIG_BTDM_CTRL_MODE_BLE_ONLY is not set -CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY=y +# CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY=y # CONFIG_BTDM_CTRL_MODE_BTDM is not set diff --git a/src/Bluetooth.cpp b/src/Bluetooth.cpp index 14b1fcc..76ae0ac 100644 --- a/src/Bluetooth.cpp +++ b/src/Bluetooth.cpp @@ -81,7 +81,7 @@ void ESPWiFi::connectBluetooth(const std::string &address) { #endif // ESPWiFi_BT_ENABLED void ESPWiFi::bluetoothConfigHandler() { -#ifdef CONFIG_BT_A2DP_ENABLE +#ifdef CONFIG_BT_A2DP_ENABLE &&ESPWiFi_BT_ENABLED static bool lastEnabled = config["bluetooth"]["enabled"].as(); static bool currentEnabled = config["bluetooth"]["enabled"].as(); diff --git a/src/Camera.cpp b/src/Camera.cpp index a1bde44..1e4946c 100644 --- a/src/Camera.cpp +++ b/src/Camera.cpp @@ -873,7 +873,6 @@ bool ESPWiFi::initCamera() { return false; } void ESPWiFi::deinitCamera() {} void ESPWiFi::clearCameraBuffer() {} void ESPWiFi::updateCameraSettings() {} -void ESPWiFi::streamCamera() {} esp_err_t ESPWiFi::sendCameraSnapshot(httpd_req_t *req, const std::string &clientInfo) { (void)req; diff --git a/src/ControlSocket.cpp b/src/ControlSocket.cpp index 6b50256..28b0b8d 100644 --- a/src/ControlSocket.cpp +++ b/src/ControlSocket.cpp @@ -39,7 +39,7 @@ static void ctrlOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, resp["type"] = "pong"; } else if (strcmp(cmd, "get_status") == 0) { resp["ip"] = espwifi->ipAddress(); - resp["hostname"] = espwifi->config["hostname"].as(); + resp["hostname"] = espwifi->getHostname(); resp["wifiMode"] = espwifi->config["wifi"]["mode"].as(); } else if (strcmp(cmd, "get_config") == 0) { resp["config"] = espwifi->config; @@ -270,10 +270,14 @@ static void ctrlOnConnect(WebSocket *ws, int clientFd, void *userCtx) { if (!ws || !espwifi) return; + // Use getHostname() which safely retrieves hostname from network interface + // or config, avoiding direct config access that could cause memory issues + std::string hostname = espwifi->getHostname(); + JsonDocument hello; hello["type"] = "hello"; hello["ok"] = true; - hello["hostname"] = espwifi->config["hostname"].as(); + hello["hostname"] = hostname; std::string out; serializeJson(hello, out); (void)ws->sendText(clientFd, out.c_str(), out.size()); @@ -288,6 +292,9 @@ static void ctrlOnDisconnect(WebSocket *ws, int clientFd, void *userCtx) { // Auth check helper for WebSocket static bool wsAuthCheck(httpd_req_t *req, void *userCtx) { + if (req == nullptr) { + return false; // Reject if request is invalid + } ESPWiFi *espwifi = static_cast(userCtx); if (!espwifi || !espwifi->authEnabled() || espwifi->isExcludedPath(req->uri)) { diff --git a/src/ESPWiFi.cpp b/src/ESPWiFi.cpp index 0c2222a..cb2a132 100644 --- a/src/ESPWiFi.cpp +++ b/src/ESPWiFi.cpp @@ -9,13 +9,13 @@ void ESPWiFi::start() { startLogging(); readConfig(); initNVS(); - startBLE(); + // startBLE(); startWiFi(); startMDNS(); startWebServer(); startControlWebSocket(); startCameraWebSocket(); - // startCloudCtl(); + startCloudCtl(); // startCloudMedia(); srvAll(); } diff --git a/src/Info.cpp b/src/Info.cpp index a10bb2a..ace8c4d 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -88,6 +88,7 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { } // Camera endpoint info +#if ESPWiFi_HAS_CAMERA JsonObject camera = endpoints["camera"].to(); camera["uri"] = "/ws/camera"; camera["started"] = cameraSocStarted; @@ -102,7 +103,8 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { camera["ui_ws_url"] = std::string(mediaWsUrl); } } -#endif +#endif // ESPWiFi_HAS_CAMERA +#endif // CONFIG_HTTPD_WS_SUPPORT } // Pairing / claim code (cloud broker claim code for iOS flows) diff --git a/src/WebSocketClient.cpp b/src/WebSocketClient.cpp index 79f523d..e9f5698 100644 --- a/src/WebSocketClient.cpp +++ b/src/WebSocketClient.cpp @@ -123,6 +123,13 @@ bool WebSocketClient::begin(const Config &config) { autoReconnect_ = config.autoReconnect; reconnectDelay_ = config.reconnectDelay; maxReconnectAttempts_ = config.maxReconnectAttempts; + + // Store configuration for reconnection + bufferSize_ = config.bufferSize; + pingInterval_ = config.pingInterval; + timeout_ = config.timeout; + disableCertVerify_ = config.disableCertVerify; + certPem_ = config.certPem; // Configure WebSocket client esp_websocket_client_config_t wsConfig = {}; @@ -237,6 +244,12 @@ bool WebSocketClient::connect() { esp_err_t err = esp_websocket_client_start(client_); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to start WebSocket client: %s", esp_err_to_name(err)); + // Clean up failed connection attempt to prevent memory leaks + // Stop and destroy the client to free all resources + // This is safe because we're not in the client's task context + esp_websocket_client_stop(client_); + esp_websocket_client_destroy(client_); + client_ = nullptr; return false; } @@ -260,16 +273,96 @@ void WebSocketClient::scheduleReconnect() { reconnectAttempts_ >= maxReconnectAttempts_) { ESP_LOGW(TAG, "Max reconnect attempts (%lu) reached", maxReconnectAttempts_); + // Disable auto-reconnect to prevent further attempts + autoReconnect_ = false; return; } reconnectAttempts_++; + + // Exponential backoff: delay increases with each attempt (capped at 60s) + uint32_t delay = reconnectDelay_; + if (reconnectAttempts_ > 1) { + delay = reconnectDelay_ * (1 << (reconnectAttempts_ - 1)); + if (delay > 60000) { + delay = 60000; // Cap at 60 seconds + } + } + ESP_LOGI(TAG, "Scheduling reconnect attempt %lu in %lu ms", - reconnectAttempts_, reconnectDelay_); + reconnectAttempts_, delay); + + // Clean up the failed client before reconnecting + // This prevents memory leaks from accumulated failed connection attempts + // Note: We're in the event handler task, so we need to be careful + // Just stop it - if connect() fails again, we'll handle cleanup there + if (client_ != nullptr && !connected_) { + esp_websocket_client_stop(client_); + // Don't destroy here - we're in the client's task context + // The next connect() will handle it, or we'll destroy on next failure + } // Schedule reconnection in a task // Note: In production, you might want to use a timer or event group - vTaskDelay(pdMS_TO_TICKS(reconnectDelay_)); + vTaskDelay(pdMS_TO_TICKS(delay)); + + // Reinitialize the client if it was destroyed + if (client_ == nullptr) { + // Recreate the client configuration + esp_websocket_client_config_t wsConfig = {}; + wsConfig.uri = uri_; + + // Reapply TLS configuration if needed + if (strncmp(uri_, "wss://", 6) == 0) { + wsConfig.transport = WEBSOCKET_TRANSPORT_OVER_SSL; + + if (certPem_) { + wsConfig.cert_pem = certPem_; + } else { + wsConfig.crt_bundle_attach = esp_crt_bundle_attach; + wsConfig.skip_cert_common_name_check = false; + } + + if (disableCertVerify_) { + wsConfig.skip_cert_common_name_check = true; + wsConfig.crt_bundle_attach = NULL; + } + } + + wsConfig.buffer_size = bufferSize_; + wsConfig.task_stack = 6144; + wsConfig.network_timeout_ms = timeout_; + wsConfig.reconnect_timeout_ms = 0; + wsConfig.disable_auto_reconnect = true; + + if (pingInterval_ > 0) { + wsConfig.ping_interval_sec = pingInterval_ / 1000; + } + + if (authHeader_[0] != '\0') { + wsConfig.headers = authHeader_; + } + + client_ = esp_websocket_client_init(&wsConfig); + if (client_ == nullptr) { + ESP_LOGE(TAG, "Failed to reinitialize WebSocket client (out of memory?)"); + // Give up on this reconnect attempt - wait longer before next try + reconnectAttempts_--; // Don't count this as an attempt since we couldn't even init + return; + } + + // Re-register event handler + esp_err_t err = esp_websocket_register_events(client_, WEBSOCKET_EVENT_ANY, + eventHandlerTrampoline, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register event handler: %s", esp_err_to_name(err)); + esp_websocket_client_destroy(client_); + client_ = nullptr; + reconnectAttempts_--; // Don't count this as an attempt + return; + } + } + connect(); } diff --git a/src/net/WiFi.cpp b/src/net/WiFi.cpp index 2b20bf7..c4d9cc3 100644 --- a/src/net/WiFi.cpp +++ b/src/net/WiFi.cpp @@ -418,7 +418,7 @@ int ESPWiFi::selectBestChannel() { } void ESPWiFi::startAP() { - // std::string ssid = genHostname(); + std::string ssid = genHostname(); std::string password = config["wifi"]["accessPoint"]["password"].as(); From 115d163fbd1f41b8c5f229f2ed75d07aa9fe1cf8 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Thu, 15 Jan 2026 19:08:25 -0500 Subject: [PATCH 10/16] media --- dashboard/src/components/CameraModule.js | 30 ++- .../deviceSettings/CloudInfoCard.js | 2 +- dashboard/src/utils/apiUtils.js | 2 +- include/ESPWiFi.h | 20 +- src/Camera.cpp | 57 +++--- src/CameraSocket.cpp | 92 --------- src/DefaultConfig.cpp | 3 +- src/ESPWiFi.cpp | 4 +- src/Info.cpp | 6 +- src/MediaSocket.cpp | 185 ++++++++++++++++++ 10 files changed, 260 insertions(+), 141 deletions(-) delete mode 100644 src/CameraSocket.cpp create mode 100644 src/MediaSocket.cpp diff --git a/dashboard/src/components/CameraModule.js b/dashboard/src/components/CameraModule.js index 0f636c3..499b6f5 100644 --- a/dashboard/src/components/CameraModule.js +++ b/dashboard/src/components/CameraModule.js @@ -42,9 +42,9 @@ export default function CameraModule({ }, [config?.name]); const handleStartStream = () => { - // LAN: connect to /ws/camera + // LAN: connect to /ws/media (camera streaming is request-driven) const mdnsHostname = globalConfig?.hostname || globalConfig?.deviceName; - const cameraWsUrl = buildWebSocketUrl("/ws/camera", mdnsHostname || null); + const cameraWsUrl = buildWebSocketUrl("/ws/media", mdnsHostname || null); if (!cameraWsUrl) { console.error("Failed to build camera WebSocket URL"); @@ -58,7 +58,13 @@ export default function CameraModule({ wsRef.current = ws; ws.onopen = () => { - console.log("πŸ“· Camera WebSocket connected:", cameraWsUrl); + console.log("🎞️ Media WebSocket connected:", cameraWsUrl); + // Request camera streaming + try { + ws.send(JSON.stringify({ cmd: "camera_start" })); + } catch (e) { + console.warn("Failed to send camera_start:", e); + } setIsStreaming(true); }; @@ -75,7 +81,7 @@ export default function CameraModule({ }; ws.onerror = (error) => { - console.error("πŸ“· Camera WebSocket error:", error); + console.error("🎞️ Media WebSocket error:", error); setIsStreaming(false); // Auto-reconnect on error after a delay (if not intentionally closed) setTimeout(() => { @@ -84,14 +90,16 @@ export default function CameraModule({ wsRef.current === ws && !intentionalCloseRef.current ) { - console.log("πŸ“· Attempting to reconnect camera after error..."); + console.log( + "🎞️ Attempting to reconnect media socket after error..." + ); handleStartStream(); } }, 2000); }; ws.onclose = (event) => { - console.log("πŸ“· Camera WebSocket closed", event.code); + console.log("🎞️ Media WebSocket closed", event.code); setIsStreaming(false); wsRef.current = null; @@ -101,7 +109,7 @@ export default function CameraModule({ isMountedRef.current && !intentionalCloseRef.current ) { - console.log("πŸ“· Unexpected close, attempting to reconnect in 2s..."); + console.log("🎞️ Unexpected close, attempting to reconnect in 2s..."); setTimeout(() => { if (isMountedRef.current && !intentionalCloseRef.current) { handleStartStream(); @@ -120,6 +128,14 @@ export default function CameraModule({ // Close WebSocket if (wsRef.current) { + // Best-effort request to stop streaming before disconnect. + try { + if (wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ cmd: "camera_stop" })); + } + } catch (e) { + // ignore + } wsRef.current.close(); wsRef.current = null; } diff --git a/dashboard/src/components/deviceSettings/CloudInfoCard.js b/dashboard/src/components/deviceSettings/CloudInfoCard.js index 8f0aadd..5f38176 100644 --- a/dashboard/src/components/deviceSettings/CloudInfoCard.js +++ b/dashboard/src/components/deviceSettings/CloudInfoCard.js @@ -87,7 +87,7 @@ export default function CloudInfoCard({ config, deviceInfo, onSave }) { }, }; - // If cloud is enabled and the device has a camera, enable it so /ws/camera can tunnel. + // If cloud is enabled and the device has a camera, enable it so /ws/media can tunnel. if (tempEnabled && config?.camera?.installed !== false) { next.camera = { ...(config?.camera || {}), enabled: true }; } diff --git a/dashboard/src/utils/apiUtils.js b/dashboard/src/utils/apiUtils.js index 063cc9f..40b651b 100644 --- a/dashboard/src/utils/apiUtils.js +++ b/dashboard/src/utils/apiUtils.js @@ -144,7 +144,7 @@ export const buildApiUrl = (path, mdnsHostname = null) => { /** * Build a full WebSocket URL - * @param {string} path - The WebSocket endpoint path (e.g., "/ws/camera", "/ws/rssi") + * @param {string} path - The WebSocket endpoint path (e.g., "/ws/media", "/ws/control") * @param {string} mdnsHostname - Optional mDNS hostname * @returns {string} The full WebSocket URL */ diff --git a/include/ESPWiFi.h b/include/ESPWiFi.h index 554d36c..ba5b8bb 100644 --- a/include/ESPWiFi.h +++ b/include/ESPWiFi.h @@ -291,8 +291,8 @@ class ESPWiFi { void startControlWebSocket(); void handleCloudControlMessage(const JsonDocument &req, JsonDocument &resp); - // ---- Camera WebSocket (for LAN streaming) - void startCameraWebSocket(); + // ---- Media WebSocket (for LAN media; camera streaming on request) + void startMediaWebSocket(); // ---- Cloud Client (remote access) void startCloudCtl(); @@ -389,19 +389,19 @@ class ESPWiFi { // ---- Camera void streamCamera(); #if ESPWiFi_HAS_CAMERA - WebSocket cameraSoc; - bool cameraSocStarted = false; + WebSocket mediaSoc; + bool mediaSocStarted = false; sensor_t *camera = nullptr; void printCameraSettings(); - // Camera streaming subscribers - static constexpr size_t kMaxCameraStreamSubscribers = 8; - int cameraStreamSubFds_[kMaxCameraStreamSubscribers] = {0}; - volatile size_t cameraStreamSubCount_ = 0; + // Media camera streaming subscribers (clients that requested camera_start) + static constexpr size_t kMaxMediaCameraStreamSubscribers = 8; + int mediaCameraStreamSubFds_[kMaxMediaCameraStreamSubscribers] = {0}; + volatile size_t mediaCameraStreamSubCount_ = 0; - void setCameraStreamSubscribed(int clientFd, bool enable); - void clearCameraStreamSubscribed(int clientFd); + void setMediaCameraStreamSubscribed(int clientFd, bool enable); + void clearMediaCameraStreamSubscribed(int clientFd); #endif // ESPWiFi_HAS_CAMERA #endif // CONFIG_HTTPD_WS_SUPPORT diff --git a/src/Camera.cpp b/src/Camera.cpp index 1e4946c..1bc6af5 100644 --- a/src/Camera.cpp +++ b/src/Camera.cpp @@ -560,20 +560,20 @@ void ESPWiFi::updateCameraSettings() { } #ifdef CONFIG_HTTPD_WS_SUPPORT -void ESPWiFi::setCameraStreamSubscribed(int clientFd, bool enable) { +void ESPWiFi::setMediaCameraStreamSubscribed(int clientFd, bool enable) { if (clientFd <= 0) { return; } // Remove if disabling if (!enable) { - for (size_t i = 0; i < (size_t)cameraStreamSubCount_;) { - if (cameraStreamSubFds_[i] == clientFd) { + for (size_t i = 0; i < (size_t)mediaCameraStreamSubCount_;) { + if (mediaCameraStreamSubFds_[i] == clientFd) { // swap-remove - const size_t last = (size_t)cameraStreamSubCount_ - 1; - cameraStreamSubFds_[i] = cameraStreamSubFds_[last]; - cameraStreamSubFds_[last] = 0; - cameraStreamSubCount_ = (size_t)cameraStreamSubCount_ - 1; + const size_t last = (size_t)mediaCameraStreamSubCount_ - 1; + mediaCameraStreamSubFds_[i] = mediaCameraStreamSubFds_[last]; + mediaCameraStreamSubFds_[last] = 0; + mediaCameraStreamSubCount_ = (size_t)mediaCameraStreamSubCount_ - 1; continue; } i++; @@ -582,30 +582,30 @@ void ESPWiFi::setCameraStreamSubscribed(int clientFd, bool enable) { } // Add if not present - for (size_t i = 0; i < (size_t)cameraStreamSubCount_; i++) { - if (cameraStreamSubFds_[i] == clientFd) { + for (size_t i = 0; i < (size_t)mediaCameraStreamSubCount_; i++) { + if (mediaCameraStreamSubFds_[i] == clientFd) { return; // Already subscribed } } - if ((size_t)cameraStreamSubCount_ >= kMaxCameraStreamSubscribers) { - log(WARNING, "πŸ“· Camera stream subscriber limit reached; ignoring fd=%d", + if ((size_t)mediaCameraStreamSubCount_ >= kMaxMediaCameraStreamSubscribers) { + log(WARNING, "🎞️ Media camera subscriber limit reached; ignoring fd=%d", clientFd); return; } - const size_t idx = (size_t)cameraStreamSubCount_; - cameraStreamSubFds_[idx] = clientFd; - cameraStreamSubCount_ = idx + 1; + const size_t idx = (size_t)mediaCameraStreamSubCount_; + mediaCameraStreamSubFds_[idx] = clientFd; + mediaCameraStreamSubCount_ = idx + 1; } -void ESPWiFi::clearCameraStreamSubscribed(int clientFd) { +void ESPWiFi::clearMediaCameraStreamSubscribed(int clientFd) { // Check if already unsubscribed to prevent double-cleanup bool found = false; if (clientFd > 0) { - for (size_t i = 0; i < (size_t)cameraStreamSubCount_; i++) { - if (cameraStreamSubFds_[i] == clientFd) { + for (size_t i = 0; i < (size_t)mediaCameraStreamSubCount_; i++) { + if (mediaCameraStreamSubFds_[i] == clientFd) { found = true; break; } @@ -616,7 +616,7 @@ void ESPWiFi::clearCameraStreamSubscribed(int clientFd) { return; // Already cleaned up } - setCameraStreamSubscribed(clientFd, false); + setMediaCameraStreamSubscribed(clientFd, false); } #endif @@ -933,13 +933,13 @@ void ESPWiFi::streamCamera() { #if !defined(CONFIG_HTTPD_WS_SUPPORT) || !ESPWiFi_HAS_CAMERA return; #else - if (!cameraSocStarted) { + if (!mediaSocStarted) { return; } // Check for subscribers (LAN + Cloud UI) - // UI controls camera start/stop via WebSocket subscription - const size_t lanSubs = (size_t)cameraStreamSubCount_; + // UI controls camera start/stop via /ws/media commands + const size_t lanSubs = (size_t)mediaCameraStreamSubCount_; const bool cloudUiConnected = cloudMedia.isConnected() && cloudMedia.isRegistered(); @@ -959,7 +959,16 @@ void ESPWiFi::streamCamera() { } static IntervalTimer frameTimer(1000); - frameTimer.setIntervalMs((uint32_t)(1000 / 30)); // 30 FPS + int fps = config["camera"]["frameRate"].isNull() + ? 10 + : config["camera"]["frameRate"].as(); + if (fps < 1) { + fps = 1; + } + if (fps > 30) { + fps = 30; + } + frameTimer.setIntervalMs((uint32_t)(1000 / (uint32_t)fps)); if (!frameTimer.shouldRunAt(esp_timer_get_time())) { return; } @@ -975,9 +984,9 @@ void ESPWiFi::streamCamera() { // Send to all LAN subscribers for (size_t i = 0; i < lanSubs; i++) { - int fd = cameraStreamSubFds_[i]; + int fd = mediaCameraStreamSubFds_[i]; if (fd > 0) { - cameraSoc.sendBinary(fd, fb->buf, fb->len); + mediaSoc.sendBinary(fd, fb->buf, fb->len); } } diff --git a/src/CameraSocket.cpp b/src/CameraSocket.cpp deleted file mode 100644 index ddd9001..0000000 --- a/src/CameraSocket.cpp +++ /dev/null @@ -1,92 +0,0 @@ -// CameraSocket.cpp - Dedicated WebSocket endpoint for camera streaming -#include "ESPWiFi.h" - -#ifdef CONFIG_HTTPD_WS_SUPPORT - -static void camOnConnect(WebSocket *ws, int clientFd, void *userCtx) { - ESPWiFi *espwifi = static_cast(userCtx); - if (!ws || !espwifi) { - return; - } - - espwifi->log(INFO, "πŸ“· LAN client connected to /ws/camera (fd=%d)", clientFd); - -#if ESPWiFi_HAS_CAMERA - // Auto-subscribe on connect - espwifi->setCameraStreamSubscribed(clientFd, true); - espwifi->log(DEBUG, "πŸ“· LAN client auto-subscribed (fd=%d)", clientFd); -#endif -} - -static void camOnDisconnect(WebSocket *ws, int clientFd, void *userCtx) { - (void)ws; - ESPWiFi *espwifi = static_cast(userCtx); - if (!espwifi) { - return; - } - - espwifi->log(INFO, "πŸ“· LAN client disconnected from /ws/camera (fd=%d)", - clientFd); - -#if ESPWiFi_HAS_CAMERA - espwifi->clearCameraStreamSubscribed(clientFd); -#endif -} - -static void camOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, - const uint8_t *data, size_t len, void *userCtx) { - // Camera socket is binary-only, no messages expected - (void)ws; - (void)clientFd; - (void)type; - (void)data; - (void)len; - (void)userCtx; -} - -// Auth check helper for WebSocket -static bool wsAuthCheck(httpd_req_t *req, void *userCtx) { - ESPWiFi *espwifi = static_cast(userCtx); - if (!espwifi || !espwifi->authEnabled() || - espwifi->isExcludedPath(req->uri)) { - return true; - } - - bool ok = espwifi->authorized(req); - if (!ok) { - const std::string tok = espwifi->getQueryParam(req, "token"); - const char *expectedC = espwifi->config["auth"]["token"].as(); - const std::string expected = (expectedC != nullptr) ? expectedC : ""; - ok = (!tok.empty() && !expected.empty() && tok == expected); - } - return ok; -} - -#endif - -void ESPWiFi::startCameraWebSocket() { -#ifndef CONFIG_HTTPD_WS_SUPPORT - return; -#else -#if ESPWiFi_HAS_CAMERA - if (cameraSocStarted) { - return; - } - - cameraSocStarted = cameraSoc.begin("/ws/camera", webServer, this, - /*onMessage*/ camOnMessage, - /*onConnect*/ camOnConnect, - /*onDisconnect*/ camOnDisconnect, - /*maxMessageLen*/ 0, - /*maxBroadcastLen*/ 200 * 1024, - /*requireAuth*/ false, - /*authCheck*/ wsAuthCheck); - if (!cameraSocStarted) { - log(ERROR, "πŸ“· Camera WebSocket failed to start"); - return; - } - - log(INFO, "πŸ“· Camera WebSocket started: /ws/camera"); -#endif -#endif -} diff --git a/src/DefaultConfig.cpp b/src/DefaultConfig.cpp index 810b795..a30d727 100644 --- a/src/DefaultConfig.cpp +++ b/src/DefaultConfig.cpp @@ -97,6 +97,8 @@ JsonDocument ESPWiFi::defaultConfig() { #endif doc["sd"]["initialized"] = false; + doc["ota"]["enabled"] = isOTAEnabled(); + // Camera #if ESPWiFi_HAS_CAMERA doc["camera"]["installed"] = true; @@ -120,7 +122,6 @@ JsonDocument ESPWiFi::defaultConfig() { doc["camera"]["installed"] = false; #endif - doc["ota"]["enabled"] = isOTAEnabled(); return doc; } diff --git a/src/ESPWiFi.cpp b/src/ESPWiFi.cpp index cb2a132..2645538 100644 --- a/src/ESPWiFi.cpp +++ b/src/ESPWiFi.cpp @@ -14,8 +14,8 @@ void ESPWiFi::start() { startMDNS(); startWebServer(); startControlWebSocket(); - startCameraWebSocket(); - startCloudCtl(); + startMediaWebSocket(); + // startCloudCtl(); // startCloudMedia(); srvAll(); } diff --git a/src/Info.cpp b/src/Info.cpp index ace8c4d..7654844 100644 --- a/src/Info.cpp +++ b/src/Info.cpp @@ -87,11 +87,11 @@ JsonDocument ESPWiFi::buildInfoJson(bool yieldForWatchdog) { } } - // Camera endpoint info + // Media (camera) endpoint info #if ESPWiFi_HAS_CAMERA JsonObject camera = endpoints["camera"].to(); - camera["uri"] = "/ws/camera"; - camera["started"] = cameraSocStarted; + camera["uri"] = "/ws/media"; + camera["started"] = mediaSocStarted; // Add media cloud tunnel info camera["cloudEnabled"] = cloudMedia.isConnected(); diff --git a/src/MediaSocket.cpp b/src/MediaSocket.cpp new file mode 100644 index 0000000..383eec2 --- /dev/null +++ b/src/MediaSocket.cpp @@ -0,0 +1,185 @@ +// MediaSocket.cpp - Media WebSocket endpoint (camera streaming on request) +#include "ESPWiFi.h" + +#ifdef CONFIG_HTTPD_WS_SUPPORT + +// Auth check helper for WebSocket +static bool wsAuthCheck(httpd_req_t *req, void *userCtx) { + ESPWiFi *espwifi = static_cast(userCtx); + if (!espwifi || !espwifi->authEnabled() || + espwifi->isExcludedPath(req->uri)) { + return true; + } + + bool ok = espwifi->authorized(req); + if (!ok) { + // Browser WebSocket APIs can't set Authorization headers. Allow token + // via query param: ws://host/path?token=... + const std::string tok = espwifi->getQueryParam(req, "token"); + const char *expectedC = espwifi->config["auth"]["token"].as(); + const std::string expected = (expectedC != nullptr) ? expectedC : ""; + ok = (!tok.empty() && !expected.empty() && tok == expected); + } + return ok; +} + +static void mediaOnConnect(WebSocket *ws, int clientFd, void *userCtx) { + (void)ws; + ESPWiFi *espwifi = static_cast(userCtx); + if (!espwifi) { + return; + } + espwifi->log(INFO, "🎞️ LAN client connected to /ws/media (fd=%d)", clientFd); +} + +static void mediaOnDisconnect(WebSocket *ws, int clientFd, void *userCtx) { + (void)ws; + ESPWiFi *espwifi = static_cast(userCtx); + if (!espwifi) { + return; + } + + espwifi->log(INFO, "🎞️ LAN client disconnected from /ws/media (fd=%d)", + clientFd); + +#if ESPWiFi_HAS_CAMERA + // Stop streaming for this client. Camera deinit is handled by stream loop. + espwifi->clearMediaCameraStreamSubscribed(clientFd); +#endif +} + +static void sendMediaAck(WebSocket *ws, int clientFd, + const JsonDocument &resp) { + if (!ws || clientFd <= 0) { + return; + } + std::string out; + serializeJson(resp, out); + (void)ws->sendText(clientFd, out.c_str(), out.size()); +} + +static void mediaOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, + const uint8_t *data, size_t len, void *userCtx) { + ESPWiFi *espwifi = static_cast(userCtx); + if (!ws || !espwifi || clientFd <= 0 || data == nullptr || len == 0) { + return; + } + + // Media control is JSON text; media payloads are sent device->client as + // binary. + if (type != HTTPD_WS_TYPE_TEXT) { + return; + } + + JsonDocument req; + DeserializationError err = deserializeJson(req, (const char *)data, len); + + JsonDocument resp; + resp["ok"] = true; + resp["type"] = "media_ack"; + + if (err) { + resp["ok"] = false; + resp["error"] = "bad_json"; + resp["detail"] = err.c_str(); + sendMediaAck(ws, clientFd, resp); + return; + } + + const char *cmd = req["cmd"] | ""; + resp["cmd"] = cmd; + +#if !ESPWiFi_HAS_CAMERA + (void)espwifi; + if (strcmp(cmd, "camera_start") == 0 || strcmp(cmd, "camera_stop") == 0 || + strcmp(cmd, "camera_frame") == 0) { + resp["ok"] = false; + resp["error"] = "camera_not_supported"; + sendMediaAck(ws, clientFd, resp); + return; + } +#else + if (strcmp(cmd, "camera_start") == 0) { + // Explicit request: start streaming to this client (pull-based "start"). + if (!espwifi->initCamera()) { + resp["ok"] = false; + resp["error"] = "camera_init_failed"; + sendMediaAck(ws, clientFd, resp); + return; + } + espwifi->setMediaCameraStreamSubscribed(clientFd, true); + resp["streaming"] = true; + sendMediaAck(ws, clientFd, resp); + return; + } + + if (strcmp(cmd, "camera_stop") == 0) { + espwifi->setMediaCameraStreamSubscribed(clientFd, false); + resp["streaming"] = false; + sendMediaAck(ws, clientFd, resp); + return; + } + + if (strcmp(cmd, "camera_frame") == 0) { + // One-shot frame: capture and send binary to requester only. + if (!espwifi->initCamera()) { + resp["ok"] = false; + resp["error"] = "camera_init_failed"; + sendMediaAck(ws, clientFd, resp); + return; + } + + camera_fb_t *fb = esp_camera_fb_get(); + if (!fb || fb->format != PIXFORMAT_JPEG || !fb->buf || fb->len == 0) { + if (fb) { + esp_camera_fb_return(fb); + } + resp["ok"] = false; + resp["error"] = "camera_capture_failed"; + sendMediaAck(ws, clientFd, resp); + return; + } + + // Send metadata (text) + payload (binary). + resp["type"] = "camera_frame"; + resp["len"] = (uint32_t)fb->len; + sendMediaAck(ws, clientFd, resp); + (void)ws->sendBinary(clientFd, fb->buf, fb->len); + esp_camera_fb_return(fb); + return; + } +#endif + + resp["ok"] = false; + resp["error"] = "unknown_cmd"; + sendMediaAck(ws, clientFd, resp); +} + +#endif + +void ESPWiFi::startMediaWebSocket() { +#ifndef CONFIG_HTTPD_WS_SUPPORT + return; +#else + if (mediaSocStarted) { + return; + } + + // Note: Keep unauthenticated by default to preserve current LAN UX. + // When auth is enabled, callers can still connect using ?token=... + mediaSocStarted = mediaSoc.begin("/ws/media", webServer, this, + /*onMessage*/ mediaOnMessage, + /*onConnect*/ mediaOnConnect, + /*onDisconnect*/ mediaOnDisconnect, + /*maxMessageLen*/ 2048, + /*maxBroadcastLen*/ 200 * 1024, + /*requireAuth*/ false, + /*authCheck*/ wsAuthCheck); + if (!mediaSocStarted) { + log(ERROR, "🎞️ Media WebSocket failed to start"); + return; + } + + log(INFO, "🎞️ Media WebSocket started: /ws/media"); +#endif +} From f66ffaefc4c63a6c98c83a410a4d58b423be58df Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Thu, 15 Jan 2026 19:44:42 -0500 Subject: [PATCH 11/16] convert media player to use media socket --- dashboard/src/components/MusicPlayerModule.js | 414 ++++++++++++++++-- include/ESPWiFi.h | 7 +- src/FileSystem.cpp | 6 +- src/MediaSocket.cpp | 245 ++++++++++- 4 files changed, 631 insertions(+), 41 deletions(-) diff --git a/dashboard/src/components/MusicPlayerModule.js b/dashboard/src/components/MusicPlayerModule.js index 0ba0bcd..b7e3876 100644 --- a/dashboard/src/components/MusicPlayerModule.js +++ b/dashboard/src/components/MusicPlayerModule.js @@ -22,8 +22,7 @@ import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import CastIcon from "@mui/icons-material/Cast"; import LibraryMusicIcon from "@mui/icons-material/LibraryMusic"; import MusicPlayerSettingsModal from "./MusicPlayerSettingsModal"; -import { buildApiUrl } from "../utils/apiUtils"; -import { getAuthToken } from "../utils/authUtils"; +import { buildWebSocketUrl } from "../utils/apiUtils"; export default function MusicPlayerModule({ config, @@ -54,6 +53,15 @@ export default function MusicPlayerModule({ const isMountedRef = useRef(true); const audioRef = useRef(null); + const mediaWsRef = useRef(null); + const mediaSourceRef = useRef(null); + const sourceBufferRef = useRef(null); + const objectUrlRef = useRef(""); + const pendingChunkLenRef = useRef(0); + const streamActiveRef = useRef(false); + const stopInProgressRef = useRef(false); + const suppressAudioErrorRef = useRef(false); + const streamSeqRef = useRef(0); // Supported audio file extensions const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]; @@ -88,6 +96,10 @@ export default function MusicPlayerModule({ }); audio.addEventListener("error", (e) => { + // Rapid track switching / intentional stop can trigger transient errors. + if (suppressAudioErrorRef.current || stopInProgressRef.current) { + return; + } console.error("🎡 Audio error:", e); setIsPlaying(false); setIsPaused(false); @@ -194,26 +206,15 @@ export default function MusicPlayerModule({ const handleSelectTrack = async (file, index) => { if (!audioRef.current) return; + // Bump sequence so any in-flight WS/audio events from older streams are ignored. + const mySeq = (streamSeqRef.current = streamSeqRef.current + 1); + + // Stop any existing playback/stream before switching tracks + await handleStop(); + const musicDir = config?.musicDir || "/music"; const filePath = `${musicDir}/${file.name}`; - // Encode the file path properly - split by '/' and encode each segment - const encodedPath = filePath - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); - - // Use /sd prefix for SD card file system - // Don't pass mdnsHostname - use relative path so it uses current browser location - let fileUrl = buildApiUrl(`/sd${encodedPath}`); - - // Add auth token as query parameter - const token = getAuthToken(); - if (token && token !== "null" && token !== "undefined" && token.trim()) { - const sep = fileUrl.includes("?") ? "&" : "?"; - fileUrl = `${fileUrl}${sep}token=${encodeURIComponent(token)}`; - } - console.log("🎡 Playing track:", file.name); // Update state immediately @@ -221,14 +222,286 @@ export default function MusicPlayerModule({ setCurrentTrackIndex(index); setProgress(0); - // Set audio source and play - audioRef.current.src = fileUrl; + // Build ws://.../ws/media URL (include token via apiUtils) + const mdnsHostname = globalConfig?.hostname || globalConfig?.deviceName; + const mediaWsUrl = buildWebSocketUrl("/ws/media", mdnsHostname || null); + if (!mediaWsUrl) { + console.error("🎡 Failed to build media WebSocket URL."); + return; + } - try { - await audioRef.current.play(); - } catch (error) { - console.error("🎡 Error playing track:", error); + // Setup websocket + streamActiveRef.current = true; + pendingChunkLenRef.current = 0; + + const ws = new WebSocket(mediaWsUrl); + ws.binaryType = "arraybuffer"; + mediaWsRef.current = ws; + + // Guess MIME; server will also accept mime hint + const lower = String(file.name || "").toLowerCase(); + const mime = lower.endsWith(".mp3") + ? "audio/mpeg" + : lower.endsWith(".ogg") + ? "audio/ogg" + : "audio/mpeg"; + + // Decide playback pipeline: + // - If MediaSource is available AND supports this mime, stream progressively. + // - Otherwise, download full file over WS, then play from a Blob URL. + const canMse = + typeof window !== "undefined" && + "MediaSource" in window && + typeof window.MediaSource?.isTypeSupported === "function" && + window.MediaSource.isTypeSupported(mime); + + let ms = null; + let sourceOpen = false; + let startAck = null; + const blobParts = []; + + const setAudioSrcObjectUrl = (url) => { + if (!audioRef.current) return; + audioRef.current.src = url; + try { + audioRef.current.load(); + } catch { + // ignore + } + }; + + if (canMse) { + ms = new MediaSource(); + mediaSourceRef.current = ms; + const objUrl = URL.createObjectURL(ms); + objectUrlRef.current = objUrl; + setAudioSrcObjectUrl(objUrl); + + const onSourceOpen = () => { + sourceOpen = true; + // If we already got music_start, we can init SB now. + if (!startAck || sourceBufferRef.current) return; + try { + const sb = ms.addSourceBuffer(startAck?.mime || mime); + sourceBufferRef.current = sb; + sb.addEventListener("updateend", () => { + // Pull next chunk after append completes + if (streamActiveRef.current) { + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } + }); + // Kick off first chunk + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } catch (e) { + console.error("🎡 addSourceBuffer failed:", e); + handleStop(); + } + }; + + // Attach immediately to avoid sourceopen race. + ms.addEventListener("sourceopen", onSourceOpen, { once: true }); + if (ms.readyState === "open") { + onSourceOpen(); + } + } else { + // Blob-download mode: don't set src until we have full file. + mediaSourceRef.current = null; + sourceBufferRef.current = null; + objectUrlRef.current = ""; + setAudioSrcObjectUrl(""); } + + const startCmd = { + cmd: "music_start", + fs: "sd", + path: filePath, + mime, + chunkSize: 16384, + }; + + ws.onopen = () => { + if (mySeq !== streamSeqRef.current) { + try { + ws.close(1000, "superseded"); + } catch { + // ignore + } + return; + } + try { + ws.send(JSON.stringify(startCmd)); + } catch (e) { + console.error("🎡 Failed to send music_start:", e); + } + }; + + ws.onmessage = async (event) => { + if (mySeq !== streamSeqRef.current) { + return; + } + // Text: control/metadata; Binary: audio bytes + if (typeof event.data === "string") { + let msg; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + + if (msg?.type === "music_start") { + if (msg?.ok === false) { + console.error("🎡 music_start failed:", msg?.error || "unknown"); + handleStop(); + return; + } + startAck = msg; + + // Kick off streaming in blob mode immediately. + if (!canMse) { + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } else { + // If source is already open, init SB now. + if (sourceOpen && ms && !sourceBufferRef.current) { + try { + const sb = ms.addSourceBuffer(startAck?.mime || mime); + sourceBufferRef.current = sb; + sb.addEventListener("updateend", () => { + if (streamActiveRef.current) { + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } + }); + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } catch (e) { + console.error("🎡 addSourceBuffer failed:", e); + handleStop(); + } + } + } + } + + if (msg?.type === "music_chunk") { + if (msg?.eof) { + if (canMse && ms) { + try { + if (ms.readyState === "open") ms.endOfStream(); + } catch { + // ignore + } + } else { + // Blob-download complete: create a playable URL and start playback. + try { + const blob = new Blob(blobParts, { + type: startAck?.mime || mime, + }); + const url = URL.createObjectURL(blob); + objectUrlRef.current = url; + setAudioSrcObjectUrl(url); + await audioRef.current.play(); + } catch (e) { + console.error("🎡 Failed to play downloaded track:", e); + } + // Close stream after completion + try { + ws.close(1000, "music_eof"); + } catch { + // ignore + } + } + return; + } + pendingChunkLenRef.current = Number(msg?.len || 0); + } + + return; + } + + if (event.data instanceof ArrayBuffer) { + if (mySeq !== streamSeqRef.current) { + return; + } + const expected = pendingChunkLenRef.current; + if (!expected || expected <= 0) { + return; + } + + const buf = new Uint8Array(event.data); + pendingChunkLenRef.current = 0; + + if (canMse) { + const sb = sourceBufferRef.current; + if (!sb) return; + + // Wait until sourceBuffer is ready + const append = () => { + try { + if (!sb.updating) { + sb.appendBuffer(buf); + } else { + setTimeout(append, 10); + } + } catch (e) { + console.error("🎡 appendBuffer failed:", e); + } + }; + append(); + + // Start playback as soon as we have some buffered data + try { + await audioRef.current.play(); + } catch { + // ignore autoplay restrictions; user initiated click should allow + } + } else { + // Blob-download mode: buffer bytes, then request next chunk. + blobParts.push(buf); + try { + ws.send(JSON.stringify({ cmd: "music_next" })); + } catch { + // ignore + } + } + } + }; + + ws.onerror = (e) => { + // Don't spam errors when we're intentionally stopping/switching. + if (mySeq !== streamSeqRef.current || stopInProgressRef.current) { + return; + } + console.warn("🎡 media socket error:", e); + handleStop(); + }; + + ws.onclose = () => { + // When switching tracks we intentionally supersede old sockets. + if (mySeq !== streamSeqRef.current || stopInProgressRef.current) { + return; + } + // Unexpected close: stop playback. + if (streamActiveRef.current) { + handleStop(); + } + }; }; // Handle play/pause @@ -257,19 +530,88 @@ export default function MusicPlayerModule({ const handleStop = async () => { if (!audioRef.current) return; - // Stop local audio playback and abort HTTP requests - audioRef.current.pause(); - audioRef.current.currentTime = 0; + stopInProgressRef.current = true; + suppressAudioErrorRef.current = true; + setTimeout(() => { + suppressAudioErrorRef.current = false; + }, 250); + + try { + // Stop media stream first (best-effort) + streamActiveRef.current = false; + if (mediaWsRef.current) { + const ws = mediaWsRef.current; + mediaWsRef.current = null; + + // Avoid browser console noise: don't close while CONNECTING. + if (ws.readyState === WebSocket.CONNECTING) { + ws.onopen = () => { + try { + ws.send(JSON.stringify({ cmd: "music_stop" })); + } catch { + // ignore + } + try { + ws.close(1000, "stop_after_connect"); + } catch { + // ignore + } + }; + } else { + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ cmd: "music_stop" })); + } + } catch { + // ignore + } + try { + ws.close(1000, "stop"); + } catch { + // ignore + } + } + } + } finally { + // Tear down MediaSource / blob URL + sourceBufferRef.current = null; + mediaSourceRef.current = null; + pendingChunkLenRef.current = 0; + if (objectUrlRef.current && objectUrlRef.current.startsWith("blob:")) { + try { + URL.revokeObjectURL(objectUrlRef.current); + } catch { + // ignore + } + objectUrlRef.current = ""; + } - // Clear the source to abort ongoing HTTP range requests - // This prevents the device from continuing to serve the file - audioRef.current.src = ""; - audioRef.current.load(); // Reset the audio element + // Stop local audio playback and abort network requests + try { + audioRef.current.pause(); + } catch { + // ignore + } + try { + audioRef.current.currentTime = 0; + } catch { + // ignore + } - setProgress(0); - setCurrentTime(0); - setIsPlaying(false); - setIsPaused(false); + // Clear the source to abort ongoing requests + try { + audioRef.current.src = ""; + audioRef.current.load(); // Reset the audio element + } catch { + // ignore + } + + setProgress(0); + setCurrentTime(0); + setIsPlaying(false); + setIsPaused(false); + stopInProgressRef.current = false; + } }; // Handle skip next diff --git a/include/ESPWiFi.h b/include/ESPWiFi.h index ba5b8bb..bc1e2c3 100644 --- a/include/ESPWiFi.h +++ b/include/ESPWiFi.h @@ -386,12 +386,13 @@ class ESPWiFi { WebSocket ctrlSoc; bool ctrlSocStarted = false; - // ---- Camera - void streamCamera(); -#if ESPWiFi_HAS_CAMERA + // ---- Media socket (camera + audio + future binary streams) WebSocket mediaSoc; bool mediaSocStarted = false; + // ---- Camera + void streamCamera(); +#if ESPWiFi_HAS_CAMERA sensor_t *camera = nullptr; void printCameraSettings(); diff --git a/src/FileSystem.cpp b/src/FileSystem.cpp index 3289cbb..d5a4f39 100644 --- a/src/FileSystem.cpp +++ b/src/FileSystem.cpp @@ -246,7 +246,11 @@ void ESPWiFi::deinitSDCard() { return; } -#if defined(CONFIG_IDF_TARGET_ESP32) +// Best-effort unmount on ESP32-family targets (ESP32 / ESP32-S3 / ESP32-C3). +// This prevents SPI bus re-init conflicts and VFS inconsistencies during +// remount. +#if defined(CONFIG_IDF_TARGET_ESP32) || defined(CONFIG_IDF_TARGET_ESP32S3) || \ + defined(CONFIG_IDF_TARGET_ESP32C3) // Best-effort unmount. This can fail if card was never mounted correctly. // Clear sdCard pointer first to prevent other code from trying to use it sdmmc_card_t *card = (sdmmc_card_t *)sdCard; diff --git a/src/MediaSocket.cpp b/src/MediaSocket.cpp index 383eec2..d8051c2 100644 --- a/src/MediaSocket.cpp +++ b/src/MediaSocket.cpp @@ -1,8 +1,14 @@ -// MediaSocket.cpp - Media WebSocket endpoint (camera streaming on request) +// MediaSocket.cpp - Media WebSocket endpoint (camera + audio streaming on +// request) #include "ESPWiFi.h" #ifdef CONFIG_HTTPD_WS_SUPPORT +#include +#include +#include +#include + // Auth check helper for WebSocket static bool wsAuthCheck(httpd_req_t *req, void *userCtx) { ESPWiFi *espwifi = static_cast(userCtx); @@ -23,6 +29,61 @@ static bool wsAuthCheck(httpd_req_t *req, void *userCtx) { return ok; } +// ---------------------------------------------------------------------------- +// Music streaming (per-client file -> binary chunks on request) +// ---------------------------------------------------------------------------- + +struct MusicStreamState { + int fd = 0; + FILE *f = nullptr; + uint32_t offset = 0; + uint32_t size = 0; + uint32_t chunkSize = 16 * 1024; + uint32_t chunksSent = 0; + char fullPath[256] = {0}; + char mime[48] = {0}; +}; + +static constexpr size_t kMaxMusicStreams = 8; +static MusicStreamState s_music[kMaxMusicStreams]; + +static MusicStreamState *musicStateForFd(int fd) { + for (size_t i = 0; i < kMaxMusicStreams; i++) { + if (s_music[i].fd == fd) { + return &s_music[i]; + } + } + return nullptr; +} + +static MusicStreamState *allocMusicState(int fd) { + MusicStreamState *existing = musicStateForFd(fd); + if (existing) { + return existing; + } + for (size_t i = 0; i < kMaxMusicStreams; i++) { + if (s_music[i].fd == 0) { + s_music[i].fd = fd; + return &s_music[i]; + } + } + return nullptr; +} + +static void closeMusicState(MusicStreamState *st) { + if (!st) { + return; + } + if (st->f) { + fclose(st->f); + } + *st = MusicStreamState{}; +} + +static bool hasDotDot(const std::string &p) { + return p.find("..") != std::string::npos; +} + static void mediaOnConnect(WebSocket *ws, int clientFd, void *userCtx) { (void)ws; ESPWiFi *espwifi = static_cast(userCtx); @@ -46,6 +107,17 @@ static void mediaOnDisconnect(WebSocket *ws, int clientFd, void *userCtx) { // Stop streaming for this client. Camera deinit is handled by stream loop. espwifi->clearMediaCameraStreamSubscribed(clientFd); #endif + + // Stop music stream for this client (close file handle). + MusicStreamState *st = musicStateForFd(clientFd); + if (st && st->f) { + espwifi->log(INFO, + "🎡 Music stream closed on disconnect (fd=%d, offset=%u, " + "chunks=%u, file=%s)", + clientFd, (unsigned)st->offset, (unsigned)st->chunksSent, + st->fullPath); + } + closeMusicState(st); } static void sendMediaAck(WebSocket *ws, int clientFd, @@ -89,6 +161,177 @@ static void mediaOnMessage(WebSocket *ws, int clientFd, httpd_ws_type_t type, const char *cmd = req["cmd"] | ""; resp["cmd"] = cmd; + // -------------------------- + // Music streaming commands + // -------------------------- + if (strcmp(cmd, "music_start") == 0) { + const char *fsC = req["fs"] | "sd"; // "sd" or "lfs" + const char *pathC = req["path"] | ""; // "/music/foo.mp3" + const char *mimeC = req["mime"] | "audio/mpeg"; + uint32_t chunkSize = req["chunkSize"] | (16 * 1024); + if (chunkSize < 4096) { + chunkSize = 4096; + } + if (chunkSize > (128 * 1024)) { + chunkSize = 128 * 1024; + } + + std::string rel = (pathC != nullptr) ? std::string(pathC) : ""; + if (rel.empty()) { + espwifi->log(WARNING, "🎡 music_start missing path (fd=%d)", clientFd); + resp["ok"] = false; + resp["error"] = "missing_path"; + sendMediaAck(ws, clientFd, resp); + return; + } + if (hasDotDot(rel)) { + espwifi->log(WARNING, "🎡 music_start invalid path (fd=%d, path=%s)", + clientFd, rel.c_str()); + resp["ok"] = false; + resp["error"] = "invalid_path"; + sendMediaAck(ws, clientFd, resp); + return; + } + if (rel[0] != '/') { + rel = "/" + rel; + } + + const bool useSd = (fsC != nullptr && strcmp(fsC, "sd") == 0); + const std::string base = + useSd ? espwifi->sdMountPoint : espwifi->lfsMountPoint; + const std::string full = base + rel; + + MusicStreamState *st = allocMusicState(clientFd); + if (!st) { + resp["ok"] = false; + resp["error"] = "too_many_streams"; + sendMediaAck(ws, clientFd, resp); + return; + } + + // Close any previous stream for this fd + closeMusicState(st); + st = allocMusicState(clientFd); + if (!st) { + resp["ok"] = false; + resp["error"] = "stream_alloc_failed"; + sendMediaAck(ws, clientFd, resp); + return; + } + + // Best-effort file size + struct stat stbuf; + uint32_t totalSize = 0; + if (stat(full.c_str(), &stbuf) == 0 && stbuf.st_size > 0) { + totalSize = (uint32_t)stbuf.st_size; + } + + FILE *f = fopen(full.c_str(), "rb"); + if (!f) { + espwifi->log(WARNING, + "🎡 music_start open failed (fd=%d, errno=%d, file=%s)", + clientFd, errno, full.c_str()); + resp["ok"] = false; + resp["error"] = "file_open_failed"; + resp["errno"] = errno; + sendMediaAck(ws, clientFd, resp); + closeMusicState(st); + return; + } + + st->f = f; + st->offset = 0; + st->size = totalSize; + st->chunkSize = chunkSize; + st->chunksSent = 0; + (void)snprintf(st->fullPath, sizeof(st->fullPath), "%s", full.c_str()); + (void)snprintf(st->mime, sizeof(st->mime), "%s", + (mimeC != nullptr) ? mimeC : "audio/mpeg"); + + espwifi->log( + INFO, + "🎡 music_start (fd=%d, fs=%s, chunk=%u, size=%u, mime=%s, path=%s)", + clientFd, useSd ? "sd" : "lfs", (unsigned)st->chunkSize, + (unsigned)st->size, st->mime, rel.c_str()); + + resp["type"] = "music_start"; + resp["fs"] = useSd ? "sd" : "lfs"; + resp["path"] = rel; + resp["mime"] = st->mime; + resp["chunkSize"] = (uint32_t)st->chunkSize; + if (st->size > 0) { + resp["size"] = (uint32_t)st->size; + } + sendMediaAck(ws, clientFd, resp); + return; + } + + if (strcmp(cmd, "music_stop") == 0) { + MusicStreamState *st = musicStateForFd(clientFd); + if (st && st->f) { + espwifi->log(INFO, "🎡 music_stop (fd=%d, offset=%u, chunks=%u, file=%s)", + clientFd, (unsigned)st->offset, (unsigned)st->chunksSent, + st->fullPath); + } else { + espwifi->log(DEBUG, "🎡 music_stop (fd=%d, no active stream)", clientFd); + } + closeMusicState(st); + resp["type"] = "music_stop"; + resp["stopped"] = true; + sendMediaAck(ws, clientFd, resp); + return; + } + + if (strcmp(cmd, "music_next") == 0) { + MusicStreamState *st = musicStateForFd(clientFd); + if (!st || !st->f) { + espwifi->log(VERBOSE, "🎡 music_next no active stream (fd=%d)", clientFd); + resp["ok"] = false; + resp["error"] = "no_active_stream"; + sendMediaAck(ws, clientFd, resp); + return; + } + + uint32_t want = req["maxBytes"] | st->chunkSize; + if (want < 1024) { + want = 1024; + } + if (want > 64 * 1024) { + want = 64 * 1024; + } + + static uint8_t s_chunkBuf[64 * 1024]; + size_t n = fread(s_chunkBuf, 1, (size_t)want, st->f); + if (n == 0) { + const bool eof = feof(st->f) != 0; + espwifi->log(INFO, "🎡 music_eof (fd=%d, offset=%u, chunks=%u, file=%s)", + clientFd, (unsigned)st->offset, (unsigned)st->chunksSent, + st->fullPath); + resp["type"] = "music_chunk"; + resp["len"] = 0; + resp["eof"] = eof; + resp["offset"] = st->offset; + sendMediaAck(ws, clientFd, resp); + closeMusicState(st); + return; + } + + st->offset += (uint32_t)n; + st->chunksSent += 1; + // Avoid spamming per-chunk logs. Emit a periodic progress log. + if ((st->chunksSent % 64) == 1) { + espwifi->log(DEBUG, "🎡 music_progress (fd=%d, offset=%u, chunks=%u)", + clientFd, (unsigned)st->offset, (unsigned)st->chunksSent); + } + resp["type"] = "music_chunk"; + resp["len"] = (uint32_t)n; + resp["eof"] = false; + resp["offset"] = st->offset; + sendMediaAck(ws, clientFd, resp); + (void)ws->sendBinary(clientFd, s_chunkBuf, n); + return; + } + #if !ESPWiFi_HAS_CAMERA (void)espwifi; if (strcmp(cmd, "camera_start") == 0 || strcmp(cmd, "camera_stop") == 0 || From 3e6c457976b1064c97c09cede6763e37265eff3e Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Thu, 15 Jan 2026 19:47:24 -0500 Subject: [PATCH 12/16] rsv --- src/ESPWiFi.cpp | 1 - src/routes/srvAll.cpp | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ESPWiFi.cpp b/src/ESPWiFi.cpp index 2645538..3a53fa9 100644 --- a/src/ESPWiFi.cpp +++ b/src/ESPWiFi.cpp @@ -9,7 +9,6 @@ void ESPWiFi::start() { startLogging(); readConfig(); initNVS(); - // startBLE(); startWiFi(); startMDNS(); startWebServer(); diff --git a/src/routes/srvAll.cpp b/src/routes/srvAll.cpp index db0d755..bf7fc95 100644 --- a/src/routes/srvAll.cpp +++ b/src/routes/srvAll.cpp @@ -5,9 +5,9 @@ void ESPWiFi::srvAll() { srvRoot(); - // srvAuth(); + srvAuth(); // srvLog(); - srvFiles(); + // srvFiles(); // srvGPIO(); // srvBLE(); // srvBluetooth(); From 5a6da059f8408f49ba23f9feb69f8a1b18941b16 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Fri, 16 Jan 2026 00:28:29 -0500 Subject: [PATCH 13/16] musicplayer --- dashboard/src/components/MusicPlayerModule.js | 103 ++++++++++++++++-- dashboard/src/components/SettingsButtonBar.js | 10 +- src/Config.cpp | 4 +- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/dashboard/src/components/MusicPlayerModule.js b/dashboard/src/components/MusicPlayerModule.js index b7e3876..e4bb52b 100644 --- a/dashboard/src/components/MusicPlayerModule.js +++ b/dashboard/src/components/MusicPlayerModule.js @@ -10,7 +10,6 @@ import { ListItemText, ListItemButton, CircularProgress, - LinearProgress, Slider, } from "@mui/material"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; @@ -48,6 +47,7 @@ export default function MusicPlayerModule({ const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); + const [seekPreviewProgress, setSeekPreviewProgress] = useState(0); const [volume, setVolume] = useState(0.7); const [isCasting, setIsCasting] = useState(false); @@ -62,6 +62,8 @@ export default function MusicPlayerModule({ const stopInProgressRef = useRef(false); const suppressAudioErrorRef = useRef(false); const streamSeqRef = useRef(0); + const isSeekingRef = useRef(false); + const pendingSeekTimeRef = useRef(null); // Supported audio file extensions const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]; @@ -84,9 +86,11 @@ export default function MusicPlayerModule({ }); audio.addEventListener("timeupdate", () => { - setCurrentTime(audio.currentTime); - if (audio.duration > 0) { - setProgress((audio.currentTime / audio.duration) * 100); + if (!isSeekingRef.current) { + setCurrentTime(audio.currentTime); + if (audio.duration > 0) { + setProgress((audio.currentTime / audio.duration) * 100); + } } }); @@ -644,6 +648,66 @@ export default function MusicPlayerModule({ } }; + // Scrub/seek handlers + const clampToSeekable = (t) => { + const a = audioRef.current; + if (!a) return t; + try { + if (a.seekable && a.seekable.length > 0) { + const start = a.seekable.start(0); + const end = a.seekable.end(a.seekable.length - 1); + if (Number.isFinite(start) && Number.isFinite(end) && end >= start) { + if (t < start) return start; + if (t > end) return end; + } + } + } catch { + // ignore + } + // Fallback clamp to [0, duration] + if (duration && Number.isFinite(duration)) { + if (t < 0) return 0; + if (t > duration) return duration; + } + return t; + }; + + const applySeek = (t) => { + const a = audioRef.current; + if (!a) return false; + const tt = clampToSeekable(t); + try { + if (typeof a.fastSeek === "function") { + a.fastSeek(tt); + } else { + a.currentTime = tt; + } + pendingSeekTimeRef.current = null; + return true; + } catch { + // Some browsers throw if not seekable yet; retry on canplay/loadedmetadata. + pendingSeekTimeRef.current = tt; + return false; + } + }; + + const handleSeekChange = (event, newValue) => { + if (!duration || !audioRef.current) return; + isSeekingRef.current = true; + const pct = Array.isArray(newValue) ? newValue[0] : newValue; + setSeekPreviewProgress(pct); + setProgress(pct); + setCurrentTime((pct / 100) * duration); + }; + + const handleSeekCommit = (event, newValue) => { + if (!duration || !audioRef.current) return; + const pct = Array.isArray(newValue) ? newValue[0] : newValue; + const t = (pct / 100) * duration; + applySeek(t); + isSeekingRef.current = false; + }; + // Handle cast to external device (Chromecast, etc.) const handleCast = async () => { if (!audioRef.current || !currentTrack) { @@ -864,12 +928,31 @@ export default function MusicPlayerModule({ )} - {/* Progress bar */} - {isPlaying && ( - 0 && ( + )} diff --git a/dashboard/src/components/SettingsButtonBar.js b/dashboard/src/components/SettingsButtonBar.js index 1c81130..f78b45b 100644 --- a/dashboard/src/components/SettingsButtonBar.js +++ b/dashboard/src/components/SettingsButtonBar.js @@ -21,7 +21,7 @@ import CameraButton from "./CameraButton"; import DeviceSettingsButton from "./DeviceSettingsButton"; import LogsButton from "./LogsButton"; import FileBrowserButton from "./FileBrowserButton"; -import BluetoothButton from "./BluetoothButton"; +// import BluetoothButton from "./BluetoothButton"; import AddModuleButton from "./AddModuleButton"; // Storage keys for persisting button configuration @@ -296,10 +296,10 @@ export default function SettingsButtonBar({ } // Always show Bluetooth button - never disabled (keep enabled even during music playback) - items.push({ - id: "bluetooth", - render: () => , - }); + // items.push({ + // id: "bluetooth", + // render: () => , + // }); // Show camera button if camera is installed and there's at least one camera module const hasCameraModule = config?.modules?.some((m) => m.type === "camera"); diff --git a/src/Config.cpp b/src/Config.cpp index dddc1e9..d40dea8 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -273,8 +273,8 @@ void ESPWiFi::handleConfigUpdate() { powerConfigHandler(); corsConfigHandler(); logConfigHandler(); - bleConfigHandler(); - bluetoothConfigHandler(); + // bleConfigHandler(); + // bluetoothConfigHandler(); wifiConfigHandler(); } From 961cb78820f312f2f095f32880d0c9f512d19712 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Sun, 18 Jan 2026 21:59:52 -0500 Subject: [PATCH 14/16] update ui from device --- dashboard/src/components/MusicPlayerModule.js | 695 +++++++++++------- dashboard/src/utils/apiUtils.js | 38 + dashboard/src/utils/connectionUtils.js | 12 + dependencies.lock | 2 +- platformio.ini | 2 +- 5 files changed, 499 insertions(+), 250 deletions(-) diff --git a/dashboard/src/components/MusicPlayerModule.js b/dashboard/src/components/MusicPlayerModule.js index e4bb52b..0ccc097 100644 --- a/dashboard/src/components/MusicPlayerModule.js +++ b/dashboard/src/components/MusicPlayerModule.js @@ -1,10 +1,12 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useMemo, useState, useEffect, useRef } from "react"; import Module from "./Module"; import { Box, Typography, IconButton, Tooltip, + TextField, + InputAdornment, List, ListItem, ListItemText, @@ -17,9 +19,11 @@ import PauseIcon from "@mui/icons-material/Pause"; import StopIcon from "@mui/icons-material/Stop"; import SkipNextIcon from "@mui/icons-material/SkipNext"; import SkipPreviousIcon from "@mui/icons-material/SkipPrevious"; -import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import CastIcon from "@mui/icons-material/Cast"; import LibraryMusicIcon from "@mui/icons-material/LibraryMusic"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import MusicPlayerSettingsModal from "./MusicPlayerSettingsModal"; import { buildWebSocketUrl } from "../utils/apiUtils"; @@ -41,15 +45,16 @@ export default function MusicPlayerModule({ const [isPaused, setIsPaused] = useState(false); const [loading, setLoading] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [settingsData, setSettingsData] = useState({ musicDir: config?.musicDir || "/music", }); - const [progress, setProgress] = useState(0); - const [duration, setDuration] = useState(0); - const [currentTime, setCurrentTime] = useState(0); - const [seekPreviewProgress, setSeekPreviewProgress] = useState(0); + const [duration, setDuration] = useState(0); // seconds (finite; may reflect seekable end) + const [currentTime, setCurrentTime] = useState(0); // seconds + const [seekPreviewTime, setSeekPreviewTime] = useState(null); // seconds or null const [volume, setVolume] = useState(0.7); const [isCasting, setIsCasting] = useState(false); + const [showSearch, setShowSearch] = useState(false); const isMountedRef = useRef(true); const audioRef = useRef(null); @@ -64,10 +69,23 @@ export default function MusicPlayerModule({ const streamSeqRef = useRef(0); const isSeekingRef = useRef(false); const pendingSeekTimeRef = useRef(null); + const userPausedRef = useRef(false); // Supported audio file extensions const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]; + const filteredMusicFiles = useMemo(() => { + const q = (searchQuery || "").trim().toLowerCase(); + if (!q) return musicFiles; + return (musicFiles || []).filter((f) => + String(f?.name || "") + .toLowerCase() + .includes(q) + ); + }, [musicFiles, searchQuery]); + + const hasSearchFilter = Boolean((searchQuery || "").trim()); + // Update settings data when config changes useEffect(() => { setSettingsData({ @@ -82,14 +100,32 @@ export default function MusicPlayerModule({ // Event listeners for audio element audio.addEventListener("loadedmetadata", () => { - setDuration(audio.duration); + const d = Number(audio.duration); + if (Number.isFinite(d) && d > 0) { + setDuration(d); + } }); audio.addEventListener("timeupdate", () => { if (!isSeekingRef.current) { - setCurrentTime(audio.currentTime); - if (audio.duration > 0) { - setProgress((audio.currentTime / audio.duration) * 100); + const t = Number(audio.currentTime); + if (Number.isFinite(t)) { + setCurrentTime(t); + } + + // Some streaming modes report duration=Infinity; use seekable end instead. + let maxT = Number(audio.duration); + if (!Number.isFinite(maxT) || maxT <= 0) { + try { + if (audio.seekable && audio.seekable.length > 0) { + maxT = Number(audio.seekable.end(audio.seekable.length - 1)); + } + } catch { + // ignore + } + } + if (Number.isFinite(maxT) && maxT > 0) { + setDuration(maxT); } } }); @@ -112,9 +148,15 @@ export default function MusicPlayerModule({ audio.addEventListener("play", () => { setIsPlaying(true); setIsPaused(false); + userPausedRef.current = false; }); audio.addEventListener("pause", () => { + // Ignore pauses triggered by teardown/track switching + if (stopInProgressRef.current) { + return; + } + userPausedRef.current = true; setIsPaused(true); }); @@ -215,6 +257,7 @@ export default function MusicPlayerModule({ // Stop any existing playback/stream before switching tracks await handleStop(); + userPausedRef.current = false; const musicDir = config?.musicDir || "/music"; const filePath = `${musicDir}/${file.name}`; @@ -224,7 +267,7 @@ export default function MusicPlayerModule({ // Update state immediately setCurrentTrack(file); setCurrentTrackIndex(index); - setProgress(0); + setCurrentTime(0); // Build ws://.../ws/media URL (include token via apiUtils) const mdnsHostname = globalConfig?.hostname || globalConfig?.deviceName; @@ -420,7 +463,9 @@ export default function MusicPlayerModule({ const url = URL.createObjectURL(blob); objectUrlRef.current = url; setAudioSrcObjectUrl(url); - await audioRef.current.play(); + if (!userPausedRef.current) { + await audioRef.current.play(); + } } catch (e) { console.error("🎡 Failed to play downloaded track:", e); } @@ -470,10 +515,12 @@ export default function MusicPlayerModule({ append(); // Start playback as soon as we have some buffered data - try { - await audioRef.current.play(); - } catch { - // ignore autoplay restrictions; user initiated click should allow + if (!userPausedRef.current && audioRef.current?.paused) { + try { + await audioRef.current.play(); + } catch { + // ignore autoplay restrictions; user initiated click should allow + } } } else { // Blob-download mode: buffer bytes, then request next chunk. @@ -610,7 +657,6 @@ export default function MusicPlayerModule({ // ignore } - setProgress(0); setCurrentTime(0); setIsPlaying(false); setIsPaused(false); @@ -692,20 +738,23 @@ export default function MusicPlayerModule({ }; const handleSeekChange = (event, newValue) => { - if (!duration || !audioRef.current) return; + if (!audioRef.current) return; isSeekingRef.current = true; - const pct = Array.isArray(newValue) ? newValue[0] : newValue; - setSeekPreviewProgress(pct); - setProgress(pct); - setCurrentTime((pct / 100) * duration); + const t = Array.isArray(newValue) ? newValue[0] : newValue; + const tt = Number(t); + if (!Number.isFinite(tt)) return; + setSeekPreviewTime(tt); + setCurrentTime(tt); }; const handleSeekCommit = (event, newValue) => { - if (!duration || !audioRef.current) return; - const pct = Array.isArray(newValue) ? newValue[0] : newValue; - const t = (pct / 100) * duration; - applySeek(t); + if (!audioRef.current) return; + const t = Array.isArray(newValue) ? newValue[0] : newValue; + const tt = Number(t); + if (!Number.isFinite(tt)) return; + applySeek(tt); isSeekingRef.current = false; + setSeekPreviewTime(null); }; // Handle cast to external device (Chromecast, etc.) @@ -852,12 +901,114 @@ export default function MusicPlayerModule({ <> + > + + + {/* Search: anchor stays fixed; field expands without reflow */} + + + + setShowSearch(true)} + size="small" + sx={{ + color: hasSearchFilter + ? "secondary.main" + : "primary.main", + padding: { xs: "4px", sm: 1 }, + minWidth: 0, + opacity: showSearch ? 0 : 1, + pointerEvents: showSearch ? "none" : "auto", + transition: "opacity 120ms ease", + }} + > + + + + + + + setSearchQuery(e.target.value)} + placeholder="Search…" + size="small" + autoComplete="off" + sx={{ + width: { xs: 170, sm: 230, md: 280 }, + "& .MuiInputBase-root": { + fontSize: { xs: "0.8rem", sm: "0.85rem" }, + }, + }} + InputProps={{ + endAdornment: ( + + + + { + setShowSearch(false); + setSearchQuery(""); + }} + sx={{ + color: hasSearchFilter + ? "secondary.main" + : "primary.main", + }} + > + + + + + + ), + }} + /> + + +
    } onSettings={handleOpenSettings} settingsDisabled={!deviceOnline} @@ -865,10 +1016,10 @@ export default function MusicPlayerModule({ errorOutline={!deviceOnline && !isPlaying} sx={{ width: { xs: "100%", sm: "auto" }, - minWidth: { xs: "280px", sm: "400px" }, - maxWidth: { xs: "100%", sm: "500px" }, + minWidth: { xs: "320px", sm: "520px" }, + maxWidth: { xs: "100%", sm: "820px" }, minHeight: "auto", - maxHeight: { xs: "90vh", sm: "600px" }, + maxHeight: { xs: "90vh", sm: "720px" }, borderColor: getBorderColor(), "& .MuiCardContent-root": { minHeight: "auto", @@ -884,20 +1035,23 @@ export default function MusicPlayerModule({ flexDirection: "column", gap: { xs: 0.5, sm: 1 }, overflow: { xs: "hidden", sm: "visible" }, + // Let the list consume remaining space in taller layouts. + minHeight: { xs: "auto", sm: 560 }, }} > {/* Current track display */} @@ -906,7 +1060,7 @@ export default function MusicPlayerModule({ align="center" sx={{ fontWeight: "bold", - fontSize: { xs: "0.7rem", sm: "0.875rem" }, + fontSize: { xs: "0.65rem", sm: "0.8rem" }, px: { xs: 0.5, sm: 1 }, lineHeight: { xs: 1.2, sm: 1.5 }, maxWidth: "100%", @@ -917,30 +1071,159 @@ export default function MusicPlayerModule({ > {currentTrack ? currentTrack.name : "No track selected"} - {isPlaying && ( + {/* (status text removed; times are shown next to scrub bar) */} + + {/* Controls + timeline layout: transport centered over the scrub bar */} + + {/* Transport row (centered over scrub column) */} + + + + + + + + + + + + + {isPlaying && !isPaused ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + + + {/* Timeline row */} 0 ? 1 : 0.6, + fontVariantNumeric: "tabular-nums", + }} > - {isPaused ? "Paused" : "Playing"} β€’ {formatTime(currentTime)} /{" "} - {formatTime(duration)} + {formatTime( + isSeekingRef.current && seekPreviewTime !== null + ? seekPreviewTime + : currentTime + )} - )} - {/* Scrub / seek */} - {currentTrack && duration > 0 && ( 0 ? duration : 0} + step={0.25} size="small" + disabled={!currentTrack || !(duration > 0)} sx={{ + gridRow: 2, + gridColumn: 2, width: "100%", - mt: { xs: 0.5, sm: 1 }, + minWidth: 0, "& .MuiSlider-thumb": { width: { xs: 10, sm: 12 }, height: { xs: 10, sm: 12 }, @@ -954,205 +1237,108 @@ export default function MusicPlayerModule({ }, }} /> - )} - - {/* Status indicator */} - - - {/* Playback controls */} - - - - - - - - + 0 ? 1 : 0.6, + fontVariantNumeric: "tabular-nums", + }} + > + {formatTime(duration)} + - - - - - - - - - - - - {isPlaying && !isPaused ? ( - - ) : ( - - )} - - - - - - - + {/* Own box/pill */} + - - - - - - - - - - - - - - - {/* Volume Control */} - - - - - - - {isCasting ? "N/A" : `${Math.round(volume * 100)}%`} - + + + + + + + + + + + + +
    {/* Track list */} @@ -1160,10 +1346,12 @@ export default function MusicPlayerModule({ sx={{ width: "100%", maxWidth: "100%", - maxHeight: { xs: "200px", sm: "300px" }, + maxHeight: { xs: "240px", sm: "420px" }, overflow: "auto", backgroundColor: "rgba(0, 0, 0, 0.1)", borderRadius: 1, + flex: { xs: "0 0 auto", sm: "1 1 auto" }, + minHeight: { xs: "200px", sm: "260px" }, }} > {loading ? ( @@ -1177,23 +1365,34 @@ export default function MusicPlayerModule({ >
    - ) : musicFiles.length === 0 ? ( + ) : filteredMusicFiles.length === 0 ? ( - No music files found in {config?.musicDir || "/music"} + {musicFiles.length === 0 + ? `No music files found in ${config?.musicDir || "/music"}` + : "No matches"} ) : ( - {musicFiles.map((file, index) => ( - + {filteredMusicFiles.map((file) => ( + handleSelectTrack(file, index)} + selected={currentTrack?.path === file?.path} + onClick={() => + handleSelectTrack( + file, + musicFiles.findIndex((f) => f?.path === file?.path) + ) + } sx={{ py: { xs: "4px", sm: 1 }, px: { xs: 1, sm: 2 }, diff --git a/dashboard/src/utils/apiUtils.js b/dashboard/src/utils/apiUtils.js index 40b651b..440a1d5 100644 --- a/dashboard/src/utils/apiUtils.js +++ b/dashboard/src/utils/apiUtils.js @@ -28,12 +28,44 @@ const normalizeProtocol = (protocol) => { return protocol.endsWith(":") ? protocol : `${protocol}:`; }; +const isPrivateIp = (hostname) => { + // Basic check for typical LAN ranges; good enough for routing logic. + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname || ""); + if (!m) return false; + const a = Number(m[1]); + const b = Number(m[2]); + if (a === 10) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + return false; +}; + +const isDeviceHostedOrigin = () => { + const hostname = (window.location.hostname || "").toLowerCase(); + const isLocalhost = + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + const isCloudHost = + hostname === "espwifi.io" || hostname.endsWith(".espwifi.io"); + + // If you're viewing the UI from the device itself (mDNS `.local` or direct LAN IP), + // always prefer same-origin. This avoids breakage when the device hostname changes + // (e.g. espwifi.local -> spark.local) and avoids relying on baked env vars. + return ( + !isLocalhost && + !isCloudHost && + (hostname.endsWith(".local") || isPrivateIp(hostname)) + ); +}; + /** * Get the API base URL for HTTP requests * Uses environment variables or falls back to localhost:80 * @returns {string} The API base URL */ export const getApiUrl = () => { + // When served from the device, always use same-origin (relative URLs). + if (isDeviceHostedOrigin()) return ""; + // Allow explicit API override even in production (e.g. app on espwifi.io, // API on espwifi.local). const forcedHost = process.env.REACT_APP_API_HOST; @@ -87,6 +119,12 @@ export const getWebSocketUrl = (mdnsHostname = null) => { protocol = "wss:"; } + // When the UI is served from the device, always use same-origin. + // This keeps control/camera sockets working even if the device hostname changes. + if (isDeviceHostedOrigin()) { + return `${protocol}//${window.location.host}`; + } + // If an API host is explicitly forced, prefer it over any passed hostname. // This keeps WS behavior consistent with HTTP requests when the dashboard is // served from a different origin (e.g. espwifi.io). diff --git a/dashboard/src/utils/connectionUtils.js b/dashboard/src/utils/connectionUtils.js index 73dcdf8..c146074 100644 --- a/dashboard/src/utils/connectionUtils.js +++ b/dashboard/src/utils/connectionUtils.js @@ -103,6 +103,18 @@ export function resolveWebSocketUrl(endpoint, config, opts = {}) { const preferTunnel = opts.preferTunnel !== false; // default true + // If the UI is served from the device itself, always use same-origin WebSockets. + // This avoids breakage when the device's mDNS hostname changes (e.g. espwifi.local -> spark.local) + // and avoids relying on any stored/baked hostname values. + const hostCtx = getHostContext(); + if ( + hostCtx.isDeviceLikeHost && + !hostCtx.isCloudHost && + !hostCtx.isLocalhost + ) { + return buildWebSocketUrl(def.localPath); + } + // If device provided a cloud tunnel URL, use it if (preferTunnel && config?.cloudTunnel?.wsUrl) { let url = config.cloudTunnel.wsUrl; diff --git a/dependencies.lock b/dependencies.lock index fea2dd6..971780f 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -42,5 +42,5 @@ direct_dependencies: - espressif/mdns - idf manifest_hash: 8b8b0e1b366ae8c7d0ba0430eca23cbcf661d5daf41abbf40e8d6fd9435f9e50 -target: esp32s3 +target: esp32c3 version: 2.0.0 diff --git a/platformio.ini b/platformio.ini index f841aa4..592b531 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ ; platformio.ini [platformio] -default_envs = esp32-s3 +default_envs = esp32-c3 [common] lib_deps = From 60de3a44f7fe79f8e8aecdcfd01e21d255f74c53 Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Sun, 18 Jan 2026 22:21:58 -0500 Subject: [PATCH 15/16] readme --- .github/README.md | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/README.md b/.github/README.md index 3dc69cc..1d0bd2e 100644 --- a/.github/README.md +++ b/.github/README.md @@ -21,7 +21,7 @@ It supports both Station (STA) and Access Point (AP) modes, JSON-based configura - 🧩 Modular: add pins, WebSockets, and more via dashboard - πŸ” Configurable authentication (username/password) - πŸ”„ Over-the-air (OTA) firmware and filesystem updates -- πŸ› οΈ PlatformIO and Arduino IDE compatible +- πŸ› οΈ ESP-IDF / PlatformIO (ESP-IDF) compatible --- @@ -34,10 +34,6 @@ lib_deps = https://github.com/vekjja/ESPWiFi.git ``` -### Arduino IDE -1. Download or clone this repo -2. Move the folder into your Arduino `libraries/` directory - --- ## πŸ“‚ File Structure @@ -74,23 +70,28 @@ ESPWiFi/ --- -## πŸ”§ Firmware Usage (C++/Arduino) +## πŸ”§ Firmware Usage (ESP-IDF) ### Quick Start (Default Configuration) -The simplest way to use ESPWiFi is with the default `start()` and `runSystem()` methods: +The simplest way to run ESPWiFi is using the default `start()` and `runSystem()` methods from your ESP-IDF entrypoint. + ###### ESPWiFi will generate a default `config.json` file if not present. ```cpp -#include +#include "ESPWiFi.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "nvs_flash.h" ESPWiFi espwifi; -void setup() { +extern "C" void app_main(void) { espwifi.start(); -} - -void loop() { - espwifi.runSystem(); + while (1) { + espwifi.runSystem(); + espwifi.feedWatchDog(); + } } ``` @@ -139,19 +140,19 @@ For more control, you can use `config.json` to configure the device and start se ### Example Custom Firmware Usage ```cpp -#include +#include "ESPWiFi.h" ESPWiFi espwifi; -void setup() { +extern "C" void app_main(void) { espwifi.readConfig(); // Load config from LittleFS (Can be omitted, but will be called by other services if needed) espwifi.startAP(); // Explicitly Start Access Point Only espwifi.srvGPIO(); // Register Only GPIO endpoints -} -void loop() { - feedWatchDog(); // Allow other tasks to run - // Custom main loop logic here + while (1) { + espwifi.feedWatchDog(); // Allow other tasks to run + // Custom main loop logic here + } } ``` From 61f48488f8f96ec99c2036c53b39ee090281213c Mon Sep 17 00:00:00 2001 From: Kevin Jayne Date: Sun, 18 Jan 2026 22:28:53 -0500 Subject: [PATCH 16/16] c3-build --- .github/workflows/FirmwareBuild.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/FirmwareBuild.yml b/.github/workflows/FirmwareBuild.yml index 499a448..a7a2b19 100644 --- a/.github/workflows/FirmwareBuild.yml +++ b/.github/workflows/FirmwareBuild.yml @@ -250,10 +250,10 @@ jobs: # chip: esp32 # baud: 460800 # bootloader_offset: "0x1000" - # - environment: esp32-c3 - # chip: esp32c3 - # baud: 921600 - # bootloader_offset: "0x0" + - environment: esp32-c3 + chip: esp32c3 + baud: 921600 + bootloader_offset: "0x0" - environment: esp32-s3 chip: esp32s3 baud: 921600