Keep fractional timezones within constrained flash budgets

Deduplicate validation and preserve the published preference bytes and transactional saves. Correct Ikoka Handheld Companion flash bounds and document QSPI Room migration. Qualify STM32 and RAK4631 at the full 1.18.0.1 release version length.
This commit is contained in:
mikecarper committed 2026-10-18 08:46:20 -07:00
1 parent 7088096abf
commit 59b0ab93d0
7 files changed
+164 -16

No files matched your search

+8 -3
View File
@@ -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
+7 -5
View File
@@ -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<int8_t>(_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<uint8_t>(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
+1 -1
View File
@@ -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;
+33 -7
View File
@@ -1,6 +1,7 @@
#pragma once
#include "CompanionTrafficPrefs.h"
#include <float.h>
#include <stdint.h>
#include <string.h>
@@ -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;
+87
View File
@@ -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("<I", struct.pack("<f", hours))[0]
bits.update((center - 1, center, center + 1))
generator = random.Random(0x545a4631)
bits.update(generator.getrandbits(32) for _ in range(1024))
rows = []
for raw in sorted(bits):
hours = struct.unpack("<f", struct.pack("<I", raw))[0]
valid = math.isfinite(hours) and -12.0 <= hours <= 14.0
# Use the original bits directly: unpacking/repacking a signaling
# NaN through Python could otherwise quiet its payload.
footer = checked_footer(b"TZF\x01" + struct.pack("<I", raw))
rows.append("{" + f"0x{raw:08x}u,{str(valid).lower()}," + cpp_bytes(footer) + "}")
code = r'''
#include <cassert>
#include <cstdio>
#include <cstdint>
#include <cstring>
#include <helpers/CompanionTimezonePrefs.h>
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()
+24
View File
@@ -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()
@@ -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