diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8ae675d0e..1ee58d8c1 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -407,6 +407,7 @@ jobs: python3 -B test/test_companion_preferences_transaction.py -v python3 -B test/test_companion_prefs_values.py -v python3 -B test/test_companion_prefs_image.py -v + python3 -B test/test_companion_timezone_prefs.py -v python3 -B test/test_companion_prefs_tail.py -v python3 -B test/test_companion_radio_settings_transaction.py -v python3 -B test/test_companion_prefs_transactions.py -v @@ -757,6 +758,7 @@ jobs: test_indicator_messages_profile test_t096_message_footer_profile test_indicator_display_profile test_indicator_render_profile test_indicator_exclusive_transport test_companion_transport_selector + test_companion_home_ble_profiles - name: Verify independent USB packet/debug logging, recovery, and instructions run: | diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 967f5ff4a..d65858ad8 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -355,6 +355,29 @@ set display.rotation 0 `0` resets the screen to that board's compiled default orientation. +The display clock accepts decimal hour offsets, including half-hour and +quarter-hour timezones, from `-12` through `+14`: + +```text +get tz.offset +set tz.offset 5.75 +``` + +This Companion display setting is separate from MQTT's `timezone.offset`. +Invalid values leave the previous setting unchanged; a failed save restores +its exact previous fractional value. + +Preference migration retains the published signed one-byte hour at offset +225 and every following legacy field. Integer settings keep the existing +image format. Fractional settings append a 10-byte `TZF` version-1 footer +after the existing board and `TXR` footers: a little-endian 32-bit float and +CRC-16/CCITT covering the first eight bytes, stored most-significant byte +first. The old byte contains the hour truncated toward zero. A missing footer +uses that legacy integer; malformed, truncated, nonfinite or out-of-range +footers reject the complete image before changing live preferences. Older +firmware with strict image-length validation cannot read a fractional image; +set an integer offset and save before downgrading. + Heltec E290 and T190 now use their Full Companion artifacts for simultaneous USB, BLE, and WiFi. Their older `usb_ble`, USB-only, and BLE-only names remain available only as explicit compatibility builds. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4fce620d7..d37cfd75a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1123,6 +1123,9 @@ bool DataStore::loadPrefsInt(const char *filename, loaded_prefs.lost_reply = 0; loaded_prefs.tx_media = mesh::RADIO_TX_AUTO; loaded_prefs.tx_ssh = mesh::RADIO_TX_AUTO; + // Images predating byte 225 inherit the caller's valid timezone default. + loaded_prefs.tz_offset_legacy = mesh::companion::timezone::valid(_prefs.tz_offset) + ? static_cast(_prefs.tz_offset) : 0; #if defined(RAK_4631) loaded_prefs.peripherals_shared = 0; #endif @@ -1225,8 +1228,24 @@ bool DataStore::loadPrefsInt(const char *filename, 236 + mesh::companion::traffic::FooterSize, #endif }; + // Keep the old compact allowlist unchanged. A fractional image has the + // complete current prefix and all existing footers, followed by TZF v1. + // Compare this length in a wide type (RAK's new length exceeds 255). + static constexpr uint32_t TIMEZONE_PREFS_SIZE = +#ifdef TBEAM_1W + 243 +#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + 333 +#else + 236 +#endif +#if defined(RAK_4631) + + mesh::peripherals::FooterSize +#endif + + mesh::companion::traffic::FooterSize + + mesh::companion::timezone::FooterSize; const uint32_t prefs_size = file.size(); - bool known_size = false; + bool known_size = prefs_size == TIMEZONE_PREFS_SIZE; for (size_t i = 0; i < sizeof(KNOWN_PREFS_SIZES) / sizeof(KNOWN_PREFS_SIZES[0]); i++) { if (prefs_size == KNOWN_PREFS_SIZES[i]) { @@ -1360,7 +1379,7 @@ bool DataStore::loadPrefsInt(const char *filename, readOptionalField(&loaded_prefs.direct_tx_delay_factor, sizeof(loaded_prefs.direct_tx_delay_factor)); readOptionalField(&loaded_prefs.interference_threshold, sizeof(loaded_prefs.interference_threshold)); readOptionalField(&loaded_prefs.agc_reset_interval, sizeof(loaded_prefs.agc_reset_interval)); - readOptionalField(&loaded_prefs.tz_offset, sizeof(loaded_prefs.tz_offset)); + readOptionalField(&loaded_prefs.tz_offset_legacy, sizeof(loaded_prefs.tz_offset_legacy)); #ifdef TBEAM_1W readOptionalField(loaded_prefs.fan_mode, sizeof(loaded_prefs.fan_mode)); readOptionalField(&loaded_prefs.fan_lo, sizeof(loaded_prefs.fan_lo)); @@ -1391,6 +1410,9 @@ bool DataStore::loadPrefsInt(const char *filename, sizeof(loaded_prefs.usb_debug_enabled)); readOptionalField(&loaded_prefs.lost_reply, sizeof(loaded_prefs.lost_reply)); #endif + // Numeric conversion, never reinterpret the historic signed hour byte as + // the first byte of a float. A checked fractional footer may override it. + if (prefs_size >= 226) loaded_prefs.tz_offset = loaded_prefs.tz_offset_legacy; #if defined(RAK_4631) if (file.available() != 0) { uint8_t peripheral_footer[mesh::peripherals::FooterSize] = {}; @@ -1411,6 +1433,14 @@ bool DataStore::loadPrefsInt(const char *filename, sizeof(traffic_footer), loaded_prefs.tx_media, loaded_prefs.tx_ssh); } + if (prefs_size == TIMEZONE_PREFS_SIZE) { + uint8_t timezone_footer[mesh::companion::timezone::FooterSize] = {}; + success = success + && file.read(timezone_footer, sizeof(timezone_footer)) == sizeof(timezone_footer) + && mesh::companion::timezone::decodeFooter(timezone_footer, + sizeof(timezone_footer), loaded_prefs.tz_offset); + } + // Any bytes left over form only part of a historically appended field. // Preserve the file and defaults rather than treating that tail as EOF. success = success && file.available() == 0; @@ -1428,7 +1458,9 @@ bool DataStore::loadPrefsInt(const char *filename, } bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, double node_lon) { - if (_prefs_load_incomplete) return false; + if (_prefs_load_incomplete || !mesh::companion::timezone::valid(_prefs.tz_offset)) + return false; + const int8_t legacy_tz_offset = static_cast(_prefs.tz_offset); #if defined(ESP32_PLATFORM) if (!promoteCompanionRecoveryFile(_fs, "/new_prefs", _prefs_recovery_source)) return false; #endif @@ -1456,7 +1488,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou used = sizeof(image) + 1; break; } - memcpy(image + used, prefValue(field, _prefs, node_lat, node_lon, pad), + memcpy(image + used, prefValue(field, _prefs, node_lat, node_lon, pad, &legacy_tz_offset), field.length); used += field.length; } @@ -1471,7 +1503,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou for (const PrefField& field : PREF_FIELDS) { if (!success) break; success = file.write(static_cast( - prefValue(field, _prefs, node_lat, node_lon, pad)), field.length) + prefValue(field, _prefs, node_lat, node_lon, pad, &legacy_tz_offset)), field.length) == field.length; } #else @@ -1590,8 +1622,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou sizeof(_prefs.interference_threshold)); success = success && writeField((uint8_t *)&_prefs.agc_reset_interval, sizeof(_prefs.agc_reset_interval)); - success = success && writeField((uint8_t *)&_prefs.tz_offset, - sizeof(_prefs.tz_offset)); + success = success && writeField(&legacy_tz_offset, sizeof(legacy_tz_offset)); #ifdef TBEAM_1W success = success && writeField((uint8_t *)_prefs.fan_mode, sizeof(_prefs.fan_mode)); @@ -1632,6 +1663,12 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou _prefs.tx_media, _prefs.tx_ssh); success = success && file.write(traffic_footer, sizeof(traffic_footer)) == sizeof(traffic_footer); + if (mesh::companion::timezone::fractional(_prefs.tz_offset)) { + uint8_t timezone_footer[mesh::companion::timezone::FooterSize]; + mesh::companion::timezone::encodeFooter(timezone_footer, _prefs.tz_offset); + success = success + && file.write(timezone_footer, sizeof(timezone_footer)) == sizeof(timezone_footer); + } #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic preferences write failed"); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a4025ea36..92f5d1935 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -10994,15 +10994,16 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, #endif if (strcmp(command, "get tz.offset") == 0) { - sprintf(reply, "> %d", _prefs.tz_offset); + sprintf(reply, "> %.9g", static_cast(_prefs.tz_offset)); return true; } if (strncmp(command, "set tz.offset ", 14) == 0) { - int32_t tz; - if (!mesh::cli::parseIntegerStrict(command + 14, tz) || tz < -12 || tz > 14) { + float tz; + if (!mesh::cli::parseDecimalStrict(command + 14, tz) + || !mesh::companion::timezone::valid(tz)) { strcpy(reply, "Error, must be from -12 to +14"); } else { - const int8_t previous = _prefs.tz_offset; + const float previous = _prefs.tz_offset; _prefs.tz_offset = tz; if (savePrefs()) strcpy(reply, "OK"); else { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 9b5a655ab..d25581a1f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -11,6 +11,7 @@ #include #include #include +#include #include "BluetoothName.h" @@ -90,7 +91,9 @@ public: float direct_tx_delay_factor = 0.2f; uint8_t interference_threshold = 0; uint8_t agc_reset_interval = 0; // seconds / 4 - int8_t tz_offset = 0; + // Storage-only shadow for the published one-byte integer timezone slot. + // Fractional precision lives in the checked footer, never in this prefix. + int8_t tz_offset_legacy = 0; #ifdef TBEAM_1W char fan_mode[5] = "auto"; uint8_t fan_lo = 30; @@ -255,12 +258,16 @@ private: DynamicConfigSerializer custom; public: + // Keep the widened runtime value after the adapters: widening the historic + // one-byte member in place would move the compact ARM preference offsets. + float tz_offset = 0; + CompanionNodePrefs() : radio(this), custom(&radio) { } - // Only the scalar/array value prefix is a snapshot. Copying the complete - // object also copies adapter owner/fallback pointers and costs two large - // memberwise copies in the transactional loader. Keep both live adapters - // untouched, and leave a normally constructed snapshot's adapters local. + // Snapshot the scalar/array prefix and the separate runtime timezone. + // Copying the complete object also copies adapter owner/fallback pointers + // and costs two large memberwise copies in the transactional loader. Keep + // both live adapters untouched and a scratch snapshot's adapters local. bool copyPersistedValuesFrom(const CompanionNodePrefs& source) { if (&source == this) return true; const auto& last_value = tx_ssh; @@ -294,6 +301,7 @@ public: }; if (!bounded(*this) || !bounded(source)) return false; memcpy(&airtime_factor, &source.airtime_factor, size); + tz_offset = source.tz_offset; return true; } diff --git a/examples/companion_radio/PrefsStorageLayout.h b/examples/companion_radio/PrefsStorageLayout.h index 33d177c49..e63c4bd04 100644 --- a/examples/companion_radio/PrefsStorageLayout.h +++ b/examples/companion_radio/PrefsStorageLayout.h @@ -105,7 +105,7 @@ static constexpr PrefField PREF_FIELDS[] = { PREF_FIELD(direct_tx_delay_factor, 4), PREF_FIELD(interference_threshold, 1), PREF_FIELD(agc_reset_interval, 1), - PREF_FIELD(tz_offset, 1), + PREF_FIELD(tz_offset_legacy, 1), PREF_FIELD(flood_retry_attempts, 1), PREF_FIELD(flood_retry_max_path, 1), PREF_FIELD(flood_retry_group_max_path, 1), @@ -128,11 +128,13 @@ static_assert(wireSize(FIELD_COUNT) == 236, "published preference prefix size"); static const void* prefValue(const PrefField& field, const CompanionNodePrefs& values, const double& lat, const double& lon, - const uint8_t pad[4]) { + const uint8_t pad[4], + const int8_t* timezone = nullptr) { if (field.offset == PrefsLat) return ⪫ if (field.offset == PrefsLon) return &lon; if (field.offset == PrefsPad) return pad; - return reinterpret_cast(&values) + field.offset; + const void* value = reinterpret_cast(&values) + field.offset; + return timezone && value == &values.tz_offset_legacy ? timezone : value; } } // namespace companion_prefs diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a2075426e..a976b5278 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -23,6 +23,7 @@ #include "../CompanionWiFi.h" #include "target.h" #include +#include #if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID) #include #endif @@ -429,7 +430,7 @@ class HomeScreen : public UIScreen { RADIO_STATUS, #ifdef COMPANION_EXCLUSIVE_WIFI_BLE TRANSPORT, -#else +#elif defined(BLE_PIN_CODE) BLUETOOTH, #endif ADVERT, @@ -929,8 +930,11 @@ public: #ifdef UI_SHOW_CLOCK display.setTextSize(3); uint32_t now = _rtc->getCurrentTime(); - int8_t tz = _node_prefs->tz_offset; - now += (int32_t)tz * 3600; + const float tz = _node_prefs->tz_offset; + // Keep fractional hours and reject corrupt values before conversion. + if (isfinite(tz) && tz >= -12.0f && tz <= 14.0f) { + now += static_cast(tz * 3600.0f); + } DateTime dt (now); sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); rows.draw(tmp); @@ -1110,7 +1114,7 @@ public: display.width() / 2, layout.prompt_y, PRESS_LABEL); #endif display.setCompactText(false); -#else +#elif defined(BLE_PIN_CODE) } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); display.drawXbm((display.width() - 32) / 2, body_top - 2, @@ -1374,7 +1378,7 @@ public: if (requested != active) _task->shutdown(true); return true; } -#else +#elif defined(BLE_PIN_CODE) if (key == KEY_ENTER && _page == HomePage::BLUETOOTH) { if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off _task->disableBluetooth(); diff --git a/src/helpers/CompanionTimezonePrefs.h b/src/helpers/CompanionTimezonePrefs.h new file mode 100644 index 000000000..c92900d2a --- /dev/null +++ b/src/helpers/CompanionTimezonePrefs.h @@ -0,0 +1,46 @@ +#pragma once + +#include "CompanionTrafficPrefs.h" +#include +#include + +namespace mesh { namespace companion { namespace timezone { + +// Optional footer after all existing preference fields and PMO/TXR footers. +// Integer-only images retain their exact published bytes and lengths. +static constexpr size_t FooterSize = 10; + +inline bool valid(float hours) { + // Ordered comparisons also reject NaN and either infinity. + return hours >= -12.0f && hours <= 14.0f; +} + +inline bool fractional(float hours) { + return valid(hours) && hours != static_cast(hours); +} + +inline void encodeFooter(uint8_t out[FooterSize], float hours) { + static_assert(sizeof(float) == sizeof(uint32_t), "32-bit timezone float"); + out[0] = 'T'; out[1] = 'Z'; out[2] = 'F'; out[3] = 1; + uint32_t bits; + memcpy(&bits, &hours, sizeof(bits)); + for (unsigned i = 0; i < 4; ++i) out[4 + i] = uint8_t(bits >> (8 * i)); + const uint16_t crc = traffic::checksum(out, 8); + out[8] = uint8_t(crc >> 8); out[9] = uint8_t(crc); +} + +inline bool decodeFooter(const uint8_t* in, size_t size, float& hours) { + if (!in || size != FooterSize || in[0] != 'T' || in[1] != 'Z' + || in[2] != 'F' || in[3] != 1 + || traffic::checksum(in, 8) != uint16_t((in[8] << 8) | in[9])) + return false; + uint32_t bits = 0; + for (unsigned i = 0; i < 4; ++i) bits |= uint32_t(in[4 + i]) << (8 * i); + float decoded; + memcpy(&decoded, &bits, sizeof(decoded)); + if (!valid(decoded)) return false; + hours = decoded; + return true; +} + +} } } // namespace mesh::companion::timezone diff --git a/test/fixtures/companion_prefs_storage/legacy_loader.inc b/test/fixtures/companion_prefs_storage/legacy_loader.inc index ef6dc26b3..db3ba8534 100644 --- a/test/fixtures/companion_prefs_storage/legacy_loader.inc +++ b/test/fixtures/companion_prefs_storage/legacy_loader.inc @@ -209,7 +209,9 @@ bool DataStore::loadPrefsLegacy(const char *filename, readOptionalField(&loaded_prefs.direct_tx_delay_factor, sizeof(loaded_prefs.direct_tx_delay_factor)); readOptionalField(&loaded_prefs.interference_threshold, sizeof(loaded_prefs.interference_threshold)); readOptionalField(&loaded_prefs.agc_reset_interval, sizeof(loaded_prefs.agc_reset_interval)); - readOptionalField(&loaded_prefs.tz_offset, sizeof(loaded_prefs.tz_offset)); + // Frozen historical representation, converted numerically for the new RAM type. + readOptionalField(&loaded_prefs.tz_offset_legacy, 1); + loaded_prefs.tz_offset = loaded_prefs.tz_offset_legacy; #ifdef TBEAM_1W readOptionalField(loaded_prefs.fan_mode, sizeof(loaded_prefs.fan_mode)); readOptionalField(&loaded_prefs.fan_lo, sizeof(loaded_prefs.fan_lo)); diff --git a/test/fixtures/companion_prefs_storage/legacy_writer.inc b/test/fixtures/companion_prefs_storage/legacy_writer.inc index 8bbe0aa70..e2e2c4a10 100644 --- a/test/fixtures/companion_prefs_storage/legacy_writer.inc +++ b/test/fixtures/companion_prefs_storage/legacy_writer.inc @@ -113,8 +113,9 @@ bool DataStore::savePrefsLegacy(const CompanionNodePrefs& _prefs, double node_la sizeof(_prefs.interference_threshold)); success = success && writeField((uint8_t *)&_prefs.agc_reset_interval, sizeof(_prefs.agc_reset_interval)); - success = success && writeField((uint8_t *)&_prefs.tz_offset, - sizeof(_prefs.tz_offset)); + // Frozen historical representation: one signed integer hour byte. + const int8_t legacy_tz_offset = static_cast(_prefs.tz_offset); + success = success && writeField(&legacy_tz_offset, 1); #ifdef TBEAM_1W success = success && writeField((uint8_t *)_prefs.fan_mode, sizeof(_prefs.fan_mode)); diff --git a/test/test_companion_home_ble_profiles.py b/test/test_companion_home_ble_profiles.py new file mode 100644 index 000000000..0e500f642 --- /dev/null +++ b/test/test_companion_home_ble_profiles.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Compile the actual home-page enum, BLE rendering/input gates and clock math.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +UI = ROOT / "examples/companion_radio/ui-new/UITask.cpp" + + +class CompanionHomeBleProfilesTest(unittest.TestCase): + def test_actual_control_blocks_across_companion_profiles(self): + source = UI.read_text() + pages = extract_braced(source, " enum HomePage") + ";" + render_start = source.rfind("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", 0, + source.index(" } else if (_page == HomePage::TRANSPORT)")) + render = source[render_start:source.index( + " } else if (_page == HomePage::ADVERT)", render_start)] + input_start = source.rfind("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", 0, + source.index(" if (_page == HomePage::TRANSPORT\n")) + control = source[input_start:source.index( + " if (key == KEY_ENTER && _page == HomePage::FIRST)", input_start)] + + harness = r''' +#include +#include +#include +#define PRESS_LABEL "hold button" +#define TOUCH_CENTER_ZONE_PERCENT 70 +#define KEY_ENTER 13 +#define KEY_UP 0xb5 +#define KEY_DOWN 0xb6 +enum class UIColor {corp_blue, secondary_txt, primary_txt}; +enum class CompanionTransportMode {WiFi, Bluetooth}; +bool wifi_active=true, save_ok=true; +CompanionTransportMode selected=CompanionTransportMode::WiFi; +int saves=0, choices=0; +bool isCompanionWiFiEnabled() { return wifi_active; } +CompanionTransportMode getCompanionTransportMode() { return selected; } +bool selectCompanionTransportMode(CompanionTransportMode value) { + ++saves; + if (!save_ok) return false; + selected=value; return true; +} +namespace mesh { namespace ui { +struct Rect { int x=0,y=20,width=60,height=25; }; +struct CompanionTransportSelectorLayout { + bool show_title=false; + int title_y=14,prompt_y=53; + Rect wifi,bluetooth; +}; +CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout(int,int,int=34) { return {}; } +}} +const uint8_t bluetooth_on[]={1}, bluetooth_off[]={0}; +struct Display { + const uint8_t* icon=nullptr; + int width() const { return 128; } + int height() const { return 64; } + void setColor(UIColor) {} void setTextSize(int) {} void setCompactText(bool) {} + void drawTextCentered(int,int,const char*) {} + void drawXbm(int,int,const uint8_t* value,int,int) { icon=value; } +}; +void drawCompanionTransportChoice(Display&,int,int,int,int,const char*,bool,bool) { ++choices; } +struct Task { + bool enabled=false; + int enables=0,disables=0,reboots=0,alerts=0; + bool isBluetoothEnabled() const { return enabled; } + void enableBluetooth() { enabled=true; ++enables; } + void disableBluetooth() { enabled=false; ++disables; } + void showAlert(const char*,int) { ++alerts; } + void shutdown(bool reboot) { assert(reboot); ++reboots; } +}; +struct Home { +@PAGES@ + Task* _task; + int _page=ADVERT, advert_renders=0; + explicit Home(Task* task):_task(task) {} + void render(Display& display) { + const int body_top=20; + if (false) { +@RENDER@ + } else if (_page==ADVERT) { ++advert_renders; } + } + bool input(uint8_t key) { +@CONTROL@ + return false; + } +}; +int main() { + Task task; Home home(&task); Display display; + static_assert(Home::FIRST==0 && Home::RECENT==1 && Home::RADIO==2, + "ordinary and radio pages remain ordered"); + static_assert(Home::RADIO2==3 && Home::RADIO_STATUS==4, + "custom secondary-radio pages remain present"); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) || defined(BLE_PIN_CODE) + static_assert(Home::ADVERT==6 && Home::SHUTDOWN==7 && Home::DISCOVERY==8 && Home::Count==9, + "only the actual transport control consumes a page"); +#else + static_assert(Home::ADVERT==5 && Home::SHUTDOWN==6 && Home::DISCOVERY==7 && Home::Count==8, + "non-BLE profiles remove the inert page without losing sleep/discovery"); +#endif + home.render(display); assert(home.advert_renders==1); +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + static_assert(Home::TRANSPORT==5, "exclusive selector precedes advert"); + home._page=Home::TRANSPORT; + home.render(display); assert(choices==2 && display.icon==nullptr); + assert(home.input(KEY_DOWN)); + assert(selected==CompanionTransportMode::Bluetooth && saves==1 && task.reboots==1); + wifi_active=false; + assert(home.input(KEY_UP)); + assert(selected==CompanionTransportMode::WiFi && saves==2 && task.reboots==2); + // A failed save cannot reboot or change the selected transport. + save_ok=false; + assert(home.input(KEY_DOWN)); + assert(task.alerts==1 && task.reboots==2 && selected==CompanionTransportMode::WiFi); + assert(task.enables==0 && task.disables==0); +#elif defined(BLE_PIN_CODE) + static_assert(Home::BLUETOOTH==5, "BLE control precedes advert"); + home._page=Home::BLUETOOTH; + home.render(display); assert(display.icon==bluetooth_off); + assert(home.input(KEY_ENTER) && task.enabled && task.enables==1); + home.render(display); assert(display.icon==bluetooth_on); + assert(home.input(KEY_ENTER) && !task.enabled && task.disables==1); + assert(choices==0 && saves==0 && task.reboots==0); +#else + assert(!home.input(KEY_ENTER)); + assert(task.enables==0 && task.disables==0 && choices==0 && saves==0); +#endif +} +'''.replace("@PAGES@", pages).replace("@RENDER@", render).replace("@CONTROL@", control) + profiles = { + "usb_without_ble": ["-DENABLE_USB_INTERFACE=1"], + "ble": ["-DBLE_PIN_CODE=123456"], + "full_usb_with_ble": ["-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1", "-DBLE_PIN_CODE=123456"], + "exclusive_full": ["-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1", "-DBLE_PIN_CODE=123456", + "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", "-DHAS_TOUCH=1"], + } + with tempfile.TemporaryDirectory(prefix="companion-home-ble-") as directory: + for profile, flags in profiles.items(): + with self.subTest(profile=profile): + actual = subprocess.run(["c++", "-E", "-P", "-x", "c++", *flags, "-"], + input="\n".join((pages, render, control)), text=True, capture_output=True) + self.assertEqual(actual.returncode, 0, actual.stderr) + standalone = profile in ("ble", "full_usb_with_ble") + self.assertEqual(actual.stdout.count("HomePage::BLUETOOTH"), 2 if standalone else 0) + self.assertEqual("BLUETOOTH," in actual.stdout, standalone) + self.assertEqual("TRANSPORT," in actual.stdout, profile == "exclusive_full") + executable = Path(directory) / profile + built = subprocess.run(["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-variable", "-fsanitize=address,undefined", + "-fno-pie", "-no-pie", *flags, "-x", "c++", "-", "-o", str(executable)], + input=harness, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + tested = subprocess.run([str(executable)], text=True, capture_output=True) + self.assertEqual(tested.returncode, 0, tested.stderr) + + def test_actual_clock_keeps_fractional_hours_and_rejects_invalid_floats(self): + source = UI.read_text() + start = source.index(" uint32_t now = _rtc->getCurrentTime();") + clock = source[start:source.index(" DateTime dt (now);", start)] + harness = r''' +#include +#include +#include +#include +#include +struct RTC { uint32_t value; uint32_t getCurrentTime() const { return value; } }; +struct Prefs { float tz_offset; }; +uint32_t localTime(float hours) { + RTC rtc{1700000000}; Prefs prefs{hours}; + auto* _rtc=&rtc; auto* _node_prefs=&prefs; +@CLOCK@ + return now; +} +int main() { + assert(localTime(0)==1700000000u); + assert(localTime(5.5f)==1700019800u); + assert(localTime(-3.5f)==1699987400u); + assert(localTime(5.75f)==1700020700u); + assert(localTime(-9.25f)==1699966700u); + assert(localTime(-12)==1699956800u); + assert(localTime(14)==1700050400u); + for (float bad : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + FLT_MAX, -FLT_MAX, 14.01f, -12.01f}) assert(localTime(bad)==1700000000u); +} +'''.replace("@CLOCK@", clock) + with tempfile.TemporaryDirectory(prefix="companion-clock-") as directory: + executable = Path(directory) / "clock" + built = subprocess.run(["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-include", "initializer_list", "-fsanitize=undefined,float-cast-overflow", + "-fno-sanitize-recover=all", "-x", "c++", "-", "-o", str(executable)], + input=harness, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + tested = subprocess.run([str(executable)], text=True, capture_output=True) + self.assertEqual(tested.returncode, 0, tested.stderr) + + +if __name__ == "__main__": unittest.main() diff --git a/test/test_companion_prefs_image.py b/test/test_companion_prefs_image.py index 6e0672390..a0f5bd32e 100644 --- a/test/test_companion_prefs_image.py +++ b/test/test_companion_prefs_image.py @@ -383,7 +383,13 @@ int main() { def field_code(rows, seed=False): result = [] for name, size in rows: - if seed: + if name == 'tz_offset': + if seed: + result.append('prefs.tz_offset = float(int(random % 27) - 12);') + else: + result.append('const int8_t legacy_tz = static_cast(prefs.tz_offset);') + result.append('append(&legacy_tz, 1);') + elif seed: if name is not None and not name.startswith('@'): result.append(f'fill(&prefs.{name}, sizeof(prefs.{name}));') elif name is None: diff --git a/test/test_companion_prefs_values.py b/test/test_companion_prefs_values.py index 0a0ee2e53..d7da1e110 100644 --- a/test/test_companion_prefs_values.py +++ b/test/test_companion_prefs_values.py @@ -25,11 +25,14 @@ static void seedValues(CompanionNodePrefs& prefs, uint32_t& random) { } }; @SEED_FIELDS@ + prefs.tz_offset = float(int(random % 27) - 12); + prefs.tz_offset_legacy = static_cast(prefs.tz_offset); } static void equalValues(const CompanionNodePrefs& a, const CompanionNodePrefs& b) { @COMPARE_FIELDS@ + assert(a.tz_offset == b.tz_offset); } static size_t adapterOffset(const CompanionNodePrefs& prefs) { @@ -43,7 +46,9 @@ static size_t adapterOffset(const CompanionNodePrefs& prefs) { static std::vector adapterBytes(const CompanionNodePrefs& prefs) { const auto* bytes = reinterpret_cast(&prefs); - return {bytes + adapterOffset(prefs), bytes + sizeof(prefs)}; + // The separately copied runtime timezone follows both adapters. + return {bytes + adapterOffset(prefs), + reinterpret_cast(&prefs.tz_offset)}; } static void expectTag(CompanionNodePrefs& prefs, const char* expected) { diff --git a/test/test_companion_timezone_prefs.py b/test/test_companion_timezone_prefs.py new file mode 100644 index 000000000..e6579945d --- /dev/null +++ b/test/test_companion_timezone_prefs.py @@ -0,0 +1,429 @@ +"""Keep fractional Companion timezones outside the published binary prefix.""" +import binascii +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest + +from test_companion_preferences_transaction import HARNESS, esp_recovery_helpers +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +PLATFORMS = ( + "STM32_PLATFORM", "NRF52_PLATFORM", "NRF52_PLATFORM,RAK_4631", + "ESP32_PLATFORM", "ESP32_PLATFORM,TBEAM_1W", "RP2040_PLATFORM", + "RP2040_PLATFORM,ENABLE_WIFI_INTERFACE", +) + + +def checked_footer(payload): + return payload + struct.pack(">H", binascii.crc_hqx(payload, 0xffff)) + + +def literal_legacy_image(platform): + """An old wire image, independent of current member types or serializers.""" + fan = "TBEAM_1W" in platform + wifi = "RP2040_PLATFORM" in platform and "ENABLE_WIFI_INTERFACE" in platform + prefix = bytearray(243 if fan else 333 if wifi else 236) + + def number(offset, fmt, value): + struct.pack_into("<" + fmt, prefix, offset, value) + + def text(offset, value): + prefix[offset:offset + len(value)] = value + + number(0, "f", 1.25) + text(4, b"literal timezone image") + number(40, "d", 47.125) + number(48, "d", -122.25) + number(56, "f", 915.25) + prefix[60:64] = bytes((7, 5, 1, 1)) + number(64, "f", 62.5) + number(68, "b", -9) + prefix[69:72] = bytes((1, 2, 0)) + number(72, "f", .5) + prefix[76:79] = bytes((1, 2, 1)) + number(80, "I", 876543) + prefix[84:86] = bytes((1, 1)) + number(86, "I", 123) + prefix[90:93] = bytes((3, 4, 1)) + text(93, b"literal scope") + prefix[124:140] = bytes(range(16)) + prefix[140:145] = bytes((1, 1, 1, 0, 1)) + number(145, "I", 15000) + number(149, "I", 30000) + prefix[153:159] = bytes((3, 16, 1, 1, 1, 1)) + text(159, b"literal Bluetooth name") + number(191, "H", 180) + prefix[193] = 1 + number(194, "H", 50) + number(196, "H", 60) + number(198, "H", 70) + prefix[200] = 1 + prefix[201:207] = bytes((2, 4, 6, 8, 10, 12)) + prefix[207] = 1 + prefix[208:214] = bytes((12, 10, 8, 6, 4, 2)) + prefix[214] = 2 + number(215, "f", 1.25) + number(219, "f", .75) + prefix[223:226] = bytes((8, 15, 0xf9)) # signed -7, always exactly byte 225 + tail = 226 + if fan: + text(tail, b"on\0\0\0") + prefix[tail + 5:tail + 7] = bytes((33, 42)) + tail += 7 + if wifi: + text(tail, b"literal-network") + text(tail + 33, b"literal-password") + tail += 97 + prefix[tail:tail + 10] = bytes((4, 3, 2, 0, 1, 0, 0x50, 1, 1, 2)) + assert tail + 10 == len(prefix) + if "RAK_4631" in platform: + prefix += checked_footer(b"PMO\x01\x01\x00") + prefix += checked_footer(b"TXR\x01\xa2\xa4") + return bytes(prefix) + + +def cpp_bytes(data): + return "{" + ",".join(f"0x{value:02x}" for value in data) + "}" + + +def fractional_footer(value): + return checked_footer(b"TZF\x01" + struct.pack(" +#include +#include +#include + +static const uint8_t LEGACY_IMAGE[] = @LEGACY_IMAGE@; + +static std::vector legacyImage(int8_t hours = -7) { + std::vector bytes(LEGACY_IMAGE, LEGACY_IMAGE + sizeof(LEGACY_IMAGE)); + bytes[225] = static_cast(hours); + return bytes; +} + +static void tag(CompanionNodePrefs& prefs, const char* value) { + assert(prefs.getCustom()->setByKey("tz_test_tag", value)); +} + +static void expectTag(CompanionNodePrefs& prefs, const char* expected) { + char value[40]; + assert(prefs.getCustom()->getByKey("tz_test_tag", value, sizeof(value))); + assert(!strcmp(value, expected)); +} + +static void expectLiteralSettings(const CompanionNodePrefs& prefs) { + assert(!strcmp(prefs.node_name, "literal timezone image")); + assert(prefs.airtime_factor == 1.25f && prefs.freq == 915.25f); + assert(prefs.sf == 7 && prefs.cr == 5 && prefs.bw == 62.5f); + assert(prefs.ble_pin == 876543 && prefs.gps_interval == 123); + assert(!strcmp(prefs.default_scope_name, "literal scope")); + for (unsigned i = 0; i < 16; ++i) assert(prefs.default_scope_key[i] == i); + assert(!strcmp(prefs.bluetooth_name, "literal Bluetooth name")); + assert(prefs.display_rotation_degrees == 180 && prefs.cad_max_duration_ms == 70); + assert(prefs.tx_delay_factor == 1.25f && prefs.direct_tx_delay_factor == .75f); + assert(prefs.interference_threshold == 8 && prefs.agc_reset_interval == 15); + assert(prefs.flood_retry_attempts == 4 && prefs.flood_retry_max_path == 3); + assert(prefs.flood_retry_group_max_path == 2 && prefs.flood_retry_advert_enabled == 0); + assert(prefs.one_key_dm_enabled == 1 && prefs.bluetooth_enabled == 0); + assert(prefs.gps_sync_interval_hours == 336 && prefs.usb_debug_enabled == 1); + assert(prefs.lost_reply == 2 && prefs.wifi_enabled == 1); + assert(prefs.tx_media == mesh::RADIO_TX_SECONDARY && prefs.tx_ssh == mesh::RADIO_TX_OFF); +#ifdef TBEAM_1W + assert(!strcmp(prefs.fan_mode, "on") && prefs.fan_lo == 33 && prefs.fan_hi == 42); +#endif +#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + assert(!strcmp(prefs.wifi_ssid, "literal-network")); + assert(!strcmp(prefs.wifi_pwd, "literal-password")); +#endif +#if defined(RAK_4631) + assert(prefs.peripherals_shared == 1); +#endif +} + +static void seedLive(CompanionNodePrefs& prefs) { + strcpy(prefs.node_name, "untouched live settings"); + prefs.freq = 900.5f; prefs.ble_pin = 765432; + prefs.tz_offset = 11.25f; prefs.tz_offset_legacy = -11; + prefs.flood_retry_attempts = 9; prefs.gps_sync_interval_hours = 12; + prefs.tx_media = mesh::RADIO_TX_BOTH; prefs.tx_ssh = mesh::RADIO_TX_PRIMARY; + tag(prefs, "live adapter"); +} + +static void expectLive(CompanionNodePrefs& prefs, double lat, double lon) { + assert(!strcmp(prefs.node_name, "untouched live settings")); + assert(prefs.freq == 900.5f && prefs.ble_pin == 765432); + assert(prefs.tz_offset == 11.25f && prefs.tz_offset_legacy == -11); + assert(prefs.flood_retry_attempts == 9 && prefs.gps_sync_interval_hours == 12); + assert(prefs.tx_media == mesh::RADIO_TX_BOTH && prefs.tx_ssh == mesh::RADIO_TX_PRIMARY); + assert(lat == 1 && lon == 2); + expectTag(prefs, "live adapter"); +} + +static void rejectImage(const std::vector& candidate, int read_after = -1) { + DataStore store; store.fs.files["/new_prefs"] = candidate; + store.fs.fail_read_after = read_after; + CompanionNodePrefs prefs; seedLive(prefs); + double lat = 1, lon = 2; + assert(!store.loadPrefsInt("/new_prefs", prefs, lat, lon)); + expectLive(prefs, lat, lon); + assert(store.fs.files.at("/new_prefs") == candidate); +} + +struct FractionVector { float hours; std::vector footer; }; +static const FractionVector FRACTIONS[] = { + @FRACTIONS@ +}; + +class TimezoneCLI { +public: + CompanionNodePrefs _prefs; + DataStore store; + unsigned saves = 0; + bool savePrefs() { ++saves; return store.savePrefs(_prefs, 47.125, -122.25); } + bool command(const char* command, char* reply) { + @GETTER@ + @SETTER@ + return false; + } +}; + +static void cliTests() { + TimezoneCLI cli; + cli.store.fs.files["/new_prefs"] = legacyImage(); + double lat = 1, lon = 2; + assert(cli.store.loadPrefsInt("/new_prefs", cli._prefs, lat, lon)); + char reply[160]; + struct Value { const char* text; float hours; }; + for (const Value& value : { + Value{"5.5", 5.5f}, Value{"5.75", 5.75f}, Value{"-3.5", -3.5f}, + Value{"+12.75", 12.75f}, Value{" .25\t", .25f}, + Value{"-12", -12.0f}, Value{"14", 14.0f}}) { + const unsigned saves = cli.saves; + const std::string command = std::string("set tz.offset ") + value.text; + assert(cli.command(command.c_str(), reply)); + assert(!strcmp(reply, "OK") && cli.saves == saves + 1); + assert(cli._prefs.tz_offset == value.hours); + assert(cli.command("get tz.offset", reply)); + assert(!strncmp(reply, "> ", 2)); + char* end = nullptr; + assert(strtof(reply + 2, &end) == value.hours && *end == 0); + CompanionNodePrefs reboot; + assert(cli.store.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.tz_offset == value.hours); + expectLiteralSettings(reboot); + } + assert(cli.command("set tz.offset 5.75", reply) && !strcmp(reply, "OK")); + assert(cli.command("get tz.offset", reply) && !strcmp(reply, "> 5.75")); + const auto disk = cli.store.fs.files.at("/new_prefs"); + for (const char* invalid : {"", " ", ".", "+", "nan", "NaN", "inf", "-inf", + "Infinity", "5.75junk", "5.5e0", "0x1p2", "--5", "15", "-12.25", + "14.25", "9999999999999999999999999", "5\n"}) { + const unsigned saves = cli.saves; + const std::string command = std::string("set tz.offset ") + invalid; + assert(cli.command(command.c_str(), reply)); + assert(!strncmp(reply, "Error", 5) && cli.saves == saves); + assert(cli._prefs.tz_offset == 5.75f && cli.store.fs.files.at("/new_prefs") == disk); + } + // The production setter must roll back the complete float after a failed + // real preferences transaction, rather than narrowing it to an int8_t. + cli.store.fs.fail_write = true; + const unsigned saves = cli.saves; + assert(cli.command("set tz.offset -3.5", reply)); + assert(strstr(reply, "could not be saved") && cli.saves == saves + 1); + assert(cli._prefs.tz_offset == 5.75f && cli.store.fs.files.at("/new_prefs") == disk); +} + +int main() { + static_assert(sizeof(CompanionNodePrefs::tz_offset_legacy) == 1, "legacy timezone slot"); + static_assert(sizeof(CompanionNodePrefs::tz_offset) == 4, "RAM timezone float"); + static_assert(mesh::companion::timezone::FooterSize == 10, "published TZF1 length"); + // Signed legacy hours are literal old bytes, not emitted using the new + // type or current serializer. Every populated tail must retain its offset. + for (int8_t hours : {-12, -7, 0, 5, 14}) { + const auto original = legacyImage(hours); + DataStore store; store.fs.files["/new_prefs"] = original; + CompanionNodePrefs loaded; loaded.tz_offset = 8.75f; + tag(loaded, "local adapter"); + double lat = 1, lon = 2; + assert(store.loadPrefsInt("/new_prefs", loaded, lat, lon)); + assert(loaded.tz_offset == float(hours) && loaded.tz_offset_legacy == hours); + assert(lat == 47.125 && lon == -122.25); + expectLiteralSettings(loaded); expectTag(loaded, "local adapter"); + assert(store.savePrefs(loaded, lat, lon)); + assert(store.fs.files.at("/new_prefs") == original); + // Pre-tail images already had the one-byte timezone at 225. + store.fs.files["/new_prefs"].resize(226); + loaded.tz_offset = -8.75f; + assert(store.loadPrefsInt("/new_prefs", loaded, lat, lon)); + assert(loaded.tz_offset == float(hours)); + } + { + DataStore old; old.fs.files["/new_prefs"] = legacyImage(); + old.fs.files["/new_prefs"].resize(215); + CompanionNodePrefs defaults; double lat = 1, lon = 2; + assert(old.loadPrefsInt("/new_prefs", defaults, lat, lon)); + assert(defaults.tz_offset == 0); + } + for (const FractionVector& vector : FRACTIONS) { + DataStore store; store.fs.files["/new_prefs"] = legacyImage(); + CompanionNodePrefs loaded; double lat = 1, lon = 2; + assert(store.loadPrefsInt("/new_prefs", loaded, lat, lon)); + loaded.tz_offset = vector.hours; + assert(store.savePrefs(loaded, lat, lon)); + auto expected = legacyImage(static_cast(vector.hours)); + expected.insert(expected.end(), vector.footer.begin(), vector.footer.end()); + assert(store.fs.files.at("/new_prefs") == expected); + CompanionNodePrefs reboot; reboot.tz_offset = 9.5f; + tag(reboot, "reboot adapter"); + assert(store.loadPrefsInt("/new_prefs", reboot, lat, lon)); + assert(reboot.tz_offset == vector.hours); + assert(reboot.tz_offset_legacy == static_cast(vector.hours)); + expectLiteralSettings(reboot); expectTag(reboot, "reboot adapter"); + // Returning to integer hours removes only the optional extension. + reboot.tz_offset = -7; + assert(store.savePrefs(reboot, lat, lon)); + assert(store.fs.files.at("/new_prefs") == legacyImage()); + } + + auto complete = legacyImage(5); + const std::vector fraction = @FIVE_SEVENTY_FIVE@; + complete.insert(complete.end(), fraction.begin(), fraction.end()); + const size_t extension = sizeof(LEGACY_IMAGE); + // Every damaged extension byte fails atomically. Removing the complete + // optional footer is a legitimate old-format integer image, tested above. + for (size_t i = 0; i < 10; ++i) { + auto corrupt = complete; corrupt[extension + i] ^= 0x80; + rejectImage(corrupt); + rejectImage(complete, static_cast(extension + i)); + if (i != 0) { + auto torn = complete; torn.resize(extension + i); + rejectImage(torn); + } + } + // Correct checksums cannot make unknown versions, NaN, infinity or an + // out-of-range float valid. These are independent literal wire vectors. + const std::vector> invalid_footers = { + @BAD_FOOTERS@ + }; + for (const auto& footer : invalid_footers) { + auto invalid = legacyImage(5); + invalid.insert(invalid.end(), footer.begin(), footer.end()); + rejectImage(invalid); + } + auto unknown = complete; unknown.push_back(0); + rejectImage(unknown); + unknown = complete; unknown.insert(unknown.end(), fraction.begin(), fraction.end()); + rejectImage(unknown); + unknown = complete; unknown.resize(complete.size() + 65536); + rejectImage(unknown); + + for (float invalid : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + 14.25f, -12.25f}) { + DataStore store; store.fs.files["/new_prefs"] = legacyImage(); + CompanionNodePrefs prefs; double lat = 1, lon = 2; + assert(store.loadPrefsInt("/new_prefs", prefs, lat, lon)); + prefs.tz_offset = invalid; + const auto before = store.fs.files; + assert(!store.savePrefs(prefs, lat, lon)); + assert(store.fs.files == before); // validation precedes temporary-file work + } + // Short writes/readback at each new footer boundary must leave the prior + // complete image authoritative. The next successful save must still work. + for (bool readback : {false, true}) { + for (size_t i = 0; i < 10; ++i) { + DataStore store; store.fs.files["/new_prefs"] = legacyImage(); + CompanionNodePrefs prefs; double lat = 1, lon = 2; + assert(store.loadPrefsInt("/new_prefs", prefs, lat, lon)); + prefs.tz_offset = -3.75f; prefs.freq = 910.5f; + if (readback) store.fs.fail_read_after = static_cast(extension + i); + else store.fs.fail_write_after = static_cast(extension + i); + assert(!store.savePrefs(prefs, lat, lon)); + assert(store.fs.files.at("/new_prefs") == legacyImage()); + assert(!store.fs.exists("/new_prefs.tmp")); + store.fs.fail_read_after = store.fs.fail_write_after = -1; + CompanionNodePrefs prior; + assert(store.loadPrefsInt("/new_prefs", prior, lat, lon)); + assert(prior.tz_offset == -7); expectLiteralSettings(prior); + assert(store.savePrefs(prefs, lat, lon)); + assert(store.loadPrefsInt("/new_prefs", prior, lat, lon)); + assert(prior.tz_offset == -3.75f && prior.freq == 910.5f); + } + } + cliTests(); + puts("PASS: literal legacy timezone ABI, fractional footer, atomic faults and CLI rollback"); +} +''' + + +class CompanionTimezonePrefsTests(unittest.TestCase): + def test_literal_legacy_fractional_persistence_and_cli(self): + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + methods = esp_recovery_helpers(source) + "\n".join(method(source, signature) for signature in ( + "bool DataStore::loadPrefs(", "bool DataStore::loadPrefsInt(", "bool DataStore::savePrefs(")) + code = HARNESS.split("int main(")[0].replace("@METHODS@", methods) + getter = method(mesh, ' if (strcmp(command, "get tz.offset") == 0)') + setter = method(mesh, ' if (strncmp(command, "set tz.offset ", 14) == 0)') + tests = TESTS.replace("@GETTER@", getter).replace("@SETTER@", setter) + values = (5.5, 5.75, -3.5, -3.75, 12.75, 13.75, -11.75, .25, -.25) + tests = tests.replace("@FRACTIONS@", ",\n ".join( + "{" + str(value) + "f," + cpp_bytes(fractional_footer(value)) + "}" for value in values)) + tests = tests.replace("@FIVE_SEVENTY_FIVE@", cpp_bytes(fractional_footer(5.75))) + bad = [checked_footer(b"TZF\x02" + struct.pack(" +#include +namespace mesh { struct Utils { + static void printHex(Stream&,const uint8_t*,size_t){assert(false);} + static void fromHex(uint8_t*,size_t,const char*){assert(false);} +}; } +''') + (work / "platform_shim.h").write_text('''#include +#include +inline char* utoa(unsigned int value,char* output,int base){ + if(base!=10)abort();sprintf(output,"%u",value);return output; +} +''') + transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text() + (work / "ContactFileTransaction.h").write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + for platform in PLATFORMS: + with self.subTest(platform=platform): + cpp = work / "test.cpp" + cpp.write_text(code + tests.replace( + "@LEGACY_IMAGE@", cpp_bytes(literal_legacy_image(platform)))) + binary = work / "test" + sanitizers = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + *["-D" + flag + "=1" for flag in platform.split(",")], *sanitizers, + "-include", str(work / "platform_shim.h"), "-I", str(work), + "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + "-I", str(ROOT / "src/helpers"), "-I", str(ROOT), str(cpp), + *[str(ROOT / "src/helpers" / name) for name in ( + "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", + "CommonRadioPrefs.cpp", "TxtDataHelpers.cpp")], + "-o", str(binary)], capture_output=True, text=True, timeout=90) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + self.assertIn("PASS: literal legacy timezone ABI", ran.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_transport_selector.py b/test/test_companion_transport_selector.py index 256a549b9..c1730f387 100644 --- a/test/test_companion_transport_selector.py +++ b/test/test_companion_transport_selector.py @@ -271,8 +271,9 @@ int main() { "if (_page == HomePage::TRANSPORT\n" " && (key == KEY_ENTER || key == KEY_UP || key == KEY_DOWN))" ) - handler_end = source.index("#else", handler_start) - handler = source[handler_start:handler_end] + handler = extract_braced(source[handler_start:], + "if (_page == HomePage::TRANSPORT\n" + " && (key == KEY_ENTER || key == KEY_UP || key == KEY_DOWN))") active = handler.index("const CompanionTransportMode active") save = handler.index("selectCompanionTransportMode(requested)") error = handler.index('showAlert("Transport save failed"') diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 405c2c4cf..b0c52af72 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -21,6 +21,7 @@ PREAMBLE = r''' #include #include #include +#include #include "companion_fleet_dispatch.h" #define ESP32 1 #define WIFI_SSID "test" @@ -89,7 +90,7 @@ struct Prefs { uint8_t lost_reply=0; float tx_delay_factor=0.5f, direct_tx_delay_factor=0.2f; uint8_t interference_threshold=0, agc_reset_interval=0; - int8_t tz_offset=0; + float tz_offset=0; float freq=910, bw=125, airtime_factor=1, rx_delay_base=1; uint8_t sf=7, cr=5, path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=16; uint32_t rx_ps_rx_us=1, rx_ps_sleep_us=1;