Merge pull request #5 from Senape3000/Waterfall_dev

This commit is contained in:
Senape3000
2026-02-15 17:30:48 +01:00
committed by GitHub
28 changed files with 1256 additions and 361 deletions
+5 -1
View File
@@ -10,4 +10,8 @@ releases/
personal_docs/
*.7z
to-do/
docs/session
docs/session
SDR/build
SDR/dist
SDR/upx
SDR/sdr_launcher.spec
+161 -1
View File
@@ -34,6 +34,7 @@ public:
handler.registerCommand(0x0F, handleMoveFile);
handler.registerCommand(0x10, handleSaveToSignalsWithName);
handler.registerCommand(0x14, handleGetDirectoryTree); // Changed from 0x12 to avoid conflict with startJam
handler.registerCommand(0x18, handleFormatSDCard);
}
private:
@@ -747,6 +748,60 @@ public:
return true;
}
/**
* @brief Recursively remove a directory and all its contents.
*
* Walks the directory tree depth-first: deletes every file, recurses
* into sub-directories, then removes the now-empty directory itself.
*
* @param fs Filesystem reference (SD or LittleFS).
* @param path Absolute path of the directory to remove.
* @return true if the directory and all children were deleted.
*/
static bool removeDirectoryRecursive(fs::FS& fs, const char* path) {
File dir = fs.open(path);
if (!dir || !dir.isDirectory()) {
dir.close();
return false;
}
bool allOk = true;
File child = dir.openNextFile();
while (child) {
// Copy path before close — child.path() is an internal pointer
// that becomes invalid after child.close().
char childPathBuf[256];
strncpy(childPathBuf, child.path(), sizeof(childPathBuf) - 1);
childPathBuf[sizeof(childPathBuf) - 1] = '\0';
bool isDir = child.isDirectory();
child.close();
if (isDir) {
if (!removeDirectoryRecursive(fs, childPathBuf)) {
ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPathBuf);
allOk = false;
// Continue deleting other entries instead of aborting
}
} else {
if (!fs.remove(childPathBuf)) {
ESP_LOGE("FileCmd", "Failed to remove file: %s", childPathBuf);
allOk = false;
}
}
// Yield to prevent watchdog timeout on deep/large trees
vTaskDelay(1);
child = dir.openNextFile();
}
dir.close();
// Directory should now be empty — remove it
if (!fs.rmdir(path)) {
ESP_LOGE("FileCmd", "Failed to rmdir: %s", path);
return false;
}
return allOk;
}
static bool handleRemoveFile(const uint8_t* data, size_t len) {
if (len < 2) {
sendBinaryFileActionResult(1, false, 1); // 1=delete, error 1=insufficient data
@@ -778,7 +833,7 @@ public:
bool ok = false;
if (isDirectory) {
ok = fs.rmdir(pathBuffer.c_str());
ok = removeDirectoryRecursive(fs, pathBuffer.c_str());
} else {
ok = fs.remove(pathBuffer.c_str());
}
@@ -786,6 +841,111 @@ public:
sendBinaryFileActionResult(1, ok, ok ? 0 : 4, pathBuffer.c_str()); // error 4=delete failed
return ok;
}
/**
* @brief Format SD card: recursively delete all contents and re-create
* the default directory structure.
* Sends progressive feedback (errorCode 0xFF = in-progress step).
*
* Payload: [0x46][0x53] ('FS') as confirmation guard — prevents
* accidental invocation.
*/
static bool handleFormatSDCard(const uint8_t* data, size_t len) {
// Require 2-byte confirmation payload 'FS' (Format SD)
if (len < 2 || data[0] != 0x46 || data[1] != 0x53) {
ESP_LOGW("FileCmd", "Format SD rejected: missing confirmation 'FS'");
sendBinaryFileActionResult(8, false, 1); // actionType 8 = format
return false;
}
ESP_LOGW("FileCmd", "FORMAT SD CARD — deleting all contents");
// Phase 1: notify app that format has started
sendBinaryFileActionResult(8, true, 0xFF, "Starting format...");
vTaskDelay(pdMS_TO_TICKS(50)); // Let BLE send the notification
// Phase 2: recursively delete every entry in SD root
File root = SD.open("/");
if (!root || !root.isDirectory()) {
ESP_LOGE("FileCmd", "Cannot open SD root");
sendBinaryFileActionResult(8, false, 2);
return false;
}
bool allOk = true;
int deletedCount = 0;
File child = root.openNextFile();
while (child) {
// Copy path to local buffer before close — child.path()
// returns an internal pointer invalidated by close().
char childPathBuf[256];
strncpy(childPathBuf, child.path(), sizeof(childPathBuf) - 1);
childPathBuf[sizeof(childPathBuf) - 1] = '\0';
bool isDir = child.isDirectory();
child.close();
// Send progress notification for each item being deleted
char progressMsg[280];
snprintf(progressMsg, sizeof(progressMsg), "Deleting: %s", childPathBuf);
sendBinaryFileActionResult(8, true, 0xFF, progressMsg);
vTaskDelay(pdMS_TO_TICKS(20)); // Let BLE send + prevent WDT
if (isDir) {
if (!removeDirectoryRecursive(SD, childPathBuf)) {
ESP_LOGE("FileCmd", "Failed to remove dir: %s", childPathBuf);
allOk = false;
}
} else {
if (!SD.remove(childPathBuf)) {
ESP_LOGE("FileCmd", "Failed to remove file: %s", childPathBuf);
allOk = false;
}
}
deletedCount++;
// Yield to prevent watchdog timeout during format
vTaskDelay(1);
child = root.openNextFile();
}
root.close();
ESP_LOGI("FileCmd", "Deleted %d items from SD root", deletedCount);
// Phase 3: re-create default directory structure with progress and verification
static const char* defaultDirs[] = {
"/DATA",
"/DATA/RECORDS",
"/DATA/SIGNALS",
"/DATA/PRESETS",
"/DATA/TEMP"
};
bool creationSuccess = true;
for (int i = 0; i < 5; i++) {
char progressMsg[280];
snprintf(progressMsg, sizeof(progressMsg), "Creating: %s", defaultDirs[i]);
sendBinaryFileActionResult(8, true, 0xFF, progressMsg);
vTaskDelay(pdMS_TO_TICKS(20));
// Create directory and verify
if (!SD.mkdir(defaultDirs[i])) {
// mkdir returns false if directory already exists or creation failed
// Check if it exists to distinguish between these cases
if (!SD.exists(defaultDirs[i])) {
ESP_LOGE("FileCmd", "Failed to create directory: %s", defaultDirs[i]);
creationSuccess = false;
allOk = false;
} else {
ESP_LOGI("FileCmd", "Directory already exists: %s", defaultDirs[i]);
}
} else {
ESP_LOGI("FileCmd", "Created directory: %s", defaultDirs[i]);
}
}
ESP_LOGI("FileCmd", "SD card format %s", allOk ? "complete" : "completed with errors");
// Send final result (errorCode 0 = done successfully, 4 = done with errors)
sendBinaryFileActionResult(8, allOk, allOk ? 0 : 4);
return allOk;
}
static bool handleRenameFile(const uint8_t* data, size_t len) {
if (len < 3) {
+3 -3
View File
@@ -7,9 +7,9 @@
// - PATCH: Bug fixes, optimizations
// The app will compare these values for FW update matching.
#define FIRMWARE_VERSION_MAJOR 1
#define FIRMWARE_VERSION_MINOR 0
#define FIRMWARE_VERSION_PATCH 3
#define FIRMWARE_VERSION_STRING "1.0.3"
#define FIRMWARE_VERSION_MINOR 1
#define FIRMWARE_VERSION_PATCH 0
#define FIRMWARE_VERSION_STRING "1.1.0"
#define CC1101_NUM_MODULES 2
+3 -3
View File
@@ -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
@@ -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"
+2
View File
@@ -118,6 +118,7 @@
}
},
"unknownDevice": "Unknown Device",
"noSupportedDevicesShowAll": "No supported devices found. Select your device manually:",
"notConnectedToDevice": "Not connected to device",
"connectToDeviceToManageFiles": "Connect to a device to manage files",
@@ -1054,6 +1055,7 @@
"appSettings": "App Settings",
"appSettingsSubtitle": "Language, cache, permissions",
"rfSettings": "RF Settings",
"rfSettingsSubtitle": "Bruteforce, Radio & Scanner settings",
"syncedWithDevice": "Synced with device",
"localOnly": "Local only",
"bruteforceSettings": "Bruteforce Settings",
@@ -362,6 +362,12 @@ abstract class AppLocalizations {
/// **'Unknown Device'**
String get unknownDevice;
/// No description provided for @noSupportedDevicesShowAll.
///
/// In en, this message translates to:
/// **'No supported devices found. Select your device manually:'**
String get noSupportedDevicesShowAll;
/// No description provided for @notConnectedToDevice.
///
/// In en, this message translates to:
@@ -2654,6 +2660,12 @@ abstract class AppLocalizations {
/// **'RF Settings'**
String get rfSettings;
/// No description provided for @rfSettingsSubtitle.
///
/// In en, this message translates to:
/// **'Bruteforce, Radio & Scanner settings'**
String get rfSettingsSubtitle;
/// No description provided for @syncedWithDevice.
///
/// In en, this message translates to:
@@ -165,6 +165,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get unknownDevice => 'Unknown Device';
@override
String get noSupportedDevicesShowAll =>
'No supported devices found. Select your device manually:';
@override
String get notConnectedToDevice => 'Not connected to device';
@@ -1492,6 +1496,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rfSettings => 'RF Settings';
@override
String get rfSettingsSubtitle => 'Bruteforce, Radio & Scanner settings';
@override
String get syncedWithDevice => 'Synced with device';
@@ -167,6 +167,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get unknownDevice => 'Неизвестное устройство';
@override
String get noSupportedDevicesShowAll =>
'Устройства не распознаны. Выберите вручную:';
@override
String get notConnectedToDevice => 'Не подключено к устройству';
@@ -1499,6 +1503,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get rfSettings => 'Настройки RF';
@override
String get rfSettingsSubtitle => 'Брутфорс, радио и сканер';
@override
String get syncedWithDevice => 'Синхронизировано с устройством';
+2
View File
@@ -105,6 +105,7 @@
}
},
"unknownDevice": "Неизвестное устройство",
"noSupportedDevicesShowAll": "Устройства не распознаны. Выберите вручную:",
"notConnectedToDevice": "Не подключено к устройству",
"connectToDeviceToManageFiles": "Подключитесь к устройству для управления файлами",
@@ -1040,6 +1041,7 @@
"appSettings": "Настройки приложения",
"appSettingsSubtitle": "Язык, кэш, разрешения",
"rfSettings": "Настройки RF",
"rfSettingsSubtitle": "Брутфорс, радио и сканер",
"syncedWithDevice": "Синхронизировано с устройством",
"localOnly": "Только локально",
"bruteforceSettings": "Настройки брутфорса",
+97 -35
View File
@@ -104,7 +104,10 @@ class BleProvider extends ChangeNotifier {
int currentPathType = 5; // 0=/DATA/RECORDS, 1=/DATA/SIGNALS, 2=/DATA/PRESETS, 3=/DATA/TEMP, 4=INTERNAL (LittleFS), 5=SD Root
bool isLoadingFiles = false;
double fileListProgress = 0.0; // Progress for file list loading (0.0 to 1.0)
bool isFormattingSD = false; // True while SD format command is in progress
bool sdFormatSuccess = false; // Result of last SD format
String sdFormatProgress = ''; // Progress message during SD format (e.g. "Deleting: /somefile")
// Scanner state
List<DetectedSignal> detectedSignals = [];
Map<String, double> frequencySpectrum = {};
@@ -694,6 +697,7 @@ class BleProvider extends ChangeNotifier {
_resetConnectionState();
_log('info', 'Disconnected from device');
isLoadingFiles = false;
isFormattingSD = false;
// Clear cache on disconnect
_fileCache.clear();
@@ -1199,38 +1203,29 @@ class BleProvider extends ChangeNotifier {
// Methods for working with files
/// Reads file content from ESP
Future<String> readFileContent(String filePath, {String? basePath}) async {
Future<String> readFileContent(String filePath, {int? pathType}) async {
if (!isConnected) {
throw Exception('Device not connected');
}
// Determine pathType based on basePath
int pathType = 0; // Default to /DATA/RECORDS
String relativePath = filePath;
// Use provided pathType or default to RECORDS (0)
int effectivePathType = pathType ?? 0;
if (basePath == '/DATA/SIGNALS') {
pathType = 1;
} else if (basePath == '/DATA/PRESETS') {
pathType = 2;
} else if (basePath == '/DATA/TEMP') {
pathType = 3;
} else if (basePath == '/SDROOT') {
pathType = 5;
} else if (basePath == '/') {
pathType = 4; // LittleFS internal storage
// For pathType 0-3 (RECORDS, SIGNALS, PRESETS, TEMP), extract relative path
// because firmware adds /DATA/XXXX/ prefix automatically.
// For pathType 4-5 (LittleFS root, SD root), keep full absolute path.
String pathToUse = filePath;
if (effectivePathType >= 0 && effectivePathType < 4 && filePath.startsWith('/DATA/')) {
// Extract relative path from /DATA/RECORDS/... or /DATA/SIGNALS/...
final parts = filePath.split('/');
if (parts.length > 3) {
pathToUse = parts.sublist(3).join('/');
} else {
pathToUse = parts.last;
}
}
// Remove /DATA/ prefix if present (shouldn't be, but handle it)
if (relativePath.startsWith('/DATA/')) {
relativePath = relativePath.substring(6); // Remove '/DATA/'
}
// Remove leading slash if present
if (relativePath.startsWith('/')) {
relativePath = relativePath.substring(1);
}
_log('INFO', 'Reading file content: $relativePath (pathType: $pathType, basePath: $basePath)');
_log('INFO', 'Reading file content: $pathToUse (pathType: $effectivePathType)');
// Set loading flag
isLoadingFileContent = true;
@@ -1243,9 +1238,9 @@ class BleProvider extends ChangeNotifier {
final completer = Completer<String>();
_pendingFileReadCompleter = completer;
// Use binary command with path type - pass full relative path including subdirectory
final command = FirmwareBinaryProtocol.createLoadFileDataCommand(relativePath, pathType: pathType);
_log('INFO', 'Sending binary command for file: $relativePath (pathType: $pathType, command length: ${command.length})');
// Use binary command with path type
final command = FirmwareBinaryProtocol.createLoadFileDataCommand(pathToUse, pathType: effectivePathType);
_log('INFO', 'Sending binary command for file: $pathToUse (pathType: $effectivePathType, command length: ${command.length})');
// Send binary file read command
await sendBinaryCommand(command);
@@ -1287,10 +1282,8 @@ class BleProvider extends ChangeNotifier {
_log('INFO', 'Downloading file: $filePath');
// Using readFileContent, which already uses binary protocol
// Determine pathType based on current path
try {
final basePath = _getBasePathForPathType(currentPathType);
final content = await readFileContent(filePath, basePath: basePath);
final content = await readFileContent(filePath, pathType: currentPathType);
// Call progress callback if available
onProgress?.call(1.0);
@@ -2545,6 +2538,26 @@ class BleProvider extends ChangeNotifier {
_handleFileUploadResponse(responseData);
}
// Handle format-sd response
if (responseData.containsKey('action') && responseData['action'] == 'format-sd') {
print('Format SD response received: $responseData');
// Check if this is a progress update (errorCode 0xFF) or final result
if (responseData['isProgress'] == true) {
// Progress update — update message but keep formatting state
sdFormatProgress = responseData['progressMessage']?.toString() ?? '';
_log('info', 'SD format progress: $sdFormatProgress');
notifyListeners();
} else {
// Final result
isFormattingSD = false;
sdFormatProgress = '';
sdFormatSuccess = responseData['success'] == true;
_log('info', 'SD format ${sdFormatSuccess ? 'succeeded' : 'failed'}');
notifyListeners();
}
}
// Handle copy response
if (responseData.containsKey('action') && responseData['action'] == 'copy') {
print('Copy response received: $responseData');
@@ -2998,10 +3011,11 @@ class BleProvider extends ChangeNotifier {
// Use provided pathType or current
int effectivePathType = pathType ?? currentPathType;
// Use the filePath as-is (it should be a relative path like "folder/file.sub" or just "file.sub")
// Only extract filename if it's an absolute path starting with /DATA/
// For pathType 0-3 (RECORDS, SIGNALS, PRESETS, TEMP), extract relative path
// because firmware adds /DATA/XXXX/ prefix automatically.
// For pathType 4-5 (LittleFS root, SD root), keep full absolute path.
String pathToUse = filePath;
if (filePath.startsWith('/DATA/')) {
if (effectivePathType >= 0 && effectivePathType < 4 && filePath.startsWith('/DATA/')) {
// Extract relative path from /DATA/RECORDS/... or /DATA/SIGNALS/...
final parts = filePath.split('/');
if (parts.length > 3) {
@@ -3233,6 +3247,25 @@ class BleProvider extends ChangeNotifier {
}
}
/// Format SD card: recursively delete all contents and re-create defaults.
Future<bool> formatSDCard() async {
if (!isConnected || txCharacteristic == null) return false;
try {
final cmd = FirmwareBinaryProtocol.createFormatSDCommand();
isFormattingSD = true;
sdFormatSuccess = false;
notifyListeners();
await sendBinaryCommand(cmd);
_log('warning', 'Format SD command sent');
return true;
} catch (e) {
isFormattingSD = false;
notifyListeners();
_log('error', 'Failed to send format SD: $e');
return false;
}
}
/// Start a bruter attack with the given menu choice (1-33)
Future<void> sendBruterCommand(int menuChoice) async {
if (!isConnected || txCharacteristic == null) {
@@ -3255,6 +3288,35 @@ class BleProvider extends ChangeNotifier {
notifyListeners();
}
/// Start a custom De Bruijn attack with per-protocol timing and frequency.
/// Uses firmware sub-command 0xFD to pass exact Te, ratio, bits, and
/// frequency instead of relying on hardcoded De Bruijn menus.
Future<void> 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<void> sendBruterCancelCommand() async {
if (!isConnected || txCharacteristic == null) {
@@ -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);
}
}
@@ -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;
+68 -54
View File
@@ -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<BruterProtocol> 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<int, int> _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<BruteScreen> {
final settingsProvider = Provider.of<SettingsProvider>(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<bool>(
@@ -811,7 +815,17 @@ class _BruteScreenState extends State<BruteScreen> {
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<NotificationProvider>(context, listen: false);
+4 -26
View File
@@ -82,36 +82,14 @@ class _FileViewerScreenState extends State<FileViewerScreen>
// 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
+3 -3
View File
@@ -48,7 +48,7 @@ class _RecordScreenState extends State<RecordScreen> with TickerProviderStateMix
// Files from current recording session
final List<String> _currentSessionFiles = [];
// Flags for tracking changes
final List<bool> _configsChanged = [];
@@ -95,7 +95,7 @@ class _RecordScreenState extends State<RecordScreen> with TickerProviderStateMix
print('_onRecordedFilesChanged called');
final runtimeFiles = _bleProvider?.recordedRuntimeFiles ?? [];
print('Runtime files: $runtimeFiles');
// Add new files to local recorded files list
for (final file in runtimeFiles) {
// Extract filename from object
@@ -931,7 +931,7 @@ class _RecordScreenState extends State<RecordScreen> with TickerProviderStateMix
),
const SizedBox(height: 12),
// File list only for Recording
if (selectedAction == ModuleAction.recording)
_buildModuleFilesList(moduleIndex),
+579 -166
View File
@@ -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<SettingsScreen> {
/// 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<SettingsScreen> {
),
),
subtitle: Text(
bleProvider.settingsSynced ? AppLocalizations.of(context)!.syncedWithDevice : AppLocalizations.of(context)!.localOnly,
style: TextStyle(
color: bleProvider.settingsSynced
? AppColors.success
: AppColors.secondaryText,
AppLocalizations.of(context)!.rfSettingsSubtitle,
style: const TextStyle(
color: AppColors.secondaryText,
fontSize: 12,
),
),
@@ -1027,73 +1035,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Widget _buildScannerSettings(BuildContext context, BleProvider bleProvider) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.signal_cellular_alt,
size: 18, color: AppColors.secondaryText),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.rssiThreshold(bleProvider.scannerRssi),
style: const TextStyle(
color: AppColors.primaryText,
fontSize: 13,
fontWeight: FontWeight.w500,
),
// ── RSSI threshold ──
Row(
children: [
const Icon(Icons.signal_cellular_alt,
size: 18, color: AppColors.secondaryText),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.rssiThreshold(bleProvider.scannerRssi),
style: const TextStyle(
color: AppColors.primaryText,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
Text(
AppLocalizations.of(context)!.minSignalStrengthDesc,
style: const TextStyle(
color: AppColors.secondaryText, fontSize: 11),
),
],
),
Text(
AppLocalizations.of(context)!.minSignalStrengthDesc,
style: const TextStyle(
color: AppColors.secondaryText, fontSize: 11),
),
],
),
),
],
),
Slider(
value: bleProvider.scannerRssi.toDouble(),
min: -120,
max: -20,
divisions: 100,
label: '${bleProvider.scannerRssi} dBm',
activeColor: AppColors.searching,
onChanged: (value) {
bleProvider.sendSettingsToDevice(scannerRssi: value.round());
},
),
Wrap(
spacing: 8,
children: [-90, -80, -70, -60, -50].map((rssi) {
final isSelected = bleProvider.scannerRssi == rssi;
return ChoiceChip(
label: Text('$rssi'),
selected: isSelected,
onSelected: (_) {
bleProvider.sendSettingsToDevice(scannerRssi: rssi);
},
selectedColor:
AppColors.searching.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected
? AppColors.searching
: AppColors.secondaryText,
fontSize: 11,
),
visualDensity: VisualDensity.compact,
);
}).toList(),
),
],
),
Slider(
value: bleProvider.scannerRssi.toDouble(),
min: -120,
max: -20,
divisions: 100,
label: '${bleProvider.scannerRssi} dBm',
activeColor: AppColors.searching,
onChanged: (value) {
bleProvider.sendSettingsToDevice(scannerRssi: value.round());
},
),
Wrap(
spacing: 8,
children: [-90, -80, -70, -60, -50].map((rssi) {
final isSelected = bleProvider.scannerRssi == rssi;
return ChoiceChip(
label: Text('$rssi'),
selected: isSelected,
onSelected: (_) {
bleProvider.sendSettingsToDevice(scannerRssi: rssi);
},
selectedColor:
AppColors.searching.withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected
? AppColors.searching
: AppColors.secondaryText,
fontSize: 11,
),
visualDensity: VisualDensity.compact,
);
}).toList(),
),
],
),
);
},
);
}
@@ -1178,7 +1192,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ChoiceChip(
label: Text(_nrfPaLabel(lvl)),
selected: isSelected,
onSelected: (_) => settingsProvider.setNrfPaLevel(lvl),
onSelected: (_) {
settingsProvider.setNrfPaLevel(lvl);
_sendNrfSettings(context, bleProvider, settingsProvider);
},
selectedColor: const Color(0xFF00BCD4).withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? const Color(0xFF00BCD4) : AppColors.secondaryText,
@@ -1220,7 +1237,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
return ChoiceChip(
label: Text(_nrfDataRateLabel(dr)),
selected: isSelected,
onSelected: (_) => settingsProvider.setNrfDataRate(dr),
onSelected: (_) {
settingsProvider.setNrfDataRate(dr);
_sendNrfSettings(context, bleProvider, settingsProvider);
},
selectedColor: const Color(0xFF00BCD4).withValues(alpha: 0.2),
labelStyle: TextStyle(
color: isSelected ? const Color(0xFF00BCD4) : AppColors.secondaryText,
@@ -1271,6 +1291,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
activeColor: const Color(0xFF00BCD4),
onChanged: (value) {
settingsProvider.setNrfChannel(value.round());
_debouncedSendNrfSettings(context, bleProvider, settingsProvider);
},
),
@@ -1313,38 +1334,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
activeColor: const Color(0xFF00BCD4),
onChanged: (value) {
settingsProvider.setNrfAutoRetransmit(value.round());
_debouncedSendNrfSettings(context, bleProvider, settingsProvider);
},
),
const SizedBox(height: 12),
// Send to device button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: bleProvider.isConnected
? () => _sendNrfSettings(context, bleProvider, settingsProvider)
: null,
icon: const Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.sendToDevice),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00BCD4),
foregroundColor: AppColors.primaryBackground,
disabledBackgroundColor:
const Color(0xFF00BCD4).withValues(alpha: 0.3),
disabledForegroundColor: AppColors.disabledText,
),
),
),
if (!bleProvider.isConnected)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.connectToDeviceToApply,
style: const TextStyle(
color: AppColors.secondaryText, fontSize: 11),
),
),
],
),
);
@@ -1375,8 +1367,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// Debounced auto-send for nRF slider changes (500ms).
void _debouncedSendNrfSettings(BuildContext context, BleProvider bleProvider,
SettingsProvider settingsProvider) {
_nrfDebounceTimer?.cancel();
_nrfDebounceTimer = Timer(const Duration(milliseconds: 500), () {
_sendNrfSettings(context, bleProvider, settingsProvider);
});
}
void _sendNrfSettings(BuildContext context, BleProvider bleProvider,
SettingsProvider settingsProvider) async {
if (!bleProvider.isConnected) return;
try {
// Send NRF settings as a settings sync command
// Using MSG_SETTINGS_UPDATE (0xC1) with extended NRF payload
@@ -1862,6 +1864,59 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
// ── Format SD Card ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.warning.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.sd_card_alert, size: 18, color: AppColors.warning),
SizedBox(width: 8),
Text(
'Format SD Card',
style: TextStyle(
color: AppColors.warning,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
const Text(
'Delete all files and folders from the SD card and re-create the default directory structure. This cannot be undone.',
style: TextStyle(color: AppColors.secondaryText, fontSize: 12),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: bleProvider.isConnected
? () => _showFormatSDDialog(context, bleProvider)
: null,
icon: const Icon(Icons.sd_card),
label: const Text('Format SD Card'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.warning,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.warning.withValues(alpha: 0.3),
),
),
),
],
),
),
const SizedBox(height: 24),
// ── Factory Reset ──
Container(
width: double.infinity,
@@ -2049,6 +2104,158 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
/// Show Format SD Card confirmation dialog, then a non-dismissible
/// progress dialog that auto-closes when the firmware sends the result.
void _showFormatSDDialog(BuildContext context, BleProvider bleProvider) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.secondaryBackground,
title: const Row(
children: [
Icon(Icons.sd_card_alert, color: AppColors.warning, size: 24),
SizedBox(width: 10),
Text('Format SD Card', style: TextStyle(color: AppColors.warning)),
],
),
content: const Text(
'Are you sure you want to delete ALL files from the SD card?\n\n'
'This will:\n'
' - Delete all recordings, signals, and presets\n'
' - Delete all uploaded .sub files\n'
' - Re-create the default directory structure\n\n'
'This action cannot be undone.',
style: TextStyle(color: AppColors.primaryText, fontSize: 14),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('No', style: TextStyle(color: AppColors.secondaryText, fontSize: 16)),
),
ElevatedButton(
onPressed: () async {
Navigator.of(ctx).pop();
final sent = await bleProvider.formatSDCard();
if (!context.mounted) return;
if (!sent) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to send format SD command.'),
backgroundColor: AppColors.error,
),
);
return;
}
// Show non-dismissible progress dialog; auto-closes on result
_showSDFormatProgressDialog(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.warning,
foregroundColor: Colors.white,
),
child: const Text('Yes, Format', style: TextStyle(fontSize: 16)),
),
],
),
);
}
/// Non-dismissible progress dialog that listens to BleProvider.isFormattingSD
/// and closes automatically when the firmware result arrives.
void _showSDFormatProgressDialog(BuildContext context) {
bool closed = false;
bool hasReceivedResponse = false;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: Consumer<BleProvider>(
builder: (context, ble, _) {
// Mark that we've started receiving progress updates
if (ble.isFormattingSD && ble.sdFormatProgress.isNotEmpty) {
hasReceivedResponse = true;
}
// Only close if we've received at least one progress update and formatting is done
// This prevents premature closure if firmware responds instantly with error
if (!ble.isFormattingSD && !closed && hasReceivedResponse) {
closed = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (ctx.mounted) Navigator.of(ctx).pop();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ble.sdFormatSuccess
? 'SD card formatted successfully.'
: 'SD card format failed.'),
backgroundColor: ble.sdFormatSuccess
? AppColors.success
: AppColors.error,
duration: const Duration(seconds: 3),
),
);
}
});
}
// Show warning if formatting takes too long without progress
if (ble.isFormattingSD && !hasReceivedResponse) {
// Start a timeout to close dialog if no response after 10 seconds
Future.delayed(const Duration(seconds: 10), () {
if (ctx.mounted && !closed && !hasReceivedResponse) {
closed = true;
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Format timeout: No response from device.'),
backgroundColor: AppColors.error,
duration: Duration(seconds: 4),
),
);
}
});
}
return AlertDialog(
backgroundColor: AppColors.secondaryBackground,
title: const Row(
children: [
Icon(Icons.sd_card, color: AppColors.warning, size: 24),
SizedBox(width: 10),
Text('Formatting...', style: TextStyle(color: AppColors.warning)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
ble.sdFormatProgress.isNotEmpty
? ble.sdFormatProgress
: 'Formatting SD card, please wait.',
style: const TextStyle(color: AppColors.primaryText),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
const Text(
'Do not disconnect the device.',
style: TextStyle(color: AppColors.secondaryText, fontSize: 12),
textAlign: TextAlign.center,
),
],
),
);
},
),
),
);
}
/// Build HW Buttons configuration section.
Widget _buildHwButtonsSection(BuildContext context, BleProvider bleProvider) {
return Card(
@@ -2124,9 +2331,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
action: settingsProvider.button1Action,
color: AppColors.primaryAccent,
replayPath: settingsProvider.button1ReplayPath,
onPickReplayFile: () => _pickReplaySubFile(context, settingsProvider, 1),
onPickReplayFile: () async {
await _pickReplaySubFile(context, settingsProvider, 1);
_sendButtonConfig(context, bleProvider, settingsProvider);
},
onChanged: (action) {
settingsProvider.setButton1Action(action);
_sendButtonConfig(context, bleProvider, settingsProvider);
},
),
@@ -2138,42 +2349,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
action: settingsProvider.button2Action,
color: AppColors.warning,
replayPath: settingsProvider.button2ReplayPath,
onPickReplayFile: () => _pickReplaySubFile(context, settingsProvider, 2),
onPickReplayFile: () async {
await _pickReplaySubFile(context, settingsProvider, 2);
_sendButtonConfig(context, bleProvider, settingsProvider);
},
onChanged: (action) {
settingsProvider.setButton2Action(action);
_sendButtonConfig(context, bleProvider, settingsProvider);
},
),
const SizedBox(height: 16),
// Send to device button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: bleProvider.isConnected
? () => _sendButtonConfig(
context, bleProvider, settingsProvider)
: null,
icon: const Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.sendToDevice),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.info,
foregroundColor: AppColors.primaryBackground,
disabledBackgroundColor:
AppColors.info.withValues(alpha: 0.3),
disabledForegroundColor: AppColors.disabledText,
),
),
),
if (!bleProvider.isConnected)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.connectToDeviceToApply,
style: const TextStyle(
color: AppColors.secondaryText, fontSize: 11),
),
),
],
),
);
@@ -2261,7 +2445,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Text(
replayPath == null || replayPath.isEmpty
? 'No .sub file selected'
: replayPath,
: replayPath.split('/').last,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.secondaryText, fontSize: 11),
@@ -3150,34 +3334,126 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
bool _hasError = false;
String _errorMessage = '';
// Pause / Resume state
bool _isPaused = false;
bool _pauseRequested = false;
bool _checkingResume = true;
bool _hasResumableSession = false;
Set<String> _completedPaths = {};
@override
void initState() {
super.initState();
_startCloning();
_checkForResumableSession();
}
Future<void> _startCloning() async {
/// On open, check if a previous session can be resumed.
Future<void> _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<void> _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<SubFileEntry> subFiles;
final subFiles = await FlipperSubDbService.downloadAndExtract(
onProgress: (phase, detail, fraction) {
if (mounted) {
setState(() {
_phase = phase;
_statusText = detail;
_progress = fraction;
});
}
},
);
if (resume) {
// ── Resume path: re-extract from cached ZIP ──
setState(() {
_phase = 'extract';
_statusText = 'Loading cached database...';
_progress = 0.0;
});
final cachedZip = await FlipperSubDbService.loadCachedZip();
if (cachedZip == null) {
setState(() {
_isDone = true;
_hasError = true;
_errorMessage = 'Cached ZIP not found. Please start a fresh clone.';
});
WakelockPlus.disable();
return;
}
// Load previously completed files
_completedPaths = await FlipperSubDbService.loadCompletedFiles();
subFiles = FlipperSubDbService.extractFromBytes(
cachedZip,
onProgress: (phase, detail, fraction) {
if (mounted) {
setState(() {
_phase = phase;
_statusText = detail;
_progress = fraction;
});
}
},
);
} else {
// ── Fresh start: download and extract ──
_completedPaths = {};
await FlipperSubDbService.clearCache();
setState(() {
_phase = 'download';
_statusText = 'Downloading SubGHz database from GitHub...';
_progress = 0.0;
});
subFiles = await FlipperSubDbService.downloadAndExtract(
onProgress: (phase, detail, fraction) {
if (mounted) {
setState(() {
_phase = phase;
_statusText = detail;
_progress = fraction;
});
}
},
onZipDownloaded: (zipBytes) async {
// Cache the raw ZIP for potential resume
await FlipperSubDbService.cacheZipBytes(zipBytes);
},
);
}
if (subFiles.isEmpty) {
setState(() {
@@ -3185,16 +3461,18 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
_hasError = true;
_errorMessage = 'No .sub files found in the repository';
});
WakelockPlus.disable();
return;
}
_totalFiles = subFiles.length;
_uploadedFiles = _completedPaths.length;
// Phase 3: Create base directory on SDCard
setState(() {
_phase = 'upload';
_statusText = 'Creating "SUB Files" folder on SDCard...';
_progress = 0.0;
_progress = _completedPaths.length / _totalFiles;
});
await widget.bleProvider.createDirectory(
@@ -3208,7 +3486,8 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
if (parts.length > 1) {
// Build cumulative subdir paths
for (int i = 1; i < parts.length; i++) {
final subdir = '${FlipperSubDbService.sdTargetFolder}/${parts.sublist(0, i).join('/')}';
final subdir =
'${FlipperSubDbService.sdTargetFolder}/${parts.sublist(0, i).join('/')}';
subdirs.add(subdir);
}
}
@@ -3217,6 +3496,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
// Create subdirectories (sorted so parents come first)
final sortedDirs = subdirs.toList()..sort();
for (final dir in sortedDirs) {
if (_pauseRequested) break;
setState(() {
_statusText = 'Creating folder: $dir';
});
@@ -3230,15 +3510,36 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
// Phase 4: Upload files one at a time
for (int i = 0; i < subFiles.length; i++) {
// ── Check for pause after each file ──
if (_pauseRequested) {
await FlipperSubDbService.saveProgress(_completedPaths);
if (mounted) {
setState(() {
_isPaused = true;
_pauseRequested = false;
_statusText =
'Paused $_uploadedFiles / $_totalFiles files uploaded. You can close and resume later.';
});
}
WakelockPlus.disable();
return;
}
final file = subFiles[i];
// Skip already-uploaded files (from a previous session)
if (_completedPaths.contains(file.relativePath)) {
continue;
}
final targetPath =
'${FlipperSubDbService.sdTargetFolder}/${file.relativePath}';
if (mounted) {
setState(() {
_uploadedFiles = i;
_statusText = 'Uploading (${i + 1}/$_totalFiles): ${file.relativePath}';
_progress = i / _totalFiles;
_statusText =
'Uploading (${_uploadedFiles + 1}/$_totalFiles): ${file.relativePath}';
_progress = _uploadedFiles / _totalFiles;
});
}
@@ -3248,6 +3549,14 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
targetPath,
pathType: 5,
);
_completedPaths.add(file.relativePath);
_uploadedFiles = _completedPaths.length;
// Persist progress every 10 files for safety
if (_uploadedFiles % 10 == 0) {
await FlipperSubDbService.saveProgress(_completedPaths);
}
// Pace uploads to avoid BLE congestion
await Future.delayed(const Duration(milliseconds: 150));
} catch (e) {
@@ -3257,6 +3566,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
}
}
// All files processed clean up cache
await FlipperSubDbService.clearCache();
if (mounted) {
setState(() {
_isDone = true;
@@ -3270,6 +3582,10 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
// Release wakelock after successful completion
WakelockPlus.disable();
} catch (e) {
// On error, save progress so user can resume later
if (_completedPaths.isNotEmpty) {
await FlipperSubDbService.saveProgress(_completedPaths);
}
// Release wakelock on error
WakelockPlus.disable();
if (mounted) {
@@ -3285,23 +3601,87 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
@override
Widget build(BuildContext context) {
// While checking for resumable session, show a spinner
if (_checkingResume) {
return AlertDialog(
title: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
const Text('Clone SubGHz DB', style: TextStyle(fontSize: 16)),
],
),
content: const Text('Checking for previous session...'),
);
}
// If a resumable session was found, show Resume / Start Fresh options
if (_hasResumableSession && !_isPaused && _phase == 'init') {
return AlertDialog(
title: Row(
children: [
Icon(Icons.replay, color: Colors.orange),
const SizedBox(width: 8),
const Expanded(
child: Text('Resume Clone?', style: TextStyle(fontSize: 16)),
),
],
),
content: Text(
'${_completedPaths.length} files were uploaded in a previous session.\n'
'Resume where you left off, or start fresh?',
style: TextStyle(color: AppColors.primaryText, fontSize: 13),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
OutlinedButton(
onPressed: () {
setState(() {
_hasResumableSession = false;
_completedPaths = {};
});
_startCloning(resume: false);
},
child: const Text('Start Fresh'),
),
ElevatedButton(
onPressed: _resumeCloning,
child: const Text('Resume'),
),
],
);
}
return AlertDialog(
title: Row(
children: [
Icon(
_isDone
? (_hasError ? Icons.error_outline : Icons.check_circle)
: Icons.cloud_download,
: _isPaused
? Icons.pause_circle
: Icons.cloud_download,
color: _isDone
? (_hasError ? AppColors.error : AppColors.success)
: Colors.orange,
: _isPaused
? Colors.orange
: Colors.orange,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isDone
? (_hasError ? 'Clone Failed' : 'Clone Complete')
: 'Cloning SubGHz Database',
: _isPaused
? 'Clone Paused'
: 'Cloning SubGHz Database',
style: const TextStyle(fontSize: 16),
),
),
@@ -3312,7 +3692,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Phase indicator
if (!_isDone) ...[
if (!_isDone && !_isPaused) ...[
Row(
children: [
SizedBox(
@@ -3364,7 +3744,9 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
? AppColors.error
: _isDone
? AppColors.success
: Colors.orange,
: _isPaused
? Colors.orange.shade300
: Colors.orange,
),
minHeight: 8,
),
@@ -3372,7 +3754,7 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
const SizedBox(height: 8),
// File counter (during upload phase)
if (_phase == 'upload' || _isDone)
if (_phase == 'upload' || _isDone || _isPaused)
Text(
'$_uploadedFiles / $_totalFiles files'
'${_failedFiles > 0 ? ' ($_failedFiles failed)' : ''}',
@@ -3402,10 +3784,41 @@ class _SubGhzCloneDialogState extends State<_SubGhzCloneDialog> {
],
),
actions: [
if (_isDone)
ElevatedButton(
// Close button when done, paused, or errored
if (_isDone || _isPaused)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close'),
child: const Text('Close'),
),
// Resume button when paused
if (_isPaused)
ElevatedButton.icon(
onPressed: _resumeCloning,
icon: const Icon(Icons.play_arrow, size: 18),
label: const Text('Resume'),
),
// Pause button during upload (only while actively uploading)
if (!_isDone && !_isPaused && _phase == 'upload' && !_pauseRequested)
ElevatedButton.icon(
onPressed: _pauseCloning,
icon: const Icon(Icons.pause, size: 18),
label: const Text('Pause'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
),
// Show "Pausing..." indicator when pause is requested
if (_pauseRequested && !_isPaused)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
);
@@ -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<SignalScannerScreen>
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<SignalScannerScreen>
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<SignalScannerScreen>
final bleProvider = Provider.of<BleProvider>(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<SignalScannerScreen>
_buildControlPanel(),
_buildModeSwitch(),
Expanded(
child: _isListMode
child: _viewMode == 0
? _buildListView(bleProvider)
: _buildSpectrumView(bleProvider),
),
@@ -313,7 +312,7 @@ class _SignalScannerScreenState extends State<SignalScannerScreen>
),
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<SignalScannerScreen>
),
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<SignalScannerScreen>
style: TextStyle(
color: isSelected ? Colors.black : AppColors.secondaryText,
fontWeight: FontWeight.bold,
fontSize: 13,
fontSize: 12,
),
),
),
@@ -430,16 +430,21 @@ class BinaryFileActionResult {
case 5: return 'move';
case 6: return 'tree';
case 7: return 'load';
case 8: return 'format-sd';
default: return 'unknown';
}
}
Map<String, dynamic> 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,
};
}
@@ -33,6 +33,7 @@ class FlipperSubDbService {
/// - phase "extract": extracting .sub files from ZIP
static Future<List<SubFileEntry>> downloadAndExtract({
void Function(String phase, String detail, double fraction)? onProgress,
Future<void> Function(List<int> 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 = <SubFileEntry>[];
// 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<Directory> _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<bool> 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<Set<String>> 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<String>.from(json['completed'] as List);
} catch (_) {
return {};
}
}
/// Save the set of completed file paths (call after each successful upload).
static Future<void> saveProgress(Set<String> 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<void> cacheZipBytes(List<int> zipBytes) async {
final dir = await _cacheDir();
final file = File('${dir.path}/$_cachedZipFileName');
await file.writeAsBytes(zipBytes);
}
/// Load cached ZIP bytes for resume.
static Future<List<int>?> 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<void> 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<SubFileEntry> extractFromBytes(
List<int> 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 = <SubFileEntry>[];
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<int>),
));
}
}
}
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;
}
}
+40 -13
View File
@@ -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<String, dynamic>? bestRelease;
@@ -154,8 +154,8 @@ class UpdateService {
static Future<AppUpdate?> 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<String, dynamic>? 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<Map<String, dynamic>?> fetchChangelog() async {
static Future<Map<String, dynamic>?> fetchChangelog(List<dynamic> 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<String, dynamic>;
}
} 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<String, dynamic>;
}
return null;
} catch (_) {
return null;
// Ignore fallback errors
}
return null;
}
/// Extract structured changes list for a specific version from changelog.json.
@@ -169,18 +169,26 @@ class QuickConnectWidget extends StatelessWidget {
} else {
// No saved devices - show scan button or scan results
List<dynamic> 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<dynamic> 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,
),
@@ -96,7 +96,7 @@ class _StatusBarWidgetState extends State<StatusBarWidget> {
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,
+1 -1
View File
@@ -1,7 +1,7 @@
name: evilcrow_rf2_controller
description: EvilCrow RF — Mobile app for controlling RF devices via BLE
publish_to: 'none'
version: 1.0.2+15
version: 1.1.0+25
environment:
sdk: '>=3.0.0 <4.0.0'
+1 -9
View File
@@ -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
+3 -2
View File
@@ -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);
+24 -5
View File
@@ -174,6 +174,7 @@ void cc1101WorkerSignalDetectedHandler(const CC1101DetectedSignal& signal)
struct DeviceConfig
{
bool powerBlink;
bool sdCardMounted; // True if SD card is available
} deviceConfig;
// REMOVED - old state machine task (all code deleted, now using CC1101Worker)
@@ -621,13 +622,31 @@ void setup()
ESP_LOGD(TAG, "Starting setup...");
// --- SD Card initialization (non-blocking) ---
sdspi.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_SS);
if (!SD.begin(SD_SS, sdspi)) {
ESP_LOGE(TAG, "Card Mount Failed");
return;
}
ESP_LOGW(TAG, "SD card not mounted — running in LittleFS-only mode");
deviceConfig.sdCardMounted = false;
// Continue setup without SD — features requiring SD will be limited
} else {
deviceConfig.sdCardMounted = true;
ESP_LOGI(TAG, "SD card initialized.");
ESP_LOGD(TAG, "SD card initialized.");
// Ensure default directory structure exists on SD
static const char* defaultDirs[] = {
"/DATA",
"/DATA/RECORDS",
"/DATA/SIGNALS",
"/DATA/PRESETS",
"/DATA/TEMP"
};
for (int i = 0; i < 5; i++) {
if (!SD.exists(defaultDirs[i])) {
SD.mkdir(defaultDirs[i]);
ESP_LOGI(TAG, "Created missing directory: %s", defaultDirs[i]);
}
}
}
ControllerAdapter::initializeQueue();
@@ -716,7 +735,7 @@ void setup()
#endif
// Notification sender on Core 0 (near BLE stack for lower latency)
xTaskCreatePinnedToCore(ClientsManager::processMessageQueue, "SendNotifications", 2560, NULL, 1, NULL, 0); // 2.5KB on Core 0
xTaskCreatePinnedToCore(ClientsManager::processMessageQueue, "SendNotifications", 4096, NULL, 1, NULL, 0); // 4KB on Core 0
ESP_LOGD(TAG, "SendNotifications task created.");
// Create time synchronization task (updates deviceTime every second)
+26 -3
View File
@@ -1,5 +1,6 @@
#include "CC1101_Worker.h"
#include <sstream> // Moved here from CC1101_Worker.h — only used in this .cpp
#include <LittleFS.h> // 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;