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:
1 parent
7088096abf
commit
59b0ab93d0
7 files changed
+164
-16
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user