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
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ⪫
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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