Merge upstream dev through 428a95c64
Integrate fractional Companion timezones and conditional Bluetooth pages while preserving the existing preference byte layout, transactional saves, and Full transport selector. Add migration, rollback, invalid-value, and UI profile regressions.
This commit is contained in:
commit
7088096abf
16 files changed
+804
-32
No files matched your search
+3
-1
@@ -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));
|
||||
|
||||
+3
-2
@@ -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<int8_t>(_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));
|
||||
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cfloat>
|
||||
#include <limits>
|
||||
#include <math.h>
|
||||
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<float>::quiet_NaN(),
|
||||
std::numeric_limits<float>::infinity(), -std::numeric_limits<float>::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()
|
||||
@@ -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<int8_t>(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:
|
||||
|
||||
@@ -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<int8_t>(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<uint8_t> adapterBytes(const CompanionNodePrefs& prefs) {
|
||||
const auto* bytes = reinterpret_cast<const uint8_t*>(&prefs);
|
||||
return {bytes + adapterOffset(prefs), bytes + sizeof(prefs)};
|
||||
// The separately copied runtime timezone follows both adapters.
|
||||
return {bytes + adapterOffset(prefs),
|
||||
reinterpret_cast<const uint8_t*>(&prefs.tz_offset)};
|
||||
}
|
||||
|
||||
static void expectTag(CompanionNodePrefs& prefs, const char* expected) {
|
||||
|
||||
@@ -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("<f", value))
|
||||
|
||||
|
||||
TESTS = r'''
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
|
||||
static const uint8_t LEGACY_IMAGE[] = @LEGACY_IMAGE@;
|
||||
|
||||
static std::vector<uint8_t> legacyImage(int8_t hours = -7) {
|
||||
std::vector<uint8_t> bytes(LEGACY_IMAGE, LEGACY_IMAGE + sizeof(LEGACY_IMAGE));
|
||||
bytes[225] = static_cast<uint8_t>(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<uint8_t>& 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<uint8_t> 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<int8_t>(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<int8_t>(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<uint8_t> 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<int>(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<std::vector<uint8_t>> 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<float>::quiet_NaN(),
|
||||
std::numeric_limits<float>::infinity(), -std::numeric_limits<float>::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<int>(extension + i);
|
||||
else store.fs.fail_write_after = static_cast<int>(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("<f", 5.75)),
|
||||
checked_footer(b"TZX\x01" + struct.pack("<f", 5.75))]
|
||||
bad += [checked_footer(b"TZF\x01" + struct.pack("<I", bits)) for bits in (
|
||||
0x7fc00000, 0x7f800000, 0xff800000, 0x41640000, 0xc1440000)]
|
||||
tests = tests.replace("@BAD_FOOTERS@", ",\n ".join(cpp_bytes(value) for value in bad))
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-timezone-prefs-") as directory:
|
||||
work = Path(directory)
|
||||
(work / "Utils.h").write_text('''#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <cassert>
|
||||
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 <cstdlib>
|
||||
#include <cstdio>
|
||||
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 <helpers/IdentityStore.h>'))
|
||||
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()
|
||||
@@ -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"')
|
||||
|
||||
@@ -21,6 +21,7 @@ PREAMBLE = r'''
|
||||
#include <helpers/RadioProfileCommandUtils.h>
|
||||
#include <helpers/BatteryChargeCLI.h>
|
||||
#include <helpers/CompanionLostReply.h>
|
||||
#include <helpers/CompanionTimezonePrefs.h>
|
||||
#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;
|
||||
|
||||
Reference in new issue
Block a user