mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 21:33:53 +00:00
Manage and restore OTA relay hop limits automatically
This commit is contained in:
1 parent
f59b6fde29
commit
cedad8da3c
6 files changed
+703
-6
No files matched your search
@@ -59,6 +59,9 @@ jobs:
|
||||
- name: Verify old-firmware compatibility in both OTA launchers
|
||||
run: python3 -B -m unittest discover -s tools/lora_ota -p test_lora_ota_bootloader_version.py -v
|
||||
|
||||
- name: Verify OTA orchestration and automatic relay policy cleanup
|
||||
run: python3 -B -m unittest discover -s tools/lora_ota -p 'test_lora_ota*.py' -v
|
||||
|
||||
- name: Verify all signed OTAFIX 2.4.6 release images
|
||||
env:
|
||||
MESHCORE_BOOTLOADER_MOTA_ZIP: ${{ runner.temp }}/OTAFIX-2.4.6-bootloader-mota.zip
|
||||
|
||||
@@ -665,6 +665,31 @@ matched repeater list in [the nRF52 QSPI guide](ota_nrf52_qspi.md).
|
||||
|
||||
## 6. Add intermediate relays
|
||||
|
||||
The runner automatically checks `ota config` on the destination, managed source,
|
||||
and each OTA-enabled relay before confirmation. The declared `--relay` count is
|
||||
the minimum required OTA reach: one relay requires at least one hop, not the
|
||||
direct-only `hops=0`. After the radio rehearsal passes, it rechecks the original
|
||||
policies and temporarily raises only limits below that minimum. Higher existing
|
||||
limits remain unchanged. Every change needs exact readback; a lost setter reply
|
||||
is reconciled by reading the policy, not blindly repeating the write.
|
||||
|
||||
The original limits are restored after the download, before installation can
|
||||
reboot the destination, and cleanup also attempts restoration after an error or
|
||||
Ctrl-C. A serial seeder is stopped before the source text CLI is used. The
|
||||
private, password-free `ota-hop-settings.json` recovery file is flushed before
|
||||
any hop write. Hop limits are persisted by firmware: if the host loses power or
|
||||
a radio becomes unreachable, use that file's original values once the correct
|
||||
radio channel is reachable. Unexpected external policy changes are not overwritten.
|
||||
|
||||
For a route containing additional, undeclared repeaters, add `--ota-hops N`
|
||||
(0..8, at least the declared relay count). This is a minimum, not a command to
|
||||
reduce larger limits. It does not discover or switch undeclared repeaters;
|
||||
all participating radios still need overlapping TempRadio windows. Direct runs
|
||||
without relays or a positive explicit minimum leave hop policies untouched.
|
||||
Explicitly non-OTA relays remain opaque forwarders; unsupported/ambiguous policy
|
||||
replies, authentication failures and timeouts on OTA participants stop the run.
|
||||
No signer, install-security or forwarding-filter checks are bypassed.
|
||||
|
||||
List relays from farthest to nearest so each command is sent before its route
|
||||
moves to TempRadio. A bare relay name uses the destination password; use
|
||||
`NAME=PASSWORD` when it differs:
|
||||
@@ -929,7 +954,8 @@ download without replacing it.
|
||||
The working directory is created before a managed source can be changed, then
|
||||
retained and printed at exit. It contains the exact
|
||||
served mOTA, `motatool-serve.log`, extracted build inputs when needed, and
|
||||
`controller-radio.txt`. A managed source also gets a protected
|
||||
`controller-radio.txt`, plus `ota-hop-settings.json` when OTA reach needed a
|
||||
temporary increase. A managed source also gets a protected
|
||||
`source-rxps-settings.json` containing its exact original preference and
|
||||
idempotent restore command. Its contents and directory entry are flushed before
|
||||
RXPS is disabled. When the destination
|
||||
|
||||
+228
-5
@@ -144,6 +144,8 @@ INSTALL_RECONCILE_WAIT_SECONDS = 20
|
||||
DEFAULT_RELAY_TX_DELAY = 0.3
|
||||
RELAY_TIMING_COMMANDS_PER_RELAY = 12
|
||||
RELAY_TIMING_RECOVERY_FILE = "relay-timing-settings.json"
|
||||
OTA_HOPS_RECOVERY_FILE = "ota-hop-settings.json"
|
||||
OTA_MAX_HOPS = 8
|
||||
TARGET_RXPS_RECOVERY_FILE = "target-rxps-settings.json"
|
||||
SOURCE_RXPS_RECOVERY_FILE = "source-rxps-settings.json"
|
||||
MIN_MESHCLI_VERSION = (1, 6, 0)
|
||||
@@ -306,6 +308,19 @@ class RelayTimingSettings:
|
||||
txdelay: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OtaHopSettings:
|
||||
name: str
|
||||
password: str | None
|
||||
local_source: bool
|
||||
original: int
|
||||
minimum: int
|
||||
|
||||
@property
|
||||
def transfer(self) -> int:
|
||||
return max(self.original, self.minimum)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteClockSample:
|
||||
"""One authenticated remote epoch with complete host-time uncertainty."""
|
||||
@@ -1614,6 +1629,13 @@ def reply_matches_command(command_text: str, reply: str) -> bool:
|
||||
)
|
||||
if command == "ota stats":
|
||||
return text.startswith("OTA | fw ") or is_unknown or needs_temp
|
||||
if command == "ota config":
|
||||
return (
|
||||
lowered.startswith("ota config:") or is_unknown or is_error
|
||||
or re.search(r"\bota (?:is )?not included\b", lowered) is not None
|
||||
)
|
||||
if command.startswith("ota config hops "):
|
||||
return lowered.startswith("ok ota reach = ") or is_unknown or is_error
|
||||
if command == "get bootloader.ver":
|
||||
try:
|
||||
parse_bootloader_version_reply(text)
|
||||
@@ -2986,6 +3008,11 @@ def recommended_temp_radio_minutes(
|
||||
0 if args.source_already_temp or args.source_shares_controller else 30
|
||||
)
|
||||
final_reply_count = (4 + relays) if include_install else 1
|
||||
hop_restore_seconds = 0
|
||||
if relays or (getattr(args, "ota_hops", None) or 0) > 0:
|
||||
# A policy restore needs a current read, absolute setter, and readback
|
||||
# on each remote participant plus the local source CLI.
|
||||
hop_restore_seconds = 3 * ((1 + relays) * args.reply_timeout + 30)
|
||||
required_seconds = (
|
||||
remote_setup_seconds
|
||||
+ source_setup_seconds
|
||||
@@ -2996,6 +3023,7 @@ def recommended_temp_radio_minutes(
|
||||
+ adaptive_poll_ceiling(args.poll_seconds)
|
||||
+ args.reply_timeout * final_reply_count
|
||||
+ relays * RELAY_TIMING_COMMANDS_PER_RELAY * args.reply_timeout
|
||||
+ hop_restore_seconds
|
||||
)
|
||||
return required_seconds // 60 + 1
|
||||
|
||||
@@ -4336,6 +4364,167 @@ def parse_delay_reply(reply: str, label: str) -> float:
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def parse_ota_hops(reply: str, label: str) -> int:
|
||||
# A local serial terminal may include a welcome banner and command echo.
|
||||
# Only the actual config line is authoritative, never another dotted or
|
||||
# numeric value from that surrounding output.
|
||||
lines = re.findall(r"(?mi)^[ \t]*(?:>[ \t]*)?ota config:[^\r\n]*", reply)
|
||||
if len(lines) == 1:
|
||||
values = re.findall(r"(?<!\S)hops=([^\s]+)", lines[0])
|
||||
if len(values) == 1 and re.fullmatch(r"[0-8]", values[0]):
|
||||
return int(values[0])
|
||||
raise OtaError(f"could not read {label} OTA hop limit: {reply}")
|
||||
|
||||
|
||||
def ota_hops_command(
|
||||
controller: Controller,
|
||||
args: argparse.Namespace,
|
||||
saved: OtaHopSettings,
|
||||
command: str,
|
||||
*,
|
||||
retry: bool = True,
|
||||
) -> str:
|
||||
if saved.local_source:
|
||||
return source_cli_command(args, command, bounded=True, retry=retry)
|
||||
|
||||
def once() -> str:
|
||||
return controller.remote_command(
|
||||
saved.name, command, password=saved.password, retry=False,
|
||||
operation_timeout=TEMP_RADIO_PREFLIGHT_OPERATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
if retry:
|
||||
return retry_transmission_bounded(once, f"{command!r} on {saved.name}")
|
||||
return once()
|
||||
|
||||
|
||||
def read_ota_hops(
|
||||
controller: Controller, args: argparse.Namespace, saved: OtaHopSettings,
|
||||
) -> int:
|
||||
return parse_ota_hops(ota_hops_command(controller, args, saved, "ota config"), saved.name)
|
||||
|
||||
|
||||
def prepare_ota_hop_settings(
|
||||
controller: Controller, args: argparse.Namespace,
|
||||
) -> list[OtaHopSettings]:
|
||||
"""Read every policy before confirmation; never guess an unknown value."""
|
||||
minimum = max(len(args.relay_values), getattr(args, "ota_hops", None) or 0)
|
||||
if minimum == 0:
|
||||
return [] # Preserve direct-run behavior and existing user policy.
|
||||
if minimum > OTA_MAX_HOPS:
|
||||
raise OtaError(f"OTA supports at most {OTA_MAX_HOPS} relay hops")
|
||||
if not has_managed_source_cli(args):
|
||||
raise OtaError(
|
||||
"automatic OTA hop setup requires a managed source CLI; supply "
|
||||
"--source-cli-serial or --source-cli-tcp instead of --source-already-temp"
|
||||
)
|
||||
source_name = (
|
||||
getattr(args, "shared_source_public_key", None)
|
||||
or getattr(args, "source_contact_value", None) or "OTA source"
|
||||
)
|
||||
participants = [
|
||||
OtaHopSettings(source_name, None, True, 0, minimum),
|
||||
OtaHopSettings(args.target, None, False, 0, minimum),
|
||||
*(OtaHopSettings(name, password, False, 0, minimum)
|
||||
for name, password in args.relay_values),
|
||||
]
|
||||
result = []
|
||||
for index, participant in enumerate(participants):
|
||||
reply = ota_hops_command(controller, args, participant, "ota config")
|
||||
# Non-OTA repeaters can forward OTA opaquely. Skip only an explicit
|
||||
# absence of the build feature. An unknown command alone does not
|
||||
# establish that this firmware has no OTA receive/forward policy.
|
||||
if index >= 2 and re.fullmatch(
|
||||
r"\s*(?:LoRa\s+)?OTA (?:is )?not included in this build"
|
||||
r"(?:[;.:!][^\r\n]*)?\s*",
|
||||
reply, re.IGNORECASE,
|
||||
):
|
||||
print(f"[hops] {participant.name}: opaque relay; no OTA policy to change")
|
||||
continue
|
||||
result.append(replace(participant, original=parse_ota_hops(reply, participant.name)))
|
||||
return result
|
||||
|
||||
|
||||
def set_ota_hops_verified(
|
||||
controller: Controller, args: argparse.Namespace, saved: OtaHopSettings, value: int,
|
||||
) -> None:
|
||||
try:
|
||||
reply = ota_hops_command(
|
||||
controller, args, saved, f"ota config hops {value}", retry=False,
|
||||
)
|
||||
except TransmissionError:
|
||||
# A missing acknowledgement is not permission to send another write.
|
||||
# The absolute, idempotent setting is reconciled by an exact readback.
|
||||
reply = None
|
||||
if reply is not None and not re.fullmatch(
|
||||
rf"\s*(?:>[ \t]*)?OK OTA reach = {value} hops? \(saved\)"
|
||||
r"(?: - direct only)?\s*", reply, re.IGNORECASE,
|
||||
):
|
||||
# Serial replies can carry a banner. Find exactly one complete ACK
|
||||
# line, rather than accepting an OK from an unrelated command.
|
||||
ack = re.findall(
|
||||
rf"(?mi)^[ \t]*(?:>[ \t]*)?OK OTA reach = {value} hops? \(saved\)"
|
||||
r"(?: - direct only)?[ \t]*\r?$", reply,
|
||||
)
|
||||
if len(ack) != 1:
|
||||
raise OtaError(f"{saved.name} refused OTA hop limit {value}: {reply}")
|
||||
if read_ota_hops(controller, args, saved) != value:
|
||||
raise OtaError(f"{saved.name} OTA hop limit did not read back as {value}")
|
||||
|
||||
|
||||
def apply_ota_hop_settings(
|
||||
controller: Controller, args: argparse.Namespace, settings: list[OtaHopSettings],
|
||||
owned: list[OtaHopSettings], work_dir: Path,
|
||||
) -> None:
|
||||
if not settings:
|
||||
return
|
||||
# Rehearsal/preparation can take minutes. Validate ALL snapshots again
|
||||
# before the first write, so another operator's change is not overwritten.
|
||||
for saved in settings:
|
||||
if read_ota_hops(controller, args, saved) != saved.original:
|
||||
raise OtaError(
|
||||
f"{saved.name} OTA hop limit changed during preparation; "
|
||||
"leaving the external setting unchanged"
|
||||
)
|
||||
changes = [saved for saved in settings if saved.transfer != saved.original]
|
||||
if changes:
|
||||
path = write_private_recovery_file(work_dir / OTA_HOPS_RECOVERY_FILE, json.dumps({
|
||||
"nodes": [{
|
||||
"name": saved.name, "local_source": saved.local_source,
|
||||
"original_hops": saved.original, "transfer_hops": saved.transfer,
|
||||
"restore_command": f"ota config hops {saved.original}",
|
||||
} for saved in changes],
|
||||
"source_endpoint": {
|
||||
"serial": getattr(args, "source_cli_serial", None) or getattr(args, "source_serial", None),
|
||||
"tcp": getattr(args, "source_cli_tcp", None),
|
||||
},
|
||||
}, indent=2) + "\n")
|
||||
print(f"[hops] recovery settings: {path}")
|
||||
for saved in changes:
|
||||
owned.append(saved) # Own cleanup BEFORE a possibly lost mutation.
|
||||
set_ota_hops_verified(controller, args, saved, saved.transfer)
|
||||
print(f"[hops] {saved.name}: temporarily {saved.original} -> {saved.transfer} (verified)")
|
||||
|
||||
|
||||
def restore_ota_hop_settings(
|
||||
controller: Controller, args: argparse.Namespace, owned: list[OtaHopSettings],
|
||||
) -> None:
|
||||
errors = []
|
||||
for saved in reversed(owned.copy()):
|
||||
try:
|
||||
current = read_ota_hops(controller, args, saved)
|
||||
if current != saved.original:
|
||||
if current != saved.transfer:
|
||||
raise OtaError("OTA hop limit changed externally; leaving it unchanged")
|
||||
set_ota_hops_verified(controller, args, saved, saved.original)
|
||||
owned.remove(saved) # Retire ownership only after exact readback.
|
||||
print(f"[hops] {saved.name}: original {saved.original} restored")
|
||||
except (OtaError, OSError) as exc:
|
||||
errors.append(f"{saved.name}: {exc}")
|
||||
if errors:
|
||||
raise OtaError("could not restore OTA hop settings: " + "; ".join(errors))
|
||||
|
||||
|
||||
def read_relay_timing(
|
||||
controller: Controller,
|
||||
relay_name: str,
|
||||
@@ -4494,6 +4683,9 @@ def confirm_update(
|
||||
f" relay timing: rxdelay 0, "
|
||||
f"txdelay {format_decimal(args.relay_txdelay)} (saved/restored)"
|
||||
)
|
||||
for saved in getattr(args, "ota_hop_settings", []):
|
||||
action = "unchanged" if saved.original == saved.transfer else "temporary; saved/restored"
|
||||
print(f" OTA hops : {saved.name}: {saved.original} -> {saved.transfer} ({action})")
|
||||
print(f" action : {'stage only' if args.no_install else 'install and reboot'}")
|
||||
current_version = (
|
||||
parse_version(target.current_version) if target.current_version else None
|
||||
@@ -7198,6 +7390,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--relay", action="append", default=[], metavar="NAME_OR_KEY[=PASSWORD]",
|
||||
help="optional relay name/key/unique key prefix, farthest-to-nearest; repeat as needed",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ota-hops", type=int, metavar="MINIMUM",
|
||||
help=("minimum OTA reach for undeclared additional hops (0..8); otherwise "
|
||||
"infer from --relay count; only raise lower limits and restore afterward"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-contact",
|
||||
metavar="NAME_OR_KEY",
|
||||
@@ -7387,6 +7584,10 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
|
||||
parser.error(f"--temp-radio: {exc}")
|
||||
if not math.isfinite(args.relay_txdelay) or not 0.0 <= args.relay_txdelay <= 2.0:
|
||||
parser.error("--relay-txdelay must be between 0 and 2")
|
||||
if len(args.relay) > OTA_MAX_HOPS:
|
||||
parser.error(f"at most {OTA_MAX_HOPS} --relay nodes are supported")
|
||||
if args.ota_hops is not None and not len(args.relay) <= args.ota_hops <= OTA_MAX_HOPS:
|
||||
parser.error("--ota-hops must be 0..8 and at least the declared --relay count")
|
||||
clear_manifest = args.clear_completed_manifest
|
||||
clear_body_hash = args.clear_completed_on_body_hash
|
||||
if bool(clear_manifest) != bool(clear_body_hash):
|
||||
@@ -8269,6 +8470,7 @@ def main(
|
||||
armed_relay_values: list[tuple[str, str]] = []
|
||||
relay_public_keys: dict[str, str] = {}
|
||||
relay_timing_settings: list[RelayTimingSettings] = []
|
||||
owned_ota_hop_settings: list[OtaHopSettings] = []
|
||||
password = args.password or os.environ.get("MESHCORE_ADMIN_PASSWORD", "")
|
||||
temp_command = f"tempradio {args.temp_radio}"
|
||||
|
||||
@@ -8464,6 +8666,7 @@ def main(
|
||||
target_rxps_profile = None
|
||||
args.target_rxps_saved = target_rxps_saved
|
||||
args.target_rxps_profile = target_rxps_profile
|
||||
args.ota_hop_settings = prepare_ota_hop_settings(controller, args)
|
||||
confirm_update(args, target, package)
|
||||
|
||||
freq, bandwidth, sf, cr, _minutes = args.temp_values
|
||||
@@ -8482,6 +8685,9 @@ def main(
|
||||
original_radio,
|
||||
temp_radio,
|
||||
)
|
||||
apply_ota_hop_settings(
|
||||
controller, args, args.ota_hop_settings, owned_ota_hop_settings, work_dir,
|
||||
)
|
||||
|
||||
# Package generation above belongs on a workstation/build VM; a
|
||||
# Pi-class radio host should receive only the completed hash-verified
|
||||
@@ -8638,6 +8844,13 @@ def main(
|
||||
seeder.start()
|
||||
find_and_start_pull(controller, args, package, seeder)
|
||||
monitor_download(controller, args, package, seeder)
|
||||
# A serial source is exclusively owned by motatool. Release that
|
||||
# ownership before trying to restore its policy through the text CLI.
|
||||
seeder.stop()
|
||||
seeder = None
|
||||
# Transfer is complete; restore while every participant is still on
|
||||
# the same tuple, before installation can reboot the destination.
|
||||
restore_ota_hop_settings(controller, args, owned_ota_hop_settings)
|
||||
|
||||
if args.no_install:
|
||||
report_staged_update(controller, args, package)
|
||||
@@ -8677,8 +8890,6 @@ def main(
|
||||
controller, args.target, target_rxps_saved
|
||||
)
|
||||
target_rxps_changed = False
|
||||
seeder.stop()
|
||||
seeder = None
|
||||
if source_temp_owned and (
|
||||
not args.leave_controller_radio
|
||||
or not args.source_shares_controller
|
||||
@@ -8721,9 +8932,7 @@ def main(
|
||||
relay_public_keys,
|
||||
)
|
||||
|
||||
# Stop seeding before returning the controller to its ordinary channel.
|
||||
seeder.stop()
|
||||
seeder = None
|
||||
# Seeding already stopped before policy restoration and installation.
|
||||
if source_temp_owned and (
|
||||
not args.leave_controller_radio
|
||||
or not args.source_shares_controller
|
||||
@@ -8808,6 +9017,11 @@ def main(
|
||||
file=sys.stderr,
|
||||
)
|
||||
if controller_changed:
|
||||
if owned_ota_hop_settings:
|
||||
try:
|
||||
restore_ota_hop_settings(controller, args, owned_ota_hop_settings)
|
||||
except (OtaError, OSError) as exc:
|
||||
print(f"WARNING: {exc}; retrying after radio restore", file=sys.stderr)
|
||||
if relay_timing_settings:
|
||||
try:
|
||||
restore_relay_timings(controller, relay_timing_settings)
|
||||
@@ -8931,6 +9145,15 @@ def main(
|
||||
f"{recovery_hint}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if owned_ota_hop_settings and controller is not None:
|
||||
try:
|
||||
restore_ota_hop_settings(controller, args, owned_ota_hop_settings)
|
||||
except (OtaError, OSError) as exc:
|
||||
print(
|
||||
f"CRITICAL: {exc}; use {work_dir / OTA_HOPS_RECOVERY_FILE} "
|
||||
"after checking the current policy and radio channel",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not source_rxps_changed:
|
||||
try:
|
||||
retire_local_source_rxps_recovery()
|
||||
|
||||
@@ -2408,6 +2408,11 @@ def run_step(
|
||||
])
|
||||
if args.debug:
|
||||
command.append("--debug")
|
||||
# The outer runner's destination policy alone cannot make a direct-only
|
||||
# source/relay transport an indirect route. Carry the reach into the
|
||||
# generic runner's temporary, saved/restored participant policy as well.
|
||||
if getattr(args, "ota_hops", None) is not None:
|
||||
command.extend(["--ota-hops", str(max(args.ota_hops, len(relays)))])
|
||||
for relay_key, relay_password in relays:
|
||||
command.extend(["--relay", f"{relay_key}={relay_password}"])
|
||||
result = ota.main(command, controller_override=controller)
|
||||
|
||||
@@ -7252,6 +7252,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
reboot_wait=90,
|
||||
relay=[],
|
||||
debug=False,
|
||||
ota_hops=3,
|
||||
)
|
||||
step = mock.Mock(
|
||||
number=1,
|
||||
@@ -7278,6 +7279,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
)
|
||||
command = nested.call_args.args[0]
|
||||
self.assertEqual(command.count("--debug"), int(enabled))
|
||||
self.assertEqual(command[command.index("--ota-hops") + 1], "3")
|
||||
self.assertEqual(command[1], target_key)
|
||||
self.assertEqual(command[command.index("--relay") + 1], f"{relay_key}=relay-password")
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Offline policy and lifecycle tests; no radio access or firmware writes."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import lora_ota as ota
|
||||
from test_lora_ota import firmware, mota_blob, target, VERSION_NEW
|
||||
|
||||
|
||||
class Stations:
|
||||
def __init__(self, relays=1, source_hops=0, target_hops=0):
|
||||
self.hops = {"source": source_hops, "remote": target_hops,
|
||||
**{f"relay{i}": 0 for i in range(relays)}}
|
||||
self.calls = []
|
||||
self.events = []
|
||||
self.lost = set()
|
||||
self.fail_reads = set()
|
||||
self.overrides = {}
|
||||
self.seeding = False
|
||||
self.normal = ota.RadioSettings(910.525, 62.5, 7, 5, False)
|
||||
|
||||
def command(self, name, command, **options):
|
||||
self.calls.append((name, command, options))
|
||||
if name in self.fail_reads and command == "ota config":
|
||||
raise ota.TransmissionError("lost config read")
|
||||
if (name, command) in self.overrides:
|
||||
return self.overrides[name, command]
|
||||
if command == "ota folder off":
|
||||
return "OK"
|
||||
if command == "ota config":
|
||||
return f"ota config: autofetch=off autoinstall=off hops={self.hops[name]} keys=0"
|
||||
if command.startswith("ota config hops "):
|
||||
if name == "source" and self.seeding:
|
||||
raise AssertionError("source text CLI used while seeder owns serial")
|
||||
value = int(command.rsplit(" ", 1)[1])
|
||||
self.hops[name] = value
|
||||
self.events.append(f"set:{name}:{value}")
|
||||
if (name, value) in self.lost:
|
||||
self.lost.remove((name, value))
|
||||
raise ota.TransmissionError("lost setting acknowledgement")
|
||||
return f"OK OTA reach = {value} hops (saved)" + (" - direct only" if value == 0 else "")
|
||||
raise AssertionError((name, command))
|
||||
|
||||
def remote_command(self, name, command, **options):
|
||||
return self.command(name, command, **options)
|
||||
|
||||
def source_cli(self, _args, command, **options):
|
||||
return self.command("source", command, **options)
|
||||
|
||||
def get_radio(self):
|
||||
return self.normal
|
||||
|
||||
def set_radio(self, _radio, _label):
|
||||
self.events.append("controller-restore")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def arguments(relays=1, minimum=None):
|
||||
return argparse.Namespace(
|
||||
target="remote", relay_values=[(f"relay{i}", "relay-secret") for i in range(relays)],
|
||||
ota_hops=minimum, source_serial="/dev/source", source_cli_serial=None,
|
||||
source_cli_tcp=None, source_contact_value="source",
|
||||
)
|
||||
|
||||
|
||||
class OtaHopTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.output = contextlib.redirect_stdout(io.StringIO())
|
||||
self.output.__enter__()
|
||||
self.addCleanup(self.output.__exit__, None, None, None)
|
||||
self.sleep = mock.patch.object(ota.time, "sleep")
|
||||
self.sleep.start()
|
||||
self.addCleanup(self.sleep.stop)
|
||||
|
||||
def test_config_parser_accepts_legacy_and_terminal_banners(self):
|
||||
for hops in range(9):
|
||||
reply = f"ota config: cache=off/0 autofetch=off advert=1440min hops={hops} keys=0"
|
||||
self.assertEqual(ota.parse_ota_hops(reply, "station"), hops)
|
||||
self.assertEqual(ota.parse_ota_hops(f"WELCOME\n> ota config\n> {reply}\r\n> ", "station"), hops)
|
||||
|
||||
def test_parser_rejects_missing_ambiguous_malformed_and_error_values(self):
|
||||
for reply in ("", "ERR permission denied", "Unknown command", "OTA | hops=0",
|
||||
"ota config: hops=9", "ota config: hops=-1", "ota config: hops=1foo",
|
||||
"ota config: hops=1 hops=1", "ota config: advert=1440",
|
||||
"ota config: hops=1\nota config: hops=2"):
|
||||
with self.subTest(reply=reply), self.assertRaises(ota.OtaError):
|
||||
ota.parse_ota_hops(reply, "station")
|
||||
|
||||
def test_command_reply_matching_does_not_accept_unrelated_ok(self):
|
||||
self.assertTrue(ota.reply_matches_command("ota config", "ota config: hops=0"))
|
||||
self.assertTrue(ota.reply_matches_command("ota config", "LoRa OTA not included in this build"))
|
||||
self.assertTrue(ota.reply_matches_command("ota config", "LoRa OTA is not included in this build; tempradio cannot enable it."))
|
||||
self.assertTrue(ota.reply_matches_command("ota config hops 1", "OK OTA reach = 1 hop (saved)"))
|
||||
self.assertFalse(ota.reply_matches_command("ota config hops 1", "OK - temp params for 1 mins"))
|
||||
|
||||
def test_direct_run_has_no_policy_queries_or_writes(self):
|
||||
stations = Stations(0)
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
self.assertEqual(ota.prepare_ota_hop_settings(stations, arguments(0)), [])
|
||||
self.assertEqual(stations.calls, [])
|
||||
|
||||
def test_infers_route_count_preserves_higher_limits_and_restores(self):
|
||||
for count in (1, 2, 3, 8):
|
||||
with self.subTest(relays=count), tempfile.TemporaryDirectory() as directory:
|
||||
stations = Stations(count)
|
||||
stations.hops["relay0"] = 8
|
||||
original = stations.hops.copy()
|
||||
args = arguments(count)
|
||||
owned = []
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, args)
|
||||
self.assertTrue(all(s.minimum == count for s in settings))
|
||||
ota.apply_ota_hop_settings(stations, args, settings, owned, Path(directory))
|
||||
self.assertEqual(stations.hops["remote"], count)
|
||||
self.assertEqual(stations.hops["source"], count)
|
||||
self.assertEqual(stations.hops["relay0"], 8)
|
||||
ota.restore_ota_hop_settings(stations, args, owned)
|
||||
self.assertEqual(stations.hops, original)
|
||||
self.assertEqual(owned, [])
|
||||
self.assertFalse(any(name == "relay0" and cmd.startswith("ota config hops ")
|
||||
for name, cmd, _ in stations.calls))
|
||||
remote_writes = [options for name, cmd, options in stations.calls
|
||||
if name != "source" and cmd.startswith("ota config hops ")]
|
||||
self.assertTrue(all(options["retry"] is False for options in remote_writes))
|
||||
relay_options = [options for name, _cmd, options in stations.calls if name.startswith("relay")]
|
||||
self.assertTrue(all(options["password"] == "relay-secret" for options in relay_options))
|
||||
|
||||
def test_explicit_minimum_handles_additional_undeclared_hops(self):
|
||||
stations = Stations(0)
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments(0, 3))
|
||||
self.assertEqual([s.transfer for s in settings], [3, 3])
|
||||
|
||||
def test_higher_limits_produce_no_recovery_file_or_changes(self):
|
||||
stations = Stations(source_hops=4, target_hops=3)
|
||||
stations.hops["relay0"] = 2
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, [], Path(directory))
|
||||
self.assertFalse((Path(directory) / ota.OTA_HOPS_RECOVERY_FILE).exists())
|
||||
self.assertFalse(stations.events)
|
||||
|
||||
def test_unknown_source_or_relay_stops_before_any_setting_change(self):
|
||||
for name in ("source", "remote", "relay0"):
|
||||
for reply in ("Unknown command", "ERR permission denied", "ota config: hops=9", ""):
|
||||
with self.subTest(name=name, reply=reply):
|
||||
stations = Stations()
|
||||
stations.overrides[name, "ota config"] = reply
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
with self.assertRaises(ota.OtaError):
|
||||
ota.prepare_ota_hop_settings(stations, arguments())
|
||||
self.assertEqual(stations.events, [])
|
||||
|
||||
def test_explicit_non_ota_relay_is_left_opaque(self):
|
||||
stations = Stations()
|
||||
stations.overrides["relay0", "ota config"] = "LoRa OTA is not included in this build; tempradio cannot enable it. Use an OTA-enabled repeater firmware"
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
self.assertEqual([s.name for s in settings], ["source", "remote"])
|
||||
|
||||
def test_setter_reply_lost_without_delivery_stops_without_replaying(self):
|
||||
stations = Stations()
|
||||
saved = ota.OtaHopSettings("remote", None, False, 0, 1)
|
||||
with mock.patch.object(stations, "remote_command", side_effect=[
|
||||
ota.TransmissionError("not delivered"), "ota config: hops=0",
|
||||
]) as command:
|
||||
with self.assertRaisesRegex(ota.OtaError, "did not read back"):
|
||||
ota.set_ota_hops_verified(stations, arguments(), saved, 1)
|
||||
self.assertEqual([c.args[1] for c in command.call_args_list], ["ota config hops 1", "ota config"])
|
||||
|
||||
def test_recovery_write_failure_prevents_any_mutation(self):
|
||||
stations = Stations()
|
||||
owned = []
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
with mock.patch.object(ota, "write_private_recovery_file", side_effect=OSError("disk full")):
|
||||
with self.assertRaises(OSError):
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, owned, Path(directory))
|
||||
self.assertFalse(stations.events)
|
||||
self.assertEqual(owned, [])
|
||||
|
||||
def test_source_serial_ack_can_include_a_welcome_banner(self):
|
||||
stations = Stations()
|
||||
saved = ota.OtaHopSettings("source", None, True, 0, 1)
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=[
|
||||
"WELCOME\n> ota config hops 1\nOK OTA reach = 1 hop (saved)\n> ",
|
||||
"WELCOME\nota config: hops=1\n> ",
|
||||
]):
|
||||
ota.set_ota_hops_verified(stations, arguments(), saved, 1)
|
||||
|
||||
def test_unmanaged_source_and_excessive_route_are_rejected(self):
|
||||
for args in (arguments(9), arguments()):
|
||||
if len(args.relay_values) == 1:
|
||||
args.source_serial = None
|
||||
stations = Stations()
|
||||
with self.assertRaises(ota.OtaError):
|
||||
ota.prepare_ota_hop_settings(stations, args)
|
||||
self.assertEqual(stations.calls, [])
|
||||
|
||||
def test_timeout_has_finite_read_retries_and_never_means_zero(self):
|
||||
stations = Stations()
|
||||
stations.fail_reads.add("remote")
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
with self.assertRaises(ota.TransmissionError):
|
||||
ota.prepare_ota_hop_settings(stations, arguments())
|
||||
self.assertEqual(len([c for c in stations.calls if c[0] == "remote"]), ota.TRANSMISSION_RETRY_LIMIT + 1)
|
||||
self.assertEqual(stations.events, [])
|
||||
|
||||
def test_external_change_during_rehearsal_aborts_all_writes(self):
|
||||
stations = Stations()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
stations.hops["relay0"] = 4
|
||||
with self.assertRaisesRegex(ota.OtaError, "changed during preparation"):
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, [], Path(directory))
|
||||
self.assertFalse((Path(directory) / ota.OTA_HOPS_RECOVERY_FILE).exists())
|
||||
self.assertEqual(stations.events, [])
|
||||
|
||||
def test_recovery_is_private_password_free_and_durable_before_writes(self):
|
||||
stations = Stations()
|
||||
args = arguments()
|
||||
owned = []
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
original_command = stations.command
|
||||
|
||||
def command(name, text, **options):
|
||||
if text.startswith("ota config hops "):
|
||||
path = root / ota.OTA_HOPS_RECOVERY_FILE
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertTrue(any(saved.name == name for saved in owned))
|
||||
raw = path.read_text()
|
||||
self.assertNotIn("relay-secret", raw)
|
||||
self.assertIn("restore_command", raw)
|
||||
if os.name != "nt":
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
return original_command(name, text, **options)
|
||||
|
||||
with mock.patch.object(stations, "command", side_effect=command):
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, args)
|
||||
ota.apply_ota_hop_settings(stations, args, settings, owned, root)
|
||||
saved = json.loads((root / ota.OTA_HOPS_RECOVERY_FILE).read_text())
|
||||
self.assertEqual(saved["source_endpoint"]["serial"], "/dev/source")
|
||||
|
||||
def test_lost_set_and_restore_acknowledgements_use_readback_without_replay(self):
|
||||
stations = Stations()
|
||||
stations.lost = {("source", 1), ("remote", 1), ("relay0", 1),
|
||||
("source", 0), ("remote", 0), ("relay0", 0)}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
owned = []
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, owned, Path(directory))
|
||||
ota.restore_ota_hop_settings(stations, arguments(), owned)
|
||||
self.assertEqual(owned, [])
|
||||
self.assertEqual(len(stations.events), 6)
|
||||
self.assertTrue(all(value == 0 for value in stations.hops.values()))
|
||||
|
||||
def test_bad_readback_is_not_success_and_partial_changes_are_restored(self):
|
||||
stations = Stations()
|
||||
stations.overrides["remote", "ota config hops 1"] = "OK OTA reach = 1 hop (saved)"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
owned = []
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
with self.assertRaisesRegex(ota.OtaError, "did not read back"):
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, owned, Path(directory))
|
||||
self.assertEqual([s.name for s in owned], ["source", "remote"])
|
||||
ota.restore_ota_hop_settings(stations, arguments(), owned)
|
||||
self.assertEqual(owned, [])
|
||||
self.assertEqual(stations.hops["source"], 0)
|
||||
|
||||
def test_restore_attempts_all_nodes_and_preserves_external_changes(self):
|
||||
stations = Stations()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
owned = []
|
||||
with mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli):
|
||||
settings = ota.prepare_ota_hop_settings(stations, arguments())
|
||||
ota.apply_ota_hop_settings(stations, arguments(), settings, owned, Path(directory))
|
||||
stations.hops["relay0"] = 4
|
||||
with self.assertRaisesRegex(ota.OtaError, "changed externally"):
|
||||
ota.restore_ota_hop_settings(stations, arguments(), owned)
|
||||
self.assertEqual(stations.hops, {"source": 0, "remote": 0, "relay0": 4})
|
||||
self.assertEqual([s.name for s in owned], ["relay0"])
|
||||
|
||||
def test_cli_minimum_must_cover_declared_route_and_stay_in_range(self):
|
||||
parser = ota.build_parser()
|
||||
for extra in (["--ota-hops", "-1"], ["--ota-hops", "9"],
|
||||
["--relay", "relay", "--ota-hops", "0"],
|
||||
["--relay", "relay"] * 9):
|
||||
with self.subTest(extra=extra), contextlib.redirect_stderr(io.StringIO()):
|
||||
args = parser.parse_args(["release.mota", "remote", *extra])
|
||||
with self.assertRaises(SystemExit):
|
||||
ota.validate_args(args, parser)
|
||||
|
||||
|
||||
class OtaHopLifecycleTests(unittest.TestCase):
|
||||
def run_main(self, directory, *, failure=None, leave=False, no_install=True):
|
||||
stations = Stations()
|
||||
image = firmware(b"ota hop lifecycle" * 300, VERSION_NEW)
|
||||
package = ota.parse_mota(mota_blob(image))
|
||||
seeder = mock.Mock()
|
||||
|
||||
def start():
|
||||
stations.seeding = True
|
||||
stations.events.append("seeder-start")
|
||||
|
||||
def stop():
|
||||
stations.seeding = False
|
||||
stations.events.append("seeder-stop")
|
||||
|
||||
seeder.start.side_effect = start
|
||||
seeder.stop.side_effect = stop
|
||||
|
||||
def phase(name):
|
||||
def action(*_args, **_kwargs):
|
||||
stations.events.append(name)
|
||||
if failure == name:
|
||||
if name == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
raise ota.OtaError("synthetic " + name)
|
||||
return action
|
||||
|
||||
def pull(*_args):
|
||||
stations.events.append("pull")
|
||||
if failure == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
if failure == "pull":
|
||||
raise ota.OtaError("synthetic discovery failure")
|
||||
self.assertTrue(all(hops == 1 for hops in stations.hops.values()))
|
||||
|
||||
def install(*_args):
|
||||
stations.events.append("install")
|
||||
self.assertTrue(all(hops == 0 for hops in stations.hops.values()))
|
||||
if failure == "install":
|
||||
raise ota.OtaError("synthetic install failure")
|
||||
return True
|
||||
|
||||
argv = ["release.mota", "remote", "--controller-serial", "/dev/controller",
|
||||
"--source-serial", "/dev/source", "--password", "secret",
|
||||
"--relay", "relay0=relay-secret", "--work-dir", str(Path(directory) / "work"),
|
||||
"--yes"]
|
||||
if no_install:
|
||||
argv.append("--no-install")
|
||||
if leave:
|
||||
argv.append("--leave-controller-radio")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for name in ("preflight_inputs", "preflight_source_cli", "bind_contact_selectors",
|
||||
"verify_shared_source_identity", "ensure_controller_clock_safe",
|
||||
"restore_relay_timings", "shorten_relay_temp_windows",
|
||||
"shorten_target_temp_window", "enforce_relay_timing", "report_staged_update",
|
||||
"wait_for_post_install_identity", "verify_installed"):
|
||||
stack.enter_context(mock.patch.object(ota, name))
|
||||
stack.enter_context(mock.patch.object(ota, "ensure_source_clock_gate_safe", return_value=(1800000000, 1800000059)))
|
||||
stack.enter_context(mock.patch.object(ota, "read_source_rxps", return_value=ota.RxpsSettings(False, 100, 100)))
|
||||
stack.enter_context(mock.patch.object(ota, "disable_source_rxps", return_value=False))
|
||||
stack.enter_context(mock.patch.object(ota, "query_target", return_value=target()))
|
||||
stack.enter_context(mock.patch.object(ota, "prepare_package", return_value=(Path("release.mota"), package, None)))
|
||||
stack.enter_context(mock.patch.object(ota, "read_lora_ota_participant_versions", return_value={}))
|
||||
stack.enter_context(mock.patch.object(ota, "read_remote_rxps", return_value=ota.RxpsSettings(False, 100, 100)))
|
||||
stack.enter_context(mock.patch.object(ota, "confirm_update", side_effect=phase("confirm")))
|
||||
stack.enter_context(mock.patch.object(ota, "run_temp_radio_preflight", side_effect=phase("rehearse")))
|
||||
stack.enter_context(mock.patch.object(ota, "source_cli_command", side_effect=stations.source_cli))
|
||||
stack.enter_context(mock.patch.object(ota, "arm_target_temp_radio", side_effect=phase("arm-target")))
|
||||
stack.enter_context(mock.patch.object(ota, "arm_relay_temp_radio_once", return_value=True))
|
||||
stack.enter_context(mock.patch.object(ota, "arm_source_temp_radio_once", return_value=True))
|
||||
stack.enter_context(mock.patch.object(ota, "switch_controller_to_temp_radio", side_effect=phase("switch")))
|
||||
stack.enter_context(mock.patch.object(ota, "read_relay_timing", return_value=ota.RelayTimingSettings("relay0", "relay-secret", 0, 0.3)))
|
||||
stack.enter_context(mock.patch.object(ota, "read_remote_public_key_bounded", return_value="a" * 64))
|
||||
stack.enter_context(mock.patch.object(ota, "shorten_source_temp_window", return_value=True))
|
||||
stack.enter_context(mock.patch.object(ota, "SeederProcess", return_value=seeder))
|
||||
stack.enter_context(mock.patch.object(ota, "find_and_start_pull", side_effect=pull))
|
||||
stack.enter_context(mock.patch.object(ota, "monitor_download", side_effect=phase("monitor")))
|
||||
stack.enter_context(mock.patch.object(ota, "request_install", side_effect=install))
|
||||
stack.enter_context(mock.patch.object(ota.time, "sleep"))
|
||||
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
|
||||
# The production source name is a bound public key; this test uses
|
||||
# the descriptive fallback while the source CLI fixture is local.
|
||||
original_prepare = ota.prepare_ota_hop_settings
|
||||
|
||||
def prepare(controller, args):
|
||||
args.source_contact_value = "source"
|
||||
return original_prepare(controller, args)
|
||||
|
||||
stack.enter_context(mock.patch.object(ota, "prepare_ota_hop_settings", side_effect=prepare))
|
||||
result = ota.main(argv, controller_override=stations)
|
||||
return result, stations
|
||||
|
||||
def test_success_restores_after_seeder_stop_even_when_temp_radio_is_preserved(self):
|
||||
for leave in (False, True):
|
||||
with self.subTest(leave=leave), tempfile.TemporaryDirectory() as directory:
|
||||
result, stations = self.run_main(directory, leave=leave)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stations.hops, {"source": 0, "remote": 0, "relay0": 0})
|
||||
events = stations.events
|
||||
self.assertLess(events.index("confirm"), events.index("rehearse"))
|
||||
self.assertLess(events.index("rehearse"), events.index("set:source:1"))
|
||||
self.assertLess(events.index("set:remote:1"), events.index("arm-target"))
|
||||
self.assertLess(events.index("seeder-stop"), events.index("set:source:0"))
|
||||
|
||||
def test_rehearsal_and_confirmation_failures_never_write_hop_settings(self):
|
||||
for failure in ("confirm", "rehearse"):
|
||||
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory:
|
||||
result, stations = self.run_main(directory, failure=failure)
|
||||
self.assertEqual(result, 2)
|
||||
self.assertFalse(any(event.startswith("set:") for event in stations.events))
|
||||
|
||||
def test_discovery_failure_and_interrupt_restore_all_original_settings(self):
|
||||
for failure in ("pull", "interrupt", "arm-target"):
|
||||
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory:
|
||||
result, stations = self.run_main(directory, failure=failure)
|
||||
self.assertEqual(result, 130 if failure == "interrupt" else 2)
|
||||
self.assertEqual(stations.hops, {"source": 0, "remote": 0, "relay0": 0})
|
||||
|
||||
def test_install_runs_only_after_policies_are_restored(self):
|
||||
for failure in (None, "install"):
|
||||
with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory:
|
||||
result, stations = self.run_main(directory, failure=failure, no_install=False)
|
||||
self.assertEqual(result, 2 if failure else 0)
|
||||
self.assertEqual(stations.hops, {"source": 0, "remote": 0, "relay0": 0})
|
||||
self.assertLess(stations.events.index("set:source:0"), stations.events.index("install"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user