From 03a0bcc66dac89d07c9d5b469f3c481ae21a42ac Mon Sep 17 00:00:00 2001 From: Senape3000 <80119773+Senape3000@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:30:11 +0100 Subject: [PATCH 1/5] Add SD format, custom DeBruijn & waterfall UI Implements SD card formatting and per-protocol De Bruijn support and adds a realtime waterfall display. Firmware: new MSG_FORMAT_SD (0x18) and handler to recursively delete/recreate SD directories; added removeDirectoryRecursive helper and integrated into remove handling. App: firmware protocol factories for format and custom DeBruijn (sub-cmd 0xFD), BleProvider methods to send format and custom DeBruijn commands, and BruteScreen/BruterProtocol extended with te/ratio fields and logic to send per-protocol De Bruijn parameters. UI: Settings screen gets a guarded "Format SD Card" control with confirmation dialog; Record screen integrates a new WaterfallWidget and syncs detected signals; new widget mobile_app/lib/widgets/waterfall_widget.dart implements the spectrogram painter. Other fixes: avoid double-slash when composing upload paths for LittleFS/SD in BleAdapter, and increase SendNotifications task stack to 4KB in main.cpp. --- include/FileCommands.h | 105 +++++++- mobile_app/lib/providers/ble_provider.dart | 43 ++++ .../lib/providers/firmware_protocol.dart | 34 +++ mobile_app/lib/screens/brute_screen.dart | 122 +++++----- mobile_app/lib/screens/record_screen.dart | 84 +++++++ mobile_app/lib/screens/settings_screen.dart | 106 ++++++++ mobile_app/lib/widgets/waterfall_widget.dart | 227 ++++++++++++++++++ src/core/ble/BleAdapter.cpp | 5 +- src/main.cpp | 2 +- 9 files changed, 670 insertions(+), 58 deletions(-) create mode 100644 mobile_app/lib/widgets/waterfall_widget.dart diff --git a/include/FileCommands.h b/include/FileCommands.h index 8109bc5..989f1a8 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,49 @@ 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; + } + + File child = dir.openNextFile(); + while (child) { + const char* childPath = child.path(); + bool isDir = child.isDirectory(); + child.close(); + + if (isDir) { + if (!removeDirectoryRecursive(fs, childPath)) { + dir.close(); + return false; + } + } else { + if (!fs.remove(childPath)) { + ESP_LOGE("FileCmd", "Failed to remove file: %s", childPath); + dir.close(); + return false; + } + } + child = dir.openNextFile(); + } + dir.close(); + + // Directory should now be empty — remove it + return fs.rmdir(path); + } + 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 +822,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 +830,65 @@ 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. + * + * 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"); + + // 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; + File child = root.openNextFile(); + while (child) { + const char* childPath = child.path(); + bool isDir = child.isDirectory(); + child.close(); + + if (isDir) { + if (!removeDirectoryRecursive(SD, childPath)) { + ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPath); + allOk = false; + } + } else { + if (!SD.remove(childPath)) { + ESP_LOGE("FileCmd", "Failed to remove file: %s", childPath); + allOk = false; + } + } + child = root.openNextFile(); + } + root.close(); + + // Re-create default directory structure + SD.mkdir("/DATA"); + SD.mkdir("/DATA/RECORDS"); + SD.mkdir("/DATA/SIGNALS"); + SD.mkdir("/DATA/PRESETS"); + SD.mkdir("/DATA/TEMP"); + + ESP_LOGI("FileCmd", "SD card format %s", allOk ? "complete" : "completed 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/mobile_app/lib/providers/ble_provider.dart b/mobile_app/lib/providers/ble_provider.dart index ebb9c9c..a1b2a46 100644 --- a/mobile_app/lib/providers/ble_provider.dart +++ b/mobile_app/lib/providers/ble_provider.dart @@ -3233,6 +3233,20 @@ 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(); + await sendBinaryCommand(cmd); + _log('warning', 'Format SD command sent'); + return true; + } catch (e) { + _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 +3269,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/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/record_screen.dart b/mobile_app/lib/screens/record_screen.dart index b67c0db..512d2af 100644 --- a/mobile_app/lib/screens/record_screen.dart +++ b/mobile_app/lib/screens/record_screen.dart @@ -9,6 +9,7 @@ import '../services/cc1101/cc1101_calculator.dart'; import '../widgets/record_screen_widgets.dart'; import '../widgets/file_list_widget.dart'; import '../widgets/transmit_file_dialog.dart'; +import '../widgets/waterfall_widget.dart'; import '../theme/app_colors.dart'; import 'file_viewer_screen.dart'; @@ -48,6 +49,10 @@ class _RecordScreenState extends State with TickerProviderStateMix // Files from current recording session final List _currentSessionFiles = []; + + // Waterfall signal activity data (per-module) + final List _waterfallEntries = []; + int _lastDetectedSignalCount = 0; // Flags for tracking changes final List _configsChanged = []; @@ -95,6 +100,9 @@ class _RecordScreenState extends State with TickerProviderStateMix print('_onRecordedFilesChanged called'); final runtimeFiles = _bleProvider?.recordedRuntimeFiles ?? []; print('Runtime files: $runtimeFiles'); + + // Feed new detected signals into the waterfall + _syncWaterfallFromProvider(); // Add new files to local recorded files list for (final file in runtimeFiles) { @@ -161,9 +169,42 @@ class _RecordScreenState extends State with TickerProviderStateMix setState(() { _currentSessionFiles.clear(); _recordedFiles.clear(); + _waterfallEntries.clear(); + _lastDetectedSignalCount = 0; }); } + /// Synchronise waterfall entries from the BLE provider's detected signals. + void _syncWaterfallFromProvider() { + final signals = _bleProvider?.detectedSignals ?? []; + if (signals.length == _lastDetectedSignalCount) return; + + // The list is sorted newest-first. Grab only new entries. + final newCount = signals.length - _lastDetectedSignalCount; + if (newCount <= 0) { + _lastDetectedSignalCount = signals.length; + return; + } + + for (int i = newCount - 1; i >= 0; i--) { + final s = signals[i]; + _waterfallEntries.add(WaterfallEntry( + timestamp: s.timestamp, + frequencyMhz: double.tryParse(s.frequency) ?? 0, + rssi: s.rssi, + module: s.module, + )); + } + + // Cap at 500 entries + if (_waterfallEntries.length > 500) { + _waterfallEntries.removeRange(0, _waterfallEntries.length - 500); + } + + _lastDetectedSignalCount = signals.length; + if (mounted) setState(() {}); + } + // Create file object for local list dynamic _createFileObject(String fileName, {DateTime? dateCreated}) { return _FileObject( @@ -931,6 +972,49 @@ class _RecordScreenState extends State with TickerProviderStateMix ), const SizedBox(height: 12), + + // Waterfall / spectrogram (only in Recording mode) + if (selectedAction == ModuleAction.recording) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Signal Activity', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primaryText, + ), + ), + if (_waterfallEntries.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + _waterfallEntries.clear(); + _lastDetectedSignalCount = 0; + _currentSessionFiles.clear(); + _recordedFiles.clear(); + }); + }, + icon: const Icon(Icons.clear_all, size: 16), + label: Text(AppLocalizations.of(context)!.clearAll), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + const SizedBox(height: 4), + WaterfallWidget( + entries: _waterfallEntries, + filterModule: moduleIndex, + isLive: isRecording || bleProvider.isModuleFrequencySearching(moduleIndex), + height: 150, + ), + const SizedBox(height: 12), + ], // File list only for Recording if (selectedAction == ModuleAction.recording) diff --git a/mobile_app/lib/screens/settings_screen.dart b/mobile_app/lib/screens/settings_screen.dart index 6345d9c..7c5f91d 100644 --- a/mobile_app/lib/screens/settings_screen.dart +++ b/mobile_app/lib/screens/settings_screen.dart @@ -1862,6 +1862,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 +2102,59 @@ class _SettingsScreenState extends State { ); } + /// Show Format SD Card confirmation dialog with Yes/No. + 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 success = await bleProvider.formatSDCard(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'SD card format started. This may take a moment.' + : 'Failed to send format SD command.'), + backgroundColor: success ? AppColors.warning : AppColors.error, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.warning, + foregroundColor: Colors.white, + ), + child: const Text('Yes, Format', style: TextStyle(fontSize: 16)), + ), + ], + ), + ); + } + /// Build HW Buttons configuration section. Widget _buildHwButtonsSection(BuildContext context, BleProvider bleProvider) { return Card( diff --git a/mobile_app/lib/widgets/waterfall_widget.dart b/mobile_app/lib/widgets/waterfall_widget.dart new file mode 100644 index 0000000..38ba438 --- /dev/null +++ b/mobile_app/lib/widgets/waterfall_widget.dart @@ -0,0 +1,227 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + +/// A single data point for the waterfall display. +class WaterfallEntry { + final DateTime timestamp; + final double frequencyMhz; + final int rssi; // typically –100 … 0 dBm + final int module; + + const WaterfallEntry({ + required this.timestamp, + required this.frequencyMhz, + required this.rssi, + required this.module, + }); +} + +/// Real-time signal-activity waterfall / spectrogram widget. +/// +/// Vertical axis = time (newest at bottom), horizontal axis = frequency, +/// colour = RSSI strength. Each detection is painted as a small rectangle +/// whose colour goes from blue (weak, ≤ –70 dBm) through green/yellow to +/// red (strong, ≥ –25 dBm). +class WaterfallWidget extends StatelessWidget { + /// The list of detection events to display. + final List entries; + + /// Height of the rendered area. + final double height; + + /// Whether the device is currently recording / searching. + final bool isLive; + + /// If provided, only entries for this module are drawn. + final int? filterModule; + + const WaterfallWidget({ + super.key, + required this.entries, + this.height = 160, + this.isLive = false, + this.filterModule, + }); + + @override + Widget build(BuildContext context) { + final filtered = filterModule != null + ? entries.where((e) => e.module == filterModule).toList() + : entries; + + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isLive + ? AppColors.success.withValues(alpha: 0.6) + : AppColors.border.withValues(alpha: 0.3), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: filtered.isEmpty + ? Center( + child: Text( + isLive ? 'Waiting for signals…' : 'No signal data', + style: TextStyle( + color: AppColors.secondaryText.withValues(alpha: 0.5), + fontSize: 12, + ), + ), + ) + : CustomPaint( + painter: _WaterfallPainter( + entries: filtered, + isLive: isLive, + ), + size: Size.infinite, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Painter +// --------------------------------------------------------------------------- + +class _WaterfallPainter extends CustomPainter { + final List entries; + final bool isLive; + + _WaterfallPainter({required this.entries, required this.isLive}); + + /// Map RSSI (dBm) to a colour. + /// –80 dBm → deep blue (very weak) + /// –50 dBm → green / cyan + /// –30 dBm → yellow + /// –15 dBm → red (very strong) + Color _rssiColor(int rssi) { + // Clamp to useful range + final clamped = rssi.clamp(-80, -15).toDouble(); + // Normalise 0 … 1 (0 = weak, 1 = strong) + final t = (clamped + 80) / 65.0; + + if (t < 0.33) { + // blue → cyan + return Color.lerp( + const Color(0xFF0D47A1), const Color(0xFF00BCD4), t / 0.33)!; + } else if (t < 0.66) { + // cyan → yellow + return Color.lerp(const Color(0xFF00BCD4), const Color(0xFFFFEB3B), + (t - 0.33) / 0.33)!; + } else { + // yellow → red + return Color.lerp( + const Color(0xFFFFEB3B), const Color(0xFFFF1744), (t - 0.66) / 0.34)!; + } + } + + @override + void paint(Canvas canvas, Size size) { + if (entries.isEmpty) return; + + // ── Determine axis ranges ── + double minFreq = double.infinity; + double maxFreq = double.negativeInfinity; + DateTime earliest = entries.first.timestamp; + DateTime latest = entries.first.timestamp; + + for (final e in entries) { + if (e.frequencyMhz < minFreq) minFreq = e.frequencyMhz; + if (e.frequencyMhz > maxFreq) maxFreq = e.frequencyMhz; + if (e.timestamp.isBefore(earliest)) earliest = e.timestamp; + if (e.timestamp.isAfter(latest)) latest = e.timestamp; + } + + // Add small padding so single-frequency data still has width + if ((maxFreq - minFreq).abs() < 0.5) { + minFreq -= 1.0; + maxFreq += 1.0; + } + + // Time window: at least 10 seconds so we don't zoom into nothing + final durationMs = math.max( + latest.difference(earliest).inMilliseconds.toDouble(), + 10000.0, + ); + + final freqRange = maxFreq - minFreq; + const double dotH = 4.0; // height of each signal dot (pixels) + const double dotMinW = 6.0; // minimum width + + // ── Draw grid lines + labels ── + final gridPaint = Paint()..color = Colors.white10; + final labelStyle = TextStyle( + color: Colors.white24, + fontSize: 9, + fontFamily: 'monospace', + ); + + // Horizontal time gridlines (every ~20% of height) + for (int i = 1; i < 5; i++) { + final y = size.height * i / 5; + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + } + + // Frequency labels at top + _drawText(canvas, '${minFreq.toStringAsFixed(1)}', Offset(2, 2), labelStyle); + _drawText(canvas, '${maxFreq.toStringAsFixed(1)} MHz', + Offset(size.width - 70, 2), labelStyle); + + // ── Draw entries ── + for (final entry in entries) { + final tNorm = + (entry.timestamp.difference(earliest).inMilliseconds) / durationMs; + final fNorm = (entry.frequencyMhz - minFreq) / freqRange; + + final x = fNorm * (size.width - dotMinW); + final y = tNorm * (size.height - dotH); + + final color = _rssiColor(entry.rssi); + final paint = Paint()..color = color; + + // Width proportional to RSSI (stronger = wider glow) + final strength = ((entry.rssi + 80) / 65.0).clamp(0.15, 1.0); + final w = dotMinW + strength * 14; + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, y, w, dotH), + const Radius.circular(2), + ), + paint, + ); + } + + // ── Live indicator ── + if (isLive) { + final livePaint = Paint() + ..color = AppColors.success + ..strokeWidth = 1.5; + canvas.drawLine( + Offset(0, size.height - 1), + Offset(size.width, size.height - 1), + livePaint, + ); + } + } + + void _drawText(Canvas canvas, String text, Offset offset, TextStyle style) { + final tp = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, offset); + } + + @override + bool shouldRepaint(covariant _WaterfallPainter old) { + return entries.length != old.entries.length || isLive != old.isLive; + } +} 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..ad9c862 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -716,7 +716,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) From 533bcb4ea0d3c6fec9a2efda4111746020f70182 Mon Sep 17 00:00:00 2001 From: Senape3000 <80119773+Senape3000@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:07:47 +0100 Subject: [PATCH 2/5] Auto-send nRF/settings, i18n & version bumps Add automatic sending of nRF and button config changes (debounced 500ms for sliders) and remove manual send flows. Introduces a Timer-based debounce, cancels on dispose, triggers _sendNrfSettings on choice/slider changes and _sendButtonConfig after picking/changing replay/button actions. Add new localization key rfSettingsSubtitle (EN/RU ARB and generated files) and use it in Settings UI. Bump firmware version to 1.0.4 and mobile app version to 1.0.3+16; update iOS Flutter generated path and build name/number in Generated.xcconfig and flutter_export_environment.sh. Misc UI tweaks: show only filename for .sub paths, increase status bar maxHeight, and change waterfall border to use AppColors.borderDefault. --- include/config.h | 4 +- mobile_app/ios/Flutter/Generated.xcconfig | 6 +- .../ios/Flutter/flutter_export_environment.sh | 6 +- mobile_app/lib/l10n/app_en.arb | 1 + mobile_app/lib/l10n/app_localizations.dart | 6 + mobile_app/lib/l10n/app_localizations_en.dart | 3 + mobile_app/lib/l10n/app_localizations_ru.dart | 3 + mobile_app/lib/l10n/app_ru.arb | 1 + mobile_app/lib/screens/settings_screen.dart | 115 +++++++----------- mobile_app/lib/widgets/status_bar_widget.dart | 2 +- mobile_app/lib/widgets/waterfall_widget.dart | 2 +- mobile_app/pubspec.yaml | 2 +- 12 files changed, 69 insertions(+), 82 deletions(-) diff --git a/include/config.h b/include/config.h index 27aad63..7541bdd 100644 --- a/include/config.h +++ b/include/config.h @@ -8,8 +8,8 @@ // 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_PATCH 5 +#define FIRMWARE_VERSION_STRING "1.0.5" #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..df3f641 100644 --- a/mobile_app/lib/l10n/app_en.arb +++ b/mobile_app/lib/l10n/app_en.arb @@ -1054,6 +1054,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..45265f6 100644 --- a/mobile_app/lib/l10n/app_localizations.dart +++ b/mobile_app/lib/l10n/app_localizations.dart @@ -2654,6 +2654,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..61249d5 100644 --- a/mobile_app/lib/l10n/app_localizations_en.dart +++ b/mobile_app/lib/l10n/app_localizations_en.dart @@ -1492,6 +1492,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..304f740 100644 --- a/mobile_app/lib/l10n/app_localizations_ru.dart +++ b/mobile_app/lib/l10n/app_localizations_ru.dart @@ -1499,6 +1499,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..58c23dc 100644 --- a/mobile_app/lib/l10n/app_ru.arb +++ b/mobile_app/lib/l10n/app_ru.arb @@ -1040,6 +1040,7 @@ "appSettings": "Настройки приложения", "appSettingsSubtitle": "Язык, кэш, разрешения", "rfSettings": "Настройки RF", + "rfSettingsSubtitle": "Брутфорс, радио и сканер", "syncedWithDevice": "Синхронизировано с устройством", "localOnly": "Только локально", "bruteforceSettings": "Настройки брутфорса", diff --git a/mobile_app/lib/screens/settings_screen.dart b/mobile_app/lib/screens/settings_screen.dart index 7c5f91d..827e822 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, ), ), @@ -1178,7 +1186,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 +1231,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 +1285,7 @@ class _SettingsScreenState extends State { activeColor: const Color(0xFF00BCD4), onChanged: (value) { settingsProvider.setNrfChannel(value.round()); + _debouncedSendNrfSettings(context, bleProvider, settingsProvider); }, ), @@ -1313,38 +1328,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 +1361,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 @@ -2230,9 +2226,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); }, ), @@ -2244,42 +2244,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), - ), - ), ], ), ); @@ -2367,7 +2340,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), 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/lib/widgets/waterfall_widget.dart b/mobile_app/lib/widgets/waterfall_widget.dart index 38ba438..77f23bd 100644 --- a/mobile_app/lib/widgets/waterfall_widget.dart +++ b/mobile_app/lib/widgets/waterfall_widget.dart @@ -59,7 +59,7 @@ class WaterfallWidget extends StatelessWidget { border: Border.all( color: isLive ? AppColors.success.withValues(alpha: 0.6) - : AppColors.border.withValues(alpha: 0.3), + : AppColors.borderDefault.withValues(alpha: 0.3), ), ), child: ClipRRect( diff --git a/mobile_app/pubspec.yaml b/mobile_app/pubspec.yaml index 5768e0c..1222651 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.0.3+16 environment: sdk: '>=3.0.0 <4.0.0' From 17683c22b7a629732b7f3719be9f9c96ca2646eb Mon Sep 17 00:00:00 2001 From: Senape3000 <80119773+Senape3000@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:43:46 +0100 Subject: [PATCH 3/5] Add SD format, clone resume & UI/localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several features and fixes across firmware and mobile app: - FileCommands.h: make recursive directory/file removal more robust — copy child.path() into a local buffer before close, log failures, continue deleting other entries instead of aborting, yield (vTaskDelay) to avoid watchdog timeouts, and return aggregate success. - Binary parser: add 'format-sd' action code. - BleProvider: add isFormattingSD/sdFormatSuccess flags, set/reset them when sending/receiving format commands and notify listeners. - SettingsScreen: improved scanner settings UI (moved into Consumer), added non-dismissible SD format progress dialog that auto-closes on firmware result, and handle command send failures. - FlipperSubDbService: add pause/resume support for cloning — cache downloaded ZIP, persist per-file progress, APIs to save/load progress and cached ZIP, extractFromBytes for resuming, and related helper functions; tweak extraction path handling. - SubGhz clone dialog: implement resume/start-fresh flow, pause/resume controls, progress persistence, caching usage, and related UI/logic changes. - QuickConnectWidget: fallback to show all scan results when no supported devices found and new localization message for that case. - Localization: add new string noSupportedDevicesShowAll in en/ru ARB and generated localization classes. - SignalScanner & RecordScreen: remove WaterfallWidget usage and refactor view mode/animation handling; delete waterfall_widget.dart. These changes improve reliability for long-running SD operations (format and large SubGHz clone), add user-facing progress/resume behavior, and update UI/localization accordingly. --- include/FileCommands.h | 45 +- mobile_app/lib/l10n/app_en.arb | 1 + mobile_app/lib/l10n/app_localizations.dart | 6 + mobile_app/lib/l10n/app_localizations_en.dart | 4 + mobile_app/lib/l10n/app_localizations_ru.dart | 4 + mobile_app/lib/l10n/app_ru.arb | 1 + mobile_app/lib/providers/ble_provider.dart | 19 +- .../lib/providers/settings_provider.dart | 1 - mobile_app/lib/screens/record_screen.dart | 84 --- mobile_app/lib/screens/settings_screen.dart | 507 ++++++++++++++---- .../lib/screens/signal_scanner_screen.dart | 44 +- .../lib/services/binary_message_parser.dart | 1 + .../lib/services/flipper_subdb_service.dart | 130 ++++- .../lib/widgets/quick_connect_widget.dart | 22 +- mobile_app/lib/widgets/waterfall_widget.dart | 227 -------- mobile_app/pubspec.yaml | 2 +- platformio.ini | 10 +- 17 files changed, 637 insertions(+), 471 deletions(-) delete mode 100644 mobile_app/lib/widgets/waterfall_widget.dart diff --git a/include/FileCommands.h b/include/FileCommands.h index 989f1a8..c610103 100644 --- a/include/FileCommands.h +++ b/include/FileCommands.h @@ -765,30 +765,41 @@ public: return false; } + bool allOk = true; File child = dir.openNextFile(); while (child) { - const char* childPath = child.path(); + // 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, childPath)) { - dir.close(); - return false; + 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(childPath)) { - ESP_LOGE("FileCmd", "Failed to remove file: %s", childPath); - dir.close(); - return false; + 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 - return fs.rmdir(path); + 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) { @@ -859,21 +870,27 @@ public: bool allOk = true; File child = root.openNextFile(); while (child) { - const char* childPath = child.path(); + // 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(); if (isDir) { - if (!removeDirectoryRecursive(SD, childPath)) { - ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPath); + if (!removeDirectoryRecursive(SD, childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPathBuf); allOk = false; } } else { - if (!SD.remove(childPath)) { - ESP_LOGE("FileCmd", "Failed to remove file: %s", childPath); + if (!SD.remove(childPathBuf)) { + ESP_LOGE("FileCmd", "Failed to remove file: %s", childPathBuf); allOk = false; } } + // Yield to prevent watchdog timeout during format + vTaskDelay(1); child = root.openNextFile(); } root.close(); diff --git a/mobile_app/lib/l10n/app_en.arb b/mobile_app/lib/l10n/app_en.arb index df3f641..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", diff --git a/mobile_app/lib/l10n/app_localizations.dart b/mobile_app/lib/l10n/app_localizations.dart index 45265f6..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: diff --git a/mobile_app/lib/l10n/app_localizations_en.dart b/mobile_app/lib/l10n/app_localizations_en.dart index 61249d5..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'; diff --git a/mobile_app/lib/l10n/app_localizations_ru.dart b/mobile_app/lib/l10n/app_localizations_ru.dart index 304f740..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 => 'Не подключено к устройству'; diff --git a/mobile_app/lib/l10n/app_ru.arb b/mobile_app/lib/l10n/app_ru.arb index 58c23dc..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": "Подключитесь к устройству для управления файлами", diff --git a/mobile_app/lib/providers/ble_provider.dart b/mobile_app/lib/providers/ble_provider.dart index a1b2a46..06c525a 100644 --- a/mobile_app/lib/providers/ble_provider.dart +++ b/mobile_app/lib/providers/ble_provider.dart @@ -104,7 +104,9 @@ 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 + // Scanner state List detectedSignals = []; Map frequencySpectrum = {}; @@ -694,6 +696,7 @@ class BleProvider extends ChangeNotifier { _resetConnectionState(); _log('info', 'Disconnected from device'); isLoadingFiles = false; + isFormattingSD = false; // Clear cache on disconnect _fileCache.clear(); @@ -2545,6 +2548,15 @@ class BleProvider extends ChangeNotifier { _handleFileUploadResponse(responseData); } + // Handle format-sd response + if (responseData.containsKey('action') && responseData['action'] == 'format-sd') { + print('Format SD response received: $responseData'); + isFormattingSD = false; + 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'); @@ -3238,10 +3250,15 @@ class BleProvider extends ChangeNotifier { 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; } 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/record_screen.dart b/mobile_app/lib/screens/record_screen.dart index 512d2af..f642dbb 100644 --- a/mobile_app/lib/screens/record_screen.dart +++ b/mobile_app/lib/screens/record_screen.dart @@ -9,7 +9,6 @@ import '../services/cc1101/cc1101_calculator.dart'; import '../widgets/record_screen_widgets.dart'; import '../widgets/file_list_widget.dart'; import '../widgets/transmit_file_dialog.dart'; -import '../widgets/waterfall_widget.dart'; import '../theme/app_colors.dart'; import 'file_viewer_screen.dart'; @@ -50,10 +49,6 @@ class _RecordScreenState extends State with TickerProviderStateMix // Files from current recording session final List _currentSessionFiles = []; - // Waterfall signal activity data (per-module) - final List _waterfallEntries = []; - int _lastDetectedSignalCount = 0; - // Flags for tracking changes final List _configsChanged = []; @@ -101,9 +96,6 @@ class _RecordScreenState extends State with TickerProviderStateMix final runtimeFiles = _bleProvider?.recordedRuntimeFiles ?? []; print('Runtime files: $runtimeFiles'); - // Feed new detected signals into the waterfall - _syncWaterfallFromProvider(); - // Add new files to local recorded files list for (final file in runtimeFiles) { // Extract filename from object @@ -169,42 +161,9 @@ class _RecordScreenState extends State with TickerProviderStateMix setState(() { _currentSessionFiles.clear(); _recordedFiles.clear(); - _waterfallEntries.clear(); - _lastDetectedSignalCount = 0; }); } - /// Synchronise waterfall entries from the BLE provider's detected signals. - void _syncWaterfallFromProvider() { - final signals = _bleProvider?.detectedSignals ?? []; - if (signals.length == _lastDetectedSignalCount) return; - - // The list is sorted newest-first. Grab only new entries. - final newCount = signals.length - _lastDetectedSignalCount; - if (newCount <= 0) { - _lastDetectedSignalCount = signals.length; - return; - } - - for (int i = newCount - 1; i >= 0; i--) { - final s = signals[i]; - _waterfallEntries.add(WaterfallEntry( - timestamp: s.timestamp, - frequencyMhz: double.tryParse(s.frequency) ?? 0, - rssi: s.rssi, - module: s.module, - )); - } - - // Cap at 500 entries - if (_waterfallEntries.length > 500) { - _waterfallEntries.removeRange(0, _waterfallEntries.length - 500); - } - - _lastDetectedSignalCount = signals.length; - if (mounted) setState(() {}); - } - // Create file object for local list dynamic _createFileObject(String fileName, {DateTime? dateCreated}) { return _FileObject( @@ -973,49 +932,6 @@ class _RecordScreenState extends State with TickerProviderStateMix const SizedBox(height: 12), - // Waterfall / spectrogram (only in Recording mode) - if (selectedAction == ModuleAction.recording) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Signal Activity', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.primaryText, - ), - ), - if (_waterfallEntries.isNotEmpty) - TextButton.icon( - onPressed: () { - setState(() { - _waterfallEntries.clear(); - _lastDetectedSignalCount = 0; - _currentSessionFiles.clear(); - _recordedFiles.clear(); - }); - }, - icon: const Icon(Icons.clear_all, size: 16), - label: Text(AppLocalizations.of(context)!.clearAll), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ), - const SizedBox(height: 4), - WaterfallWidget( - entries: _waterfallEntries, - filterModule: moduleIndex, - isLive: isRecording || bleProvider.isModuleFrequencySearching(moduleIndex), - height: 150, - ), - 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 827e822..b7f0c39 100644 --- a/mobile_app/lib/screens/settings_screen.dart +++ b/mobile_app/lib/screens/settings_screen.dart @@ -1035,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(), - ), - ], - ), + ); + }, ); } @@ -2098,7 +2104,8 @@ class _SettingsScreenState extends State { ); } - /// Show Format SD Card confirmation dialog with Yes/No. + /// 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, @@ -2128,17 +2135,19 @@ class _SettingsScreenState extends State { ElevatedButton( onPressed: () async { Navigator.of(ctx).pop(); - final success = await bleProvider.formatSDCard(); - if (context.mounted) { + final sent = await bleProvider.formatSDCard(); + if (!context.mounted) return; + if (!sent) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(success - ? 'SD card format started. This may take a moment.' - : 'Failed to send format SD command.'), - backgroundColor: success ? AppColors.warning : AppColors.error, + 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, @@ -2151,6 +2160,69 @@ class _SettingsScreenState extends State { ); } + /// Non-dismissible progress dialog that listens to BleProvider.isFormattingSD + /// and closes automatically when the firmware result arrives. + void _showSDFormatProgressDialog(BuildContext context) { + bool closed = false; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => PopScope( + canPop: false, + child: Consumer( + builder: (context, ble, _) { + if (!ble.isFormattingSD && !closed) { + 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, + ), + ); + } + }); + } + 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: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + 'Formatting SD card, please wait.', + style: TextStyle(color: AppColors.primaryText), + textAlign: TextAlign.center, + ), + SizedBox(height: 8), + 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( @@ -3229,34 +3301,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(() { @@ -3264,16 +3428,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( @@ -3287,7 +3453,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); } } @@ -3296,6 +3463,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'; }); @@ -3309,15 +3477,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; }); } @@ -3327,6 +3516,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) { @@ -3336,6 +3533,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { } } + // All files processed – clean up cache + await FlipperSubDbService.clearCache(); + if (mounted) { setState(() { _isDone = true; @@ -3349,6 +3549,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) { @@ -3364,23 +3568,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), ), ), @@ -3391,7 +3659,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Phase indicator - if (!_isDone) ...[ + if (!_isDone && !_isPaused) ...[ Row( children: [ SizedBox( @@ -3443,7 +3711,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> { ? AppColors.error : _isDone ? AppColors.success - : Colors.orange, + : _isPaused + ? Colors.orange.shade300 + : Colors.orange, ), minHeight: 8, ), @@ -3451,7 +3721,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)' : ''}', @@ -3481,10 +3751,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..fe625d7 100644 --- a/mobile_app/lib/services/binary_message_parser.dart +++ b/mobile_app/lib/services/binary_message_parser.dart @@ -430,6 +430,7 @@ class BinaryFileActionResult { case 5: return 'move'; case 6: return 'tree'; case 7: return 'load'; + case 8: return 'format-sd'; default: return 'unknown'; } } 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/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/waterfall_widget.dart b/mobile_app/lib/widgets/waterfall_widget.dart deleted file mode 100644 index 77f23bd..0000000 --- a/mobile_app/lib/widgets/waterfall_widget.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; - -/// A single data point for the waterfall display. -class WaterfallEntry { - final DateTime timestamp; - final double frequencyMhz; - final int rssi; // typically –100 … 0 dBm - final int module; - - const WaterfallEntry({ - required this.timestamp, - required this.frequencyMhz, - required this.rssi, - required this.module, - }); -} - -/// Real-time signal-activity waterfall / spectrogram widget. -/// -/// Vertical axis = time (newest at bottom), horizontal axis = frequency, -/// colour = RSSI strength. Each detection is painted as a small rectangle -/// whose colour goes from blue (weak, ≤ –70 dBm) through green/yellow to -/// red (strong, ≥ –25 dBm). -class WaterfallWidget extends StatelessWidget { - /// The list of detection events to display. - final List entries; - - /// Height of the rendered area. - final double height; - - /// Whether the device is currently recording / searching. - final bool isLive; - - /// If provided, only entries for this module are drawn. - final int? filterModule; - - const WaterfallWidget({ - super.key, - required this.entries, - this.height = 160, - this.isLive = false, - this.filterModule, - }); - - @override - Widget build(BuildContext context) { - final filtered = filterModule != null - ? entries.where((e) => e.module == filterModule).toList() - : entries; - - return Container( - height: height, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isLive - ? AppColors.success.withValues(alpha: 0.6) - : AppColors.borderDefault.withValues(alpha: 0.3), - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(7), - child: filtered.isEmpty - ? Center( - child: Text( - isLive ? 'Waiting for signals…' : 'No signal data', - style: TextStyle( - color: AppColors.secondaryText.withValues(alpha: 0.5), - fontSize: 12, - ), - ), - ) - : CustomPaint( - painter: _WaterfallPainter( - entries: filtered, - isLive: isLive, - ), - size: Size.infinite, - ), - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Painter -// --------------------------------------------------------------------------- - -class _WaterfallPainter extends CustomPainter { - final List entries; - final bool isLive; - - _WaterfallPainter({required this.entries, required this.isLive}); - - /// Map RSSI (dBm) to a colour. - /// –80 dBm → deep blue (very weak) - /// –50 dBm → green / cyan - /// –30 dBm → yellow - /// –15 dBm → red (very strong) - Color _rssiColor(int rssi) { - // Clamp to useful range - final clamped = rssi.clamp(-80, -15).toDouble(); - // Normalise 0 … 1 (0 = weak, 1 = strong) - final t = (clamped + 80) / 65.0; - - if (t < 0.33) { - // blue → cyan - return Color.lerp( - const Color(0xFF0D47A1), const Color(0xFF00BCD4), t / 0.33)!; - } else if (t < 0.66) { - // cyan → yellow - return Color.lerp(const Color(0xFF00BCD4), const Color(0xFFFFEB3B), - (t - 0.33) / 0.33)!; - } else { - // yellow → red - return Color.lerp( - const Color(0xFFFFEB3B), const Color(0xFFFF1744), (t - 0.66) / 0.34)!; - } - } - - @override - void paint(Canvas canvas, Size size) { - if (entries.isEmpty) return; - - // ── Determine axis ranges ── - double minFreq = double.infinity; - double maxFreq = double.negativeInfinity; - DateTime earliest = entries.first.timestamp; - DateTime latest = entries.first.timestamp; - - for (final e in entries) { - if (e.frequencyMhz < minFreq) minFreq = e.frequencyMhz; - if (e.frequencyMhz > maxFreq) maxFreq = e.frequencyMhz; - if (e.timestamp.isBefore(earliest)) earliest = e.timestamp; - if (e.timestamp.isAfter(latest)) latest = e.timestamp; - } - - // Add small padding so single-frequency data still has width - if ((maxFreq - minFreq).abs() < 0.5) { - minFreq -= 1.0; - maxFreq += 1.0; - } - - // Time window: at least 10 seconds so we don't zoom into nothing - final durationMs = math.max( - latest.difference(earliest).inMilliseconds.toDouble(), - 10000.0, - ); - - final freqRange = maxFreq - minFreq; - const double dotH = 4.0; // height of each signal dot (pixels) - const double dotMinW = 6.0; // minimum width - - // ── Draw grid lines + labels ── - final gridPaint = Paint()..color = Colors.white10; - final labelStyle = TextStyle( - color: Colors.white24, - fontSize: 9, - fontFamily: 'monospace', - ); - - // Horizontal time gridlines (every ~20% of height) - for (int i = 1; i < 5; i++) { - final y = size.height * i / 5; - canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); - } - - // Frequency labels at top - _drawText(canvas, '${minFreq.toStringAsFixed(1)}', Offset(2, 2), labelStyle); - _drawText(canvas, '${maxFreq.toStringAsFixed(1)} MHz', - Offset(size.width - 70, 2), labelStyle); - - // ── Draw entries ── - for (final entry in entries) { - final tNorm = - (entry.timestamp.difference(earliest).inMilliseconds) / durationMs; - final fNorm = (entry.frequencyMhz - minFreq) / freqRange; - - final x = fNorm * (size.width - dotMinW); - final y = tNorm * (size.height - dotH); - - final color = _rssiColor(entry.rssi); - final paint = Paint()..color = color; - - // Width proportional to RSSI (stronger = wider glow) - final strength = ((entry.rssi + 80) / 65.0).clamp(0.15, 1.0); - final w = dotMinW + strength * 14; - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(x, y, w, dotH), - const Radius.circular(2), - ), - paint, - ); - } - - // ── Live indicator ── - if (isLive) { - final livePaint = Paint() - ..color = AppColors.success - ..strokeWidth = 1.5; - canvas.drawLine( - Offset(0, size.height - 1), - Offset(size.width, size.height - 1), - livePaint, - ); - } - } - - void _drawText(Canvas canvas, String text, Offset offset, TextStyle style) { - final tp = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, offset); - } - - @override - bool shouldRepaint(covariant _WaterfallPainter old) { - return entries.length != old.entries.length || isLive != old.isLive; - } -} diff --git a/mobile_app/pubspec.yaml b/mobile_app/pubspec.yaml index 1222651..d3cd034 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.3+16 +version: 1.0.6+20 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 From ccc23341005cd070d74250062c46c465a7d996e8 Mon Sep 17 00:00:00 2001 From: Senape3000 <80119773+Senape3000@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:40:22 +0100 Subject: [PATCH 4/5] Add SD format progress and LittleFS handling Report SD format progress over BLE and improve filesystem handling. - FileCommands: send in-progress progress notifications (errorCode 0xFF) during SD format, report per-item delete/create messages, track deleted count and send final result. - mobile app: add sdFormatProgress field, parse progress messages in BinaryFileActionResult JSON, update BLE provider to handle progress vs final result, and show progress text in settings UI dialog. - binary_message_parser: include isProgress and progressMessage fields for in-progress messages. - src/main.cpp: gracefully handle missing SD card (fallback to LittleFS-only), record sdCardMounted state, and ensure default DATA directories exist when SD is present. - CC1101_Worker: add LittleFS support for pathType 4 (and root handling for pathType 5), choose fs object dynamically, and return an error for transmit attempts from LittleFS (unsupported). - Bump mobile app version to 1.0.7+21. These changes enable live feedback to the mobile app during long SD operations and improve robustness when the SD card is absent, while introducing limited LittleFS path support in the transmitter. --- include/FileCommands.h | 40 +++++++++++++++---- mobile_app/lib/providers/ble_provider.dart | 20 ++++++++-- mobile_app/lib/screens/settings_screen.dart | 18 +++++---- .../lib/services/binary_message_parser.dart | 4 ++ mobile_app/pubspec.yaml | 2 +- src/main.cpp | 27 +++++++++++-- src/modules/CC1101_driver/CC1101_Worker.cpp | 29 ++++++++++++-- 7 files changed, 114 insertions(+), 26 deletions(-) diff --git a/include/FileCommands.h b/include/FileCommands.h index c610103..867cf51 100644 --- a/include/FileCommands.h +++ b/include/FileCommands.h @@ -845,6 +845,7 @@ public: /** * @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. @@ -859,7 +860,11 @@ public: ESP_LOGW("FileCmd", "FORMAT SD CARD — deleting all contents"); - // Recursively delete every entry in SD root + // 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"); @@ -868,6 +873,7 @@ public: } bool allOk = true; + int deletedCount = 0; File child = root.openNextFile(); while (child) { // Copy path to local buffer before close — child.path() @@ -878,6 +884,12 @@ public: 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); @@ -889,20 +901,34 @@ public: allOk = false; } } + deletedCount++; // Yield to prevent watchdog timeout during format vTaskDelay(1); child = root.openNextFile(); } root.close(); - // Re-create default directory structure - SD.mkdir("/DATA"); - SD.mkdir("/DATA/RECORDS"); - SD.mkdir("/DATA/SIGNALS"); - SD.mkdir("/DATA/PRESETS"); - SD.mkdir("/DATA/TEMP"); + ESP_LOGI("FileCmd", "Deleted %d items from SD root", deletedCount); + + // Phase 3: re-create default directory structure with progress + static const char* defaultDirs[] = { + "/DATA", + "/DATA/RECORDS", + "/DATA/SIGNALS", + "/DATA/PRESETS", + "/DATA/TEMP" + }; + 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)); + SD.mkdir(defaultDirs[i]); + 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; } diff --git a/mobile_app/lib/providers/ble_provider.dart b/mobile_app/lib/providers/ble_provider.dart index 06c525a..7b40eef 100644 --- a/mobile_app/lib/providers/ble_provider.dart +++ b/mobile_app/lib/providers/ble_provider.dart @@ -106,6 +106,7 @@ class BleProvider extends ChangeNotifier { 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 = []; @@ -2551,10 +2552,21 @@ class BleProvider extends ChangeNotifier { // Handle format-sd response if (responseData.containsKey('action') && responseData['action'] == 'format-sd') { print('Format SD response received: $responseData'); - isFormattingSD = false; - sdFormatSuccess = responseData['success'] == true; - _log('info', 'SD format ${sdFormatSuccess ? 'succeeded' : 'failed'}'); - notifyListeners(); + + // 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 diff --git a/mobile_app/lib/screens/settings_screen.dart b/mobile_app/lib/screens/settings_screen.dart index b7f0c39..6ff60b6 100644 --- a/mobile_app/lib/screens/settings_screen.dart +++ b/mobile_app/lib/screens/settings_screen.dart @@ -2198,18 +2198,22 @@ class _SettingsScreenState extends State { Text('Formatting...', style: TextStyle(color: AppColors.warning)), ], ), - content: const Column( + content: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), Text( - 'Formatting SD card, please wait.', - style: TextStyle(color: AppColors.primaryText), + ble.sdFormatProgress.isNotEmpty + ? ble.sdFormatProgress + : 'Formatting SD card, please wait.', + style: const TextStyle(color: AppColors.primaryText), textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - SizedBox(height: 8), - Text( + const SizedBox(height: 8), + const Text( 'Do not disconnect the device.', style: TextStyle(color: AppColors.secondaryText, fontSize: 12), textAlign: TextAlign.center, diff --git a/mobile_app/lib/services/binary_message_parser.dart b/mobile_app/lib/services/binary_message_parser.dart index fe625d7..8cb479f 100644 --- a/mobile_app/lib/services/binary_message_parser.dart +++ b/mobile_app/lib/services/binary_message_parser.dart @@ -436,11 +436,15 @@ class BinaryFileActionResult { } 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/pubspec.yaml b/mobile_app/pubspec.yaml index d3cd034..11197ab 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.6+20 +version: 1.0.7+21 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/src/main.cpp b/src/main.cpp index ad9c862..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(); 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; From c0c13f785047d48a337cf362623f01ddfd312c9c Mon Sep 17 00:00:00 2001 From: Senape3000 <80119773+Senape3000@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:26:49 +0100 Subject: [PATCH 5/5] Refactor file handling, SD verify, bump versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple fixes and refactors to improve SD robustness and file path handling across the app: - .gitignore: Ignore SDR build/dist/upx/spec artifacts. - include/FileCommands.h: Improve SD format phase by verifying directory creation, logging failures, and marking overall format result when mkdir fails. - include/config.h: Bump firmware version to 1.1.0 (minor/patch updated). - mobile_app/lib/providers/ble_provider.dart: Refactor readFileContent to accept pathType (int) instead of basePath, centralize relative/absolute path extraction logic, and propagate effectivePathType in binary commands (breaking API change — callers updated). - mobile_app/lib/screens/file_viewer_screen.dart: Updated to call readFileContent with pathType and let provider normalize paths. - mobile_app/lib/screens/settings_screen.dart: Improve SD format progress dialog handling by tracking whether any progress response was received, adding a timeout to avoid indefinite blocking, and showing a timeout snackbar on no response. - mobile_app/lib/services/update_service.dart: Change fetchChangelog to search release assets for changelog.json (newer approach) and fall back to the legacy raw URL if not found. - mobile_app/pubspec.yaml: Bump mobile app version to 1.0.9+24. Notes: The main breaking change is the BLE provider API signature change (basePath -> pathType); UI callers have been updated in this changeset. These changes improve reliability of SD formatting and make changelog fetching more robust. --- .gitignore | 6 +- include/FileCommands.h | 20 ++++++- include/config.h | 6 +- mobile_app/lib/providers/ble_provider.dart | 58 ++++++++----------- .../lib/screens/file_viewer_screen.dart | 30 ++-------- mobile_app/lib/screens/settings_screen.dart | 31 +++++++++- mobile_app/lib/services/update_service.dart | 53 ++++++++++++----- mobile_app/pubspec.yaml | 2 +- 8 files changed, 124 insertions(+), 82 deletions(-) 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 867cf51..37cc555 100644 --- a/include/FileCommands.h +++ b/include/FileCommands.h @@ -910,7 +910,7 @@ public: ESP_LOGI("FileCmd", "Deleted %d items from SD root", deletedCount); - // Phase 3: re-create default directory structure with progress + // Phase 3: re-create default directory structure with progress and verification static const char* defaultDirs[] = { "/DATA", "/DATA/RECORDS", @@ -918,13 +918,27 @@ public: "/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)); - SD.mkdir(defaultDirs[i]); - ESP_LOGI("FileCmd", "Created directory: %s", defaultDirs[i]); + + // 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"); diff --git a/include/config.h b/include/config.h index 7541bdd..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 5 -#define FIRMWARE_VERSION_STRING "1.0.5" +#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/lib/providers/ble_provider.dart b/mobile_app/lib/providers/ble_provider.dart index 7b40eef..c6c190b 100644 --- a/mobile_app/lib/providers/ble_provider.dart +++ b/mobile_app/lib/providers/ble_provider.dart @@ -1203,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; @@ -1247,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); @@ -1291,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); @@ -3022,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) { 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/settings_screen.dart b/mobile_app/lib/screens/settings_screen.dart index 6ff60b6..4118b95 100644 --- a/mobile_app/lib/screens/settings_screen.dart +++ b/mobile_app/lib/screens/settings_screen.dart @@ -2164,6 +2164,8 @@ class _SettingsScreenState extends State { /// and closes automatically when the firmware result arrives. void _showSDFormatProgressDialog(BuildContext context) { bool closed = false; + bool hasReceivedResponse = false; + showDialog( context: context, barrierDismissible: false, @@ -2171,7 +2173,14 @@ class _SettingsScreenState extends State { canPop: false, child: Consumer( builder: (context, ble, _) { - if (!ble.isFormattingSD && !closed) { + // 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(); @@ -2184,11 +2193,31 @@ class _SettingsScreenState extends State { 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( 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/pubspec.yaml b/mobile_app/pubspec.yaml index 11197ab..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.7+21 +version: 1.1.0+25 environment: sdk: '>=3.0.0 <4.0.0'