Merge branch 'keymindCascade' of github.com:mikecarper/MeshCore into keymindCascade

# Conflicts:
#	test/test_nrf52_ble_startup.py
This commit is contained in:
mikecarper committed 2026-09-24 17:36:09 -07:00
commit bbb5c14fba
50 files changed
+3450 -261

No files matched your search

+40 -10
View File
@@ -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
+18 -2
View File
@@ -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
View File
@@ -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,
+85 -20
View File
@@ -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;
}
+4 -2
View File
@@ -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.
+128 -29
View File
@@ -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
+14 -6
View File
@@ -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.
+2 -2
View File
@@ -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`.
+7 -2
View File
@@ -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
View File
@@ -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"
+4 -1
View File
@@ -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;
+610 -31
View File
@@ -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)) {
+211
View File
@@ -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
View File
@@ -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
+231
View File
@@ -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()
+164
View File
@@ -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
View File
@@ -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) {
+1
View File
@@ -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;
+10
View File
@@ -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);
+1
View File
@@ -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;
/**
+26 -1
View File
@@ -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);
+132 -2
View File
@@ -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
+44
View File
@@ -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;
+9 -5
View File
@@ -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(),
+64 -3
View File
@@ -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);
}
+20 -2
View File
@@ -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];
+18 -5
View File
@@ -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;
}
+6 -1
View File
@@ -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);
} }
+4
View File
@@ -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"}) {
@@ -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()
+15
View File
@@ -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"
);
+21 -3
View File
@@ -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
+46 -1
View File
@@ -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__":
+5 -5
View File
@@ -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)
+48
View File
@@ -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
View File
@@ -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));
+79 -8
View File
@@ -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);
+5 -1
View File
@@ -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;
+3 -1
View File
@@ -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);
+5 -1
View File
@@ -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(
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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.
+5 -4
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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
+30 -6
View File
@@ -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.
+27
View File
@@ -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.