"""Meshtastic smoke tests with a single virtual device via localhost. These tests run against a real ``meshtasticd`` instance in simulator mode, managed by the function-scoped ``firmware_node`` fixture (see conftest.py). The function scope gives every test a freshly-erased node, so destructive commands (factory reset, channel mutations) are safe and order-independent. Strategy -------- * ``cli_then_verify(port, args, verifier)`` runs a CLI mutation, then opens a fresh ``TCPInterface`` and passes it to *verifier* which reads the real firmware state back through the Python library. Assertions target the protobuf-backed localConfig / channel objects, so they don't break when the CLI's stdout wording changes. * Display-format tests (``--info``, ``--nodes``, ``--debug``, ``--qr``, ``--seriallog``) intentionally assert against stdout — that's what they actually test. * Error paths are collapsed into parameterized tests. """ from __future__ import annotations import base64 import re import time import pytest from meshtastic.protobuf import channel_pb2, config_pb2 from .fw_helpers import ( PAUSE_AFTER_CLI, cli_then_verify, run_cli, ) # Some channel mutations cause the firmware to reboot internally. The # platform doesn't *actually* reboot in sim mode, but we still give it a # beat to commit the write back to disk before reconnecting. PAUSE_AFTER_REBOOT = 1.0 # --------------------------------------------------------------------------- # Section 1: Read-only / display tests (stdout assertions) # --------------------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_info(firmware_node): """--info connects and prints the standard summary sections.""" rc, out = run_cli(firmware_node.port, "--info") assert rc == 0, out assert re.search(r"Connected to radio", out) assert re.search(r"Owner:", out) assert re.search(r"My info:", out) assert re.search(r"Nodes in mesh:", out) assert re.search(r"Preferences:", out) @pytest.mark.smokevirt def test_smokevirt_debug(firmware_node): """--info --debug should include DEBUG log lines.""" rc, out = run_cli(firmware_node.port, "--info", "--debug") assert rc == 0, out assert re.search(r"DEBUG file", out), out @pytest.mark.smokevirt def test_smokevirt_nodes(firmware_node): """--nodes prints a node table containing the local node.""" rc, out = run_cli(firmware_node.port, "--nodes") assert rc == 0, out assert re.search(r"Connected to radio", out) # The local node shows up as the only entry on a single-node sim. assert re.search(r"(?i)user|name", out) @pytest.mark.smokevirt def test_smokevirt_qr(firmware_node): """--qr prints a non-empty ANSI QR code on stdout.""" rc, out = run_cli(firmware_node.port, "--qr") assert rc == 0, out assert len(out) > 500, f"QR output too short ({len(out)} bytes)" @pytest.mark.smokevirt def test_smokevirt_seriallog(firmware_node, tmp_path): """--seriallog FILE should write a serial log file.""" log_path = tmp_path / "serial.log" rc, _ = run_cli( firmware_node.port, "--info", "--seriallog", str(log_path) ) assert rc == 0 assert log_path.exists() @pytest.mark.smokevirt def test_smokevirt_test_requires_two_devices(firmware_node): """--test with a single device fails cleanly.""" rc, out = run_cli(firmware_node.port, "--test") assert rc != 0, out assert re.search(r"(?i)at least two devices", out) # --------------------------------------------------------------------------- # Section 2: Error paths (parameterized) # --------------------------------------------------------------------------- _INVALID_SETTING_CASES = [ pytest.param(("--get", "a_bad_setting"), id="get"), pytest.param(("--set", "a_bad_setting", "foo"), id="set"), pytest.param( ("--ch-set", "invalid_setting", "foo", "--ch-index", "0"), id="ch-set", ), ] @pytest.mark.smokevirt @pytest.mark.parametrize("args", _INVALID_SETTING_CASES) def test_smokevirt_invalid_setting(firmware_node, args): """Invalid --get/--set/--ch-set should print available choices.""" rc, out = run_cli(firmware_node.port, *args) assert rc == 0, out assert re.search(r"Choices are", out), out _PRIMARY_CHANNEL_GUARD_CASES = [ pytest.param(("--ch-del", "--ch-index", "0"), id="ch-del"), pytest.param(("--ch-disable", "--ch-index", "0"), id="ch-disable"), pytest.param(("--ch-enable", "--ch-index", "0"), id="ch-enable"), ] @pytest.mark.smokevirt @pytest.mark.parametrize("args", _PRIMARY_CHANNEL_GUARD_CASES) def test_smokevirt_primary_channel_guard(firmware_node, args): """Cannot delete/disable/enable the PRIMARY channel.""" rc, out = run_cli(firmware_node.port, *args) assert rc != 0, out assert re.search(r"(?i)cannot (delete|enable|disable)(.*primary)?", out), out # --------------------------------------------------------------------------- # Section 3: State-mutation tests (CLI mutates, library verifies) # --------------------------------------------------------------------------- # --- Owner ----------------------------------------------------------------- def _long_name(iface): user = iface.getMyUser() return user["longName"] if isinstance(user, dict) else user.long_name def _short_name(iface): user = iface.getMyUser() return user["shortName"] if isinstance(user, dict) else user.short_name def _assert_long_name(iface, expected): actual = _long_name(iface) assert actual == expected, f"longName: {actual!r} != {expected!r}" def _assert_short_name(iface, expected): actual = _short_name(iface) assert actual == expected, f"shortName: {actual!r} != {expected!r}" @pytest.mark.smokevirt def test_smokevirt_set_owner(firmware_node): """--set-owner changes longName persistently.""" cli_then_verify( firmware_node.port, ["--set-owner", "Alice Meshtastic"], lambda iface: _assert_long_name(iface, "Alice Meshtastic"), ) @pytest.mark.smokevirt def test_smokevirt_set_owner_short(firmware_node): """--set-owner-short updates the shortName persistently.""" cli_then_verify( firmware_node.port, ["--set-owner-short", "ALI"], lambda iface: _assert_short_name(iface, "ALI"), ) # --- Position -------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_set_location(firmware_node): """--setlat/--setlon/--setalt persist a fixed position.""" def check(iface): info = iface.getMyNodeInfo() pos = info.get("position", {}) or {} assert abs(float(pos.get("latitude", 0)) - 32.7767) < 1e-3, pos assert abs(float(pos.get("longitude", 0)) - (-96.7970)) < 1e-3, pos assert int(pos.get("altitude", 0)) == 1337, pos cli_then_verify( firmware_node.port, ["--setlat", "32.7767", "--setlon", "-96.7970", "--setalt", "1337"], check, ) @pytest.mark.smokevirt def test_smokevirt_remove_position(firmware_node): """--remove-position clears any fixed position.""" run_cli( firmware_node.port, "--setlat", "10", "--setlon", "20", "--setalt", "30", ) time.sleep(PAUSE_AFTER_CLI) def check(iface): info = iface.getMyNodeInfo() pos = info.get("position", {}) or {} # After remove, position should be empty or zero-lat/lon. assert "latitude" not in pos or float(pos.get("latitude", 0)) == 0, pos cli_then_verify( firmware_node.port, ["--remove-position"], check, ) # --- Channels -------------------------------------------------------------- def _channel(iface, idx): return iface.localNode.getChannelByChannelIndex(idx) def _assert_channel_role(iface, idx, expected_role): ch = _channel(iface, idx) assert ch is not None, f"channel {idx} missing" assert ch.role == expected_role, ( f"channel {idx} role: {channel_pb2.Channel.Role.Name(ch.role)} " f"!= {channel_pb2.Channel.Role.Name(expected_role)}" ) def _set_and_verify(port, args, verifier, expect_rc=0): """Run CLI, optional reboot pause, verify via fresh interface.""" cli_then_verify(port, list(args), verifier, expect_rc=expect_rc) @pytest.mark.smokevirt def test_smokevirt_ch_set_name(firmware_node): """--ch-set name on ch-index 0 persists.""" def check(iface): ch = _channel(iface, 0) assert ch is not None assert ch.settings.name == "MyChannel", ch cli_then_verify( firmware_node.port, ["--ch-set", "name", "MyChannel", "--ch-index", "0"], check, ) _CH_PRESETS = [ ("--ch-longmod", config_pb2.Config.LoRaConfig.ModemPreset.LONG_MODERATE), ("--ch-longfast", config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST), ("--ch-longturbo", config_pb2.Config.LoRaConfig.ModemPreset.LONG_TURBO), ("--ch-medslow", config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_SLOW), ("--ch-medfast", config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_FAST), ("--ch-shortslow", config_pb2.Config.LoRaConfig.ModemPreset.SHORT_SLOW), ("--ch-shortfast", config_pb2.Config.LoRaConfig.ModemPreset.SHORT_FAST), ("--ch-shortturbo", config_pb2.Config.LoRaConfig.ModemPreset.SHORT_TURBO), ] @pytest.mark.smokevirt @pytest.mark.parametrize("flag,expected_preset", _CH_PRESETS) def test_smokevirt_ch_preset(firmware_node, flag, expected_preset): """Each channel preset sets the LoRa modem_preset config value.""" def check(iface): actual = iface.localNode.localConfig.lora.modem_preset assert actual == expected_preset, ( f"modem_preset: {config_pb2.Config.LoRaConfig.ModemPreset.Name(actual)} " f"!= {config_pb2.Config.LoRaConfig.ModemPreset.Name(expected_preset)}" ) cli_then_verify(firmware_node.port, [flag], check) @pytest.mark.smokevirt def test_smokevirt_ch_set_downlink_uplink(firmware_node): """--ch-set downlink_enabled/uplink_enabled flips both flags.""" def check_disabled(iface): ch = _channel(iface, 0) assert ch is not None assert ch.settings.downlink_enabled is False, ch assert ch.settings.uplink_enabled is False, ch cli_then_verify( firmware_node.port, [ "--ch-set", "downlink_enabled", "false", "--ch-set", "uplink_enabled", "false", "--ch-index", "0", ], check_disabled, ) def check_enabled(iface): ch = _channel(iface, 0) assert ch is not None assert ch.settings.downlink_enabled is True, ch assert ch.settings.uplink_enabled is True, ch cli_then_verify( firmware_node.port, [ "--ch-set", "downlink_enabled", "true", "--ch-set", "uplink_enabled", "true", "--ch-index", "0", ], check_enabled, ) @pytest.mark.smokevirt def test_smokevirt_ch_add_then_del(firmware_node): """--ch-add creates a SECONDARY channel; --ch-del removes it.""" # Clean slate: ensure channel 1 is disabled. run_cli(firmware_node.port, "--ch-disable", "--ch-index", "1") time.sleep(PAUSE_AFTER_REBOOT) def check_added(iface): ch = _channel(iface, 1) assert ch is not None, "secondary channel missing after --ch-add" assert ch.role == channel_pb2.Channel.Role.SECONDARY, ch assert ch.settings.name == "testing", ch cli_then_verify( firmware_node.port, ["--ch-add", "testing"], check_added, ) def check_deleted(iface): ch = _channel(iface, 1) assert ch is None or ch.role == channel_pb2.Channel.Role.DISABLED, ( f"channel 1 still present after --ch-del: role={ch.role}" ) cli_then_verify( firmware_node.port, ["--ch-del", "--ch-index", "1"], check_deleted, ) @pytest.mark.smokevirt def test_smokevirt_ch_enable_disable(firmware_node): """--ch-disable and --ch-enable toggle a secondary channel's role.""" # Start clean: ensure channel 1 is disabled. run_cli(firmware_node.port, "--ch-disable", "--ch-index", "1") time.sleep(PAUSE_AFTER_REBOOT) run_cli(firmware_node.port, "--ch-add", "toggle_me") time.sleep(PAUSE_AFTER_REBOOT) def check_disabled(iface): _assert_channel_role(iface, 1, channel_pb2.Channel.Role.DISABLED) cli_then_verify( firmware_node.port, ["--ch-disable", "--ch-index", "1"], check_disabled, ) def check_enabled(iface): _assert_channel_role(iface, 1, channel_pb2.Channel.Role.SECONDARY) cli_then_verify( firmware_node.port, ["--ch-enable", "--ch-index", "1"], check_enabled, ) @pytest.mark.smokevirt def test_smokevirt_ch_del_needs_ch_index(firmware_node): """--ch-del without --ch-index should warn and exit non-zero.""" rc, out = run_cli(firmware_node.port, "--ch-del") assert rc != 0, out assert re.search(r"(?i)need to specify|ch-index", out), out # --- URL ------------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_seturl_default(firmware_node): """--seturl applies a known channel URL.""" url = "https://www.meshtastic.org/d/#CgUYAyIBAQ" # Use the fixture's already-connected TCPInterface so the same # connection handles the firmware restart after setURL. if firmware_node.iface is None: pytest.fail("fixture interface not connected") iface = firmware_node.iface iface.localNode.setURL(url) time.sleep(2.0) actual = iface.localNode.getURL() assert "meshtastic.org" in actual, f"not a channel URL: {actual}" # The firmware may reshape the URL and use slightly different base64 # for the same data (trailing bits are ignored during decode), so we # decode both and compare the protobuf payload prefix rather than # doing a substring match on the encoded form. _, _, frag = actual.partition("/#") if not frag: _, _, frag = actual.rpartition("#") missing = len(frag) % 4 if missing: frag += "=" * (4 - missing) actual_bytes: bytes = base64.urlsafe_b64decode(frag) _, _, efrag = url.partition("/#") missing = len(efrag) % 4 if missing: efrag += "=" * (4 - missing) expected_bytes: bytes = base64.urlsafe_b64decode(efrag) assert actual_bytes.startswith(expected_bytes), ( f"URL payload mismatch:\n" f" expected (hex): {expected_bytes.hex()}\n" f" actual (hex): {actual_bytes.hex()}" ) @pytest.mark.smokevirt def test_smokevirt_seturl_invalid(firmware_node): """--seturl with an undecodable URL fails cleanly.""" url = ( "https://www.meshtastic.org/c/#" "GAMiENTxuzogKQdZ8Lz_q89Oab8qB0RlZmF1bHQ=" ) rc, out = run_cli(firmware_node.port, "--seturl", url) assert rc != 0, out assert re.search(r"(?i)warning|no settings|invalid|error", out), out # --- Configure ------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_configure(firmware_node, tmp_path): """--configure applies an inline YAML config.""" cfg_path = tmp_path / "test_config.yaml" cfg_path.write_text("""\ owner: Bob TBeam config: position: fixed_position: true """) def check(iface): _assert_long_name(iface, "Bob TBeam") assert iface.localNode.localConfig.position.fixed_position is True cli_then_verify( firmware_node.port, ["--configure", str(cfg_path)], check, cli_timeout=90, ) # --- Ham ------------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_set_ham(firmware_node): """--set-ham sets the ham callsign as the device owner.""" def check(iface): _assert_long_name(iface, "KI1234") cli_then_verify( firmware_node.port, ["--set-ham", "KI1234"], check, ) # --- Network config -------------------------------------------------------- _NETWORK_SET_CASES = [ pytest.param( "network.wifi_ssid", "some_ssid", "network.wifi_ssid", "some_ssid", id="wifi_ssid", ), pytest.param( "network.wifi_psk", "temp1234", "network.wifi_psk", "temp1234", id="wifi_psk", ), ] @pytest.mark.smokevirt @pytest.mark.parametrize("cli_field,cli_value,lib_path,expected", _NETWORK_SET_CASES) def test_smokevirt_set_network( firmware_node, cli_field, cli_value, lib_path, expected ): """--set wifi_* should persist in LocalConfig.NetworkConfig fields.""" def check(iface): obj = iface.localNode.localConfig for part in lib_path.split("."): obj = getattr(obj, part) assert obj == expected, f"{lib_path}: {obj!r} != {expected!r}" cli_then_verify( firmware_node.port, ["--set", cli_field, cli_value], check, ) # --- --get valid settings -------------------------------------------------- _GET_VALID_CASES = [ pytest.param("network.wifi_ssid", id="wifi_ssid"), pytest.param("lora.hop_limit", id="lora_hop_limit"), pytest.param("position.position_broadcast_secs", id="pos_broadcast_secs"), ] @pytest.mark.smokevirt @pytest.mark.parametrize("field", _GET_VALID_CASES) def test_smokevirt_get_valid_setting(firmware_node, field): """--get of a known setting should print the field, rc==0.""" rc, out = run_cli(firmware_node.port, "--get", field) assert rc == 0, out # CLI prints dotted paths as the last segment; match case-insensitively. short = field.rsplit(".", 1)[-1] assert re.search(short, out, re.IGNORECASE), out # --- Position flags -------------------------------------------------------- _POS_FIELDS_INPUT = ["ALTITUDE", "ALTITUDE_MSL", "HEADING"] @pytest.mark.smokevirt def test_smokevirt_pos_fields(firmware_node): """--pos-fields should mount the requested bit flags in the position config.""" def check(iface): flags = iface.localNode.localConfig.position.position_flags PosFlags = config_pb2.Config.PositionConfig.PositionFlags for name in _POS_FIELDS_INPUT: assert flags & int(PosFlags.Value(name)), ( f"{name} bit not set in position_flags={flags:#x}" ) cli_then_verify( firmware_node.port, ["--pos-fields"] + _POS_FIELDS_INPUT, check, ) # --------------------------------------------------------------------------- # Section 4: Destructive command (factory reset) # --------------------------------------------------------------------------- @pytest.mark.smokevirt def test_smokevirt_factory_reset(firmware_node): """--set factory_reset true returns rc=0. We only assert the CLI command succeeds. The node's persistent state is wiped by the reset and the sandboxed sim process may exit shortly after; the function-scoped fixture teardown handles cleaning up the (likely-dead) meshtasticd process. """ rc, out = run_cli(firmware_node.port, "--set", "factory_reset", "true") assert rc == 0, out # The CLI prints a confirmation line before the firmware actually resets. assert re.search(r"(?i)factory.?reset|writing", out), out