diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index c500d8faa..e5f58ddae 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -26,7 +26,7 @@ an update is downloaded. ## Supported repeater families -QSPI staging is enabled only for explicitly matched repeater-role environments +QSPI staging is enabled for explicitly matched repeater-role environments (and RS232 bridge variants that directly inherit one of those repeater targets) on these currently matched families: @@ -59,8 +59,13 @@ T-Echo target. Companion builds are intentionally excluded. Some companion targets use the same external QSPI as a LittleFS message/data store, where raw OTA staging would corrupt the filesystem; other companions simply do not assign that chip -to OTA. Room-server, sensor, and KISS roles are unchanged. Raw QSPI OTA is -scoped to the explicitly matched repeater-derived environments. +to OTA. Dedicated `*_room_server_lora_ota` environments also enable raw QSPI +staging with their own exact role identity and matched bootloader layout. +For Ikoka, these cover the Handheld E22-30 and Nano/Stick 22, 30, and 33 dBm +variants. The legacy `*_room_server` environments retain their internal-flash +layout and a different OTA target identity; use the matching wired installation +and bootloader migration procedure before switching to a QSPI Room image. +Sensor and KISS roles do not gain QSPI staging from these recipes. ### RAK15001 placement and module conflicts diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index d37cfd75a..b63f0e255 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1123,9 +1123,6 @@ bool DataStore::loadPrefsInt(const char *filename, loaded_prefs.lost_reply = 0; loaded_prefs.tx_media = mesh::RADIO_TX_AUTO; loaded_prefs.tx_ssh = mesh::RADIO_TX_AUTO; - // Images predating byte 225 inherit the caller's valid timezone default. - loaded_prefs.tz_offset_legacy = mesh::companion::timezone::valid(_prefs.tz_offset) - ? static_cast(_prefs.tz_offset) : 0; #if defined(RAK_4631) loaded_prefs.peripherals_shared = 0; #endif @@ -1488,10 +1485,13 @@ 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, &legacy_tz_offset), + memcpy(image + used, prefValue(field, _prefs, node_lat, node_lon, pad), field.length); used += field.length; } + // STM32 stages the frozen prefix: patch its published integer hour once, + // rather than selecting an alternate source for every table entry. + image[225] = static_cast(legacy_tz_offset); bool success = used == sizeof(image) && file.write(image, sizeof(image)) == sizeof(image); #elif defined(NRF52_PLATFORM) && defined(__GNUC__) @@ -1663,7 +1663,9 @@ 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)) { + // Range was checked before opening the transaction. Reuse its integer + // fallback instead of validating and converting the same float again. + if (_prefs.tz_offset != legacy_tz_offset) { uint8_t timezone_footer[mesh::companion::timezone::FooterSize]; mesh::companion::timezone::encodeFooter(timezone_footer, _prefs.tz_offset); success = success diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 92f5d1935..a88f42718 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -11008,7 +11008,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (savePrefs()) strcpy(reply, "OK"); else { _prefs.tz_offset = previous; - strcpy(reply, "Error: timezone could not be saved"); + strcpy(reply, "Error: setting could not be saved"); } } return true; diff --git a/src/helpers/CompanionTimezonePrefs.h b/src/helpers/CompanionTimezonePrefs.h index c92900d2a..a4ac87a0a 100644 --- a/src/helpers/CompanionTimezonePrefs.h +++ b/src/helpers/CompanionTimezonePrefs.h @@ -1,6 +1,7 @@ #pragma once #include "CompanionTrafficPrefs.h" +#include #include #include @@ -10,9 +11,21 @@ namespace mesh { namespace companion { namespace timezone { // Integer-only images retain their exact published bytes and lengths. static constexpr size_t FooterSize = 10; +// All supported preference ABIs use IEEE binary32. Checking its bits avoids +// duplicated soft-float comparisons on small ARM targets and remains strict +// when a board's compiler enables finite-math optimizations. +#if defined(__GNUC__) +__attribute__((noinline)) +#endif inline bool valid(float hours) { - // Ordered comparisons also reject NaN and either infinity. - return hours >= -12.0f && hours <= 14.0f; + static_assert(sizeof(float) == sizeof(uint32_t) && FLT_RADIX == 2 + && FLT_MANT_DIG == 24 && FLT_MAX_EXP == 128, "IEEE binary32 timezone"); + uint32_t bits; + memcpy(&bits, &hours, sizeof(bits)); + // Positive binary32 bits are ordered numerically. For negative values, + // their magnitude is ordered likewise above the sign bit. The thresholds + // are exactly -12 and +14; either sign of NaN/infinity exceeds its limit. + return bits <= ((bits & 0x80000000UL) ? 0xc1400000UL : 0x41600000UL); } inline bool fractional(float hours) { @@ -22,22 +35,35 @@ inline bool fractional(float 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; +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + memcpy(out + 4, &hours, sizeof(hours)); +#else uint32_t bits; memcpy(&bits, &hours, sizeof(bits)); for (unsigned i = 0; i < 4; ++i) out[4 + i] = uint8_t(bits >> (8 * i)); +#endif 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; + if (!in || size != FooterSize) return false; +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + uint32_t magic; + memcpy(&magic, in, sizeof(magic)); + if (magic != 0x01465a54UL) return false; // TZF, version 1 +#else + if (memcmp(in, "TZF\1", 4) != 0) return false; +#endif + if (traffic::checksum(in, 8) != uint16_t((in[8] << 8) | in[9])) return false; + float decoded; +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + memcpy(&decoded, in + 4, sizeof(decoded)); +#else 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)); +#endif if (!valid(decoded)) return false; hours = decoded; return true; diff --git a/test/test_companion_timezone_prefs.py b/test/test_companion_timezone_prefs.py index e6579945d..f87987852 100644 --- a/test/test_companion_timezone_prefs.py +++ b/test/test_companion_timezone_prefs.py @@ -1,7 +1,9 @@ """Keep fractional Companion timezones outside the published binary prefix.""" import binascii +import math import os from pathlib import Path +import random import struct import subprocess import tempfile @@ -270,6 +272,11 @@ int main() { CompanionNodePrefs defaults; double lat = 1, lon = 2; assert(old.loadPrefsInt("/new_prefs", defaults, lat, lon)); assert(defaults.tz_offset == 0); + // A missing optional timezone must preserve an exact caller default, + // including fractional precision introduced after this old image. + defaults.tz_offset = 5.75f; + assert(old.loadPrefsInt("/new_prefs", defaults, lat, lon)); + assert(defaults.tz_offset == 5.75f); } for (const FractionVector& vector : FRACTIONS) { DataStore store; store.fs.files["/new_prefs"] = legacyImage(); @@ -364,6 +371,86 @@ int main() { class CompanionTimezonePrefsTests(unittest.TestCase): + def test_ieee_validation_and_footer_under_size_and_math_modes(self): + # Expected validity comes from Python's decoded binary32 values, not + # the production bit thresholds or a compiler's fast-math comparisons. + bits = { + 0, 0x80000000, 1, 0x80000001, 0x007fffff, 0x807fffff, + 0x00800000, 0x80800000, 0x7f7fffff, 0xff7fffff, + 0x7f800000, 0xff800000, 0x7f800001, 0xff800001, + 0x7fc00000, 0xffc00000, 0x7fffffff, 0xffffffff, + } + for hours in (-14.0, -12.0, 12.0, 14.0, -3.75, 5.75): + center = struct.unpack(" +#include +#include +#include +#include +struct Case { uint32_t bits; bool valid; uint8_t footer[10]; }; +static const Case CASES[] = { +@CASES@ +}; +int main() { + unsigned valid_cases = 0, invalid_cases = 0; + for (const Case& test : CASES) { + float hours; + memcpy(&hours, &test.bits, 4); + assert(mesh::companion::timezone::valid(hours) == test.valid); + const uint32_t sentinel = 0x40b80000u; // exactly 5.75 + float decoded; + memcpy(&decoded, &sentinel, 4); + if (test.valid) { + ++valid_cases; + uint8_t footer[10]; + mesh::companion::timezone::encodeFooter(footer, hours); + assert(!memcmp(footer, test.footer, sizeof(footer))); + assert(mesh::companion::timezone::decodeFooter(footer, sizeof(footer), decoded)); + uint32_t restored; + memcpy(&restored, &decoded, 4); + assert(restored == test.bits); // includes negative zero and subnormals + } else { + ++invalid_cases; + // A correct header/checksum cannot admit NaN, infinity or bad range, + // even when floating-point comparisons are compiled with fast-math. + assert(!mesh::companion::timezone::decodeFooter(test.footer, 10, decoded)); + uint32_t unchanged; + memcpy(&unchanged, &decoded, 4); + assert(unchanged == sentinel); + } + } + assert(valid_cases > 400 && invalid_cases > 400); + puts("PASS: independent IEEE validity and exact timezone footer bits/CRC"); +} +'''.replace("@CASES@", ",\n".join(rows)) + with tempfile.TemporaryDirectory(prefix="meshcore-timezone-ieee-") as directory: + work = Path(directory) + cpp = work / "test.cpp" + cpp.write_text(code) + for mode, flags in (("size", []), ("fast-math", ["-ffast-math"]), + ("portable-endian", ["-U__BYTE_ORDER__"])): + with self.subTest(mode=mode): + binary = work / "test" + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Os", + *flags, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + self.assertIn("PASS: independent IEEE validity", ran.stdout) + 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() diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py index b02ea7bd2..ac7f69c30 100644 --- a/test/test_nrf52_variant_contracts.py +++ b/test/test_nrf52_variant_contracts.py @@ -34,6 +34,30 @@ def hex_define(source: str, name: str) -> int: class Nrf52VariantContractsTest(unittest.TestCase): + def test_ikoka_handheld_companion_size_matches_its_linked_flash(self) -> None: + recipe = (ROOT / "variants/ikoka_handheld_nrf/platformio.ini").read_text() + sections = re.findall(r"^\[(env:ikoka_handheld[^\]]*companion_radio_(?:usb|ble))\]$", + recipe, re.MULTILINE) + self.assertEqual(len(sections), 4) + for name in sections: + with self.subTest(target=name): + section = ini_section(recipe, name) + linker = re.search(r"^board_build\.ldscript\s*=\s*(\S+)$", + section, re.MULTILINE) + size = re.search(r"^board_upload\.maximum_size\s*=\s*(\d+)$", + section, re.MULTILINE) + self.assertIsNotNone(linker) + self.assertIsNotNone(size) + flash = re.search( + r"FLASH\s*\(rx\)\s*:\s*ORIGIN\s*=\s*(0x[0-9A-Fa-f]+)," + r"\s*LENGTH\s*=\s*(0x[0-9A-Fa-f]+)\s*-\s*(0x[0-9A-Fa-f]+)", + (ROOT / linker.group(1)).read_text()) + self.assertIsNotNone(flash) + origin, end, length_origin = (int(value, 16) for value in flash.groups()) + self.assertEqual(origin, length_origin) + self.assertEqual(end, 0xD4000) + self.assertEqual(int(size.group(1)), end - origin) + def test_rak3401_w25q16_handoff_keeps_rak13302_and_gps_isolated(self) -> None: shared = (ROOT / "platformio.ini").read_text() recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() diff --git a/variants/ikoka_handheld_nrf/platformio.ini b/variants/ikoka_handheld_nrf/platformio.ini index cec1a016a..56a56a951 100644 --- a/variants/ikoka_handheld_nrf/platformio.ini +++ b/variants/ikoka_handheld_nrf/platformio.ini @@ -53,6 +53,7 @@ build_src_filter = ${ikoka_handheld_nrf.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_companion_radio_ble] extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D BLE_PIN_CODE=123456 -D LORA_TX_POWER=20 @@ -62,6 +63,7 @@ build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_ble] extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D BLE_PIN_CODE=123456 -D LORA_TX_POWER=20 @@ -72,6 +74,7 @@ build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_companion_radio_usb] extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 -D ENABLE_USB_INTERFACE @@ -80,6 +83,7 @@ build_src_filter = ${ikoka_handheld_nrf_ssd1306_companion.build_src_filter} [env:ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb] extends = ikoka_handheld_nrf board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${ikoka_handheld_nrf_ssd1306_companion.build_flags} -D LORA_TX_POWER=20 -D DISPLAY_ROTATION=2