diff --git a/.gitignore b/.gitignore index 96dd0fa..9d828ce 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ releases/ personal_docs/ *.7z to-do/ -docs/session \ No newline at end of file +docs/session +SDR/build +SDR/dist +SDR/upx +SDR/sdr_launcher.spec \ No newline at end of file diff --git a/include/FileCommands.h b/include/FileCommands.h index 8109bc5..37cc555 100644 --- a/include/FileCommands.h +++ b/include/FileCommands.h @@ -34,6 +34,7 @@ public: handler.registerCommand(0x0F, handleMoveFile); handler.registerCommand(0x10, handleSaveToSignalsWithName); handler.registerCommand(0x14, handleGetDirectoryTree); // Changed from 0x12 to avoid conflict with startJam + handler.registerCommand(0x18, handleFormatSDCard); } private: @@ -747,6 +748,60 @@ public: return true; } + /** + * @brief Recursively remove a directory and all its contents. + * + * Walks the directory tree depth-first: deletes every file, recurses + * into sub-directories, then removes the now-empty directory itself. + * + * @param fs Filesystem reference (SD or LittleFS). + * @param path Absolute path of the directory to remove. + * @return true if the directory and all children were deleted. + */ + static bool removeDirectoryRecursive(fs::FS& fs, const char* path) { + File dir = fs.open(path); + if (!dir || !dir.isDirectory()) { + dir.close(); + return false; + } + + bool allOk = true; + File child = dir.openNextFile(); + while (child) { + // Copy path before close — child.path() is an internal pointer + // that becomes invalid after child.close(). + char childPathBuf[256]; + strncpy(childPathBuf, child.path(), sizeof(childPathBuf) - 1); + childPathBuf[sizeof(childPathBuf) - 1] = '\0'; + bool isDir = child.isDirectory(); + child.close(); + + if (isDir) { + if (!removeDirectoryRecursive(fs, childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPathBuf); + allOk = false; + // Continue deleting other entries instead of aborting + } + } else { + if (!fs.remove(childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove file: %s", childPathBuf); + allOk = false; + } + } + // Yield to prevent watchdog timeout on deep/large trees + vTaskDelay(1); + child = dir.openNextFile(); + } + dir.close(); + + // Directory should now be empty — remove it + if (!fs.rmdir(path)) { + ESP_LOGE("FileCmd", "Failed to rmdir: %s", path); + return false; + } + return allOk; + } + static bool handleRemoveFile(const uint8_t* data, size_t len) { if (len < 2) { sendBinaryFileActionResult(1, false, 1); // 1=delete, error 1=insufficient data @@ -778,7 +833,7 @@ public: bool ok = false; if (isDirectory) { - ok = fs.rmdir(pathBuffer.c_str()); + ok = removeDirectoryRecursive(fs, pathBuffer.c_str()); } else { ok = fs.remove(pathBuffer.c_str()); } @@ -786,6 +841,111 @@ public: sendBinaryFileActionResult(1, ok, ok ? 0 : 4, pathBuffer.c_str()); // error 4=delete failed return ok; } + + /** + * @brief Format SD card: recursively delete all contents and re-create + * the default directory structure. + * Sends progressive feedback (errorCode 0xFF = in-progress step). + * + * Payload: [0x46][0x53] ('FS') as confirmation guard — prevents + * accidental invocation. + */ + static bool handleFormatSDCard(const uint8_t* data, size_t len) { + // Require 2-byte confirmation payload 'FS' (Format SD) + if (len < 2 || data[0] != 0x46 || data[1] != 0x53) { + ESP_LOGW("FileCmd", "Format SD rejected: missing confirmation 'FS'"); + sendBinaryFileActionResult(8, false, 1); // actionType 8 = format + return false; + } + + ESP_LOGW("FileCmd", "FORMAT SD CARD — deleting all contents"); + + // Phase 1: notify app that format has started + sendBinaryFileActionResult(8, true, 0xFF, "Starting format..."); + vTaskDelay(pdMS_TO_TICKS(50)); // Let BLE send the notification + + // Phase 2: recursively delete every entry in SD root + File root = SD.open("/"); + if (!root || !root.isDirectory()) { + ESP_LOGE("FileCmd", "Cannot open SD root"); + sendBinaryFileActionResult(8, false, 2); + return false; + } + + bool allOk = true; + int deletedCount = 0; + File child = root.openNextFile(); + while (child) { + // Copy path to local buffer before close — child.path() + // returns an internal pointer invalidated by close(). + char childPathBuf[256]; + strncpy(childPathBuf, child.path(), sizeof(childPathBuf) - 1); + childPathBuf[sizeof(childPathBuf) - 1] = '\0'; + bool isDir = child.isDirectory(); + child.close(); + + // Send progress notification for each item being deleted + char progressMsg[280]; + snprintf(progressMsg, sizeof(progressMsg), "Deleting: %s", childPathBuf); + sendBinaryFileActionResult(8, true, 0xFF, progressMsg); + vTaskDelay(pdMS_TO_TICKS(20)); // Let BLE send + prevent WDT + + if (isDir) { + if (!removeDirectoryRecursive(SD, childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPathBuf); + allOk = false; + } + } else { + if (!SD.remove(childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove file: %s", childPathBuf); + allOk = false; + } + } + deletedCount++; + // Yield to prevent watchdog timeout during format + vTaskDelay(1); + child = root.openNextFile(); + } + root.close(); + + ESP_LOGI("FileCmd", "Deleted %d items from SD root", deletedCount); + + // Phase 3: re-create default directory structure with progress and verification + static const char* defaultDirs[] = { + "/DATA", + "/DATA/RECORDS", + "/DATA/SIGNALS", + "/DATA/PRESETS", + "/DATA/TEMP" + }; + bool creationSuccess = true; + for (int i = 0; i < 5; i++) { + char progressMsg[280]; + snprintf(progressMsg, sizeof(progressMsg), "Creating: %s", defaultDirs[i]); + sendBinaryFileActionResult(8, true, 0xFF, progressMsg); + vTaskDelay(pdMS_TO_TICKS(20)); + + // Create directory and verify + if (!SD.mkdir(defaultDirs[i])) { + // mkdir returns false if directory already exists or creation failed + // Check if it exists to distinguish between these cases + if (!SD.exists(defaultDirs[i])) { + ESP_LOGE("FileCmd", "Failed to create directory: %s", defaultDirs[i]); + creationSuccess = false; + allOk = false; + } else { + ESP_LOGI("FileCmd", "Directory already exists: %s", defaultDirs[i]); + } + } else { + ESP_LOGI("FileCmd", "Created directory: %s", defaultDirs[i]); + } + } + + ESP_LOGI("FileCmd", "SD card format %s", allOk ? "complete" : "completed with errors"); + // Send final result (errorCode 0 = done successfully, 4 = done with errors) + sendBinaryFileActionResult(8, allOk, allOk ? 0 : 4); + return allOk; + } static bool handleRenameFile(const uint8_t* data, size_t len) { if (len < 3) { diff --git a/include/config.h b/include/config.h index 27aad63..540b409 100644 --- a/include/config.h +++ b/include/config.h @@ -7,9 +7,9 @@ // - PATCH: Bug fixes, optimizations // The app will compare these values for FW update matching. #define FIRMWARE_VERSION_MAJOR 1 -#define FIRMWARE_VERSION_MINOR 0 -#define FIRMWARE_VERSION_PATCH 3 -#define FIRMWARE_VERSION_STRING "1.0.3" +#define FIRMWARE_VERSION_MINOR 1 +#define FIRMWARE_VERSION_PATCH 0 +#define FIRMWARE_VERSION_STRING "1.1.0" #define CC1101_NUM_MODULES 2 diff --git a/mobile_app/ios/Flutter/Generated.xcconfig b/mobile_app/ios/Flutter/Generated.xcconfig index 5735512..4e648c2 100644 --- a/mobile_app/ios/Flutter/Generated.xcconfig +++ b/mobile_app/ios/Flutter/Generated.xcconfig @@ -1,11 +1,11 @@ // This is a generated file; do not edit or check into version control. FLUTTER_ROOT=C:\Flutter\flutter -FLUTTER_APPLICATION_PATH=C:\Users\Andrea\VSCode_project\EvilCrow-V2\mobile_app +FLUTTER_APPLICATION_PATH=C:\Users\Andrea\VSCode_project\EvilCrow-RF-V2\EvilCrowRF-V2\mobile_app COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_TARGET=lib\main.dart FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.1.0 -FLUTTER_BUILD_NUMBER=6 +FLUTTER_BUILD_NAME=1.0.3 +FLUTTER_BUILD_NUMBER=16 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 DART_OBFUSCATION=false diff --git a/mobile_app/ios/Flutter/flutter_export_environment.sh b/mobile_app/ios/Flutter/flutter_export_environment.sh index 6a82d77..70987f1 100644 --- a/mobile_app/ios/Flutter/flutter_export_environment.sh +++ b/mobile_app/ios/Flutter/flutter_export_environment.sh @@ -1,12 +1,12 @@ #!/bin/sh # This is a generated file; do not edit or check into version control. export "FLUTTER_ROOT=C:\Flutter\flutter" -export "FLUTTER_APPLICATION_PATH=C:\Users\Andrea\VSCode_project\EvilCrow-V2\mobile_app" +export "FLUTTER_APPLICATION_PATH=C:\Users\Andrea\VSCode_project\EvilCrow-RF-V2\EvilCrowRF-V2\mobile_app" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_TARGET=lib\main.dart" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.1.0" -export "FLUTTER_BUILD_NUMBER=6" +export "FLUTTER_BUILD_NAME=1.0.3" +export "FLUTTER_BUILD_NUMBER=16" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" diff --git a/mobile_app/lib/l10n/app_en.arb b/mobile_app/lib/l10n/app_en.arb index 11878b6..44c9a9b 100644 --- a/mobile_app/lib/l10n/app_en.arb +++ b/mobile_app/lib/l10n/app_en.arb @@ -118,6 +118,7 @@ } }, "unknownDevice": "Unknown Device", + "noSupportedDevicesShowAll": "No supported devices found. Select your device manually:", "notConnectedToDevice": "Not connected to device", "connectToDeviceToManageFiles": "Connect to a device to manage files", @@ -1054,6 +1055,7 @@ "appSettings": "App Settings", "appSettingsSubtitle": "Language, cache, permissions", "rfSettings": "RF Settings", + "rfSettingsSubtitle": "Bruteforce, Radio & Scanner settings", "syncedWithDevice": "Synced with device", "localOnly": "Local only", "bruteforceSettings": "Bruteforce Settings", diff --git a/mobile_app/lib/l10n/app_localizations.dart b/mobile_app/lib/l10n/app_localizations.dart index eedd463..761f67d 100644 --- a/mobile_app/lib/l10n/app_localizations.dart +++ b/mobile_app/lib/l10n/app_localizations.dart @@ -362,6 +362,12 @@ abstract class AppLocalizations { /// **'Unknown Device'** String get unknownDevice; + /// No description provided for @noSupportedDevicesShowAll. + /// + /// In en, this message translates to: + /// **'No supported devices found. Select your device manually:'** + String get noSupportedDevicesShowAll; + /// No description provided for @notConnectedToDevice. /// /// In en, this message translates to: @@ -2654,6 +2660,12 @@ abstract class AppLocalizations { /// **'RF Settings'** String get rfSettings; + /// No description provided for @rfSettingsSubtitle. + /// + /// In en, this message translates to: + /// **'Bruteforce, Radio & Scanner settings'** + String get rfSettingsSubtitle; + /// No description provided for @syncedWithDevice. /// /// In en, this message translates to: diff --git a/mobile_app/lib/l10n/app_localizations_en.dart b/mobile_app/lib/l10n/app_localizations_en.dart index 19a10ec..0d50619 100644 --- a/mobile_app/lib/l10n/app_localizations_en.dart +++ b/mobile_app/lib/l10n/app_localizations_en.dart @@ -165,6 +165,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unknownDevice => 'Unknown Device'; + @override + String get noSupportedDevicesShowAll => + 'No supported devices found. Select your device manually:'; + @override String get notConnectedToDevice => 'Not connected to device'; @@ -1492,6 +1496,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rfSettings => 'RF Settings'; + @override + String get rfSettingsSubtitle => 'Bruteforce, Radio & Scanner settings'; + @override String get syncedWithDevice => 'Synced with device'; diff --git a/mobile_app/lib/l10n/app_localizations_ru.dart b/mobile_app/lib/l10n/app_localizations_ru.dart index 00d825e..8e71515 100644 --- a/mobile_app/lib/l10n/app_localizations_ru.dart +++ b/mobile_app/lib/l10n/app_localizations_ru.dart @@ -167,6 +167,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get unknownDevice => 'Неизвестное устройство'; + @override + String get noSupportedDevicesShowAll => + 'Устройства не распознаны. Выберите вручную:'; + @override String get notConnectedToDevice => 'Не подключено к устройству'; @@ -1499,6 +1503,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get rfSettings => 'Настройки RF'; + @override + String get rfSettingsSubtitle => 'Брутфорс, радио и сканер'; + @override String get syncedWithDevice => 'Синхронизировано с устройством'; diff --git a/mobile_app/lib/l10n/app_ru.arb b/mobile_app/lib/l10n/app_ru.arb index d0c0b2a..b934e21 100644 --- a/mobile_app/lib/l10n/app_ru.arb +++ b/mobile_app/lib/l10n/app_ru.arb @@ -105,6 +105,7 @@ } }, "unknownDevice": "Неизвестное устройство", + "noSupportedDevicesShowAll": "Устройства не распознаны. Выберите вручную:", "notConnectedToDevice": "Не подключено к устройству", "connectToDeviceToManageFiles": "Подключитесь к устройству для управления файлами", @@ -1040,6 +1041,7 @@ "appSettings": "Настройки приложения", "appSettingsSubtitle": "Язык, кэш, разрешения", "rfSettings": "Настройки RF", + "rfSettingsSubtitle": "Брутфорс, радио и сканер", "syncedWithDevice": "Синхронизировано с устройством", "localOnly": "Только локально", "bruteforceSettings": "Настройки брутфорса", diff --git a/mobile_app/lib/providers/ble_provider.dart b/mobile_app/lib/providers/ble_provider.dart index ebb9c9c..c6c190b 100644 --- a/mobile_app/lib/providers/ble_provider.dart +++ b/mobile_app/lib/providers/ble_provider.dart @@ -104,7 +104,10 @@ class BleProvider extends ChangeNotifier { int currentPathType = 5; // 0=/DATA/RECORDS, 1=/DATA/SIGNALS, 2=/DATA/PRESETS, 3=/DATA/TEMP, 4=INTERNAL (LittleFS), 5=SD Root bool isLoadingFiles = false; double fileListProgress = 0.0; // Progress for file list loading (0.0 to 1.0) - + bool isFormattingSD = false; // True while SD format command is in progress + bool sdFormatSuccess = false; // Result of last SD format + String sdFormatProgress = ''; // Progress message during SD format (e.g. "Deleting: /somefile") + // Scanner state List detectedSignals = []; Map frequencySpectrum = {}; @@ -694,6 +697,7 @@ class BleProvider extends ChangeNotifier { _resetConnectionState(); _log('info', 'Disconnected from device'); isLoadingFiles = false; + isFormattingSD = false; // Clear cache on disconnect _fileCache.clear(); @@ -1199,38 +1203,29 @@ class BleProvider extends ChangeNotifier { // Methods for working with files /// Reads file content from ESP - Future readFileContent(String filePath, {String? basePath}) async { + Future readFileContent(String filePath, {int? pathType}) async { if (!isConnected) { throw Exception('Device not connected'); } - // Determine pathType based on basePath - int pathType = 0; // Default to /DATA/RECORDS - String relativePath = filePath; + // Use provided pathType or default to RECORDS (0) + int effectivePathType = pathType ?? 0; - if (basePath == '/DATA/SIGNALS') { - pathType = 1; - } else if (basePath == '/DATA/PRESETS') { - pathType = 2; - } else if (basePath == '/DATA/TEMP') { - pathType = 3; - } else if (basePath == '/SDROOT') { - pathType = 5; - } else if (basePath == '/') { - pathType = 4; // LittleFS internal storage + // For pathType 0-3 (RECORDS, SIGNALS, PRESETS, TEMP), extract relative path + // because firmware adds /DATA/XXXX/ prefix automatically. + // For pathType 4-5 (LittleFS root, SD root), keep full absolute path. + String pathToUse = filePath; + if (effectivePathType >= 0 && effectivePathType < 4 && filePath.startsWith('/DATA/')) { + // Extract relative path from /DATA/RECORDS/... or /DATA/SIGNALS/... + final parts = filePath.split('/'); + if (parts.length > 3) { + pathToUse = parts.sublist(3).join('/'); + } else { + pathToUse = parts.last; + } } - // Remove /DATA/ prefix if present (shouldn't be, but handle it) - if (relativePath.startsWith('/DATA/')) { - relativePath = relativePath.substring(6); // Remove '/DATA/' - } - - // Remove leading slash if present - if (relativePath.startsWith('/')) { - relativePath = relativePath.substring(1); - } - - _log('INFO', 'Reading file content: $relativePath (pathType: $pathType, basePath: $basePath)'); + _log('INFO', 'Reading file content: $pathToUse (pathType: $effectivePathType)'); // Set loading flag isLoadingFileContent = true; @@ -1243,9 +1238,9 @@ class BleProvider extends ChangeNotifier { final completer = Completer(); _pendingFileReadCompleter = completer; - // Use binary command with path type - pass full relative path including subdirectory - final command = FirmwareBinaryProtocol.createLoadFileDataCommand(relativePath, pathType: pathType); - _log('INFO', 'Sending binary command for file: $relativePath (pathType: $pathType, command length: ${command.length})'); + // Use binary command with path type + final command = FirmwareBinaryProtocol.createLoadFileDataCommand(pathToUse, pathType: effectivePathType); + _log('INFO', 'Sending binary command for file: $pathToUse (pathType: $effectivePathType, command length: ${command.length})'); // Send binary file read command await sendBinaryCommand(command); @@ -1287,10 +1282,8 @@ class BleProvider extends ChangeNotifier { _log('INFO', 'Downloading file: $filePath'); // Using readFileContent, which already uses binary protocol - // Determine pathType based on current path try { - final basePath = _getBasePathForPathType(currentPathType); - final content = await readFileContent(filePath, basePath: basePath); + final content = await readFileContent(filePath, pathType: currentPathType); // Call progress callback if available onProgress?.call(1.0); @@ -2545,6 +2538,26 @@ class BleProvider extends ChangeNotifier { _handleFileUploadResponse(responseData); } + // Handle format-sd response + if (responseData.containsKey('action') && responseData['action'] == 'format-sd') { + print('Format SD response received: $responseData'); + + // Check if this is a progress update (errorCode 0xFF) or final result + if (responseData['isProgress'] == true) { + // Progress update — update message but keep formatting state + sdFormatProgress = responseData['progressMessage']?.toString() ?? ''; + _log('info', 'SD format progress: $sdFormatProgress'); + notifyListeners(); + } else { + // Final result + isFormattingSD = false; + sdFormatProgress = ''; + sdFormatSuccess = responseData['success'] == true; + _log('info', 'SD format ${sdFormatSuccess ? 'succeeded' : 'failed'}'); + notifyListeners(); + } + } + // Handle copy response if (responseData.containsKey('action') && responseData['action'] == 'copy') { print('Copy response received: $responseData'); @@ -2998,10 +3011,11 @@ class BleProvider extends ChangeNotifier { // Use provided pathType or current int effectivePathType = pathType ?? currentPathType; - // Use the filePath as-is (it should be a relative path like "folder/file.sub" or just "file.sub") - // Only extract filename if it's an absolute path starting with /DATA/ + // For pathType 0-3 (RECORDS, SIGNALS, PRESETS, TEMP), extract relative path + // because firmware adds /DATA/XXXX/ prefix automatically. + // For pathType 4-5 (LittleFS root, SD root), keep full absolute path. String pathToUse = filePath; - if (filePath.startsWith('/DATA/')) { + if (effectivePathType >= 0 && effectivePathType < 4 && filePath.startsWith('/DATA/')) { // Extract relative path from /DATA/RECORDS/... or /DATA/SIGNALS/... final parts = filePath.split('/'); if (parts.length > 3) { @@ -3233,6 +3247,25 @@ class BleProvider extends ChangeNotifier { } } + /// Format SD card: recursively delete all contents and re-create defaults. + Future formatSDCard() async { + if (!isConnected || txCharacteristic == null) return false; + try { + final cmd = FirmwareBinaryProtocol.createFormatSDCommand(); + isFormattingSD = true; + sdFormatSuccess = false; + notifyListeners(); + await sendBinaryCommand(cmd); + _log('warning', 'Format SD command sent'); + return true; + } catch (e) { + isFormattingSD = false; + notifyListeners(); + _log('error', 'Failed to send format SD: $e'); + return false; + } + } + /// Start a bruter attack with the given menu choice (1-33) Future sendBruterCommand(int menuChoice) async { if (!isConnected || txCharacteristic == null) { @@ -3255,6 +3288,35 @@ class BleProvider extends ChangeNotifier { notifyListeners(); } + /// Start a custom De Bruijn attack with per-protocol timing and frequency. + /// Uses firmware sub-command 0xFD to pass exact Te, ratio, bits, and + /// frequency instead of relying on hardcoded De Bruijn menus. + Future sendCustomDeBruijnCommand({ + required int bits, + required int te, + required int ratio, + required double frequencyMhz, + }) async { + if (!isConnected || txCharacteristic == null) { + _log('error', 'Cannot start custom De Bruijn: not connected'); + throw Exception('Not connected'); + } + + _log('command', 'Starting custom De Bruijn: bits=$bits te=$te ratio=$ratio freq=$frequencyMhz'); + + final command = FirmwareBinaryProtocol.createCustomDeBruijnCommand( + bits: bits, + te: te, + ratio: ratio, + frequencyMhz: frequencyMhz, + ); + await sendBinaryCommand(command); + + _isBruterRunning = true; + _bruterActiveProtocol = 0xFD; + notifyListeners(); + } + /// Cancel a running bruter attack (STOP — clears saved state) Future sendBruterCancelCommand() async { if (!isConnected || txCharacteristic == null) { diff --git a/mobile_app/lib/providers/firmware_protocol.dart b/mobile_app/lib/providers/firmware_protocol.dart index 79d471a..c3eaa3c 100644 --- a/mobile_app/lib/providers/firmware_protocol.dart +++ b/mobile_app/lib/providers/firmware_protocol.dart @@ -69,6 +69,7 @@ class FirmwareBinaryProtocol { static const int MSG_REBOOT = 0x15; static const int MSG_FACTORY_RESET = 0x16; static const int MSG_SET_DEVICE_NAME = 0x17; + static const int MSG_FORMAT_SD = 0x18; // Bruter command static const int MSG_BRUTER = 0x04; @@ -820,6 +821,31 @@ class FirmwareBinaryProtocol { return _createEnhancedCommand(MSG_BRUTER, payload); } + /// Create custom De Bruijn command (sub-command 0xFD) with per-protocol params. + /// Format: [0xFD][bits:1][teLo:1][teHi:1][ratio:1][freq:4LE float] (9 bytes) + /// This sends the correct timing and frequency for any protocol, avoiding + /// hardcoded De Bruijn menus (35-39) which use fixed frequencies. + static Uint8List createCustomDeBruijnCommand({ + required int bits, + required int te, + required int ratio, + required double frequencyMhz, + }) { + final payload = Uint8List(9); + payload[0] = 0xFD; // Sub-command: custom De Bruijn + payload[1] = bits & 0xFF; + payload[2] = te & 0xFF; // Te low byte + payload[3] = (te >> 8) & 0xFF; // Te high byte + payload[4] = ratio & 0xFF; + // IEEE 754 float, little-endian + final freqBytes = ByteData(4)..setFloat32(0, frequencyMhz, Endian.little); + payload[5] = freqBytes.getUint8(0); + payload[6] = freqBytes.getUint8(1); + payload[7] = freqBytes.getUint8(2); + payload[8] = freqBytes.getUint8(3); + return _createEnhancedCommand(MSG_BRUTER, payload); + } + // ═══════════════════════════════════════════════════════════ // NRF24 Command Factories (0x20-0x2E) // ═══════════════════════════════════════════════════════════ @@ -1146,4 +1172,12 @@ class FirmwareBinaryProtocol { final payload = Uint8List.fromList([0x46, 0x52]); // 'F', 'R' return _createEnhancedCommand(MSG_FACTORY_RESET, payload); } + + /// Format SD card (0x18) + /// Payload: [0x46][0x53] ('FS') as safety confirmation. + /// Recursively deletes all SD contents and re-creates default directories. + static Uint8List createFormatSDCommand() { + final payload = Uint8List.fromList([0x46, 0x53]); // 'F', 'S' + return _createEnhancedCommand(MSG_FORMAT_SD, payload); + } } diff --git a/mobile_app/lib/providers/settings_provider.dart b/mobile_app/lib/providers/settings_provider.dart index 11a9cc3..0dfbd46 100644 --- a/mobile_app/lib/providers/settings_provider.dart +++ b/mobile_app/lib/providers/settings_provider.dart @@ -54,7 +54,6 @@ class SettingsProvider with ChangeNotifier { int _nrfDataRate = 0; // 0=1MBPS, 1=2MBPS, 2=250KBPS int _nrfChannel = 76; // Default channel (0-125) int _nrfAutoRetransmit = 5; // Retransmit count (0-15) - bool get debugMode => _debugMode; int get bruterDelayMs => _bruterDelayMs; int get bruterModule => _bruterModule; diff --git a/mobile_app/lib/screens/brute_screen.dart b/mobile_app/lib/screens/brute_screen.dart index 7156eae..c71915d 100644 --- a/mobile_app/lib/screens/brute_screen.dart +++ b/mobile_app/lib/screens/brute_screen.dart @@ -15,6 +15,10 @@ class BruterProtocol { final int bits; final String encoding; final IconData icon; + /// Timing element in microseconds (shortest pulse duration) + final int te; + /// Ratio of long pulse to short pulse (e.g. 3 means 1:3) + final int ratio; const BruterProtocol({ required this.menuId, @@ -24,6 +28,8 @@ class BruterProtocol { required this.bits, required this.encoding, required this.icon, + this.te = 300, + this.ratio = 3, }); /// Whether this protocol uses De Bruijn mode (menu 35-40) @@ -94,58 +100,58 @@ class BruterProtocol { /// All supported bruter protocols const List bruterProtocols = [ // EU Garage Remotes - BruterProtocol(menuId: 1, name: 'CAME', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 2, name: 'Princeton', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'tristate', icon: Icons.garage), - BruterProtocol(menuId: 3, name: 'NiceFlo', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 6, name: 'Holtek', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 8, name: 'Ansonic', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 11, name: 'FAAC', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 12, name: 'BFT', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 13, name: 'SMC5326', category: 'EU Garage', frequencyMhz: 433.42, bits: 12, encoding: 'tristate', icon: Icons.garage), - BruterProtocol(menuId: 14, name: 'Clemsa', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 15, name: 'GateTX', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 16, name: 'Phox', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 17, name: 'Phoenix V2', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 18, name: 'Prastel', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), - BruterProtocol(menuId: 19, name: 'Doitrand', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage), + BruterProtocol(menuId: 1, name: 'CAME', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 320, ratio: 2), + BruterProtocol(menuId: 2, name: 'Princeton', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'tristate', icon: Icons.garage, te: 350, ratio: 3), + BruterProtocol(menuId: 3, name: 'NiceFlo', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 700, ratio: 2), + BruterProtocol(menuId: 6, name: 'Holtek', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 430, ratio: 2), + BruterProtocol(menuId: 8, name: 'Ansonic', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 555, ratio: 2), + BruterProtocol(menuId: 11, name: 'FAAC', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 3), + BruterProtocol(menuId: 12, name: 'BFT', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 2), + BruterProtocol(menuId: 13, name: 'SMC5326', category: 'EU Garage', frequencyMhz: 433.42, bits: 12, encoding: 'tristate', icon: Icons.garage, te: 320, ratio: 3), + BruterProtocol(menuId: 14, name: 'Clemsa', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 2), + BruterProtocol(menuId: 15, name: 'GateTX', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 350, ratio: 2), + BruterProtocol(menuId: 16, name: 'Phox', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 2), + BruterProtocol(menuId: 17, name: 'Phoenix V2', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 500, ratio: 2), + BruterProtocol(menuId: 18, name: 'Prastel', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 2), + BruterProtocol(menuId: 19, name: 'Doitrand', category: 'EU Garage', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.garage, te: 400, ratio: 2), // US Garage Remotes - BruterProtocol(menuId: 4, name: 'Chamberlain', category: 'US Garage', frequencyMhz: 315.0, bits: 12, encoding: 'binary', icon: Icons.door_sliding), - BruterProtocol(menuId: 5, name: 'Linear', category: 'US Garage', frequencyMhz: 300.0, bits: 10, encoding: 'binary', icon: Icons.door_sliding), - BruterProtocol(menuId: 7, name: 'LiftMaster', category: 'US Garage', frequencyMhz: 315.0, bits: 12, encoding: 'binary', icon: Icons.door_sliding), - BruterProtocol(menuId: 23, name: 'Firefly', category: 'US Garage', frequencyMhz: 300.0, bits: 10, encoding: 'binary', icon: Icons.door_sliding), - BruterProtocol(menuId: 24, name: 'Linear MegaCode', category: 'US Garage', frequencyMhz: 318.0, bits: 24, encoding: 'binary', icon: Icons.door_sliding), + BruterProtocol(menuId: 4, name: 'Chamberlain', category: 'US Garage', frequencyMhz: 315.0, bits: 12, encoding: 'binary', icon: Icons.door_sliding, te: 430, ratio: 2), + BruterProtocol(menuId: 5, name: 'Linear', category: 'US Garage', frequencyMhz: 300.0, bits: 10, encoding: 'binary', icon: Icons.door_sliding, te: 500, ratio: 3), + BruterProtocol(menuId: 7, name: 'LiftMaster', category: 'US Garage', frequencyMhz: 315.0, bits: 12, encoding: 'binary', icon: Icons.door_sliding, te: 400, ratio: 2), + BruterProtocol(menuId: 23, name: 'Firefly', category: 'US Garage', frequencyMhz: 300.0, bits: 10, encoding: 'binary', icon: Icons.door_sliding, te: 400, ratio: 2), + BruterProtocol(menuId: 24, name: 'Linear MegaCode', category: 'US Garage', frequencyMhz: 318.0, bits: 24, encoding: 'binary', icon: Icons.door_sliding, te: 500, ratio: 2), // Home Automation - BruterProtocol(menuId: 20, name: 'Dooya', category: 'Home Auto', frequencyMhz: 433.92, bits: 24, encoding: 'binary', icon: Icons.blinds), - BruterProtocol(menuId: 21, name: 'Nero', category: 'Home Auto', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.blinds), - BruterProtocol(menuId: 22, name: 'Magellen', category: 'Home Auto', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.blinds), + BruterProtocol(menuId: 20, name: 'Dooya', category: 'Home Auto', frequencyMhz: 433.92, bits: 24, encoding: 'binary', icon: Icons.blinds, te: 350, ratio: 2), + BruterProtocol(menuId: 21, name: 'Nero', category: 'Home Auto', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.blinds, te: 450, ratio: 2), + BruterProtocol(menuId: 22, name: 'Magellen', category: 'Home Auto', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.blinds, te: 400, ratio: 2), // Alarm / Sensors - BruterProtocol(menuId: 9, name: 'EV1527', category: 'Alarm', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.security), - BruterProtocol(menuId: 10, name: 'Honeywell', category: 'Alarm', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.security), - BruterProtocol(menuId: 29, name: 'EV1527 24b', category: 'Alarm', frequencyMhz: 433.92, bits: 24, encoding: 'binary', icon: Icons.security), + BruterProtocol(menuId: 9, name: 'EV1527', category: 'Alarm', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.security, te: 320, ratio: 3), + BruterProtocol(menuId: 10, name: 'Honeywell', category: 'Alarm', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.security, te: 300, ratio: 2), + BruterProtocol(menuId: 29, name: 'EV1527 24b', category: 'Alarm', frequencyMhz: 433.92, bits: 24, encoding: 'binary', icon: Icons.security, te: 320, ratio: 3), // 868 MHz - BruterProtocol(menuId: 25, name: 'Hörmann', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio), - BruterProtocol(menuId: 26, name: 'Marantec', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio), - BruterProtocol(menuId: 27, name: 'Berner', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio), + BruterProtocol(menuId: 25, name: 'Hörmann', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio, te: 500, ratio: 2), + BruterProtocol(menuId: 26, name: 'Marantec', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio, te: 600, ratio: 2), + BruterProtocol(menuId: 27, name: 'Berner', category: '868 MHz', frequencyMhz: 868.35, bits: 12, encoding: 'binary', icon: Icons.radio, te: 400, ratio: 2), // Misc - BruterProtocol(menuId: 28, name: 'Intertechno V3', category: 'Misc', frequencyMhz: 433.92, bits: 32, encoding: 'binary', icon: Icons.power), - BruterProtocol(menuId: 30, name: 'StarLine', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key), - BruterProtocol(menuId: 31, name: 'Tedsen', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key), - BruterProtocol(menuId: 32, name: 'Airforce', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key), - BruterProtocol(menuId: 33, name: 'Unilarm', category: 'Misc', frequencyMhz: 433.42, bits: 12, encoding: 'binary', icon: Icons.key), - BruterProtocol(menuId: 34, name: 'ELKA', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key), + BruterProtocol(menuId: 28, name: 'Intertechno V3', category: 'Misc', frequencyMhz: 433.92, bits: 32, encoding: 'binary', icon: Icons.power, te: 250, ratio: 5), + BruterProtocol(menuId: 30, name: 'StarLine', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key, te: 500, ratio: 2), + BruterProtocol(menuId: 31, name: 'Tedsen', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key, te: 600, ratio: 2), + BruterProtocol(menuId: 32, name: 'Airforce', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key, te: 350, ratio: 3), + BruterProtocol(menuId: 33, name: 'Unilarm', category: 'Misc', frequencyMhz: 433.42, bits: 12, encoding: 'binary', icon: Icons.key, te: 350, ratio: 3), + BruterProtocol(menuId: 34, name: 'ELKA', category: 'Misc', frequencyMhz: 433.92, bits: 12, encoding: 'binary', icon: Icons.key, te: 400, ratio: 2), // De Bruijn protocols (~90x faster for binary ≤16 bits) - BruterProtocol(menuId: 35, name: 'DeBruijn Generic 433', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt), - BruterProtocol(menuId: 36, name: 'DeBruijn Generic 315', category: 'De Bruijn', frequencyMhz: 315.0, bits: 12, encoding: 'debruijn', icon: Icons.bolt), - BruterProtocol(menuId: 37, name: 'DeBruijn Holtek', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt), - BruterProtocol(menuId: 38, name: 'DeBruijn Linear', category: 'De Bruijn', frequencyMhz: 300.0, bits: 10, encoding: 'debruijn', icon: Icons.bolt), - BruterProtocol(menuId: 39, name: 'DeBruijn EV1527', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt), - BruterProtocol(menuId: 40, name: 'Universal Sweep', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.radar), + BruterProtocol(menuId: 35, name: 'DeBruijn Generic 433', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt, te: 300, ratio: 3), + BruterProtocol(menuId: 36, name: 'DeBruijn Generic 315', category: 'De Bruijn', frequencyMhz: 315.0, bits: 12, encoding: 'debruijn', icon: Icons.bolt, te: 300, ratio: 3), + BruterProtocol(menuId: 37, name: 'DeBruijn Holtek', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt, te: 430, ratio: 2), + BruterProtocol(menuId: 38, name: 'DeBruijn Linear', category: 'De Bruijn', frequencyMhz: 300.0, bits: 10, encoding: 'debruijn', icon: Icons.bolt, te: 500, ratio: 3), + BruterProtocol(menuId: 39, name: 'DeBruijn EV1527', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.bolt, te: 320, ratio: 3), + BruterProtocol(menuId: 40, name: 'Universal Sweep', category: 'De Bruijn', frequencyMhz: 433.92, bits: 12, encoding: 'debruijn', icon: Icons.radar, te: 300, ratio: 3), ]; /// Get unique category list preserving order @@ -169,7 +175,11 @@ class BruteScreen extends StatefulWidget { } /// Map from standard protocol menuId to its De Bruijn equivalent menuId. -/// Only protocols with binary encoding and ≤16 bits have De Bruijn support. +/// NOTE: This map is kept for reference only. In De Bruijn mode, the app now +/// sends a custom 0xFD command with the protocol's own Te, ratio, bits, and +/// frequency — ensuring correct per-protocol timing and frequency. +/// The hardcoded De Bruijn menus (35-39) remain available as standalone entries. +// ignore: unused_element const Map _standardToDeBruijnMap = { // CAME, NiceFlo, FAAC, BFT, Clemsa, GateTX, Phox, PhoenixV2, Prastel, // Doitrand, Nero, Magellen, Ansonic, EV1527 12b, Honeywell, StarLine, @@ -731,23 +741,17 @@ class _BruteScreenState extends State { final settingsProvider = Provider.of(context, listen: false); final delayMs = settingsProvider.bruterDelayMs; - // Determine actual menu ID: if DeBruijn mode is on and protocol is compatible, - // route to the corresponding DeBruijn menu instead. + // Determine if we should use custom De Bruijn (per-protocol timing/freq) + // instead of hardcoded De Bruijn menus which have fixed frequencies. + bool useCustomDeBruijn = false; int actualMenuId = protocol.menuId; String modeSuffix = ''; if (_useDeBruijnMode && !protocol.isDeBruijn && protocol.deBruijnCompatible) { - final dbMenuId = _standardToDeBruijnMap[protocol.menuId]; - if (dbMenuId != null) { - actualMenuId = dbMenuId; - modeSuffix = ' (DeBruijn)'; - } + useCustomDeBruijn = true; + modeSuffix = ' (DeBruijn)'; } - // Use the correct protocol for time estimation - final displayProto = actualMenuId != protocol.menuId - ? bruterProtocols.firstWhere((p) => p.menuId == actualMenuId, orElse: () => protocol) - : protocol; - final estTime = displayProto.estimatedTimeWithDelay(delayMs); + final estTime = protocol.estimatedTimeWithDelay(delayMs); // Show confirmation dialog with protocol details final confirmed = await showDialog( @@ -811,7 +815,17 @@ class _BruteScreenState extends State { if (confirmed != true || !context.mounted) return; try { - await bleProvider.sendBruterCommand(actualMenuId); + if (useCustomDeBruijn) { + // Send custom De Bruijn command with per-protocol timing and frequency + await bleProvider.sendCustomDeBruijnCommand( + bits: protocol.bits, + te: protocol.te, + ratio: protocol.ratio, + frequencyMhz: protocol.frequencyMhz, + ); + } else { + await bleProvider.sendBruterCommand(actualMenuId); + } if (context.mounted) { final notificationProvider = Provider.of(context, listen: false); diff --git a/mobile_app/lib/screens/file_viewer_screen.dart b/mobile_app/lib/screens/file_viewer_screen.dart index d95eddc..4d2d0ca 100644 --- a/mobile_app/lib/screens/file_viewer_screen.dart +++ b/mobile_app/lib/screens/file_viewer_screen.dart @@ -82,36 +82,14 @@ class _FileViewerScreenState extends State // Log file path for debugging - // Determine basePath from pathType - String basePath; - switch (widget.pathType) { - case 1: - basePath = '/DATA/SIGNALS'; - break; - case 2: - basePath = '/DATA/PRESETS'; - break; - case 3: - basePath = '/DATA/TEMP'; - break; - case 4: - basePath = '/'; - break; - default: - basePath = '/DATA/RECORDS'; - } - - // Use full path with directory (widget.filePath already contains the relative path) - // Remove leading slash if present to get relative path + // Use filePath as-is and let readFileContent handle path construction + // based on pathType (0-3=relative, 4-5=absolute) String filePath = widget.filePath; - if (filePath.startsWith('/')) { - filePath = filePath.substring(1); - } print('Loading file: path="$filePath", pathType=${widget.pathType}'); - // Read file from ESP (isLoadingFileContent flag is set internally) - final content = await bleProvider.readFileContent(filePath, basePath: basePath); + // Read file from ESP (pathType determines how path is interpreted) + final content = await bleProvider.readFileContent(filePath, pathType: widget.pathType); if (mounted) { // Check if response is an error from ESP diff --git a/mobile_app/lib/screens/record_screen.dart b/mobile_app/lib/screens/record_screen.dart index b67c0db..f642dbb 100644 --- a/mobile_app/lib/screens/record_screen.dart +++ b/mobile_app/lib/screens/record_screen.dart @@ -48,7 +48,7 @@ class _RecordScreenState extends State with TickerProviderStateMix // Files from current recording session final List _currentSessionFiles = []; - + // Flags for tracking changes final List _configsChanged = []; @@ -95,7 +95,7 @@ class _RecordScreenState extends State with TickerProviderStateMix print('_onRecordedFilesChanged called'); final runtimeFiles = _bleProvider?.recordedRuntimeFiles ?? []; print('Runtime files: $runtimeFiles'); - + // Add new files to local recorded files list for (final file in runtimeFiles) { // Extract filename from object @@ -931,7 +931,7 @@ class _RecordScreenState extends State with TickerProviderStateMix ), const SizedBox(height: 12), - + // File list only for Recording if (selectedAction == ModuleAction.recording) _buildModuleFilesList(moduleIndex), diff --git a/mobile_app/lib/screens/settings_screen.dart b/mobile_app/lib/screens/settings_screen.dart index 6345d9c..4118b95 100644 --- a/mobile_app/lib/screens/settings_screen.dart +++ b/mobile_app/lib/screens/settings_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -28,6 +29,15 @@ class _SettingsScreenState extends State { /// True after we sync HW button config from device once. bool _hwConfigSynced = false; + /// Debounce timer for nRF slider auto-send. + Timer? _nrfDebounceTimer; + + @override + void dispose() { + _nrfDebounceTimer?.cancel(); + super.dispose(); + } + /// Navigates to DebugScreen on single tap. void _onDebugTap(BuildContext context) { final settingsProvider = @@ -615,11 +625,9 @@ class _SettingsScreenState extends State { ), ), subtitle: Text( - bleProvider.settingsSynced ? AppLocalizations.of(context)!.syncedWithDevice : AppLocalizations.of(context)!.localOnly, - style: TextStyle( - color: bleProvider.settingsSynced - ? AppColors.success - : AppColors.secondaryText, + AppLocalizations.of(context)!.rfSettingsSubtitle, + style: const TextStyle( + color: AppColors.secondaryText, fontSize: 12, ), ), @@ -1027,73 +1035,79 @@ class _SettingsScreenState extends State { } Widget _buildScannerSettings(BuildContext context, BleProvider bleProvider) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return Consumer( + builder: (context, settingsProvider, child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.signal_cellular_alt, - size: 18, color: AppColors.secondaryText), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.rssiThreshold(bleProvider.scannerRssi), - style: const TextStyle( - color: AppColors.primaryText, - fontSize: 13, - fontWeight: FontWeight.w500, - ), + // ── RSSI threshold ── + Row( + children: [ + const Icon(Icons.signal_cellular_alt, + size: 18, color: AppColors.secondaryText), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.rssiThreshold(bleProvider.scannerRssi), + style: const TextStyle( + color: AppColors.primaryText, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + Text( + AppLocalizations.of(context)!.minSignalStrengthDesc, + style: const TextStyle( + color: AppColors.secondaryText, fontSize: 11), + ), + ], ), - Text( - AppLocalizations.of(context)!.minSignalStrengthDesc, - style: const TextStyle( - color: AppColors.secondaryText, fontSize: 11), - ), - ], - ), + ), + ], ), + Slider( + value: bleProvider.scannerRssi.toDouble(), + min: -120, + max: -20, + divisions: 100, + label: '${bleProvider.scannerRssi} dBm', + activeColor: AppColors.searching, + onChanged: (value) { + bleProvider.sendSettingsToDevice(scannerRssi: value.round()); + }, + ), + Wrap( + spacing: 8, + children: [-90, -80, -70, -60, -50].map((rssi) { + final isSelected = bleProvider.scannerRssi == rssi; + return ChoiceChip( + label: Text('$rssi'), + selected: isSelected, + onSelected: (_) { + bleProvider.sendSettingsToDevice(scannerRssi: rssi); + }, + selectedColor: + AppColors.searching.withValues(alpha: 0.2), + labelStyle: TextStyle( + color: isSelected + ? AppColors.searching + : AppColors.secondaryText, + fontSize: 11, + ), + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ], ), - Slider( - value: bleProvider.scannerRssi.toDouble(), - min: -120, - max: -20, - divisions: 100, - label: '${bleProvider.scannerRssi} dBm', - activeColor: AppColors.searching, - onChanged: (value) { - bleProvider.sendSettingsToDevice(scannerRssi: value.round()); - }, - ), - Wrap( - spacing: 8, - children: [-90, -80, -70, -60, -50].map((rssi) { - final isSelected = bleProvider.scannerRssi == rssi; - return ChoiceChip( - label: Text('$rssi'), - selected: isSelected, - onSelected: (_) { - bleProvider.sendSettingsToDevice(scannerRssi: rssi); - }, - selectedColor: - AppColors.searching.withValues(alpha: 0.2), - labelStyle: TextStyle( - color: isSelected - ? AppColors.searching - : AppColors.secondaryText, - fontSize: 11, - ), - visualDensity: VisualDensity.compact, - ); - }).toList(), - ), - ], - ), + ); + }, ); } @@ -1178,7 +1192,10 @@ class _SettingsScreenState extends State { return ChoiceChip( label: Text(_nrfPaLabel(lvl)), selected: isSelected, - onSelected: (_) => settingsProvider.setNrfPaLevel(lvl), + onSelected: (_) { + settingsProvider.setNrfPaLevel(lvl); + _sendNrfSettings(context, bleProvider, settingsProvider); + }, selectedColor: const Color(0xFF00BCD4).withValues(alpha: 0.2), labelStyle: TextStyle( color: isSelected ? const Color(0xFF00BCD4) : AppColors.secondaryText, @@ -1220,7 +1237,10 @@ class _SettingsScreenState extends State { return ChoiceChip( label: Text(_nrfDataRateLabel(dr)), selected: isSelected, - onSelected: (_) => settingsProvider.setNrfDataRate(dr), + onSelected: (_) { + settingsProvider.setNrfDataRate(dr); + _sendNrfSettings(context, bleProvider, settingsProvider); + }, selectedColor: const Color(0xFF00BCD4).withValues(alpha: 0.2), labelStyle: TextStyle( color: isSelected ? const Color(0xFF00BCD4) : AppColors.secondaryText, @@ -1271,6 +1291,7 @@ class _SettingsScreenState extends State { activeColor: const Color(0xFF00BCD4), onChanged: (value) { settingsProvider.setNrfChannel(value.round()); + _debouncedSendNrfSettings(context, bleProvider, settingsProvider); }, ), @@ -1313,38 +1334,9 @@ class _SettingsScreenState extends State { activeColor: const Color(0xFF00BCD4), onChanged: (value) { settingsProvider.setNrfAutoRetransmit(value.round()); + _debouncedSendNrfSettings(context, bleProvider, settingsProvider); }, ), - - const SizedBox(height: 12), - - // Send to device button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: bleProvider.isConnected - ? () => _sendNrfSettings(context, bleProvider, settingsProvider) - : null, - icon: const Icon(Icons.send), - label: Text(AppLocalizations.of(context)!.sendToDevice), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00BCD4), - foregroundColor: AppColors.primaryBackground, - disabledBackgroundColor: - const Color(0xFF00BCD4).withValues(alpha: 0.3), - disabledForegroundColor: AppColors.disabledText, - ), - ), - ), - if (!bleProvider.isConnected) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - AppLocalizations.of(context)!.connectToDeviceToApply, - style: const TextStyle( - color: AppColors.secondaryText, fontSize: 11), - ), - ), ], ), ); @@ -1375,8 +1367,18 @@ class _SettingsScreenState extends State { } } + /// Debounced auto-send for nRF slider changes (500ms). + void _debouncedSendNrfSettings(BuildContext context, BleProvider bleProvider, + SettingsProvider settingsProvider) { + _nrfDebounceTimer?.cancel(); + _nrfDebounceTimer = Timer(const Duration(milliseconds: 500), () { + _sendNrfSettings(context, bleProvider, settingsProvider); + }); + } + void _sendNrfSettings(BuildContext context, BleProvider bleProvider, SettingsProvider settingsProvider) async { + if (!bleProvider.isConnected) return; try { // Send NRF settings as a settings sync command // Using MSG_SETTINGS_UPDATE (0xC1) with extended NRF payload @@ -1862,6 +1864,59 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), + // ── Format SD Card ── + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warning.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.sd_card_alert, size: 18, color: AppColors.warning), + SizedBox(width: 8), + Text( + 'Format SD Card', + style: TextStyle( + color: AppColors.warning, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Delete all files and folders from the SD card and re-create the default directory structure. This cannot be undone.', + style: TextStyle(color: AppColors.secondaryText, fontSize: 12), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: bleProvider.isConnected + ? () => _showFormatSDDialog(context, bleProvider) + : null, + icon: const Icon(Icons.sd_card), + label: const Text('Format SD Card'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.warning, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.warning.withValues(alpha: 0.3), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + // ── Factory Reset ── Container( width: double.infinity, @@ -2049,6 +2104,158 @@ class _SettingsScreenState extends State { ); } + /// Show Format SD Card confirmation dialog, then a non-dismissible + /// progress dialog that auto-closes when the firmware sends the result. + void _showFormatSDDialog(BuildContext context, BleProvider bleProvider) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.secondaryBackground, + title: const Row( + children: [ + Icon(Icons.sd_card_alert, color: AppColors.warning, size: 24), + SizedBox(width: 10), + Text('Format SD Card', style: TextStyle(color: AppColors.warning)), + ], + ), + content: const Text( + 'Are you sure you want to delete ALL files from the SD card?\n\n' + 'This will:\n' + ' - Delete all recordings, signals, and presets\n' + ' - Delete all uploaded .sub files\n' + ' - Re-create the default directory structure\n\n' + 'This action cannot be undone.', + style: TextStyle(color: AppColors.primaryText, fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('No', style: TextStyle(color: AppColors.secondaryText, fontSize: 16)), + ), + ElevatedButton( + onPressed: () async { + Navigator.of(ctx).pop(); + final sent = await bleProvider.formatSDCard(); + if (!context.mounted) return; + if (!sent) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to send format SD command.'), + backgroundColor: AppColors.error, + ), + ); + return; + } + // Show non-dismissible progress dialog; auto-closes on result + _showSDFormatProgressDialog(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.warning, + foregroundColor: Colors.white, + ), + child: const Text('Yes, Format', style: TextStyle(fontSize: 16)), + ), + ], + ), + ); + } + + /// Non-dismissible progress dialog that listens to BleProvider.isFormattingSD + /// and closes automatically when the firmware result arrives. + void _showSDFormatProgressDialog(BuildContext context) { + bool closed = false; + bool hasReceivedResponse = false; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => PopScope( + canPop: false, + child: Consumer( + builder: (context, ble, _) { + // Mark that we've started receiving progress updates + if (ble.isFormattingSD && ble.sdFormatProgress.isNotEmpty) { + hasReceivedResponse = true; + } + + // Only close if we've received at least one progress update and formatting is done + // This prevents premature closure if firmware responds instantly with error + if (!ble.isFormattingSD && !closed && hasReceivedResponse) { + closed = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (ctx.mounted) Navigator.of(ctx).pop(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ble.sdFormatSuccess + ? 'SD card formatted successfully.' + : 'SD card format failed.'), + backgroundColor: ble.sdFormatSuccess + ? AppColors.success + : AppColors.error, + duration: const Duration(seconds: 3), + ), + ); + } + }); + } + + // Show warning if formatting takes too long without progress + if (ble.isFormattingSD && !hasReceivedResponse) { + // Start a timeout to close dialog if no response after 10 seconds + Future.delayed(const Duration(seconds: 10), () { + if (ctx.mounted && !closed && !hasReceivedResponse) { + closed = true; + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Format timeout: No response from device.'), + backgroundColor: AppColors.error, + duration: Duration(seconds: 4), + ), + ); + } + }); + } + + return AlertDialog( + backgroundColor: AppColors.secondaryBackground, + title: const Row( + children: [ + Icon(Icons.sd_card, color: AppColors.warning, size: 24), + SizedBox(width: 10), + Text('Formatting...', style: TextStyle(color: AppColors.warning)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + ble.sdFormatProgress.isNotEmpty + ? ble.sdFormatProgress + : 'Formatting SD card, please wait.', + style: const TextStyle(color: AppColors.primaryText), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + const Text( + 'Do not disconnect the device.', + style: TextStyle(color: AppColors.secondaryText, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ), + ), + ); + } + /// Build HW Buttons configuration section. Widget _buildHwButtonsSection(BuildContext context, BleProvider bleProvider) { return Card( @@ -2124,9 +2331,13 @@ class _SettingsScreenState extends State { action: settingsProvider.button1Action, color: AppColors.primaryAccent, replayPath: settingsProvider.button1ReplayPath, - onPickReplayFile: () => _pickReplaySubFile(context, settingsProvider, 1), + onPickReplayFile: () async { + await _pickReplaySubFile(context, settingsProvider, 1); + _sendButtonConfig(context, bleProvider, settingsProvider); + }, onChanged: (action) { settingsProvider.setButton1Action(action); + _sendButtonConfig(context, bleProvider, settingsProvider); }, ), @@ -2138,42 +2349,15 @@ class _SettingsScreenState extends State { action: settingsProvider.button2Action, color: AppColors.warning, replayPath: settingsProvider.button2ReplayPath, - onPickReplayFile: () => _pickReplaySubFile(context, settingsProvider, 2), + onPickReplayFile: () async { + await _pickReplaySubFile(context, settingsProvider, 2); + _sendButtonConfig(context, bleProvider, settingsProvider); + }, onChanged: (action) { settingsProvider.setButton2Action(action); + _sendButtonConfig(context, bleProvider, settingsProvider); }, ), - - const SizedBox(height: 16), - - // Send to device button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: bleProvider.isConnected - ? () => _sendButtonConfig( - context, bleProvider, settingsProvider) - : null, - icon: const Icon(Icons.send), - label: Text(AppLocalizations.of(context)!.sendToDevice), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.info, - foregroundColor: AppColors.primaryBackground, - disabledBackgroundColor: - AppColors.info.withValues(alpha: 0.3), - disabledForegroundColor: AppColors.disabledText, - ), - ), - ), - if (!bleProvider.isConnected) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - AppLocalizations.of(context)!.connectToDeviceToApply, - style: const TextStyle( - color: AppColors.secondaryText, fontSize: 11), - ), - ), ], ), ); @@ -2261,7 +2445,7 @@ class _SettingsScreenState extends State { child: Text( replayPath == null || replayPath.isEmpty ? 'No .sub file selected' - : replayPath, + : replayPath.split('/').last, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppColors.secondaryText, fontSize: 11), @@ -3150,34 +3334,126 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { bool _hasError = false; String _errorMessage = ''; + // Pause / Resume state + bool _isPaused = false; + bool _pauseRequested = false; + bool _checkingResume = true; + bool _hasResumableSession = false; + Set _completedPaths = {}; + @override void initState() { super.initState(); - _startCloning(); + _checkForResumableSession(); } - Future _startCloning() async { + /// On open, check if a previous session can be resumed. + Future _checkForResumableSession() async { + final hasResume = await FlipperSubDbService.hasResumableSession(); + if (!mounted) return; + if (hasResume) { + final completed = await FlipperSubDbService.loadCompletedFiles(); + setState(() { + _checkingResume = false; + _hasResumableSession = true; + _completedPaths = completed; + _statusText = + 'Previous session found (${completed.length} files already uploaded). Resume or start fresh?'; + }); + } else { + setState(() { + _checkingResume = false; + _hasResumableSession = false; + }); + _startCloning(resume: false); + } + } + + /// Pause the current upload. Finishes the file in progress, then stops. + void _pauseCloning() { + setState(() { + _pauseRequested = true; + _statusText = 'Pausing after current file...'; + }); + } + + /// Resume a paused or previously-saved session. + void _resumeCloning() { + setState(() { + _isPaused = false; + _pauseRequested = false; + _hasResumableSession = false; + }); + _startCloning(resume: true); + } + + Future _startCloning({required bool resume}) async { // Keep screen awake during the entire cloning process WakelockPlus.enable(); try { - // Phase 1 & 2: Download and extract - setState(() { - _phase = 'download'; - _statusText = 'Downloading SubGHz database from GitHub...'; - _progress = 0.0; - }); + List subFiles; - final subFiles = await FlipperSubDbService.downloadAndExtract( - onProgress: (phase, detail, fraction) { - if (mounted) { - setState(() { - _phase = phase; - _statusText = detail; - _progress = fraction; - }); - } - }, - ); + if (resume) { + // ── Resume path: re-extract from cached ZIP ── + setState(() { + _phase = 'extract'; + _statusText = 'Loading cached database...'; + _progress = 0.0; + }); + + final cachedZip = await FlipperSubDbService.loadCachedZip(); + if (cachedZip == null) { + setState(() { + _isDone = true; + _hasError = true; + _errorMessage = 'Cached ZIP not found. Please start a fresh clone.'; + }); + WakelockPlus.disable(); + return; + } + + // Load previously completed files + _completedPaths = await FlipperSubDbService.loadCompletedFiles(); + + subFiles = FlipperSubDbService.extractFromBytes( + cachedZip, + onProgress: (phase, detail, fraction) { + if (mounted) { + setState(() { + _phase = phase; + _statusText = detail; + _progress = fraction; + }); + } + }, + ); + } else { + // ── Fresh start: download and extract ── + _completedPaths = {}; + await FlipperSubDbService.clearCache(); + + setState(() { + _phase = 'download'; + _statusText = 'Downloading SubGHz database from GitHub...'; + _progress = 0.0; + }); + + subFiles = await FlipperSubDbService.downloadAndExtract( + onProgress: (phase, detail, fraction) { + if (mounted) { + setState(() { + _phase = phase; + _statusText = detail; + _progress = fraction; + }); + } + }, + onZipDownloaded: (zipBytes) async { + // Cache the raw ZIP for potential resume + await FlipperSubDbService.cacheZipBytes(zipBytes); + }, + ); + } if (subFiles.isEmpty) { setState(() { @@ -3185,16 +3461,18 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { _hasError = true; _errorMessage = 'No .sub files found in the repository'; }); + WakelockPlus.disable(); return; } _totalFiles = subFiles.length; + _uploadedFiles = _completedPaths.length; // Phase 3: Create base directory on SDCard setState(() { _phase = 'upload'; _statusText = 'Creating "SUB Files" folder on SDCard...'; - _progress = 0.0; + _progress = _completedPaths.length / _totalFiles; }); await widget.bleProvider.createDirectory( @@ -3208,7 +3486,8 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { if (parts.length > 1) { // Build cumulative subdir paths for (int i = 1; i < parts.length; i++) { - final subdir = '${FlipperSubDbService.sdTargetFolder}/${parts.sublist(0, i).join('/')}'; + final subdir = + '${FlipperSubDbService.sdTargetFolder}/${parts.sublist(0, i).join('/')}'; subdirs.add(subdir); } } @@ -3217,6 +3496,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { // Create subdirectories (sorted so parents come first) final sortedDirs = subdirs.toList()..sort(); for (final dir in sortedDirs) { + if (_pauseRequested) break; setState(() { _statusText = 'Creating folder: $dir'; }); @@ -3230,15 +3510,36 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { // Phase 4: Upload files one at a time for (int i = 0; i < subFiles.length; i++) { + // ── Check for pause after each file ── + if (_pauseRequested) { + await FlipperSubDbService.saveProgress(_completedPaths); + if (mounted) { + setState(() { + _isPaused = true; + _pauseRequested = false; + _statusText = + 'Paused – $_uploadedFiles / $_totalFiles files uploaded. You can close and resume later.'; + }); + } + WakelockPlus.disable(); + return; + } + final file = subFiles[i]; + + // Skip already-uploaded files (from a previous session) + if (_completedPaths.contains(file.relativePath)) { + continue; + } + final targetPath = '${FlipperSubDbService.sdTargetFolder}/${file.relativePath}'; if (mounted) { setState(() { - _uploadedFiles = i; - _statusText = 'Uploading (${i + 1}/$_totalFiles): ${file.relativePath}'; - _progress = i / _totalFiles; + _statusText = + 'Uploading (${_uploadedFiles + 1}/$_totalFiles): ${file.relativePath}'; + _progress = _uploadedFiles / _totalFiles; }); } @@ -3248,6 +3549,14 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { targetPath, pathType: 5, ); + _completedPaths.add(file.relativePath); + _uploadedFiles = _completedPaths.length; + + // Persist progress every 10 files for safety + if (_uploadedFiles % 10 == 0) { + await FlipperSubDbService.saveProgress(_completedPaths); + } + // Pace uploads to avoid BLE congestion await Future.delayed(const Duration(milliseconds: 150)); } catch (e) { @@ -3257,6 +3566,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { } } + // All files processed – clean up cache + await FlipperSubDbService.clearCache(); + if (mounted) { setState(() { _isDone = true; @@ -3270,6 +3582,10 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { // Release wakelock after successful completion WakelockPlus.disable(); } catch (e) { + // On error, save progress so user can resume later + if (_completedPaths.isNotEmpty) { + await FlipperSubDbService.saveProgress(_completedPaths); + } // Release wakelock on error WakelockPlus.disable(); if (mounted) { @@ -3285,23 +3601,87 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { @override Widget build(BuildContext context) { + // While checking for resumable session, show a spinner + if (_checkingResume) { + return AlertDialog( + title: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + const Text('Clone SubGHz DB', style: TextStyle(fontSize: 16)), + ], + ), + content: const Text('Checking for previous session...'), + ); + } + + // If a resumable session was found, show Resume / Start Fresh options + if (_hasResumableSession && !_isPaused && _phase == 'init') { + return AlertDialog( + title: Row( + children: [ + Icon(Icons.replay, color: Colors.orange), + const SizedBox(width: 8), + const Expanded( + child: Text('Resume Clone?', style: TextStyle(fontSize: 16)), + ), + ], + ), + content: Text( + '${_completedPaths.length} files were uploaded in a previous session.\n' + 'Resume where you left off, or start fresh?', + style: TextStyle(color: AppColors.primaryText, fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + OutlinedButton( + onPressed: () { + setState(() { + _hasResumableSession = false; + _completedPaths = {}; + }); + _startCloning(resume: false); + }, + child: const Text('Start Fresh'), + ), + ElevatedButton( + onPressed: _resumeCloning, + child: const Text('Resume'), + ), + ], + ); + } + return AlertDialog( title: Row( children: [ Icon( _isDone ? (_hasError ? Icons.error_outline : Icons.check_circle) - : Icons.cloud_download, + : _isPaused + ? Icons.pause_circle + : Icons.cloud_download, color: _isDone ? (_hasError ? AppColors.error : AppColors.success) - : Colors.orange, + : _isPaused + ? Colors.orange + : Colors.orange, ), const SizedBox(width: 8), Expanded( child: Text( _isDone ? (_hasError ? 'Clone Failed' : 'Clone Complete') - : 'Cloning SubGHz Database', + : _isPaused + ? 'Clone Paused' + : 'Cloning SubGHz Database', style: const TextStyle(fontSize: 16), ), ), @@ -3312,7 +3692,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Phase indicator - if (!_isDone) ...[ + if (!_isDone && !_isPaused) ...[ Row( children: [ SizedBox( @@ -3364,7 +3744,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { ? AppColors.error : _isDone ? AppColors.success - : Colors.orange, + : _isPaused + ? Colors.orange.shade300 + : Colors.orange, ), minHeight: 8, ), @@ -3372,7 +3754,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { const SizedBox(height: 8), // File counter (during upload phase) - if (_phase == 'upload' || _isDone) + if (_phase == 'upload' || _isDone || _isPaused) Text( '$_uploadedFiles / $_totalFiles files' '${_failedFiles > 0 ? ' ($_failedFiles failed)' : ''}', @@ -3402,10 +3784,41 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { ], ), actions: [ - if (_isDone) - ElevatedButton( + // Close button when done, paused, or errored + if (_isDone || _isPaused) + TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text('Close'), + child: const Text('Close'), + ), + + // Resume button when paused + if (_isPaused) + ElevatedButton.icon( + onPressed: _resumeCloning, + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('Resume'), + ), + + // Pause button during upload (only while actively uploading) + if (!_isDone && !_isPaused && _phase == 'upload' && !_pauseRequested) + ElevatedButton.icon( + onPressed: _pauseCloning, + icon: const Icon(Icons.pause, size: 18), + label: const Text('Pause'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + ), + + // Show "Pausing..." indicator when pause is requested + if (_pauseRequested && !_isPaused) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), ], ); diff --git a/mobile_app/lib/screens/signal_scanner_screen.dart b/mobile_app/lib/screens/signal_scanner_screen.dart index 18b7e49..017e82a 100644 --- a/mobile_app/lib/screens/signal_scanner_screen.dart +++ b/mobile_app/lib/screens/signal_scanner_screen.dart @@ -8,7 +8,6 @@ import '../providers/notification_provider.dart'; import '../providers/firmware_protocol.dart'; import '../models/detected_signal.dart'; import '../theme/app_colors.dart'; - class SignalScannerScreen extends StatefulWidget { const SignalScannerScreen({super.key}); @@ -19,8 +18,8 @@ class SignalScannerScreen extends StatefulWidget { class _SignalScannerScreenState extends State with TickerProviderStateMixin { - // Scanning modes - bool _isListMode = true; + // View modes: 0 = Signal List, 1 = Spectrogram (default) + int _viewMode = 1; bool _isScanning = false; int _selectedModule = 0; // 0 or 1 (displayed as 1 or 2) @@ -53,7 +52,7 @@ class _SignalScannerScreenState extends State duration: const Duration(milliseconds: 800), vsync: this, )..addListener(() { - if (!_isListMode) setState(() {}); // Rebuild spectrogram on tick + if (_viewMode != 0) setState(() {}); // Rebuild spectrogram on tick }); } @@ -75,7 +74,7 @@ class _SignalScannerScreenState extends State final bleProvider = Provider.of(context, listen: false); setState(() => _isScanning = true); - if (!_isListMode) _spectrumAnimationController.repeat(); + if (_viewMode != 0) _spectrumAnimationController.repeat(); // Start decay timer for dynamic spectrogram _startDecayTimer(); @@ -261,7 +260,7 @@ class _SignalScannerScreenState extends State _buildControlPanel(), _buildModeSwitch(), Expanded( - child: _isListMode + child: _viewMode == 0 ? _buildListView(bleProvider) : _buildSpectrumView(bleProvider), ), @@ -313,7 +312,7 @@ class _SignalScannerScreenState extends State ), const SizedBox(height: 8), // RSSI threshold - if (_isListMode) + if (_viewMode == 0) Row( children: [ const Text('RSSI:', style: TextStyle( @@ -374,29 +373,28 @@ class _SignalScannerScreenState extends State ), child: Row( children: [ - _modeSwitchTab(AppLocalizations.of(context)!.signalList, isSelected: _isListMode, onTap: () { - setState(() => _isListMode = true); - }), - _modeSwitchTab(AppLocalizations.of(context)!.spectrogramView, isSelected: !_isListMode, onTap: () { - setState(() => _isListMode = false); - }), + _modeSwitchTab(AppLocalizations.of(context)!.signalList, + isSelected: _viewMode == 0, onTap: () => _setViewMode(0)), + _modeSwitchTab(AppLocalizations.of(context)!.spectrogramView, + isSelected: _viewMode == 1, onTap: () => _setViewMode(1)), ], ), ); } + void _setViewMode(int mode) { + setState(() => _viewMode = mode); + if (_viewMode != 0 && _isScanning) { + _spectrumAnimationController.repeat(); + } else { + _spectrumAnimationController.stop(); + } + } + Widget _modeSwitchTab(String label, {required bool isSelected, required VoidCallback onTap}) { return Expanded( child: GestureDetector( - onTap: () { - onTap(); - // Start/stop animation controller when switching to/from spectrogram - if (!_isListMode && _isScanning) { - _spectrumAnimationController.repeat(); - } else { - _spectrumAnimationController.stop(); - } - }, + onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( @@ -409,7 +407,7 @@ class _SignalScannerScreenState extends State style: TextStyle( color: isSelected ? Colors.black : AppColors.secondaryText, fontWeight: FontWeight.bold, - fontSize: 13, + fontSize: 12, ), ), ), diff --git a/mobile_app/lib/services/binary_message_parser.dart b/mobile_app/lib/services/binary_message_parser.dart index d05d334..8cb479f 100644 --- a/mobile_app/lib/services/binary_message_parser.dart +++ b/mobile_app/lib/services/binary_message_parser.dart @@ -430,16 +430,21 @@ class BinaryFileActionResult { case 5: return 'move'; case 6: return 'tree'; case 7: return 'load'; + case 8: return 'format-sd'; default: return 'unknown'; } } Map toJson() { + // errorCode 0xFF with success=true means "in progress" (format SD step) + bool isProgress = (success && errorCode == 0xFF); return { 'action': getActionString(), 'success': success, 'path': path, 'error': success ? null : _getErrorMessage(errorCode), + 'isProgress': isProgress, + 'progressMessage': isProgress ? path : null, }; } diff --git a/mobile_app/lib/services/flipper_subdb_service.dart b/mobile_app/lib/services/flipper_subdb_service.dart index 606318f..2932474 100644 --- a/mobile_app/lib/services/flipper_subdb_service.dart +++ b/mobile_app/lib/services/flipper_subdb_service.dart @@ -33,6 +33,7 @@ class FlipperSubDbService { /// - phase "extract": extracting .sub files from ZIP static Future> downloadAndExtract({ void Function(String phase, String detail, double fraction)? onProgress, + Future Function(List zipBytes)? onZipDownloaded, }) async { // --- Phase 1: Download ZIP --- onProgress?.call('download', 'Connecting to GitHub...', 0.0); @@ -65,6 +66,9 @@ class FlipperSubDbService { onProgress?.call('download', 'Download complete', 1.0); + // Allow caller to cache the raw ZIP for later resume + await onZipDownloaded?.call(zipBytes); + // --- Phase 2: Extract .sub files --- onProgress?.call('extract', 'Decompressing ZIP...', 0.0); @@ -72,8 +76,12 @@ class FlipperSubDbService { final subFiles = []; // The ZIP contains a root folder like "FlipperZero-Subghz-DB-main/" - // We strip that prefix to get clean relative paths. + // followed by a "subghz/" subfolder. We strip both prefixes so that + // relative paths start directly at the category level (e.g., + // "Adjustable_Beds/RIZE .../file.sub") and map onto "SUB Files/..." on SD. String? rootPrefix; + // Second-level prefix to strip (e.g. "subghz/") + const String subghzFolder = 'subghz/'; int processed = 0; final total = archive.files.length; @@ -92,6 +100,11 @@ class FlipperSubDbService { if (rootPrefix != null && relativePath.startsWith(rootPrefix)) { relativePath = relativePath.substring(rootPrefix.length); } + // Strip the "subghz/" second-level folder so paths go directly + // inside "SUB Files/" on the SD card. + if (relativePath.startsWith(subghzFolder)) { + relativePath = relativePath.substring(subghzFolder.length); + } // Skip empty paths if (relativePath.isNotEmpty) { subFiles.add(SubFileEntry( @@ -129,4 +142,119 @@ class FlipperSubDbService { } return null; } + + // --------------------------------------------------------------------------- + // Progress persistence for Pause / Resume + // --------------------------------------------------------------------------- + + static const String _progressFileName = 'clone_progress.json'; + static const String _cachedZipFileName = 'clone_cached.zip'; + + /// Return the app-data directory used for clone cache files. + static Future _cacheDir() async { + final appDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${appDir.path}/clone_cache'); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; + } + + /// Check whether a resumable clone session exists. + static Future hasResumableSession() async { + final dir = await _cacheDir(); + final progressFile = File('${dir.path}/$_progressFileName'); + final zipFile = File('${dir.path}/$_cachedZipFileName'); + return await progressFile.exists() && await zipFile.exists(); + } + + /// Load the set of already-uploaded relative paths from the progress file. + static Future> loadCompletedFiles() async { + final dir = await _cacheDir(); + final file = File('${dir.path}/$_progressFileName'); + if (!await file.exists()) return {}; + try { + final json = jsonDecode(await file.readAsString()); + return Set.from(json['completed'] as List); + } catch (_) { + return {}; + } + } + + /// Save the set of completed file paths (call after each successful upload). + static Future saveProgress(Set completedPaths) async { + final dir = await _cacheDir(); + final file = File('${dir.path}/$_progressFileName'); + await file.writeAsString(jsonEncode({'completed': completedPaths.toList()})); + } + + /// Cache the raw ZIP bytes so we don't have to re-download on resume. + static Future cacheZipBytes(List zipBytes) async { + final dir = await _cacheDir(); + final file = File('${dir.path}/$_cachedZipFileName'); + await file.writeAsBytes(zipBytes); + } + + /// Load cached ZIP bytes for resume. + static Future?> loadCachedZip() async { + final dir = await _cacheDir(); + final file = File('${dir.path}/$_cachedZipFileName'); + if (!await file.exists()) return null; + return await file.readAsBytes(); + } + + /// Delete all clone cache files (call on completion or manual reset). + static Future clearCache() async { + final dir = await _cacheDir(); + if (await dir.exists()) await dir.delete(recursive: true); + } + + /// Extract .sub files from already-downloaded ZIP bytes. + /// Same logic as [downloadAndExtract] phase 2, but without downloading. + static List extractFromBytes( + List zipBytes, { + void Function(String phase, String detail, double fraction)? onProgress, + }) { + onProgress?.call('extract', 'Decompressing cached ZIP...', 0.0); + + final archive = ZipDecoder().decodeBytes(zipBytes); + final subFiles = []; + String? rootPrefix; + const String subghzFolder = 'subghz/'; + + int processed = 0; + final total = archive.files.length; + + for (final file in archive.files) { + processed++; + if (file.isFile) { + final name = file.name; + rootPrefix ??= _extractRootPrefix(name); + + if (name.toLowerCase().endsWith('.sub')) { + String relativePath = name; + if (rootPrefix != null && relativePath.startsWith(rootPrefix)) { + relativePath = relativePath.substring(rootPrefix.length); + } + if (relativePath.startsWith(subghzFolder)) { + relativePath = relativePath.substring(subghzFolder.length); + } + if (relativePath.isNotEmpty) { + subFiles.add(SubFileEntry( + relativePath: relativePath, + content: Uint8List.fromList(file.content as List), + )); + } + } + } + if (total > 0) { + onProgress?.call( + 'extract', + 'Extracting files... (${subFiles.length} .sub files found)', + processed / total, + ); + } + } + + onProgress?.call('extract', '${subFiles.length} .sub files extracted', 1.0); + return subFiles; + } } diff --git a/mobile_app/lib/services/update_service.dart b/mobile_app/lib/services/update_service.dart index 008481f..3d0204e 100644 --- a/mobile_app/lib/services/update_service.dart +++ b/mobile_app/lib/services/update_service.dart @@ -87,8 +87,8 @@ class UpdateService { String currentVersion) async { try { final releases = await _fetchReleases(); - // Fetch structured changelog in parallel - final changelogData = await fetchChangelog(); + // Fetch structured changelog from release assets + final changelogData = await fetchChangelog(releases); String? bestVersion; Map? bestRelease; @@ -154,8 +154,8 @@ class UpdateService { static Future checkAppUpdate(String currentVersion) async { try { final releases = await _fetchReleases(); - // Fetch structured changelog in parallel - final changelogData = await fetchChangelog(); + // Fetch structured changelog from release assets + final changelogData = await fetchChangelog(releases); String? bestVersion; Map? bestRelease; @@ -329,25 +329,52 @@ class UpdateService { return await getTemporaryDirectory(); } - /// URL to the structured changelog.json in the repository. - static const String _changelogUrl = - 'https://raw.githubusercontent.com/$githubOwner/$githubRepo/main/releases/changelog.json'; - - /// Fetch the structured changelog from changelog.json. + /// Fetch the structured changelog from changelog.json in release assets. + /// Searches through releases to find changelog.json as an asset. /// Returns a map with "firmware" and "app" lists parsed from the JSON. /// Returns null on failure (non-fatal — falls back to release body). - static Future?> fetchChangelog() async { + static Future?> fetchChangelog(List releases) async { + // Try to find changelog.json in release assets (newest first) + for (final release in releases) { + if (release['draft'] == true) continue; + final assets = release['assets'] as List? ?? []; + + // Look for changelog.json asset + for (final asset in assets) { + final name = asset['name'] as String? ?? ''; + if (name.toLowerCase() == 'changelog.json') { + final downloadUrl = asset['browser_download_url'] as String?; + if (downloadUrl == null) continue; + + try { + final response = await http.get( + Uri.parse(downloadUrl), + ).timeout(_timeout); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + } catch (e) { + // Continue searching in other releases + continue; + } + } + } + } + + // Fallback: try legacy URL from main branch (for backward compatibility) try { + const legacyUrl = 'https://raw.githubusercontent.com/$githubOwner/$githubRepo/main/releases/changelog.json'; final response = await http.get( - Uri.parse(_changelogUrl), + Uri.parse(legacyUrl), ).timeout(_timeout); if (response.statusCode == 200) { return jsonDecode(response.body) as Map; } - return null; } catch (_) { - return null; + // Ignore fallback errors } + + return null; } /// Extract structured changes list for a specific version from changelog.json. diff --git a/mobile_app/lib/widgets/quick_connect_widget.dart b/mobile_app/lib/widgets/quick_connect_widget.dart index 6fe8c02..30bdd51 100644 --- a/mobile_app/lib/widgets/quick_connect_widget.dart +++ b/mobile_app/lib/widgets/quick_connect_widget.dart @@ -169,18 +169,26 @@ class QuickConnectWidget extends StatelessWidget { } else { // No saved devices - show scan button or scan results List supportedDevices = bleProvider.supportedScanResults; - if (supportedDevices.isNotEmpty) { - // Show found supported devices + + // Fallback: if no supported devices found but scan returned results, + // show ALL nearby BLE devices so user can manually select (e.g. renamed device) + final bool isFallback = supportedDevices.isEmpty && bleProvider.scanResults.isNotEmpty; + final List devicesToShow = isFallback ? bleProvider.scanResults : supportedDevices; + + if (devicesToShow.isNotEmpty) { + // Show found devices return Column( children: [ Text( - AppLocalizations.of(context)!.foundSupportedDevicesCount(supportedDevices.length), + isFallback + ? AppLocalizations.of(context)!.noSupportedDevicesShowAll + : AppLocalizations.of(context)!.foundSupportedDevicesCount(devicesToShow.length), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.secondaryText, + color: isFallback ? AppColors.warning : AppColors.secondaryText, ), ), const SizedBox(height: 8), - ...supportedDevices.map((result) => Container( + ...devicesToShow.map((result) => Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), @@ -190,8 +198,8 @@ class QuickConnectWidget extends StatelessWidget { ), child: Row( children: [ - const Icon( - Icons.bluetooth, + Icon( + isFallback ? Icons.bluetooth_searching : Icons.bluetooth, color: AppColors.info, size: 20, ), diff --git a/mobile_app/lib/widgets/status_bar_widget.dart b/mobile_app/lib/widgets/status_bar_widget.dart index 725a78d..1b3ee93 100644 --- a/mobile_app/lib/widgets/status_bar_widget.dart +++ b/mobile_app/lib/widgets/status_bar_widget.dart @@ -96,7 +96,7 @@ class _StatusBarWidgetState extends State { right: 0, child: Container( constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.6, + maxHeight: MediaQuery.of(context).size.height * 0.85, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, diff --git a/mobile_app/pubspec.yaml b/mobile_app/pubspec.yaml index 5768e0c..2a05baf 100644 --- a/mobile_app/pubspec.yaml +++ b/mobile_app/pubspec.yaml @@ -1,7 +1,7 @@ name: evilcrow_rf2_controller description: EvilCrow RF — Mobile app for controlling RF devices via BLE publish_to: 'none' -version: 1.0.2+15 +version: 1.1.0+25 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/platformio.ini b/platformio.ini index 0f07577..daa6fd3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,12 +1,4 @@ -; PlatformIO Project Configuration File -; -; Build options: build flags, source filter -; Upload options: custom upload port, speed and extra flags -; Library options: dependencies, extra library storages -; Advanced options: extra scripting -; -; Please visit documentation for the other options and examples -; https://docs.platformio.org/page/projectconf.html + [env:esp32dev] platform = espressif32 diff --git a/src/core/ble/BleAdapter.cpp b/src/core/ble/BleAdapter.cpp index d2f42c0..8144379 100644 --- a/src/core/ble/BleAdapter.cpp +++ b/src/core/ble/BleAdapter.cpp @@ -633,14 +633,15 @@ bool BleAdapter::handleUploadChunk(uint8_t chunkId, uint8_t chunkNum, uint8_t to } if (pathLength > 0) { - if (pathType != 4) strcat(fullPath, "/"); + // pathType 4 (LittleFS root) and 5 (SD root) already end with '/' + if (pathType != 4 && pathType != 5) strcat(fullPath, "/"); if (path[0] == '/') { strncat(fullPath, path + 1, pathLength - 1); } else { strncat(fullPath, path, pathLength); } } else { - if (pathType != 4) strcat(fullPath, "/"); + if (pathType != 4 && pathType != 5) strcat(fullPath, "/"); } ESP_LOGI(TAG, "Starting file upload: %s (pathType=%d)", fullPath, pathType); diff --git a/src/main.cpp b/src/main.cpp index d52f0ea..7ef445f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -174,6 +174,7 @@ void cc1101WorkerSignalDetectedHandler(const CC1101DetectedSignal& signal) struct DeviceConfig { bool powerBlink; + bool sdCardMounted; // True if SD card is available } deviceConfig; // REMOVED - old state machine task (all code deleted, now using CC1101Worker) @@ -621,13 +622,31 @@ void setup() ESP_LOGD(TAG, "Starting setup..."); + // --- SD Card initialization (non-blocking) --- sdspi.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_SS); if (!SD.begin(SD_SS, sdspi)) { - ESP_LOGE(TAG, "Card Mount Failed"); - return; - } + ESP_LOGW(TAG, "SD card not mounted — running in LittleFS-only mode"); + deviceConfig.sdCardMounted = false; + // Continue setup without SD — features requiring SD will be limited + } else { + deviceConfig.sdCardMounted = true; + ESP_LOGI(TAG, "SD card initialized."); - ESP_LOGD(TAG, "SD card initialized."); + // Ensure default directory structure exists on SD + static const char* defaultDirs[] = { + "/DATA", + "/DATA/RECORDS", + "/DATA/SIGNALS", + "/DATA/PRESETS", + "/DATA/TEMP" + }; + for (int i = 0; i < 5; i++) { + if (!SD.exists(defaultDirs[i])) { + SD.mkdir(defaultDirs[i]); + ESP_LOGI(TAG, "Created missing directory: %s", defaultDirs[i]); + } + } + } ControllerAdapter::initializeQueue(); @@ -716,7 +735,7 @@ void setup() #endif // Notification sender on Core 0 (near BLE stack for lower latency) - xTaskCreatePinnedToCore(ClientsManager::processMessageQueue, "SendNotifications", 2560, NULL, 1, NULL, 0); // 2.5KB on Core 0 + xTaskCreatePinnedToCore(ClientsManager::processMessageQueue, "SendNotifications", 4096, NULL, 1, NULL, 0); // 4KB on Core 0 ESP_LOGD(TAG, "SendNotifications task created."); // Create time synchronization task (updates deviceTime every second) diff --git a/src/modules/CC1101_driver/CC1101_Worker.cpp b/src/modules/CC1101_driver/CC1101_Worker.cpp index b71f34c..2711814 100644 --- a/src/modules/CC1101_driver/CC1101_Worker.cpp +++ b/src/modules/CC1101_driver/CC1101_Worker.cpp @@ -1,5 +1,6 @@ #include "CC1101_Worker.h" #include // Moved here from CC1101_Worker.h — only used in this .cpp +#include // For pathType 4 (internal flash) transmit support #include "FlipperSubFile.h" #include "modules/subghz_function/StreamingSubFileParser.h" #include "StreamingPulsePayload.h" @@ -904,29 +905,44 @@ bool CC1101Worker::transmitRaw(int module, float frequency, int modulation, floa std::string CC1101Worker::transmitSub(const std::string& filename, int module, int repeat, int pathType) { std::string fullPath; + // Determine filesystem: pathType 4 = LittleFS, all others = SD + bool useLittleFS = (pathType == 4); + fs::FS& fs = useLittleFS ? (fs::FS&)LittleFS : (fs::FS&)SD; + // If path is already absolute (/DATA/...), use it directly if (filename.find("/DATA/") == 0) { fullPath = filename; ESP_LOGD(TAG, "Using full system path: %s", fullPath.c_str()); } else { // Use pathType to determine subdirectory + // 0=RECORDS, 1=SIGNALS, 2=PRESETS, 3=TEMP, 4=INTERNAL(LittleFS root), 5=SD root static const char* DIRS[] = {"/DATA/RECORDS", "/DATA/SIGNALS", "/DATA/PRESETS", "/DATA/TEMP"}; if (pathType >= 0 && pathType < 4) { fullPath = std::string(DIRS[pathType]) + "/" + filename; ESP_LOGD(TAG, "Using pathType %d: %s", pathType, DIRS[pathType]); + } else if (pathType == 4 || pathType == 5) { + // Root-based storage: LittleFS root (4) or SD root (5) + // filename is relative to root, ensure it starts with "/" + if (!filename.empty() && filename[0] == '/') { + fullPath = filename; + } else { + fullPath = "/" + filename; + } + ESP_LOGI(TAG, "Using pathType %d (%s root): %s", + pathType, useLittleFS ? "LittleFS" : "SD", fullPath.c_str()); } else { fullPath = std::string("/DATA/RECORDS/") + filename; ESP_LOGW(TAG, "Unknown pathType %d; default RECORDS", pathType); } ESP_LOGD(TAG, "Added base path, full path: %s", fullPath.c_str()); } - ESP_LOGI(TAG, "Opening file: %s", fullPath.c_str()); - if (!SD.exists(fullPath.c_str())) { + ESP_LOGI(TAG, "Opening file: %s (fs=%s)", fullPath.c_str(), useLittleFS ? "LittleFS" : "SD"); + if (!fs.exists(fullPath.c_str())) { std::string msg = "File does not exist: " + fullPath; ESP_LOGE(TAG, "%s", msg.c_str()); return msg; } - File file = SD.open(fullPath.c_str(), FILE_READ); + File file = fs.open(fullPath.c_str(), FILE_READ); if (!file) { std::string msg = "Failed to open file: " + fullPath; ESP_LOGE(TAG, "%s", msg.c_str()); @@ -934,6 +950,13 @@ std::string CC1101Worker::transmitSub(const std::string& filename, int module, i } ESP_LOGD(TAG, "File opened successfully, size: %d bytes", file.size()); file.close(); // Close immediately - will reopen for streaming + + // Transmission from LittleFS is not supported yet (parsers use SD internally) + if (useLittleFS) { + std::string msg = "Transmission from internal flash (LittleFS) not supported"; + ESP_LOGE(TAG, "%s", msg.c_str()); + return msg; + } // OPTIMIZED: Use StreamingSubFileParser (minimal RAM usage!) StreamingSubFileParser streamParser;