Harden terminal retries and OTA cleanup

This commit is contained in:
mikecarper
2026-08-08 15:28:34 -07:00
parent e47e028b6d
commit b85fa75a43
5 changed files with 192 additions and 33 deletions
+20 -11
View File
@@ -282,8 +282,8 @@ while its raw USB CLI is used to start TempRadio:
If the source is already on the exact TempRadio tuple through a scheduled or
manual operation, `--source-already-temp` lets a TCP source run without a raw
CLI link. The script cannot verify or extend that source window, so leave a
comfortable time margin.
CLI link. The script cannot verify, extend, or shorten that source window, so
leave a comfortable time margin.
Use `--controller-baud` or `--source-baud` only for a build whose corresponding
interface is genuinely configured to another speed.
@@ -303,14 +303,18 @@ files differ, select one explicitly:
If no ready mOTA is usable, it searches `.bin` and `.hex` members for a valid,
matching `EndF`, then builds the platform-appropriate container. Every result
is structurally checked by the runner and independently passed through
`motatool verify` before any radio changes.
`motatool verify` before any radio changes. Direct firmware and mOTA inputs, as
well as individual ZIP members, are rejected above 64 MiB before being loaded.
Useful controls:
- `--public-key signer.key.pub` requires a particular Ed25519 signer during
verification.
- `--sign-key signer.key` signs a newly built container.
- `--no-install` downloads and verifies the image but leaves it staged.
- `--no-install` downloads and verifies the image but leaves it staged. By
default the runner then schedules the target, relays, and a script-configured
source back to their normal radios. Combining it with
`--leave-controller-radio` deliberately preserves the TempRadio topology.
- `--allow-non-upgrade` deliberately permits the same or an older version.
- `--replace-active-download` deliberately discards a different update already
downloading or staged on the target. Without it, that update is preserved.
@@ -348,11 +352,15 @@ the destination.
discovery, the transfer timeout, final polling, and install checks.
6. Start `motatool serve`, discover the exact eight-hex manifest ID, request
`ota pull <id> flash`, and poll until that same ID reports ready. A seeder
process exit stops the run immediately.
process exit stops the run immediately. For `--no-install`, schedule all
script-controlled nodes back to their normal radios before restoring the
controller, unless `--leave-controller-radio` was requested.
7. Recheck that exact ID, give the target a short final TempRadio safety window,
and request `ota install`. Then shorten each relay's TempRadio window so the
normal multi-hop route returns, restore the controller, wait for reboot, and
require the new running identity and exact package version.
normal multi-hop route returns, stop the seeder, shorten the source window,
restore the controller, wait for reboot, and require the new running identity
and exact package version. A source supplied with `--source-already-temp` is
never modified.
`--leave-controller-radio` moves the controller back to TempRadio only after
this normal-channel verification.
@@ -391,10 +399,11 @@ served mOTA, `motatool-serve.log`, extracted build inputs when needed, and
## Interruption and recovery
Ctrl-C stops the seeder, detaches its serial folder, and attempts to restore
the controller. The target and relays remain on TempRadio only until their
bounded windows end; rebooting also restores their saved radio settings. A
partial download remains safe. Once the target is reachable again (after its
Ctrl-C stops the seeder, detaches its serial folder, makes one best-effort
request to shorten a source TempRadio window started by the script, and attempts
to restore the controller. The target and relays remain on TempRadio only until
their bounded windows end; rebooting also restores their saved radio settings.
A partial download remains safe. Once the target is reachable again (after its
TempRadio window ends, or after putting the controller back on that tuple),
rerunning the same package recognizes its manifest ID and resumes the existing
session instead of clearing it.
+15 -8
View File
@@ -3212,10 +3212,18 @@ ContactInfo* MyMesh::getTerminalRecipient() {
void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t message_timestamp, uint32_t expected_ack,
uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE]) {
if (expected_ack == 0) return;
const uint8_t packet_retry_key[MAX_HASH_SIZE],
AckTableEntry* replacement_entry) {
if (expected_ack == 0) {
if (replacement_entry != NULL) {
clearExpectedAck(*replacement_entry, false);
}
return;
}
AckTableEntry& entry = expected_ack_table[next_ack_idx];
AckTableEntry& entry = replacement_entry != NULL
? *replacement_entry
: expected_ack_table[next_ack_idx];
clearExpectedAck(entry, false);
entry.msg_sent = _ms->getMillis();
entry.expires_at = futureMillis(est_timeout);
@@ -3227,7 +3235,9 @@ void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text,
(const uint8_t*)text, strlen(text));
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
entry.terminal_origin = true;
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
if (replacement_entry == NULL) {
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
expireExpectedAcks();
}
@@ -3289,11 +3299,8 @@ void MyMesh::handleTerminalCommand(char* command) {
if (result == MSG_SEND_FAILED) {
Serial.print(" ERROR: unable to send\r\n");
} else {
if (replacement_entry != NULL) {
clearExpectedAck(*replacement_entry, false);
}
rememberTerminalAck(*recipient, text, message_timestamp, expected_ack,
est_timeout, packet_retry_key);
est_timeout, packet_retry_key, replacement_entry);
Serial.printf(" message sent - %s\r\n",
result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT");
}
+7 -4
View File
@@ -263,10 +263,6 @@ private:
#ifdef ENABLE_USB_INTERFACE
ContactInfo* getTerminalRecipient();
void importTerminalCard(char* command);
void rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t message_timestamp, uint32_t expected_ack,
uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE]);
#endif
bool isValidClientRepeatFreq(uint32_t f) const;
bool hasLocationTelemetryRecipient();
@@ -369,6 +365,13 @@ private:
void expireExpectedAcks();
AckTableEntry* findPendingTextMessage(
const uint8_t text_fingerprint[MAX_HASH_SIZE], uint32_t message_timestamp);
#ifdef ENABLE_USB_INTERFACE
void rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t message_timestamp, uint32_t expected_ack,
uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE],
AckTableEntry* replacement_entry);
#endif
#define ADVERT_PATH_TABLE_SIZE 16
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
+91 -10
View File
@@ -54,8 +54,8 @@ TRANSMISSION_RETRY_WINDOW_SECONDS = 90
TRANSMISSION_RETRY_DELAY_SECONDS = 1
TRANSMISSION_PROMPT_SECONDS = 10
TEMP_RADIO_SWITCH_DELAY_SECONDS = 3
POST_INSTALL_RELAY_MINUTES = 1
POST_INSTALL_RELAY_MARGIN_SECONDS = 15
TEMP_RADIO_RETURN_MINUTES = 1
TEMP_RADIO_RETURN_MARGIN_SECONDS = 15
INSTALL_TARGET_WINDOW_MINUTES = 3
# Firmware may hold the apply reboot for up to 15 seconds while its reply
# drains. Do not interpret "still ready" as a failed install before that cap.
@@ -446,8 +446,25 @@ def parse_intel_hex(raw: bytes) -> bytes:
return bytes(image)
def read_bounded_file(path: Path, max_size: int, description: str) -> bytes:
try:
size = path.stat().st_size
except OSError as exc:
raise OtaError(f"cannot inspect {description} {path}: {exc}") from exc
if size > max_size:
raise OtaError(f"{description} is unexpectedly large: {path}")
try:
raw = path.read_bytes()
except OSError as exc:
raise OtaError(f"cannot read {description} {path}: {exc}") from exc
# Check again after reading in case the file changed between stat and read.
if len(raw) > max_size:
raise OtaError(f"{description} is unexpectedly large: {path}")
return raw
def read_firmware_file(path: Path) -> bytes:
raw = path.read_bytes()
raw = read_bounded_file(path, MAX_FIRMWARE_IMAGE_SIZE, "firmware file")
return parse_intel_hex(raw) if path.suffix.lower() == ".hex" else raw
@@ -594,7 +611,9 @@ def select_firmware_from_zip(
def load_base_image(path: Path, target: TargetInfo) -> EndFInfo:
suffix = path.suffix.lower()
if suffix == ".mota":
info = parse_mota(path.read_bytes(), path)
info = parse_mota(
read_bounded_file(path, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), path
)
if not info.is_full:
raise OtaError("--base mOTA must be a full-image container")
identity = parse_endf(info.payload)
@@ -723,7 +742,9 @@ def prepare_package(
new_identity: EndFInfo | None = None
if source.suffix.lower() == ".mota":
selected_blob = source.read_bytes()
selected_blob = read_bounded_file(
source, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"
)
selected = parse_mota(selected_blob, source)
elif source.suffix.lower() == ".zip":
try:
@@ -751,7 +772,9 @@ def prepare_package(
raise OtaError(f"package is not installable on {target.name}: {reason}")
output = served_dir / f"{selected.manifest_id.lower()}.mota"
output.write_bytes(selected_blob if selected_blob is not None else selected.blob)
selected = parse_mota(output.read_bytes(), output)
selected = parse_mota(
read_bounded_file(output, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), output
)
else:
if new_identity is None:
raise OtaError("could not obtain firmware from the input package")
@@ -789,7 +812,9 @@ def prepare_package(
command.extend(["--sign", str(args.sign_key.resolve())])
result = run_checked(command, label="build mOTA", timeout=600)
print(result.stdout.strip())
selected = parse_mota(output.read_bytes(), output)
selected = parse_mota(
read_bounded_file(output, MAX_ARCHIVE_MEMBER_SIZE, "mOTA file"), output
)
good, reason = compatible_mota(selected, target)
if not good:
raise OtaError(f"newly built package is not installable: {reason}")
@@ -1664,10 +1689,10 @@ def shorten_relay_temp_windows(
) -> None:
if not args.relay_values:
return
command = temp_radio_command_for_minutes(args, POST_INSTALL_RELAY_MINUTES)
command = temp_radio_command_for_minutes(args, TEMP_RADIO_RETURN_MINUTES)
print(
f"[relays] scheduling return to the normal channel in "
f"{POST_INSTALL_RELAY_MINUTES} minute"
f"{TEMP_RADIO_RETURN_MINUTES} minute"
)
for relay_name, relay_password in args.relay_values:
reply = controller.remote_command(
@@ -1676,6 +1701,42 @@ def shorten_relay_temp_windows(
require_temp_radio_reply(relay_name, reply)
def shorten_target_temp_window(
controller: Controller,
args: argparse.Namespace,
) -> None:
command = temp_radio_command_for_minutes(args, TEMP_RADIO_RETURN_MINUTES)
print(
f"[destination] scheduling return to the normal channel in "
f"{TEMP_RADIO_RETURN_MINUTES} minute"
)
reply = controller.remote_command(args.target, command)
require_temp_radio_reply(args.target, reply)
def shorten_source_temp_window(
args: argparse.Namespace,
*,
check: bool = True,
) -> bool:
if args.source_already_temp:
return True
command = temp_radio_command_for_minutes(args, TEMP_RADIO_RETURN_MINUTES)
print(
f"[source] scheduling return to the normal channel in "
f"{TEMP_RADIO_RETURN_MINUTES} minute"
)
output = source_cli_command(args, command, check=check)
if not output and not check:
print(
"[warn] could not shorten the OTA source TempRadio window; "
"it will return when its original bounded window ends",
file=sys.stderr,
)
return False
return True
def verify_installed(
controller: Controller,
args: argparse.Namespace,
@@ -1937,6 +1998,7 @@ def main(argv: list[str] | None = None) -> int:
controller_changed = False
seeder: SeederProcess | None = None
seeder_attempted = False
source_temp_owned = False
password = args.password or os.environ.get("MESHCORE_ADMIN_PASSWORD", "")
try:
preflight_inputs(args)
@@ -1990,6 +2052,7 @@ def main(argv: list[str] | None = None) -> int:
require_temp_radio_reply(relay_name, temp_reply)
if not args.source_already_temp:
source_cli_command(args, temp_command)
source_temp_owned = True
temp_radio = RadioSettings(
freq, bandwidth, sf, cr, original_radio.repeat
@@ -2006,6 +2069,19 @@ def main(argv: list[str] | None = None) -> int:
if args.no_install:
print(f"{args.target} is ready to install; leaving the verified update staged.")
if args.leave_controller_radio:
print(
"[cleanup] preserving TempRadio windows because "
"--leave-controller-radio was requested"
)
else:
shorten_target_temp_window(controller, args)
shorten_relay_temp_windows(controller, args)
seeder.stop()
seeder = None
if source_temp_owned:
shorten_source_temp_window(args)
source_temp_owned = False
return 0
install_confirmed = request_install(controller, args, package)
@@ -2025,10 +2101,13 @@ def main(argv: list[str] | None = None) -> int:
# Stop seeding before returning the controller to its ordinary channel.
seeder.stop()
seeder = None
if source_temp_owned and not args.leave_controller_radio:
shorten_source_temp_window(args)
source_temp_owned = False
controller.set_radio(original_radio, "restore controller radio for verification")
controller_changed = False
relay_wait = (
POST_INSTALL_RELAY_MINUTES * 60 + POST_INSTALL_RELAY_MARGIN_SECONDS
TEMP_RADIO_RETURN_MINUTES * 60 + TEMP_RADIO_RETURN_MARGIN_SECONDS
if args.relay_values else 0
)
time.sleep(max(args.reboot_wait, relay_wait))
@@ -2057,6 +2136,8 @@ def main(argv: list[str] | None = None) -> int:
# this is harmless on TCP and explicitly cleans the serial case.
if seeder_attempted and args.source_serial:
source_cli_command(args, "ota folder off", check=False)
if source_temp_owned and not args.leave_controller_radio:
shorten_source_temp_window(args, check=False)
if (
controller is not None
and controller_changed
+59
View File
@@ -179,6 +179,19 @@ class FormatTests(unittest.TestCase):
with self.assertRaisesRegex(ota.OtaError, "address span"):
ota.parse_intel_hex(raw)
def test_oversized_direct_file_is_rejected_before_read(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "oversized.mota"
path.write_bytes(b"1234")
with (
mock.patch.object(
Path, "read_bytes",
side_effect=AssertionError("oversized file was read"),
),
self.assertRaisesRegex(ota.OtaError, "unexpectedly large"),
):
ota.read_bounded_file(path, 3, "mOTA file")
class CompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
@@ -624,6 +637,52 @@ class ReliabilityTests(unittest.TestCase):
):
ota.validate_args(args, parser)
def test_stage_cleanup_shortens_target_and_relays(self) -> None:
class Controller:
def __init__(self) -> None:
self.commands: list[tuple[str, str, str | None]] = []
def remote_command(
self, target_name: str, command: str, **kwargs: object
) -> str:
password = kwargs.get("password")
self.commands.append((target_name, command, password))
return "OK - temp params for 1 mins"
controller = Controller()
args = argparse.Namespace(
target="remote",
relay_values=[("relay", "relay-secret")],
temp_values=(909.95, 250.0, 7, 5, 120),
)
ota.shorten_target_temp_window(controller, args)
ota.shorten_relay_temp_windows(controller, args)
self.assertEqual(
controller.commands,
[
("remote", "tempradio 909.95,250,7,5,1", None),
("relay", "tempradio 909.95,250,7,5,1", "relay-secret"),
],
)
def test_source_cleanup_only_changes_a_script_owned_window(self) -> None:
args = argparse.Namespace(
source_already_temp=False,
temp_values=(909.95, 250.0, 7, 5, 120),
)
with mock.patch.object(
ota, "source_cli_command", return_value="OK - temp params for 1 mins"
) as source_command:
self.assertTrue(ota.shorten_source_temp_window(args))
source_command.assert_called_once_with(
args, "tempradio 909.95,250,7,5,1", check=True
)
args.source_already_temp = True
with mock.patch.object(ota, "source_cli_command") as source_command:
self.assertTrue(ota.shorten_source_temp_window(args))
source_command.assert_not_called()
class MotatoolIntegrationTests(unittest.TestCase):
@classmethod