diff --git a/build.sh b/build.sh index 0b50f6ca..f034ef32 100755 --- a/build.sh +++ b/build.sh @@ -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 : Override runtime settings embedded in the firmware (not its feature set). --build-profile : 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 diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 70725dc3..12947512 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -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 && diff --git a/docs/_javascript/preset_test.js b/docs/_javascript/preset_test.js index 0f3a666a..a084dec3 100644 --- a/docs/_javascript/preset_test.js +++ b/docs/_javascript/preset_test.js @@ -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, diff --git a/docs/_stylesheets/preset_test.css b/docs/_stylesheets/preset_test.css index e285619a..e2493663 100644 --- a/docs/_stylesheets/preset_test.css +++ b/docs/_stylesheets/preset_test.css @@ -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; } diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 37f7fcae..9c6ac956 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` is an equivalent setter, and `ota config` shows -the value. The setting is saved and can be changed during a transfer. +`ota config speed ` 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. diff --git a/docs/esp32_wifi_partition_migration.md b/docs/esp32_wifi_partition_migration.md index ec77672f..b59b5283 100644 --- a/docs/esp32_wifi_partition_migration.md +++ b/docs/esp32_wifi_partition_migration.md @@ -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-/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 --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 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 diff --git a/docs/heltec_v4_wifi_partition_migration.md b/docs/heltec_v4_wifi_partition_migration.md index 28aa493c..b199080b 100644 --- a/docs/heltec_v4_wifi_partition_migration.md +++ b/docs/heltec_v4_wifi_partition_migration.md @@ -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. diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 591f3868..a6a03dbd 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -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`. diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index d061aeff..5f982ddc 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -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. diff --git a/docs/preset_test.md b/docs/preset_test.md index 3859fdc4..39348cab 100644 --- a/docs/preset_test.md +++ b/docs/preset_test.md @@ -61,13 +61,36 @@ create a shareable temporary-radio test URL.