From dbc3c08c4d4c02331bdeeb4ea93fb58fae2ec0d6 Mon Sep 17 00:00:00 2001 From: ftc2 Date: Thu, 15 Oct 2026 22:59:44 -0500 Subject: [PATCH] Resolve CLI configuration names using protobuf descriptors Accept canonical protobuf field names and JSON names while retaining legacy camel-to-snake lookup compatibility. Fix the use12hClock profile round trip, share name resolution with CLI reads and writes, and keep nested assignments on the current submessage. Reject unknown YAML profile keys and failed assignments before starting configuration writes. Preserve supported aliases, update shipped examples, and provide consistent confirmations and original-input lookup errors. Add offline regression coverage for imports, exports, CLI field aliases, examples, nested paths, channel settings, and validation failures. --- exampleConfig.yaml | 10 +- example_config.yaml | 1 - meshtastic/__main__.py | 117 +++-- meshtastic/tests/test_config_profile.py | 584 ++++++++++++++++++++++++ meshtastic/tests/test_main.py | 2 +- 5 files changed, 664 insertions(+), 50 deletions(-) create mode 100644 meshtastic/tests/test_config_profile.py diff --git a/exampleConfig.yaml b/exampleConfig.yaml index a546e5a39..ac02f8638 100644 --- a/exampleConfig.yaml +++ b/exampleConfig.yaml @@ -9,8 +9,8 @@ location: lon: -93.88888 alt: 304 -userPrefs: - region: 1 - isAlwaysPowered: "true" - screenOnSecs: 31536000 - waitBluetoothSecs: 31536000 +config: + lora: + region: US + display: + screenOnSecs: 31536000 diff --git a/example_config.yaml b/example_config.yaml index acbdc332b..ea1bcdd42 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -36,7 +36,6 @@ config: positionFlags: 3 power: lsSecs: 300 - meshSdsTimeoutSecs: 7200 minWakeSecs: 10 sdsSecs: 4294967295 diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index ca1680a58..586f9794e 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -30,6 +30,7 @@ pyqrcode = None import yaml +from google.protobuf.descriptor import Descriptor, FieldDescriptor from google.protobuf.json_format import MessageToDict from pubsub import pub # type: ignore[import-untyped] @@ -138,11 +139,6 @@ def _printSetting(config_type, uni_name, pref_value, repeated): name = splitCompoundName(comp_name) wholeField = name[0] == name[1] # We want the whole field - camel_name = meshtastic.util.snake_to_camel(name[1]) - # Note: protobufs has the keys in snake_case, so snake internally - snake_name = meshtastic.util.camel_to_snake(name[1]) - uni_name = camel_name if mt_config.camel_case else snake_name - logger.debug(f"snake_name:{snake_name} camel_name:{camel_name}") logger.debug(f"use camel:{mt_config.camel_case}") # First validate the input @@ -151,28 +147,27 @@ def _printSetting(config_type, uni_name, pref_value, repeated): found: bool = False for config in [localConfig, moduleConfig]: objDesc = config.DESCRIPTOR - config_type = objDesc.fields_by_name.get(name[0]) - pref = "" #FIXME - is this correct to leave as an empty string if not found? - if config_type: - pref = config_type.message_type.fields_by_name.get(snake_name) + config_type = _resolve_field(objDesc, name[0]) + pref = None + if config_type and config_type.message_type is not None: + pref = _resolve_field(config_type.message_type, name[1]) if pref or wholeField: found = True break - if not found: - print( - f"{localConfig.__class__.__name__} and {moduleConfig.__class__.__name__} do not have attribute {uni_name}." - ) + if not found or config_type is None: + print(f"Unknown configuration field: {comp_name}") print("Choices are...") printConfig(localConfig) printConfig(moduleConfig) return False # Check if we need to request the config - if len(config.ListFields()) != 0 and not isinstance(pref, str): # if str, it's still the empty string, I think + if len(config.ListFields()) != 0: # read the value config_values = getattr(config, config_type.name) - if not wholeField: + if not wholeField and pref is not None: + uni_name = pref.json_name if mt_config.camel_case else pref.name # type: ignore[attr-defined] pref_value = getattr(config_values, pref.name) repeated = _is_repeated_field(pref) _printSetting(config_type, uni_name, pref_value, repeated) @@ -196,15 +191,31 @@ def splitCompoundName(comp_name: str) -> List[str]: return name +def _resolve_field(descriptor: Descriptor, name: str) -> Optional[FieldDescriptor]: + """Resolve protobuf and JSON names, retaining legacy camel-case conversion.""" + return ( + descriptor.fields_by_name.get(name) + or next((field for field in descriptor.fields if field.json_name == name), None) + or descriptor.fields_by_name.get(meshtastic.util.camel_to_snake(name)) + ) + + def traverseConfig(config_root, config, interface_config) -> bool: """Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference""" - snake_name = meshtastic.util.camel_to_snake(config_root) + descriptor = interface_config.DESCRIPTOR + for part in config_root.split("."): + field = _resolve_field(descriptor, part) + if field is None or field.message_type is None: + raise ValueError(f"Invalid configuration field: {config_root}") + descriptor = field.message_type + if not isinstance(config, dict): + raise ValueError(f"Expected a mapping for configuration field: {config_root}") for pref in config: - pref_name = f"{snake_name}.{pref}" + pref_name = f"{config_root}.{pref}" if isinstance(config[pref], dict): traverseConfig(pref_name, config[pref], interface_config) - else: - setPref(interface_config, pref_name, config[pref]) + elif not setPref(interface_config, pref_name, config[pref]): + raise ValueError(f"Invalid configuration field or value: {pref_name}") return True @@ -214,30 +225,35 @@ def setPref(config, comp_name, raw_val) -> bool: name = splitCompoundName(comp_name) - snake_name = meshtastic.util.camel_to_snake(name[-1]) - camel_name = meshtastic.util.snake_to_camel(name[-1]) - uni_name = camel_name if mt_config.camel_case else snake_name - logger.debug(f"snake_name:{snake_name}") - logger.debug(f"camel_name:{camel_name}") - objDesc = config.DESCRIPTOR config_part = config - config_type = objDesc.fields_by_name.get(name[0]) + resolved_fields: List[FieldDescriptor] = [] + config_type = _resolve_field(objDesc, name[0]) if config_type and config_type.message_type is not None: for name_part in name[1:-1]: - part_snake_name = meshtastic.util.camel_to_snake((name_part)) - config_part = getattr(config, config_type.name) - config_type = config_type.message_type.fields_by_name.get(part_snake_name) + if config_type is None or config_type.message_type is None: + config_type = None + break + resolved_fields.append(config_type) + config_part = getattr(config_part, config_type.name) + config_type = _resolve_field(config_type.message_type, name_part) pref = None if config_type and config_type.message_type is not None: - pref = config_type.message_type.fields_by_name.get(snake_name) + pref = _resolve_field(config_type.message_type, name[-1]) # Others like ChannelSettings are standalone - elif config_type: + elif config_type and len(name) == 2 and name[0] == name[1]: pref = config_type if (not pref) or (not config_type): return False + if config_type.message_type is not None: + resolved_fields.append(config_type) + resolved_fields.append(pref) + snake_name = pref.name + uni_name = pref.json_name if mt_config.camel_case else pref.name # type: ignore[attr-defined] + logger.debug(f"Resolved {comp_name} to protobuf field {snake_name}") + if isinstance(raw_val, str): val = meshtastic.util.fromStr(raw_val) else: @@ -297,10 +313,10 @@ def setPref(config, comp_name, raw_val) -> bool: setattr(config_values, pref.name, str(val)) elif type(val) == list: new_vals = [meshtastic.util.fromStr(x) for x in val] - config_values = getattr(config, config_type.name) + config_values = getattr(config_part, config_type.name) getattr(config_values, pref.name)[:] = new_vals else: - config_values = getattr(config, config_type.name) + config_values = getattr(config_part, config_type.name) if val == 0: # clear values print(f"Clearing {pref.name} list") @@ -313,8 +329,11 @@ def setPref(config, comp_name, raw_val) -> bool: getattr(config_values, pref.name)[:] = cur_vals return True - prefix = f"{'.'.join(name[0:-1])}." if config_type.message_type is not None else "" - print(f"Set {prefix}{uni_name} to {raw_val}") + display_path = ".".join( + field.json_name if mt_config.camel_case else field.name # type: ignore[attr-defined] + for field in resolved_fields + ) + print(f"Set {display_path} to {raw_val}") return True @@ -707,17 +726,15 @@ def onConnected(interface): fields = set() for pref in args.set: found = False - field = splitCompoundName(pref[0].lower())[0] + field = splitCompoundName(pref[0])[0] for config in [node.localConfig, node.moduleConfig]: - config_type = config.DESCRIPTOR.fields_by_name.get(field) + config_type = _resolve_field(config.DESCRIPTOR, field) if config_type: if len(config.ListFields()) == 0: - node.requestConfig( - config.DESCRIPTOR.fields_by_name.get(field) - ) + node.requestConfig(config_type) found = setPref(config, pref[0], pref[1]) if found: - fields.add(field) + fields.add(config_type.name) break if found: @@ -1373,6 +1390,14 @@ def _profile_from_yaml( before applying YAML values. It should return the current protobuf message for that section (or None) so that unmentioned fields are preserved. """ + supported_keys = { + "owner", "owner_short", "ownerShort", "channel_url", "channelUrl", + "canned_messages", "ringtone", "location", "config", "module_config", + } + for key in configuration: + if key not in supported_keys: + raise ValueError(f"Unknown configuration key: {key}") + profile = clientonly_pb2.DeviceProfile() if "owner" in configuration: _set_if_populated(profile, "long_name", configuration["owner"]) @@ -1399,7 +1424,10 @@ def _profile_from_yaml( profile.fixed_position.altitude = alt if "config" in configuration: for section in configuration["config"]: - section_snake = meshtastic.util.camel_to_snake(section) + field = _resolve_field(profile.config.DESCRIPTOR, section) + if field is None or field.message_type is None: + raise ValueError(f"Invalid configuration field: config.{section}") + section_snake = field.name if seed_fn is not None: seeded = seed_fn(section_snake, False) if seeded is not None: @@ -1407,7 +1435,10 @@ def _profile_from_yaml( traverseConfig(section, configuration["config"][section], profile.config) if "module_config" in configuration: for section in configuration["module_config"]: - section_snake = meshtastic.util.camel_to_snake(section) + field = _resolve_field(profile.module_config.DESCRIPTOR, section) + if field is None or field.message_type is None: + raise ValueError(f"Invalid configuration field: module_config.{section}") + section_snake = field.name if seed_fn is not None: seeded = seed_fn(section_snake, True) if seeded is not None: diff --git a/meshtastic/tests/test_config_profile.py b/meshtastic/tests/test_config_profile.py new file mode 100644 index 000000000..652990db8 --- /dev/null +++ b/meshtastic/tests/test_config_profile.py @@ -0,0 +1,584 @@ +"""Offline regression tests for configuration profile field names.""" +# pylint: disable=redefined-outer-name,too-many-positional-arguments + +from pathlib import Path +from typing import Any, Type +from unittest.mock import MagicMock + +import pytest +import yaml +from google.protobuf.message import Message + +from meshtastic import mt_config +from meshtastic.__main__ import _read_profile, export_config, getPref, main, setPref +from meshtastic.protobuf.channel_pb2 import ChannelSettings # pylint: disable=no-name-in-module +from meshtastic.protobuf.localonly_pb2 import LocalConfig, LocalModuleConfig # pylint: disable=no-name-in-module +from meshtastic.serial_interface import SerialInterface + +pytestmark = [pytest.mark.unit, pytest.mark.usefixtures("reset_mt_config")] + + +@pytest.mark.parametrize("fmt", ["auto", "yaml"]) +def test_shipped_configuration_example(fmt: str) -> None: + """Load the shipped example from disk and retain its configuration sections.""" + path = Path(__file__).resolve().parents[2] / "example_config.yaml" + + profile = _read_profile(str(path), fmt) + + assert profile.long_name == "Bob TBeam" + assert profile.config.power.ls_secs == 300 + assert profile.config.power.min_wake_secs == 10 + assert profile.config.power.sds_secs == 4294967295 + assert profile.config.lora.hop_limit == 3 + assert profile.module_config.telemetry.device_update_interval == 900 + + +@pytest.mark.parametrize("fmt", ["auto", "yaml"]) +def test_shipped_camel_case_example(fmt: str) -> None: + """The camel-case example contains current, importable configuration.""" + path = Path(__file__).resolve().parents[2] / "exampleConfig.yaml" + + profile = _read_profile(str(path), fmt) + + assert profile.long_name == "Bob TBeam" + assert profile.short_name == "BOB" + assert profile.channel_url + assert profile.config.lora.region == 1 + assert profile.config.display.screen_on_secs == 31536000 + + +@pytest.fixture +def profile_interface() -> MagicMock: + """Provide real protobuf configuration with a mocked radio transport.""" + iface = MagicMock(spec=SerialInterface) + iface.localNode = MagicMock() + iface.devPath = "offline" + iface.getLongName.return_value = None + iface.getShortName.return_value = None + iface.localNode.getURL.return_value = "" + iface.getCannedMessage.return_value = None + iface.getRingtone.return_value = None + iface.getMyNodeInfo.return_value = {} + iface.localNode.localConfig = LocalConfig() + iface.localNode.moduleConfig = LocalModuleConfig() + iface.getNode.return_value = iface.localNode + return iface + + +@pytest.mark.parametrize("camel_case", [False, True]) +@pytest.mark.parametrize("fmt", ["auto", "yaml"]) +def test_yaml_export_import_clock( + tmp_path: Path, profile_interface: MagicMock, camel_case: bool, fmt: str +) -> None: + """The CLI's own exported digit-bearing field survives profile loading.""" + mt_config.camel_case = camel_case + profile_interface.localNode.localConfig.display.use_12h_clock = True + exported = export_config(profile_interface) + assert yaml.safe_load(exported)["config"]["display"]["use12hClock"] is True + path = tmp_path / "profile.yaml" + path.write_text(exported, encoding="utf8") + + profile = _read_profile(str(path), fmt) + + assert profile.config.display.use_12h_clock is True + + +@pytest.mark.parametrize("clock_name", ["use12hClock", "use_12h_clock"]) +@pytest.mark.parametrize("camel_case", [False, True]) +def test_configure_field_names( + tmp_path: Path, + profile_interface: MagicMock, + monkeypatch: pytest.MonkeyPatch, + clock_name: str, + camel_case: bool, +) -> None: + """Import canonical/JSON names through main(), retaining unspecified fields.""" + mt_config.camel_case = camel_case + path = tmp_path / "profile.yaml" + path.write_text( + yaml.safe_dump( + { + "config": { + "display": {clock_name: True}, + "network": {"ipv4Config": {"ip": 123}, "wifiEnabled": True}, + "bluetooth": {"fixed_pin": 123456}, + "position": {"positionFlags": "ALTITUDE,SPEED"}, + }, + "module_config": { + "externalNotification": {"outputMs": 321}, + "mqtt": {"proxy_to_client_enabled": True}, + }, + } + ), + encoding="utf8", + ) + local_config = profile_interface.localNode.localConfig + local_config.display.screen_on_secs = 45 + profile_interface.localNode.moduleConfig.external_notification.enabled = True + monkeypatch.setattr("sys.argv", ["meshtastic", "--configure", str(path)]) + monkeypatch.setattr( + "meshtastic.serial_interface.SerialInterface", + lambda *_, **__: profile_interface, + ) + monkeypatch.setattr("meshtastic.__main__.time.sleep", lambda _: None) + + main() + + assert local_config.display.use_12h_clock is True + assert local_config.display.screen_on_secs == 45 + assert local_config.network.ipv4_config.ip == 123 + assert local_config.network.wifi_enabled is True + assert local_config.bluetooth.fixed_pin == 123456 + assert local_config.position.position_flags == 513 + module_config = profile_interface.localNode.moduleConfig + assert module_config.external_notification.output_ms == 321 + assert module_config.external_notification.enabled is True + assert module_config.mqtt.proxy_to_client_enabled is True + assert profile_interface.localNode.writeConfig.call_count == 6 + profile_interface.localNode.beginSettingsTransaction.assert_called_once() + profile_interface.localNode.commitSettingsTransaction.assert_called_once() + + +@pytest.mark.parametrize( + "container,section,values,error_path", + [ + ("config", "display", {"unknownClock": True}, "display.unknownClock"), + ("module_config", "mqtt", {"unknownSetting": True}, "mqtt.unknownSetting"), + ("config", "unknownSection", {}, "config.unknownSection"), + ("module_config", "unknownSection", {}, "module_config.unknownSection"), + ( + "config", + "network", + {"ipv4Config": {"unknownIp": 1}}, + "network.ipv4Config.unknownIp", + ), + ( + "module_config", + "mqtt", + {"mapReportSettings": {"unknownSetting": 1}}, + "mqtt.mapReportSettings.unknownSetting", + ), + ("config", "network", {"unknownNested": {}}, "network.unknownNested"), + ("config", "display", {"use12hClock": {}}, "display.use12hClock"), + ("config", "display", True, "display"), + ("config", "network", {"wifiPsk": "short"}, "network.wifiPsk"), + ("config", "lora", {"region": "UNKNOWN_REGION"}, "lora.region"), + ( + "config", + "position", + {"positionFlags": "UNKNOWN_FLAG"}, + "position.positionFlags", + ), + ("module_config", "serial", {"mode": "UNKNOWN_MODE"}, "serial.mode"), + ], +) +@pytest.mark.parametrize("fmt", ["auto", "yaml"]) +def test_configure_rejects_invalid_assignment( + tmp_path: Path, + profile_interface: MagicMock, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + container: str, + section: str, + values: Any, + error_path: str, + fmt: str, +) -> None: + """Invalid imports exit through the CLI error path before starting writes.""" + path = tmp_path / "invalid.yaml" + path.write_text(yaml.safe_dump({container: {section: values}}), encoding="utf8") + monkeypatch.setattr( + "sys.argv", ["meshtastic", "--configure", str(path), "--export-format", fmt] + ) + monkeypatch.setattr( + "meshtastic.serial_interface.SerialInterface", + lambda *_, **__: profile_interface, + ) + monkeypatch.setattr("meshtastic.__main__.time.sleep", lambda _: None) + + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "Aborting due to:" in out + assert error_path in out + profile_interface.localNode.beginSettingsTransaction.assert_not_called() + profile_interface.localNode.writeConfig.assert_not_called() + profile_interface.localNode.commitSettingsTransaction.assert_not_called() + profile_interface.close.assert_called() + + +@pytest.mark.parametrize( + "unknown_key", ["confg", "moduleConfig", "userPrefs", "metadata"] +) +@pytest.mark.parametrize("fmt", ["auto", "yaml"]) +def test_configure_rejects_unknown_top_level_key( + tmp_path: Path, + profile_interface: MagicMock, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + unknown_key: str, + fmt: str, +) -> None: + """Unknown top-level keys must not silently permit a partial import.""" + path = tmp_path / "invalid.yaml" + path.write_text( + yaml.safe_dump({"config": {"display": {"use12hClock": True}}, unknown_key: {}}), + encoding="utf8", + ) + monkeypatch.setattr( + "sys.argv", ["meshtastic", "--configure", str(path), "--export-format", fmt] + ) + monkeypatch.setattr( + "meshtastic.serial_interface.SerialInterface", + lambda *_, **__: profile_interface, + ) + + with pytest.raises(SystemExit) as exc: + main() + + assert exc.value.code == 1 + assert f"Unknown configuration key: {unknown_key}" in capsys.readouterr().out + assert profile_interface.localNode.localConfig.ListFields() == [] + profile_interface.localNode.beginSettingsTransaction.assert_not_called() + profile_interface.localNode.writeConfig.assert_not_called() + profile_interface.localNode.commitSettingsTransaction.assert_not_called() + profile_interface.close.assert_called() + + +@pytest.mark.parametrize( + "short_key,url_key", [("owner_short", "channel_url"), ("ownerShort", "channelUrl")] +) +def test_profile_top_level_aliases( + tmp_path: Path, short_key: str, url_key: str +) -> None: + """Strict top-level validation retains every supported profile setting.""" + path = tmp_path / "aliases.yaml" + path.write_text( + yaml.safe_dump( + { + "owner": "Owner", + short_key: "OWN", + url_key: "https://example.com/channel", + "canned_messages": "Yes|No", + "ringtone": "tune", + "location": {"lat": 1, "lon": 2, "alt": 3}, + "config": {}, + "module_config": {}, + } + ), + encoding="utf8", + ) + + profile = _read_profile(str(path), "yaml") + + assert profile.long_name == "Owner" + assert profile.short_name == "OWN" + assert profile.channel_url == "https://example.com/channel" + assert profile.canned_messages == "Yes|No" + assert profile.ringtone == "tune" + assert profile.fixed_position.latitude_i == 10000000 + assert profile.fixed_position.longitude_i == 20000000 + assert profile.fixed_position.altitude == 3 + + +@pytest.mark.parametrize( + "config_type,path,canonical,value", + [ + (LocalConfig, "display.use12hClock", "display.use_12h_clock", True), + (LocalConfig, "display.use_12h_clock", "display.use_12h_clock", True), + (LocalConfig, "network.ipv4Config.ip", "network.ipv4_config.ip", 123), + (LocalConfig, "network.ipv4_config.ip", "network.ipv4_config.ip", 123), + (LocalConfig, "bluetooth.FixedPin", "bluetooth.fixed_pin", 123456), + (LocalConfig, "lora.ignoreIncoming", "lora.ignore_incoming", ["1", "2"]), + ( + LocalModuleConfig, + "externalNotification.outputMs", + "external_notification.output_ms", + 123, + ), + ( + LocalModuleConfig, + "external_notification.output_ms", + "external_notification.output_ms", + 123, + ), + ( + LocalModuleConfig, + "mqtt.mapReportSettings.publishIntervalSecs", + "mqtt.map_report_settings.publish_interval_secs", + 60, + ), + ( + LocalModuleConfig, + "meshBeacon.broadcastOfferChannel.moduleSettings.positionPrecision", + "mesh_beacon.broadcast_offer_channel.module_settings.position_precision", + 24, + ), + ], +) +def test_set_pref_names( + config_type: Type[Message], + path: str, + canonical: str, + value: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + """Resolve every component while retaining legacy spelling and value handling.""" + config = config_type() + assert setPref(config, path, value) is True + actual = config + for part in canonical.split("."): + actual = getattr(actual, part) + assert actual == ([1, 2] if isinstance(value, list) else value) + assert capsys.readouterr().out == f"Set {canonical} to {value}\n" + + +@pytest.mark.parametrize("camel_case", [False, True]) +@pytest.mark.parametrize( + "path", + [ + "mqtt.mapReportSettings.publishIntervalSecs", + "mqtt.map_report_settings.publish_interval_secs", + ], +) +def test_set_pref_confirmation_style( + capsys: pytest.CaptureFixture[str], path: str, camel_case: bool +) -> None: + """Confirmation style applies to every resolved component, not the input spelling.""" + mt_config.camel_case = camel_case + config = LocalModuleConfig() + + assert setPref(config, path, 60) is True + + expected_path = ( + "mqtt.mapReportSettings.publishIntervalSecs" + if camel_case + else "mqtt.map_report_settings.publish_interval_secs" + ) + assert capsys.readouterr().out == f"Set {expected_path} to 60\n" + assert config.mqtt.map_report_settings.publish_interval_secs == 60 + + +@pytest.mark.parametrize("camel_case", [False, True]) +@pytest.mark.parametrize("name", ["uplink_enabled", "uplinkEnabled", "UplinkEnabled"]) +def test_set_channel_pref_names( + name: str, camel_case: bool, capsys: pytest.CaptureFixture[str] +) -> None: + """Standalone channel settings retain name aliases.""" + settings = ChannelSettings() + mt_config.camel_case = camel_case + assert setPref(settings, name, True) is True + assert settings.uplink_enabled is True + expected_name = "uplinkEnabled" if camel_case else "uplink_enabled" + assert capsys.readouterr().out == f"Set {expected_name} to True\n" + + +@pytest.mark.parametrize( + "path", + ["network.missing.ip", "network.wifiEnabled.ip", "network.missing.deeper.ip"], +) +def test_set_pref_unknown_path(path: str) -> None: + """Invalid intermediate components return failure without assignment.""" + config = LocalConfig() + assert setPref(config, path, 1) is False + assert config.ListFields() == [] + + +def test_yaml_round_trip_local_and_module_fields( + tmp_path: Path, profile_interface: MagicMock +) -> None: + """Exported ordinary, nested and digit-bearing values survive YAML import.""" + local_config = profile_interface.localNode.localConfig + local_config.display.use_12h_clock = True + local_config.bluetooth.fixed_pin = 123456 + local_config.network.ipv4_config.ip = 123 + module_config = profile_interface.localNode.moduleConfig + module_config.external_notification.output_ms = 321 + module_config.mqtt.map_report_settings.publish_interval_secs = 60 + module_config.mesh_beacon.broadcast_offer_channel.module_settings.position_precision = 24 + path = tmp_path / "roundtrip.yaml" + path.write_text(export_config(profile_interface), encoding="utf8") + + profile = _read_profile(str(path), "auto") + + assert profile.config.display == local_config.display + assert profile.config.bluetooth == local_config.bluetooth + assert profile.config.network == local_config.network + assert profile.module_config == module_config + + +@pytest.mark.parametrize( + "path,container,section,field,value", + [ + ("display.use12hClock", "localConfig", "display", "use_12h_clock", True), + ("display.use_12h_clock", "localConfig", "display", "use_12h_clock", True), + ( + "externalNotification.outputMs", + "moduleConfig", + "external_notification", + "output_ms", + 123, + ), + ( + "external_notification.output_ms", + "moduleConfig", + "external_notification", + "output_ms", + 123, + ), + ], +) +def test_cli_set_field_names( + profile_interface: MagicMock, + monkeypatch: pytest.MonkeyPatch, + path: str, + container: str, + section: str, + field: str, + value: Any, +) -> None: + """Direct --set resolves section aliases and writes the canonical section.""" + monkeypatch.setattr("sys.argv", ["meshtastic", "--set", path, str(value)]) + monkeypatch.setattr( + "meshtastic.serial_interface.SerialInterface", + lambda *_, **__: profile_interface, + ) + + main() + + config = getattr(profile_interface.localNode, container) + assert getattr(getattr(config, section), field) == value + profile_interface.localNode.writeConfig.assert_called_once_with(section) + + +@pytest.mark.parametrize("camel_case", [False, True]) +@pytest.mark.parametrize( + "path,container,section,field,value,json_name", + [ + ( + "display.use12hClock", + "localConfig", + "display", + "use_12h_clock", + True, + "use12hClock", + ), + ( + "display.use_12h_clock", + "localConfig", + "display", + "use_12h_clock", + True, + "use12hClock", + ), + ( + "bluetooth.FixedPin", + "localConfig", + "bluetooth", + "fixed_pin", + 123456, + "fixedPin", + ), + ( + "externalNotification.outputMs", + "moduleConfig", + "external_notification", + "output_ms", + 123, + "outputMs", + ), + ( + "external_notification.output_ms", + "moduleConfig", + "external_notification", + "output_ms", + 123, + "outputMs", + ), + ], +) +def test_cli_get_field_names( + profile_interface: MagicMock, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + camel_case: bool, + path: str, + container: str, + section: str, + field: str, + value: Any, + json_name: str, +) -> None: + """--get accepts the same local/module aliases as --set in either output mode.""" + mt_config.camel_case = camel_case + config = getattr(profile_interface.localNode, container) + setattr(getattr(config, section), field, value) + monkeypatch.setattr("sys.argv", ["meshtastic", "--get", path]) + monkeypatch.setattr( + "meshtastic.serial_interface.SerialInterface", + lambda *_, **__: profile_interface, + ) + + main() + + output_name = json_name if camel_case else field + assert f"{section}.{output_name}: {value}" in capsys.readouterr().out + profile_interface.localNode.requestConfig.assert_not_called() + profile_interface.localNode.writeConfig.assert_not_called() + + +@pytest.mark.parametrize( + "path", ["externalNotification", "externalNotification.outputMs"] +) +def test_get_pref_requests_unavailable_section( + profile_interface: MagicMock, path: str +) -> None: + """A section alias still requests the canonical config when no values are cached.""" + node = profile_interface.localNode + + assert getPref(node, path) is True + + node.requestConfig.assert_called_once_with( + node.moduleConfig.DESCRIPTOR.fields_by_name["external_notification"] + ) + + +def test_get_pref_whole_section_alias( + profile_interface: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Whole-section queries retain their existing output behavior.""" + node = profile_interface.localNode + node.moduleConfig.external_notification.output_ms = 123 + + assert getPref(node, "externalNotification") is True + + assert "external_notification.output_ms: 123" in capsys.readouterr().out + node.requestConfig.assert_not_called() + + +@pytest.mark.parametrize("camel_case", [False, True]) +@pytest.mark.parametrize( + "path", + [ + "display.unknownClock", + "externalNotification.unknownField", + "unknownSection", + "unknownSection.outputMs", + ], +) +def test_get_pref_unknown_field( + profile_interface: MagicMock, + capsys: pytest.CaptureFixture[str], + path: str, + camel_case: bool, +) -> None: + """Unknown queries keep the existing failure result and available choices.""" + mt_config.camel_case = camel_case + assert getPref(profile_interface.localNode, path) is False + + assert capsys.readouterr().out.startswith( + f"Unknown configuration field: {path}\nChoices are...\n" + ) + profile_interface.localNode.requestConfig.assert_not_called() diff --git a/meshtastic/tests/test_main.py b/meshtastic/tests/test_main.py index e0d65f60f..6b69f9136 100644 --- a/meshtastic/tests/test_main.py +++ b/meshtastic/tests/test_main.py @@ -1768,7 +1768,7 @@ def test_main_get_with_invalid(capsys): main() out, err = capsys.readouterr() assert re.search(r"Connected to radio", out, re.MULTILINE) - assert re.search(r"do not have attribute foo", out, re.MULTILINE) + assert re.search(r"Unknown configuration field: foo", out, re.MULTILINE) assert re.search(r"Choices are...", out, re.MULTILINE) assert err == "" mo.assert_called()