diff --git a/.github/workflows/build_parallel.yml b/.github/workflows/build_parallel.yml index 771275dfd..401d2fa9c 100644 --- a/.github/workflows/build_parallel.yml +++ b/.github/workflows/build_parallel.yml @@ -2,16 +2,23 @@ name: Build and Push Parallel on: workflow_dispatch: - push: - branches: - - master - tags: - - "*" + inputs: + create_release: + description: "Create the draft release after the full matrix passes" + required: true + type: boolean + default: true pull_request: + types: [labeled] + +concurrency: + group: full-hardware-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: compile_sketch: name: build ${{ matrix.board.name }} + if: ${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'full-hardware-ci' }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -44,6 +51,8 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install Arduino CLI run: | @@ -286,7 +295,7 @@ jobs: name: Create Release runs-on: ubuntu-latest needs: [compile_sketch] - if: ${{ github.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.create_release }} permissions: contents: write steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..1f0b0baec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Development workflow + +ESP32 Marauder supports many hardware targets, so the full firmware matrix is +reserved for final validation and releases. It is not an iteration loop. + +## Iteration + +1. Work on a local branch without opening a pull request. +2. Run native unit tests and static checks locally. +3. Compile only the hardware targets requested for physical testing. +4. Clearly identify locally compiled test binaries by target and exact commit. +5. Revise locally until the requested hardware behavior is accepted. + +## Pull request gate + +Push the branch and open a pull request only when the change is ready for +review. Pull requests run the comparatively inexpensive native unit-test +workflow. Additional commits should be batched instead of pushed after every +small edit. + +## Full-matrix gate + +When a pull request is merge-ready, a maintainer applies the +`full-hardware-ci` label. That label runs **Build and Push Parallel** against the +pull request's exact current head. Any later source push invalidates that +evidence, so the label must be removed and applied again to validate the new +head before merge. + +The workflow can also be dispatched manually against a final candidate branch +with `create_release` disabled. Do not use the full matrix for ordinary +iteration or to obtain one or two test binaries. + +Untrusted fork code must not receive protected-build credentials. Private V8, +Mini V3, and Dual Mini C5 validation is performed only after review, from an +exact-head branch controlled by the maintainers. + +For a firmware release, dispatch the same workflow against the exact release +source commit with `create_release` enabled. The workflow remains the +authoritative producer of the draft release and its public binaries. diff --git a/esp32_marauder/CommandLine.cpp b/esp32_marauder/CommandLine.cpp index 30a242829..4d6629e2b 100644 --- a/esp32_marauder/CommandLine.cpp +++ b/esp32_marauder/CommandLine.cpp @@ -1,5 +1,48 @@ #include "CommandLine.h" +// GCOVR_EXCL_START -- serial protocol output depends on Arduino Serial. +namespace { + bool validTransactionId(const String& transaction_id) { + if (transaction_id.length() == 0 || transaction_id.length() > 40) + return false; + + for (size_t i = 0; i < transaction_id.length(); i++) { + char c = transaction_id.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || c == '-' || c == '_' || c == '.')) + return false; + } + return true; + } + + void machineResult( + const String& transaction_id, + const char* command, + const char* status, + const char* code, + size_t files = 0, + size_t bytes = 0, + bool rebooting = false + ) { + Serial.printf( + "@MARAUDER:{\"protocol\":1,\"tx\":\"%s\",\"command\":\"%s\"," + "\"status\":\"%s\",\"code\":\"%s\",\"files\":%u,\"bytes\":%u," + "\"rebooting\":%s}\n", + transaction_id.c_str(), command, status, code, + (unsigned)files, (unsigned)bytes, rebooting ? "true" : "false" + ); + } + + const char* storageErrorCode(uint8_t error, const char* fallback) { + if (error == 1) + return "SD_NOT_READY"; + if (error == 2) + return "BACKUP_NOT_FOUND"; + return fallback; + } +} +// GCOVR_EXCL_STOP + // Brightness functions defined in esp32_marauder.ino #ifndef HAS_MINI_SCREEN extern void brightnessCycle(); @@ -7,7 +50,9 @@ #endif void CommandLine::RunSetup() { - Serial.println(this->ascii_art); + #ifndef MARAUDER_V8 + Serial.println(this->ascii_art); + #endif Serial.println(F("\n\n--------------------------------\n")); Serial.println(F(" ESP32 Marauder \n")); @@ -231,6 +276,14 @@ void CommandLine::runCommand(String input) { Serial.println(HELP_REBOOT_CMD); Serial.println(HELP_UPDATE_CMD_A); Serial.println(HELP_LS_CMD); + // GCOVR_EXCL_START -- hardware-only command help entry. + Serial.println(HELP_PROTOCOL_INFO_CMD); + #ifdef HAS_SD + Serial.println(HELP_BACKUP_SPIFFS_CMD); + Serial.println(HELP_BACKUP_STATUS_CMD); + Serial.println(HELP_RESTORE_SPIFFS_CMD); + #endif + // GCOVR_EXCL_STOP Serial.println(HELP_LED_CMD); Serial.println(HELP_GPS_DATA_CMD); Serial.println(HELP_GPS_CMD); @@ -461,6 +514,76 @@ void CommandLine::runCommand(String input) { sd_obj.listDir(cmd_args.get(1)); #endif } + // GCOVR_EXCL_START -- requires mounted SPIFFS and SD filesystems. + else if (cmd_args.get(0) == PROTOCOL_INFO_CMD) { + int machine_arg = this->argSearch(&cmd_args, "--machine"); + String transaction_id = machine_arg >= 0 && machine_arg + 1 < cmd_args.size() + ? cmd_args.get(machine_arg + 1) : ""; + if (machine_arg >= 0 && !validTransactionId(transaction_id)) + machineResult(transaction_id, PROTOCOL_INFO_CMD, "error", "INVALID_TRANSACTION"); + else if (machine_arg >= 0) { + #ifdef HAS_SD + Serial.printf( + "@MARAUDER:{\"protocol\":1,\"tx\":\"%s\",\"command\":\"protocolinfo\"," + "\"status\":\"success\",\"code\":\"OK\",\"firmware\":\"%s\"," + "\"capabilities\":[\"spiffs-backup\",\"spiffs-backup-status\"," + "\"spiffs-restore\"],\"backupPath\":\"/spiffs\"}\n", + transaction_id.c_str(), version_number.c_str() + ); + #else + Serial.printf( + "@MARAUDER:{\"protocol\":1,\"tx\":\"%s\",\"command\":\"protocolinfo\"," + "\"status\":\"success\",\"code\":\"OK\",\"firmware\":\"%s\"," + "\"capabilities\":[]}\n", + transaction_id.c_str(), version_number.c_str() + ); + #endif + } + } + else if (cmd_args.get(0) == BACKUP_SPIFFS_CMD || + cmd_args.get(0) == BACKUP_STATUS_CMD || + cmd_args.get(0) == RESTORE_SPIFFS_CMD) { + uint8_t operation = cmd_args.get(0) == BACKUP_SPIFFS_CMD ? 0 : + cmd_args.get(0) == BACKUP_STATUS_CMD ? 1 : 2; + const char* command = operation == 0 ? BACKUP_SPIFFS_CMD : + operation == 1 ? BACKUP_STATUS_CMD : RESTORE_SPIFFS_CMD; + int machine_arg = this->argSearch(&cmd_args, "--machine"); + String transaction_id = machine_arg >= 0 && machine_arg + 1 < cmd_args.size() + ? cmd_args.get(machine_arg + 1) : ""; + bool machine = machine_arg >= 0; + if (machine && !validTransactionId(transaction_id)) { + machineResult(transaction_id, command, "error", "INVALID_TRANSACTION"); + return; + } + #ifdef HAS_SD + size_t files = 0; + size_t bytes = 0; + uint8_t error = 0; + if (machine && operation != 1) + machineResult(transaction_id, command, "started", "OK"); + + bool success = sd_obj.migrateSPIFFS(operation, files, bytes, error); + if (machine) { + if (success) + machineResult(transaction_id, command, "success", "OK", files, bytes, operation == 2); + else { + const char* fallback = operation == 0 ? "BACKUP_FAILED" : + operation == 1 ? "BACKUP_INSPECTION_FAILED" : "RESTORE_FAILED"; + machineResult(transaction_id, command, "error", storageErrorCode(error, fallback)); + } + } + if (success && operation == 2) { + delay(1000); + ESP.restart(); + } + #else + if (machine) + machineResult(transaction_id, command, "error", "SD_NOT_SUPPORTED"); + else + Serial.println(F("SD Card NOT Supported")); + #endif + } + // GCOVR_EXCL_STOP // Channel command else if (cmd_args.get(0) == CH_CMD) { @@ -1289,11 +1412,11 @@ void CommandLine::runCommand(String input) { this->startScanFromCLI(WIFI_PING_SCAN, TFT_GREEN, "Ping Scan"); } - // GCOVR_EXCL_START -- command dispatch requires the firmware CLI runtime. + // ARP discovery uses the active station netif on both legacy and C5 + // dual-band hardware. if (cmd_args.get(0) == ARP_SCAN_CMD) { this->startScanFromCLI(WIFI_ARP_SCAN, TFT_CYAN, "ARP Scan"); } - // GCOVR_EXCL_STOP // GPS POI if (cmd_args.get(0) == GPS_POI_CMD) { diff --git a/esp32_marauder/CommandLine.h b/esp32_marauder/CommandLine.h index 513851c03..09129674b 100644 --- a/esp32_marauder/CommandLine.h +++ b/esp32_marauder/CommandLine.h @@ -55,6 +55,10 @@ const char PROGMEM UPDATE_CMD[] = "update"; const char PROGMEM HELP_CMD[] = "help"; const char PROGMEM SETTINGS_CMD[] = "settings"; const char PROGMEM LS_CMD[] = "ls"; +const char PROGMEM PROTOCOL_INFO_CMD[] = "protocolinfo"; +const char PROGMEM BACKUP_SPIFFS_CMD[] = "backupspiffs"; +const char PROGMEM BACKUP_STATUS_CMD[] = "backupstatus"; +const char PROGMEM RESTORE_SPIFFS_CMD[] = "restorespiffs"; const char PROGMEM LED_CMD[] = "led"; const char PROGMEM GPS_DATA_CMD[] = "gpsdata"; const char PROGMEM GPS_CMD[] = "gps"; @@ -133,6 +137,10 @@ const char PROGMEM HELP_REBOOT_CMD[] = "reboot"; const char PROGMEM HELP_UPDATE_CMD_A[] = "update -s/-w"; const char PROGMEM HELP_SETTINGS_CMD[] = "settings [-s enable/disable>]/[-r]"; const char PROGMEM HELP_LS_CMD[] = "ls "; +const char PROGMEM HELP_PROTOCOL_INFO_CMD[] = "protocolinfo [--machine ]"; +const char PROGMEM HELP_BACKUP_SPIFFS_CMD[] = "backupspiffs [--machine ] - copy SPIFFS to /spiffs on SD"; +const char PROGMEM HELP_BACKUP_STATUS_CMD[] = "backupstatus [--machine ] - inspect /spiffs on SD"; +const char PROGMEM HELP_RESTORE_SPIFFS_CMD[] = "restorespiffs [--machine ] - restore SPIFFS from /spiffs on SD"; const char PROGMEM HELP_LED_CMD[] = "led -s /-p "; const char PROGMEM HELP_GPS_DATA_CMD[] = "gpsdata"; const char PROGMEM HELP_GPS_CMD[] = "gps [-t] [-g] \r\n [-n] \r\n [-b = use BD vs GB for beidou]"; @@ -217,7 +225,6 @@ class CommandLine { LinkedList parseCommand(String input, char* delim); String toLowerCase(String str); void filterAccessPoints(String filter); - void runCommand(String input); bool checkValueExists(LinkedList* cmd_args_list, int index); bool inRange(int max, int index); //bool apSelected(); @@ -226,6 +233,7 @@ class CommandLine { int argSearch(LinkedList* cmd_args, const char* key); void startScanFromCLI(int scan_mode, uint16_t color, const char* scan_name); + #ifndef MARAUDER_V8 const char* ascii_art = "\r\n" " @@@@@@ \r\n" @@ -252,11 +260,13 @@ class CommandLine { " @@@@@@ \r\n" " @@@@ \r\n" "\r\n"; + #endif public: void RunSetup(); void main(uint32_t currentTime); + void runCommand(String input); }; #endif diff --git a/esp32_marauder/MenuFunctions.cpp b/esp32_marauder/MenuFunctions.cpp index 4a9087f64..460db7c0b 100644 --- a/esp32_marauder/MenuFunctions.cpp +++ b/esp32_marauder/MenuFunctions.cpp @@ -1,4 +1,5 @@ #include "MenuFunctions.h" +#include "CommandLine.h" #include "lang_var.h" #ifdef HAS_SCREEN @@ -9,6 +10,7 @@ extern LinkedList* stations; extern LinkedList* airtags; extern LinkedList* flippers; extern LinkedList* ble_devices; +extern CommandLine cli_obj; #ifdef HAS_MINI_SCREEN void MenuFunctions::drawMiniMenuButton(int b, int x, bool selected) { @@ -2002,13 +2004,13 @@ void MenuFunctions::RunSetup() this->drawStatusBar(); wifi_scan_obj.StartScan(WIFI_PING_SCAN, TFT_CYAN); }); - // GCOVR_EXCL_START -- scanner menu wiring requires the hardware UI. + // The C5 implementation uses the active station netif with lwIP core + // locking, so dual-band hardware supports the same ARP scanner. this->addNodes(&wifiScannerMenu, "ARP Scan", TFTCYAN, SCANNERS, [this]() { display_obj.clearScreen(); this->drawStatusBar(); wifi_scan_obj.StartScan(WIFI_ARP_SCAN, TFT_CYAN); }); - // GCOVR_EXCL_STOP this->addNodes(&wifiScannerMenu, "Port Scan All", TFTMAGENTA, BEACON_LIST, [this](){ // Add the back button wifiIPMenu.list->clear(); @@ -3486,6 +3488,15 @@ void MenuFunctions::RunSetup() this->changeMenu(&sdDeleteMenu, true); }); + + this->addNodes(&deviceMenu, "Backup SPIFFS", TFTGREEN, SD_UPDATE, [this]() { + cli_obj.runCommand(BACKUP_SPIFFS_CMD); + }); + + this->addNodes(&deviceMenu, "Restore SPIFFS", TFTORANGE, SD_UPDATE, [this]() { + cli_obj.runCommand(RESTORE_SPIFFS_CMD); + }); + } #endif @@ -4658,6 +4669,3 @@ void MenuFunctions::displayCurrentMenu(int start_index) #endif #endif - - - diff --git a/esp32_marauder/MenuFunctions.h b/esp32_marauder/MenuFunctions.h index ded624934..5916117ba 100644 --- a/esp32_marauder/MenuFunctions.h +++ b/esp32_marauder/MenuFunctions.h @@ -238,7 +238,7 @@ class MenuFunctions void buildUploadFileMenu(); void setupSDFileList(bool update = false); - void buildSDFileMenu(bool update = false); + void buildSDFileMenu(bool update = false); void displayMenuButtons(); uint16_t getColor(uint16_t color); void drawAvgLine(int16_t value); diff --git a/esp32_marauder/SDInterface.cpp b/esp32_marauder/SDInterface.cpp index 0287c1914..1a615a0da 100644 --- a/esp32_marauder/SDInterface.cpp +++ b/esp32_marauder/SDInterface.cpp @@ -1,6 +1,123 @@ #include "SDInterface.h" #include "lang_var.h" +// GCOVR_EXCL_START -- requires mounted SPIFFS and SD filesystems. +namespace { + bool removeTree(fs::FS& fs, const String& path, bool keep_root = false) { + if (!fs.exists(path)) + return true; + + File node = fs.open(path); + if (!node) + return false; + + if (!node.isDirectory()) { + node.close(); + return fs.remove(path); + } + + File child = node.openNextFile(); + while (child) { + String child_path = child.path(); + child.close(); + if (!removeTree(fs, child_path)) { + node.close(); + return false; + } + child = node.openNextFile(); + } + + node.close(); + return keep_root || fs.rmdir(path); + } + + String joinPath(const String& base, const String& child) { + return base == "/" ? "/" + child : base + "/" + child; + } + + bool copyTree( + fs::FS& source, + const String& source_path, + fs::FS* destination, + const String& destination_path, + size_t& files_copied, + size_t& bytes_copied, + uint8_t& error + ) { + File source_node = source.open(source_path); + if (!source_node) { + error = 3; + return false; + } + + if (!source_node.isDirectory()) { + if (destination) { + File destination_file = destination->open(destination_path, FILE_WRITE); + if (!destination_file) { + source_node.close(); + error = 3; + return false; + } + + uint8_t buffer[512]; + while (source_node.available()) { + size_t bytes_read = source_node.read(buffer, sizeof(buffer)); + if (bytes_read == 0 || destination_file.write(buffer, bytes_read) != bytes_read) { + source_node.close(); + destination_file.close(); + error = 3; + return false; + } + bytes_copied += bytes_read; + } + destination_file.close(); + } + else + bytes_copied += source_node.size(); + source_node.close(); + files_copied++; + return true; + } + + if (destination && destination_path != "/" && + !destination->exists(destination_path) && !destination->mkdir(destination_path)) { + source_node.close(); + error = 3; + return false; + } + + File child = source_node.openNextFile(); + while (child) { + String child_source_path = child.path(); + String child_name = child_source_path; + if (child_name.startsWith(source_path)) + child_name.remove(0, source_path.length()); + while (child_name.startsWith("/")) + child_name.remove(0, 1); + child.close(); + + if (!copyTree( + source, + child_source_path, + destination, + joinPath(destination_path, child_name), + files_copied, + bytes_copied, + error + )) { + source_node.close(); + return false; + } + child = source_node.openNextFile(); + } + + source_node.close(); + return true; + } + +} +// GCOVR_EXCL_STOP + #ifdef HAS_C5_SD SDInterface::SDInterface(SPIClass* spi, int cs) : _spi(spi), _cs(cs) {} @@ -107,6 +224,90 @@ bool SDInterface::removeFile(String file_path) { return false; } +// GCOVR_EXCL_START -- requires mounted SPIFFS and SD filesystems. +bool SDInterface::migrateSPIFFS(uint8_t operation, size_t& files_copied, size_t& bytes_copied, uint8_t& error) { + files_copied = bytes_copied = error = 0; + + if (!this->supported) { + error = 1; + return false; + } + + const String backup_path = "/spiffs"; + File backup = SD.open(backup_path); + bool valid_backup = backup && backup.isDirectory(); + backup.close(); + + if (operation == 1) { + if (!valid_backup) { + error = 2; + return false; + } + return copyTree(SD, backup_path, nullptr, "", files_copied, bytes_copied, error); + } + + if (operation == 2) { + if (!valid_backup) { + error = 2; + return false; + } + const String rollback_path = "/spiffs.restore-rollback"; + if (!removeTree(SD, rollback_path)) { + error = 3; + return false; + } + size_t rollback_files = 0, rollback_bytes = 0; + uint8_t rollback_error = 0; + if (!copyTree(SPIFFS, "/", &SD, rollback_path, rollback_files, rollback_bytes, rollback_error)) { + removeTree(SD, rollback_path); + error = 3; + return false; + } + bool cleared = removeTree(SPIFFS, "/", true); + if (cleared && copyTree(SD, backup_path, &SPIFFS, "/", files_copied, bytes_copied, error)) { + removeTree(SD, rollback_path); + return true; + } + removeTree(SPIFFS, "/", true); + size_t recovered_files = 0, recovered_bytes = 0; + uint8_t recovery_error = 0; + copyTree(SD, rollback_path, &SPIFFS, "/", recovered_files, recovered_bytes, recovery_error); + removeTree(SD, rollback_path); + error = 3; + return false; + } + + const String staging_path = "/spiffs.tmp"; + const String previous_path = "/spiffs.previous"; + + if (!removeTree(SD, staging_path) || !removeTree(SD, previous_path)) { + error = 3; + return false; + } + + if (!copyTree(SPIFFS, "/", &SD, staging_path, files_copied, bytes_copied, error)) { + removeTree(SD, staging_path); + return false; + } + + if (SD.exists(backup_path) && !SD.rename(backup_path, previous_path)) { + removeTree(SD, staging_path); + error = 3; + return false; + } + + if (!SD.rename(staging_path, backup_path)) { + if (SD.exists(previous_path)) + SD.rename(previous_path, backup_path); + error = 3; + return false; + } + + removeTree(SD, previous_path); + return true; +} +// GCOVR_EXCL_STOP + void SDInterface::listDirToLinkedList(LinkedList* file_names, String str_dir, String ext) { if (this->supported) { File dir = SD.open(str_dir); diff --git a/esp32_marauder/SDInterface.h b/esp32_marauder/SDInterface.h index 0d3c8cb70..9ebb884d1 100644 --- a/esp32_marauder/SDInterface.h +++ b/esp32_marauder/SDInterface.h @@ -10,6 +10,7 @@ #include "FS.h" #endif #include "SD.h" +#include "SPIFFS.h" #ifdef HAS_C5_SD #include "SPI.h" #endif @@ -68,6 +69,7 @@ class SDInterface { void runUpdate(String file_name = ""); void performUpdate(Stream &updateSource, size_t updateSize); bool removeFile(String file_path); + bool migrateSPIFFS(uint8_t operation, size_t& files, size_t& bytes, uint8_t& error); }; #endif diff --git a/esp32_marauder/TargetListSort.cpp b/esp32_marauder/TargetListSort.cpp index 0a07ea990..6179df6da 100644 --- a/esp32_marauder/TargetListSort.cpp +++ b/esp32_marauder/TargetListSort.cpp @@ -1,6 +1,5 @@ #include "TargetListSort.h" -#include #include #include @@ -14,6 +13,21 @@ int caseInsensitiveCompare(const char* left, const char* right) { } return (unsigned char)*left - (unsigned char)*right; } + +bool targetComesBefore(const TargetListItem& a, const TargetListItem& b, + TargetSortMode mode) { + switch (mode) { + case TargetSortMode::SIGNAL_DESC: + return a.rssi != b.rssi ? a.rssi > b.rssi + : caseInsensitiveCompare(a.name, b.name) < 0; + case TargetSortMode::NAME_ASC: + return caseInsensitiveCompare(a.name, b.name) < 0; + case TargetSortMode::CHANNEL_ASC: + return a.channel != b.channel ? a.channel < b.channel + : caseInsensitiveCompare(a.name, b.name) < 0; + } + return false; +} } // namespace bool targetListItemMatchesFilter(const TargetListItem& item, TargetFilterMode filter, @@ -32,16 +46,15 @@ bool targetListItemMatchesFilter(const TargetListItem& item, TargetFilterMode fi } void sortTargetList(std::vector& items, TargetSortMode mode) { - std::stable_sort(items.begin(), items.end(), [mode](const TargetListItem& a, const TargetListItem& b) { - switch (mode) { - case TargetSortMode::SIGNAL_DESC: - return a.rssi != b.rssi ? a.rssi > b.rssi : caseInsensitiveCompare(a.name, b.name) < 0; - case TargetSortMode::NAME_ASC: - return caseInsensitiveCompare(a.name, b.name) < 0; - case TargetSortMode::CHANNEL_ASC: - return a.channel != b.channel ? a.channel < b.channel : caseInsensitiveCompare(a.name, b.name) < 0; + // Target lists are screen-sized. Stable insertion sort avoids pulling the + // large libstdc++ stable_sort implementation into constrained firmware. + for (size_t i = 1; i < items.size(); ++i) { + TargetListItem item = items[i]; + size_t position = i; + while (position > 0 && targetComesBefore(item, items[position - 1], mode)) { + items[position] = items[position - 1]; + --position; } - return false; - }); + items[position] = item; + } } - diff --git a/esp32_marauder/configs.h b/esp32_marauder/configs.h index 41444ed34..1e53bc7ff 100644 --- a/esp32_marauder/configs.h +++ b/esp32_marauder/configs.h @@ -42,7 +42,7 @@ #define JSON_SETTING_SIZE 2048 -#define MARAUDER_VERSION "v1.15.1" +#define MARAUDER_VERSION "v1.16.0" #define GRAPH_REFRESH 100 diff --git a/tools/test_retained_develop_features.py b/tools/test_retained_develop_features.py new file mode 100644 index 000000000..723f3ed75 --- /dev/null +++ b/tools/test_retained_develop_features.py @@ -0,0 +1,46 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class RetainedDevelopFeatureTests(unittest.TestCase): + def test_arp_scan_is_exposed_on_dual_band_hardware(self): + menu = (ROOT / "esp32_marauder" / "MenuFunctions.cpp").read_text() + cli = (ROOT / "esp32_marauder" / "CommandLine.cpp").read_text() + + menu_entry = menu.index('"ARP Scan", TFTCYAN') + cli_entry = cli.index("cmd_args.get(0) == ARP_SCAN_CMD") + self.assertNotIn("#ifndef HAS_DUAL_BAND", menu[menu_entry - 160:menu_entry]) + self.assertNotIn("#ifndef HAS_DUAL_BAND", cli[cli_entry - 160:cli_entry]) + + def test_service_scans_remain_arp_first(self): + source = (ROOT / "esp32_marauder" / "WiFiScan.cpp").read_text() + service_scan = source[source.index("void WiFiScan::pingScan") :] + self.assertIn("this->singleARP(this->current_scan_ip)", service_scan) + for mode in ("WIFI_SCAN_SSH", "WIFI_SCAN_TELNET", "WIFI_SCAN_SMTP", + "WIFI_SCAN_DNS", "WIFI_SCAN_HTTP", "WIFI_SCAN_HTTPS", + "WIFI_SCAN_RDP"): + self.assertIn(mode, service_scan) + + def test_spiffs_backup_and_restore_are_in_device_menu(self): + source = (ROOT / "esp32_marauder" / "MenuFunctions.cpp").read_text() + self.assertIn('"Backup SPIFFS"', source) + self.assertIn('"Restore SPIFFS"', source) + self.assertIn("cli_obj.runCommand(BACKUP_SPIFFS_CMD)", source) + self.assertIn("cli_obj.runCommand(RESTORE_SPIFFS_CMD)", source) + + def test_v8_keeps_complete_cli_help(self): + source = (ROOT / "esp32_marauder" / "CommandLine.cpp").read_text() + help_block = source[source.index("if (cmd_args.get(0) == HELP_CMD)"): + source.index("// Stop Scan")] + self.assertNotIn("ESP32 Marauder commands: https://", help_block) + self.assertNotIn("#ifdef MARAUDER_V8", help_block) + self.assertIn("Serial.println(HELP_HEAD)", help_block) + self.assertIn("Serial.println(HELP_PROTOCOL_INFO_CMD)", help_block) + self.assertIn("Serial.println(HELP_RESTORE_SPIFFS_CMD)", help_block) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_spiffs_machine_protocol.py b/tools/test_spiffs_machine_protocol.py new file mode 100644 index 000000000..998887d7d --- /dev/null +++ b/tools/test_spiffs_machine_protocol.py @@ -0,0 +1,43 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class SpiffsMachineProtocolTests(unittest.TestCase): + def test_protocol_is_versioned_framed_and_transactional(self): + header = (ROOT / "esp32_marauder" / "CommandLine.h").read_text() + source = (ROOT / "esp32_marauder" / "CommandLine.cpp").read_text() + + self.assertIn('PROTOCOL_INFO_CMD[] = "protocolinfo"', header) + self.assertIn('BACKUP_STATUS_CMD[] = "backupstatus"', header) + self.assertIn('@MARAUDER:{\\"protocol\\":1', source) + self.assertIn('this->argSearch(&cmd_args, "--machine")', source) + self.assertIn('"INVALID_TRANSACTION"', source) + self.assertIn('"SD_NOT_SUPPORTED"', source) + self.assertRegex(source, r'machineResult\([^;]+"started"') + self.assertRegex(source, r'machineResult\([^;]+"success"') + + def test_machine_restore_acknowledges_reboot(self): + source = (ROOT / "esp32_marauder" / "CommandLine.cpp").read_text() + restore = source[source.index("cmd_args.get(0) == RESTORE_SPIFFS_CMD) {") :] + success = restore.index( + 'machineResult(transaction_id, command, "success", "OK", files, bytes, operation == 2)' + ) + restart = restore.index("ESP.restart();") + self.assertLess(success, restart) + self.assertIn("if (success && operation == 2)", restore[success:restart]) + + def test_backup_status_measures_the_activated_backup(self): + source = (ROOT / "esp32_marauder" / "SDInterface.cpp").read_text() + method = re.search( + r"bool SDInterface::migrateSPIFFS\(.*?\n\}", source, re.S + ).group(0) + self.assertIn("File backup = SD.open(backup_path)", method) + self.assertIn('copyTree(SD, backup_path, nullptr', method) + + +if __name__ == "__main__": + unittest.main()