From 300b889bcaf1b771d2da3f1a35dd5d3b838b8fc1 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 20 Oct 2026 21:14:49 -0700 Subject: [PATCH] Add saved OLED controller selection on RAK boards Allow Auto, SSD1306 or SH1106 through display.controller on display-enabled RAK4631 and RAK3401 roles. Persist the choice before restart without changing the active screen policy, and let saved Auto override legacy build defaults. Share checked settings I/O and the existing atomic writer to keep Full Companion within its flash boundary while retaining features and existing record formats. Cover save failures, backup recovery, startup selection and ambiguous OLED replies. Validation: 35 targeted host tests passed. RAK4631 Full Companion and RAK3401 unified LoRa OTA repeater build and package checks passed with release-length version strings. --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_commands.md | 33 +- docs/firmware_picker.md | 5 + docs/optional_screens.md | 12 +- examples/companion_radio/MyMesh.cpp | 12 + examples/companion_radio/main.cpp | 6 + examples/simple_repeater/main.cpp | 6 + examples/simple_room_server/main.cpp | 6 + examples/simple_sensor/main.cpp | 6 + platformio.ini | 1 + src/helpers/CheckedSettingsFile.h | 53 +++ src/helpers/CommonCLI.cpp | 6 + src/helpers/LedSettings.cpp | 20 +- src/helpers/RegulatorSettings.cpp | 26 +- src/helpers/ui/AutoOLEDBackend.h | 21 +- src/helpers/ui/DisplayPowerSettings.cpp | 33 +- src/helpers/ui/OLEDControllerSettings.cpp | 166 +++++++++ src/helpers/ui/OLEDControllerSettings.h | 18 + src/helpers/ui/OLEDDisplayProbe.h | 12 + src/helpers/ui/SSD1306Display.cpp | 9 +- test/test_display_power.py | 137 +++++++- test/test_oled_controller_settings.py | 405 ++++++++++++++++++++++ test/test_oled_display_probe.py | 28 ++ 23 files changed, 955 insertions(+), 67 deletions(-) create mode 100644 src/helpers/CheckedSettingsFile.h create mode 100644 src/helpers/ui/OLEDControllerSettings.cpp create mode 100644 src/helpers/ui/OLEDControllerSettings.h create mode 100644 test/test_oled_controller_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 768c3574e..f1041500a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1003,6 +1003,7 @@ jobs: python3 -B test/test_infrastructure_ui_version.py -v python3 -B test/test_ssd1306_power.py -v python3 -B test/test_oled_display_probe.py -v + python3 -B test/test_oled_controller_settings.py -v python3 -B test/test_companion_inbox.py python3 -B test/test_heltec_r8_external_power.py python3 -B test/test_bluetooth_mac_contract.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e4b55a5fc..e9b5995e9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1059,15 +1059,36 @@ set display.rotation 270 The values are clockwise degrees. `0` clears the override and restores the board's compiled default. Unsupported display drivers return an error. -RAK4631 and RAK3401 images detect SSD1306 or SH1106 OLED controllers when -initializing the screen. This lets the same RAK4631 image serve WisMesh Pocket -v2 and v3. Both retain rotation support and use one 1 KiB framebuffer. +## Select RAK OLED controller + +Display-capable RAK4631 and RAK3401 images with OLED controller detection +support a saved selection in Companion, Repeater, Room Server and Sensor roles: + +```text +get display.controller +set display.controller auto +set display.controller ssd1306 +set display.controller sh1106 +``` + +Choose `auto` for normal use. If a panel is shifted, wraps at its edge or is +otherwise incorrectly detected, select its controller explicitly and restart +the node. Changes are saved immediately and apply after restart; the current +screen keeps its active controller until then. `get display.controller` +reports the configured choice, active choice and whether a restart is +needed. A custom firmware build is not needed to change this choice. + +Automatic detection lets the same RAK4631 image serve WisMesh Pocket v2 and +v3. Both controllers retain rotation support and use one 1 KiB framebuffer. Detection checks the configured `DISPLAY_ADDRESS` (default `0x3C`). Only repeated SH1106 status readings select that controller; ambiguous responses -keep SSD1306, preserving compatibility with older RAK1921 panels. For panels -that cannot identify themselves, build with `OLED_CONTROLLER_OVERRIDE=1` -(SSD1306) or `=2` (SH1106); `=0` restores automatic detection. +keep SSD1306, preserving compatibility with older RAK1921 panels. With no +saved choice, the default is automatic detection unless the build sets +`OLED_CONTROLLER_OVERRIDE=1` (SSD1306) or `=2` (SH1106). This build-time +override remains a fallback for existing custom builds. Any saved selection +takes precedence, including `auto`, which restores detection even when that +fallback forces a controller. See [the Pocket v3 issue](https://github.com/meshcore-dev/MeshCore/issues/3296) and [the RAK1921 detection caveat](https://github.com/meshtastic/firmware/pull/11265). diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index d4d6251d9..42d1ac257 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -335,6 +335,11 @@ SSD1306 Full Companion builds use `set display.rotation 90|180|270`; `0` restores the board default, so a separate rotated release image is not recommended. +New display-capable RAK4631 and RAK3401 images with OLED controller detection +also offer saved `display.controller` choices: `auto`, `ssd1306` or `sh1106`. +This applies after restart and avoids a custom image when a panel needs an +explicit controller. See [controller selection](cli_commands.md#select-rak-oled-controller). + New Heltec V4 R8 TFT images also support saved rotation in Companion, Repeater, Room Server and Sensor roles. The observer dashboard follows the selected portrait or landscape orientation. Once the canonical image proves diff --git a/docs/optional_screens.md b/docs/optional_screens.md index 609c85ff4..02db65bd1 100644 --- a/docs/optional_screens.md +++ b/docs/optional_screens.md @@ -24,7 +24,7 @@ add detection to an older download. | Heltec Wireless Tracker V1/V1.1 | The [manufacturer listing](https://old.heltec.org/project/wireless-tracker/) explicitly offers Screen Yes/No. | New display-capable images detect the ST7735 panel. Its absence releases only the display's power claim; GPS retains its own claim on the shared supply. | | Heltec V4 R8 Expansion Kit | The [manufacturer kit](https://heltec.org/project/v4-r8-ex/) offers front-panel configurations; its V2 expansion schematic connects the display's separate return line. | New R8 TFT images detect the panel through the documented MISO pin. Existing touch reset and backlight-only sleep/wake behavior are retained. | | Heltec V4/V4 R8 OLED | The [manufacturer listing](https://heltec.org/project/wifi-lora-32-v4/) offers OLED and No Display configurations. | Existing SSD1306 images already check whether the OLED responds. | -| RAK WisBlock | The [RAK4631 kit guide](https://downloads.rakwireless.com/Meshtastic/Quick%20Start%20Guide_WisMesh%20RAK4631%20Starter%20Kit.pdf) labels the RAK1921 OLED optional. | Existing display-capable SSD1306 images already check presence. | +| RAK WisBlock | The [RAK4631 kit guide](https://downloads.rakwireless.com/Meshtastic/Quick%20Start%20Guide_WisMesh%20RAK4631%20Starter%20Kit.pdf) labels the RAK1921 OLED optional. | Display-capable RAK4631 and RAK3401 images check presence and detect SSD1306 or SH1106 controllers. New images also provide a saved controller choice; see below. | | iKoka Stick and Handheld | The designer documents an [optional Stick OLED](https://github.com/ndoo/ikoka-stick-meshtastic-device#oled-display-optional) and [optional Handheld OLED](https://github.com/ndoo/ikoka-handheld-meshtastic-device#oled-display-optional). | Existing display-capable images already check presence. Handheld Companion supports the screen; currently headless infrastructure recipes remain headless. | | Original LilyGo T-Beam | [LilyGo](https://lilygo.cc/products/t-beam-v1-1-esp32-lora-module) offers soldered/non-soldered OLED configurations. | Existing display-capable SSD1306 images already check presence. | | XIAO ESP32-S3 with Wio-SX1262 | [Seeed](https://wiki.seeedstudio.com/wio_sx1262_with_xiao_esp32s3_kit/#recommended-accessories) lists the expansion board as an accessory for adding screens and sensors. | Existing display-capable Companion images already check presence. | @@ -36,6 +36,16 @@ recipe has no verified panel readback connection. Detection is not enabled for that wiring. A touchscreen acknowledgement alone does not establish that the LCD responds. The V4 R8 TFT and older V4 TFT pin mappings are distinct. +RAK4631 and RAK3401 images with OLED controller detection support +`set display.controller auto`, `ssd1306` or `sh1106` in all display-enabled +roles. Keep `auto` for normal use; choose the panel's controller explicitly if +detection gives shifted or wrapped pixels. The choice is saved and applies +after restarting the node. `get display.controller` shows the configured and +active choices and any pending restart. With no saved choice, automatic +detection or an existing build-time override supplies the default; saved +`auto` takes precedence over that override. Normal controller selection does +not require custom firmware. See the [controller commands](cli_commands.md#select-rak-oled-controller). + New R8 TFT images support saved `display.rotation` values of `0`, `90`, `180` and `270`, including portrait/landscape dashboard changes without reflashing. The stock R8 expansion board has no accelerometer for automatic orientation. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b415adc25..8bd6311f6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,6 +1,9 @@ #include #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) +#include +#endif #include #include #include @@ -4334,6 +4337,12 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) + if (mesh::ui::handleOLEDControllerCommand(command, reply, reply_size, true)) { + return true; + } +#endif + if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: display rotation is unsupported"); @@ -10388,6 +10397,9 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, " memory\r\n" #endif " get/set display.rotation <0|90|180|270>\r\n" +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) + " get/set display.controller (saved; restart to apply)\r\n" +#endif #ifdef PIN_BUZZER " get/set buzzer on|off\r\n" #endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 67ed9f8c6..1672fe9a0 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3,6 +3,9 @@ #include // needed for PlatformIO #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include +#endif #include #include #include @@ -3650,6 +3653,9 @@ void setup() { // Never open files on a primary store whose mount and RAM fallback failed. if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) && !store.isVolatilePrimaryFS()) display_prefs_fs = nullptr; +#endif +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); #endif mesh::ui::loadDisplayPowerSettings(display_prefs_fs, #ifdef BLE_PIN_CODE diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index ab965be14..f78bf5864 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -5,6 +5,9 @@ #include #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include +#endif #include #include #include @@ -217,6 +220,9 @@ void setup() { #ifdef DISPLAY_CLASS mesh::hilStartupTrace("display_begin"); mesh::ui::loadDisplayPowerSettings(fs, false); +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); +#endif mesh::ui::StartupScreen startup_screen; display_ready = display.begin(); if (display_ready) { diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index a60beda64..d7393bed6 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -5,6 +5,9 @@ #include #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include +#endif #include #include #include @@ -149,6 +152,9 @@ void setup() { } #ifdef DISPLAY_CLASS mesh::ui::loadDisplayPowerSettings(fs, false); +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); +#endif mesh::ui::StartupScreen startup_screen; display_ready = display.begin(); if (display_ready) { diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 5d5e9c6f6..c35b8235b 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -5,6 +5,9 @@ #include #include #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include +#endif #if defined(NRF52_PLATFORM) #include #include @@ -174,6 +177,9 @@ void setup() { } #ifdef DISPLAY_CLASS mesh::ui::loadDisplayPowerSettings(fs, false); +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); +#endif mesh::ui::StartupScreen startup_screen; display_ready = display.begin(); if (display_ready) { diff --git a/platformio.ini b/platformio.ini index 48ff7a46e..7d650409e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -63,6 +63,7 @@ build_src_filter = + + + + + + ; MQTT-only sources (need Timezone/NTPClient); observer envs add them back. - diff --git a/src/helpers/CheckedSettingsFile.h b/src/helpers/CheckedSettingsFile.h new file mode 100644 index 000000000..7ff17d4e6 --- /dev/null +++ b/src/helpers/CheckedSettingsFile.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#if defined(NRF52_PLATFORM) +#include +#endif + +namespace mesh { namespace storage { + +#if defined(__GNUC__) && !defined(__clang__) +#define MESH_SETTINGS_FILE_SHARED __attribute__((noinline, noclone)) +#else +#define MESH_SETTINGS_FILE_SHARED __attribute__((noinline)) +#endif + +// Share the filesystem boundary between small settings records. Callers keep +// their own format/version checks; no preference layout changes here. +MESH_SETTINGS_FILE_SHARED inline bool readFixedSettingsFile( + FILESYSTEM* fs, const char* path, uint8_t* data, size_t size) { + if (!fs) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file(*fs); + if (!file.open(path, FILE_O_READ)) return false; +#else + File file = fs->open(path, "r"); + if (!file) return false; +#endif + const bool complete = file.size() == size && file.read(data, size) == size; + file.close(); + return complete; +} + +#if defined(NRF52_PLATFORM) +// Reuse the existing atomic writer and compare the complete public record as +// well as its transaction checksum. Keep this uncommon path out of callers. +MESH_SETTINGS_FILE_SHARED inline AtomicFileWriter::Outcome writeCheckedSettingsFile( + FILESYSTEM* fs, const char* path, const uint8_t* data, size_t size) { + using Outcome = AtomicFileWriter::Outcome; + uint8_t verified[16]; + if (!fs || !data || size == 0 || size > sizeof(verified)) return Outcome::NotCommitted; + AtomicFileWriter file(fs, path); + if (!file || file.write(data, size) != size) return Outcome::NotCommitted; + if (!file.commit()) return file.outcome(); + if (!readFixedSettingsFile(fs, path, verified, size) + || memcmp(data, verified, size) != 0) return Outcome::Indeterminate; + return Outcome::Committed; +} +#endif + +#undef MESH_SETTINGS_FILE_SHARED + +}} // namespace mesh::storage diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a47b36ea5..948a6bd4e 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -9,6 +9,9 @@ #include "PowerSavingDiagnostics.h" #include "HilStartupTrace.h" #include +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) +#include +#endif #if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) \ && HELTEC_TFT_RUNTIME_ROTATION == 1 #include @@ -3060,6 +3063,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re return; } if (mesh::ui::handleDisplayPowerCommand(command, reply, 160)) return; +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS) + if (mesh::ui::handleOLEDControllerCommand(command, reply, 160, true)) return; +#endif #if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) \ && HELTEC_TFT_RUNTIME_ROTATION == 1 if (mesh::ui::handleDisplayRotationCommand(command, reply, 160, true)) return; diff --git a/src/helpers/LedSettings.cpp b/src/helpers/LedSettings.cpp index 062c498c6..01abe5c54 100644 --- a/src/helpers/LedSettings.cpp +++ b/src/helpers/LedSettings.cpp @@ -1,4 +1,5 @@ #include "LedSettings.h" +#include "CheckedSettingsFile.h" #include #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include @@ -22,18 +23,8 @@ const uint8_t images[2][8] = { // Return the saved value, or -1 for an absent or invalid image. Comparing the // two canonical images shares the write format without a second encoder. int readSetting(const char* name) { - if (!settings_fs) return -1; -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File file(*settings_fs); - if (!file.open(name, FILE_O_READ)) return -1; -#else - File file = settings_fs->open(name, "r"); - if (!file) return -1; -#endif uint8_t data[8]; - const bool complete = file.size() == sizeof(data) - && file.read(data, sizeof(data)) == sizeof(data); - file.close(); + const bool complete = storage::readFixedSettingsFile(settings_fs, name, data, sizeof(data)); if (!complete || data[4] > 1 || memcmp(data, images[data[4]], sizeof(data)) != 0) return -1; return data[4]; @@ -42,7 +33,11 @@ int readSetting(const char* name) { bool saveSetting(bool on) { if (!settings_fs) return false; const uint8_t* data = images[on ? 1 : 0]; -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + return storage::writeCheckedSettingsFile(settings_fs, path, data, sizeof(images[0])) + == AtomicFileWriter::Outcome::Committed; +#else +#if defined(STM32_PLATFORM) AtomicFileWriter file(settings_fs, path); #else ContactFileTransaction file(settings_fs, path); @@ -50,6 +45,7 @@ bool saveSetting(bool on) { if (!file || file.write(data, sizeof(images[0])) != sizeof(images[0]) || !file.commit()) return false; return readSetting(path) == int(on); +#endif } } // namespace diff --git a/src/helpers/RegulatorSettings.cpp b/src/helpers/RegulatorSettings.cpp index 0ef1755b1..085a3041d 100644 --- a/src/helpers/RegulatorSettings.cpp +++ b/src/helpers/RegulatorSettings.cpp @@ -3,6 +3,7 @@ #include "NRF52VoltageRules.h" #include "NRF52VoltagePolicy.h" #include "PersistentStoreFormat.h" +#include "CheckedSettingsFile.h" #if defined(NRF52_PLATFORM) #include "AtomicFileWriter.h" #endif @@ -64,17 +65,7 @@ void encode(RegulatorMode mcu, RegulatorMode radio, uint8_t fallback, uint8_t (& storage::writeLE32(data + 8, storage::updateCRC32(0xffffffffUL, data, 8)); } bool readFile(const char* name, uint8_t (&data)[12]) { - if (!settings_fs) return false; -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File file(*settings_fs); - if (!file.open(name, FILE_O_READ)) return false; -#else - File file = settings_fs->open(name, "r"); - if (!file) return false; -#endif - const bool complete = file.size() == sizeof(data) - && file.read(data, sizeof(data)) == sizeof(data); - file.close(); + const bool complete = storage::readFixedSettingsFile(settings_fs, name, data, sizeof(data)); return complete && memcmp(data, "REG1", 4) == 0 && data[4] <= 2 && data[5] <= 2 && data[6] <= RECOVERY_DOMAINS && data[7] == 0 && storage::readLE32(data + 8) @@ -83,17 +74,16 @@ bool readFile(const char* name, uint8_t (&data)[12]) { bool save(RegulatorMode mcu, RegulatorMode radio, uint8_t fallback) { if (!settings_fs) return false; #if defined(NRF52_PLATFORM) - uint8_t data[12], checked[12]; + uint8_t data[12]; + if (uint8_t(mcu) > 2 || uint8_t(radio) > 2 || fallback > RECOVERY_DOMAINS) return false; encode(mcu, radio, fallback, data); // The shared LittleFS writer already stages, flushes, checks size/CRC32C, // atomically publishes and verifies the public file. Reuse it rather than // linking a second replacement transaction into flash-constrained targets. - mesh::AtomicFileWriter file(settings_fs, path); - if (!file || file.write(data, sizeof(data)) != sizeof(data) || !file.commit()) return false; - // Retain the format validation and exact-byte comparison as well as the - // writer's independent transaction checksum. Legacy .bak files remain - // readable at boot and are retired only after this verified publication. - if (!readFile(path, checked) || memcmp(data, checked, sizeof(data)) != 0) return false; + if (storage::writeCheckedSettingsFile(settings_fs, path, data, sizeof(data)) + != mesh::AtomicFileWriter::Outcome::Committed) return false; + // The checked writer compares the complete encoded record after commit. + // Retire legacy backups only after this verified publication. settings_fs->remove(backup); saved_fallback = fallback; return true; diff --git a/src/helpers/ui/AutoOLEDBackend.h b/src/helpers/ui/AutoOLEDBackend.h index cff55bd20..0596105fe 100644 --- a/src/helpers/ui/AutoOLEDBackend.h +++ b/src/helpers/ui/AutoOLEDBackend.h @@ -20,7 +20,8 @@ class AutoOLEDBackend : public Adafruit_SSD1306 { public: using Adafruit_SSD1306::Adafruit_SSD1306; - bool begin(uint8_t vcc, uint8_t address, bool reset, bool periphBegin) { + bool begin(uint8_t vcc, uint8_t address, bool reset, bool periphBegin, + mesh::ui::OLEDControllerMode mode = mesh::ui::defaultOLEDControllerMode()) { _ready = false; i2caddr = address; if (periphBegin) wire->begin(); @@ -33,16 +34,14 @@ public: digitalWrite(rstPin, HIGH); } -#if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE != 0 - static_assert(OLED_CONTROLLER_OVERRIDE == 1 || OLED_CONTROLLER_OVERRIDE == 2, - "OLED_CONTROLLER_OVERRIDE must be 0 (auto), 1 (SSD1306) or 2 (SH1106)"); - wire->beginTransmission(address); - _controller = wire->endTransmission() != 0 ? mesh::ui::OLEDController::Absent - : OLED_CONTROLLER_OVERRIDE == 2 ? mesh::ui::OLEDController::SH1106 - : mesh::ui::OLEDController::SSD1306; -#else - _controller = mesh::ui::detectOLEDController(*wire, address); -#endif + if (mode == mesh::ui::OLEDControllerMode::Auto) { + _controller = mesh::ui::detectOLEDController(*wire, address); + } else { + wire->beginTransmission(address); + _controller = wire->endTransmission() != 0 ? mesh::ui::OLEDController::Absent + : mode == mesh::ui::OLEDControllerMode::SH1106 ? mesh::ui::OLEDController::SH1106 + : mesh::ui::OLEDController::SSD1306; + } if (_controller == mesh::ui::OLEDController::Absent) return false; if (_controller == mesh::ui::OLEDController::SSD1306) { _ready = Adafruit_SSD1306::begin(vcc, address, false, false); diff --git a/src/helpers/ui/DisplayPowerSettings.cpp b/src/helpers/ui/DisplayPowerSettings.cpp index 2f1e41532..015757079 100644 --- a/src/helpers/ui/DisplayPowerSettings.cpp +++ b/src/helpers/ui/DisplayPowerSettings.cpp @@ -1,5 +1,6 @@ #include "DisplayPowerSettings.h" #include "DisplayBuildFlags.h" +#include #include namespace mesh { namespace ui { @@ -9,7 +10,9 @@ bool pairing_available = false; FILESYSTEM* settings_fs = nullptr; const char* const path = "/display_prefs"; const char* const backup = "/display_prefs.bak"; +#if !defined(NRF52_PLATFORM) const char* const temporary = "/display_prefs.tmp"; +#endif // Explicit bytes avoid compiler padding and preserve a small fixed format. void encode(const DisplayPowerPrefs& prefs, uint8_t (&data)[12]) { @@ -22,18 +25,8 @@ void encode(const DisplayPowerPrefs& prefs, uint8_t (&data)[12]) { for (unsigned i = 0; i < 11; ++i) data[11] ^= data[i]; } bool readPrefs(const char* name, DisplayPowerPrefs& prefs) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - File file(*settings_fs); - if (!file.open(name, FILE_O_READ)) return false; -#else - File file = settings_fs->open(name, "r"); - if (!file) return false; -#endif uint8_t data[12], expected[12]; - const bool complete = file.size() == sizeof(data) - && file.read(data, sizeof(data)) == sizeof(data); - file.close(); - if (!complete) return false; + if (!mesh::storage::readFixedSettingsFile(settings_fs, name, data, sizeof(data))) return false; prefs.battery.mode = DisplayMode(data[3]); prefs.battery.seconds = uint16_t(data[4]) | (uint16_t(data[5]) << 8); prefs.usb.mode = DisplayMode(data[6]); @@ -49,8 +42,25 @@ bool readPrefs(const char* name, DisplayPowerPrefs& prefs) { && validDisplayProfile(prefs.battery) && validDisplayProfile(prefs.usb) && uint8_t(prefs.inbox) <= uint8_t(DisplayInboxMode::Unread); } +#if defined(NRF52_PLATFORM) +// Both CLI updates and legacy migration use this transaction. +__attribute__((noinline)) +#endif bool savePrefs(const DisplayPowerPrefs& prefs) { if (!settings_fs) return false; +#if defined(NRF52_PLATFORM) + if (!validDisplayProfile(prefs.battery) || !validDisplayProfile(prefs.usb) + || uint8_t(prefs.inbox) > uint8_t(DisplayInboxMode::Unread)) return false; + uint8_t data[12]; + encode(prefs, data); + // The shared transaction verifies staging and exact published bytes. + if (mesh::storage::writeCheckedSettingsFile(settings_fs, path, data, sizeof(data)) + != mesh::AtomicFileWriter::Outcome::Committed) return false; + // Older images can leave a .bak record. Retire it only after publication + // of the checked display format has been verified. + settings_fs->remove(backup); + return true; +#else // Preserve the previous complete file until the replacement is verified. settings_fs->remove(temporary); if (settings_fs->exists(temporary)) return false; @@ -78,6 +88,7 @@ bool savePrefs(const DisplayPowerPrefs& prefs) { } settings_fs->remove(backup); return true; +#endif } #endif // MESHCORE_HAS_REAL_DISPLAY } diff --git a/src/helpers/ui/OLEDControllerSettings.cpp b/src/helpers/ui/OLEDControllerSettings.cpp new file mode 100644 index 000000000..315fb8072 --- /dev/null +++ b/src/helpers/ui/OLEDControllerSettings.cpp @@ -0,0 +1,166 @@ +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + +#include "OLEDControllerSettings.h" +#include + +#include +#include + +namespace mesh { namespace ui { +namespace { +FILESYSTEM* settings_fs = nullptr; +OLEDControllerMode active_mode = defaultOLEDControllerMode(); +OLEDControllerMode configured_mode = defaultOLEDControllerMode(); +bool saved_mode = false; +bool recovery_pending = false; +const char* const path = "/display_controller"; +const char* const backup = "/display_controller.bak"; +#if !defined(NRF52_PLATFORM) +const char* const temporary = "/display_controller.tmp"; +#endif + +const char* modeName(OLEDControllerMode mode) { + switch (mode) { + case OLEDControllerMode::SSD1306: return "ssd1306"; + case OLEDControllerMode::SH1106: return "sh1106"; + default: return "auto"; + } +} + +const uint8_t images[3][6] = { + {'O', 'C', 1, 0, 0, 'O' ^ 'C' ^ 1}, + {'O', 'C', 1, 1, 0, 'O' ^ 'C' ^ 1 ^ 1}, + {'O', 'C', 1, 2, 0, 'O' ^ 'C' ^ 1 ^ 2}}; + +bool readPrefs(const char* name, OLEDControllerMode& mode) { + uint8_t data[sizeof(images[0])]; + if (!storage::readFixedSettingsFile(settings_fs, name, data, sizeof(data)) + || data[3] > uint8_t(OLEDControllerMode::SH1106) + || memcmp(data, images[data[3]], sizeof(data)) != 0) return false; + mode = OLEDControllerMode(data[3]); + return true; +} + +bool savePrefs(OLEDControllerMode mode) { + // A failed backup recovery may leave that backup as the only valid record. + // Do not rotate it away until a later successful load repairs the store. + if (!settings_fs || recovery_pending) return false; + const uint8_t* data = images[uint8_t(mode)]; +#if defined(NRF52_PLATFORM) + // Share the existing LittleFS transaction implementation on constrained + // nRF52 targets: it verifies staged bytes and the published file by CRC32C. + const auto outcome = storage::writeCheckedSettingsFile( + settings_fs, path, data, sizeof(images[0])); + if (outcome != mesh::AtomicFileWriter::Outcome::Committed) { + recovery_pending = outcome == mesh::AtomicFileWriter::Outcome::Indeterminate; + return false; + } + settings_fs->remove(backup); + return true; +#else + // Keep the previous complete record until the replacement is read back. + settings_fs->remove(temporary); + if (settings_fs->exists(temporary)) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file(*settings_fs); + if (!file.open(temporary, FILE_O_WRITE)) return false; +#elif defined(RP2040_PLATFORM) + File file = settings_fs->open(temporary, "w"); + if (!file) return false; +#else + File file = settings_fs->open(temporary, "w", true); + if (!file) return false; +#endif + const bool written = file.write(data, sizeof(images[0])) == sizeof(images[0]); + file.close(); + OLEDControllerMode checked = OLEDControllerMode::Auto; + if (!written || !readPrefs(temporary, checked) || checked != mode) return false; + if (settings_fs->exists(backup) && !settings_fs->remove(backup)) return false; + if (settings_fs->exists(path) && !settings_fs->rename(path, backup)) return false; + if (!settings_fs->rename(temporary, path)) { + if (settings_fs->exists(backup) && !settings_fs->rename(backup, path)) { + recovery_pending = true; + } + return false; + } + settings_fs->remove(backup); + return true; +#endif +} + +bool equalMode(const char* value, const char* expected) { + while (*value && *expected) { + const char c = *value >= 'A' && *value <= 'Z' ? *value + ('a' - 'A') : *value; + if (c != *expected) return false; + ++value; + ++expected; + } + return *value == 0 && *expected == 0; +} +} + +bool loadOLEDControllerSettings(FILESYSTEM* fs) { + settings_fs = fs; + active_mode = configured_mode = defaultOLEDControllerMode(); + saved_mode = recovery_pending = false; + if (!fs) return false; + OLEDControllerMode loaded = defaultOLEDControllerMode(); + bool found = readPrefs(path, loaded); + if (!found && readPrefs(backup, loaded)) { + found = true; + recovery_pending = (fs->exists(path) && !fs->remove(path)) + || !fs->rename(backup, path); + } + if (found) { + active_mode = configured_mode = loaded; + saved_mode = true; + } + return found; +} + +OLEDControllerMode oledControllerMode() { return active_mode; } + +bool handleOLEDControllerCommand(const char* command, char* reply, + size_t reply_size, bool supported) { + const bool get = strncmp(command, "get ", 4) == 0; + const bool set = strncmp(command, "set ", 4) == 0; + if (!get && !set) return false; + const char* key = command + 4; + constexpr size_t key_length = sizeof("display.controller") - 1; + if (strncmp(key, "display.controller", key_length) != 0 + || (key[key_length] != 0 && key[key_length] != ' ')) return false; + if (!supported) { + snprintf(reply, reply_size, "Error: display controller unsupported"); + return true; + } + const char* value = key + key_length; + if (get) { + if (*value) snprintf(reply, reply_size, "Error: unexpected argument"); + else snprintf(reply, reply_size, "display.controller %s (active %s%s)", + modeName(configured_mode), modeName(active_mode), + configured_mode != active_mode ? "; restart required" : ""); + return true; + } + while (*value == ' ') ++value; + OLEDControllerMode candidate; + if (equalMode(value, "auto")) candidate = OLEDControllerMode::Auto; + else if (equalMode(value, "ssd1306")) candidate = OLEDControllerMode::SSD1306; + else if (equalMode(value, "sh1106")) candidate = OLEDControllerMode::SH1106; + else { + snprintf(reply, reply_size, "Error: use set display.controller "); + return true; + } + if ((!saved_mode || candidate != configured_mode) && !savePrefs(candidate)) { + snprintf(reply, reply_size, "Error: display controller save failed"); + return true; + } + configured_mode = candidate; + saved_mode = true; + snprintf(reply, reply_size, "OK - display controller %s%s", modeName(candidate), + configured_mode != active_mode ? "; restart required" : ""); + return true; +} + +}} // namespace mesh::ui + +#endif // OLED_AUTO_DETECT diff --git a/src/helpers/ui/OLEDControllerSettings.h b/src/helpers/ui/OLEDControllerSettings.h new file mode 100644 index 000000000..144c37855 --- /dev/null +++ b/src/helpers/ui/OLEDControllerSettings.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include "OLEDDisplayProbe.h" + +namespace mesh { namespace ui { + +// Separate, checked storage leaves existing node preference layouts unchanged. +// Load before display startup. A saved Auto selection overrides a build override. +bool loadOLEDControllerSettings(FILESYSTEM* fs); + +// The mode selected at startup. Saved changes take effect after a restart. +OLEDControllerMode oledControllerMode(); + +bool handleOLEDControllerCommand(const char* command, char* reply, + size_t reply_size, bool supported); + +}} // namespace mesh::ui diff --git a/src/helpers/ui/OLEDDisplayProbe.h b/src/helpers/ui/OLEDDisplayProbe.h index fc46ea840..20e95db86 100644 --- a/src/helpers/ui/OLEDDisplayProbe.h +++ b/src/helpers/ui/OLEDDisplayProbe.h @@ -6,6 +6,18 @@ namespace mesh { namespace ui { enum class OLEDController : uint8_t { Absent, SSD1306, SH1106 }; +enum class OLEDControllerMode : uint8_t { Auto, SSD1306, SH1106 }; + +constexpr OLEDControllerMode defaultOLEDControllerMode() { +#if defined(OLED_CONTROLLER_OVERRIDE) + static_assert(OLED_CONTROLLER_OVERRIDE >= 0 && OLED_CONTROLLER_OVERRIDE <= 2, + "OLED_CONTROLLER_OVERRIDE must be 0 (auto), 1 (SSD1306) or 2 (SH1106)"); + return static_cast(OLED_CONTROLLER_OVERRIDE); +#else + return OLEDControllerMode::Auto; +#endif +} + // Status-byte detection is used by ss_oled and Meshtastic. Only 0x08 is // evidence of SH1106: some RAK1921 SSD1306 panels return the ambiguous 0x00. // See https://github.com/meshtastic/firmware/pull/11265. diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 82dbec97d..e066e6993 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -1,4 +1,7 @@ #include "SSD1306Display.h" +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT +#include "OLEDControllerSettings.h" +#endif #if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET)) // Adafruit_SSD1306::begin allocates WIDTH * ((HEIGHT + 7) / 8) once and @@ -34,7 +37,11 @@ bool SSD1306Display::begin() { _isOn = true; } const bool started = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, - true, false) + true, false +#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT + , mesh::ui::oledControllerMode() +#endif + ) && i2c_probe(Wire, DISPLAY_ADDRESS); if (started) { _panel_ready = true; diff --git a/test/test_display_power.py b/test/test_display_power.py index 790fb7339..29f572cee 100644 --- a/test/test_display_power.py +++ b/test/test_display_power.py @@ -15,44 +15,84 @@ FS_STUB = r''' #include #include #include +#define FILE_O_READ 1 +#define FILE_O_WRITE 2 struct FakeFS; struct File { FakeFS* fs = nullptr; std::string name; + File() = default; + explicit File(FakeFS& backing) : fs(&backing) {} + File(FakeFS* backing, const char* path) : fs(backing), name(path) {} explicit operator bool() const { return fs != nullptr; } + bool open(const char*, int); size_t size() const; size_t read(uint8_t*, size_t); size_t write(const uint8_t*, size_t); + void flush() {} + bool isDirectory() const { return false; } void close() {} }; struct FakeFS { std::map> files; bool fail_write = false, fail_publish = false; - unsigned calls = 0; + bool corrupt_write = false, fail_temp_read = false, fail_open_write = false; + bool false_publish = false, late_publish_error = false, fail_public_read = false; + bool corrupt_public = false, replace_after_validation = false, published = false; + unsigned calls = 0, public_reads = 0; bool exists(const char* name) { ++calls; return files.count(name); } bool remove(const char* name) { ++calls; files.erase(name); return true; } bool rename(const char* from, const char* to) { ++calls; if (fail_publish && std::string(from) == "/display_prefs.tmp") return false; - if (!exists(from) || exists(to)) return false; - files[to] = files[from]; files.erase(from); return true; + if (!exists(from)) return false; +#if !defined(NRF52_PLATFORM) + if (exists(to)) return false; +#endif + if (false_publish && std::string(from) == "/display_prefs.tmp") return true; + files[to] = files[from]; files.erase(from); + if (std::string(from) == "/display_prefs.tmp") { + published = true; public_reads = 0; + if (corrupt_public) files[to][0] ^= 1; + if (late_publish_error) return false; + } + return true; } File open(const char* name, const char* mode, bool = false) { ++calls; - if (*mode == 'w') files[name].clear(); + if (*mode == 'w') { + if (fail_open_write) return {}; + files[name].clear(); + } if (!exists(name)) return {}; - return File{this, name}; + return File(this, name); } }; +inline bool File::open(const char* path, int mode) { + *this = fs->open(path, mode == FILE_O_WRITE ? "w" : "r"); + return bool(*this); +} inline size_t File::size() const { return fs->files[name].size(); } inline size_t File::read(uint8_t* out, size_t count) { + if (fs->fail_temp_read && name == "/display_prefs.tmp") return 0; + if (fs->published && name == "/display_prefs") { + if (fs->fail_public_read) return 0; + // Change to another valid image only after AtomicFileWriter's public + // verification, exercising the caller's independent exact-format readback. + if (++fs->public_reads == 2 && fs->replace_after_validation) { + auto& altered = fs->files[name]; + altered[4] ^= 1; altered[11] ^= 1; + } + } const auto& bytes = fs->files[name]; count = count < bytes.size() ? count : bytes.size(); memcpy(out, bytes.data(), count); return count; } inline size_t File::write(const uint8_t* in, size_t count) { - if (fs->fail_write) return 0; - fs->files[name].assign(in, in + count); return count; + const size_t written = fs->fail_write ? count / 2 : count; + fs->files[name].assign(in, in + written); + if (fs->corrupt_write && written) fs->files[name][0] ^= 1; + return written; } #define FILESYSTEM FakeFS ''' @@ -60,6 +100,10 @@ inline size_t File::write(const uint8_t* in, size_t count) { TEST = r''' #include #include +#if defined(NRF52_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#endif using namespace mesh::ui; void policyTests() { for (unsigned mode = 0; mode <= 5; ++mode) { @@ -329,6 +373,64 @@ int main() { ''' +NRF_ATOMIC_TESTS = r''' +// Independent persisted bytes preserve the existing v2 disk layout. +std::vector powerRecord(uint16_t timeout) { + std::vector data = {'D', 'P', 2, 4, uint8_t(timeout), + uint8_t(timeout >> 8), 4, 15, 0, 0, 0, 0}; + for (unsigned i = 0; i < 11; ++i) data[11] ^= data[i]; + return data; +} +void atomicPersistenceTests() { + const auto previous = powerRecord(15), candidate = powerRecord(27); + for (unsigned failure = 0; failure < 9; ++failure) { + FakeFS fs; + fs.files["/display_prefs"] = previous; + fs.files["/display_prefs.bak"] = previous; + assert(loadDisplayPowerSettings(&fs, true)); + switch (failure) { + case 0: fs.fail_write = true; break; + case 1: fs.corrupt_write = true; break; + case 2: fs.fail_temp_read = true; break; + case 3: fs.fail_open_write = true; break; + case 4: fs.fail_publish = true; break; + case 5: fs.false_publish = true; break; + case 6: fs.fail_public_read = true; break; + case 7: fs.corrupt_public = true; break; + case 8: fs.replace_after_validation = true; break; + } + char reply[160]; + assert(handleDisplayPowerCommand("set display.timeout 27", reply, sizeof(reply))); + assert(std::string(reply) == "Error: display settings save failed"); + assert(displayPowerPrefs().battery.seconds == 15); + assert(fs.files.at("/display_prefs.bak") == previous); + assert(!fs.files.count("/display_prefs.tmp")); + if (failure < 6) assert(fs.files.at("/display_prefs") == previous); + if (failure == 6) assert(fs.files.at("/display_prefs") == candidate); + // A post-publication failure is not proof that the old record survived. + if (failure >= 7) assert(fs.files.at("/display_prefs") != previous); + } + for (bool late_error : {false, true}) { + FakeFS fs; + fs.files["/display_prefs"] = previous; + fs.files["/display_prefs.bak"] = previous; + fs.files["/display_prefs.tmp"] = {'s', 't', 'a', 'l', 'e'}; + assert(loadDisplayPowerSettings(&fs, true)); + fs.late_publish_error = late_error; + char reply[160]; + assert(handleDisplayPowerCommand("set display.timeout 27", reply, sizeof(reply))); + assert(std::string(reply) == "OK"); + assert(fs.files.at("/display_prefs") == candidate); + assert(!fs.files.count("/display_prefs.bak")); + assert(!fs.files.count("/display_prefs.tmp")); + assert(displayPowerPrefs().battery.seconds == 27); + assert(loadDisplayPowerSettings(&fs, true)); + assert(displayPowerPrefs().battery.seconds == 27); + } +} +''' + + HEADLESS_TEST = r''' #include #include @@ -443,6 +545,27 @@ class DisplayPowerTest(unittest.TestCase): result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_nrf52_atomic_publication_failures_and_legacy_format(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "host C++ compiler required") + with tempfile.TemporaryDirectory(prefix="mesh-nrf-display-test-") as name: + work = Path(name) + (work / "helpers").mkdir() + (work / "helpers/IdentityStore.h").write_text(FS_STUB, encoding="ascii") + code = TEST.replace("int main() {", NRF_ATOMIC_TESTS + "\nint main() {\n atomicPersistenceTests();") + (work / "test.cpp").write_text(code, encoding="ascii") + executable = work / "nrf-display-test.exe" + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DNRF52_PLATFORM", "-DDISPLAY_CLASS=SSD1306Display", + "-I" + str(work), "-I" + str(ROOT / "src"), + str(work / "test.cpp"), str(ROOT / "src/helpers/ui/DisplayPowerSettings.cpp"), + "-o", str(executable), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/test/test_oled_controller_settings.py b/test/test_oled_controller_settings.py new file mode 100644 index 000000000..91ef21dd2 --- /dev/null +++ b/test/test_oled_controller_settings.py @@ -0,0 +1,405 @@ +"""Exercise real OLED controller persistence and command code on the host.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +# Adapted from test_display_power.py, with per-operation failures and the nRF52 +# File API so the same persistence behavior is exercised on both FS APIs. +FS_STUB = r''' +#pragma once +#include +#include +#include +#include +#include +#define FILE_O_READ 1 +#define FILE_O_WRITE 2 +struct FakeFS; +struct File { + FakeFS* fs = nullptr; + std::string name; + File() = default; + explicit File(FakeFS& backing) : fs(&backing) {} + File(FakeFS* backing, const char* path) : fs(backing), name(path) {} + explicit operator bool() const { return fs != nullptr; } + bool open(const char*, int); + size_t size() const; + size_t read(uint8_t*, size_t); + size_t write(const uint8_t*, size_t); + void flush() {} + bool isDirectory() const { return false; } + void close() {} +}; +struct FakeFS { + std::map> files; + bool fail_write = false, corrupt_write = false, fail_temp_read = false; + bool fail_open_write = false, fail_publish = false, fail_rollback = false; + bool fail_backup = false, fail_remove_backup = false, fail_remove_temp = false; + bool fail_remove_primary = false; + bool false_publish = false, late_publish_error = false, fail_public_read = false; + bool published = false; + unsigned calls = 0, writes = 0; + bool exists(const char* name) { ++calls; return files.count(name); } + bool remove(const char* name) { + ++calls; + const std::string path(name); + if ((fail_remove_backup && path == "/display_controller.bak") + || (fail_remove_temp && path == "/display_controller.tmp") + || (fail_remove_primary && path == "/display_controller")) return false; + files.erase(name); return true; + } + bool rename(const char* from, const char* to) { + ++calls; + const std::string source(from); + if ((fail_publish && source == "/display_controller.tmp") + || (fail_rollback && source == "/display_controller.bak") + || (fail_backup && source == "/display_controller")) return false; + if (!exists(from)) return false; +#if !defined(NRF52_PLATFORM) + if (exists(to)) return false; +#endif + if (source == "/display_controller.tmp" && false_publish) return true; + files[to] = files[from]; files.erase(from); + if (source == "/display_controller.tmp") { + published = true; + if (late_publish_error) return false; + } + return true; + } + File open(const char* name, const char* mode, bool = false) { + ++calls; + if (*mode == 'w') { + if (fail_open_write) return {}; + files[name].clear(); + } + if (!exists(name)) return {}; + return File(this, name); + } +}; +inline bool File::open(const char* path, int flags) { + *this = fs->open(path, flags == FILE_O_WRITE ? "w" : "r"); + return bool(*this); +} +inline size_t File::size() const { return fs->files[name].size(); } +inline size_t File::read(uint8_t* out, size_t count) { + if (fs->fail_temp_read && name == "/display_controller.tmp") return 0; + if (fs->fail_public_read && fs->published && name == "/display_controller") return 0; + const auto& bytes = fs->files[name]; + count = count < bytes.size() ? count : bytes.size(); + memcpy(out, bytes.data(), count); return count; +} +inline size_t File::write(const uint8_t* in, size_t count) { + ++fs->writes; + if (fs->fail_write) { + fs->files[name].assign(in, in + count / 2); + return count / 2; + } + fs->files[name].assign(in, in + count); + if (fs->corrupt_write && count) fs->files[name][0] ^= 1; + return count; +} +#define FILESYSTEM FakeFS +''' + +TEST = r''' +#include +#include +#if defined(NRF52_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#endif +using namespace mesh::ui; +const char* name(OLEDControllerMode mode) { + return mode == OLEDControllerMode::Auto ? "auto" + : mode == OLEDControllerMode::SSD1306 ? "ssd1306" : "sh1106"; +} +void run(const char* command, const std::string& expected, bool supported = true) { + char reply[160]; + assert(handleOLEDControllerCommand(command, reply, sizeof(reply), supported)); + assert(std::string(reply) == expected); +} +std::string getter(OLEDControllerMode saved, OLEDControllerMode active) { + return std::string("display.controller ") + name(saved) + " (active " + name(active) + + (saved != active ? "; restart required)" : ")"); +} +void set(OLEDControllerMode mode, OLEDControllerMode active) { + run((std::string("set display.controller ") + name(mode)).c_str(), + std::string("OK - display controller ") + name(mode) + + (mode != active ? "; restart required" : "")); +} +std::vector record(OLEDControllerMode mode) { + std::vector data = {'O', 'C', 1, uint8_t(mode), 0, 0}; + for (unsigned i = 0; i < 5; ++i) data[5] ^= data[i]; + return data; +} +void commandsAndRestart() { + FakeFS fs; + const auto initial = defaultOLEDControllerMode(); + assert(!loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == initial); + assert(fs.files.empty()); + run("get display.controller", getter(initial, initial)); + char reply[160]; + for (const char* text : {"get display.controllers", "set display.controllerx auto", + "get display.mode", "display.controller", ""}) { + assert(!handleOLEDControllerCommand(text, reply, sizeof(reply), true)); + } + for (const char* value : {"", "0", "1", "ssd1305", "ssD1306 extra", "sh1106x", + "auto sh1106", "sh1106 ", "auto\t", "auto\n", "+auto"}) { + run((std::string("set display.controller ") + value).c_str(), + "Error: use set display.controller "); + assert(fs.files.empty() && fs.writes == 0); + } + run("get display.controller extra", "Error: unexpected argument"); + run("get display.controller ", "Error: unexpected argument"); + run("set display.controller sh1106", "Error: display controller unsupported", false); + run("get display.controller", "Error: display controller unsupported", false); + assert(fs.files.empty() && oledControllerMode() == initial); + // Explicitly selecting the default must survive later build-default changes. + set(initial, initial); + assert(fs.files.at("/display_controller") == record(initial) && fs.writes == 1); + const unsigned initial_calls = fs.calls; + set(initial, initial); + assert(fs.calls == initial_calls && fs.writes == 1); + const auto next = initial == OLEDControllerMode::SH1106 + ? OLEDControllerMode::SSD1306 : OLEDControllerMode::SH1106; + run(next == OLEDControllerMode::SH1106 ? "set display.controller SH1106" + : "set display.controller SsD1306", + std::string("OK - display controller ") + name(next) + "; restart required"); + assert(oledControllerMode() == initial); + run("get display.controller", getter(next, initial)); + assert(fs.files.at("/display_controller") == record(next)); + const auto complete = fs.files; + const auto calls = fs.calls; + const auto writes = fs.writes; + set(next, initial); + assert(fs.files == complete && fs.calls == calls && fs.writes == writes); + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == next); + run("get display.controller", getter(next, next)); + run("set display.controller AuTo", "OK - display controller auto; restart required"); + assert(oledControllerMode() == next); + assert(fs.files.at("/display_controller") == record(OLEDControllerMode::Auto)); + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == OLEDControllerMode::Auto); + // A saved Auto is authoritative even in a build compiled to force a chip. + run("get display.controller", getter(OLEDControllerMode::Auto, OLEDControllerMode::Auto)); + set(OLEDControllerMode::SH1106, OLEDControllerMode::Auto); + set(OLEDControllerMode::Auto, OLEDControllerMode::Auto); + run("get display.controller", getter(OLEDControllerMode::Auto, OLEDControllerMode::Auto)); + char short_reply[4] = {'?', '?', '?', '?'}; + assert(handleOLEDControllerCommand("get display.controller", short_reply, + sizeof(short_reply), true)); + assert(short_reply[3] == '\0'); + assert(!loadOLEDControllerSettings(nullptr)); + assert(oledControllerMode() == initial); + run((std::string("set display.controller ") + name(initial)).c_str(), + "Error: display controller save failed"); + assert(oledControllerMode() == initial); + run("get display.controller", getter(initial, initial)); +} +void failedSaves() { + for (unsigned failure = 0; failure < 9; ++failure) { + FakeFS fs; + fs.files["/display_controller"] = record(OLEDControllerMode::SSD1306); + assert(loadOLEDControllerSettings(&fs)); + const auto saved = fs.files.at("/display_controller"); + switch (failure) { + case 0: fs.fail_write = true; break; + case 1: fs.corrupt_write = true; break; + case 2: fs.fail_temp_read = true; break; + case 3: fs.fail_open_write = true; break; + case 4: fs.fail_publish = true; break; + case 5: fs.fail_backup = true; break; + case 6: + fs.files["/display_controller.bak"] = saved; + fs.fail_remove_backup = true; + break; + case 7: + fs.files["/display_controller.tmp"] = {1, 2, 3}; + fs.fail_remove_temp = true; + break; + case 8: fs.fail_publish = fs.fail_rollback = true; break; + } + run("set display.controller sh1106", "Error: display controller save failed"); + assert(oledControllerMode() == OLEDControllerMode::SSD1306); + run("get display.controller", getter(OLEDControllerMode::SSD1306, OLEDControllerMode::SSD1306)); + if (failure == 8) { + assert(!fs.files.count("/display_controller")); + assert(fs.files.at("/display_controller.bak") == saved); + const auto files = fs.files; + const auto calls = fs.calls; + // A second command before reboot must not rotate away the sole backup. + run("set display.controller auto", "Error: display controller save failed"); + assert(fs.files == files && fs.calls == calls); + run("get display.controller", getter(OLEDControllerMode::SSD1306, OLEDControllerMode::SSD1306)); + } else assert(fs.files.at("/display_controller") == saved); + fs.fail_publish = fs.fail_rollback = false; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == OLEDControllerMode::SSD1306); + assert(fs.files.at("/display_controller") == saved); + } +} +#if defined(NRF52_PLATFORM) +void atomicSaves() { + for (unsigned failure = 0; failure < 7; ++failure) { + FakeFS fs; + fs.files["/display_controller"] = record(OLEDControllerMode::SSD1306); + assert(loadOLEDControllerSettings(&fs)); + const auto saved = fs.files.at("/display_controller"); + switch (failure) { + case 0: fs.fail_write = true; break; + case 1: fs.corrupt_write = true; break; + case 2: fs.fail_temp_read = true; break; + case 3: fs.fail_open_write = true; break; + case 4: fs.fail_publish = true; break; + case 5: fs.false_publish = true; break; + case 6: fs.fail_public_read = true; break; + } + run("set display.controller sh1106", "Error: display controller save failed"); + assert(oledControllerMode() == OLEDControllerMode::SSD1306); + run("get display.controller", getter(OLEDControllerMode::SSD1306, OLEDControllerMode::SSD1306)); + if (failure < 6) assert(fs.files.at("/display_controller") == saved); + else assert(fs.files.at("/display_controller") == record(OLEDControllerMode::SH1106)); + if (failure >= 4) { + const auto files = fs.files; + const auto calls = fs.calls; + // Indeterminate publication must block any later write until reloaded. + run("set display.controller auto", "Error: display controller save failed"); + assert(fs.files == files && fs.calls == calls); + } + fs.fail_public_read = false; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == (failure < 6 ? OLEDControllerMode::SSD1306 + : OLEDControllerMode::SH1106)); + } + FakeFS fs; + fs.files["/display_controller"] = record(OLEDControllerMode::SSD1306); + fs.files["/display_controller.bak"] = record(OLEDControllerMode::Auto); + assert(loadOLEDControllerSettings(&fs)); + fs.late_publish_error = true; + // Public-byte validation, rather than rename's return code, decides success. + set(OLEDControllerMode::SH1106, OLEDControllerMode::SSD1306); + assert(!fs.files.count("/display_controller.bak")); + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == OLEDControllerMode::SH1106); +} +#endif +void corruptionAndRecovery() { + const auto desired = OLEDControllerMode::SH1106; + const auto good = record(desired); + for (unsigned corrupt = 0; corrupt < 9; ++corrupt) { + FakeFS fs; + auto bytes = good; + if (corrupt < 6) bytes[corrupt] ^= 1; + else if (corrupt == 6) bytes.pop_back(); + else if (corrupt == 7) bytes.push_back(0); + else { + bytes[3] = 3; bytes[5] = 0; + for (unsigned i = 0; i < 5; ++i) bytes[5] ^= bytes[i]; + } + fs.files["/display_controller"] = bytes; + fs.files["/display_controller.tmp"] = good; + assert(!loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == defaultOLEDControllerMode()); + assert(fs.writes == 0); // Unpublished temp data is never selected. + fs.files["/display_controller.bak"] = good; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == desired); + assert(fs.files.at("/display_controller") == good); + assert(!fs.files.count("/display_controller.bak")); + } + FakeFS fs; + fs.files["/display_controller.bak"] = good; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == desired); + // A valid published primary wins over a stale backup. + fs.files["/display_controller"] = record(OLEDControllerMode::Auto); + fs.files["/display_controller.bak"] = good; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == OLEDControllerMode::Auto); + fs.files.erase("/display_controller"); + fs.fail_rollback = true; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == desired); // Readable backup survives repair failure. + assert(fs.files.at("/display_controller.bak") == good); + const auto before = fs.files; + const auto calls = fs.calls; + set(desired, desired); + assert(fs.files == before && fs.calls == calls); + run("set display.controller auto", "Error: display controller save failed"); + assert(fs.files == before && oledControllerMode() == desired); + fs.fail_rollback = false; + assert(loadOLEDControllerSettings(&fs)); + assert(fs.files.at("/display_controller") == good); + set(OLEDControllerMode::Auto, desired); + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == OLEDControllerMode::Auto); + // Failure to remove a corrupt primary must also protect the sole good backup. + fs.files["/display_controller"] = {1, 2, 3}; + fs.files["/display_controller.bak"] = good; + fs.fail_remove_primary = true; + assert(loadOLEDControllerSettings(&fs)); + assert(oledControllerMode() == desired); + const auto recovery_files = fs.files; + run("set display.controller auto", "Error: display controller save failed"); + assert(fs.files == recovery_files); + fs.fail_remove_primary = false; + assert(loadOLEDControllerSettings(&fs)); + assert(fs.files.at("/display_controller") == good); +} +int main() { + commandsAndRestart(); +#if defined(NRF52_PLATFORM) + atomicSaves(); +#else + failedSaves(); +#endif + corruptionAndRecovery(); +} +''' + + +class OLEDControllerSettingsTests(unittest.TestCase): + def compile_and_run(self, default_mode=0, platform=None): + compiler = shutil.which("g++") + if compiler is None: + self.skipTest("g++ unavailable") + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + include = temporary / "helpers" + include.mkdir() + (include / "IdentityStore.h").write_text(FS_STUB, encoding="ascii") + source = temporary / "main.cpp" + source.write_text(TEST, encoding="ascii") + executable = temporary / "test" + command = [ + compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-DOLED_AUTO_DETECT=1", f"-DOLED_CONTROLLER_OVERRIDE={default_mode}", + f"-I{temporary}", f"-I{ROOT / 'src'}", str(source), + str(ROOT / "src/helpers/ui/OLEDControllerSettings.cpp"), + "-o", str(executable), + ] + if platform: + command.insert(1, f"-D{platform}") + subprocess.run(command, check=True, capture_output=True, text=True) + subprocess.run([str(executable)], check=True, capture_output=True, text=True) + + def test_saved_mode_and_atomic_failures_for_each_build_default(self): + for mode in (0, 1, 2): + with self.subTest(default_mode=mode): + self.compile_and_run(default_mode=mode) + + def test_nrf52_filesystem_api(self): + self.compile_and_run(platform="NRF52_PLATFORM") + + def test_rp2040_filesystem_api(self): + self.compile_and_run(platform="RP2040_PLATFORM") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_oled_display_probe.py b/test/test_oled_display_probe.py index 0bcfaa320..ea5825099 100644 --- a/test/test_oled_display_probe.py +++ b/test/test_oled_display_probe.py @@ -361,6 +361,34 @@ static unsigned totalRequests(const TwoWire& wire) { int main() { resetPins(); + // A saved selection overrides either legacy build default. In particular, + // force SH1106 for ambiguous zero replies, and restore probing with Auto. + for (const auto mode : {mesh::ui::OLEDControllerMode::Auto, + mesh::ui::OLEDControllerMode::SSD1306, + mesh::ui::OLEDControllerMode::SH1106}) { + TwoWire wire; + wire.status[0x3C] = {0, 0}; + AutoOLEDBackend display(128, 64, &wire, -1); + assert(display.begin(2, 0x3C, false, false, mode)); + const bool sh = mode == mesh::ui::OLEDControllerMode::SH1106; + assert(display.controller() == (sh ? OLEDController::SH1106 : OLEDController::SSD1306)); + assert(totalRequests(wire) == (mode == mesh::ui::OLEDControllerMode::Auto ? 2 : 0)); + if (sh) verifyFrame(display, wire); + else { + display.display(); + assert(display.base_display_calls == 1); + } + } + assert(active_allocations == 0); + { + TwoWire wire; + AutoOLEDBackend display(128, 64, &wire, -1); + assert(display.begin(2, 0x3C, false, false, mesh::ui::OLEDControllerMode::Auto)); + assert(display.controller() == OLEDController::SH1106 && totalRequests(wire) == 2); + verifyFrame(display, wire); + } + assert(active_allocations == 0); + resetPins(); #if defined(OLED_CONTROLLER_OVERRIDE) && OLED_CONTROLLER_OVERRIDE == 1 { TwoWire wire;