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()