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:
mikecarper committed 2026-10-18 08:30:50 -07:00
commit 7088096abf
16 files changed
+804 -32

No files matched your search

+2
View File
@@ -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: |
+23
View File
@@ -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.
+44 -7
View File
@@ -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<int8_t>(_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<int8_t>(_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<const uint8_t*>(
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");
+5 -4
View File
@@ -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<double>(_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 {
+13 -5
View File
@@ -11,6 +11,7 @@
#include <helpers/CommonRadioPrefs.h>
#include <helpers/DynamicConfigSerializer.h>
#include <helpers/CompanionTrafficPrefs.h>
#include <helpers/CompanionTimezonePrefs.h>
#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;
}
@@ -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 &lat;
if (field.offset == PrefsLon) return &lon;
if (field.offset == PrefsPad) return pad;
return reinterpret_cast<const uint8_t*>(&values) + field.offset;
const void* value = reinterpret_cast<const uint8_t*>(&values) + field.offset;
return timezone && value == &values.tz_offset_legacy ? timezone : value;
}
} // namespace companion_prefs
+9 -5
View File
@@ -23,6 +23,7 @@
#include "../CompanionWiFi.h"
#include "target.h"
#include <time.h>
#include <math.h>
#if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID)
#include <WiFi.h>
#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<int32_t>(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();
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "CompanionTrafficPrefs.h"
#include <stdint.h>
#include <string.h>
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<int8_t>(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
+3 -1
View File
@@ -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
View File
@@ -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));
+204
View File
@@ -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()
+7 -1
View File
@@ -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:
+6 -1
View File
@@ -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) {
+429
View File
@@ -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()
+3 -2
View File
@@ -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"')
+2 -1
View File
@@ -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;