Merge branch 'keymindCascade' of github.com:mikecarper/MeshCore into keymindCascade
# Conflicts: # test/test_nrf52_ble_startup.py
This commit is contained in:
commit
bbb5c14fba
50 files changed
+3450
-261
No files matched your search
@@ -23,6 +23,7 @@ MQTT_DEBUG_OVERRIDE="${MQTT_DEBUG_OVERRIDE-}"
|
||||
FIRMWARE_FILENAME_INFIX=""
|
||||
ESP32_FULL_BUILD=0
|
||||
SINGLE_TARGET_FULL_BUILD="${SINGLE_TARGET_FULL_BUILD:-0}"
|
||||
EXACT_IDENTITY_FULL_BUILD="${EXACT_IDENTITY_FULL_BUILD:-0}"
|
||||
AUTO_PREFER_FULL_BUILD=0
|
||||
AUTO_COMPLETE_FIRST_PASS=0
|
||||
AUTO_REDUCED_FALLBACK_TARGET=""
|
||||
@@ -140,6 +141,7 @@ Options:
|
||||
--profile <default|cascade>: Override runtime settings embedded in the firmware (not its feature set).
|
||||
--build-profile <auto|standard|full>: Select feature/partition policy. Auto uses the combined Full MQTT/USB/WiFi recipe when it covers the plain infrastructure target; otherwise it first builds complete LoRa-OTA-capable firmware with a measured-size fallback. Internal-flash nRF52 repeaters and room servers also publish the reduced image for delta-staging headroom; XIAO nRF52 repeaters with external-QSPI staging publish one combined image. Standard preserves portable images, including the 1.25 MiB slot; full requires the expanded ESP32 recipe.
|
||||
--auto|--standard|--full: Short forms of --build-profile.
|
||||
--full-exact: Build one expanded ESP32 release image under the requested target's own LoRa OTA identity.
|
||||
--skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds.
|
||||
--clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts.
|
||||
--require-ota: Require a verified wireless self-update path for infrastructure.
|
||||
@@ -995,6 +997,13 @@ parse_cli_options() {
|
||||
BUILD_PROFILE_EXPLICIT=1
|
||||
shift
|
||||
;;
|
||||
--full-exact)
|
||||
BUILD_PROFILE_OVERRIDE="full"
|
||||
BUILD_PROFILE_EXPLICIT=1
|
||||
SINGLE_TARGET_FULL_BUILD=1
|
||||
EXACT_IDENTITY_FULL_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--skip-kiss)
|
||||
KISS_MODE_OVERRIDE="skip"
|
||||
shift
|
||||
@@ -3100,22 +3109,30 @@ is_esp32_full_only_bulk_target() {
|
||||
}
|
||||
|
||||
# These normal role identities still ship a portable legacy image for installed
|
||||
# nodes, but also need a canonical FULL artifact for the one-time wired layout
|
||||
# nodes, but also need a canonical FULL artifact for the one-time layout
|
||||
# migration. They are deliberately separate from the FULL-only list above:
|
||||
# their deployed 0x140000 slots differ from the expanded table, so LoRa mOTA
|
||||
# must not try to cross this boundary. The matching merged image is the
|
||||
# migration package; after it is flashed, later normal-target FULL packages
|
||||
# use the same logical mOTA identity.
|
||||
# some deployed 0x140000 slots differ from the expanded table, so LoRa mOTA
|
||||
# must not try to cross this boundary. An exact-target Wi-Fi bridge and Full
|
||||
# application are packaged separately; after migration, later normal-target
|
||||
# FULL packages use the same logical mOTA identity.
|
||||
is_esp32_partition_migration_full_target() {
|
||||
local env_name=${1,,}
|
||||
|
||||
[ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "ESP32_PLATFORM" ] || return 1
|
||||
|
||||
case "$env_name" in
|
||||
ebyte_eora-s3_repeater|ebyte_eora-s3_room_server|\
|
||||
generic_e22_sx1262_repeater|generic_e22_sx1268_repeater|\
|
||||
heltec_ct62_repeater|heltec_ct62_sensor|\
|
||||
lilygo_t3s3_sx1262_repeater|lilygo_t3s3_sx1262_room_server|\
|
||||
lilygo_t3s3_sx1276_repeater|lilygo_t3s3_sx1276_room_server|\
|
||||
station_g2_repeater|station_g2_room_server|\
|
||||
thinknode_m2_repeater|thinknode_m2_room_server)
|
||||
station_g2_repeater|station_g2_logging_repeater|station_g2_room_server|\
|
||||
thinknode_m2_repeater|thinknode_m2_room_server|\
|
||||
thinknode_m5_repeater|thinknode_m5_room_server|\
|
||||
xiao_c3_repeater|xiao_c3_room_server|\
|
||||
tenstar_c3_sx1262_repeater|tenstar_c3_sx1268_repeater|\
|
||||
nibble_zero_connect_repeater_|nibble_zero_connect_room_server_|\
|
||||
nibble_screen_connect_repeater_|nibble_screen_connect_room_server_)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
@@ -3193,7 +3210,7 @@ declare_build_capability_contract() {
|
||||
if is_esp32_full_only_bulk_target "$env_name"; then
|
||||
record_build_capability "release.full.same_partition_successor"
|
||||
elif is_esp32_partition_migration_full_target "$env_name"; then
|
||||
record_build_capability "release.full.partition_migration_usb"
|
||||
record_build_capability "release.full.partition_migration_ota"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -5367,7 +5384,15 @@ configure_effective_build_profile() {
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then
|
||||
if [ "$EXACT_IDENTITY_FULL_BUILD" = "1" ]; then
|
||||
if [ "$command_name" != "build-firmware" ] \
|
||||
|| [ "${#RESOLVED_BUILD_TARGETS[@]}" -ne 1 ] \
|
||||
|| ! is_esp32_canonical_full_release_target "$target"; then
|
||||
echo "--full-exact requires one canonical ESP32 infrastructure target."
|
||||
return 1
|
||||
fi
|
||||
BUILD_PROFILE_EFFECTIVE="full"
|
||||
elif [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then
|
||||
BUILD_PROFILE_EFFECTIVE="full"
|
||||
elif is_automatic_profile_command "$command_name"; then
|
||||
if [ "$BUILD_PROFILE_EXPLICIT" = "1" ] \
|
||||
@@ -6270,6 +6295,11 @@ run_command() {
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ "$EXACT_IDENTITY_FULL_BUILD" = "1" ]; then
|
||||
run_full_only_esp32_profile "${RESOLVED_BUILD_TARGETS[@]}"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then
|
||||
run_full_esp32_build_targets "all" "${RESOLVED_BUILD_TARGETS[@]}"
|
||||
return $?
|
||||
@@ -6461,7 +6491,7 @@ launch_background_build() {
|
||||
PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER
|
||||
PLATFORMIO_EXTRA_SCRIPTS DISABLE_DEBUG OPTION3_PIO_JOBS OUTPUT_DIR
|
||||
FIRMWARE_VERSION FIRMWARE_BUILD_NUMBER BUILD_PROFILE_OVERRIDE
|
||||
BUILD_PROFILE_EXPLICIT SINGLE_TARGET_FULL_BUILD FIRMWARE_PROFILE_OVERRIDE
|
||||
BUILD_PROFILE_EXPLICIT SINGLE_TARGET_FULL_BUILD EXACT_IDENTITY_FULL_BUILD FIRMWARE_PROFILE_OVERRIDE
|
||||
MESHDEBUG_OVERRIDE PACKET_LOGGING_OVERRIDE MQTT_BRIDGE_OVERRIDE
|
||||
MQTT_DEBUG_OVERRIDE RADIO_SETTING_TITLE RADIO_FREQ_OVERRIDE
|
||||
RADIO_BW_OVERRIDE RADIO_SF_OVERRIDE RADIO_CR_OVERRIDE
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
repeater: "Repeater",
|
||||
room: "Room Server",
|
||||
sensor: "Sensor / telemetry",
|
||||
"partition-expander": "Partition Expander",
|
||||
terminal: "Terminal Chat",
|
||||
kiss: "KISS modem",
|
||||
other: "Other",
|
||||
@@ -264,6 +265,10 @@
|
||||
role: "sensor",
|
||||
pattern: /_sensor(?=$|[_-])/i,
|
||||
},
|
||||
{
|
||||
role: "partition-expander",
|
||||
pattern: /_partition_expander(?=$|[_-])/i,
|
||||
},
|
||||
{
|
||||
role: "terminal",
|
||||
pattern: /_terminal_chat(?=$|[_-])/i,
|
||||
@@ -365,7 +370,8 @@
|
||||
(parts.role === "companion" && mode === "full")
|
||||
? "full"
|
||||
: "standard";
|
||||
const explicitOta = lowerTarget.includes("lora_ota")
|
||||
const explicitOta = parts.role === "partition-expander" ||
|
||||
lowerTarget.includes("lora_ota")
|
||||
? "lora-receiver"
|
||||
: parts.role === "companion" && mode === "full"
|
||||
? "lora-source"
|
||||
@@ -979,7 +985,8 @@
|
||||
}
|
||||
|
||||
function optionSort(field, a, b) {
|
||||
const roleOrder = ["companion", "repeater", "room", "sensor", "terminal", "kiss", "other"];
|
||||
const roleOrder = ["companion", "repeater", "room", "sensor",
|
||||
"partition-expander", "terminal", "kiss", "other"];
|
||||
const loggingOrder = ["none", "usb", "wifi", "both"];
|
||||
const otaOrder = ["none", "lora-receiver", "lora-source"];
|
||||
const featureOrder = ["standard", "full"];
|
||||
@@ -1025,6 +1032,15 @@
|
||||
"Verify that the hardware name and every displayed variant match the physical board.",
|
||||
"Back up configuration, keys, and radio settings before changing roles or profiles.",
|
||||
];
|
||||
if (profile.role === "partition-expander") {
|
||||
return common.concat(
|
||||
kind === "zip"
|
||||
? ["This ZIP is a staged ESP32 partition-migration package, not an nRF52 Serial DFU package. Follow its included README for the existing Wi-Fi or LoRa update route."]
|
||||
: ["Install this temporary bridge only through the existing OTA route documented for this exact board and source layout; do not flash it as permanent node firmware."],
|
||||
["Keep power stable while the partition table changes. Confirm that private identity restoration succeeded before transferring the exact Full application over LoRa or Wi-Fi.",
|
||||
"The Partition Expander is a temporary update receiver, not a repeater, client, or sensor firmware."]
|
||||
);
|
||||
}
|
||||
const memory = profile.controls;
|
||||
const selectedAsset = canonicalAsset(profile.files, kind);
|
||||
if (memory && memory.memoryNote && memory.memorySource &&
|
||||
|
||||
+115
-24
@@ -2,8 +2,6 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
start: "2026-09-21T17:00:00-07:00",
|
||||
end: "2026-09-23T17:00:00-07:00",
|
||||
tz: "",
|
||||
freq: "910.1",
|
||||
bw: "500",
|
||||
@@ -17,6 +15,10 @@
|
||||
const SCHEDULE_HORIZON_MS = 0x7fffffff;
|
||||
const SCHEDULER_EPOCH_MAX = 0xffffffff;
|
||||
const EARLY_JOIN_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_START_HOUR = 17;
|
||||
const DEFAULT_WINDOW_DAYS = 2;
|
||||
const SCHEDULE_MODE_RELATIVE = "relative";
|
||||
const SCHEDULE_MODE_ABSOLUTE = "absolute";
|
||||
const CLOCK_RESET_COMMAND = "clkreboot";
|
||||
const TIMEZONE_BOUNDARY_PATH = "../_data/timezones-2025b-simplified.json";
|
||||
const TIMEZONE_MAP_STYLE = Object.freeze({
|
||||
@@ -202,7 +204,7 @@
|
||||
|
||||
function hasPresetParameters(search) {
|
||||
const params = new URLSearchParams(search || "");
|
||||
return Object.keys(DEFAULTS).some(function (key) {
|
||||
return ["start", "end"].concat(Object.keys(DEFAULTS)).some(function (key) {
|
||||
return params.has(key);
|
||||
});
|
||||
}
|
||||
@@ -229,7 +231,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
function configFromSearch(search, fallbackTimeZone) {
|
||||
function calendarDayValue(parts, daysLater) {
|
||||
const value = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + daysLater));
|
||||
return String(value.getUTCFullYear()).padStart(4, "0") + "-" +
|
||||
twoDigits(value.getUTCMonth() + 1) + "-" + twoDigits(value.getUTCDate());
|
||||
}
|
||||
|
||||
function defaultWindow(timeZone, nowMs) {
|
||||
const zone = validateTimeZone(timeZone);
|
||||
const now = nowMs == null ? Date.now() : Number(nowMs);
|
||||
if (!Number.isFinite(now)) throw new PresetTestError("browser clock is unavailable");
|
||||
const today = zonedParts(now, zone);
|
||||
const startText = calendarDayValue(today, 1) + "T" +
|
||||
twoDigits(DEFAULT_START_HOUR) + ":00";
|
||||
const endText = calendarDayValue(today, 1 + DEFAULT_WINDOW_DAYS) + "T" +
|
||||
twoDigits(DEFAULT_START_HOUR) + ":00";
|
||||
return Object.freeze({
|
||||
startMs: localDateTimeToMs(startText, zone, "default start"),
|
||||
endMs: localDateTimeToMs(endText, zone, "default end"),
|
||||
});
|
||||
}
|
||||
|
||||
function configFromSearch(search, fallbackTimeZone, nowMs) {
|
||||
const params = new URLSearchParams(search || "");
|
||||
const raw = {};
|
||||
Object.keys(DEFAULTS).forEach(function (key) {
|
||||
@@ -239,9 +262,14 @@
|
||||
raw.tz = fallbackTimeZone || browserTimeZone();
|
||||
}
|
||||
|
||||
const startMs = parseTimestamp(raw.start, "start");
|
||||
const endMs = parseTimestamp(raw.end, "end");
|
||||
const tz = validateTimeZone(raw.tz);
|
||||
const generatedWindow = defaultWindow(tz, nowMs);
|
||||
let startMs = params.has("start")
|
||||
? parseTimestamp(params.get("start"), "start") : generatedWindow.startMs;
|
||||
let endMs = params.has("end")
|
||||
? parseTimestamp(params.get("end"), "end") : generatedWindow.endMs;
|
||||
if (params.has("start") && !params.has("end")) endMs = startMs + 86400000;
|
||||
if (!params.has("start") && params.has("end")) startMs = endMs - 86400000;
|
||||
const freq = strictNumber(raw.freq, "freq");
|
||||
const bw = strictNumber(raw.bw, "bw");
|
||||
const sf = strictInteger(raw.sf, "sf");
|
||||
@@ -289,14 +317,17 @@
|
||||
}
|
||||
|
||||
function isDefaultPreset(config) {
|
||||
const startLocal = zonedInputValue(config.startMs, config.tz);
|
||||
const endLocal = zonedInputValue(config.endMs, config.tz);
|
||||
const startParts = zonedParts(config.startMs, config.tz);
|
||||
return (
|
||||
config.startMs === parseTimestamp(DEFAULTS.start, "start") &&
|
||||
config.endMs === parseTimestamp(DEFAULTS.end, "end") &&
|
||||
config.freq === Number(DEFAULTS.freq) &&
|
||||
config.bw === Number(DEFAULTS.bw) &&
|
||||
config.sf === Number(DEFAULTS.sf) &&
|
||||
config.cr === Number(DEFAULTS.cr) &&
|
||||
config.tx === Number(DEFAULTS.tx)
|
||||
config.tx === Number(DEFAULTS.tx) &&
|
||||
startLocal.endsWith("T17:00") &&
|
||||
endLocal === calendarDayValue(startParts, DEFAULT_WINDOW_DAYS) + "T17:00"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,30 +385,47 @@
|
||||
reason: "The end is outside the firmware's roughly 24-day horizon; return closer to the test.",
|
||||
};
|
||||
}
|
||||
return { available: true, reason: "Ready to queue after the node clock is verified." };
|
||||
return { available: true, reason: "Ready to queue." };
|
||||
}
|
||||
|
||||
function primaryScheduleAvailability(config, nowMs) {
|
||||
if (nowMs >= config.startMs) {
|
||||
return { available: false, reason: "The start time has passed; use the immediate option." };
|
||||
}
|
||||
return { available: true, reason: "Ready to queue after the node clock is verified." };
|
||||
return { available: true, reason: "Ready to queue." };
|
||||
}
|
||||
|
||||
function commandsFor(config, nowMs, clockOffsetSeconds) {
|
||||
function relativeMinutes(targetMs, nowMs) {
|
||||
return Math.max(1, Math.ceil((targetMs - nowMs) / 60000));
|
||||
}
|
||||
|
||||
function commandsFor(config, nowMs, clockOffsetSeconds, scheduleMode) {
|
||||
const tuple = [config.freqText, config.bwText, config.sf, config.cr].join(",");
|
||||
const minutes = remainingMinutes(config, nowMs);
|
||||
const scheduled = schedulerEpochs(config, clockOffsetSeconds);
|
||||
const mode = scheduleMode || SCHEDULE_MODE_RELATIVE;
|
||||
if (mode !== SCHEDULE_MODE_RELATIVE && mode !== SCHEDULE_MODE_ABSOLUTE) {
|
||||
throw new PresetTestError("schedule mode must be relative or absolute");
|
||||
}
|
||||
let startArgument;
|
||||
let endArgument;
|
||||
if (mode === SCHEDULE_MODE_RELATIVE) {
|
||||
startArgument = "+" + relativeMinutes(config.startMs, nowMs);
|
||||
endArgument = "+" + relativeMinutes(config.endMs, nowMs);
|
||||
} else {
|
||||
const scheduled = schedulerEpochs(config, clockOffsetSeconds);
|
||||
startArgument = String(scheduled.startEpoch);
|
||||
endArgument = String(scheduled.endEpoch);
|
||||
}
|
||||
return Object.freeze({
|
||||
stockNow: "tempradio " + tuple + "," + minutes,
|
||||
companionNow:
|
||||
"set radio2.cross on\nset tempradio2 " + tuple + ",rxtx," + minutes,
|
||||
primaryScheduled:
|
||||
"set tempradioat " + tuple + "," + scheduled.startEpoch + "," +
|
||||
scheduled.endEpoch + "\nget tempradioat",
|
||||
"set tempradioat " + tuple + "," + startArgument + "," +
|
||||
endArgument + "\nget tempradioat",
|
||||
companionScheduled:
|
||||
"set radio2.cross on\nset tempradioat2 " + tuple + ",rxtx," +
|
||||
scheduled.startEpoch + "," + scheduled.endEpoch + "\nget tempradioat2",
|
||||
startArgument + "," + endArgument + "\nget tempradioat2",
|
||||
stockCancelDuring: "tempradio " + tuple + ",1",
|
||||
stockLeaveIn30: "tempradio " + tuple + ",30",
|
||||
primaryCancel:
|
||||
@@ -824,18 +872,32 @@
|
||||
if (builderDisclosure) builderDisclosure.open = !showTest;
|
||||
|
||||
let activeClockOffsetSeconds = 0;
|
||||
let scheduleMode = SCHEDULE_MODE_RELATIVE;
|
||||
|
||||
function setScheduledCommands() {
|
||||
function setScheduledCommands(nowMs) {
|
||||
const staticCommands = commandsFor(
|
||||
config,
|
||||
config.startMs,
|
||||
activeClockOffsetSeconds
|
||||
nowMs == null ? Date.now() : nowMs,
|
||||
activeClockOffsetSeconds,
|
||||
scheduleMode
|
||||
);
|
||||
setCommand(root, "primary-scheduled", staticCommands.primaryScheduled);
|
||||
setCommand(root, "companion-scheduled", staticCommands.companionScheduled);
|
||||
return staticCommands;
|
||||
}
|
||||
|
||||
function setScheduleMode(nextMode) {
|
||||
scheduleMode = nextMode === SCHEDULE_MODE_ABSOLUTE
|
||||
? SCHEDULE_MODE_ABSOLUTE : SCHEDULE_MODE_RELATIVE;
|
||||
const absoluteControls = root.querySelector('[data-role="absolute-clock-controls"]');
|
||||
if (absoluteControls) absoluteControls.hidden = scheduleMode !== SCHEDULE_MODE_ABSOLUTE;
|
||||
const relativeNote = root.querySelector('[data-role="relative-schedule-note"]');
|
||||
if (relativeNote) relativeNote.hidden = scheduleMode !== SCHEDULE_MODE_RELATIVE;
|
||||
const absoluteNote = root.querySelector('[data-role="absolute-schedule-note"]');
|
||||
if (absoluteNote) absoluteNote.hidden = scheduleMode !== SCHEDULE_MODE_ABSOLUTE;
|
||||
setScheduledCommands(Date.now());
|
||||
}
|
||||
|
||||
function setNodeClockStatus(message, state) {
|
||||
const status = root.querySelector('[data-role="node-clock-status"]');
|
||||
if (!status) return;
|
||||
@@ -848,7 +910,7 @@
|
||||
const text = input ? input.value : "";
|
||||
if (!String(text).trim()) {
|
||||
activeClockOffsetSeconds = 0;
|
||||
setScheduledCommands();
|
||||
setScheduledCommands(Date.now());
|
||||
setNodeClockStatus(
|
||||
"No node-clock correction is applied. Scheduled commands use the normal UTC epochs.",
|
||||
"normal"
|
||||
@@ -860,7 +922,7 @@
|
||||
const offset = nodeClockOffsetSeconds(nodeClockEpoch, Date.now());
|
||||
schedulerEpochs(config, offset);
|
||||
activeClockOffsetSeconds = offset;
|
||||
setScheduledCommands();
|
||||
setScheduledCommands(Date.now());
|
||||
const direction = offset === 0
|
||||
? "matches browser UTC to the minute"
|
||||
: "is " + formatClockOffset(offset) + (offset > 0 ? " ahead of" : " behind") +
|
||||
@@ -901,7 +963,7 @@
|
||||
" window. Saved primary settings return automatically at the end."
|
||||
);
|
||||
|
||||
const staticCommands = setScheduledCommands();
|
||||
const staticCommands = setScheduledCommands(Date.now());
|
||||
setCommand(root, "stock-leave-30", staticCommands.stockLeaveIn30);
|
||||
setCommand(root, "primary-cancel", staticCommands.primaryCancel);
|
||||
setCommand(root, "companion-cancel-before", staticCommands.companionCancelBefore);
|
||||
@@ -911,6 +973,13 @@
|
||||
}
|
||||
|
||||
if (showTest) {
|
||||
root.querySelectorAll('input[name="schedule-command-mode"]').forEach(function (input) {
|
||||
input.addEventListener("change", function () {
|
||||
if (input.checked) setScheduleMode(input.value);
|
||||
});
|
||||
});
|
||||
setScheduleMode(SCHEDULE_MODE_RELATIVE);
|
||||
|
||||
const nodeClockInput = root.querySelector('[data-role="node-clock-input"]');
|
||||
const applyNodeClockButton = root.querySelector('[data-action="apply-node-clock"]');
|
||||
if (nodeClockInput) {
|
||||
@@ -928,6 +997,9 @@
|
||||
|
||||
const generator = root.querySelector('[data-role="url-generator"]');
|
||||
if (generator) {
|
||||
const sourceParams = new URLSearchParams(search || "");
|
||||
const followsDefaultWindow = !sourceParams.has("start") && !sourceParams.has("end");
|
||||
let timeFieldsEdited = false;
|
||||
generator.elements.start.value = zonedInputValue(config.startMs, config.tz);
|
||||
generator.elements.end.value = zonedInputValue(config.endMs, config.tz);
|
||||
generator.elements.tz.value = config.tz;
|
||||
@@ -987,9 +1059,19 @@
|
||||
event.preventDefault();
|
||||
generateUrl();
|
||||
});
|
||||
[generator.elements.start, generator.elements.end].forEach(function (input) {
|
||||
input.addEventListener("input", function () { timeFieldsEdited = true; });
|
||||
});
|
||||
generator.addEventListener("change", generateUrl);
|
||||
generateUrl();
|
||||
initTimeZoneMap(root, config.tz, generateUrl);
|
||||
initTimeZoneMap(root, config.tz, function (zone) {
|
||||
if (followsDefaultWindow && !timeFieldsEdited) {
|
||||
const nextWindow = defaultWindow(zone, Date.now());
|
||||
generator.elements.start.value = zonedInputValue(nextWindow.startMs, zone);
|
||||
generator.elements.end.value = zonedInputValue(nextWindow.endMs, zone);
|
||||
}
|
||||
generateUrl();
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-copy-command]").forEach(function (button) {
|
||||
@@ -1011,7 +1093,11 @@
|
||||
const status = root.querySelector('[data-role="status"]');
|
||||
const primarySchedule = primaryScheduleAvailability(config, nowMs);
|
||||
const schedule = scheduleAvailability(config, nowMs);
|
||||
const commands = commandsFor(config, nowMs, activeClockOffsetSeconds);
|
||||
const commands = commandsFor(
|
||||
config, nowMs, activeClockOffsetSeconds, scheduleMode
|
||||
);
|
||||
setCommand(root, "primary-scheduled", commands.primaryScheduled);
|
||||
setCommand(root, "companion-scheduled", commands.companionScheduled);
|
||||
|
||||
status.dataset.state = phase;
|
||||
if (phase === "before") {
|
||||
@@ -1111,6 +1197,10 @@
|
||||
SCHEDULE_HORIZON_MS: SCHEDULE_HORIZON_MS,
|
||||
SCHEDULER_EPOCH_MAX: SCHEDULER_EPOCH_MAX,
|
||||
EARLY_JOIN_MS: EARLY_JOIN_MS,
|
||||
DEFAULT_START_HOUR: DEFAULT_START_HOUR,
|
||||
DEFAULT_WINDOW_DAYS: DEFAULT_WINDOW_DAYS,
|
||||
SCHEDULE_MODE_RELATIVE: SCHEDULE_MODE_RELATIVE,
|
||||
SCHEDULE_MODE_ABSOLUTE: SCHEDULE_MODE_ABSOLUTE,
|
||||
CLOCK_RESET_COMMAND: CLOCK_RESET_COMMAND,
|
||||
PresetTestError: PresetTestError,
|
||||
parseNodeClock: parseNodeClock,
|
||||
@@ -1127,6 +1217,7 @@
|
||||
presetEyebrow: presetEyebrow,
|
||||
validateTimeZone: validateTimeZone,
|
||||
browserTimeZone: browserTimeZone,
|
||||
defaultWindow: defaultWindow,
|
||||
hasPresetParameters: hasPresetParameters,
|
||||
supportedTimeZones: supportedTimeZones,
|
||||
phaseAt: phaseAt,
|
||||
|
||||
@@ -393,32 +393,88 @@
|
||||
content: "Hide advanced";
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode legend {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode label {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode input {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.2rem;
|
||||
accent-color: var(--preset-accent);
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode label:has(input:checked) {
|
||||
border-color: var(--preset-accent);
|
||||
background: color-mix(in srgb, var(--preset-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.preset-test-generator-disclosure-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.preset-test-generator-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(15rem, 2fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-test-generator {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.8rem;
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.preset-test-time-fields,
|
||||
.preset-test-radio-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.preset-test-time-fields {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preset-test-radio-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(15rem, 2fr);
|
||||
gap: 0.65rem;
|
||||
align-items: start;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.preset-test-generator,
|
||||
.preset-test-estimates {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.65rem;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.preset-test-generator-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.preset-test-generator label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -443,7 +499,6 @@
|
||||
}
|
||||
|
||||
.preset-test-timezone-picker {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -547,21 +602,21 @@
|
||||
}
|
||||
|
||||
.preset-test-estimates h3 {
|
||||
margin-top: 0;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.preset-test-estimates dl {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin: 0 0 1rem;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
|
||||
.preset-test-estimates dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: 0.65rem;
|
||||
border-bottom: 1px solid var(--preset-border);
|
||||
padding-bottom: 0.55rem;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.preset-test-estimates dt {
|
||||
@@ -578,6 +633,11 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preset-test-estimates .preset-test-note {
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.preset-test-generator-error {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -614,8 +674,13 @@
|
||||
.preset-test-grid--clock,
|
||||
.preset-test-clock-check,
|
||||
.preset-test-clock-override,
|
||||
.preset-test-generator-layout,
|
||||
.preset-test-generator-fields {
|
||||
.preset-test-time-fields,
|
||||
.preset-test-radio-layout,
|
||||
.preset-test-radio-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode fieldset {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +196,10 @@ set ota.speed <0.05..3>
|
||||
`1` preserves the current OTA timing. Values below `1` slow OTA traffic;
|
||||
values above `1` shorten adjustable delays. For example, `0.5` doubles those
|
||||
delays and `3` divides them by three. Decimal values such as `.05` work.
|
||||
`ota config speed <factor>` is an equivalent setter, and `ota config` shows
|
||||
the value. The setting is saved and can be changed during a transfer.
|
||||
`ota config speed <factor>` is an equivalent setter. `get ota.speed` and
|
||||
`ota config` show both the saved value and the effective `packet` pacing
|
||||
factor. Automatic loss response may make packet spacing slower without
|
||||
changing the saved setting. The setting can be changed during a transfer.
|
||||
|
||||
Apply it on the source, destination, and relays. It controls all LoRa OTA
|
||||
packet types on both profiles, relay timing, discovery, and OTA adverts.
|
||||
|
||||
@@ -1,20 +1,101 @@
|
||||
# ESP32 legacy-partition migration over Wi-Fi
|
||||
# ESP32 legacy-partition migration over Wi-Fi or LoRa
|
||||
|
||||
The partition-migration bridge lets a supported ESP32 node move from a small
|
||||
dual-OTA layout to a verified larger dual-OTA layout without cable flashing.
|
||||
It is designed for inaccessible repeaters: the bridge itself fits a legacy
|
||||
1.25 MiB slot, replaces the table from Wi-Fi, and then exposes Wi-Fi again for
|
||||
the full application image.
|
||||
dual-OTA layout to a verified larger dual-OTA layout without cable flashing or
|
||||
a full-chip erase. It fits a legacy 1.25 MiB slot. The Wi-Fi version exposes an
|
||||
updater for the full image after migration. The LoRa version retains the old
|
||||
OTA-capable repeater in the other expanded slot and returns to it after
|
||||
restoring identity, so it can fetch the full image over LoRa.
|
||||
|
||||
It currently has explicit, byte-for-byte generated target plans for Arduino
|
||||
ESP32 `default_8MB.csv` and `default_16MB.csv`. It does not guess a table for
|
||||
other flash sizes. Adding a board later means adding one reviewed target plan
|
||||
and its generated table-prefix test; the bridge core is otherwise shared.
|
||||
It has explicit, byte-for-byte generated target plans for the 4 MiB MeshCore
|
||||
dual-OTA Full table and Arduino ESP32 `default_8MB.csv` and
|
||||
`default_16MB.csv`. It does not guess a table for other flash sizes. Adding a
|
||||
flash size later means adding one reviewed target plan and its generated
|
||||
table-prefix test; the bridge core is otherwise shared.
|
||||
|
||||
| Flash size | Target table | app0 / app1 size | Typical board |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 MiB | `default_8MB.csv` | 0x330000 / 0x330000 | Seeed XIAO ESP32-S3 |
|
||||
| 16 MiB | `default_16MB.csv` | 0x640000 / 0x640000 | Heltec V4 / V4.3 OLED |
|
||||
| 4 MiB | `variants/dual_ota_full_4MB.csv` | 0x1F0000 / 0x1F0000 | ThinkNode M2/M5, LilyGo T3S3, Nibble, Heltec CT62 |
|
||||
| 8 MiB | `default_8MB.csv` | 0x330000 / 0x330000 | Seeed XIAO ESP32-S3, Heltec V3 |
|
||||
| 16 MiB | `default_16MB.csv` | 0x640000 / 0x640000 | Heltec V4 / V4.3, Station G2 |
|
||||
|
||||
## Repeatable ESP32 build recipe
|
||||
|
||||
From a clean commit, run the shell menu in WSL/Linux:
|
||||
|
||||
```bash
|
||||
sh scripts/build_esp32_partition_migration.sh
|
||||
```
|
||||
|
||||
Choose one exact board/role, or **all listed board/role pairs**. The menu
|
||||
also offers all repeaters, all room servers, or all sensors separately. It asks
|
||||
for the firmware version, radio preset, and profile, then builds Full application
|
||||
images first, the available bridge(s) for each selected board, and verifies each
|
||||
ZIP. Only one PlatformIO process runs at a time; `build.sh` can clean the
|
||||
shared `.pio/build` tree between Full targets. The same menu can be run
|
||||
non-interactively:
|
||||
|
||||
```bash
|
||||
sh scripts/build_esp32_partition_migration.sh --board heltec-v4 \
|
||||
--version v1.17.1.7-halo-keymind-cascade-dev \
|
||||
--radio-preset usa-cascadia --profile cascade
|
||||
sh scripts/build_esp32_partition_migration.sh --all \
|
||||
--version v1.17.1.7-halo-keymind-cascade-dev \
|
||||
--radio-preset usa-cascadia --profile cascade
|
||||
sh scripts/build_esp32_partition_migration.sh --role sensor \
|
||||
--version v1.17.1.7-halo-keymind-cascade-dev \
|
||||
--radio-preset usa-cascadia --profile cascade
|
||||
```
|
||||
|
||||
`--all` creates one verified ZIP per listed board/role pair and a release
|
||||
ZIP containing all of them, their hashes, and a manifest. This is a **local
|
||||
release bundle**, not an upload to GitHub Releases. It is not an archive of
|
||||
old firmware binaries. `--list-boards` shows the configured choices.
|
||||
The release fails instead of publishing a partial bundle if one board package
|
||||
is absent or fails verification.
|
||||
|
||||
The underlying Python recipe remains available:
|
||||
|
||||
```bash
|
||||
python3 -B scripts/build_esp32_partition_migration.py \
|
||||
--version v1.17.1.7-halo-keymind-cascade-dev \
|
||||
--radio-preset usa-cascadia --profile cascade
|
||||
```
|
||||
|
||||
The default builds all configured board/role recipes. Add `--board heltec-v4`
|
||||
or `--board xiao-s3-wio` to build one; repeat `--board` for a selected set.
|
||||
Use `--dry-run` to inspect the command order without building. The resulting
|
||||
ZIPs appear under `.releases/esp32-expanded-<commit>/packages/`. The recipe
|
||||
requires a clean checkout so each ZIP identifies the firmware commit it was
|
||||
built from. It creates files only; it does not flash a device or erase flash.
|
||||
|
||||
The catalog covers canonical ESP32 repeater, room-server, and sensor roles on
|
||||
4, 8, and 16 MiB chip-family plans, including historical 1.25 MiB candidates
|
||||
now built with expanded tables. This includes ESP32-S3, original ESP32, and
|
||||
ESP32-C3 boards with an evidenced old default layout. `--list-boards` is the
|
||||
authoritative list of exact targets; specialty ESP-NOW/observer aliases are
|
||||
separate identities and are not packaged under their old IDs. With Wi-Fi OTA
|
||||
they can migrate to the
|
||||
same physical board's canonical role image; their feature settings may need
|
||||
to be recreated. The LoRa route still requires an exact old target ID. The
|
||||
catalog omits boards with no evidenced legacy default-layout build, such as
|
||||
boards introduced with `min_spiffs.csv` or another non-1.25 MiB layout. Some
|
||||
listed boards use larger slots today but had historical default-layout builds.
|
||||
A listed package is usable only when the installed old image has a working
|
||||
Wi-Fi updater and the bridge verifies its live source table. Menu inclusion
|
||||
does **not** override either check. The only LoRa
|
||||
bridge packages currently available are the exact-target Heltec V4 and XIAO
|
||||
S3 WIO repeater packages. On 4 MiB flash the expanded app1 overlaps old app1,
|
||||
so the currently safe LoRa receiver-copy method cannot be offered there. A
|
||||
4 MiB Full build offers 1,984 KiB slots, not 4 MiB per slot. A Wi-Fi-only ZIP
|
||||
can omit `full-application.mota` when its application exceeds the current
|
||||
2 KiB x 1024-block LoRa serving limit; `full-application.bin` remains the
|
||||
verified Wi-Fi update image.
|
||||
|
||||
Other ESP32 boards need a reviewed source updater, chip/flash-size bridge,
|
||||
verified Full image, and board entry before they can be added. The LoRa route
|
||||
also needs an exact old OTA target ID and a non-overlapping receiver handoff.
|
||||
Matching flash capacity alone is not enough to claim a package is safe.
|
||||
|
||||
## What the bridge preserves
|
||||
|
||||
@@ -31,6 +112,9 @@ image is deliberately not raw-copied into an expanded partition: SPIFFS is not
|
||||
safe to resize that way. Other SPIFFS-backed settings can be recreated after
|
||||
the full image boots.
|
||||
|
||||
Migration does erase the 4 KiB partition-table sector and the destination app
|
||||
sectors. Expanded SPIFFS can be reformatted. It never issues a full-chip erase.
|
||||
|
||||
## Eligible source layout
|
||||
|
||||
The bridge only accepts a source table that has all of the following:
|
||||
@@ -38,7 +122,7 @@ The bridge only accepts a source table that has all of the following:
|
||||
- NVS at `0x9000`, size `0x5000`, and OTA metadata at `0xE000`, size `0x2000`.
|
||||
- Two distinct, non-empty OTA application slots and a non-empty SPIFFS
|
||||
partition, all inside the physical flash size.
|
||||
- A known exact 8 MiB or 16 MiB physical flash capacity and a source table
|
||||
- A known exact 4, 8, or 16 MiB physical flash capacity and a source table
|
||||
different from that capacity's target table.
|
||||
|
||||
This means the source app-slot and SPIFFS sizes can vary; the bridge validates
|
||||
@@ -51,31 +135,46 @@ in legacy A or B.
|
||||
Keep power stable while the bridge is replacing the 4 KiB table sector. A loss
|
||||
of power before that point leaves the old table and source intact. After that
|
||||
small critical write, the selected A copy of the bridge is the recovery path.
|
||||
The stock ESP32 bootloader reads one partition-table sector; rewriting that
|
||||
sector is **not atomic**. A power loss during its erase/write window can leave
|
||||
an unreadable table and require cable recovery. This recipe must not be called
|
||||
power-loss-proof on a device whose USB/serial flash port is inaccessible.
|
||||
|
||||
## Build and migrate a Seeed XIAO ESP32-S3
|
||||
## Migrate a Seeed XIAO ESP32-S3
|
||||
|
||||
The production bridge is a legacy-slot application image:
|
||||
|
||||
```powershell
|
||||
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e xiao_s3_partition_migrator
|
||||
```
|
||||
|
||||
Build the Full XIAO image with its 8 MiB table:
|
||||
|
||||
```powershell
|
||||
$env:MESHCORE_ESP32_FULL_BUILD = '1'
|
||||
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e Xiao_S3_WIO_repeater_observer_mqtt
|
||||
```
|
||||
|
||||
Upload the bridge through the legacy node's existing Wi-Fi browser updater.
|
||||
Use `--board xiao-s3-wio` with the recipe above. Its Wi-Fi bridge is a
|
||||
legacy-slot application image; its Full image keeps the old repeater's exact
|
||||
LoRa OTA target ID. Upload `wifi-bridge.bin` from the ZIP through the legacy
|
||||
node's existing Wi-Fi browser updater.
|
||||
Then join `MeshCore-Migrate` (password `meshcore-migrate`) and wait for
|
||||
**Expanded layout ready**. Upload the Full app-only `.bin` from the second
|
||||
build at `/update`. Do not upload an `-merged.bin`: merged images include
|
||||
**Expanded layout ready**. Upload `full-application.bin` at `/update`.
|
||||
Do not upload an `-merged.bin`: merged images include
|
||||
bootloader and table offsets for cable flashing, not browser OTA.
|
||||
|
||||
`xiao_s3_partition_legacy_seed` is a disposable-hardware test image only. It
|
||||
models the legacy table and an identity file; it is not a deployable repeater.
|
||||
|
||||
## LoRa-only migration of a repeater
|
||||
|
||||
The exact old repeater must already support MeshCore LoRa mOTA and have a valid
|
||||
EndF image identity. The LoRa bridge checks the old app's target ID and body
|
||||
hash before any partition-table write; an unsupported or damaged receiver is
|
||||
refused. It works from either legacy A or B. It copies legacy B to the future
|
||||
B address, boots the bridge once to restore the private key in expanded
|
||||
SPIFFS, then boots the preserved old repeater. That repeater can receive the
|
||||
full image into its now-expanded inactive slot.
|
||||
|
||||
The board-specific LoRa bridges are `heltec_v4_partition_migrator_lora_repeater`
|
||||
and `xiao_s3_partition_migrator_lora_repeater`. Build the final repeater with
|
||||
`bash build.sh build-firmware <target> --full-exact` so its mOTA target ID
|
||||
still matches the old repeater. `scripts/package_esp32_partition_migration.py`
|
||||
checks the images, partition tables, hashes and mOTA containers and creates
|
||||
separate Wi-Fi/LoRa migration ZIPs for those two boards. The ZIP README gives
|
||||
the exact `ota ls`, `ota pull <id> flash`, `ota install` sequence.
|
||||
|
||||
A stock repeater without LoRa mOTA cannot use the LoRa-only route. The
|
||||
Wi-Fi bridge remains the route for that device.
|
||||
|
||||
## Heltec V4 / V4.3
|
||||
|
||||
Build `heltec_v4_partition_migrator`, upload it through the legacy Wi-Fi
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
# Heltec V4 legacy-partition migration over Wi-Fi
|
||||
|
||||
Heltec V4/V4.3 OLED repeaters with their normal 16 MiB flash use the shared
|
||||
[ESP32 Wi-Fi partition migration](esp32_wifi_partition_migration.md) bridge.
|
||||
[ESP32 partition migration](esp32_wifi_partition_migration.md) bridge.
|
||||
It changes Arduino's old `default.csv` layout, with two 1.25 MiB OTA slots,
|
||||
to `default_16MB.csv`, with two 6.25 MiB OTA slots.
|
||||
|
||||
Build the bridge application image:
|
||||
Build the complete V4 package with the repeatable ESP32 recipe in WSL/Linux:
|
||||
|
||||
```powershell
|
||||
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e heltec_v4_partition_migrator
|
||||
```bash
|
||||
python3 -B scripts/build_esp32_partition_migration.py \
|
||||
--board heltec-v4 --version v1.17.1.7-halo-keymind-cascade-dev \
|
||||
--radio-preset usa-cascadia --profile cascade
|
||||
```
|
||||
|
||||
Upload `.pio/build/heltec_v4_partition_migrator/firmware.bin` through the
|
||||
The ZIP contains the Wi-Fi bridge, LoRa bridge, and exact-target Full image.
|
||||
Upload its `wifi-bridge.bin` through the
|
||||
legacy browser Wi-Fi updater. Rejoin `MeshCore-Migrate` with password
|
||||
`meshcore-migrate`; when it reports **Expanded layout ready**, upload the
|
||||
app-only Full V4 repeater image at `/update`.
|
||||
ZIP's `full-application.bin` at `/update`.
|
||||
|
||||
Do not use an `-merged.bin` in either browser uploader. Merged images contain
|
||||
bootloader and table offsets for cable flashing. The bridge preserves the
|
||||
private identity; other SPIFFS-backed settings may be recreated after the
|
||||
Full image starts.
|
||||
|
||||
For an existing `heltec_v4_repeater` that already supports LoRa mOTA, use the
|
||||
separate LoRa migration package described in the shared guide. It preserves
|
||||
the old repeater across the partition change so the full image can also be
|
||||
installed over LoRa without reaching the board's Wi-Fi network.
|
||||
@@ -472,10 +472,10 @@ required `ver` command instead of entering an operator continuation loop.
|
||||
The default TempRadio tuple is:
|
||||
|
||||
```text
|
||||
909.950,250,5,5,120
|
||||
909.5,500,5,5,120
|
||||
```
|
||||
|
||||
The test default is 250 kHz bandwidth, SF5, and CR5. The frequency is only a
|
||||
The lab test default is 909.5 MHz, 500 kHz bandwidth, SF5, and CR5. The frequency is only a
|
||||
North American example: choose a legal frequency supported by every
|
||||
participating radio and appropriate to your location. Older radios that do not
|
||||
support SF5 require a complete replacement tuple passed with `--temp-radio`.
|
||||
|
||||
@@ -430,8 +430,13 @@ already received; queue congestion alone does not trigger legacy fallback.
|
||||
Retry timing includes the primary channel when an RX-only temporary radio2
|
||||
uses crossover to send requests there.
|
||||
|
||||
`ota config speed 0.5` is an equivalent setter. `ota config speed` and
|
||||
`ota speed` read the current factor; the `ota config` summary also includes it.
|
||||
`ota config speed 0.5` is an equivalent setter. `get ota.speed`,
|
||||
`ota config speed`, and `ota speed` show both the saved `ota.speed` setting and
|
||||
the effective `packet` pace. The latter is the slower of the saved speed and
|
||||
the sender's automatic loss-responsive packet-spacing factor; it does not
|
||||
replace the saved setting or change non-packet OTA timers. The `ota config`
|
||||
summary also shows both values. If no OTA workspace is active, the automatic
|
||||
factor starts at 1, so `packet` reflects the saved setting up to 1x.
|
||||
The setting is available on OTA-enabled repeaters, rooms, sensors, Companions,
|
||||
and seeder-only builds. Its separate `/ota_speed` settings file preserves the
|
||||
existing preference layouts and does not need an active OTA workspace.
|
||||
|
||||
+88
-53
@@ -61,13 +61,36 @@ create a shareable temporary-radio test URL.</span>
|
||||
<div data-role="test-content-footer" hidden>
|
||||
<details class="preset-test-generator-disclosure preset-test-keymind-disclosure"
|
||||
data-role="keymind-disclosure">
|
||||
<summary><h2>KeyMind firmware and advanced commands</h2></summary>
|
||||
<summary><h2>KeyMind Cascade firmware and advanced commands</h2></summary>
|
||||
<div class="preset-test-generator-disclosure-body">
|
||||
<p>
|
||||
Use these only if your firmware supports the listed commands or you need
|
||||
dual-radio operation, scheduling, or clock correction.
|
||||
</p>
|
||||
<p>Firmware schedule epochs: <code data-role="epoch-range"></code></p>
|
||||
|
||||
<section class="preset-test-schedule-mode" aria-labelledby="schedule-mode-title">
|
||||
<h2 id="schedule-mode-title">Scheduled command timing</h2>
|
||||
<fieldset>
|
||||
<legend>Choose the syntax supported by the node</legend>
|
||||
<label>
|
||||
<input type="radio" name="schedule-command-mode" value="relative" checked>
|
||||
<span><strong>KeyMind Cascade · <code>+minutes</code></strong> (default)</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="schedule-command-mode" value="absolute">
|
||||
<span><strong>Absolute Unix time</strong></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p data-role="relative-schedule-note">
|
||||
Relative commands calculate whole minutes from now to the shared start
|
||||
and end. Both offsets use the node's same command-time snapshot, so no
|
||||
clock correction is needed. Copy the command shortly after it is shown.
|
||||
</p>
|
||||
<p data-role="absolute-schedule-note" hidden>
|
||||
Absolute commands use fixed UTC epochs. Verify or compensate for the
|
||||
node clock with the controls below before queuing them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="preset-test-warning" aria-labelledby="continuity-plan-title">
|
||||
<h2 id="continuity-plan-title">Bridges and early-revert plan</h2>
|
||||
@@ -90,6 +113,8 @@ create a shareable temporary-radio test URL.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-role="absolute-clock-controls" hidden>
|
||||
<p>Firmware schedule epochs: <code data-role="epoch-range"></code></p>
|
||||
<section aria-labelledby="clock-check-title">
|
||||
<h2 id="clock-check-title">Check the node clock before scheduling</h2>
|
||||
<p>
|
||||
@@ -188,6 +213,7 @@ clock</code></pre>
|
||||
reboots.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="join-now-title">
|
||||
<h2 id="join-now-title">Companion: use both frequencies</h2>
|
||||
@@ -218,11 +244,11 @@ clock</code></pre>
|
||||
<p>
|
||||
A Simple Repeater build can schedule its primary radio with
|
||||
<code>tempradioat</code>. A Companion can instead schedule its second
|
||||
profile with <code>tempradioat2</code>. Both commands use the exact UTC
|
||||
epochs below, switch at the common start, and restore saved settings at
|
||||
the common end. A node-clock conversion above replaces only those command
|
||||
epochs with its per-node translated values. Room-server and sensor roles
|
||||
use the stock join command above instead.
|
||||
profile with <code>tempradioat2</code>. The default KeyMind Cascade form
|
||||
uses <code>+minutes</code> for both endpoints. Switch to Absolute Unix time
|
||||
above for fixed UTC epochs and the node-clock correction tools. Both forms
|
||||
switch at the common start and restore saved settings at the common end.
|
||||
Room-server and sensor roles use the stock join command above instead.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid">
|
||||
@@ -348,12 +374,14 @@ clock</code></pre>
|
||||
Enter the test times in the selected time zone and choose the radio
|
||||
tuple. The generated URL converts the times to exact UTC instants, keeps
|
||||
the date punctuation readable, and keeps <code>tz</code> so the page
|
||||
displays them in the organizer's local time zone.
|
||||
displays them in the organizer's local time zone. With no URL settings,
|
||||
the builder starts at 5:00 PM tomorrow and ends at 5:00 PM two days
|
||||
later in the selected time zone, creating a 48-hour window.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-generator-layout">
|
||||
<form class="preset-test-generator" data-role="url-generator">
|
||||
<div class="preset-test-generator-fields">
|
||||
<div class="preset-test-time-fields">
|
||||
<label>
|
||||
<span>Start date and time</span>
|
||||
<input type="datetime-local" name="start" step="60" required>
|
||||
@@ -362,35 +390,40 @@ clock</code></pre>
|
||||
<span>End date and time</span>
|
||||
<input type="datetime-local" name="end" step="60" required>
|
||||
</label>
|
||||
<div class="preset-test-timezone-picker">
|
||||
<div class="preset-test-timezone-toolbar">
|
||||
<div>
|
||||
<span>Selected time zone</span>
|
||||
<strong data-role="selected-time-zone">Detecting browser time zone…</strong>
|
||||
</div>
|
||||
<button type="button" data-action="use-browser-time-zone">
|
||||
Use browser time zone
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-timezone-picker">
|
||||
<div class="preset-test-timezone-toolbar">
|
||||
<div>
|
||||
<span>Selected time zone</span>
|
||||
<strong data-role="selected-time-zone">Detecting browser time zone…</strong>
|
||||
</div>
|
||||
<input type="hidden" name="tz" required>
|
||||
<div
|
||||
class="preset-test-timezone-map"
|
||||
data-role="timezone-map"
|
||||
aria-label="Interactive world map for selecting a time zone"
|
||||
></div>
|
||||
<p class="preset-test-timezone-status" data-role="timezone-map-status" aria-live="polite">
|
||||
Loading time zone map…
|
||||
</p>
|
||||
<small>
|
||||
Click a region to select its IANA time zone. The initial selection
|
||||
comes from <code>tz=</code> when present; otherwise it uses your
|
||||
browser's time zone. Map design inspired by
|
||||
<a href="https://zones.arilyn.cc/" target="_blank" rel="noopener">zones.arilyn.cc</a>;
|
||||
boundaries from
|
||||
<a href="https://github.com/evansiroky/timezone-boundary-builder" target="_blank" rel="noopener">Timezone Boundary Builder</a>
|
||||
and © OpenStreetMap contributors.
|
||||
</small>
|
||||
<button type="button" data-action="use-browser-time-zone">
|
||||
Use browser time zone
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="tz" required>
|
||||
<div
|
||||
class="preset-test-timezone-map"
|
||||
data-role="timezone-map"
|
||||
aria-label="Interactive world map for selecting a time zone"
|
||||
></div>
|
||||
<p class="preset-test-timezone-status" data-role="timezone-map-status" aria-live="polite">
|
||||
Loading time zone map…
|
||||
</p>
|
||||
<small>
|
||||
Click a region to select its IANA time zone. The initial selection
|
||||
comes from <code>tz=</code> when present; otherwise it uses your
|
||||
browser's time zone. Map design inspired by
|
||||
<a href="https://zones.arilyn.cc/" target="_blank" rel="noopener">zones.arilyn.cc</a>;
|
||||
boundaries from
|
||||
<a href="https://github.com/evansiroky/timezone-boundary-builder" target="_blank" rel="noopener">Timezone Boundary Builder</a>
|
||||
and © OpenStreetMap contributors.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-radio-layout">
|
||||
<div class="preset-test-radio-fields">
|
||||
<div class="preset-test-generator-subheading">Test radio profile</div>
|
||||
<label>
|
||||
<span>Frequency (MHz)</span>
|
||||
@@ -422,26 +455,28 @@ clock</code></pre>
|
||||
<input type="number" name="tx" min="-30" max="60" step="0.1" required>
|
||||
<small>This estimate-only value does not change the TempRadio commands.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<aside class="preset-test-estimates" aria-labelledby="radio-estimates-title">
|
||||
<h3 id="radio-estimates-title">Radio estimates</h3>
|
||||
<dl>
|
||||
<div><dt>Nominal LoRa bitrate</dt><dd data-role="estimate-rate">—</dd></div>
|
||||
<div><dt>Estimated sensitivity</dt><dd data-role="estimate-sensitivity">—</dd></div>
|
||||
<div><dt>TX output used</dt><dd data-role="estimate-tx">—</dd></div>
|
||||
<div><dt>Estimated link budget</dt><dd data-role="estimate-budget">—</dd></div>
|
||||
</dl>
|
||||
<p class="preset-test-note">
|
||||
The bitrate is the nominal LoRa physical-layer rate; usable payload
|
||||
throughput is lower. Sensitivity assumes a 6 dB receiver noise figure
|
||||
and the standard LoRa SNR threshold for the selected spreading factor.
|
||||
Link budget is TX output minus that sensitivity, before antenna gain,
|
||||
cable loss, path loss, interference, and implementation differences.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<button type="submit">Generate test URL</button>
|
||||
</form>
|
||||
|
||||
<aside class="preset-test-estimates" aria-labelledby="radio-estimates-title">
|
||||
<h3 id="radio-estimates-title">Radio estimates</h3>
|
||||
<dl>
|
||||
<div><dt>Nominal LoRa bitrate</dt><dd data-role="estimate-rate">—</dd></div>
|
||||
<div><dt>Estimated sensitivity</dt><dd data-role="estimate-sensitivity">—</dd></div>
|
||||
<div><dt>TX output used</dt><dd data-role="estimate-tx">—</dd></div>
|
||||
<div><dt>Estimated link budget</dt><dd data-role="estimate-budget">—</dd></div>
|
||||
</dl>
|
||||
<p class="preset-test-note">
|
||||
The bitrate is the nominal LoRa physical-layer rate; usable payload
|
||||
throughput is lower. Sensitivity assumes a 6 dB receiver noise figure
|
||||
and the standard LoRa SNR threshold for the selected spreading factor.
|
||||
Link budget is TX output minus that sensitivity, before antenna gain,
|
||||
cable loss, path loss, interference, and implementation differences.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p class="preset-test-error preset-test-generator-error"
|
||||
|
||||
@@ -2544,7 +2544,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
|
||||
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
|
||||
#if COMPANION_FEATURE_OTA_CLI
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, reply_size)) return true;
|
||||
const auto* ota_context = mesh::ota::ota_context_if_active();
|
||||
const float adaptive_ota_pace = ota_context
|
||||
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, reply_size, adaptive_ota_pace)) return true;
|
||||
#endif
|
||||
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
|
||||
if (handleCompanionWirelessCommand(command, reply, reply_size)) return true;
|
||||
|
||||
@@ -9,10 +9,19 @@
|
||||
#include <esp_attr.h>
|
||||
#include <esp_flash.h>
|
||||
#include <esp_partition.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <nvs.h>
|
||||
#endif
|
||||
|
||||
#include <helpers/ESP32PartitionMigrationPolicy.h>
|
||||
#include <helpers/esp32/WiFiRadioPolicy.h>
|
||||
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
String partitionExpanderStatus();
|
||||
#endif
|
||||
|
||||
namespace migration = mesh::esp32_partition_migration;
|
||||
|
||||
namespace {
|
||||
@@ -33,12 +42,61 @@ constexpr char kMigrationNvsNamespace[] = "mesh-pt-migrate";
|
||||
constexpr char kMigrationIdentityKey[] = "identity";
|
||||
// NVS key names are limited to 15 characters.
|
||||
constexpr char kMigrationIdentityPendingKey[] = "id-pending";
|
||||
constexpr char kMigrationConfigKey[] = "cfg-meta";
|
||||
constexpr char kMigrationConfigPendingKey[] = "cfg-pending";
|
||||
constexpr char kMigrationConfigRestoredKey[] = "cfg-restored";
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
constexpr char kExpanderHandoffKey[] = "full-handoff";
|
||||
#endif
|
||||
constexpr uint32_t kConfigStageMagic = 0x43464731U;
|
||||
constexpr size_t kMaxConfigFileBytes = 16384;
|
||||
// The common preferences hold node name, password and primary radio settings.
|
||||
// Other entries preserve access policy, extra radio profiles and the credentials
|
||||
// needed to remain manageable after the SPIFFS partition moves.
|
||||
struct ConfigFile { const char* path; const char* nvs_key; };
|
||||
constexpr ConfigFile kConfigFiles[] = {
|
||||
{"/com_prefs", "cfg00"}, {"/node_prefs", "cfg01"},
|
||||
{"/s_contacts", "cfg02"}, {"/s_login_replay", "cfg03"},
|
||||
{"/regions2", "cfg04"}, {"/radio_profiles", "cfg05"},
|
||||
{"/mqtt_prefs", "cfg06"}, {"/mqtt.json", "cfg07"},
|
||||
{"/ota_config", "cfg08"}, {"/flood_filter", "cfg09"},
|
||||
{"/flood_filter_bl", "cfg10"}, {"/flood_ch_scope", "cfg11"},
|
||||
{"/flood_ch_req", "cfg12"}, {"/flood_grp_mod", "cfg13"},
|
||||
{"/clock_sync", "cfg14"}, {"/display_prefs", "cfg15"},
|
||||
{"/telemetry_tx", "cfg16"}, {"/data_tx", "cfg17"},
|
||||
{"/com_prefs.bak", "cfg18"}, {"/radio_profiles.bak", "cfg19"},
|
||||
{"/s_contacts.bak", "cfg20"}, {"/mqtt_prefs.bak", "cfg21"},
|
||||
{"/regions2.bak", "cfg22"}, {"/prefs.json", "cfg23"},
|
||||
{"/management", "cfg24"}, {"/management.bak", "cfg25"},
|
||||
{"/s_login_replay.bak", "cfg26"}, {"/ota_config.bak", "cfg27"},
|
||||
{"/ota_speed", "cfg28"}, {"/flood_ch_block", "cfg29"},
|
||||
{"/bsec_state.bin", "cfg30"}, {"/display_prefs.bak", "cfg31"},
|
||||
};
|
||||
constexpr size_t kConfigFileCount = sizeof(kConfigFiles) / sizeof(kConfigFiles[0]);
|
||||
static_assert(kConfigFileCount <= 32, "config presence mask is 32 bits");
|
||||
struct ConfigStage {
|
||||
uint32_t magic;
|
||||
uint32_t present;
|
||||
uint32_t sizes[kConfigFileCount];
|
||||
uint32_t crcs[kConfigFileCount];
|
||||
};
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
constexpr char kMigrationResumeSlotKey[] = "resume-slot";
|
||||
#endif
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
constexpr size_t kEndfBytes = 56;
|
||||
constexpr char kBridgeImageMarker[] = "MeshCore ESP32 partition migration bridge image";
|
||||
// Earlier bridge packages predate the dedicated marker but contain this page title.
|
||||
constexpr char kLegacyBridgeImageMarker[] = "MeshCore Wi-Fi partition migration";
|
||||
#endif
|
||||
static_assert(sizeof(kMigrationNvsNamespace) - 1 <= 15,
|
||||
"ESP32 NVS namespace names are limited to 15 characters");
|
||||
|
||||
AsyncWebServer server(80);
|
||||
bool migration_started = false;
|
||||
bool migration_complete = false;
|
||||
bool expanded_layout_ready = false;
|
||||
bool identity_ready = false;
|
||||
bool reboot_requested = false;
|
||||
uint32_t migration_at = 0;
|
||||
uint32_t reboot_at = 0;
|
||||
@@ -142,13 +200,6 @@ esp_partition_t rawPartition(uint32_t address, uint32_t size, const char* label)
|
||||
return part;
|
||||
}
|
||||
|
||||
esp_partition_t rawApp0Partition(uint32_t address, uint32_t size, const char* label) {
|
||||
esp_partition_t part = rawPartition(address, size, label);
|
||||
part.type = ESP_PARTITION_TYPE_APP;
|
||||
part.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0;
|
||||
return part;
|
||||
}
|
||||
|
||||
bool rangesOverlap(uint32_t first_address, uint32_t first_size,
|
||||
uint32_t second_address, uint32_t second_size) {
|
||||
const uint64_t first_end = static_cast<uint64_t>(first_address) + first_size;
|
||||
@@ -234,6 +285,22 @@ bool copyAndVerify(const esp_partition_t& source, uint32_t destination_address,
|
||||
// both layouts. The source file format is exactly the historical
|
||||
// IdentityStore layout: 32 public-key bytes followed by 64 private-key bytes.
|
||||
bool stageLegacyIdentity() {
|
||||
// A previous attempt may have reached the future app1 copy before power
|
||||
// failed. That copy can overlap old SPIFFS on 8/16 MiB boards. Never read
|
||||
// a regenerated or damaged legacy file over the already staged old key.
|
||||
Preferences prior_stage;
|
||||
if (prior_stage.begin(kMigrationNvsNamespace, true)) {
|
||||
const bool pending = prior_stage.getBool(kMigrationIdentityPendingKey, false);
|
||||
const size_t saved = pending
|
||||
? prior_stage.getBytes(kMigrationIdentityKey, copy_buffer,
|
||||
kIdentityFileBytes) : 0;
|
||||
prior_stage.end();
|
||||
if (pending) {
|
||||
if (saved == kIdentityFileBytes) return true;
|
||||
strcpy(status_text, "Refused: previously staged private key is incomplete");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!SPIFFS.begin(false)) {
|
||||
strcpy(status_text, "Could not mount legacy SPIFFS to save identity");
|
||||
return false;
|
||||
@@ -312,8 +379,8 @@ bool restoreStagedIdentity() {
|
||||
strcpy(status_text, "Could not finalize NVS identity recovery");
|
||||
return false;
|
||||
}
|
||||
migration_nvs.remove(kMigrationIdentityKey);
|
||||
const bool cleared = migration_nvs.remove(kMigrationIdentityPendingKey);
|
||||
if (cleared) migration_nvs.remove(kMigrationIdentityKey);
|
||||
migration_nvs.end();
|
||||
if (!cleared) {
|
||||
strcpy(status_text, "Private key restored; NVS cleanup needs retry");
|
||||
@@ -322,6 +389,439 @@ bool restoreStagedIdentity() {
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
bool verifyExpandedIdentityFile() {
|
||||
if (!SPIFFS.begin(false)) {
|
||||
strcpy(status_text, "Refused: expanded identity filesystem is unavailable");
|
||||
return false;
|
||||
}
|
||||
File identity = SPIFFS.open("/identity/_main.id", "r");
|
||||
const bool available = identity && identity.size() >= kIdentityFileBytes
|
||||
&& identity.read(copy_buffer, kIdentityFileBytes) == kIdentityFileBytes;
|
||||
if (identity) identity.close();
|
||||
SPIFFS.end();
|
||||
if (!available) strcpy(status_text, "Refused: restored private key is unavailable");
|
||||
return available;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool readConfigStage(Preferences& nvs, ConfigStage& stage) {
|
||||
if (nvs.getBytes(kMigrationConfigKey, &stage, sizeof(stage)) != sizeof(stage)
|
||||
|| stage.magic != kConfigStageMagic) {
|
||||
strcpy(status_text, "Refused: staged configuration manifest is incomplete");
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < kConfigFileCount; ++i) {
|
||||
const bool present = (stage.present & (1UL << i)) != 0;
|
||||
if ((!present && stage.sizes[i] != 0)
|
||||
|| (present && (stage.sizes[i] == 0
|
||||
|| stage.sizes[i] > kMaxConfigFileBytes
|
||||
|| nvs.getBytesLength(kConfigFiles[i].nvs_key) != stage.sizes[i]))) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Refused: staged %s is incomplete", kConfigFiles[i].path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool verifyStagedConfigBlob(Preferences& nvs, const ConfigStage& stage,
|
||||
size_t i, uint8_t* buffer) {
|
||||
const size_t size = stage.sizes[i];
|
||||
return nvs.getBytes(kConfigFiles[i].nvs_key, buffer, size) == size
|
||||
&& (~crc32(buffer, size)) == stage.crcs[i];
|
||||
}
|
||||
|
||||
void clearConfigStageBlobs(Preferences& nvs) {
|
||||
nvs.remove(kMigrationConfigKey);
|
||||
for (const ConfigFile& file : kConfigFiles) nvs.remove(file.nvs_key);
|
||||
}
|
||||
|
||||
bool validateStagedConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return true;
|
||||
const bool pending = nvs.getBool(kMigrationConfigPendingKey, false);
|
||||
if (!pending) { nvs.end(); return true; }
|
||||
ConfigStage stage = {};
|
||||
bool valid = readConfigStage(nvs, stage);
|
||||
for (size_t i = 0; valid && i < kConfigFileCount; ++i) {
|
||||
if (!(stage.present & (1UL << i))) continue;
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
stage.sizes[i], MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
valid = buffer && verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
heap_caps_free(buffer);
|
||||
if (!valid) snprintf(status_text, sizeof(status_text),
|
||||
"Refused: staged %s failed verification", kConfigFiles[i].path);
|
||||
}
|
||||
nvs.end();
|
||||
return valid;
|
||||
}
|
||||
|
||||
bool stageLegacyConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
strcpy(status_text, "Could not open NVS configuration staging");
|
||||
return false;
|
||||
}
|
||||
if (nvs.getBool(kMigrationConfigPendingKey, false)) {
|
||||
nvs.end();
|
||||
return validateStagedConfig();
|
||||
}
|
||||
// A power failure before the commit marker can leave partial blobs. They
|
||||
// are never authoritative and would otherwise consume the small NVS area.
|
||||
if (!nvs.putBool(kMigrationConfigRestoredKey, false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not reset configuration handoff record");
|
||||
return false;
|
||||
}
|
||||
clearConfigStageBlobs(nvs);
|
||||
if (!SPIFFS.begin(false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not mount legacy configuration filesystem");
|
||||
return false;
|
||||
}
|
||||
ConfigStage stage = {};
|
||||
stage.magic = kConfigStageMagic;
|
||||
bool saved = true;
|
||||
for (size_t i = 0; saved && i < kConfigFileCount; ++i) {
|
||||
if (!SPIFFS.exists(kConfigFiles[i].path)) continue;
|
||||
File source = SPIFFS.open(kConfigFiles[i].path, "r");
|
||||
const size_t size = source ? source.size() : 0;
|
||||
if (size == 0 || size > kMaxConfigFileBytes) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Refused: %s cannot fit NVS staging", kConfigFiles[i].path);
|
||||
saved = false;
|
||||
} else {
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
saved = buffer && source.read(buffer, size) == size;
|
||||
if (saved) {
|
||||
stage.sizes[i] = size;
|
||||
stage.crcs[i] = ~crc32(buffer, size);
|
||||
saved = nvs.putBytes(kConfigFiles[i].nvs_key, buffer, size) == size
|
||||
&& verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
}
|
||||
heap_caps_free(buffer);
|
||||
if (!saved) snprintf(status_text, sizeof(status_text),
|
||||
"Could not stage and verify %s", kConfigFiles[i].path);
|
||||
}
|
||||
if (source) source.close();
|
||||
if (saved) stage.present |= (1UL << i);
|
||||
delay(1);
|
||||
}
|
||||
SPIFFS.end();
|
||||
if (saved) {
|
||||
saved = nvs.putBytes(kMigrationConfigKey, &stage, sizeof(stage))
|
||||
== sizeof(stage)
|
||||
&& nvs.putBool(kMigrationConfigPendingKey, true);
|
||||
if (!saved) strcpy(status_text, "Could not finish NVS configuration staging");
|
||||
}
|
||||
if (!saved) clearConfigStageBlobs(nvs);
|
||||
nvs.end();
|
||||
return saved && validateStagedConfig();
|
||||
}
|
||||
|
||||
bool restoreStagedConfig() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) return true;
|
||||
if (!nvs.getBool(kMigrationConfigPendingKey, false)) {
|
||||
// Cleanup may have been interrupted after the restored files were
|
||||
// committed. This does not require another SPIFFS write.
|
||||
clearConfigStageBlobs(nvs);
|
||||
nvs.end();
|
||||
return true;
|
||||
}
|
||||
ConfigStage stage = {};
|
||||
if (!readConfigStage(nvs, stage) || !validateStagedConfig()) {
|
||||
nvs.end();
|
||||
return false;
|
||||
}
|
||||
if (!SPIFFS.begin(false)) {
|
||||
nvs.end();
|
||||
strcpy(status_text, "Could not mount expanded configuration filesystem");
|
||||
return false;
|
||||
}
|
||||
bool restored = true;
|
||||
for (size_t i = 0; restored && i < kConfigFileCount; ++i) {
|
||||
if (!(stage.present & (1UL << i))) continue;
|
||||
const size_t size = stage.sizes[i];
|
||||
uint8_t* buffer = static_cast<uint8_t*>(heap_caps_malloc(
|
||||
size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
|
||||
restored = buffer && verifyStagedConfigBlob(nvs, stage, i, buffer);
|
||||
if (restored) {
|
||||
File destination = SPIFFS.open(kConfigFiles[i].path, "w");
|
||||
restored = destination && destination.write(buffer, size) == size;
|
||||
if (destination) { destination.flush(); destination.close(); }
|
||||
File verify = SPIFFS.open(kConfigFiles[i].path, "r");
|
||||
restored = restored && verify && verify.size() == size
|
||||
&& verify.read(buffer, size) == size
|
||||
&& (~crc32(buffer, size)) == stage.crcs[i];
|
||||
if (verify) verify.close();
|
||||
}
|
||||
heap_caps_free(buffer);
|
||||
if (!restored) snprintf(status_text, sizeof(status_text),
|
||||
"Could not restore and verify %s", kConfigFiles[i].path);
|
||||
delay(1);
|
||||
}
|
||||
SPIFFS.end();
|
||||
if (restored) {
|
||||
// Record the verified restore before clearing the pending marker. A lost
|
||||
// pending flag cannot otherwise prove that saved settings survived.
|
||||
restored = nvs.putBool(kMigrationConfigRestoredKey, true);
|
||||
if (restored) restored = nvs.remove(kMigrationConfigPendingKey);
|
||||
if (restored) {
|
||||
clearConfigStageBlobs(nvs);
|
||||
} else {
|
||||
strcpy(status_text, "Configuration restored; NVS commit needs retry");
|
||||
}
|
||||
}
|
||||
nvs.end();
|
||||
return restored;
|
||||
}
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA) || defined(MOTA_MIGRATION_TARGET_ID)
|
||||
uint32_t readLe32(const uint8_t* bytes) {
|
||||
return static_cast<uint32_t>(bytes[0])
|
||||
| (static_cast<uint32_t>(bytes[1]) << 8)
|
||||
| (static_cast<uint32_t>(bytes[2]) << 16)
|
||||
| (static_cast<uint32_t>(bytes[3]) << 24);
|
||||
}
|
||||
|
||||
struct OtaImageIdentity {
|
||||
uint32_t target_id = 0;
|
||||
uint32_t body_bytes = 0;
|
||||
uint8_t hardware_id[32] = {};
|
||||
};
|
||||
|
||||
// The package appends an exact-target EndF to this bridge. Read and verify
|
||||
// it from the running image, then require the opposite-slot firmware to have
|
||||
// that same target and hardware identity. A generic chip-family bridge is
|
||||
// therefore reusable without accepting a cross-board LoRa handoff.
|
||||
bool readVerifiedOtaIdentity(const esp_partition_t& source,
|
||||
OtaImageIdentity& identity) {
|
||||
uint8_t header = 0;
|
||||
if (esp_partition_read(&source, 0, &header, 1) != ESP_OK || header != 0xE9) {
|
||||
strcpy(status_text, "Refused: LoRa application image is invalid");
|
||||
return false;
|
||||
}
|
||||
uint8_t trailer[kEndfBytes];
|
||||
for (uint32_t base = 0; base < source.size;
|
||||
base += sizeof(copy_buffer)) {
|
||||
const uint32_t count = source.size - base < sizeof(copy_buffer)
|
||||
? source.size - base : sizeof(copy_buffer);
|
||||
if (esp_partition_read(&source, base, copy_buffer, count) != ESP_OK) break;
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
if (copy_buffer[index] != 'E') continue;
|
||||
const uint32_t marker = base + index;
|
||||
if (marker + kEndfBytes > source.size
|
||||
|| esp_partition_read(&source, marker, trailer, sizeof(trailer)) != ESP_OK
|
||||
|| memcmp(trailer, "EndF", 4) != 0
|
||||
|| readLe32(trailer + 4) != marker) {
|
||||
continue;
|
||||
}
|
||||
mbedtls_sha256_context hash;
|
||||
mbedtls_sha256_init(&hash);
|
||||
bool valid = mbedtls_sha256_starts_ret(&hash, 0) == 0;
|
||||
for (uint32_t offset = 0; valid && offset < marker;) {
|
||||
const uint32_t length = marker - offset < sizeof(copy_buffer)
|
||||
? marker - offset : sizeof(copy_buffer);
|
||||
valid = esp_partition_read(&source, offset, copy_buffer, length) == ESP_OK
|
||||
&& mbedtls_sha256_update_ret(&hash, copy_buffer, length) == 0;
|
||||
offset += length;
|
||||
}
|
||||
uint8_t digest[32];
|
||||
valid = valid && mbedtls_sha256_finish_ret(&hash, digest) == 0
|
||||
&& memcmp(digest, trailer + 8, 8) == 0;
|
||||
mbedtls_sha256_free(&hash);
|
||||
if (valid) {
|
||||
identity.target_id = readLe32(trailer + 20);
|
||||
identity.body_bytes = marker;
|
||||
memcpy(identity.hardware_id, trailer + 24, sizeof(identity.hardware_id));
|
||||
if (identity.target_id != 0) return true;
|
||||
strcpy(status_text, "Refused: LoRa firmware EndF has no target ID");
|
||||
return false;
|
||||
}
|
||||
strcpy(status_text, "Refused: LoRa firmware EndF hash is invalid");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
strcpy(status_text, "Refused: LoRa firmware has no valid EndF");
|
||||
return false;
|
||||
}
|
||||
|
||||
enum class BridgeMarkerResult { Missing, Present, ReadFailure };
|
||||
|
||||
BridgeMarkerResult containsBridgeMarker(const esp_partition_t& image,
|
||||
uint32_t body_bytes) {
|
||||
constexpr size_t marker_bytes = sizeof(kBridgeImageMarker) - 1;
|
||||
constexpr size_t legacy_marker_bytes = sizeof(kLegacyBridgeImageMarker) - 1;
|
||||
size_t matched = 0;
|
||||
size_t matched_legacy = 0;
|
||||
for (uint32_t offset = 0; offset < body_bytes;) {
|
||||
const uint32_t count = body_bytes - offset < sizeof(copy_buffer)
|
||||
? body_bytes - offset : sizeof(copy_buffer);
|
||||
if (esp_partition_read(&image, offset, copy_buffer, count) != ESP_OK) {
|
||||
strcpy(status_text, "Refused: could not inspect other LoRa image");
|
||||
return BridgeMarkerResult::ReadFailure;
|
||||
}
|
||||
for (uint32_t index = 0; index < count; ++index) {
|
||||
matched = copy_buffer[index] == kBridgeImageMarker[matched]
|
||||
? matched + 1
|
||||
: (copy_buffer[index] == kBridgeImageMarker[0] ? 1 : 0);
|
||||
matched_legacy = copy_buffer[index] == kLegacyBridgeImageMarker[matched_legacy]
|
||||
? matched_legacy + 1
|
||||
: (copy_buffer[index] == kLegacyBridgeImageMarker[0] ? 1 : 0);
|
||||
if (matched == marker_bytes || matched_legacy == legacy_marker_bytes)
|
||||
return BridgeMarkerResult::Present;
|
||||
}
|
||||
offset += count;
|
||||
delay(1);
|
||||
}
|
||||
return BridgeMarkerResult::Missing;
|
||||
}
|
||||
|
||||
bool validOtherLoRaFirmware(const esp_partition_t& bridge,
|
||||
const esp_partition_t& receiver) {
|
||||
OtaImageIdentity bridge_identity;
|
||||
OtaImageIdentity receiver_identity;
|
||||
if (!readVerifiedOtaIdentity(bridge, bridge_identity)
|
||||
|| !readVerifiedOtaIdentity(receiver, receiver_identity)) return false;
|
||||
if (bridge_identity.target_id != receiver_identity.target_id
|
||||
|| memcmp(bridge_identity.hardware_id, receiver_identity.hardware_id,
|
||||
sizeof(bridge_identity.hardware_id)) != 0) {
|
||||
strcpy(status_text, "Refused: other LoRa firmware target does not match bridge");
|
||||
return false;
|
||||
}
|
||||
const BridgeMarkerResult marker = containsBridgeMarker(
|
||||
receiver, receiver_identity.body_bytes);
|
||||
if (marker != BridgeMarkerResult::Missing) {
|
||||
if (marker == BridgeMarkerResult::Present)
|
||||
strcpy(status_text, "Refused: other LoRa slot contains a migration bridge");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
bool stageExpanderHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
strcpy(status_text, "Could not open Partition Expander handoff record");
|
||||
return false;
|
||||
}
|
||||
const bool saved = nvs.putUChar(kExpanderHandoffKey, 0xA5) == 1;
|
||||
nvs.end();
|
||||
if (!saved) strcpy(status_text, "Could not save Partition Expander handoff record");
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool hasExpanderHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return false;
|
||||
const bool pending = nvs.getUChar(kExpanderHandoffKey, 0) == 0xA5;
|
||||
nvs.end();
|
||||
return pending;
|
||||
}
|
||||
|
||||
bool hasVerifiedConfigHandoff() {
|
||||
Preferences nvs;
|
||||
if (!nvs.begin(kMigrationNvsNamespace, true)) return false;
|
||||
const bool restored = nvs.getBool(kMigrationConfigRestoredKey, false);
|
||||
nvs.end();
|
||||
return restored;
|
||||
}
|
||||
|
||||
bool returnToVerifiedFull() {
|
||||
const esp_partition_t* running = esp_ota_get_running_partition();
|
||||
const esp_partition_t* other = esp_ota_get_next_update_partition(nullptr);
|
||||
if (!running || !other || running->address == other->address) return false;
|
||||
OtaImageIdentity bridge;
|
||||
OtaImageIdentity full;
|
||||
if (!readVerifiedOtaIdentity(*running, bridge)
|
||||
|| !readVerifiedOtaIdentity(*other, full)
|
||||
|| full.target_id != (uint32_t)MOTA_MIGRATION_TARGET_ID
|
||||
|| memcmp(full.hardware_id, bridge.hardware_id,
|
||||
sizeof(full.hardware_id)) != 0
|
||||
|| containsBridgeMarker(*other, full.body_bytes)
|
||||
!= BridgeMarkerResult::Missing) return false;
|
||||
const esp_err_t selected = esp_ota_set_boot_partition(other);
|
||||
if (selected != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text),
|
||||
"Could not return to verified Full image: %s", errName(selected));
|
||||
return false;
|
||||
}
|
||||
strcpy(status_text, "Already expanded; returning to verified Full image");
|
||||
reboot_at = millis() + 1000;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
|
||||
bool stageResumeSlot(uint8_t slot) {
|
||||
Preferences migration_nvs;
|
||||
const bool opened = migration_nvs.begin(kMigrationNvsNamespace, false);
|
||||
const bool saved = opened && migration_nvs.putUChar(kMigrationResumeSlotKey, slot) == 1;
|
||||
if (opened) migration_nvs.end();
|
||||
if (!saved) strcpy(status_text, "Could not stage old LoRa application slot");
|
||||
return saved;
|
||||
}
|
||||
|
||||
bool resumeLegacyOtaReceiver(const migration::PartitionGeometry& geometry) {
|
||||
uint8_t slot = 0xFF;
|
||||
nvs_handle_t nvs_handle;
|
||||
const esp_err_t opened = nvs_open(kMigrationNvsNamespace, NVS_READONLY, &nvs_handle);
|
||||
if (opened == ESP_OK) {
|
||||
const esp_err_t read = nvs_get_u8(nvs_handle, kMigrationResumeSlotKey, &slot);
|
||||
nvs_close(nvs_handle);
|
||||
if (read != ESP_OK && read != ESP_ERR_NVS_NOT_FOUND) {
|
||||
snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS read failed: %s",
|
||||
errName(read));
|
||||
return false;
|
||||
}
|
||||
} else if (opened != ESP_ERR_NVS_NOT_FOUND) {
|
||||
snprintf(status_text, sizeof(status_text), "Refused: LoRa handoff NVS open failed: %s",
|
||||
errName(opened));
|
||||
return false;
|
||||
}
|
||||
const esp_partition_t* running = esp_ota_get_running_partition();
|
||||
const migration::LoRaHandoffPlan handoff = migration::planLoRaHandoff(
|
||||
geometry, running ? running->address : 0, slot);
|
||||
if (!handoff.valid) {
|
||||
strcpy(status_text, "Refused: LoRa handoff record or running slot is unsafe");
|
||||
return false;
|
||||
}
|
||||
const esp_partition_t* other_app = esp_partition_find_first(
|
||||
ESP_PARTITION_TYPE_APP,
|
||||
handoff.other_slot == 0 ? ESP_PARTITION_SUBTYPE_APP_OTA_0
|
||||
: ESP_PARTITION_SUBTYPE_APP_OTA_1,
|
||||
nullptr);
|
||||
if (!other_app || !running || other_app->address == running->address
|
||||
|| !validOtherLoRaFirmware(*running, *other_app)) {
|
||||
if (!other_app || !running || other_app->address == running->address)
|
||||
strcpy(status_text, "Refused: other LoRa application slot is unavailable");
|
||||
return false;
|
||||
}
|
||||
const esp_err_t selected = esp_ota_set_boot_partition(other_app);
|
||||
if (selected != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text), "Could not boot verified LoRa app: %s",
|
||||
errName(selected));
|
||||
return false;
|
||||
}
|
||||
if (handoff.has_record) {
|
||||
Preferences migration_nvs;
|
||||
if (migration_nvs.begin(kMigrationNvsNamespace, false)) {
|
||||
migration_nvs.remove(kMigrationResumeSlotKey);
|
||||
migration_nvs.end();
|
||||
}
|
||||
}
|
||||
strcpy(status_text, "Expanded layout ready; returning to verified LoRa firmware");
|
||||
reboot_at = millis() + 1000;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#endif // MESHCORE_MIGRATION_RESUME_OTA || MOTA_MIGRATION_TARGET_ID
|
||||
|
||||
bool publishExpandedPartitionTable(const migration::TargetPlan& plan) {
|
||||
// Preserve ESP-IDF's normal OS flash hooks. In particular, their start/end
|
||||
// hooks suspend the other core and safely disable/re-enable caches. Do not
|
||||
@@ -354,11 +854,11 @@ bool publishExpandedPartitionTable(const migration::TargetPlan& plan) {
|
||||
if (result == ESP_OK) {
|
||||
result = esp_flash_write(chip, partition_table_bytes,
|
||||
migration::kPartitionTableAddress,
|
||||
sizeof(partition_table_bytes));
|
||||
plan.partition_table_prefix_bytes);
|
||||
if (result == ESP_OK) {
|
||||
result = esp_flash_read(chip, partition_table_verified,
|
||||
migration::kPartitionTableAddress,
|
||||
sizeof(partition_table_verified));
|
||||
plan.partition_table_prefix_bytes);
|
||||
ok = result == ESP_OK && memcmp(partition_table_verified,
|
||||
plan.partition_table_prefix, plan.partition_table_prefix_bytes) == 0;
|
||||
if (!ok) strcpy(status_text, "Partition table verification failed");
|
||||
@@ -402,16 +902,59 @@ void runMigration() {
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
const migration::LoRaResumePlan resume = migration::planLoRaResume(
|
||||
geometry, plan->layout, running->address);
|
||||
if (!resume.valid) {
|
||||
strcpy(status_text, "Refused: LoRa bridge/receiver slot layout is unsafe");
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
const esp_partition_t* old_receiver = resume.resume_slot == 0
|
||||
? refs.app0 : refs.app1;
|
||||
if (!validOtherLoRaFirmware(*running, *old_receiver)) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
Serial.println("Migration: staging private key; do not interrupt power");
|
||||
if (!stageLegacyIdentity()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
Serial.println("Migration: private key safely staged in NVS");
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
if (!stageLegacyConfig()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
Serial.println("Migration: ACL, radio profiles and node configuration safely staged in NVS");
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
if (!stageExpanderHandoff()) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Always make target app0 contain this migration image. If it was uploaded
|
||||
// into a different legacy slot, this preserves a Wi-Fi endpoint after the
|
||||
// table change. Never erase a running source range while copying it.
|
||||
// The Wi-Fi bridge always runs from target app0. The LoRa bridge instead
|
||||
// keeps the old application in the other expanded slot, restores identity,
|
||||
// then boots that old application to receive the final image by LoRa.
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
if (resume.copy_app1) {
|
||||
Serial.println("Migration: preserving legacy app1 in expanded app1");
|
||||
if (!copyAndVerify(*refs.app1, plan->layout.app1_address,
|
||||
refs.app1->size, "future-app1")
|
||||
|| !stageResumeSlot(resume.resume_slot)) {
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Serial.println("Migration: old receiver remains in app0; Full firmware will restore the key");
|
||||
}
|
||||
#else
|
||||
// Never erase a running source range while copying it.
|
||||
if (running->address != plan->layout.app0_address) {
|
||||
if (running->size > plan->layout.app0_size) {
|
||||
strcpy(status_text, "Refused: migration slot is larger than future app0");
|
||||
@@ -432,29 +975,33 @@ void runMigration() {
|
||||
}
|
||||
Serial.println("Migration: bridge image copied to app0");
|
||||
}
|
||||
#endif
|
||||
|
||||
// The OTA-select data records a slot identity. Select a descriptor for the
|
||||
// target app0 before replacing the table so the next boot runs this bridge
|
||||
// at the target table's app0 address, never the blank new app1.
|
||||
const esp_partition_t target_app0 = rawApp0Partition(plan->layout.app0_address,
|
||||
plan->layout.app0_size,
|
||||
"app0");
|
||||
const esp_err_t select_result = esp_ota_set_boot_partition(&target_app0);
|
||||
// The OTA-select data records a slot identity. On 8/16 MiB LoRa and Wi-Fi
|
||||
// paths it selects the bridge; the 4 MiB LoRa path selects old receiver A
|
||||
// because old bridge B cannot survive in either expanded slot.
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
const bool boot_in_app0 = resume.copy_app1
|
||||
? resume.bridge_slot == 0 : resume.resume_slot == 0;
|
||||
#else
|
||||
const bool boot_in_app0 = true;
|
||||
#endif
|
||||
const esp_err_t select_result = esp_ota_set_boot_partition(
|
||||
boot_in_app0 ? refs.app0 : refs.app1);
|
||||
if (select_result != ESP_OK) {
|
||||
snprintf(status_text, sizeof(status_text), "Could not select app0: %s",
|
||||
snprintf(status_text, sizeof(status_text), "Could not select migration boot slot: %s",
|
||||
errName(select_result));
|
||||
Serial.println(status_text);
|
||||
return;
|
||||
}
|
||||
Serial.println("Migration: app0 selected for restart");
|
||||
Serial.println("Migration: boot slot selected for restart");
|
||||
|
||||
Serial.println("Migration: publishing expanded partition table");
|
||||
// This bridge is built without native USB CDC, so a connected USB host cannot
|
||||
// post a flash-backed event while the partition-sector operation disables the
|
||||
// flash cache. Stop UART0 as well: the S3's serial event path is otherwise
|
||||
// still able to interrupt the raw flash operation. The bridge itself is
|
||||
// deliberately Wi-Fi-only; the final normal repeater build restores its
|
||||
// standard USB behavior.
|
||||
// still able to interrupt the raw flash operation. The bridge has no native
|
||||
// USB CDC; the final normal repeater build restores its standard USB behavior.
|
||||
Serial.flush();
|
||||
Serial.end();
|
||||
const bool table_published = publishExpandedPartitionTable(*plan);
|
||||
@@ -469,7 +1016,13 @@ void runMigration() {
|
||||
if (kMigrationRestartDelayMs == 0) {
|
||||
strcpy(status_text, "Partition table verified; waiting for test reboot");
|
||||
} else {
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
strcpy(status_text, resume.copy_app1
|
||||
? "Migration complete; restarting LoRa bridge"
|
||||
: "Migration complete; resuming old LoRa receiver");
|
||||
#else
|
||||
strcpy(status_text, "Migration complete; restarting Wi-Fi uploader");
|
||||
#endif
|
||||
reboot_at = millis() + kMigrationRestartDelayMs;
|
||||
}
|
||||
Serial.println(status_text);
|
||||
@@ -480,13 +1033,29 @@ void sendHome(AsyncWebServerRequest* request) {
|
||||
String page;
|
||||
page.reserve(1000);
|
||||
page += "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>";
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
page += "<!--";
|
||||
page += kBridgeImageMarker;
|
||||
page += "-->";
|
||||
#endif
|
||||
page += "<h2>MeshCore Wi-Fi partition migration</h2><p>";
|
||||
page += mode;
|
||||
page += "</p>";
|
||||
if (strstr(status_text, "ready") != nullptr) {
|
||||
page += "<p>The expanded partition layout is active and the private device "
|
||||
"identity was staged and restored before this uploader was exposed.</p>"
|
||||
"<p><a href='/update'>Upload the full application image for this board.</a>.</p>";
|
||||
#if defined(MOTA_MIGRATION_TARGET_ID)
|
||||
if (expanded_layout_ready && identity_ready) {
|
||||
page += "<p>";
|
||||
page += partitionExpanderStatus();
|
||||
page += "</p>";
|
||||
}
|
||||
#endif
|
||||
if (expanded_layout_ready && identity_ready) {
|
||||
page += "<p>The expanded partition layout is active. If the bridge could "
|
||||
"not return to a verified LoRa image, Wi-Fi recovery is available "
|
||||
"here: <a href='/update'>upload the correct Full application</a>. "
|
||||
"Check the status above before proceeding.</p>";
|
||||
} else if (expanded_layout_ready) {
|
||||
page += "<p>Private identity recovery did not complete. Firmware upload is "
|
||||
"disabled; restart the bridge and inspect the status before retrying.</p>";
|
||||
} else if (migration_complete) {
|
||||
page += "<p>Partition-table bytes were read back successfully. The test harness "
|
||||
"is waiting for an explicit reboot.</p><p><a href='/reboot'>Restart now</a></p>";
|
||||
@@ -536,7 +1105,7 @@ void startServer() {
|
||||
request->send(200, "text/plain", "Restarting migration bridge");
|
||||
reboot_requested = true;
|
||||
});
|
||||
AsyncElegantOTA.begin(&server);
|
||||
if (!expanded_layout_ready || identity_ready) AsyncElegantOTA.begin(&server);
|
||||
server.begin();
|
||||
}
|
||||
|
||||
@@ -551,8 +1120,18 @@ void setup() {
|
||||
const uint32_t flash_bytes = ESP.getFlashChipSize();
|
||||
if (findPartitions(refs, geometry)
|
||||
&& migration::isTargetLayout(flash_bytes, geometry)) {
|
||||
if (restoreStagedIdentity()) {
|
||||
expanded_layout_ready = true;
|
||||
if (validateStagedConfig() && restoreStagedIdentity()
|
||||
#if !defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
&& restoreStagedConfig()
|
||||
&& verifyExpandedIdentityFile()
|
||||
#endif
|
||||
) {
|
||||
identity_ready = true;
|
||||
strcpy(status_text, "Expanded layout ready");
|
||||
#if defined(MESHCORE_MIGRATION_RESUME_OTA)
|
||||
resumeLegacyOtaReceiver(geometry);
|
||||
#endif
|
||||
}
|
||||
} else if (findPartitions(refs, geometry)
|
||||
&& migration::canMigrateGeneric(flash_bytes, geometry)) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// A self-hosted partition bridge. Reuse the tested Wi-Fi migration engine so
|
||||
// both legacy OTA placements preserve the running image and node configuration.
|
||||
// After its restart into expanded app0, this role also receives the final
|
||||
// Full application over LoRa; the old application need not remain bootable.
|
||||
#define setup partitionMigrationSetup
|
||||
#define loop partitionMigrationLoop
|
||||
#include "../esp32_partition_migrator/main.cpp"
|
||||
#undef setup
|
||||
#undef loop
|
||||
|
||||
#include <Mesh.h>
|
||||
#include <target.h>
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/ESP32TrueRandom.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/SimpleMeshTables.h>
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#include <helpers/ota/OtaApply.h>
|
||||
|
||||
#ifndef MOTA_MIGRATION_TARGET_ID
|
||||
#error "Partition Expander must pin the exact successor Full target ID"
|
||||
#endif
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
#define LORA_FREQ 915.0
|
||||
#endif
|
||||
#ifndef LORA_BW
|
||||
#define LORA_BW 250.0
|
||||
#endif
|
||||
#ifndef LORA_SF
|
||||
#define LORA_SF 10
|
||||
#endif
|
||||
#ifndef LORA_CR
|
||||
#define LORA_CR 5
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
class ExpanderMesh final : public mesh::Mesh {
|
||||
public:
|
||||
ExpanderMesh(mesh::Radio& radio, mesh::MillisecondClock& millis_clock,
|
||||
mesh::RNG& rng, mesh::RTCClock& rtc,
|
||||
mesh::PacketManager& packets, mesh::MeshTables& tables)
|
||||
: mesh::Mesh(radio, millis_clock, rng, rtc, packets, tables) {}
|
||||
|
||||
bool isTempRadioActive() const override { return true; }
|
||||
};
|
||||
|
||||
ArduinoMillis uptime_clock;
|
||||
StdRNG fast_rng;
|
||||
StaticPoolPacketManager packets(32);
|
||||
SimpleMeshTables tables;
|
||||
ExpanderMesh the_mesh(radio_driver, uptime_clock, fast_rng, rtc_clock, packets, tables);
|
||||
bool lora_ready = false;
|
||||
bool install_attempted = false;
|
||||
mesh::RadioProfileParams active_profile;
|
||||
|
||||
// /com_prefs stores these fields at fixed offsets in both stock 1.17.1 and
|
||||
// keymindCascade. Keep the migration receiver on the user's saved radio1
|
||||
// channel so an existing LoRa seeder can reach it without a USB-only retune.
|
||||
bool loadPrimaryRadio(mesh::RadioProfileParams& profile) {
|
||||
bool ok = false;
|
||||
if (SPIFFS.exists("/com_prefs") || SPIFFS.exists("/node_prefs")) {
|
||||
File prefs = SPIFFS.open(SPIFFS.exists("/com_prefs")
|
||||
? "/com_prefs" : "/node_prefs", "r");
|
||||
if (prefs && prefs.size() >= 290) {
|
||||
ok = prefs.seek(72) && prefs.readBytes(reinterpret_cast<char*>(&profile.freq), 4) == 4;
|
||||
ok = ok && prefs.seek(112) && prefs.readBytes(reinterpret_cast<char*>(&profile.sf), 1) == 1;
|
||||
ok = ok && prefs.readBytes(reinterpret_cast<char*>(&profile.cr), 1) == 1;
|
||||
ok = ok && prefs.seek(116) && prefs.readBytes(reinterpret_cast<char*>(&profile.bw), 4) == 4;
|
||||
}
|
||||
if (prefs) prefs.close();
|
||||
} else if (SPIFFS.exists("/prefs.json")) {
|
||||
File prefs = SPIFFS.open("/prefs.json", "r");
|
||||
NodePrefs legacy;
|
||||
ok = prefs && legacy.loadSerial(prefs);
|
||||
if (prefs) prefs.close();
|
||||
if (ok) {
|
||||
profile.freq = legacy.freq;
|
||||
profile.bw = legacy.bw;
|
||||
profile.sf = legacy.sf;
|
||||
profile.cr = legacy.cr;
|
||||
}
|
||||
}
|
||||
return ok && profile.freq >= 100.0f && profile.freq <= 2500.0f
|
||||
&& profile.bw > 0.0f && profile.bw <= 500.0f
|
||||
&& profile.sf >= 5 && profile.sf <= 12
|
||||
&& profile.cr >= 5 && profile.cr <= 8;
|
||||
}
|
||||
|
||||
void startLoRaReceiver() {
|
||||
board.begin();
|
||||
if (!radio_init()) {
|
||||
Serial.println("Partition Expander: radio init failed; Wi-Fi recovery remains available");
|
||||
return;
|
||||
}
|
||||
fast_rng.begin(radio_driver.getRngSeed());
|
||||
|
||||
if (!SPIFFS.begin(false)) {
|
||||
Serial.println("Partition Expander: identity filesystem unavailable");
|
||||
return;
|
||||
}
|
||||
IdentityStore identity(SPIFFS, "/identity");
|
||||
if (identity.loadResult("_main", the_mesh.self_id) != IdentityLoadResult::Loaded) {
|
||||
Serial.println("Partition Expander: private identity unavailable; LoRa disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
mesh::RadioProfileParams profile;
|
||||
profile.freq = LORA_FREQ;
|
||||
profile.bw = LORA_BW;
|
||||
profile.sf = LORA_SF;
|
||||
profile.cr = LORA_CR;
|
||||
// HIL recipe: save 909.5 MHz / 500 kHz / SF5 / CR5 as radio1 on
|
||||
// both bench nodes before migration. Production recovery must still use
|
||||
// the node's own saved radio1, never a hard-coded lab frequency.
|
||||
if (!loadPrimaryRadio(profile)) {
|
||||
Serial.println("Partition Expander: saved radio1 unavailable; using build preset");
|
||||
}
|
||||
if (radio_driver.trySetPrimaryParams(profile, true)
|
||||
!= mesh::RadioParamApplyResult::APPLIED) {
|
||||
Serial.println("Partition Expander: OTA radio profile refused");
|
||||
return;
|
||||
}
|
||||
active_profile = profile;
|
||||
|
||||
the_mesh.begin();
|
||||
auto& ota = mesh::ota::ota_ctx();
|
||||
ota.manager.set_auto_migration_target((uint32_t)MOTA_MIGRATION_TARGET_ID);
|
||||
ota.manager.set_auto_version_floor(0, false);
|
||||
ota.manager.set_autofetch(mesh::ota::OtaManager::AUTOFETCH_ANY);
|
||||
lora_ready = true;
|
||||
Serial.println("Partition Expander: waiting for exact Full target over LoRa");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
String partitionExpanderStatus() {
|
||||
if (reboot_at) return "Returning to the verified Full image";
|
||||
if (!lora_ready) return "LoRa receiver unavailable; use Wi-Fi recovery";
|
||||
const auto& manager = mesh::ota::ota_ctx().manager;
|
||||
char detail[160];
|
||||
snprintf(detail, sizeof(detail),
|
||||
"LoRa receiver %.3f MHz / %.1f kHz / SF%u / CR%u; "
|
||||
"OTA state %u; blocks %lu/%lu; request window %u/%u",
|
||||
active_profile.freq, active_profile.bw,
|
||||
(unsigned)active_profile.sf, (unsigned)active_profile.cr,
|
||||
(unsigned)manager.fetchState(),
|
||||
(unsigned long)manager.blocksHave(),
|
||||
(unsigned long)manager.blocksTotal(),
|
||||
(unsigned)manager.fetchPipelineWidth(),
|
||||
(unsigned)manager.fetchPipelineCapacity());
|
||||
return detail;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
partitionMigrationSetup();
|
||||
// Never start a LoRa fetch against the 1.25 MiB layout or before the key is
|
||||
// restored. The migration engine restarts after verifying the new table.
|
||||
if (!expanded_layout_ready || !identity_ready) return;
|
||||
// On an already-expanded board the temporary role may have landed next to
|
||||
// a valid Full image. Return to it instead of needlessly replacing it.
|
||||
if (returnToVerifiedFull()) return;
|
||||
if (!hasExpanderHandoff()) {
|
||||
strcpy(status_text,
|
||||
"Already expanded; no migration handoff. Wi-Fi recovery available");
|
||||
return;
|
||||
}
|
||||
if (!hasVerifiedConfigHandoff()) {
|
||||
strcpy(status_text,
|
||||
"Refused: saved configuration was not verified; Wi-Fi recovery available");
|
||||
return;
|
||||
}
|
||||
startLoRaReceiver();
|
||||
if (lora_ready) strcpy(status_text, "Expanded layout ready; LoRa receiver active");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
partitionMigrationLoop();
|
||||
if (!lora_ready) return;
|
||||
the_mesh.loop();
|
||||
auto& ota = mesh::ota::ota_ctx();
|
||||
if (!install_attempted
|
||||
&& ota.manager.fetchState() == mesh::ota::OtaManager::COMPLETE) {
|
||||
install_attempted = true;
|
||||
uint8_t manifest_bytes[mesh::ota::MOTA_MFL];
|
||||
mesh::ota::MotaManifest manifest;
|
||||
if (!ota.fetch_store.read(8, manifest_bytes, sizeof(manifest_bytes))
|
||||
|| !mesh::ota::mota_parse_manifest(manifest_bytes,
|
||||
sizeof(manifest_bytes), manifest)
|
||||
|| !manifest.is_full()
|
||||
|| manifest.target_id != (uint32_t)MOTA_MIGRATION_TARGET_ID) {
|
||||
Serial.println("Partition Expander: staged image is not the pinned Full target");
|
||||
return;
|
||||
}
|
||||
char message[100] = {};
|
||||
const bool installed = ota.apply_fetched(message);
|
||||
Serial.println(message);
|
||||
if (installed) {
|
||||
// This role has no CLI reply queue or role-specific reboot scheduler.
|
||||
// apply_fetched() only arms the ESP32 OTA slot; it does not restart.
|
||||
Serial.flush();
|
||||
delay(200);
|
||||
mesh::ota::ota_reboot_to_apply();
|
||||
} else {
|
||||
Serial.println("Partition Expander: Full install refused; restart to retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -22,7 +22,7 @@ extra_css:
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
- _stylesheets/management_decoder.css
|
||||
- _stylesheets/filter_tool.css
|
||||
- _stylesheets/preset_test.css?v=20260922-1
|
||||
- _stylesheets/preset_test.css?v=20260923-2
|
||||
|
||||
extra_javascript:
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
@@ -30,4 +30,4 @@ extra_javascript:
|
||||
- _javascript/telemetry_decoder.js
|
||||
- _javascript/management_decoder.js
|
||||
- _javascript/filter_tool.js
|
||||
- _javascript/preset_test.js?v=20260922-1
|
||||
- _javascript/preset_test.js?v=20260923-2
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build verified in-place Wi-Fi and LoRa ESP32 partition-migration ZIPs.
|
||||
|
||||
Run in Linux/WSL with PlatformIO installed. PlatformIO steps are deliberately
|
||||
serial: build.sh cleans the shared .pio/build tree between Full targets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from package_esp32_partition_migration import BOARDS
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def git_output(*args: str) -> str:
|
||||
# This Windows checkout uses core.autocrlf=input. WSL often lacks that
|
||||
# global setting and would misreport unchanged CRLF files as dirty.
|
||||
return subprocess.check_output(
|
||||
["git", "-c", "core.autocrlf=input", *args], cwd=ROOT, text=True
|
||||
).strip()
|
||||
|
||||
|
||||
def build_steps(boards: list[str], version: str, radio_preset: str,
|
||||
profile: str, jobs: int) -> list[tuple[list[str], bool]]:
|
||||
steps: list[tuple[list[str], bool]] = []
|
||||
for name in boards:
|
||||
steps.append(([
|
||||
"bash", "build.sh", "build-firmware", BOARDS[name]["target"],
|
||||
"--full-exact", "--firmware-version", version,
|
||||
"--radio-preset", radio_preset, "--profile", profile,
|
||||
"--require-ota", "--resume",
|
||||
], True))
|
||||
# Full builds can clear .pio/build; every bridge must be built afterwards.
|
||||
built_bridges: set[str] = set()
|
||||
for name in boards:
|
||||
for key in ("wifi_bridge", "lora_bridge", "expander_bridge"):
|
||||
bridge = BOARDS[name].get(key)
|
||||
if bridge and bridge not in built_bridges:
|
||||
steps.append((["pio", "run", "-e", bridge,
|
||||
"-j", str(jobs)], False))
|
||||
built_bridges.add(bridge)
|
||||
return steps
|
||||
|
||||
|
||||
def verify_archive(path: Path, board: str, version: str, source: str) -> None:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
damaged = archive.testzip()
|
||||
if damaged:
|
||||
raise ValueError(f"{path}: damaged ZIP entry {damaged}")
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if (manifest["board"] != board or manifest["firmware_version"] != version
|
||||
or manifest["source_commit"] != source):
|
||||
raise ValueError(f"{path}: package identity does not match recipe")
|
||||
for filename, expected in manifest["files"].items():
|
||||
data = archive.read(filename)
|
||||
if (len(data) != expected["bytes"]
|
||||
or hashlib.sha256(data).hexdigest() != expected["sha256"]):
|
||||
raise ValueError(f"{path}: invalid {filename}")
|
||||
|
||||
|
||||
def bundle_release(output_root: Path, package_dir: Path, boards: list[str],
|
||||
version: str, source: str, radio_preset: str,
|
||||
profile: str) -> Path:
|
||||
"""Atomically publish the complete, exact-source migration set."""
|
||||
packages = []
|
||||
for board in boards:
|
||||
matches = list(package_dir.glob(f"{board}-{version}-{source}-migration.zip"))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"release is missing {board}")
|
||||
verify_archive(matches[0], board, version, source)
|
||||
data = matches[0].read_bytes()
|
||||
packages.append((matches[0].name, data))
|
||||
manifest = {
|
||||
"format": "meshcore-esp32-partition-migration-release-v1",
|
||||
"scope": "configured-legacy-layout-board-role-recipes",
|
||||
"historical_firmware_binaries_included": False,
|
||||
"source_commit": source,
|
||||
"firmware_version": version,
|
||||
"radio_preset": radio_preset,
|
||||
"profile": profile,
|
||||
"boards": boards,
|
||||
"board_roles": {name: BOARDS[name]["role"] for name in boards},
|
||||
"roles": sorted({BOARDS[name]["role"] for name in boards}),
|
||||
"packages": {name: {"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in packages},
|
||||
}
|
||||
archive = output_root / f"esp32-partition-migration-{version}-{source}-release.zip"
|
||||
with tempfile.NamedTemporaryFile(prefix="migration-release-", suffix=".zip",
|
||||
dir=output_root, delete=False) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary_path, "w", compression=zipfile.ZIP_STORED) as result:
|
||||
result.writestr("release-manifest.json", json.dumps(manifest, indent=2) + "\n")
|
||||
for name, data in packages:
|
||||
result.writestr(name, data)
|
||||
with zipfile.ZipFile(temporary_path) as result:
|
||||
if result.testzip() or set(result.namelist()) != {"release-manifest.json"} | {
|
||||
name for name, _ in packages}:
|
||||
raise ValueError("release ZIP failed integrity check")
|
||||
if archive.exists():
|
||||
if hashlib.sha256(archive.read_bytes()).digest() != \
|
||||
hashlib.sha256(temporary_path.read_bytes()).digest():
|
||||
raise ValueError(f"existing release differs: {archive}")
|
||||
else:
|
||||
temporary_path.replace(archive)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", help="firmware release version")
|
||||
parser.add_argument("--radio-preset",
|
||||
help="radio preset passed to build.sh")
|
||||
parser.add_argument("--profile", choices=("default", "cascade"),
|
||||
default="default", help="embedded runtime profile")
|
||||
parser.add_argument("--board", action="append", choices=tuple(BOARDS),
|
||||
help="repeat to select board/role recipes (default: all configured recipes)")
|
||||
parser.add_argument("--role", choices=("repeater", "room-server", "sensor"),
|
||||
help="build every configured board for this role")
|
||||
parser.add_argument("--jobs", type=int, default=4,
|
||||
help="workers within each serial PlatformIO build")
|
||||
parser.add_argument("--output-root", type=Path,
|
||||
help="release folder (default: .releases/esp32-expanded-<commit>)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="show the build order without writing files")
|
||||
parser.add_argument("--list-boards", action="store_true",
|
||||
help="print configured board/role keys and exit")
|
||||
args = parser.parse_args()
|
||||
if args.list_boards:
|
||||
for name in BOARDS:
|
||||
print(name)
|
||||
return
|
||||
if not args.version or not args.radio_preset:
|
||||
parser.error("--version and --radio-preset are required for a build")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+\-]*", args.version):
|
||||
parser.error("--version must be a filename-safe version token")
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be positive")
|
||||
if args.board and args.role:
|
||||
parser.error("choose --board or --role, not both")
|
||||
if os.name == "nt" and not args.dry_run:
|
||||
parser.error("run this recipe inside Linux/WSL, where bash and pio are available")
|
||||
|
||||
source = git_output("rev-parse", "--short=8", "HEAD")
|
||||
if args.role:
|
||||
boards = [name for name, spec in BOARDS.items() if spec["role"] == args.role]
|
||||
else:
|
||||
boards = list(dict.fromkeys(args.board or BOARDS))
|
||||
requested_root = args.output_root or Path(".releases") / f"esp32-expanded-{source}"
|
||||
output_root = (requested_root if requested_root.is_absolute()
|
||||
else ROOT / requested_root).resolve()
|
||||
build_dir = output_root / "builds"
|
||||
package_dir = output_root / "packages"
|
||||
steps = build_steps(boards, args.version, args.radio_preset, args.profile, args.jobs)
|
||||
for command, full in steps:
|
||||
prefix = f"OUTPUT_DIR={shlex.quote(str(build_dir))} " if full else ""
|
||||
print(prefix + shlex.join(command), flush=True)
|
||||
print(f"Package {', '.join(boards)} into {package_dir}", flush=True)
|
||||
if set(boards) == set(BOARDS):
|
||||
print(f"Bundle all configured board/role recipes (not historical binaries) in {output_root}", flush=True)
|
||||
if args.dry_run:
|
||||
return
|
||||
|
||||
if git_output("status", "--porcelain", "--untracked-files=normal"):
|
||||
parser.error("commit or remove working-tree changes before building a versioned package")
|
||||
for executable in ("bash", "pio"):
|
||||
if not shutil.which(executable):
|
||||
parser.error(f"{executable} is unavailable in this environment")
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
for command, full in steps:
|
||||
environment = os.environ.copy()
|
||||
if full:
|
||||
environment["OUTPUT_DIR"] = str(build_dir)
|
||||
subprocess.run(command, cwd=ROOT, env=environment, check=True)
|
||||
|
||||
# Package in a fresh directory so an interruption never leaves a partial
|
||||
# release ZIP at the final path. Existing identical ZIPs are reusable.
|
||||
with tempfile.TemporaryDirectory(prefix="migration-package-", dir=output_root) as temporary:
|
||||
staging = Path(temporary) / "packages"
|
||||
command = [
|
||||
sys.executable, "-B", "scripts/package_esp32_partition_migration.py",
|
||||
"--build-dir", str(build_dir), "--output-dir", str(staging),
|
||||
"--version", args.version, "--source", source,
|
||||
]
|
||||
for name in boards:
|
||||
command.extend(("--board", name))
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
archives = sorted(staging.glob("*.zip"))
|
||||
if len(archives) != len(boards):
|
||||
raise ValueError("packager did not produce one ZIP per selected board")
|
||||
package_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in boards:
|
||||
matches = [path for path in archives if path.name.startswith(name + "-")]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one {name} ZIP")
|
||||
staged = matches[0]
|
||||
verify_archive(staged, name, args.version, source)
|
||||
destination = package_dir / staged.name
|
||||
if destination.exists():
|
||||
if hashlib.sha256(destination.read_bytes()).digest() != \
|
||||
hashlib.sha256(staged.read_bytes()).digest():
|
||||
raise ValueError(f"existing package differs: {destination}")
|
||||
else:
|
||||
staged.replace(destination)
|
||||
print(f"Ready: {destination}", flush=True)
|
||||
if set(boards) == set(BOARDS):
|
||||
print("Release: " + str(bundle_release(
|
||||
output_root, package_dir, boards, args.version, source,
|
||||
args.radio_preset, args.profile)), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/sh
|
||||
# Interactive front end for the verified ESP32 in-place migration recipe.
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
recipe=$script_dir/build_esp32_partition_migration.py
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/build_esp32_partition_migration.sh [options]
|
||||
|
||||
With no arguments, choose one ESP32 board/role or the complete configured
|
||||
set from a menu. The complete set creates one ZIP per board/role plus a release ZIP.
|
||||
|
||||
--board KEY Build one board (repeatable)
|
||||
--role ROLE Build every repeater, room-server, or sensor board
|
||||
--all Build all configured repeater, room-server and sensor roles
|
||||
--version VERSION Firmware version (required without the menu)
|
||||
--radio-preset NAME Radio preset (required without the menu)
|
||||
--profile NAME default or cascade (default: cascade)
|
||||
--jobs N PlatformIO workers within each serial build
|
||||
--output-root PATH Release output folder
|
||||
--dry-run Print commands without building
|
||||
--list-boards Show configured board/role keys
|
||||
--help Show this help
|
||||
|
||||
Boards with unrelated source layout plans are not offered. A name appearing
|
||||
in an old firmware release is not proof that OTA migration is safe on that
|
||||
hardware. The bridge checks the live layout, and no release ZIP is published
|
||||
unless every selected package passes the recipe's checks.
|
||||
EOF
|
||||
}
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=
|
||||
radio_preset=
|
||||
profile=cascade
|
||||
jobs=
|
||||
output_root=
|
||||
boards=
|
||||
role=
|
||||
all=0
|
||||
dry_run=0
|
||||
interactive=1
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
interactive=0
|
||||
case "$1" in
|
||||
--board|--role|--version|--radio-preset|--profile|--jobs|--output-root)
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$1" in
|
||||
--board) boards="${boards}${boards:+ }$2" ;;
|
||||
--role) role=$2 ;;
|
||||
--version) version=$2 ;;
|
||||
--radio-preset) radio_preset=$2 ;;
|
||||
--profile) profile=$2 ;;
|
||||
--jobs) jobs=$2 ;;
|
||||
--output-root) output_root=$2 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--all) all=1; shift ;;
|
||||
--dry-run) dry_run=1; shift ;;
|
||||
--list-boards) exec python3 -B "$recipe" --list-boards ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
board_keys=$(python3 -B "$recipe" --list-boards)
|
||||
if [ -z "$board_keys" ]; then
|
||||
echo "No boards are configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$interactive" -eq 1 ]; then
|
||||
echo "ESP32 in-place partition migration"
|
||||
echo "Repeater, room-server and sensor recipes are listed."
|
||||
index=1
|
||||
for board in $board_keys; do
|
||||
echo " $index) $board"
|
||||
index=$((index + 1))
|
||||
done
|
||||
echo " a) Build all configured board/role recipes and create a release ZIP"
|
||||
echo " r) Build all repeaters"
|
||||
echo " m) Build all room servers"
|
||||
echo " s) Build all sensors"
|
||||
echo " q) Quit"
|
||||
printf 'Choose: '
|
||||
IFS= read -r choice
|
||||
case "$choice" in
|
||||
a|A|all) all=1 ;;
|
||||
r|R) role=repeater ;;
|
||||
m|M) role=room-server ;;
|
||||
s|S) role=sensor ;;
|
||||
q|Q|quit) exit 0 ;;
|
||||
*)
|
||||
index=1
|
||||
for board in $board_keys; do
|
||||
if [ "$choice" = "$index" ]; then
|
||||
boards=$board
|
||||
break
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done
|
||||
if [ -z "$boards" ]; then
|
||||
echo "Invalid selection" >&2
|
||||
exit 2
|
||||
fi ;;
|
||||
esac
|
||||
printf 'Firmware version: '
|
||||
IFS= read -r version
|
||||
printf 'Radio preset [usa-cascadia]: '
|
||||
IFS= read -r radio_preset
|
||||
radio_preset=${radio_preset:-usa-cascadia}
|
||||
printf 'Profile [cascade]: '
|
||||
IFS= read -r selected_profile
|
||||
profile=${selected_profile:-cascade}
|
||||
fi
|
||||
|
||||
if { [ "$all" -eq 1 ] && { [ -n "$boards" ] || [ -n "$role" ]; }; } \
|
||||
|| { [ -n "$boards" ] && [ -n "$role" ]; }; then
|
||||
echo "Choose only one of --all, --board, or --role" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "$version" ] || [ -z "$radio_preset" ]; then
|
||||
echo "Firmware version and radio preset are required" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$profile" != default ] && [ "$profile" != cascade ]; then
|
||||
echo "Profile must be default or cascade" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "$role" ] && [ "$role" != repeater ] \
|
||||
&& [ "$role" != room-server ] && [ "$role" != sensor ]; then
|
||||
echo "Role must be repeater, room-server, or sensor" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
set -- "$recipe" --version "$version" --radio-preset "$radio_preset" --profile "$profile"
|
||||
if [ -n "$boards" ]; then
|
||||
for board in $boards; do
|
||||
found=0
|
||||
for known in $board_keys; do
|
||||
if [ "$board" = "$known" ]; then found=1; break; fi
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "Board/role is not configured for this recipe: $board" >&2
|
||||
exit 2
|
||||
fi
|
||||
set -- "$@" --board "$board"
|
||||
done
|
||||
fi
|
||||
if [ -n "$role" ]; then set -- "$@" --role "$role"; fi
|
||||
if [ -n "$jobs" ]; then set -- "$@" --jobs "$jobs"; fi
|
||||
if [ -n "$output_root" ]; then set -- "$@" --output-root "$output_root"; fi
|
||||
if [ "$dry_run" -eq 1 ]; then set -- "$@" --dry-run; fi
|
||||
exec python3 -B "$@"
|
||||
@@ -0,0 +1,624 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package the two-stage Wi-Fi and LoRa ESP32 partition migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools" / "mota"))
|
||||
from motalib import ( # noqa: E402
|
||||
CODEC_FULL, FwIdent, build_container, build_endf, build_manifest, has_endf,
|
||||
hardware_id_for_env, pack_version, parse_container, parse_endf,
|
||||
parse_endf_ident,
|
||||
target_id_for_env, verify,
|
||||
)
|
||||
from check_esp32_app_size import app_partition_size # noqa: E402
|
||||
from firmware_memory_manifest import validate_package # noqa: E402
|
||||
|
||||
|
||||
BOARDS = {
|
||||
"heltec-v4": {
|
||||
"target": "heltec_v4_repeater",
|
||||
"expander_bridge": "heltec_v4_partition_expander",
|
||||
"wifi_bridge": "heltec_v4_partition_migrator",
|
||||
"lora_bridge": "heltec_v4_partition_migrator_lora_repeater",
|
||||
"flash_bytes": 16 * 1024 * 1024,
|
||||
"slot_bytes": 0x640000,
|
||||
"slot1_address": 0x650000,
|
||||
},
|
||||
"xiao-s3-wio": {
|
||||
"target": "Xiao_S3_WIO_repeater",
|
||||
"expander_bridge": "Xiao_S3_WIO_partition_expander",
|
||||
"wifi_bridge": "xiao_s3_partition_migrator",
|
||||
"lora_bridge": "xiao_s3_partition_migrator_lora_repeater",
|
||||
"flash_bytes": 8 * 1024 * 1024,
|
||||
"slot_bytes": 0x330000,
|
||||
"slot1_address": 0x340000,
|
||||
},
|
||||
"station-g2-repeater": {
|
||||
"target": "Station_G2_repeater",
|
||||
"wifi_bridge": "esp32_s3_16mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 16 * 1024 * 1024,
|
||||
"slot_bytes": 0x640000,
|
||||
"slot1_address": 0x650000,
|
||||
},
|
||||
"station-g2-room-server": {
|
||||
"target": "Station_G2_room_server",
|
||||
"wifi_bridge": "esp32_s3_16mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 16 * 1024 * 1024,
|
||||
"slot_bytes": 0x640000,
|
||||
"slot1_address": 0x650000,
|
||||
},
|
||||
"t3s3-sx1262-repeater": {
|
||||
"target": "LilyGo_T3S3_sx1262_repeater",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
"t3s3-sx1262-room-server": {
|
||||
"target": "LilyGo_T3S3_sx1262_room_server",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
"t3s3-sx1276-repeater": {
|
||||
"target": "LilyGo_T3S3_sx1276_repeater",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
"t3s3-sx1276-room-server": {
|
||||
"target": "LilyGo_T3S3_sx1276_room_server",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
"thinknode-m2-repeater": {
|
||||
"target": "ThinkNode_M2_Repeater",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
"thinknode-m2-room-server": {
|
||||
"target": "ThinkNode_M2_room_server",
|
||||
"wifi_bridge": "esp32_s3_4mb_partition_migrator",
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": 4 * 1024 * 1024,
|
||||
"slot_bytes": 0x1F0000,
|
||||
"slot1_address": 0x200000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def role_for_target(target: str) -> str:
|
||||
lower = target.lower()
|
||||
if "_room_server" in lower:
|
||||
return "room-server"
|
||||
if "_repeater" in lower:
|
||||
return "repeater"
|
||||
if "_sensor" in lower:
|
||||
return "sensor"
|
||||
raise ValueError(f"migration target has no infrastructure role: {target}")
|
||||
|
||||
|
||||
def add_roles(family: str, flash_mib: int,
|
||||
targets: dict[str, str],
|
||||
bridge: str | None = None) -> None:
|
||||
"""Register exact role identities; a family can have one or all roles."""
|
||||
if flash_mib not in (4, 8, 16):
|
||||
raise ValueError(f"unsupported migration flash size: {flash_mib} MiB")
|
||||
slot_bytes, slot1_address = {
|
||||
4: (0x1F0000, 0x200000),
|
||||
8: (0x330000, 0x340000),
|
||||
16: (0x640000, 0x650000),
|
||||
}[flash_mib]
|
||||
if bridge is None:
|
||||
bridge = f"esp32_s3_{flash_mib}mb_partition_migrator"
|
||||
for role, target in targets.items():
|
||||
if role not in ("repeater", "room-server", "sensor"):
|
||||
raise ValueError(f"unsupported migration role: {role}")
|
||||
key = f"{family}-{role}"
|
||||
if key in BOARDS:
|
||||
raise ValueError(f"duplicate migration board role: {key}")
|
||||
BOARDS[key] = {
|
||||
"target": target,
|
||||
"role": role,
|
||||
"wifi_bridge": bridge,
|
||||
"lora_bridge": None,
|
||||
"flash_bytes": flash_mib * 1024 * 1024,
|
||||
"slot_bytes": slot_bytes,
|
||||
"slot1_address": slot1_address,
|
||||
}
|
||||
|
||||
|
||||
# The first two repeater keys above retain their published LoRa-capable bridge
|
||||
# names. Other role packages use a chip-family bridge for both Wi-Fi and LoRa.
|
||||
# These are the canonical ESP32 infrastructure identities on 4/8/16 MiB boards which can
|
||||
# have a deployed Arduino default.csv 1.25 MiB table, including historically
|
||||
# default.csv boards now using a larger layout. Other boards that were only
|
||||
# ever built with unrelated layouts are not represented as 1.25 MiB migrants.
|
||||
for family, flash_mib, roles in (
|
||||
("heltec-v4", 16, {"room-server": "heltec_v4_room_server",
|
||||
"sensor": "heltec_v4_sensor"}),
|
||||
("heltec-v4-expansionkit", 16, {
|
||||
"repeater": "heltec_v4_expansionkit_repeater"}),
|
||||
("xiao-s3-wio", 8, {"room-server": "Xiao_S3_WIO_room_server",
|
||||
"sensor": "Xiao_S3_WIO_sensor"}),
|
||||
("nibble-zero", 4, {"repeater": "nibble_zero_connect_repeater_",
|
||||
"room-server": "nibble_zero_connect_room_server_"}),
|
||||
("nibble-screen", 4, {"repeater": "nibble_screen_connect_repeater_",
|
||||
"room-server": "nibble_screen_connect_room_server_"}),
|
||||
("ebyte-eora-s3", 4, {"repeater": "Ebyte_EoRa-S3_Repeater",
|
||||
"room-server": "Ebyte_EoRa-S3_room_server"}),
|
||||
("thinknode-m5", 4, {"repeater": "ThinkNode_M5_Repeater",
|
||||
"room-server": "ThinkNode_M5_room_server"}),
|
||||
("heltec-wireless-tracker", 8, {
|
||||
"repeater": "Heltec_Wireless_Tracker_repeater",
|
||||
"room-server": "Heltec_Wireless_Tracker_room_server"}),
|
||||
("heltec-tracker-v2", 8, {"repeater": "heltec_tracker_v2_repeater",
|
||||
"room-server": "heltec_tracker_v2_room_server",
|
||||
"sensor": "heltec_tracker_v2_sensor"}),
|
||||
("heltec-v3", 8, {"repeater": "Heltec_v3_repeater",
|
||||
"room-server": "Heltec_v3_room_server",
|
||||
"sensor": "Heltec_v3_sensor"}),
|
||||
("heltec-wsl3", 8, {"repeater": "Heltec_WSL3_repeater",
|
||||
"room-server": "Heltec_WSL3_room_server",
|
||||
"sensor": "Heltec_WSL3_sensor"}),
|
||||
("heltec-wireless-paper", 8, {
|
||||
"repeater": "Heltec_Wireless_Paper_repeater",
|
||||
"room-server": "Heltec_Wireless_Paper_room_server"}),
|
||||
("t-beam-s3-supreme-sx1262", 8, {
|
||||
"repeater": "T_Beam_S3_Supreme_SX1262_repeater",
|
||||
"room-server": "T_Beam_S3_Supreme_SX1262_room_server"}),
|
||||
("thinknode-m7", 8, {"repeater": "ThinkNode_M7_repeater",
|
||||
"room-server": "ThinkNode_M7_room_server"}),
|
||||
("xiao-s3", 8, {"repeater": "Xiao_S3_repeater",
|
||||
"room-server": "Xiao_S3_room_server",
|
||||
"sensor": "Xiao_S3_sensor"}),
|
||||
("mke-s3", 8, {"repeater": "MKE_s3_repeater",
|
||||
"room-server": "MKE_s3_room_server",
|
||||
"sensor": "MKE_s3_sensor"}),
|
||||
("rak-3112", 8, {"repeater": "RAK_3112_repeater",
|
||||
"room-server": "RAK_3112_room_server",
|
||||
"sensor": "RAK_3112_sensor"}),
|
||||
("heltec-e213", 16, {"repeater": "Heltec_E213_repeater",
|
||||
"room-server": "Heltec_E213_room_server"}),
|
||||
("heltec-e290", 16, {"repeater": "Heltec_E290_repeater",
|
||||
"room-server": "Heltec_E290_room_server"}),
|
||||
("heltec-rc32", 16, {"repeater": "heltec_rc32_repeater",
|
||||
"room-server": "heltec_rc32_room_server",
|
||||
"sensor": "heltec_rc32_sensor"}),
|
||||
("heltec-rc32-no-display", 16, {
|
||||
"repeater": "heltec_rc32_without_display_repeater",
|
||||
"room-server": "heltec_rc32_without_display_room_server",
|
||||
"sensor": "heltec_rc32_without_display_sensor"}),
|
||||
("heltec-t190", 16, {"repeater": "Heltec_T190_repeater_",
|
||||
"room-server": "Heltec_T190_room_server_"}),
|
||||
("heltec-v4-tft", 16, {"repeater": "heltec_v4_tft_repeater",
|
||||
"room-server": "heltec_v4_tft_room_server",
|
||||
"sensor": "heltec_v4_tft_sensor"}),
|
||||
("heltec-v4-r8", 16, {"repeater": "heltec_v4_r8_repeater",
|
||||
"room-server": "heltec_v4_r8_room_server",
|
||||
"sensor": "heltec_v4_r8_sensor"}),
|
||||
("heltec-v4-r8-tft", 16, {"repeater": "heltec_v4_r8_tft_repeater",
|
||||
"room-server": "heltec_v4_r8_tft_room_server",
|
||||
"sensor": "heltec_v4_r8_tft_sensor"}),
|
||||
("lilygo-tbeam-1w", 16, {"repeater": "LilyGo_TBeam_1W_repeater",
|
||||
"room-server": "LilyGo_TBeam_1W_room_server"}),
|
||||
("lilygo-tdeck", 16, {"repeater": "LilyGo_TDeck_repeater"}),
|
||||
("lilygo-teth-elite-sx1262", 16, {
|
||||
"repeater": "LilyGo_TETH_Elite_sx1262_repeater",
|
||||
"room-server": "LilyGo_TETH_Elite_sx1262_room_server"}),
|
||||
("meshnology-w12", 16, {"repeater": "meshnology_w12_repeater",
|
||||
"room-server": "meshnology_w12_room_server",
|
||||
"sensor": "meshnology_w12_sensor"}),
|
||||
("station-g3-esp32", 16, {"repeater": "Station_G3_ESP32_repeater",
|
||||
"room-server": "Station_G3_ESP32_room_server"}),
|
||||
("station-g3-esp32-logging", 16, {
|
||||
"repeater": "Station_G3_ESP32_logging_repeater"}),
|
||||
("station-g2-logging", 16, {
|
||||
"repeater": "Station_G2_logging_repeater"}),
|
||||
("thinknode-m9", 16, {"repeater": "ThinkNode_M9_repeater_",
|
||||
"room-server": "ThinkNode_M9_room_server_"}),
|
||||
):
|
||||
add_roles(family, flash_mib, roles)
|
||||
|
||||
add_roles("heltec-v2", 8, {
|
||||
"repeater": "Heltec_v2_repeater",
|
||||
"room-server": "Heltec_v2_room_server",
|
||||
}, bridge="esp32_8mb_partition_migrator")
|
||||
add_roles("heltec-ct62", 4, {
|
||||
"repeater": "Heltec_ct62_repeater",
|
||||
"sensor": "Heltec_ct62_sensor",
|
||||
}, bridge="esp32_c3_4mb_partition_migrator")
|
||||
add_roles("xiao-c3", 4, {
|
||||
"repeater": "Xiao_C3_repeater",
|
||||
"room-server": "Xiao_C3_room_server",
|
||||
}, bridge="esp32_c3_4mb_partition_migrator")
|
||||
add_roles("tenstar-c3-sx1262", 4, {
|
||||
"repeater": "Tenstar_C3_sx1262_repeater",
|
||||
}, bridge="esp32_c3_4mb_partition_migrator")
|
||||
add_roles("tenstar-c3-sx1268", 4, {
|
||||
"repeater": "Tenstar_C3_sx1268_repeater",
|
||||
}, bridge="esp32_c3_4mb_partition_migrator")
|
||||
add_roles("generic-e22-sx1262", 4, {
|
||||
"repeater": "Generic_E22_sx1262_repeater",
|
||||
}, bridge="esp32_4mb_partition_migrator")
|
||||
add_roles("generic-e22-sx1268", 4, {
|
||||
"repeater": "Generic_E22_sx1268_repeater",
|
||||
}, bridge="esp32_4mb_partition_migrator")
|
||||
|
||||
for specification in BOARDS.values():
|
||||
specification.setdefault("role", role_for_target(specification["target"]))
|
||||
if specification["lora_bridge"] is None:
|
||||
specification["lora_bridge"] = specification["wifi_bridge"] + "_lora"
|
||||
|
||||
LEGACY_SLOT_BYTES = 0x140000
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def partition_entries(table: bytes) -> dict[str, tuple[int, int]]:
|
||||
entries = {}
|
||||
for offset in range(0, len(table) - 31, 32):
|
||||
entry = table[offset:offset + 32]
|
||||
if entry[:2] != b"\xaa\x50":
|
||||
continue
|
||||
address, size = struct.unpack_from("<II", entry, 4)
|
||||
label = entry[12:28].split(b"\0", 1)[0].decode("ascii")
|
||||
entries[label] = (address, size)
|
||||
return entries
|
||||
|
||||
|
||||
def mota_full(image: bytes, identity: FwIdent, block_size: int) -> bytes:
|
||||
# The wire format supports more than the 1024 leaves held by older
|
||||
# 4 KiB-scratch seeders. The package records the required scratch size so
|
||||
# an operator can select a capable seeder before installing the bridge.
|
||||
if (len(image) + block_size - 1) // block_size > 4096:
|
||||
raise ValueError("mOTA image exceeds the supported 4096-block seeder limit")
|
||||
manifest = build_manifest(
|
||||
target_id=identity.target_id,
|
||||
fw_version=identity.fw_version,
|
||||
image_size=len(image),
|
||||
payload=image,
|
||||
block_size=block_size,
|
||||
image_hash=hashlib.sha256(image).digest(),
|
||||
codec_id=CODEC_FULL,
|
||||
is_full=True,
|
||||
hw_id=identity.hw_id,
|
||||
)
|
||||
package = build_container(manifest, image)
|
||||
problems = verify(parse_container(package))
|
||||
if problems:
|
||||
raise ValueError("mOTA verification failed: " + "; ".join(problems))
|
||||
return package
|
||||
|
||||
|
||||
def bridge_with_successor_endf(image: bytes, successor: FwIdent) -> bytes:
|
||||
"""Bind a temporary role image to the old node's exact LoRa target.
|
||||
|
||||
PlatformIO can already append EndF for the temporary role. Keeping that
|
||||
trailer would make the old repeater reject stage one on target mismatch.
|
||||
"""
|
||||
body = parse_endf(image)[0] if has_endf(image) else image
|
||||
return body + build_endf(body, successor)
|
||||
|
||||
|
||||
def check_esp32_stage(package: bytes, image: bytes, slot_bytes: int) -> None:
|
||||
# Mirror the ESP32 FULL staging geometry: the payload is written at the
|
||||
# start of the inactive app, with metadata in aligned sectors at its end.
|
||||
metadata_bytes = len(package) - len(image) - 5
|
||||
metadata_flush = (metadata_bytes + 5 + 4095) & ~4095
|
||||
if (len(package) > slot_bytes or metadata_flush > 65536
|
||||
or len(image) > slot_bytes - metadata_flush):
|
||||
raise ValueError("mOTA image cannot be staged in the inactive ESP32 slot")
|
||||
|
||||
|
||||
def readme(board: str, spec: dict, version: str, source: str,
|
||||
has_full_mota: bool = True, full_mota_blocks: int = 0) -> str:
|
||||
expander_instructions = ""
|
||||
if spec.get("expander_bridge"):
|
||||
expander_instructions = f"""## Partition Expander route (automatic LoRa finish)
|
||||
|
||||
For this exact **{spec['target']}** hardware/role, `partition-expander.bin`
|
||||
is an alternative to `wifi-bridge.bin` in step 1 above. It works from either
|
||||
legacy OTA slot. It stages and verifies the private identity, saved node name,
|
||||
radio profiles and ACL before changing the table. After **Expanded layout
|
||||
ready**, it listens on the saved primary radio profile and automatically
|
||||
fetches only this package's exact-target Full application. Serve
|
||||
`full-application.mota` on that profile; do not upload it to the browser.
|
||||
The bridge's status page reports the receiver profile and block progress.
|
||||
Keep only the intended Full build for this target on the seeder during the
|
||||
migration; the bridge pins the hardware/role target, not an individual MID.
|
||||
If installed on an already-expanded board, it returns to a verified Full image
|
||||
in the other slot. Without a recorded migration handoff or safe Full image, it
|
||||
does not fetch or replace an image; the status page explains whether Wi-Fi
|
||||
recovery is available. Firmware upload is disabled if identity verification
|
||||
failed.
|
||||
|
||||
`partition-expander.mota` offers the same bridge as a first-stage LoRa update
|
||||
only if the installed old firmware already supports compatible mOTA and its
|
||||
target ID matches this repeater. A stock image without mOTA must use its
|
||||
existing Wi-Fi updater for stage one. Keep a compatible seeder available before
|
||||
installing the bridge; the bridge does not itself repeat normal traffic.
|
||||
"""
|
||||
wifi_instructions = f"""## Wi-Fi route
|
||||
|
||||
1. On the old node, run `start ota ap` through its authenticated terminal.
|
||||
Join its `MeshCore-OTA` Wi-Fi and upload `wifi-bridge.bin` at the exact
|
||||
`Started:` URL returned by the command (the port can vary by old build).
|
||||
2. Keep power stable. Join `MeshCore-Migrate` (password `meshcore-migrate`),
|
||||
open `http://192.168.4.1/`, and wait for **Expanded layout ready**.
|
||||
3. At that page's `/update`, upload `full-application.bin`.
|
||||
"""
|
||||
if spec["lora_bridge"]:
|
||||
slot_instructions = (
|
||||
"""On this **4 MiB** layout, the bridge must be installed in old slot B
|
||||
while a verified old LoRa receiver remains in old slot A. If the old node is
|
||||
running B, first install/boot a working same-target old receiver in A, then
|
||||
install the bridge in B. A bridge installed in A refuses migration. After the
|
||||
table switch the old receiver in A may use a temporary identity; the Full
|
||||
application restores the original private key from NVS on its first boot.
|
||||
Do not abandon the update between those two boots.\n\n"""
|
||||
if spec["flash_bytes"] == 4 * 1024 * 1024 else
|
||||
"The bridge may be installed in either old OTA slot.\n\n"
|
||||
)
|
||||
handoff = (
|
||||
"the original key stays staged in NVS until the Full firmware "
|
||||
"restores it on first boot"
|
||||
if spec["flash_bytes"] == 4 * 1024 * 1024 else
|
||||
"the bridge restores the private key before handing off"
|
||||
)
|
||||
lora_instructions = f"""## LoRa route
|
||||
|
||||
The old node must already have working MeshCore LoRa mOTA, a valid EndF,
|
||||
and the exact target ID in `manifest.json`. A stock image without that updater
|
||||
cannot take this route. The bridge checks the old receiver before changing the
|
||||
table and refuses if its image or target does not match.
|
||||
|
||||
If this bridge is accidentally installed on a node that already has the
|
||||
expanded table, it verifies the other OTA slot's target, EndF image hash and
|
||||
bootability, then returns to that firmware without changing the table. It
|
||||
refuses a second migration bridge or an invalid other image; Wi-Fi recovery
|
||||
remains available if no safe LoRa image exists and identity restoration has
|
||||
completed. A failed identity restore disables firmware upload. Do not
|
||||
deliberately install the bridge on an already-expanded node; use the Full image
|
||||
directly.
|
||||
|
||||
{slot_instructions}The Full image has {full_mota_blocks} blocks and needs a seeder with at least
|
||||
{full_mota_blocks * 4} bytes of proof scratch. Older 4 KiB-scratch seeders cannot
|
||||
serve more than 1024 blocks. Verify that a capable seeder lists the Full image
|
||||
before installing the bridge.
|
||||
|
||||
The resized SPIFFS may reset saved radio settings. Before stage one, know
|
||||
the old receiver's compiled default radio profile and arrange a seeder on
|
||||
that profile for stage two; a remote node that cannot hear the seeder cannot
|
||||
finish its LoRa migration.
|
||||
|
||||
1. Make `lora-bridge.mota` available from a compatible mOTA seeder. On the old
|
||||
node use `ota ls`, `ota pull <id> flash`, then `ota install` after the
|
||||
complete download. A manual install handles an equal-version bridge.
|
||||
2. Keep power stable. The bridge expands the table; {handoff}. It then reboots
|
||||
into the preserved old LoRa receiver in the opposite slot.
|
||||
3. Serve `full-application.mota`; again use `ota ls`, `ota pull <id> flash`,
|
||||
then `ota install`. The old receiver now sees the expanded inactive slot.
|
||||
A full-image LoRa transfer can take considerable time.
|
||||
|
||||
`full-application.bin` and `full-application.mota` are the same exact-target
|
||||
Full image. `target-partitions.bin` is included for verification only;
|
||||
do not upload it to the browser. Neither bridge is a USB merged image.
|
||||
"""
|
||||
else:
|
||||
full_mota_note = (
|
||||
"Do not upload `full-application.mota` until the expanded layout "
|
||||
"and new Full image are in place."
|
||||
if has_full_mota else
|
||||
"This ZIP does not include `full-application.mota`: the Full image "
|
||||
"exceeds the current LoRa mOTA 2 KiB x 1024-block serving limit. "
|
||||
"Use `full-application.bin` through Wi-Fi."
|
||||
)
|
||||
lora_instructions = f"""## LoRa limitation
|
||||
|
||||
This package has **no LoRa bridge**. The Wi-Fi updater in the installed old
|
||||
firmware is required. On 4 MiB flash, the expanded app1 overlaps the old app1,
|
||||
so the current LoRa receiver-copy method cannot survive a power loss safely.
|
||||
Some older room-server images also have no LoRa mOTA receiver. Do not upload
|
||||
the Full image through LoRa before the new layout is active. {full_mota_note}
|
||||
`target-partitions.bin` is for verification only, not browser upload.
|
||||
"""
|
||||
return f"""# {board} in-place partition migration
|
||||
|
||||
Source: `{source}`; firmware version: `{version}`. This package is only for the
|
||||
physical board/role represented by `{spec['target']}` with
|
||||
{spec['flash_bytes'] // 1048576} MiB flash. A legacy Wi-Fi-updatable observer or
|
||||
bridge variant on the same hardware may migrate to this canonical Full target;
|
||||
the Wi-Fi bridge stages its saved node/radio configuration and ACL when NVS has
|
||||
enough room, and refuses migration otherwise. The older LoRa handoff bridge
|
||||
preserves only the private identity. The LoRa route, where present,
|
||||
requires the old image's exact target ID.
|
||||
The current partition table must have stable NVS at 0x9000, OTA metadata at
|
||||
0xE000, two OTA apps, and SPIFFS with `/identity/_main.id`.
|
||||
|
||||
The bridge checks the live layout and refuses unsupported boards. It stages
|
||||
the 96-byte public/private identity in unchanged NVS, preserves a bootable old
|
||||
receiver, and changes the partition table. On 8/16 MiB LoRa and Wi-Fi routes
|
||||
the bridge restores and verifies the identity in expanded SPIFFS; on the
|
||||
4 MiB LoRa route the new Full firmware does so at first boot. No full-chip
|
||||
erase or USB connection is used.
|
||||
The table sector and inactive app sectors are erased as part of migration.
|
||||
SPIFFS may be reformatted. The Wi-Fi bridge restores verified radio 1 and
|
||||
radio 2 settings, node name, ACL and login replay state, region keys, and
|
||||
selected network/OTA settings. Temporary radio sessions and logs are not
|
||||
preserved. The older LoRa handoff route may reset saved settings.
|
||||
The stock bootloader has no atomic backup partition table: power loss during
|
||||
the table-sector erase/write can still require cable recovery. Do not use this
|
||||
on an inaccessible node without accepting that risk and testing a sacrificial
|
||||
example of the same board first.
|
||||
|
||||
{wifi_instructions}
|
||||
{expander_instructions}
|
||||
{lora_instructions}
|
||||
"""
|
||||
|
||||
|
||||
def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path,
|
||||
version: str, source: str) -> Path:
|
||||
target = spec["target"]
|
||||
target_id = target_id_for_env(target)
|
||||
hw_id = hardware_id_for_env(target)
|
||||
images = sorted(build_dir.glob(f"{target}-full-*-{version}-{source}.bin"))
|
||||
if len(images) != 1:
|
||||
raise ValueError(f"{name}: expected one exact-target Full image, found {len(images)}")
|
||||
stem = images[0].stem
|
||||
full_path = build_dir / (stem + ".bin")
|
||||
capability_path = build_dir / (stem + ".capabilities.json")
|
||||
validate_package(build_dir / stem)
|
||||
capabilities = json.loads(capability_path.read_text())
|
||||
if (capabilities.get("target") != target
|
||||
or capabilities.get("build_profile") != "full"
|
||||
or capabilities.get("platform") != "ESP32_PLATFORM"
|
||||
or not capabilities.get("ota_update_verified")):
|
||||
raise ValueError(f"{name}: Full build has the wrong target or lacks OTA")
|
||||
|
||||
# build.sh may clean .pio/build between board builds. Its merged image
|
||||
# retains the exact bootloader/partition table/app bytes that were audited.
|
||||
merged = (build_dir / (stem + "-merged.bin")).read_bytes()
|
||||
if len(merged) < 0x9000 or merged[0x10000:] != full_path.read_bytes():
|
||||
raise ValueError(f"{name}: merged image does not contain the release app")
|
||||
table = merged[0x8000:0x9000]
|
||||
entries = partition_entries(table)
|
||||
if (entries.get("nvs") != (0x9000, 0x5000)
|
||||
or entries.get("otadata") != (0xE000, 0x2000)
|
||||
or entries.get("app0") != (0x10000, spec["slot_bytes"])
|
||||
or entries.get("app1") != (spec["slot1_address"], spec["slot_bytes"])):
|
||||
raise ValueError(f"{name}: built partition table differs from the migration plan")
|
||||
_label, slot_bytes = app_partition_size(table)
|
||||
full_image = full_path.read_bytes()
|
||||
full_ident = parse_endf_ident(full_image)
|
||||
if (full_ident is None or full_ident.target_id != target_id
|
||||
or full_ident.hw_id != hw_id or len(full_image) >= slot_bytes):
|
||||
raise ValueError(f"{name}: Full image identity or partition fit failed")
|
||||
|
||||
wifi_bridge = (ROOT / ".pio" / "build" / spec["wifi_bridge"] / "firmware.bin").read_bytes()
|
||||
bridge_ident = FwIdent(pack_version(version.split("-", 1)[0]), target_id, hw_id)
|
||||
if len(wifi_bridge) > LEGACY_SLOT_BYTES:
|
||||
raise ValueError(f"{name}: bridge does not fit the 1.25 MiB legacy slot")
|
||||
if full_ident.fw_version != bridge_ident.fw_version:
|
||||
raise ValueError(f"{name}: bridge and Full image versions differ")
|
||||
|
||||
files = {
|
||||
"wifi-bridge.bin": wifi_bridge,
|
||||
"full-application.bin": full_image,
|
||||
"target-partitions.bin": table,
|
||||
"capabilities.json": capability_path.read_bytes(),
|
||||
}
|
||||
if spec.get("expander_bridge"):
|
||||
expander_body = (ROOT / ".pio" / "build" / spec["expander_bridge"] /
|
||||
"firmware.bin").read_bytes()
|
||||
expander_image = bridge_with_successor_endf(expander_body, bridge_ident)
|
||||
if len(expander_image) > LEGACY_SLOT_BYTES:
|
||||
raise ValueError(f"{name}: Partition Expander does not fit the legacy slot")
|
||||
expander_mota = mota_full(expander_image, bridge_ident, 1024)
|
||||
check_esp32_stage(expander_mota, expander_image, LEGACY_SLOT_BYTES)
|
||||
files["partition-expander.bin"] = expander_body
|
||||
files["partition-expander.mota"] = expander_mota
|
||||
full_mota_blocks = (len(full_image) + 2047) // 2048
|
||||
if full_mota_blocks <= 4096:
|
||||
candidate_mota = mota_full(full_image, full_ident, 2048)
|
||||
try:
|
||||
check_esp32_stage(candidate_mota, full_image, slot_bytes)
|
||||
except ValueError:
|
||||
if spec["lora_bridge"]:
|
||||
raise
|
||||
else:
|
||||
files["full-application.mota"] = candidate_mota
|
||||
elif spec["lora_bridge"]:
|
||||
raise ValueError(f"{name}: LoRa Full image exceeds the mOTA block limit")
|
||||
if spec["lora_bridge"]:
|
||||
lora_bridge_body = (ROOT / ".pio" / "build" / spec["lora_bridge"] / "firmware.bin").read_bytes()
|
||||
lora_bridge = bridge_with_successor_endf(lora_bridge_body, bridge_ident)
|
||||
if len(lora_bridge) > LEGACY_SLOT_BYTES:
|
||||
raise ValueError(f"{name}: LoRa bridge does not fit the legacy slot")
|
||||
files["lora-bridge.mota"] = mota_full(lora_bridge, bridge_ident, 1024)
|
||||
check_esp32_stage(files["lora-bridge.mota"], lora_bridge, LEGACY_SLOT_BYTES)
|
||||
files["README.md"] = readme(name, spec, version, source,
|
||||
"full-application.mota" in files,
|
||||
full_mota_blocks).encode()
|
||||
manifest = {
|
||||
"board": name,
|
||||
"role": spec["role"],
|
||||
"target": target,
|
||||
"target_id": f"0x{target_id:08x}",
|
||||
"hardware_id": hw_id,
|
||||
"source_commit": source,
|
||||
"firmware_version": version,
|
||||
"flash_bytes": spec["flash_bytes"],
|
||||
"legacy_slot_bytes": LEGACY_SLOT_BYTES,
|
||||
"expanded_slot_bytes": slot_bytes,
|
||||
"atomic_power_loss_recovery": False,
|
||||
"lora_bridge_mode": ("slot-b-only-full-identity-recovery"
|
||||
if spec["flash_bytes"] == 4 * 1024 * 1024
|
||||
else "either-slot-preserve-receiver"),
|
||||
"partition_expander": spec.get("expander_bridge"),
|
||||
"full_mota_seeder_scratch_bytes": full_mota_blocks * 4,
|
||||
"files": {filename: {"bytes": len(data), "sha256": sha256(data)}
|
||||
for filename, data in files.items()},
|
||||
}
|
||||
files["manifest.json"] = (json.dumps(manifest, indent=2) + "\n").encode()
|
||||
files["SHA256SUMS.txt"] = "".join(
|
||||
f"{sha256(data)} {filename}\n" for filename, data in sorted(files.items())
|
||||
).encode()
|
||||
directory = output_dir / name
|
||||
directory.mkdir(parents=True)
|
||||
for filename, data in files.items():
|
||||
(directory / filename).write_bytes(data)
|
||||
archive = output_dir / f"{name}-{version}-{source}-migration.zip"
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as zip_file:
|
||||
for filename in sorted(files):
|
||||
zip_file.write(directory / filename, arcname=filename)
|
||||
return archive
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--build-dir", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--source", required=True, help="eight-character source commit")
|
||||
parser.add_argument("--board", action="append", choices=tuple(BOARDS),
|
||||
help="board to package; repeat for multiple boards (default: all)")
|
||||
args = parser.parse_args()
|
||||
if args.output_dir.exists() and any(args.output_dir.iterdir()):
|
||||
parser.error("output directory must be empty")
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
selected = dict.fromkeys(args.board or BOARDS)
|
||||
archives = [package_board(name, BOARDS[name], args.build_dir, args.output_dir,
|
||||
args.version, args.source)
|
||||
for name in selected]
|
||||
for archive in archives:
|
||||
print(f"{archive}: {archive.stat().st_size} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -849,7 +849,7 @@ void Dispatcher::checkSend() {
|
||||
// override. RX processing and ordinary watchdogs continue in loop().
|
||||
if (!restoreOutboundTxOverrides()) return;
|
||||
const uint32_t now = _ms->getMillis();
|
||||
if (ota_tx_airtime && now - ota_tx_finished_at >= ota::packetQuietTime(ota_tx_airtime, getOtaSpeedFactor())) {
|
||||
if (ota_tx_airtime && now - ota_tx_finished_at >= ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor())) {
|
||||
ota_tx_airtime = 0;
|
||||
}
|
||||
uint32_t next_outbound;
|
||||
@@ -870,7 +870,7 @@ void Dispatcher::checkSend() {
|
||||
return;
|
||||
}
|
||||
if (ota_tx_airtime) {
|
||||
const uint32_t gap = ota::packetQuietTime(ota_tx_airtime, getOtaSpeedFactor());
|
||||
const uint32_t gap = ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor());
|
||||
const uint32_t elapsed = now - ota_tx_finished_at;
|
||||
if (elapsed >= gap) ota_tx_airtime = 0;
|
||||
else if (pending && pending->getPayloadType() == PAYLOAD_TYPE_OTA) {
|
||||
|
||||
@@ -423,6 +423,7 @@ protected:
|
||||
|
||||
virtual float getAirtimeBudgetFactor() const;
|
||||
virtual float getOtaSpeedFactor() const { return 1.0f; }
|
||||
virtual float getOtaPacketSpeedFactor() const { return getOtaSpeedFactor(); }
|
||||
virtual int calcRxDelay(float score, uint32_t air_time) const;
|
||||
virtual bool shouldBypassRxDelay(const Packet* packet) {
|
||||
(void)packet;
|
||||
|
||||
@@ -663,6 +663,16 @@ float Mesh::getOtaSpeedFactor() const {
|
||||
#endif
|
||||
}
|
||||
|
||||
float Mesh::getOtaPacketSpeedFactor() const {
|
||||
const float configured = getOtaSpeedFactor();
|
||||
#if defined(ENABLE_OTA)
|
||||
const float adaptive = ota::ota_ctx().manager.adaptivePacketSpeed();
|
||||
return configured < adaptive ? configured : adaptive;
|
||||
#else
|
||||
return configured;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t Mesh::getOtaPacketAirtime() const {
|
||||
const auto* profiles = _radio->profiles();
|
||||
if (!profiles || !profiles->enabled()) return _radio->getProfileAirtime(0, MAX_TRANS_UNIT);
|
||||
|
||||
@@ -542,6 +542,7 @@ protected:
|
||||
bool _ota_temp_was_active = false; // detects entry into a temporary-radio window
|
||||
#endif
|
||||
float getOtaSpeedFactor() const override;
|
||||
float getOtaPacketSpeedFactor() const override;
|
||||
uint32_t getOtaPacketAirtime() const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -792,6 +792,28 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
}
|
||||
_com_prefs_needs_upgrade = false;
|
||||
#endif
|
||||
} else if (fs->exists("/prefs.json")) {
|
||||
// Stock 1.17.1 stored NodePrefs as JSON. A partition migration preserves
|
||||
// that file byte-for-byte; import it before writing the current binary
|
||||
// image so node name, passwords and primary radio survive the Full handoff.
|
||||
#if defined(NRF52_PLATFORM)
|
||||
File legacy(*fs);
|
||||
legacy.open("/prefs.json", FILE_O_READ);
|
||||
#elif defined(STM32_PLATFORM)
|
||||
File legacy = fs->open("/prefs.json", FILE_O_READ);
|
||||
#else
|
||||
File legacy = fs->open("/prefs.json", "r");
|
||||
#endif
|
||||
if (legacy && _prefs->loadSerial(legacy)) {
|
||||
loaded = true;
|
||||
is_upgrade = true;
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
node_prefs_needs_migration = true;
|
||||
#else
|
||||
savePrefs(fs);
|
||||
#endif
|
||||
}
|
||||
if (legacy) legacy.close();
|
||||
} else {
|
||||
// File doesn't exist - set defaults for a fresh install. Dual R1/R2
|
||||
// scanning keeps the node awake, so only that configuration starts with
|
||||
@@ -2579,7 +2601,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
_callbacks->wirelessCommandSource(sender_timestamp))) return;
|
||||
if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return;
|
||||
#if defined(ENABLE_OTA)
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, 160)) return;
|
||||
const auto* ota_context = mesh::ota::ota_context_if_active();
|
||||
const float adaptive_ota_pace = ota_context
|
||||
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
|
||||
if (mesh::ota::handleSpeedCommand(command, reply, 160, adaptive_ota_pace)) return;
|
||||
#endif
|
||||
if (strncmp(command, "set tempradio ", 14) == 0) {
|
||||
handleCommand(sender_timestamp, command + 4, reply);
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace esp32_partition_migration {
|
||||
|
||||
constexpr uint32_t kRequiredFlashBytes = 16U * 1024U * 1024U;
|
||||
constexpr uint32_t kXiaoRequiredFlashBytes = 8U * 1024U * 1024U;
|
||||
constexpr uint32_t kSmallRequiredFlashBytes = 4U * 1024U * 1024U;
|
||||
constexpr uint32_t kPartitionTableAddress = 0x8000;
|
||||
constexpr uint32_t kPartitionTableBytes = 0x1000;
|
||||
constexpr uint32_t kSectorBytes = 0x1000;
|
||||
@@ -59,6 +60,17 @@ constexpr PartitionGeometry kExpanded8MBLayout = {
|
||||
0x670000, 0x180000,
|
||||
};
|
||||
|
||||
// variants/dual_ota_full_4MB.csv. This is the largest practical dual-OTA
|
||||
// layout on a 4 MiB board. It preserves only a 64 KiB SPIFFS region, so the
|
||||
// private identity must be staged in stable NVS before the table is changed.
|
||||
constexpr PartitionGeometry kExpanded4MBLayout = {
|
||||
0x9000, 0x5000,
|
||||
0xE000, 0x2000,
|
||||
0x10000, 0x1F0000,
|
||||
0x200000, 0x1F0000,
|
||||
0x3F0000, 0x10000,
|
||||
};
|
||||
|
||||
constexpr bool sameGeometry(const PartitionGeometry& left,
|
||||
const PartitionGeometry& right) {
|
||||
return left.nvs_address == right.nvs_address
|
||||
@@ -83,7 +95,10 @@ constexpr bool isExpandedLayout(const PartitionGeometry& geometry) {
|
||||
|
||||
constexpr bool canMigrate(uint32_t flash_bytes,
|
||||
const PartitionGeometry& geometry) {
|
||||
return flash_bytes >= kRequiredFlashBytes && isLegacyLayout(geometry);
|
||||
return (flash_bytes == kSmallRequiredFlashBytes
|
||||
|| flash_bytes == kXiaoRequiredFlashBytes
|
||||
|| flash_bytes == kRequiredFlashBytes)
|
||||
&& isLegacyLayout(geometry);
|
||||
}
|
||||
|
||||
// Binary prefix generated from Arduino-ESP32's default_16MB.csv by
|
||||
@@ -160,6 +175,29 @@ constexpr uint8_t kExpanded8MBPartitionTablePrefix[] = {
|
||||
constexpr size_t kExpanded8MBPartitionTablePrefixBytes =
|
||||
sizeof(kExpanded8MBPartitionTablePrefix);
|
||||
|
||||
// Generated from variants/dual_ota_full_4MB.csv with gen_esp32part.py.
|
||||
constexpr uint8_t kExpanded4MBPartitionTablePrefix[] = {
|
||||
0xAA, 0x50, 0x01, 0x02, 0x00, 0x90, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00,
|
||||
0x6E, 0x76, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0x50, 0x01, 0x00,
|
||||
0x00, 0xE0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x6F, 0x74, 0x61, 0x64,
|
||||
0x61, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xAA, 0x50, 0x00, 0x10, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x1F, 0x00, 0x61, 0x70, 0x70, 0x30, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xAA, 0x50, 0x00, 0x11, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x1F, 0x00,
|
||||
0x61, 0x70, 0x70, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0x50, 0x01, 0x82,
|
||||
0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x01, 0x00, 0x73, 0x70, 0x69, 0x66,
|
||||
0x66, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0xEB, 0xEB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0xC5, 0x69, 0x36,
|
||||
0x65, 0xF8, 0xAB, 0x77, 0x12, 0x5A, 0xB8, 0x36, 0xBD, 0x27, 0xF1, 0x1F,
|
||||
};
|
||||
|
||||
constexpr size_t kExpanded4MBPartitionTablePrefixBytes =
|
||||
sizeof(kExpanded4MBPartitionTablePrefix);
|
||||
|
||||
struct TargetPlan {
|
||||
uint32_t flash_bytes;
|
||||
const char* name;
|
||||
@@ -174,6 +212,12 @@ constexpr TargetPlan kTarget8MB = {
|
||||
kExpanded8MBPartitionTablePrefixBytes,
|
||||
};
|
||||
|
||||
constexpr TargetPlan kTarget4MB = {
|
||||
kSmallRequiredFlashBytes, "MeshCore dual_ota_full_4MB",
|
||||
kExpanded4MBLayout, kExpanded4MBPartitionTablePrefix,
|
||||
kExpanded4MBPartitionTablePrefixBytes,
|
||||
};
|
||||
|
||||
constexpr TargetPlan kTarget16MB = {
|
||||
kRequiredFlashBytes, "Arduino default_16MB",
|
||||
kExpandedLayout, kExpandedPartitionTablePrefix,
|
||||
@@ -181,7 +225,8 @@ constexpr TargetPlan kTarget16MB = {
|
||||
};
|
||||
|
||||
constexpr const TargetPlan* targetForFlash(uint32_t flash_bytes) {
|
||||
return flash_bytes == kXiaoRequiredFlashBytes ? &kTarget8MB
|
||||
return flash_bytes == kSmallRequiredFlashBytes ? &kTarget4MB
|
||||
: flash_bytes == kXiaoRequiredFlashBytes ? &kTarget8MB
|
||||
: flash_bytes == kRequiredFlashBytes ? &kTarget16MB
|
||||
: nullptr;
|
||||
}
|
||||
@@ -222,6 +267,79 @@ inline bool canMigrateGeneric(uint32_t flash_bytes,
|
||||
&& !sameGeometry(geometry, plan->layout);
|
||||
}
|
||||
|
||||
// On 8/16 MiB flash the bridge can copy legacy app1 to its new address,
|
||||
// reboot, restore the staged identity, and hand off to the old receiver.
|
||||
// On 4 MiB flash that copy would overwrite legacy app1. The only safe LoRa
|
||||
// route there starts with the old receiver in app0 and the bridge in app1;
|
||||
// the new Full application must restore the NVS-staged identity on first boot.
|
||||
struct LoRaResumePlan {
|
||||
bool valid;
|
||||
uint8_t bridge_slot;
|
||||
uint8_t resume_slot;
|
||||
bool copy_app1;
|
||||
bool restore_identity_in_full;
|
||||
};
|
||||
|
||||
struct LoRaHandoffPlan {
|
||||
bool valid;
|
||||
uint8_t other_slot;
|
||||
bool has_record;
|
||||
};
|
||||
|
||||
// An already-expanded LoRa bridge has no handoff record. It may return to
|
||||
// the opposite slot, but never to itself or to a slot contradicted by a
|
||||
// staged record. Runtime code must still verify that image's identity and
|
||||
// bootability before selecting it.
|
||||
inline LoRaHandoffPlan planLoRaHandoff(const PartitionGeometry& layout,
|
||||
uint32_t running_address,
|
||||
uint8_t staged_slot) {
|
||||
const bool in_app0 = running_address == layout.app0_address;
|
||||
const bool in_app1 = running_address == layout.app1_address;
|
||||
const uint8_t other = in_app0 ? 1 : 0;
|
||||
const bool absent = staged_slot == 0xFF;
|
||||
return {
|
||||
(in_app0 || in_app1) && (absent || staged_slot == other),
|
||||
other,
|
||||
!absent,
|
||||
};
|
||||
}
|
||||
|
||||
constexpr bool overlaps(uint32_t a, uint32_t a_size,
|
||||
uint32_t b, uint32_t b_size) {
|
||||
return static_cast<uint64_t>(a) < static_cast<uint64_t>(b) + b_size
|
||||
&& static_cast<uint64_t>(b) < static_cast<uint64_t>(a) + a_size;
|
||||
}
|
||||
|
||||
inline LoRaResumePlan planLoRaResume(const PartitionGeometry& source,
|
||||
const PartitionGeometry& target,
|
||||
uint32_t running_address) {
|
||||
const bool in_app0 = running_address == source.app0_address;
|
||||
const bool in_app1 = running_address == source.app1_address;
|
||||
const bool common = (in_app0 || in_app1)
|
||||
&& source.app0_address == target.app0_address
|
||||
&& source.app0_size <= target.app0_size
|
||||
&& source.app1_size <= target.app1_size
|
||||
&& !overlaps(source.app0_address, source.app0_size,
|
||||
source.app1_address, source.app1_size)
|
||||
&& !overlaps(source.app0_address, source.app0_size,
|
||||
target.app1_address, target.app1_size);
|
||||
const bool can_copy = common
|
||||
&& !overlaps(source.app1_address, source.app1_size,
|
||||
target.app1_address, source.app1_size);
|
||||
const bool direct_4mb = common && in_app1
|
||||
&& target.app1_address == kExpanded4MBLayout.app1_address
|
||||
&& target.app1_size == kExpanded4MBLayout.app1_size
|
||||
&& !overlaps(source.app0_address, source.app0_size,
|
||||
target.spiffs_address, target.spiffs_size);
|
||||
return {
|
||||
can_copy || direct_4mb,
|
||||
static_cast<uint8_t>(in_app0 ? 0 : 1),
|
||||
static_cast<uint8_t>(in_app0 ? 1 : 0),
|
||||
can_copy,
|
||||
direct_4mb && !can_copy,
|
||||
};
|
||||
}
|
||||
|
||||
static_assert(kExpandedLayout.app0_address + kExpandedLayout.app0_size
|
||||
== kExpandedLayout.app1_address,
|
||||
"expanded OTA slots must be adjacent");
|
||||
@@ -236,9 +354,21 @@ static_assert(kExpanded8MBLayout.app0_address + kExpanded8MBLayout.app0_size
|
||||
static_assert(kExpanded8MBLayout.app1_address + kExpanded8MBLayout.app1_size
|
||||
== kExpanded8MBLayout.spiffs_address,
|
||||
"8 MiB expanded SPIFFS must follow both OTA slots");
|
||||
static_assert(kExpanded4MBLayout.app0_address + kExpanded4MBLayout.app0_size
|
||||
== kExpanded4MBLayout.app1_address,
|
||||
"4 MiB expanded OTA slots must be adjacent");
|
||||
static_assert(kExpanded4MBLayout.app1_address + kExpanded4MBLayout.app1_size
|
||||
== kExpanded4MBLayout.spiffs_address,
|
||||
"4 MiB expanded SPIFFS must follow both OTA slots");
|
||||
static_assert(kExpanded4MBLayout.spiffs_address + kExpanded4MBLayout.spiffs_size
|
||||
== kSmallRequiredFlashBytes,
|
||||
"4 MiB target partitions must fit physical flash");
|
||||
static_assert(kExpandedPartitionTablePrefixBytes
|
||||
== kExpanded8MBPartitionTablePrefixBytes,
|
||||
"target table buffers share one fixed DRAM size");
|
||||
static_assert(kExpanded4MBPartitionTablePrefixBytes
|
||||
<= kExpandedPartitionTablePrefixBytes,
|
||||
"4 MiB partition table must fit the shared DRAM buffer");
|
||||
|
||||
} // namespace esp32_partition_migration
|
||||
} // namespace mesh
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "IdentityStore.h"
|
||||
|
||||
#include "FilePresence.h"
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#include <Preferences.h>
|
||||
#endif
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include "AtomicFileWriter.h"
|
||||
#else
|
||||
@@ -43,6 +46,47 @@ IdentityLoadResult IdentityStore::loadResult(
|
||||
char filename[40];
|
||||
if (snprintf(filename, sizeof(filename), "%s/%s.id", _dir, name)
|
||||
>= (int)sizeof(filename)) return IdentityLoadResult::Unreadable;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
// A 4 MiB LoRa partition migration must boot the old app0 receiver before
|
||||
// the new Full image can run. The bridge staged the original key in stable
|
||||
// NVS; that old receiver may have formatted the newly sized SPIFFS and
|
||||
// generated a temporary key. Restore the original before normal startup
|
||||
// accepts either that temporary key or a missing identity.
|
||||
if (strcmp(_dir, "/identity") == 0 && strcmp(name, "_main") == 0) {
|
||||
Preferences migration_nvs;
|
||||
if (migration_nvs.begin("mesh-pt-migrate", true)) {
|
||||
const bool pending = migration_nvs.getBool("id-pending", false);
|
||||
uint8_t file_bytes[PUB_KEY_SIZE + PRV_KEY_SIZE];
|
||||
const size_t staged = pending
|
||||
? migration_nvs.getBytes("identity", file_bytes, sizeof(file_bytes)) : 0;
|
||||
migration_nvs.end();
|
||||
if (pending) {
|
||||
if (staged != sizeof(file_bytes)) return IdentityLoadResult::Unreadable;
|
||||
uint8_t export_bytes[sizeof(file_bytes)];
|
||||
memcpy(export_bytes, file_bytes + PUB_KEY_SIZE, PRV_KEY_SIZE);
|
||||
memcpy(export_bytes + PRV_KEY_SIZE, file_bytes, PUB_KEY_SIZE);
|
||||
mesh::LocalIdentity original;
|
||||
original.readFrom(export_bytes, sizeof(export_bytes));
|
||||
if (!saveWithRetry(name, original)) return IdentityLoadResult::Unreadable;
|
||||
File restored = _fs->open(filename, "r");
|
||||
uint8_t verified[sizeof(file_bytes)];
|
||||
const bool matches = restored
|
||||
&& restored.read(verified, sizeof(verified)) == sizeof(verified)
|
||||
&& memcmp(file_bytes, verified, sizeof(file_bytes)) == 0;
|
||||
if (restored) restored.close();
|
||||
if (!matches) return IdentityLoadResult::Unreadable;
|
||||
if (!migration_nvs.begin("mesh-pt-migrate", false))
|
||||
return IdentityLoadResult::Unreadable;
|
||||
// Clear the pending flag first: a power cut cannot then leave a
|
||||
// pending flag pointing at already removed key bytes.
|
||||
const bool cleared = migration_nvs.remove("id-pending");
|
||||
if (cleared) migration_nvs.remove("identity");
|
||||
migration_nvs.end();
|
||||
if (!cleared) return IdentityLoadResult::Unreadable;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
for (unsigned attempt = 0; attempt < IO_ATTEMPTS; ++attempt) {
|
||||
if (!recover(name)) continue;
|
||||
bool present = false;
|
||||
|
||||
@@ -147,7 +147,9 @@ static bool is_cmd(const char* a, const char* names, const char** rest) {
|
||||
static bool handle_dev(const char* d, char* reply, OtaContext& c);
|
||||
|
||||
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) {
|
||||
if (handleSpeedCommand(command, reply, 160)) return true;
|
||||
const auto* active = ota_context_if_active();
|
||||
const float adaptive_pace = active ? active->manager.adaptivePacketSpeed() : OTA_SPEED_DEFAULT;
|
||||
if (handleSpeedCommand(command, reply, 160, adaptive_pace)) return true;
|
||||
const char* a = command + 3;
|
||||
if (*a != 0 && *a != ' ') return false;
|
||||
while (*a == ' ') a++;
|
||||
@@ -1002,10 +1004,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
strcpy(reply, "ERR unknown OTA config setting");
|
||||
} else { // show current policy
|
||||
uint8_t af = c.manager.autofetch();
|
||||
char speed[16]; formatSpeed(speed, sizeof(speed));
|
||||
char speed[16], packet[16];
|
||||
formatSpeed(speed, sizeof(speed));
|
||||
formatSpeedFactor(effectivePacketPace(c.manager.adaptivePacketSpeed()), packet, sizeof(packet));
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
bool cache_ready = c.ensureSdCache();
|
||||
snprintf(reply, 160, "ota config: speed=%sx cache=%s/%u autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u", speed,
|
||||
snprintf(reply, 160, "ota config: speed=%sx packet=%sx cache=%s/%u autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u", speed, packet,
|
||||
cache_ready ? (c.sd_cache.autoCaptureEnabled() ? "on" : "off") : "unavailable",
|
||||
cache_ready ? (unsigned)c.sd_cache.capturedCount() : 0,
|
||||
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
|
||||
@@ -1014,11 +1018,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
|
||||
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
|
||||
#else
|
||||
#if defined(OTA_SEEDER_ONLY)
|
||||
snprintf(reply, 160, "ota config: speed=%sx mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u", speed,
|
||||
snprintf(reply, 160, "ota config: speed=%sx packet=%sx mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u", speed, packet,
|
||||
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
|
||||
(unsigned)c.manager.max_hops());
|
||||
#else
|
||||
snprintf(reply, 160, "ota config: speed=%sx autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)", speed,
|
||||
snprintf(reply, 160, "ota config: speed=%sx packet=%sx autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)", speed, packet,
|
||||
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
|
||||
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
|
||||
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
|
||||
|
||||
@@ -94,6 +94,10 @@ void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
|
||||
memset(_src_advertised, 0, sizeof(_src_advertised));
|
||||
_n_src = 0; _n_cat = 0;
|
||||
clearPendingEgress();
|
||||
_adaptive_packet_speed = 1.0f;
|
||||
_pace_clean_blocks = 0;
|
||||
_pace_has_mid = false;
|
||||
_pace_has_loss = false;
|
||||
}
|
||||
|
||||
// ---------------- serve (multi-mota registry) ----------------
|
||||
@@ -682,6 +686,45 @@ bool OtaManager::loadActiveServeBlock() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void OtaManager::noteServedRequestPacing(const uint8_t* mid, uint16_t block,
|
||||
uint16_t want, uint16_t full_mask) {
|
||||
if (want != full_mask) {
|
||||
// A sparse mask is the receiver's explicit evidence that our previous
|
||||
// DATA burst left holes. One extra packet-airtime gap is much cheaper than
|
||||
// repeated multi-second request turns on a fast link.
|
||||
if (!_pace_has_loss || memcmp(_pace_loss_mid, mid, sizeof(_pace_loss_mid)) != 0
|
||||
|| _pace_last_loss_block != block) {
|
||||
memcpy(_pace_loss_mid, mid, sizeof(_pace_loss_mid));
|
||||
_pace_last_loss_block = block;
|
||||
_pace_has_loss = true;
|
||||
if (_adaptive_packet_speed > 0.5f) _adaptive_packet_speed = 0.5f;
|
||||
else if (_adaptive_packet_speed > 0.25f) {
|
||||
_adaptive_packet_speed -= 0.1f;
|
||||
if (_adaptive_packet_speed < 0.25f) _adaptive_packet_speed = 0.25f;
|
||||
}
|
||||
}
|
||||
_pace_clean_blocks = 0;
|
||||
return;
|
||||
}
|
||||
if (!_pace_has_mid || memcmp(_pace_mid, mid, sizeof(_pace_mid)) != 0) {
|
||||
memcpy(_pace_mid, mid, sizeof(_pace_mid));
|
||||
_pace_has_mid = true;
|
||||
_pace_last_full_block = block;
|
||||
_pace_clean_blocks = 0;
|
||||
return;
|
||||
}
|
||||
// Count only forward progress; duplicates of a full request are not proof
|
||||
// that the receiver tolerated the current packet rate.
|
||||
if (block <= _pace_last_full_block) return;
|
||||
_pace_last_full_block = block;
|
||||
if (_adaptive_packet_speed >= 1.0f) return;
|
||||
if (++_pace_clean_blocks >= 16) {
|
||||
_adaptive_packet_speed += 0.1f;
|
||||
if (_adaptive_packet_speed > 1.0f) _adaptive_packet_speed = 1.0f;
|
||||
_pace_clean_blocks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool OtaManager::handleReq(const uint8_t* m, uint16_t n) {
|
||||
ReqWindowMsg rq;
|
||||
if (!decode_req_window(m, n, rq)) return false;
|
||||
@@ -708,8 +751,11 @@ bool OtaManager::handleReq(const uint8_t* m, uint16_t n) {
|
||||
const uint16_t requested = wire_v2
|
||||
? ota_req_v2_fragments(rq.items[i].want_mask) : rq.items[i].want_mask;
|
||||
const uint16_t want = (uint16_t)(requested & valid_mask);
|
||||
if (want != 0) accepted |= queueServeJob(v->m.merkle_root, (uint16_t)idx, want,
|
||||
wire_v2, allow_deflate, extended_length);
|
||||
if (want != 0 && queueServeJob(v->m.merkle_root, (uint16_t)idx, want,
|
||||
wire_v2, allow_deflate, extended_length)) {
|
||||
accepted = true;
|
||||
noteServedRequestPacing(v->m.merkle_root, (uint16_t)idx, want, valid_mask);
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
@@ -1223,7 +1269,22 @@ uint32_t OtaManager::fetchRetryTimeoutMs() const {
|
||||
service = (service * _tx_spacing_permille + 999u) / 1000u;
|
||||
service = (service * 5u + 3u) / 4u; // 25% CAD/relay contention allowance
|
||||
service += OTA_FETCH_RETRY_GUARD_MS;
|
||||
if (service < OTA_FETCH_RETRY_MIN_MS) service = OTA_FETCH_RETRY_MIN_MS;
|
||||
uint64_t floor = OTA_FETCH_RETRY_MIN_MS;
|
||||
if (_observed_path_transmissions != 0 && anyWireDataReceived()) {
|
||||
// A valid fragment proves this source has already answered the current flight.
|
||||
// Missing-fragment requests need not inherit the five-second allowance for
|
||||
// an unanswered host/relay request. Four full-packet service intervals cover
|
||||
// queued DATA/proof/turnaround; measured airtime and the observed path raise
|
||||
// the floor automatically on slower or relayed links.
|
||||
uint64_t partial_floor = (uint64_t)_radio_packet_airtime_ms
|
||||
* _observed_path_transmissions * _tx_spacing_permille * 4u;
|
||||
partial_floor = (partial_floor + 999u) / 1000u;
|
||||
partial_floor += OTA_FETCH_RETRY_GUARD_MS;
|
||||
if (partial_floor < OTA_FETCH_RETRY_PARTIAL_MIN_MS)
|
||||
partial_floor = OTA_FETCH_RETRY_PARTIAL_MIN_MS;
|
||||
if (partial_floor < floor) floor = partial_floor;
|
||||
}
|
||||
if (service < floor) service = floor;
|
||||
if (service > OTA_FETCH_RETRY_MAX_MS) service = OTA_FETCH_RETRY_MAX_MS;
|
||||
return retryDelay((uint32_t)service);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ typedef bool (*ServeDeflateReadFn)(void* ctx, uint16_t block, uint8_t* dst,
|
||||
uint16_t dst_cap, uint16_t* dst_len);
|
||||
|
||||
#ifndef OTA_PROOFGEN_SCRATCH
|
||||
#if defined(OTA_SD_STORE)
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#define OTA_PROOFGEN_SCRATCH 16384 // heap-backed; can seed <=4096 blocks (8 MiB at 2 KiB/block)
|
||||
#elif defined(OTA_SD_STORE)
|
||||
#define OTA_PROOFGEN_SCRATCH 8192 // SD archive can seed <=2048 blocks (about 4 MiB at 2 KiB/block)
|
||||
#else
|
||||
#define OTA_PROOFGEN_SCRATCH 4096 // server proof-gen working buffer (supports up to 1024 blocks)
|
||||
@@ -213,7 +215,10 @@ static constexpr uint16_t ota_max_block_capability() { return (uint16_t)OTA_MAX_
|
||||
#error "OTA_FETCH_PIPELINE_INITIAL must be between 1 and OTA_FETCH_PIPELINE"
|
||||
#endif
|
||||
#ifndef OTA_FETCH_RETRY_MIN_MS
|
||||
#define OTA_FETCH_RETRY_MIN_MS 5000 // quiet-time floor: covers host/queue/turnaround latency on fast links
|
||||
#define OTA_FETCH_RETRY_MIN_MS 5000 // unanswered/empty flight: allow host and relay turnaround
|
||||
#endif
|
||||
#ifndef OTA_FETCH_RETRY_PARTIAL_MIN_MS
|
||||
#define OTA_FETCH_RETRY_PARTIAL_MIN_MS 1500 // after valid DATA, recover missing holes promptly on fast links
|
||||
#endif
|
||||
#ifndef OTA_FETCH_RETRY_MAX_MS
|
||||
#define OTA_FETCH_RETRY_MAX_MS 60000 // bound loss recovery when configured radio settings are impractical
|
||||
@@ -407,6 +412,9 @@ public:
|
||||
void set_clock(uint32_t ms) { _now_ms = ms; }
|
||||
bool set_speed(float speed);
|
||||
float speed() const { return _speed; }
|
||||
// Sender-only, loss-responsive packet pacing. This does not alter discovery,
|
||||
// proof, or retry timers; the user's persisted speed remains an upper bound.
|
||||
float adaptivePacketSpeed() const { return _adaptive_packet_speed; }
|
||||
uint32_t pacedDelay(uint32_t ms) const { return scaleDelay(ms, _speed); }
|
||||
// Faster pacing cannot shorten a loss-recovery deadline below the existing
|
||||
// physical packet-flight allowance. Slower pacing expands that allowance.
|
||||
@@ -618,6 +626,8 @@ private:
|
||||
bool queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want_mask,
|
||||
bool wire_v2 = false, bool allow_deflate = false,
|
||||
bool extended_length = false);
|
||||
void noteServedRequestPacing(const uint8_t* mid, uint16_t block,
|
||||
uint16_t want, uint16_t full_mask);
|
||||
bool queueManifestJob(const uint8_t* mid, uint16_t want_mask);
|
||||
uint32_t manifestEgressGapMs() const;
|
||||
uint32_t proofEgressGapMs() const;
|
||||
@@ -697,6 +707,14 @@ private:
|
||||
};
|
||||
ServeJob _serve_jobs[OTA_SERVE_QUEUE];
|
||||
uint8_t _n_serve_jobs = 0;
|
||||
float _adaptive_packet_speed = 1.0f;
|
||||
uint8_t _pace_clean_blocks = 0;
|
||||
uint8_t _pace_mid[4] = {0};
|
||||
uint16_t _pace_last_full_block = 0;
|
||||
bool _pace_has_mid = false;
|
||||
uint8_t _pace_loss_mid[4] = {0};
|
||||
uint16_t _pace_last_loss_block = 0;
|
||||
bool _pace_has_loss = false;
|
||||
struct ManifestServeJob {
|
||||
OtaReplyRoute route;
|
||||
uint8_t mid[4];
|
||||
|
||||
@@ -156,8 +156,8 @@ void beginSpeedConfig(FILESYSTEM* fs) {
|
||||
|
||||
float speedFactor() { return factor; }
|
||||
|
||||
void formatSpeed(char* text, size_t capacity) {
|
||||
const uint32_t scaled = (uint32_t)(factor * 1000000.0f + 0.5f);
|
||||
void formatSpeedFactor(float speed, char* text, size_t capacity) {
|
||||
const uint32_t scaled = (uint32_t)(speed * 1000000.0f + 0.5f);
|
||||
snprintf(text, capacity, "%u.%06u", (unsigned)(scaled / 1000000), (unsigned)(scaled % 1000000));
|
||||
if (!capacity) return;
|
||||
size_t n = strlen(text);
|
||||
@@ -165,7 +165,17 @@ void formatSpeed(char* text, size_t capacity) {
|
||||
if (n && text[n - 1] == '.') text[n - 1] = 0;
|
||||
}
|
||||
|
||||
bool handleSpeedCommand(const char* command, char* reply, size_t capacity) {
|
||||
void formatSpeed(char* text, size_t capacity) {
|
||||
formatSpeedFactor(factor, text, capacity);
|
||||
}
|
||||
|
||||
float effectivePacketPace(float adaptive_speed) {
|
||||
if (!validSpeed(adaptive_speed)) adaptive_speed = OTA_SPEED_DEFAULT;
|
||||
return factor < adaptive_speed ? factor : adaptive_speed;
|
||||
}
|
||||
|
||||
bool handleSpeedCommand(const char* command, char* reply, size_t capacity,
|
||||
float adaptive_speed) {
|
||||
const char* value = nullptr;
|
||||
bool require_value = false, read_only = false;
|
||||
const char* const forms[] = {"set ota.speed", "get ota.speed", "ota config speed", "ota cfg speed", "ota set speed", "ota speed"};
|
||||
@@ -194,8 +204,11 @@ bool handleSpeedCommand(const char* command, char* reply, size_t capacity) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
char number[16]; formatSpeed(number, sizeof(number));
|
||||
snprintf(reply, capacity, *value ? "OK ota.speed=%sx (saved)" : "> ota.speed=%sx", number);
|
||||
char number[16], packet[16];
|
||||
formatSpeed(number, sizeof(number));
|
||||
formatSpeedFactor(effectivePacketPace(adaptive_speed), packet, sizeof(packet));
|
||||
if (*value) snprintf(reply, capacity, "OK ota.speed=%sx (saved) packet=%sx", number, packet);
|
||||
else snprintf(reply, capacity, "> ota.speed=%sx packet=%sx", number, packet);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ namespace mesh { namespace ota {
|
||||
void beginSpeedConfig(FILESYSTEM* fs);
|
||||
float speedFactor();
|
||||
void formatSpeed(char* text, size_t capacity);
|
||||
bool handleSpeedCommand(const char* command, char* reply, size_t capacity);
|
||||
void formatSpeedFactor(float speed, char* text, size_t capacity);
|
||||
// Automatic sender pacing only adds packet quiet time. The saved setting is
|
||||
// still the upper bound and continues to control the other OTA timers.
|
||||
float effectivePacketPace(float adaptive_speed);
|
||||
bool handleSpeedCommand(const char* command, char* reply, size_t capacity,
|
||||
float adaptive_speed = OTA_SPEED_DEFAULT);
|
||||
|
||||
} }
|
||||
@@ -34,6 +34,7 @@ struct Manager {
|
||||
uint8_t max_hops() const { return hops; }
|
||||
uint16_t checkpoint_blocks() const { return checkpoint; }
|
||||
uint16_t advert_mins() const { return advert; }
|
||||
float adaptivePacketSpeed() const { return 0.5f; }
|
||||
void set_autofetch(uint8_t v) { af=v; }
|
||||
void set_max_hops(uint8_t v) { hops=v; }
|
||||
void set_checkpoint_blocks(uint16_t v) { checkpoint=v; }
|
||||
@@ -53,6 +54,8 @@ static bool ota_acquire_context(char* reply, size_t cap) {
|
||||
static OtaContext& ota_ctx() { return context; }
|
||||
static OtaContext* ota_context_if_active() { return &context; }
|
||||
static void formatSpeed(char* text, size_t) { strcpy(text, "1"); }
|
||||
static float effectivePacketPace(float adaptive) { return adaptive; }
|
||||
static void formatSpeedFactor(float factor, char* text, size_t cap) { snprintf(text, cap, "%g", factor); }
|
||||
@IS_CMD@
|
||||
static bool config(const char* rest, char* reply, OtaContext& c) {
|
||||
@CONFIG@
|
||||
@@ -111,6 +114,7 @@ int main() {
|
||||
assert(!context.config_dirty);
|
||||
};
|
||||
reboot(fs);
|
||||
command("ota config", "ota config: speed=1x packet=0.5x");
|
||||
for (const auto* setting : {"hops", "checkpoint", "advert"}) {
|
||||
const auto before=OtaConfigState::capture(context);
|
||||
for (const auto* bad : {"", " ", "x", "-1", "1x", "1 2", "1.0", "4294967296", "9999999999999999999999"}) {
|
||||
|
||||
+82
-5
@@ -4,12 +4,16 @@
|
||||
|
||||
namespace policy = mesh::esp32_partition_migration;
|
||||
|
||||
TEST(Esp32PartitionMigrationPolicy, AcceptsOnlyTheKnownLegacyV4Layout) {
|
||||
TEST(Esp32PartitionMigrationPolicy, AcceptsOnlyKnownFlashSizesWithLegacyLayout) {
|
||||
EXPECT_TRUE(policy::isLegacyLayout(policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrate(policy::kSmallRequiredFlashBytes,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrate(policy::kXiaoRequiredFlashBytes,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrate(policy::kRequiredFlashBytes,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrate(policy::kRequiredFlashBytes + 1,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_FALSE(policy::canMigrate(policy::kRequiredFlashBytes + 1,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_FALSE(policy::canMigrate(policy::kRequiredFlashBytes - 1,
|
||||
policy::kLegacyLayout));
|
||||
|
||||
@@ -33,14 +37,19 @@ TEST(Esp32PartitionMigrationPolicy, ExpandedLayoutRetainsAndGrowsSpiffs) {
|
||||
policy::kExpandedLayout.spiffs_address);
|
||||
}
|
||||
|
||||
TEST(Esp32PartitionMigrationPolicy, GenericPlansAcceptStableEightAndSixteenMiBLayouts) {
|
||||
TEST(Esp32PartitionMigrationPolicy, GenericPlansAcceptStableFourEightAndSixteenMiBLayouts) {
|
||||
EXPECT_EQ(&policy::kTarget4MB, policy::targetForFlash(4U * 1024U * 1024U));
|
||||
EXPECT_EQ(&policy::kTarget8MB, policy::targetForFlash(8U * 1024U * 1024U));
|
||||
EXPECT_EQ(&policy::kTarget16MB, policy::targetForFlash(16U * 1024U * 1024U));
|
||||
EXPECT_EQ(nullptr, policy::targetForFlash(4U * 1024U * 1024U));
|
||||
EXPECT_EQ(nullptr, policy::targetForFlash(2U * 1024U * 1024U));
|
||||
EXPECT_TRUE(policy::isTargetLayout(4U * 1024U * 1024U,
|
||||
policy::kExpanded4MBLayout));
|
||||
EXPECT_TRUE(policy::isTargetLayout(8U * 1024U * 1024U,
|
||||
policy::kExpanded8MBLayout));
|
||||
EXPECT_TRUE(policy::canMigrateGeneric(8U * 1024U * 1024U,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrateGeneric(4U * 1024U * 1024U,
|
||||
policy::kLegacyLayout));
|
||||
EXPECT_TRUE(policy::canMigrateGeneric(16U * 1024U * 1024U,
|
||||
policy::kLegacyLayout));
|
||||
|
||||
@@ -55,6 +64,71 @@ TEST(Esp32PartitionMigrationPolicy, GenericPlansAcceptStableEightAndSixteenMiBLa
|
||||
out_of_bounds));
|
||||
}
|
||||
|
||||
TEST(Esp32PartitionMigrationPolicy, LoRaResumePreservesTheOldApplicationInEitherSlot) {
|
||||
for (const auto& target : {policy::kExpanded8MBLayout,
|
||||
policy::kExpandedLayout}) {
|
||||
const auto from_a = policy::planLoRaResume(policy::kLegacyLayout, target,
|
||||
policy::kLegacyLayout.app0_address);
|
||||
EXPECT_TRUE(from_a.valid);
|
||||
EXPECT_EQ(0, from_a.bridge_slot);
|
||||
EXPECT_EQ(1, from_a.resume_slot);
|
||||
EXPECT_TRUE(from_a.copy_app1);
|
||||
EXPECT_FALSE(from_a.restore_identity_in_full);
|
||||
|
||||
const auto from_b = policy::planLoRaResume(policy::kLegacyLayout, target,
|
||||
policy::kLegacyLayout.app1_address);
|
||||
EXPECT_TRUE(from_b.valid);
|
||||
EXPECT_EQ(1, from_b.bridge_slot);
|
||||
EXPECT_EQ(0, from_b.resume_slot);
|
||||
EXPECT_TRUE(from_b.copy_app1);
|
||||
EXPECT_FALSE(from_b.restore_identity_in_full);
|
||||
}
|
||||
|
||||
auto overlapping = policy::kExpanded8MBLayout;
|
||||
overlapping.app1_address = policy::kLegacyLayout.app1_address;
|
||||
EXPECT_FALSE(policy::planLoRaResume(policy::kLegacyLayout, overlapping,
|
||||
policy::kLegacyLayout.app0_address).valid);
|
||||
EXPECT_FALSE(policy::planLoRaResume(policy::kLegacyLayout,
|
||||
policy::kExpandedLayout, 0x300000).valid);
|
||||
EXPECT_FALSE(policy::planLoRaResume(policy::kLegacyLayout,
|
||||
policy::kExpanded4MBLayout,
|
||||
policy::kLegacyLayout.app0_address).valid);
|
||||
const auto small_from_b = policy::planLoRaResume(
|
||||
policy::kLegacyLayout, policy::kExpanded4MBLayout,
|
||||
policy::kLegacyLayout.app1_address);
|
||||
EXPECT_TRUE(small_from_b.valid);
|
||||
EXPECT_EQ(1, small_from_b.bridge_slot);
|
||||
EXPECT_EQ(0, small_from_b.resume_slot);
|
||||
EXPECT_FALSE(small_from_b.copy_app1);
|
||||
EXPECT_TRUE(small_from_b.restore_identity_in_full);
|
||||
}
|
||||
|
||||
TEST(Esp32PartitionMigrationPolicy, ExpandedLoRaHandoffUsesOnlyTheOppositeSlot) {
|
||||
for (const auto& layout : {policy::kExpanded4MBLayout,
|
||||
policy::kExpanded8MBLayout,
|
||||
policy::kExpandedLayout}) {
|
||||
const auto no_record_from_a = policy::planLoRaHandoff(
|
||||
layout, layout.app0_address, 0xFF);
|
||||
EXPECT_TRUE(no_record_from_a.valid);
|
||||
EXPECT_EQ(1, no_record_from_a.other_slot);
|
||||
EXPECT_FALSE(no_record_from_a.has_record);
|
||||
|
||||
const auto no_record_from_b = policy::planLoRaHandoff(
|
||||
layout, layout.app1_address, 0xFF);
|
||||
EXPECT_TRUE(no_record_from_b.valid);
|
||||
EXPECT_EQ(0, no_record_from_b.other_slot);
|
||||
EXPECT_FALSE(no_record_from_b.has_record);
|
||||
|
||||
const auto recorded = policy::planLoRaHandoff(
|
||||
layout, layout.app1_address, 0);
|
||||
EXPECT_TRUE(recorded.valid);
|
||||
EXPECT_TRUE(recorded.has_record);
|
||||
EXPECT_FALSE(policy::planLoRaHandoff(layout, layout.app1_address, 1).valid);
|
||||
EXPECT_FALSE(policy::planLoRaHandoff(layout, layout.app0_address, 2).valid);
|
||||
EXPECT_FALSE(policy::planLoRaHandoff(layout, 0x300000, 0xFF).valid);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(Esp32PartitionMigrationPolicy, GeneratedTablePrefixContainsAllSixEntriesAndMd5) {
|
||||
EXPECT_EQ(0xE0U, policy::kExpandedPartitionTablePrefixBytes);
|
||||
EXPECT_EQ(0xAA, policy::kExpandedPartitionTablePrefix[0]);
|
||||
@@ -68,6 +142,9 @@ TEST(Esp32PartitionMigrationPolicy, GeneratedTablePrefixContainsAllSixEntriesAnd
|
||||
EXPECT_EQ(0x33, policy::kExpanded8MBPartitionTablePrefix[0x4A]);
|
||||
EXPECT_EQ(0x34, policy::kExpanded8MBPartitionTablePrefix[0x66]);
|
||||
EXPECT_EQ(0x67, policy::kExpanded8MBPartitionTablePrefix[0x86]);
|
||||
EXPECT_EQ(0xC0U, policy::kExpanded4MBPartitionTablePrefixBytes);
|
||||
EXPECT_EQ(0xEB, policy::kExpanded4MBPartitionTablePrefix[0xA0]);
|
||||
EXPECT_EQ(0xEB, policy::kExpanded4MBPartitionTablePrefix[0xA1]);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""No-hardware contract tests for the serial ESP32 migration build recipe."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from build_esp32_partition_migration import ( # noqa: E402
|
||||
build_steps, bundle_release, verify_archive,
|
||||
)
|
||||
from package_esp32_partition_migration import ( # noqa: E402
|
||||
BOARDS, bridge_with_successor_endf, mota_full, readme,
|
||||
)
|
||||
from motalib import FwIdent, build_endf, parse_endf_ident # noqa: E402
|
||||
|
||||
|
||||
class Esp32MigrationRecipeTest(unittest.TestCase):
|
||||
def test_catalog_covers_all_three_roles_and_exact_flash_plans(self):
|
||||
self.assertEqual({"repeater", "room-server", "sensor"},
|
||||
{spec["role"] for spec in BOARDS.values()})
|
||||
self.assertIn("heltec-v4-sensor", BOARDS)
|
||||
self.assertIn("xiao-s3-wio-sensor", BOARDS)
|
||||
self.assertIn("heltec-ct62-sensor", BOARDS)
|
||||
self.assertIn("nibble-zero-room-server", BOARDS)
|
||||
self.assertEqual("esp32_c3_4mb_partition_migrator",
|
||||
BOARDS["heltec-ct62-sensor"]["wifi_bridge"])
|
||||
self.assertEqual("esp32_4mb_partition_migrator",
|
||||
BOARDS["generic-e22-sx1262-repeater"]["wifi_bridge"])
|
||||
self.assertEqual("esp32_s3_8mb_partition_migrator",
|
||||
BOARDS["heltec-v3-sensor"]["wifi_bridge"])
|
||||
self.assertEqual("heltec_v4_partition_expander",
|
||||
BOARDS["heltec-v4"]["expander_bridge"])
|
||||
self.assertEqual("Xiao_S3_WIO_partition_expander",
|
||||
BOARDS["xiao-s3-wio"]["expander_bridge"])
|
||||
self.assertEqual("esp32_8mb_partition_migrator",
|
||||
BOARDS["heltec-v2-room-server"]["wifi_bridge"])
|
||||
expected_slots = {4 * 1024 * 1024: 0x1F0000,
|
||||
8 * 1024 * 1024: 0x330000,
|
||||
16 * 1024 * 1024: 0x640000}
|
||||
env_names = set()
|
||||
for config in (ROOT / "variants").glob("*/platformio.ini"):
|
||||
env_names.update(re.findall(r"^\[env:([^]]+)\]",
|
||||
config.read_text(), re.MULTILINE))
|
||||
for board, spec in BOARDS.items():
|
||||
with self.subTest(board=board):
|
||||
self.assertIn(spec["target"], env_names)
|
||||
self.assertIn(spec["wifi_bridge"], env_names)
|
||||
self.assertIn(spec["lora_bridge"], env_names)
|
||||
if spec.get("expander_bridge"):
|
||||
self.assertIn(spec["expander_bridge"], env_names)
|
||||
self.assertEqual(expected_slots[spec["flash_bytes"]],
|
||||
spec["slot_bytes"])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX shell menu runs in WSL/Linux")
|
||||
def test_shell_menu_and_noninteractive_selection(self):
|
||||
script = str(ROOT / "scripts/build_esp32_partition_migration.sh")
|
||||
common = ["--version", "v1.17.1.7-test",
|
||||
"--radio-preset", "usa-cascadia", "--dry-run"]
|
||||
one = subprocess.run(["sh", script, "--board", "heltec-v4", *common],
|
||||
cwd=ROOT, text=True, capture_output=True, check=True)
|
||||
self.assertIn("build-firmware heltec_v4_repeater", one.stdout)
|
||||
self.assertNotIn("build-firmware Xiao_S3_WIO_repeater", one.stdout)
|
||||
self.assertNotIn("Bundle all configured board/role recipes", one.stdout)
|
||||
all_boards = subprocess.run(["sh", script, "--all", *common],
|
||||
cwd=ROOT, text=True, capture_output=True, check=True)
|
||||
self.assertIn("build-firmware Xiao_S3_WIO_repeater", all_boards.stdout)
|
||||
self.assertIn("build-firmware ThinkNode_M2_Repeater", all_boards.stdout)
|
||||
self.assertIn("Bundle all configured board/role recipes", all_boards.stdout)
|
||||
sensors = subprocess.run(["sh", script, "--role", "sensor", *common],
|
||||
cwd=ROOT, text=True, capture_output=True,
|
||||
check=True)
|
||||
self.assertIn("build-firmware Xiao_S3_WIO_sensor", sensors.stdout)
|
||||
self.assertNotIn("build-firmware Xiao_S3_WIO_repeater", sensors.stdout)
|
||||
self.assertNotIn("Bundle all configured board/role recipes", sensors.stdout)
|
||||
interactive = subprocess.run(["sh", script], input="q\n",
|
||||
cwd=ROOT, text=True, capture_output=True,
|
||||
check=True)
|
||||
self.assertIn("ESP32 in-place partition migration", interactive.stdout)
|
||||
invalid = subprocess.run(["sh", script, "--board", "unknown", *common],
|
||||
cwd=ROOT, text=True, capture_output=True)
|
||||
self.assertNotEqual(0, invalid.returncode)
|
||||
self.assertIn("not configured", invalid.stderr)
|
||||
|
||||
def test_full_images_precede_every_bridge_and_builds_are_serial(self):
|
||||
steps = build_steps(["heltec-v4", "xiao-s3-wio"], "v1.17.1.7-test",
|
||||
"usa-cascadia", "cascade", 4)
|
||||
self.assertEqual(8, len(steps))
|
||||
self.assertEqual([True, True] + [False] * 6,
|
||||
[full for _, full in steps])
|
||||
self.assertEqual("heltec_v4_repeater", steps[0][0][3])
|
||||
self.assertEqual("Xiao_S3_WIO_repeater", steps[1][0][3])
|
||||
self.assertTrue(all("--full-exact" in command for command, _ in steps[:2]))
|
||||
self.assertEqual([
|
||||
"heltec_v4_partition_migrator",
|
||||
"heltec_v4_partition_migrator_lora_repeater",
|
||||
"heltec_v4_partition_expander",
|
||||
"xiao_s3_partition_migrator",
|
||||
"xiao_s3_partition_migrator_lora_repeater",
|
||||
"Xiao_S3_WIO_partition_expander",
|
||||
], [command[3] for command, _ in steps[2:]])
|
||||
guide = readme("heltec-v4", BOARDS["heltec-v4"],
|
||||
"v1.17.1.7-test", "01234567", full_mota_blocks=960)
|
||||
self.assertIn("partition-expander.mota", guide)
|
||||
self.assertIn("automatically", guide)
|
||||
self.assertIn("already-expanded board", guide)
|
||||
|
||||
def test_4mb_roles_share_two_chip_family_bridges(self):
|
||||
steps = build_steps(["thinknode-m2-repeater", "thinknode-m2-room-server"],
|
||||
"v1.17.1.7-test", "usa-cascadia", "cascade", 4)
|
||||
self.assertEqual([True, True, False, False], [full for _, full in steps])
|
||||
self.assertEqual("ThinkNode_M2_Repeater", steps[0][0][3])
|
||||
self.assertEqual("ThinkNode_M2_room_server", steps[1][0][3])
|
||||
self.assertEqual("esp32_s3_4mb_partition_migrator", steps[2][0][3])
|
||||
self.assertEqual("esp32_s3_4mb_partition_migrator_lora", steps[3][0][3])
|
||||
guide = readme("thinknode-m2-repeater", BOARDS["thinknode-m2-repeater"],
|
||||
"v1.17.1.7-test", "01234567", full_mota_blocks=900)
|
||||
self.assertIn("old slot B", guide)
|
||||
self.assertIn("original private key from NVS", guide)
|
||||
self.assertIn("accidentally installed on a node that already has", guide)
|
||||
self.assertIn("power loss during", guide)
|
||||
self.assertIn("exact\n `Started:` URL", guide)
|
||||
self.assertIn("ota pull <id>", guide)
|
||||
large_guide = readme("heltec-v3-sensor", BOARDS["heltec-v3-sensor"],
|
||||
"v1.17.1.7-test", "01234567", full_mota_blocks=1400)
|
||||
self.assertIn("5600 bytes of proof scratch", large_guide)
|
||||
|
||||
def test_sensor_and_room_roles_share_the_8mb_bridge(self):
|
||||
steps = build_steps(["xiao-s3-wio-sensor", "xiao-s3-wio-room-server"],
|
||||
"v1.17.1.7-test", "usa-cascadia", "cascade", 4)
|
||||
self.assertEqual([True, True, False, False], [full for _, full in steps])
|
||||
self.assertEqual("Xiao_S3_WIO_sensor", steps[0][0][3])
|
||||
self.assertEqual("Xiao_S3_WIO_room_server", steps[1][0][3])
|
||||
self.assertEqual("esp32_s3_8mb_partition_migrator", steps[2][0][3])
|
||||
self.assertEqual("esp32_s3_8mb_partition_migrator_lora", steps[3][0][3])
|
||||
|
||||
def test_full_mota_can_exceed_old_seeder_scratch_limit(self):
|
||||
image = b"\xE9" + b"\0" * (1024 * 2048)
|
||||
package = mota_full(image, FwIdent(0x01020304, 0xAABBCCDD, "TEST"), 2048)
|
||||
self.assertGreater(len(package), len(image))
|
||||
|
||||
def test_bridge_mota_rebinds_preexisting_temporary_role_endf(self):
|
||||
body = b"\xe9" + b"bridge image"
|
||||
temporary = body + build_endf(body, FwIdent(1, 0x12345678, "BOARD"))
|
||||
successor = FwIdent(2, 0x87654321, "BOARD")
|
||||
rebound = bridge_with_successor_endf(temporary, successor)
|
||||
self.assertEqual(len(temporary), len(rebound))
|
||||
self.assertEqual(successor, parse_endf_ident(rebound))
|
||||
self.assertEqual(body, rebound[:-56])
|
||||
|
||||
def test_dry_run_has_no_build_side_effects(self):
|
||||
with tempfile.TemporaryDirectory(prefix="esp32-migration-plan-") as temporary:
|
||||
output_root = Path(temporary) / "release"
|
||||
result = subprocess.run([
|
||||
sys.executable, "-B",
|
||||
str(ROOT / "scripts/build_esp32_partition_migration.py"),
|
||||
"--version", "v1.17.1.7-test", "--radio-preset", "usa-cascadia",
|
||||
"--board", "heltec-v4", "--output-root", str(output_root),
|
||||
"--dry-run",
|
||||
], cwd=ROOT, text=True, capture_output=True, check=True)
|
||||
self.assertIn("build-firmware heltec_v4_repeater", result.stdout)
|
||||
self.assertIn("pio run -e heltec_v4_partition_migrator", result.stdout)
|
||||
self.assertFalse(output_root.exists())
|
||||
|
||||
def test_partition_expanders_use_adaptive_four_block_transfer_window(self):
|
||||
for variant in ("heltec_v4", "xiao_s3_wio"):
|
||||
config = (ROOT / "variants" / variant / "platformio.ini").read_text()
|
||||
expander = config.split("partition_expander]", 1)[1].split("\n[env:", 1)[0]
|
||||
self.assertIn("-D OTA_FETCH_PIPELINE=4", expander)
|
||||
|
||||
def test_archive_verification_rejects_changed_payload(self):
|
||||
with tempfile.TemporaryDirectory(prefix="esp32-migration-zip-") as temporary:
|
||||
archive_path = Path(temporary) / "test.zip"
|
||||
payload = b"correct image"
|
||||
manifest = {
|
||||
"board": "heltec-v4", "firmware_version": "v1.17.1.7-test",
|
||||
"source_commit": "01234567", "files": {
|
||||
"full-application.bin": {
|
||||
"bytes": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
}
|
||||
},
|
||||
}
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("full-application.bin", payload)
|
||||
verify_archive(archive_path, "heltec-v4", "v1.17.1.7-test", "01234567")
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("full-application.bin", b"damaged image")
|
||||
with self.assertRaisesRegex(ValueError, "invalid full-application.bin"):
|
||||
verify_archive(archive_path, "heltec-v4", "v1.17.1.7-test", "01234567")
|
||||
|
||||
def test_all_bundle_contains_only_verified_board_packages(self):
|
||||
with tempfile.TemporaryDirectory(prefix="esp32-migration-release-") as temporary:
|
||||
output_root = Path(temporary)
|
||||
package_dir = output_root / "packages"
|
||||
package_dir.mkdir()
|
||||
version = "v1.17.1.7-test"
|
||||
source = "01234567"
|
||||
sample_boards = ["heltec-v4", "xiao-s3-wio-room-server",
|
||||
"heltec-ct62-sensor"]
|
||||
for board in sample_boards:
|
||||
payload = (board + " image").encode()
|
||||
manifest = {
|
||||
"board": board, "firmware_version": version,
|
||||
"source_commit": source, "files": {
|
||||
"full-application.bin": {
|
||||
"bytes": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
}
|
||||
},
|
||||
}
|
||||
archive_path = package_dir / f"{board}-{version}-{source}-migration.zip"
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("full-application.bin", payload)
|
||||
release = bundle_release(output_root, package_dir,
|
||||
sample_boards,
|
||||
version, source, "usa-cascadia", "cascade")
|
||||
with zipfile.ZipFile(release) as archive:
|
||||
self.assertIsNone(archive.testzip())
|
||||
release_manifest = json.loads(archive.read("release-manifest.json"))
|
||||
self.assertEqual(sample_boards,
|
||||
release_manifest["boards"])
|
||||
self.assertEqual(["repeater", "room-server", "sensor"],
|
||||
release_manifest["roles"])
|
||||
self.assertEqual("repeater",
|
||||
release_manifest["board_roles"]["heltec-v4"])
|
||||
self.assertEqual("room-server", release_manifest["board_roles"][
|
||||
"xiao-s3-wio-room-server"])
|
||||
self.assertEqual("sensor", release_manifest["board_roles"][
|
||||
"heltec-ct62-sensor"])
|
||||
for name, checks in release_manifest["packages"].items():
|
||||
data = archive.read(name)
|
||||
self.assertEqual(checks["bytes"], len(data))
|
||||
self.assertEqual(checks["sha256"], hashlib.sha256(data).hexdigest())
|
||||
self.assertEqual(release, bundle_release(
|
||||
output_root, package_dir, sample_boards,
|
||||
version, source, "usa-cascadia", "cascade"))
|
||||
(package_dir / f"heltec-ct62-sensor-{version}-{source}-migration.zip").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "missing heltec-ct62-sensor"):
|
||||
bundle_release(output_root, package_dir,
|
||||
sample_boards,
|
||||
version, source, "usa-cascadia", "cascade")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -296,6 +296,21 @@ const fullUsbLogging = picker.parseTargetProfile(
|
||||
assert.strictEqual(fullUsbLogging.mode, "usb");
|
||||
assert.strictEqual(fullUsbLogging.variant, "default");
|
||||
|
||||
const partitionExpander = picker.parseTargetProfile(
|
||||
"Xiao_S3_WIO_partition_expander"
|
||||
);
|
||||
assert.strictEqual(partitionExpander.role, "partition-expander");
|
||||
assert.strictEqual(partitionExpander.hardware, "Xiao_S3_WIO");
|
||||
assert.strictEqual(partitionExpander.explicitOta, "lora-receiver");
|
||||
assert.strictEqual(picker.ROLE_LABELS[partitionExpander.role],
|
||||
"Partition Expander");
|
||||
assert(picker.installSteps(partitionExpander, "zip").some(function (step) {
|
||||
return step.includes("not an nRF52 Serial DFU package");
|
||||
}));
|
||||
assert(picker.installSteps(partitionExpander, "bin").some(function (step) {
|
||||
return step.includes("temporary bridge");
|
||||
}));
|
||||
|
||||
const heltecV4Full = picker.parseTargetProfile(
|
||||
"heltec_v4_2_v4_3_companion_radio_full_femon"
|
||||
);
|
||||
|
||||
@@ -42,6 +42,24 @@ assert_auto_unified Heltec_T190_repeater_ Heltec_T190_repeater_observer_mqtt
|
||||
assert_auto_unified LilyGo_TLora_V2_1_1_6_room_server LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_
|
||||
assert_auto_unified heltec_v4_r8_repeater_observer_mqtt heltec_v4_r8_repeater_observer_mqtt
|
||||
|
||||
# A one-board migration package must retain the old repeater's mOTA identity,
|
||||
# even when its Full sources come from the matching observer environment.
|
||||
(
|
||||
BUILD_PROFILE_OVERRIDE=full
|
||||
BUILD_PROFILE_EXPLICIT=1
|
||||
SINGLE_TARGET_FULL_BUILD=1
|
||||
EXACT_IDENTITY_FULL_BUILD=1
|
||||
RESOLVED_BUILD_TARGETS=(heltec_v4_repeater)
|
||||
configure_effective_build_profile build-firmware >/dev/null
|
||||
[ "${RESOLVED_BUILD_TARGETS[*]}" = heltec_v4_repeater ] \
|
||||
|| fail "exact Full target changed identity"
|
||||
run_logged_build_targets() {
|
||||
[ "${*}" = heltec_v4_repeater ] || fail "exact Full built a different target"
|
||||
[ "$ESP32_FULL_BUILD" = 1 ] || fail "exact Full lost expanded partitions"
|
||||
}
|
||||
run_command build-firmware >/dev/null
|
||||
)
|
||||
|
||||
# Only targets whose standard and FULL board recipes retain the exact same
|
||||
# partition table may omit their redundant portable bulk artifact.
|
||||
full_only_count=0
|
||||
@@ -157,7 +175,7 @@ done
|
||||
observed_filename=""
|
||||
observed_output=""
|
||||
build_artifacts_exist() {
|
||||
observed_filename=$2
|
||||
observed_filename=$3
|
||||
observed_output="$PACKET_LOGGING_OVERRIDE/$MQTT_BRIDGE_OVERRIDE/$MESHDEBUG_OVERRIDE/$MQTT_DEBUG_OVERRIDE"
|
||||
return 0
|
||||
}
|
||||
@@ -183,7 +201,7 @@ done
|
||||
observed_filename=""
|
||||
observed_full=0
|
||||
build_artifacts_exist() {
|
||||
observed_filename=$2
|
||||
observed_filename=$3
|
||||
observed_full=$ESP32_FULL_BUILD
|
||||
return 0
|
||||
}
|
||||
@@ -203,7 +221,7 @@ done
|
||||
FIRMWARE_FILENAME_INFIX=""
|
||||
observed_filename=""
|
||||
build_artifacts_exist() {
|
||||
observed_filename=$2
|
||||
observed_filename=$3
|
||||
return 0
|
||||
}
|
||||
build_firmware heltec_v4_r8_repeater >/dev/null
|
||||
|
||||
@@ -15,8 +15,10 @@ PROFILE = ROOT / "variants/heltec_v4/platformio.ini"
|
||||
MIGRATOR_BOARD = ROOT / "boards/heltec_v4_migrator.json"
|
||||
XIAO_PROFILE = ROOT / "variants/xiao_s3_wio/platformio.ini"
|
||||
XIAO_MIGRATOR_BOARD = ROOT / "boards/seeed_xiao_esp32s3_migrator.json"
|
||||
EXPANDER = ROOT / "examples/partition_expander/main.cpp"
|
||||
PARTITIONS = Path.home() / ".platformio/packages/framework-arduinoespressif32/tools/partitions/default_16MB.csv"
|
||||
PARTITIONS_8MB = Path.home() / ".platformio/packages/framework-arduinoespressif32/tools/partitions/default_8MB.csv"
|
||||
PARTITIONS_4MB = ROOT / "variants/dual_ota_full_4MB.csv"
|
||||
GENERATOR = Path.home() / ".platformio/packages/framework-arduinoespressif32/tools/gen_esp32part.py"
|
||||
|
||||
|
||||
@@ -54,10 +56,26 @@ class HeltecV4PartitionMigratorTest(unittest.TestCase):
|
||||
migration_body = source[source.index("void runMigration()") :]
|
||||
self.assertLess(migration_body.index("stageLegacyIdentity()"),
|
||||
migration_body.index("copyAndVerify(*running"))
|
||||
self.assertLess(migration_body.index("esp_ota_set_boot_partition(&target_app0)"),
|
||||
self.assertLess(migration_body.index("esp_ota_set_boot_partition(\n boot_in_app0 ? refs.app0 : refs.app1)"),
|
||||
migration_body.index("publishExpandedPartitionTable(*plan)"))
|
||||
self.assertLess(migration_body.index("stageLegacyConfig()"),
|
||||
migration_body.index("copyAndVerify(*running"))
|
||||
self.assertLess(migration_body.index("stageExpanderHandoff()"),
|
||||
migration_body.index("publishExpandedPartitionTable(*plan)"))
|
||||
setup_body = source[source.index("void setup()") :]
|
||||
self.assertIn("restoreStagedIdentity()", setup_body)
|
||||
self.assertIn("restoreStagedConfig()", setup_body)
|
||||
self.assertIn("verifyExpandedIdentityFile()", setup_body)
|
||||
self.assertIn('kMigrationConfigRestoredKey[] = "cfg-restored"', source)
|
||||
self.assertIn("nvs.putBool(kMigrationConfigRestoredKey, true)", source)
|
||||
self.assertIn("resumeLegacyOtaReceiver(geometry)", setup_body)
|
||||
self.assertLess(setup_body.index("restoreStagedIdentity()"),
|
||||
setup_body.index("restoreStagedConfig()"))
|
||||
self.assertLess(setup_body.index("restoreStagedConfig()"),
|
||||
setup_body.index("resumeLegacyOtaReceiver(geometry)"))
|
||||
self.assertIn("validOtherLoRaFirmware(*running, *old_receiver)", migration_body)
|
||||
self.assertIn("copyAndVerify(*refs.app1", migration_body)
|
||||
self.assertIn("stageResumeSlot(resume.resume_slot)", migration_body)
|
||||
self.assertIn("AsyncElegantOTA.begin(&server)", source)
|
||||
self.assertIn("MeshCore-Migrate", source)
|
||||
self.assertIn("DRAM_ATTR esp_partition_t copy_destination", source)
|
||||
@@ -66,6 +84,11 @@ class HeltecV4PartitionMigratorTest(unittest.TestCase):
|
||||
self.assertIn("esp_flash_erase_region(chip", source)
|
||||
self.assertIn("esp_flash_write(chip", source)
|
||||
self.assertNotIn("#include <esp_flash_internal.h>", source)
|
||||
for path in ("/com_prefs", "/node_prefs", "/s_contacts",
|
||||
"/s_login_replay", "/radio_profiles", "/regions2",
|
||||
"/com_prefs.bak", "/radio_profiles.bak", "/prefs.json",
|
||||
"/management", "/ota_speed", "/bsec_state.bin"):
|
||||
self.assertIn(path, source)
|
||||
|
||||
board = MIGRATOR_BOARD.read_text(encoding="utf-8")
|
||||
self.assertIn('"-DARDUINO_USB_CDC_ON_BOOT=0"', board)
|
||||
@@ -92,6 +115,28 @@ class HeltecV4PartitionMigratorTest(unittest.TestCase):
|
||||
embedded_8mb = bytes(int(value, 16)
|
||||
for value in re.findall(r"0x([0-9A-F]{2})", block_8mb))
|
||||
self.assertEqual(embedded_8mb, generated_8mb.read_bytes()[:len(embedded_8mb)])
|
||||
generated_4mb = Path(directory) / "default4.bin"
|
||||
subprocess.run([sys.executable, str(GENERATOR), str(PARTITIONS_4MB),
|
||||
str(generated_4mb)], check=True, capture_output=True, text=True)
|
||||
start_4mb = text.index("kExpanded4MBPartitionTablePrefix[]")
|
||||
block_4mb = text[start_4mb:text.index("};", start_4mb)]
|
||||
embedded_4mb = bytes(int(value, 16)
|
||||
for value in re.findall(r"0x([0-9A-F]{2})", block_4mb))
|
||||
self.assertEqual(0xC0, len(embedded_4mb))
|
||||
self.assertEqual(embedded_4mb, generated_4mb.read_bytes()[:len(embedded_4mb)])
|
||||
|
||||
def test_expander_pins_full_target_and_reboots_after_apply(self):
|
||||
expander = EXPANDER.read_text(encoding="utf-8")
|
||||
self.assertIn("MOTA_MIGRATION_TARGET_ID", expander)
|
||||
self.assertIn("manifest.is_full()", expander)
|
||||
self.assertIn("loadPrimaryRadio(profile)", expander)
|
||||
self.assertIn("returnToVerifiedFull()", expander)
|
||||
self.assertIn("hasExpanderHandoff()", expander)
|
||||
self.assertIn("hasVerifiedConfigHandoff()", expander)
|
||||
self.assertIn("ota.apply_fetched(message)", expander)
|
||||
self.assertIn("ota_reboot_to_apply()", expander)
|
||||
self.assertLess(expander.index("ota.apply_fetched(message)"),
|
||||
expander.index("ota_reboot_to_apply()"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -212,9 +212,9 @@ int main() {
|
||||
|
||||
class Nrf52BleStartupTest(unittest.TestCase):
|
||||
def test_reported_startup_failures_are_not_reinitialized(self):
|
||||
implementation = (ROOT / "src/helpers/nrf52/SerialBLEInterface.cpp").read_text()
|
||||
begin = method(implementation, "bool SerialBLEInterface::begin(")
|
||||
format_device_name = method(implementation, "static bool formatDeviceName(")
|
||||
serial_source = (ROOT / "src/helpers/nrf52/SerialBLEInterface.cpp").read_text()
|
||||
formatter = method(serial_source, "static bool formatDeviceName(")
|
||||
begin = method(serial_source, "bool SerialBLEInterface::begin(")
|
||||
header = (ROOT / "src/helpers/nrf52/SerialBLEInterface.h").read_text()
|
||||
# Use the real in-class startup initializers, too.
|
||||
fields = "\n".join(line for line in header.splitlines()
|
||||
@@ -224,8 +224,8 @@ class Nrf52BleStartupTest(unittest.TestCase):
|
||||
or "char _active_name" in line)
|
||||
self.assertTrue(fields)
|
||||
source = (HARNESS.replace("@BEGIN@", begin)
|
||||
.replace("@FORMAT_DEVICE_NAME@", format_device_name)
|
||||
.replace("@STARTUP_FIELDS@", fields))
|
||||
.replace("@STARTUP_FIELDS@", fields)
|
||||
.replace("@FORMAT_DEVICE_NAME@", formatter))
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-ble-start-") as temp:
|
||||
temp = Path(temp)
|
||||
(temp / "Arduino.h").write_text(RTOS)
|
||||
|
||||
@@ -2781,6 +2781,47 @@ TEST(OtaTransfer, ServerPacesOneKilobyteBlockAndProactiveProofWithBackpressure)
|
||||
EXPECT_EQ(server.pendingServeJobs(), 0u);
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, SenderPacketPaceBacksOffOnDistinctLostBlocks) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
ASSERT_EQ(manifest.block_count, 3u);
|
||||
|
||||
OtaManager server;
|
||||
CapturedMessages sent;
|
||||
server.begin(0, capture_send, &sent);
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f);
|
||||
|
||||
auto request = [&](uint16_t block, uint16_t mask) {
|
||||
ReqMsg req{};
|
||||
memcpy(req.manifest_id, manifest.merkle_root, 4);
|
||||
req.block_idx = block;
|
||||
req.want_mask = mask;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
const uint16_t len = encode_req(wire, sizeof(wire), req);
|
||||
return len != 0 && server.on_message(wire, len);
|
||||
};
|
||||
|
||||
ASSERT_TRUE(request(0, 0xFFFF));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f);
|
||||
ASSERT_TRUE(request(0, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.5f);
|
||||
ASSERT_TRUE(request(0, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.5f); // same hole cannot ratchet down
|
||||
ASSERT_TRUE(request(1, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.4f);
|
||||
ASSERT_TRUE(request(0, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.3f);
|
||||
ASSERT_TRUE(request(1, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.25f); // automatic floor
|
||||
ASSERT_TRUE(request(1, 0x0001));
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.25f);
|
||||
|
||||
// Reinitializing a source starts with the configured maximum again.
|
||||
server.begin(0, capture_send, &sent);
|
||||
EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f);
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, LiteralLegacyFullMaskServesTwoKilobyteBlockInThirteenFragments) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
|
||||
@@ -3744,6 +3785,7 @@ TEST(OtaTransfer, FlightRetriesOnlyMissingFragmentsAfterItsDeadline) {
|
||||
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
||||
client.set_fetch_store(&store);
|
||||
client.set_clock(100);
|
||||
client.set_link_timing(20, 2000); // direct SF5-class link
|
||||
client.pull(manifest.merkle_root, manifest.target_id);
|
||||
sent.items.clear();
|
||||
deliver_manifest_fragment(client, manifest.merkle_root, 0,
|
||||
@@ -3752,6 +3794,10 @@ TEST(OtaTransfer, FlightRetriesOnlyMissingFragmentsAfterItsDeadline) {
|
||||
manifest.manifest_start + OTA_MF_FRAG,
|
||||
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
||||
ASSERT_EQ(sent.items.size(), 1u); // conservative one-block probe flight
|
||||
const uint32_t unanswered_timeout = client.fetchRetryTimeoutMs();
|
||||
EXPECT_GE(unanswered_timeout, (uint32_t)OTA_FETCH_RETRY_MIN_MS);
|
||||
client.note_rx_path_hops(0);
|
||||
EXPECT_EQ(client.fetchRetryTimeoutMs(), unanswered_timeout); // path alone is not proof of a live response
|
||||
|
||||
DataMsg first_fragment;
|
||||
memcpy(first_fragment.manifest_id, manifest.merkle_root, 4);
|
||||
@@ -3765,6 +3811,8 @@ TEST(OtaTransfer, FlightRetriesOnlyMissingFragmentsAfterItsDeadline) {
|
||||
client.on_message(wire, wire_len);
|
||||
|
||||
const uint32_t timeout = client.fetchRetryTimeoutMs();
|
||||
EXPECT_EQ(timeout, (uint32_t)OTA_FETCH_RETRY_PARTIAL_MIN_MS);
|
||||
EXPECT_LT(timeout, unanswered_timeout);
|
||||
client.set_clock(100 + timeout - 1);
|
||||
client.loop(); // no premature fixed-tick retry
|
||||
EXPECT_EQ(sent.items.size(), 1u);
|
||||
|
||||
+13
-7
@@ -20,20 +20,26 @@ int main() {
|
||||
assert(!strncmp(reply, prefix, strlen(prefix)));
|
||||
};
|
||||
beginSpeedConfig(&fs);
|
||||
command("get ota.speed", "> ota.speed=1x");
|
||||
command("get ota.speed", "> ota.speed=1x packet=1x");
|
||||
assert(handleSpeedCommand("get ota.speed", reply, sizeof reply, 0.25f));
|
||||
assert(!strcmp(reply, "> ota.speed=1x packet=0.25x"));
|
||||
assert(effectivePacketPace(0.25f) == 0.25f);
|
||||
assert(effectivePacketPace(1.0f) == 1.0f);
|
||||
for (const auto* bad : {"", "0", "-1", "0.049", "3.01", "nan", "inf", "1e0", "1abc", "1 2"}) {
|
||||
const std::string text = std::string("set ota.speed ") + bad;
|
||||
command(text.c_str(), "ERR");
|
||||
assert(speedFactor() == 1.0f && fs.files.empty());
|
||||
}
|
||||
command("set ota.speed .05", "OK ota.speed=0.05x (saved)");
|
||||
command("set ota.speed .05", "OK ota.speed=0.05x (saved) packet=0.05x");
|
||||
assert(handleSpeedCommand("get ota.speed", reply, sizeof reply, 0.25f));
|
||||
assert(!strcmp(reply, "> ota.speed=0.05x packet=0.05x"));
|
||||
assert(validSpeed(speedFactor()));
|
||||
beginSpeedConfig(&fs);
|
||||
command("get ota.speed", "> ota.speed=0.05x");
|
||||
command("ota config speed 3", "OK ota.speed=3x (saved)");
|
||||
command("ota config speed", "> ota.speed=3x");
|
||||
command("ota cfg speed 0.5", "OK ota.speed=0.5x");
|
||||
command("ota speed", "> ota.speed=0.5x");
|
||||
command("get ota.speed", "> ota.speed=0.05x packet=0.05x");
|
||||
command("ota config speed 3", "OK ota.speed=3x (saved) packet=1x");
|
||||
command("ota config speed", "> ota.speed=3x packet=1x");
|
||||
command("ota cfg speed 0.5", "OK ota.speed=0.5x (saved) packet=0.5x");
|
||||
command("ota speed", "> ota.speed=0.5x packet=0.5x");
|
||||
command("get ota.speed 1", "ERR");
|
||||
assert(!handleSpeedCommand("get ota.speed.extra", reply, sizeof reply));
|
||||
assert(!handleSpeedCommand("ota config advert 10", reply, sizeof reply));
|
||||
|
||||
@@ -51,6 +51,13 @@ assert.match(pageSource, /data-action="apply-node-clock"/);
|
||||
assert.match(pageSource, /data-role="node-clock-status"/);
|
||||
assert.match(pageSource, /data-command="primary-scheduled"/);
|
||||
assert.match(pageSource, /data-command="primary-cancel"/);
|
||||
assert.match(pageSource, /name="schedule-command-mode" value="relative" checked/);
|
||||
assert.match(pageSource, /KeyMind Cascade · <code>\+minutes<\/code>/);
|
||||
assert.match(pageSource, /name="schedule-command-mode" value="absolute"/);
|
||||
assert.match(pageSource, /data-role="absolute-clock-controls" hidden/);
|
||||
assert.match(pageSource, /class="preset-test-time-fields"/);
|
||||
assert.match(pageSource, /class="preset-test-radio-layout"/);
|
||||
assert.match(pageSource, /class="preset-test-radio-fields"/);
|
||||
assert.match(pageSource, /Simple Repeater primary radio/);
|
||||
assert.doesNotMatch(pageSource, /Stock firmware has no <code>tempradioat<\/code> command/);
|
||||
assert.doesNotMatch(pageSource, /Normal return profile/);
|
||||
@@ -61,7 +68,8 @@ const query =
|
||||
"&tz=America%2FLos_Angeles" +
|
||||
"&freq=910.1&bw=500&sf=8&cr=7";
|
||||
const config = tool.configFromSearch(query);
|
||||
const defaults = tool.configFromSearch("", "America/Los_Angeles");
|
||||
const defaultNow = Date.parse("2026-09-22T08:00:00.000Z");
|
||||
const defaults = tool.configFromSearch("", "America/Los_Angeles", defaultNow);
|
||||
|
||||
assert.strictEqual(tool.hasPresetParameters(""), false);
|
||||
assert.strictEqual(tool.hasPresetParameters("?"), false);
|
||||
@@ -107,8 +115,15 @@ assert.strictEqual(config.sf, 8);
|
||||
assert.strictEqual(config.cr, 7);
|
||||
assert.strictEqual(config.tz, "America/Los_Angeles");
|
||||
assert.strictEqual(config.tx, 22);
|
||||
assert.strictEqual(defaults.startEpoch, config.startEpoch);
|
||||
assert.strictEqual(defaults.endEpoch, config.endEpoch);
|
||||
assert.strictEqual(
|
||||
defaults.startMs,
|
||||
Date.parse("2026-09-23T17:00:00-07:00")
|
||||
);
|
||||
assert.strictEqual(
|
||||
defaults.endMs,
|
||||
Date.parse("2026-09-25T17:00:00-07:00")
|
||||
);
|
||||
assert.strictEqual(defaults.endEpoch - defaults.startEpoch, 48 * 60 * 60);
|
||||
assert.strictEqual(defaults.freq, config.freq);
|
||||
assert.strictEqual(defaults.bw, 500);
|
||||
assert.strictEqual(defaults.sf, 8);
|
||||
@@ -116,10 +131,27 @@ assert.strictEqual(defaults.cr, 7);
|
||||
assert.strictEqual(defaults.tx, 22);
|
||||
assert.strictEqual(defaults.tz, config.tz);
|
||||
assert.strictEqual(tool.isDefaultPreset(defaults), true);
|
||||
assert.strictEqual(tool.isDefaultPreset(config), true);
|
||||
assert.strictEqual(tool.presetPageTitle(defaults), "Default temporary radio test · 910.1 MHz");
|
||||
assert.match(tool.presetPageSummary(defaults), /910\.1 MHz, 500 kHz, SF8, CR7, 22 dBm/);
|
||||
assert.strictEqual(tool.presetEyebrow(defaults), "MeshCore · default 48-hour temporary preset test");
|
||||
|
||||
const dstDefault = tool.configFromSearch(
|
||||
"?tz=America%2FLos_Angeles",
|
||||
"UTC",
|
||||
Date.parse("2026-03-06T08:00:00.000Z")
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.zonedInputValue(dstDefault.startMs, dstDefault.tz),
|
||||
"2026-03-07T17:00"
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.zonedInputValue(dstDefault.endMs, dstDefault.tz),
|
||||
"2026-03-09T17:00"
|
||||
);
|
||||
assert.strictEqual(dstDefault.endMs - dstDefault.startMs, 47 * 60 * 60 * 1000);
|
||||
assert.strictEqual(tool.isDefaultPreset(dstDefault), true);
|
||||
|
||||
const requestedLink = tool.configFromSearch(
|
||||
"?start=2026-09-22T00:00:00.000Z&end=2026-09-24T00:00:00.000Z" +
|
||||
"&tz=America%2FLos_Angeles&freq=911.3&bw=500&sf=8&cr=7&tx=22"
|
||||
@@ -135,6 +167,15 @@ assert.strictEqual(
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.commandsFor(requestedLink, requestedLink.startMs).primaryScheduled,
|
||||
"set tempradioat 911.3,500,8,7,+1,+2880\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.commandsFor(
|
||||
requestedLink,
|
||||
requestedLink.startMs,
|
||||
0,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
).primaryScheduled,
|
||||
"set tempradioat 911.3,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
);
|
||||
|
||||
@@ -151,7 +192,8 @@ assert.deepStrictEqual(adjustedEpochs, {
|
||||
const adjustedCommands = tool.commandsFor(
|
||||
clockAdjustedLink,
|
||||
clockAdjustedLink.startMs,
|
||||
clockOffset
|
||||
clockOffset,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
);
|
||||
assert.strictEqual(
|
||||
adjustedCommands.primaryScheduled,
|
||||
@@ -178,10 +220,12 @@ assert.throws(
|
||||
/outside the firmware range/
|
||||
);
|
||||
|
||||
const browserZoneFallback = tool.configFromSearch("", "America/New_York");
|
||||
const browserZoneFallback = tool.configFromSearch(
|
||||
"", "America/New_York", defaultNow
|
||||
);
|
||||
assert.strictEqual(browserZoneFallback.tz, "America/New_York");
|
||||
assert.strictEqual(
|
||||
tool.configFromSearch("?tz=UTC", "America/New_York").tz,
|
||||
tool.configFromSearch("?tz=UTC", "America/New_York", defaultNow).tz,
|
||||
"UTC"
|
||||
);
|
||||
assert.doesNotThrow(() => tool.validateTimeZone(tool.browserTimeZone()));
|
||||
@@ -218,7 +262,7 @@ const commands = tool.commandsFor(config, active);
|
||||
assert.strictEqual(commands.stockNow, "tempradio 910.1,500,8,7,2880");
|
||||
assert.strictEqual(
|
||||
commands.primaryScheduled,
|
||||
"set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
"set tempradioat 910.1,500,8,7,+1,+2880\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(commands.primaryCancel, "get tempradioat\ndel tempradioat all");
|
||||
assert.strictEqual(
|
||||
@@ -228,7 +272,7 @@ assert.strictEqual(
|
||||
assert.strictEqual(
|
||||
commands.companionScheduled,
|
||||
"set radio2.cross on\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,+1,+2880\n" +
|
||||
"get tempradioat2"
|
||||
);
|
||||
assert.strictEqual(commands.stockLeaveIn30, "tempradio 910.1,500,8,7,30");
|
||||
@@ -243,6 +287,33 @@ assert.strictEqual(
|
||||
"set tempradio2 910.1,500,8,7,rxtx,30"
|
||||
);
|
||||
|
||||
const absoluteCommands = tool.commandsFor(
|
||||
config,
|
||||
active,
|
||||
0,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
);
|
||||
assert.strictEqual(
|
||||
absoluteCommands.primaryScheduled,
|
||||
"set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
absoluteCommands.companionScheduled,
|
||||
"set radio2.cross on\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
|
||||
"get tempradioat2"
|
||||
);
|
||||
assert.doesNotThrow(() => tool.commandsFor(
|
||||
config,
|
||||
active,
|
||||
tool.SCHEDULER_EPOCH_MAX,
|
||||
tool.SCHEDULE_MODE_RELATIVE
|
||||
));
|
||||
assert.throws(
|
||||
() => tool.commandsFor(config, active, 0, "unsupported"),
|
||||
/schedule mode must be relative or absolute/
|
||||
);
|
||||
|
||||
assert.strictEqual(tool.scheduleAvailability(config, before).available, true);
|
||||
assert.strictEqual(tool.scheduleAvailability(config, active).available, false);
|
||||
assert.strictEqual(tool.primaryScheduleAvailability(config, before).available, true);
|
||||
|
||||
@@ -42,6 +42,10 @@ static volatile uint8_t state = STATE_RX;
|
||||
static uint32_t now_ms = 0;
|
||||
uint32_t millis() { return now_ms; }
|
||||
uint32_t micros() { return now_ms * 1000UL; }
|
||||
// The dedicated profile-clock contract exercises the production clock helpers.
|
||||
// This receive-state harness only needs their portable single-process behavior.
|
||||
void syncProfileClock(bool) {}
|
||||
uint32_t profileTimestamp(bool) { return micros(); }
|
||||
void noInterrupts() {}
|
||||
void interrupts() {}
|
||||
void yield() { ++now_ms; }
|
||||
@@ -62,7 +66,7 @@ struct RadioLibWrapper {
|
||||
bool _cw_active = false;
|
||||
bool serviceCarrierWave() { return false; } // dedicated CW harness owns this path
|
||||
mesh::RadioProfiles _profiles;
|
||||
uint32_t _profile_visit_us = 0;
|
||||
uint32_t _profile_visit_stamp = 0;
|
||||
uint32_t _profile_scan_generation[2] = {};
|
||||
uint8_t _active_profile = 0;
|
||||
bool _profile_rxps_suspended = false;
|
||||
|
||||
@@ -35,7 +35,9 @@ int main() {
|
||||
assert(!pairPlanSupported(300,2048) && !pairPlanSupported(0,1024));
|
||||
assert(!pairPlanSupported(4000,1024) && !pairPlanSupported(0,40000));
|
||||
assert(pairListenUs(0,300,1024)==p.listenUs(0));
|
||||
assert(pairListenUs(1,300,1024)==p.listenUs(1));
|
||||
// This HIL case reserves 300 us for its loop; production now relies on the
|
||||
// measured, guarded switch time and gives those 300 us back to fast RX.
|
||||
assert(pairListenUs(1,300,1024)+300==p.listenUs(1));
|
||||
assert(pairProfiles[0].sf==7 && pairProfiles[0].bw==62.5f);
|
||||
assert(pairProfiles[1].sf==8 && pairProfiles[1].bw==500);
|
||||
assert(pairListenUs(1,4000)>4.1*512);
|
||||
|
||||
@@ -63,6 +63,10 @@ MAX_ARCHIVE_MEMBER_SIZE = 64 * 1024 * 1024
|
||||
MAX_FIRMWARE_IMAGE_SIZE = 64 * 1024 * 1024
|
||||
LEGACY_TARGET_MAX_BLOCK_SIZE = 1024
|
||||
MOTA_MAX_BLOCK_SIZE = 2048
|
||||
# Lab/HIL OTA tests use one explicit tuple on every participating radio.
|
||||
# This is a bench default, not a regional or production firmware preset.
|
||||
# Override --temp-radio only when the hardware or local rules require it.
|
||||
DEFAULT_LAB_TEMP_RADIO = "909.5,500,5,5,120"
|
||||
TRANSMISSION_RETRY_LIMIT = 3
|
||||
TRANSMISSION_RETRY_WINDOW_SECONDS = 90
|
||||
TRANSMISSION_RETRY_DELAY_SECONDS = 2
|
||||
@@ -7150,7 +7154,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--temp-radio", default="909.950,250,5,5,120",
|
||||
"--temp-radio", default=DEFAULT_LAB_TEMP_RADIO,
|
||||
help="frequency,bw,sf,cr,minutes",
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -2452,7 +2452,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=ota.DEFAULT_RELAY_TX_DELAY,
|
||||
help="temporary flood txdelay for managed intermediate relays",
|
||||
)
|
||||
parser.add_argument("--temp-radio", default="909.950,250,5,5,120")
|
||||
parser.add_argument("--temp-radio", default=ota.DEFAULT_LAB_TEMP_RADIO)
|
||||
parser.add_argument(
|
||||
"--ota-hops",
|
||||
type=int,
|
||||
|
||||
@@ -378,7 +378,7 @@ ZIP and a persistent work directory. The runner enforces exact target, start
|
||||
body hash, package order, watchdog state, post-boot identity, and normal-radio
|
||||
restoration.
|
||||
|
||||
Direct bench: use `--temp-radio 909.950,500,5,5,120 --ota-hops 0`. Add
|
||||
Direct bench: use `--temp-radio 909.5,500,5,5,120 --ota-hops 0`. Add
|
||||
`--legacy-full-airtime` only where the frequency and local duty-cycle rules
|
||||
permit it; omit it otherwise. The runner saves and restores the original
|
||||
airtime factor and OTA hop reach.
|
||||
|
||||
@@ -360,11 +360,12 @@ class FormatTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(argparse.ArgumentTypeError, "bandwidth must be"):
|
||||
ota.parse_temp_radio("909.950,200,5,5,120")
|
||||
|
||||
def test_ota_runners_default_to_sf5_and_250_khz(self) -> None:
|
||||
def test_ota_runners_use_the_same_lab_tuple(self) -> None:
|
||||
generic = ota.build_parser().parse_args(["release.mota", "remote"])
|
||||
chain = rak_chain.build_parser().parse_args([])
|
||||
self.assertEqual(generic.temp_radio, "909.950,250,5,5,120")
|
||||
self.assertEqual(chain.temp_radio, "909.950,250,5,5,120")
|
||||
self.assertEqual(generic.temp_radio, ota.DEFAULT_LAB_TEMP_RADIO)
|
||||
self.assertEqual(chain.temp_radio, ota.DEFAULT_LAB_TEMP_RADIO)
|
||||
self.assertEqual(generic.temp_radio, "909.5,500,5,5,120")
|
||||
self.assertFalse(chain.legacy_full_airtime)
|
||||
|
||||
def test_package_build_timeout_is_configurable_and_positive(self) -> None:
|
||||
@@ -1736,7 +1737,7 @@ class SourceCliTests(unittest.TestCase):
|
||||
self.assertIn(
|
||||
mock.call(
|
||||
mock.ANY,
|
||||
"tempradio 909.950,250,5,5,120",
|
||||
f"tempradio {ota.DEFAULT_LAB_TEMP_RADIO}",
|
||||
retry=False,
|
||||
),
|
||||
source_cli.call_args_list,
|
||||
|
||||
@@ -252,7 +252,7 @@ def hardware_id_for_env(env_name: str) -> str:
|
||||
return hardware_id
|
||||
|
||||
role = re.search(
|
||||
r"[_-](?:repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|"
|
||||
r"[_-](?:partition_expander|repeater|repeatr|room_server|room_svr|sensor|terminal_chat|kiss_modem|"
|
||||
r"companion_radio|companion|comp_radio)(?=[_-]|$)", env_name, re.IGNORECASE)
|
||||
family = (env_name[:role.start()] if role else env_name).strip("_-") or env_name.strip("_-")
|
||||
# Match the explicit MOTA_HW_ID shared by every role in these variant
|
||||
|
||||
+29
-11
@@ -199,7 +199,12 @@ def test_espnow_tx_power_matches_cli_callback_contract():
|
||||
|
||||
assert "bool setTxPower(int8_t dbm);" in header
|
||||
assert "bool ESPNOWRadio::setTxPower(int8_t dbm)" in implementation
|
||||
assert "esp_wifi_set_max_tx_power(dbm * 4) == ESP_OK" in implementation
|
||||
setter = implementation.split(
|
||||
"bool ESPNOWRadio::setTxPower(int8_t dbm) {", 1
|
||||
)[1].split("\n}", 1)[0]
|
||||
assert "esp_wifi_set_max_tx_power(dbm * 4) != ESP_OK" in setter
|
||||
assert setter.index("return false;") < setter.index("tx_power_dbm_ = dbm;")
|
||||
assert "return true;" in setter
|
||||
|
||||
|
||||
def test_flash_constrained_stm32_repeaters_pin_the_size_qualified_toolchain():
|
||||
@@ -436,7 +441,7 @@ def test_canonical_bulk_matrix_omits_runtime_and_transport_aliases():
|
||||
assert "is_esp32_dual_cdc_companion_radio_full_target" not in replacement
|
||||
|
||||
|
||||
def test_single_tty_logging_off_restores_each_builds_default_mode():
|
||||
def test_single_tty_logging_off_keeps_ascii_until_explicit_mode_switch():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
companion_main = (
|
||||
root / "examples/companion_radio/main.cpp"
|
||||
@@ -465,21 +470,21 @@ def test_single_tty_logging_off_restores_each_builds_default_mode():
|
||||
assert "usb_terminal_discard_line = false;" in keep_ascii
|
||||
assert "leaveUsbTerminalMode" not in keep_ascii
|
||||
|
||||
# A command entered on the logging terminal follows the same split: Full
|
||||
# stays in ASCII, while a non-Full Companion returns to Binary immediately.
|
||||
# The command's reply remains visible in the ASCII terminal on every
|
||||
# build. Turning logging off alone must not silently switch the session
|
||||
# to Binary before the user sees that reply or chooses an explicit token.
|
||||
command_disable = service.split(
|
||||
"the_mesh.handleTerminalCommand(usb_terminal_line);", 1
|
||||
)[1].split('usbTerminalOutput().print("> ");', 1)[0]
|
||||
assert "!mesh::isUsbLoggingEnabled()" in command_disable
|
||||
assert "usb_logging_terminal_mode = false;" in command_disable
|
||||
assert "#if defined(COMPANION_RADIO_FULL)" in command_disable
|
||||
assert "leaveUsbTerminalMode(true);" in command_disable
|
||||
assert "leaveUsbTerminalMode" not in command_disable
|
||||
|
||||
# Full Companion reaches Binary through its ordinary explicit token or
|
||||
# framed startup probe after logging is disabled.
|
||||
terminal_stop = service.split(
|
||||
"if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) {", 1
|
||||
)[1].split("}", 1)[0]
|
||||
)[1]
|
||||
assert "leaveUsbTerminalMode(true);" in terminal_stop
|
||||
usb_mota_start = service.split(
|
||||
"if (strcmp(usb_terminal_line, USB_MOTA_START_TOKEN) == 0) {", 1
|
||||
@@ -671,6 +676,9 @@ def test_tbeam_1w_release_hardware_fixes_are_preserved():
|
||||
board_impl = (variant_root / "TBeam1WBoard.cpp").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
board_header = (variant_root / "TBeam1WBoard.h").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
wrapper = (
|
||||
root / "src/helpers/radiolib/CustomSX1262Wrapper.h"
|
||||
).read_text(encoding="utf-8")
|
||||
@@ -711,13 +719,21 @@ def test_tbeam_1w_release_hardware_fixes_are_preserved():
|
||||
assert "applyCachedTxPower(int8_t dbm) override" in wrapper
|
||||
assert "SX126X_PA_RAMP_TIME" in wrapper
|
||||
|
||||
# Preserve the hardware-tested battery divider and fan thermostat.
|
||||
# Preserve the hardware-tested battery divider and the current on/off fan
|
||||
# hysteresis with a TX cooldown. These defaults replaced the old fixed
|
||||
# 45/41 C thermostat when the fan controls became configurable.
|
||||
assert "#define BATTERY_PIN 4" in variant
|
||||
assert "#define BATTERY_SENSE_SAMPLES 30" in variant
|
||||
assert "#define ADC_MULTIPLIER 2.9333f" in variant
|
||||
assert "#define FAN_TEMP_ON_C 45.0f" in board_impl
|
||||
assert "#define FAN_TEMP_OFF_C 41.0f" in board_impl
|
||||
assert "#define FAN_MIN_RUN_TIME_MS 5000UL" in board_impl
|
||||
assert "#define FAN_DEFAULT_LO_C 30" in variant
|
||||
assert "#define FAN_DEFAULT_HI_C 36" in variant
|
||||
assert "#define FAN_TX_COOLDOWN_MS 15000" in variant
|
||||
assert "_lo_c = FAN_DEFAULT_LO_C" in board_header
|
||||
assert "_hi_c = FAN_DEFAULT_HI_C" in board_header
|
||||
assert "_tx_until_ms = millis() + FAN_TX_COOLDOWN_MS" in board_impl
|
||||
assert "if (t >= (float)_hi_c) _thermal_on = true;" in board_impl
|
||||
assert "else if (t < (float)_lo_c) _thermal_on = false;" in board_impl
|
||||
assert "enabled = _thermal_on || tx_cooling;" in board_impl
|
||||
for role_main in (
|
||||
"examples/companion_radio/main.cpp",
|
||||
"examples/simple_repeater/main.cpp",
|
||||
@@ -1033,6 +1049,8 @@ def test_rak_nrf52_ota_profiles_keep_ina_and_gps_where_uart_is_available():
|
||||
|
||||
|
||||
def test_hardware_id_for_env():
|
||||
assert ml.hardware_id_for_env("Xiao_S3_WIO_partition_expander") == "Xiao_S3_WIO"
|
||||
assert ml.hardware_id_for_env("Xiao_S3_WIO_partition_expander") == ml.hardware_id_for_env("Xiao_S3_WIO_repeater")
|
||||
assert ml.hardware_id_for_env("RAK_4631_repeater") == "RAK4631"
|
||||
assert ml.hardware_id_for_env("RAK_4631_companion_radio_usb") == "RAK4631"
|
||||
assert (
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
; Board-independent ESP32-S3 Wi-Fi bridge for 4 MiB dual-OTA nodes. This
|
||||
; utility uses Wi-Fi, NVS and SPIFFS only; no board GPIO or PSRAM is needed.
|
||||
; DIO and disabled USB CDC minimize board-specific boot assumptions.
|
||||
[env:esp32_s3_4mb_partition_migrator]
|
||||
platform = platformio/espressif32@6.11.0
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
board_build.partitions = default.csv
|
||||
board_build.flash_mode = dio
|
||||
board_upload.flash_size = 4MB
|
||||
board_upload.maximum_size = 4194304
|
||||
extra_scripts = post:scripts/check_firmware_ram.py
|
||||
build_flags =
|
||||
-D ESP32_PLATFORM
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=0
|
||||
-D MESH_MIN_RUNTIME_HEAP=100352
|
||||
-I src
|
||||
build_src_filter =
|
||||
-<*>
|
||||
+<../examples/esp32_partition_migrator>
|
||||
lib_deps =
|
||||
ESP32Async/AsyncTCP @ 3.5.0
|
||||
ESP32Async/ESPAsyncWebServer @ 3.12.0
|
||||
file://arch/esp32/AsyncElegantOTA
|
||||
|
||||
; The same board-independent bridge with a 16 MiB app image header. The
|
||||
; source updater still sees Arduino default.csv's 1.25 MiB slots.
|
||||
[env:esp32_s3_16mb_partition_migrator]
|
||||
extends = env:esp32_s3_4mb_partition_migrator
|
||||
board_upload.flash_size = 16MB
|
||||
board_upload.maximum_size = 16777216
|
||||
|
||||
[env:esp32_s3_8mb_partition_migrator]
|
||||
extends = env:esp32_s3_4mb_partition_migrator
|
||||
board_upload.flash_size = 8MB
|
||||
board_upload.maximum_size = 8388608
|
||||
|
||||
; LoRa first-stage variants derive the exact board/role target from the EndF
|
||||
; appended by the packager and verify the old receiver's EndF before changing
|
||||
; flash. The bridge itself uses no board-specific radio GPIO.
|
||||
[env:esp32_s3_4mb_partition_migrator_lora]
|
||||
extends = env:esp32_s3_4mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_s3_4mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
[env:esp32_s3_8mb_partition_migrator_lora]
|
||||
extends = env:esp32_s3_8mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_s3_8mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
[env:esp32_s3_16mb_partition_migrator_lora]
|
||||
extends = env:esp32_s3_16mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_s3_16mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
; Heltec V2 and other first-generation ESP32 (non-S3) 8 MiB boards need a
|
||||
; matching chip-family image. The migration logic still verifies the live
|
||||
; partition table and flash size before touching flash.
|
||||
[env:esp32_8mb_partition_migrator]
|
||||
extends = env:esp32_s3_8mb_partition_migrator
|
||||
board = esp32dev
|
||||
|
||||
[env:esp32_8mb_partition_migrator_lora]
|
||||
extends = env:esp32_8mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_8mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
; The 4 MiB C3 bridge covers historical default.csv images such as the
|
||||
; Heltec CT62. It never assumes the old app slots are exactly 1.25 MiB: the
|
||||
; live table must still pass the common geometry and identity checks.
|
||||
[env:esp32_c3_4mb_partition_migrator]
|
||||
extends = env:esp32_s3_4mb_partition_migrator
|
||||
board = esp32-c3-devkitm-1
|
||||
|
||||
[env:esp32_c3_4mb_partition_migrator_lora]
|
||||
extends = env:esp32_c3_4mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_c3_4mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
[env:esp32_4mb_partition_migrator]
|
||||
extends = env:esp32_s3_4mb_partition_migrator
|
||||
board = esp32dev
|
||||
|
||||
[env:esp32_4mb_partition_migrator_lora]
|
||||
extends = env:esp32_4mb_partition_migrator
|
||||
build_flags =
|
||||
${env:esp32_4mb_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
@@ -860,13 +860,28 @@ build_flags =
|
||||
build_src_filter = ${Heltec_lora32_v4.build_src_filter}
|
||||
+<../examples/kiss_modem/>
|
||||
|
||||
; Self-hosted partition migration receiver for a legacy V4 1.25 MiB slot.
|
||||
; This role keeps Wi-Fi recovery and receives the exact Full repeater over LoRa.
|
||||
[env:heltec_v4_partition_expander]
|
||||
extends = Heltec_lora32_v4
|
||||
board = heltec_v4_migrator
|
||||
board_build.partitions = default.csv
|
||||
board_build.flash_mode = dio
|
||||
board_upload.maximum_size = 1310720
|
||||
build_src_filter = ${Heltec_lora32_v4.build_src_filter}
|
||||
+<../examples/partition_expander/*.cpp>
|
||||
build_flags = ${Heltec_lora32_v4.build_flags}
|
||||
-D MESH_MIN_RUNTIME_HEAP=100352
|
||||
-D MOTA_MIGRATION_TARGET_ID=0xe792a051
|
||||
; Pipeline verified OTA blocks across one request turn. The adaptive window
|
||||
; starts at one and grows only after clean flights, then shrinks on loss.
|
||||
-D OTA_FETCH_PIPELINE=4
|
||||
lib_deps = ${Heltec_lora32_v4.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
|
||||
; One-time Wi-Fi-only migration bridge for legacy Heltec V4/V4.3 OLED
|
||||
; repeaters that still use Arduino's 1.25 MiB OTA slots. It is deliberately
|
||||
; linked against default.csv so the existing browser updater can install it.
|
||||
; On first boot it stages /identity/_main.id in unchanged NVS, makes a
|
||||
; CRC-verified copy of legacy SPIFFS, copies itself to app0 when necessary,
|
||||
; then publishes default_16MB. It restores the identity after the larger
|
||||
; SPIFFS region initializes and exposes a browser updater for the full image.
|
||||
; repeaters that still use Arduino's 1.25 MiB OTA slots. It is linked against
|
||||
; default.csv so the existing browser updater can install it.
|
||||
[env:heltec_v4_partition_migrator]
|
||||
platform = platformio/espressif32@6.11.0
|
||||
board = heltec_v4_migrator
|
||||
@@ -894,6 +909,15 @@ lib_deps =
|
||||
ESP32Async/ESPAsyncWebServer @ 3.12.0
|
||||
file://arch/esp32/AsyncElegantOTA
|
||||
|
||||
; LoRa-delivered first stage for an OTA-enabled heltec_v4_repeater. It keeps
|
||||
; that exact old application bootable after the table change, restores its
|
||||
; private identity, and returns to it for the expanded-slot LoRa update.
|
||||
[env:heltec_v4_partition_migrator_lora_repeater]
|
||||
extends = env:heltec_v4_partition_migrator
|
||||
build_flags =
|
||||
${env:heltec_v4_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
; Disposable-hardware harness: leaves a ten-minute Wi-Fi OTA window for
|
||||
; installing the production bridge into legacy slot B. This is not a release
|
||||
; profile.
|
||||
|
||||
@@ -58,6 +58,26 @@ lib_deps =
|
||||
${Xiao_S3_WIO.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
|
||||
; Self-hosted Wi-Fi/LoRa receiver for the 1.25 MiB -> full-layout migration.
|
||||
; The target ID is the exact successor Xiao_S3_WIO_repeater; this build must
|
||||
; not automatically select an unrelated role. Hardware deployment still needs
|
||||
; an A/B and power-interruption test before it is included in a release.
|
||||
[env:Xiao_S3_WIO_partition_expander]
|
||||
extends = Xiao_S3_WIO
|
||||
board_build.partitions = default.csv
|
||||
board_build.flash_mode = dio
|
||||
board_upload.maximum_size = 1310720
|
||||
build_src_filter = ${Xiao_S3_WIO.build_src_filter}
|
||||
+<../examples/partition_expander/*.cpp>
|
||||
build_flags = ${Xiao_S3_WIO.build_flags}
|
||||
-D MESH_MIN_RUNTIME_HEAP=100352
|
||||
-D MOTA_MIGRATION_TARGET_ID=0x9f19bcd1
|
||||
; Same adaptive four-block window as the accelerated LoRa bridges: fewer
|
||||
; request/proof turnarounds without weakening per-block verification.
|
||||
-D OTA_FETCH_PIPELINE=4
|
||||
lib_deps = ${Xiao_S3_WIO.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
|
||||
; Generic source, XIAO-specific board definition. It is intentionally linked
|
||||
; to default.csv so the stock legacy browser updater can install it.
|
||||
[env:xiao_s3_partition_migrator]
|
||||
@@ -81,6 +101,13 @@ lib_deps =
|
||||
ESP32Async/ESPAsyncWebServer @ 3.12.0
|
||||
file://arch/esp32/AsyncElegantOTA
|
||||
|
||||
; LoRa-delivered first stage for an OTA-enabled Xiao_S3_WIO_repeater.
|
||||
[env:xiao_s3_partition_migrator_lora_repeater]
|
||||
extends = env:xiao_s3_partition_migrator
|
||||
build_flags =
|
||||
${env:xiao_s3_partition_migrator.build_flags}
|
||||
-D MESHCORE_MIGRATION_RESUME_OTA=1
|
||||
|
||||
; Disposable-hardware seed. It reproduces a real legacy identity file and
|
||||
; exposes browser OTA without trying to squeeze a current full repeater into
|
||||
; its historical 1.25 MiB app slot.
|
||||
|
||||
Reference in new issue
Block a user