mirror of
https://github.com/meshtastic/python.git
synced 2026-07-31 09:06:28 -04:00
Refactor DeviceProfile import/export and harden handling
- Unify parsing for yaml/DeviceProfile paths - Improve autodetection - Ensure partial-config behavior with yaml - gate configuration to local node (avoiding some pre-existing bugs) - gate fixed position updates behind explicit opt-in via position settings field - use setOwner properly - tests, tests, and more tests
This commit is contained in:
@@ -5,9 +5,11 @@
|
|||||||
# later we can have a separate changelist to refactor main.py into smaller files
|
# later we can have a separate changelist to refactor main.py into smaller files
|
||||||
# pylint: disable=R0917,C0302
|
# pylint: disable=R0917,C0302
|
||||||
|
|
||||||
from typing import List, Optional, Union
|
from typing import Any, Callable, List, Optional, Union
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
argcomplete: Union[None, ModuleType] = None
|
argcomplete: Union[None, ModuleType] = None
|
||||||
@@ -61,7 +63,7 @@ except ImportError as e:
|
|||||||
have_powermon = False
|
have_powermon = False
|
||||||
powermon_exception = e
|
powermon_exception = e
|
||||||
meter = None
|
meter = None
|
||||||
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, portnums_pb2, mesh_pb2
|
from meshtastic.protobuf import admin_pb2, channel_pb2, clientonly_pb2, config_pb2, portnums_pb2, mesh_pb2
|
||||||
from meshtastic.version import get_active_version
|
from meshtastic.version import get_active_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -742,207 +744,105 @@ def onConnected(interface):
|
|||||||
printConfig(node.moduleConfig)
|
printConfig(node.moduleConfig)
|
||||||
|
|
||||||
if args.configure:
|
if args.configure:
|
||||||
|
if args.dest != BROADCAST_ADDR:
|
||||||
|
print("Configuring remote nodes is not supported.")
|
||||||
|
return
|
||||||
|
|
||||||
filename = args.configure[0]
|
filename = args.configure[0]
|
||||||
|
fmt = getattr(args, "export_format", "auto")
|
||||||
is_binary = False
|
|
||||||
if hasattr(args, "export_format") and args.export_format in ("binary", "protobuf"):
|
|
||||||
is_binary = True
|
|
||||||
elif hasattr(args, "export_format") and args.export_format == "yaml":
|
|
||||||
is_binary = False
|
|
||||||
else:
|
|
||||||
# Autodetection
|
|
||||||
try:
|
|
||||||
with open(filename, "r", encoding="utf8") as file:
|
|
||||||
configuration = yaml.safe_load(file)
|
|
||||||
if isinstance(configuration, dict):
|
|
||||||
is_binary = False
|
|
||||||
else:
|
|
||||||
is_binary = True
|
|
||||||
except Exception:
|
|
||||||
is_binary = True
|
|
||||||
|
|
||||||
if is_binary:
|
def _seed_config(section: str, is_module: bool) -> Optional[Any]:
|
||||||
from meshtastic.protobuf import clientonly_pb2
|
"""Return the current local-node config section, if present."""
|
||||||
# Read the binary protobuf file
|
config_obj = (
|
||||||
with open(filename, "rb") as file:
|
interface.localNode.moduleConfig
|
||||||
profile = clientonly_pb2.DeviceProfile()
|
if is_module
|
||||||
profile.ParseFromString(file.read())
|
else interface.localNode.localConfig
|
||||||
|
)
|
||||||
closeNow = True
|
return getattr(config_obj, section) if config_obj.HasField(section) else None
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).beginSettingsTransaction()
|
|
||||||
|
|
||||||
if profile.long_name:
|
profile = _read_profile(filename, fmt, seed_fn=_seed_config)
|
||||||
print(f"Setting device owner to {profile.long_name}")
|
|
||||||
waitForAckNak = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(profile.long_name)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if profile.short_name:
|
closeNow = True
|
||||||
print(f"Setting device owner short to {profile.short_name}")
|
interface.getNode(args.dest, False, **getNode_kwargs).beginSettingsTransaction()
|
||||||
waitForAckNak = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
|
||||||
long_name=None, short_name=profile.short_name
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if profile.channel_url:
|
# Owner: combine long_name and short_name into a single setOwner call.
|
||||||
print(f"Setting channel url to {profile.channel_url}")
|
# NOTE: is_licensed and is_unmessagable are not yet in DeviceProfile;
|
||||||
interface.getNode(args.dest, **getNode_kwargs).setURL(profile.channel_url)
|
# ref: https://github.com/meshtastic/protobufs/pull/971
|
||||||
time.sleep(0.5)
|
long_name = str(profile.long_name).strip() if profile.long_name else None
|
||||||
|
short_name = str(profile.short_name).strip() if profile.short_name else None
|
||||||
|
|
||||||
if profile.canned_messages:
|
if long_name is not None and not long_name:
|
||||||
print(f"Setting canned message messages to {profile.canned_messages}")
|
meshtastic.util.our_exit("ERROR: Long Name cannot be empty or contain only whitespace characters")
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_canned_message(profile.canned_messages)
|
if short_name is not None and not short_name:
|
||||||
time.sleep(0.5)
|
meshtastic.util.our_exit("ERROR: Short Name cannot be empty or contain only whitespace characters")
|
||||||
|
|
||||||
if profile.ringtone:
|
if long_name or short_name:
|
||||||
print(f"Setting ringtone to {profile.ringtone}")
|
if long_name and short_name:
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_ringtone(profile.ringtone)
|
print(f"Setting device owner to {long_name} and short name to {short_name}")
|
||||||
time.sleep(0.5)
|
elif long_name:
|
||||||
|
print(f"Setting device owner to {long_name}")
|
||||||
|
else:
|
||||||
|
print(f"Setting device owner short to {short_name}")
|
||||||
|
waitForAckNak = True
|
||||||
|
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
||||||
|
long_name=long_name, short_name=short_name
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if profile.HasField("fixed_position"):
|
if profile.channel_url:
|
||||||
# Convert high-precision integer coordinates back to floating point
|
print(f"Setting channel url to {profile.channel_url}")
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).setURL(profile.channel_url)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if profile.canned_messages:
|
||||||
|
print(f"Setting canned message messages to {profile.canned_messages}")
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).set_canned_message(profile.canned_messages)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if profile.ringtone:
|
||||||
|
print(f"Setting ringtone to {profile.ringtone}")
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).set_ringtone(profile.ringtone)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if profile.HasField("fixed_position"):
|
||||||
|
# Only send the admin message when the position config
|
||||||
|
# explicitly opts into fixed_position. The admin message
|
||||||
|
# unconditionally enables the flag on the device, so we
|
||||||
|
# require an explicit opt-in from the profile's config.
|
||||||
|
if (
|
||||||
|
profile.HasField("config")
|
||||||
|
and profile.config.HasField("position")
|
||||||
|
and profile.config.position.fixed_position
|
||||||
|
):
|
||||||
pos = profile.fixed_position
|
pos = profile.fixed_position
|
||||||
lat = pos.latitude_i / 1e7 if pos.latitude_i else 0.0
|
lat = float(pos.latitude_i * Decimal("1e-7")) if pos.latitude_i else 0.0
|
||||||
lon = pos.longitude_i / 1e7 if pos.longitude_i else 0.0
|
lon = float(pos.longitude_i * Decimal("1e-7")) if pos.longitude_i else 0.0
|
||||||
alt = pos.altitude if pos.altitude else 0
|
alt = pos.altitude if pos.altitude else 0
|
||||||
print(f"Fixing altitude at {alt} meters")
|
print(f"Fixing altitude at {alt} meters")
|
||||||
print(f"Fixing latitude at {lat} degrees")
|
print(f"Fixing latitude at {lat} degrees")
|
||||||
print(f"Fixing longitude at {lon} degrees")
|
print(f"Fixing longitude at {lon} degrees")
|
||||||
print("Setting device position")
|
print("Setting device position")
|
||||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
interface.getNode(args.dest, False, **getNode_kwargs).setFixedPosition(lat, lon, alt)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if profile.HasField("config"):
|
if profile.HasField("config"):
|
||||||
# Dynamically iterate through populated config sections (e.g. lora, display)
|
localConfig = interface.getNode(args.dest, **getNode_kwargs).localConfig
|
||||||
localConfig = interface.getNode(args.dest, **getNode_kwargs).localConfig
|
for field in profile.config.DESCRIPTOR.fields:
|
||||||
for field in profile.config.DESCRIPTOR.fields:
|
if field.message_type is not None and profile.config.HasField(field.name):
|
||||||
if profile.config.HasField(field.name):
|
getattr(localConfig, field.name).CopyFrom(getattr(profile.config, field.name))
|
||||||
getattr(localConfig, field.name).CopyFrom(getattr(profile.config, field.name))
|
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if profile.HasField("module_config"):
|
|
||||||
# Dynamically iterate through populated module config sections (e.g. telemetry, serial)
|
|
||||||
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
|
||||||
for field in profile.module_config.DESCRIPTOR.fields:
|
|
||||||
if profile.module_config.HasField(field.name):
|
|
||||||
getattr(moduleConfig, field.name).CopyFrom(getattr(profile.module_config, field.name))
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).commitSettingsTransaction()
|
|
||||||
print("Writing modified configuration to device")
|
|
||||||
|
|
||||||
else:
|
|
||||||
with open(filename, "r", encoding="utf8") as file:
|
|
||||||
configuration = yaml.safe_load(file)
|
|
||||||
closeNow = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).beginSettingsTransaction()
|
|
||||||
|
|
||||||
if "owner" in configuration:
|
|
||||||
# Validate owner name before setting
|
|
||||||
owner_name = str(configuration["owner"]).strip()
|
|
||||||
if not owner_name:
|
|
||||||
meshtastic.util.our_exit("ERROR: Long Name cannot be empty or contain only whitespace characters")
|
|
||||||
print(f"Setting device owner to {configuration['owner']}")
|
|
||||||
waitForAckNak = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(configuration["owner"])
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "owner_short" in configuration:
|
|
||||||
# Validate owner short name before setting
|
|
||||||
owner_short_name = str(configuration["owner_short"]).strip()
|
|
||||||
if not owner_short_name:
|
|
||||||
meshtastic.util.our_exit("ERROR: Short Name cannot be empty or contain only whitespace characters")
|
|
||||||
print(
|
|
||||||
f"Setting device owner short to {configuration['owner_short']}"
|
|
||||||
)
|
|
||||||
waitForAckNak = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
|
||||||
long_name=None, short_name=configuration["owner_short"]
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "ownerShort" in configuration:
|
|
||||||
# Validate owner short name before setting
|
|
||||||
owner_short_name = str(configuration["ownerShort"]).strip()
|
|
||||||
if not owner_short_name:
|
|
||||||
meshtastic.util.our_exit("ERROR: Short Name cannot be empty or contain only whitespace characters")
|
|
||||||
print(
|
|
||||||
f"Setting device owner short to {configuration['ownerShort']}"
|
|
||||||
)
|
|
||||||
waitForAckNak = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
|
||||||
long_name=None, short_name=configuration["ownerShort"]
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "channel_url" in configuration:
|
|
||||||
print("Setting channel url to", configuration["channel_url"])
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).setURL(configuration["channel_url"])
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "channelUrl" in configuration:
|
|
||||||
print("Setting channel url to", configuration["channelUrl"])
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).setURL(configuration["channelUrl"])
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "canned_messages" in configuration:
|
|
||||||
print("Setting canned message messages to", configuration["canned_messages"])
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_canned_message(configuration["canned_messages"])
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "ringtone" in configuration:
|
|
||||||
print("Setting ringtone to", configuration["ringtone"])
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_ringtone(configuration["ringtone"])
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "location" in configuration:
|
|
||||||
alt = 0
|
|
||||||
lat = 0.0
|
|
||||||
lon = 0.0
|
|
||||||
localConfig = interface.localNode.localConfig
|
|
||||||
|
|
||||||
if "alt" in configuration["location"]:
|
|
||||||
alt = int(configuration["location"]["alt"] or 0)
|
|
||||||
print(f"Fixing altitude at {alt} meters")
|
|
||||||
if "lat" in configuration["location"]:
|
|
||||||
lat = float(configuration["location"]["lat"] or 0)
|
|
||||||
print(f"Fixing latitude at {lat} degrees")
|
|
||||||
if "lon" in configuration["location"]:
|
|
||||||
lon = float(configuration["location"]["lon"] or 0)
|
|
||||||
print(f"Fixing longitude at {lon} degrees")
|
|
||||||
print("Setting device position")
|
|
||||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if "config" in configuration:
|
|
||||||
localConfig = interface.getNode(args.dest, **getNode_kwargs).localConfig
|
|
||||||
for section in configuration["config"]:
|
|
||||||
traverseConfig(
|
|
||||||
section, configuration["config"][section], localConfig
|
|
||||||
)
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(
|
|
||||||
meshtastic.util.camel_to_snake(section)
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "module_config" in configuration:
|
if profile.HasField("module_config"):
|
||||||
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
||||||
for section in configuration["module_config"]:
|
for field in profile.module_config.DESCRIPTOR.fields:
|
||||||
traverseConfig(
|
if field.message_type is not None and profile.module_config.HasField(field.name):
|
||||||
section,
|
getattr(moduleConfig, field.name).CopyFrom(getattr(profile.module_config, field.name))
|
||||||
configuration["module_config"][section],
|
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
||||||
moduleConfig,
|
|
||||||
)
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(
|
|
||||||
meshtastic.util.camel_to_snake(section)
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).commitSettingsTransaction()
|
interface.getNode(args.dest, False, **getNode_kwargs).commitSettingsTransaction()
|
||||||
print("Writing modified configuration to device")
|
print("Writing modified configuration to device")
|
||||||
|
|
||||||
if args.export_config:
|
if args.export_config:
|
||||||
if args.dest != BROADCAST_ADDR:
|
if args.dest != BROADCAST_ADDR:
|
||||||
@@ -952,24 +852,28 @@ def onConnected(interface):
|
|||||||
closeNow = True
|
closeNow = True
|
||||||
|
|
||||||
is_binary = False
|
is_binary = False
|
||||||
if hasattr(args, "export_format") and args.export_format in ("binary", "protobuf"):
|
fmt = getattr(args, "export_format", "auto")
|
||||||
|
if fmt in ("binary", "protobuf"):
|
||||||
is_binary = True
|
is_binary = True
|
||||||
elif hasattr(args, "export_format") and args.export_format == "yaml":
|
elif fmt == "yaml":
|
||||||
is_binary = False
|
is_binary = False
|
||||||
else:
|
else:
|
||||||
is_binary = args.export_config.endswith(".cfg")
|
is_binary = args.export_config.endswith(".cfg")
|
||||||
|
|
||||||
if is_binary:
|
if is_binary:
|
||||||
config_bytes = export_profile(interface)
|
config_bytes = export_profile(interface)
|
||||||
try:
|
if args.export_config == "-":
|
||||||
with open(args.export_config, "wb") as f:
|
sys.stdout.buffer.write(config_bytes)
|
||||||
f.write(config_bytes)
|
else:
|
||||||
print(f"Exported profile to {args.export_config}")
|
try:
|
||||||
except Exception as e:
|
with open(args.export_config, "wb") as f:
|
||||||
meshtastic.util.our_exit(f"ERROR: Failed to write profile file: {e}")
|
f.write(config_bytes)
|
||||||
|
print(f"Exported profile to {args.export_config}")
|
||||||
|
except Exception as e:
|
||||||
|
meshtastic.util.our_exit(f"ERROR: Failed to write profile file: {e}")
|
||||||
else:
|
else:
|
||||||
config_txt = export_config(interface)
|
config_txt = export_config(interface)
|
||||||
|
|
||||||
if args.export_config == "-":
|
if args.export_config == "-":
|
||||||
# Output to stdout (preserves legacy use of `> file.yaml`)
|
# Output to stdout (preserves legacy use of `> file.yaml`)
|
||||||
print(config_txt)
|
print(config_txt)
|
||||||
@@ -1373,7 +1277,7 @@ def export_config(interface) -> str:
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
alt = None
|
alt = None
|
||||||
if pos:
|
if pos and interface.localNode.localConfig.position.fixed_position:
|
||||||
lat = pos.get("latitude")
|
lat = pos.get("latitude")
|
||||||
lon = pos.get("longitude")
|
lon = pos.get("longitude")
|
||||||
alt = pos.get("altitude")
|
alt = pos.get("altitude")
|
||||||
@@ -1417,7 +1321,7 @@ def export_config(interface) -> str:
|
|||||||
for i in range(len(prefs[pref]['adminKey'])):
|
for i in range(len(prefs[pref]['adminKey'])):
|
||||||
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
||||||
if mt_config.camel_case:
|
if mt_config.camel_case:
|
||||||
configObj["config"] = config #Identical command here and 2 lines below?
|
configObj["config"] = prefs
|
||||||
else:
|
else:
|
||||||
configObj["config"] = config
|
configObj["config"] = config
|
||||||
|
|
||||||
@@ -1442,19 +1346,129 @@ def export_config(interface) -> str:
|
|||||||
config_txt += yaml.dump(configObj)
|
config_txt += yaml.dump(configObj)
|
||||||
return config_txt
|
return config_txt
|
||||||
|
|
||||||
|
def _set_if_populated(profile, field_name, value):
|
||||||
|
val = str(value).strip()
|
||||||
|
if val:
|
||||||
|
setattr(profile, field_name, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_from_yaml(
|
||||||
|
configuration: dict,
|
||||||
|
seed_fn: Optional[Callable[[str, bool], Optional[Any]]] = None,
|
||||||
|
) -> clientonly_pb2.DeviceProfile:
|
||||||
|
"""Convert a YAML config dict to a DeviceProfile protobuf for uniform import.
|
||||||
|
|
||||||
|
If seed_fn is provided, it is called for each config/module_config section
|
||||||
|
before applying YAML values. It should return the current protobuf message
|
||||||
|
for that section (or None) so that unmentioned fields are preserved.
|
||||||
|
"""
|
||||||
|
profile = clientonly_pb2.DeviceProfile()
|
||||||
|
if "owner" in configuration:
|
||||||
|
_set_if_populated(profile, "long_name", configuration["owner"])
|
||||||
|
if "owner_short" in configuration:
|
||||||
|
_set_if_populated(profile, "short_name", configuration["owner_short"])
|
||||||
|
elif "ownerShort" in configuration:
|
||||||
|
_set_if_populated(profile, "short_name", configuration["ownerShort"])
|
||||||
|
if "channel_url" in configuration:
|
||||||
|
_set_if_populated(profile, "channel_url", configuration["channel_url"])
|
||||||
|
elif "channelUrl" in configuration:
|
||||||
|
_set_if_populated(profile, "channel_url", configuration["channelUrl"])
|
||||||
|
if "canned_messages" in configuration:
|
||||||
|
_set_if_populated(profile, "canned_messages", configuration["canned_messages"])
|
||||||
|
if "ringtone" in configuration:
|
||||||
|
_set_if_populated(profile, "ringtone", configuration["ringtone"])
|
||||||
|
loc = configuration.get("location")
|
||||||
|
if loc:
|
||||||
|
lat = float(loc.get("lat", 0) or 0)
|
||||||
|
lon = float(loc.get("lon", 0) or 0)
|
||||||
|
alt = int(loc.get("alt", 0) or 0)
|
||||||
|
if lat or lon or alt:
|
||||||
|
profile.fixed_position.latitude_i = int(Decimal(str(lat)) * Decimal("1e7"))
|
||||||
|
profile.fixed_position.longitude_i = int(Decimal(str(lon)) * Decimal("1e7"))
|
||||||
|
profile.fixed_position.altitude = alt
|
||||||
|
if "config" in configuration:
|
||||||
|
for section in configuration["config"]:
|
||||||
|
section_snake = meshtastic.util.camel_to_snake(section)
|
||||||
|
if seed_fn is not None:
|
||||||
|
seeded = seed_fn(section_snake, False)
|
||||||
|
if seeded is not None:
|
||||||
|
getattr(profile.config, section_snake).CopyFrom(seeded)
|
||||||
|
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)
|
||||||
|
if seed_fn is not None:
|
||||||
|
seeded = seed_fn(section_snake, True)
|
||||||
|
if seeded is not None:
|
||||||
|
getattr(profile.module_config, section_snake).CopyFrom(seeded)
|
||||||
|
traverseConfig(section, configuration["module_config"][section], profile.module_config)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _read_profile(
|
||||||
|
filename: str,
|
||||||
|
fmt: str,
|
||||||
|
seed_fn: Optional[Callable[[str, bool], Optional[Any]]] = None,
|
||||||
|
) -> clientonly_pb2.DeviceProfile:
|
||||||
|
"""Read a config file and return a DeviceProfile, autodetecting format by content."""
|
||||||
|
with open(filename, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
|
||||||
|
if fmt in ("binary", "protobuf"):
|
||||||
|
try:
|
||||||
|
return _parse_profile_bytes(raw)
|
||||||
|
except Exception as e:
|
||||||
|
meshtastic.util.our_exit(
|
||||||
|
f"ERROR: {filename} is not a valid DeviceProfile (.cfg) file: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fmt == "yaml":
|
||||||
|
try:
|
||||||
|
configuration = yaml.safe_load(raw.decode("utf8"))
|
||||||
|
except (UnicodeDecodeError, yaml.YAMLError) as e:
|
||||||
|
meshtastic.util.our_exit(
|
||||||
|
f"ERROR: {filename} is not a valid YAML config (expected UTF-8 YAML): {e}"
|
||||||
|
)
|
||||||
|
if not isinstance(configuration, dict):
|
||||||
|
meshtastic.util.our_exit(
|
||||||
|
f"ERROR: {filename} is not a valid YAML config (expected a mapping)."
|
||||||
|
)
|
||||||
|
return _profile_from_yaml(configuration, seed_fn=seed_fn)
|
||||||
|
|
||||||
|
# Auto: try YAML first (text files), fall back to protobuf (binary files).
|
||||||
|
try:
|
||||||
|
configuration = yaml.safe_load(raw.decode("utf8"))
|
||||||
|
if isinstance(configuration, dict):
|
||||||
|
return _profile_from_yaml(configuration, seed_fn=seed_fn)
|
||||||
|
except (UnicodeDecodeError, yaml.YAMLError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _parse_profile_bytes(raw)
|
||||||
|
except Exception as e:
|
||||||
|
meshtastic.util.our_exit(
|
||||||
|
f"ERROR: {filename} is not a valid YAML config or DeviceProfile (.cfg) file: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_profile_bytes(raw: bytes) -> clientonly_pb2.DeviceProfile:
|
||||||
|
"""Parse raw bytes as a DeviceProfile protobuf, raising on failure."""
|
||||||
|
profile = clientonly_pb2.DeviceProfile()
|
||||||
|
profile.ParseFromString(raw)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def export_profile(interface) -> bytes:
|
def export_profile(interface) -> bytes:
|
||||||
"""used in --export-config for binary .cfg files"""
|
"""used in --export-config for binary .cfg files"""
|
||||||
from meshtastic.protobuf import clientonly_pb2
|
|
||||||
|
|
||||||
profile = clientonly_pb2.DeviceProfile()
|
profile = clientonly_pb2.DeviceProfile()
|
||||||
|
|
||||||
owner = interface.getLongName()
|
owner = interface.getLongName()
|
||||||
owner_short = interface.getShortName()
|
owner_short = interface.getShortName()
|
||||||
channel_url = interface.localNode.getURL()
|
channel_url = interface.localNode.getURL()
|
||||||
myinfo = interface.getMyNodeInfo()
|
myinfo = interface.getMyNodeInfo()
|
||||||
canned_messages = interface.getCannedMessage()
|
canned_messages = interface.getCannedMessage()
|
||||||
ringtone = interface.getRingtone()
|
ringtone = interface.getRingtone()
|
||||||
|
|
||||||
if owner:
|
if owner:
|
||||||
profile.long_name = owner
|
profile.long_name = owner
|
||||||
if owner_short:
|
if owner_short:
|
||||||
@@ -1465,24 +1479,25 @@ def export_profile(interface) -> bytes:
|
|||||||
profile.canned_messages = canned_messages
|
profile.canned_messages = canned_messages
|
||||||
if ringtone:
|
if ringtone:
|
||||||
profile.ringtone = ringtone
|
profile.ringtone = ringtone
|
||||||
|
|
||||||
profile.config.CopyFrom(interface.localNode.localConfig)
|
profile.config.CopyFrom(interface.localNode.localConfig)
|
||||||
profile.module_config.CopyFrom(interface.localNode.moduleConfig)
|
profile.module_config.CopyFrom(interface.localNode.moduleConfig)
|
||||||
|
|
||||||
pos = myinfo.get("position")
|
if interface.localNode.localConfig.position.fixed_position:
|
||||||
if pos:
|
pos = myinfo.get("position")
|
||||||
lat = pos.get("latitude")
|
if pos:
|
||||||
lon = pos.get("longitude")
|
lat = pos.get("latitude")
|
||||||
alt = pos.get("altitude")
|
lon = pos.get("longitude")
|
||||||
|
alt = pos.get("altitude")
|
||||||
if lat or lon or alt:
|
|
||||||
if lat:
|
if lat or lon or alt:
|
||||||
profile.fixed_position.latitude_i = int(lat * 1e7)
|
if lat:
|
||||||
if lon:
|
profile.fixed_position.latitude_i = int(Decimal(str(lat)) * Decimal("1e7"))
|
||||||
profile.fixed_position.longitude_i = int(lon * 1e7)
|
if lon:
|
||||||
if alt:
|
profile.fixed_position.longitude_i = int(Decimal(str(lon)) * Decimal("1e7"))
|
||||||
profile.fixed_position.altitude = int(alt)
|
if alt:
|
||||||
|
profile.fixed_position.altitude = int(alt)
|
||||||
|
|
||||||
return profile.SerializeToString()
|
return profile.SerializeToString()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import tempfile
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import mock_open, MagicMock, patch
|
from unittest.mock import mock_open, MagicMock, patch
|
||||||
|
|
||||||
|
import yaml
|
||||||
import pytest
|
import pytest
|
||||||
import meshtastic.__main__ as mt_main
|
import meshtastic.__main__ as mt_main
|
||||||
|
|
||||||
from meshtastic.__main__ import (
|
from meshtastic.__main__ import (
|
||||||
export_config,
|
export_config,
|
||||||
|
export_profile,
|
||||||
initParser,
|
initParser,
|
||||||
main,
|
main,
|
||||||
onConnection,
|
onConnection,
|
||||||
@@ -23,11 +25,14 @@ from meshtastic.__main__ import (
|
|||||||
setPref,
|
setPref,
|
||||||
tunnelMain,
|
tunnelMain,
|
||||||
set_missing_flags_false,
|
set_missing_flags_false,
|
||||||
|
_profile_from_yaml,
|
||||||
)
|
)
|
||||||
from meshtastic import mt_config
|
from meshtastic import mt_config
|
||||||
|
|
||||||
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
|
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
|
||||||
from ..protobuf.config_pb2 import Config # pylint: disable=E0611
|
from ..protobuf.config_pb2 import Config # pylint: disable=E0611
|
||||||
|
from ..protobuf.clientonly_pb2 import DeviceProfile # pylint: disable=E0611
|
||||||
|
from ..protobuf.localonly_pb2 import LocalConfig, LocalModuleConfig # pylint: disable=E0611
|
||||||
|
|
||||||
# from ..ble_interface import BLEInterface
|
# from ..ble_interface import BLEInterface
|
||||||
from ..mesh_interface import MeshInterface
|
from ..mesh_interface import MeshInterface
|
||||||
@@ -871,7 +876,7 @@ def test_main_sendtext_with_invalid_channel_nine(caplog, capsys):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_sendtext_with_dest(mock_findPorts, mock_serial, mocked_open, mock_hupcl, capsys, caplog, iface_with_nodes):
|
def test_main_sendtext_with_dest(mock_findPorts, mock_serial, mocked_open, mock_hupcl, capsys, caplog, iface_with_nodes):
|
||||||
@@ -1069,7 +1074,7 @@ def test_main_seturl(capsys):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1093,7 +1098,7 @@ def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hup
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1117,7 +1122,7 @@ def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, m
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1144,7 +1149,7 @@ def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open,
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1169,7 +1174,7 @@ def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open,
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1194,7 +1199,7 @@ def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, moc
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1226,7 +1231,7 @@ def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_configure_with_camel_case_keys(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_configure_with_camel_case_keys(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -2012,6 +2017,471 @@ position_flags: 35"""
|
|||||||
assert err == ""
|
assert err == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_export_profile_serializes_deviceprofile():
|
||||||
|
"""export_profile() returns a parseable DeviceProfile protobuf"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.getLongName.return_value = "foo"
|
||||||
|
iface.getShortName.return_value = "oof"
|
||||||
|
iface.localNode.getURL.return_value = "https://meshtastic.org/e/#test"
|
||||||
|
iface.getCannedMessage.return_value = "Hi|Bye"
|
||||||
|
iface.getRingtone.return_value = "24:d=32,o=5"
|
||||||
|
iface.getMyNodeInfo.return_value = {
|
||||||
|
"position": {"latitude": 35.88888, "longitude": -93.88888, "altitude": 304}
|
||||||
|
}
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.localConfig.position.fixed_position = True
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
|
||||||
|
raw = export_profile(iface)
|
||||||
|
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(raw) # must not raise
|
||||||
|
assert profile.long_name == "foo"
|
||||||
|
assert profile.short_name == "oof"
|
||||||
|
assert profile.channel_url == "https://meshtastic.org/e/#test"
|
||||||
|
assert profile.canned_messages == "Hi|Bye"
|
||||||
|
assert profile.ringtone == "24:d=32,o=5"
|
||||||
|
assert profile.HasField("fixed_position")
|
||||||
|
assert profile.fixed_position.latitude_i == 358888800
|
||||||
|
assert profile.fixed_position.longitude_i == -938888800
|
||||||
|
assert profile.fixed_position.altitude == 304
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_export_profile_omits_fixed_position_when_not_enabled():
|
||||||
|
"""export_profile() omits fixed_position when position.fixed_position is False"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.getLongName.return_value = "foo"
|
||||||
|
iface.getShortName.return_value = "oof"
|
||||||
|
iface.localNode.getURL.return_value = ""
|
||||||
|
iface.getCannedMessage.return_value = None
|
||||||
|
iface.getRingtone.return_value = None
|
||||||
|
iface.getMyNodeInfo.return_value = {
|
||||||
|
"position": {"latitude": 35.88888, "longitude": -93.88888, "altitude": 304}
|
||||||
|
}
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
|
||||||
|
raw = export_profile(iface)
|
||||||
|
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(raw)
|
||||||
|
assert not profile.HasField("fixed_position"), \
|
||||||
|
"fixed_position should be omitted when position.fixed_position is not enabled"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_export_profile_omits_empty_fields():
|
||||||
|
"""export_profile() should not populate protobuf oneofs for missing values"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
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()
|
||||||
|
|
||||||
|
raw = export_profile(iface)
|
||||||
|
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(raw)
|
||||||
|
assert not profile.HasField("long_name")
|
||||||
|
assert not profile.HasField("short_name")
|
||||||
|
assert not profile.HasField("channel_url")
|
||||||
|
assert not profile.HasField("canned_messages")
|
||||||
|
assert not profile.HasField("ringtone")
|
||||||
|
assert not profile.HasField("fixed_position")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_export_profile_converts_precisely():
|
||||||
|
"""export_profile() uses exact arithmetic for lat/lon conversion"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
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 = {
|
||||||
|
"position": {"latitude": -49.82207, "longitude": 0, "altitude": 0},
|
||||||
|
}
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.localConfig.position.fixed_position = True
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
|
||||||
|
raw = export_profile(iface)
|
||||||
|
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(raw)
|
||||||
|
assert profile.fixed_position.latitude_i == -498220700
|
||||||
|
assert profile.fixed_position.longitude_i == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_profile_from_yaml_maps_all_fields():
|
||||||
|
"""_profile_from_yaml() converts a YAML dict to an equivalent DeviceProfile"""
|
||||||
|
configuration = {
|
||||||
|
"owner": "YAML Owner",
|
||||||
|
"owner_short": "YO",
|
||||||
|
"channel_url": "https://meshtastic.org/e/#test",
|
||||||
|
"canned_messages": "Hi|Bye",
|
||||||
|
"ringtone": "24:d=32,o=5",
|
||||||
|
"location": {"lat": 35.88888, "lon": -93.88888, "alt": 304},
|
||||||
|
"config": {"bluetooth": {"enabled": True, "fixedPin": 123456}},
|
||||||
|
"module_config": {"telemetry": {"deviceUpdateInterval": 900}},
|
||||||
|
}
|
||||||
|
profile = _profile_from_yaml(configuration)
|
||||||
|
|
||||||
|
assert profile.long_name == "YAML Owner"
|
||||||
|
assert profile.short_name == "YO"
|
||||||
|
assert profile.channel_url == "https://meshtastic.org/e/#test"
|
||||||
|
assert profile.canned_messages == "Hi|Bye"
|
||||||
|
assert profile.ringtone == "24:d=32,o=5"
|
||||||
|
assert profile.HasField("fixed_position")
|
||||||
|
assert profile.fixed_position.latitude_i == 358888800
|
||||||
|
assert profile.fixed_position.longitude_i == -938888800
|
||||||
|
assert profile.fixed_position.altitude == 304
|
||||||
|
assert profile.HasField("config")
|
||||||
|
assert profile.config.bluetooth.enabled is True
|
||||||
|
assert profile.config.bluetooth.fixed_pin == 123456
|
||||||
|
assert profile.HasField("module_config")
|
||||||
|
assert profile.module_config.telemetry.device_update_interval == 900
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_profile_from_yaml_camelcase_keys():
|
||||||
|
"""_profile_from_yaml() handles camelCase YAML keys"""
|
||||||
|
configuration = {
|
||||||
|
"ownerShort": "CB",
|
||||||
|
"channelUrl": "https://meshtastic.org/e/#camel",
|
||||||
|
}
|
||||||
|
profile = _profile_from_yaml(configuration)
|
||||||
|
|
||||||
|
assert profile.short_name == "CB"
|
||||||
|
assert profile.channel_url == "https://meshtastic.org/e/#camel"
|
||||||
|
assert not profile.HasField("long_name")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_profile_from_yaml_converts_precisely():
|
||||||
|
"""_profile_from_yaml() uses exact arithmetic for lat/lon conversion"""
|
||||||
|
# -49.82207 is a value where int(float * 1e7) truncates to -498220699
|
||||||
|
# instead of the correct -498220700
|
||||||
|
configuration = {
|
||||||
|
"location": {"lat": -49.82207, "lon": 0, "alt": 0},
|
||||||
|
}
|
||||||
|
profile = _profile_from_yaml(configuration)
|
||||||
|
|
||||||
|
assert profile.fixed_position.latitude_i == -498220700
|
||||||
|
assert profile.fixed_position.longitude_i == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_combines_setowner_into_single_call(tmp_path, capsys):
|
||||||
|
"""Both long_name and short_name are set in a single setOwner call"""
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.long_name = "Single Call"
|
||||||
|
profile.short_name = "SC"
|
||||||
|
cfg_path = tmp_path / "combined.cfg"
|
||||||
|
cfg_path.write_bytes(profile.SerializeToString())
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(cfg_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
# Single combined message, not two separate ones
|
||||||
|
assert re.search(r"Setting device owner to Single Call and short name to SC", out, re.MULTILINE)
|
||||||
|
assert not re.search(r"Setting device owner short to SC$", out, re.MULTILINE)
|
||||||
|
# setOwner should be called once, not twice
|
||||||
|
iface.getNode.return_value.setOwner.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_with_binary_cfg(tmp_path, capsys):
|
||||||
|
"""--configure with a .cfg binary file parses and applies a DeviceProfile"""
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.long_name = "Binary Bob"
|
||||||
|
profile.short_name = "BB"
|
||||||
|
cfg_path = tmp_path / "backup.cfg"
|
||||||
|
cfg_path.write_bytes(profile.SerializeToString())
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(cfg_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device owner to Binary Bob and short name to BB", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_rejects_invalid_yaml_and_extension(tmp_path, capsys):
|
||||||
|
"""A non-dict YAML file with a non-.cfg extension should error cleanly."""
|
||||||
|
bad = tmp_path / "bad.yaml"
|
||||||
|
bad.write_text("just a scalar string")
|
||||||
|
sys.argv = ["", "--configure", str(bad)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"is not a valid YAML config or DeviceProfile", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_rejects_corrupt_cfg(tmp_path, capsys):
|
||||||
|
"""A .cfg file that is not a valid DeviceProfile should error cleanly."""
|
||||||
|
bad = tmp_path / "corrupt.cfg"
|
||||||
|
bad.write_bytes(b"\x00\x01\x02 not really protobuf \xff\xfe")
|
||||||
|
sys.argv = ["", "--configure", str(bad)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"is not a valid YAML config or DeviceProfile", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_binary_with_explicit_format(tmp_path, capsys):
|
||||||
|
"""--configure --export-format binary forces binary parsing regardless of extension"""
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.long_name = "Forced Binary"
|
||||||
|
cfg_path = tmp_path / "notcfg.dat" # non-.cfg extension, but format forced
|
||||||
|
cfg_path.write_bytes(profile.SerializeToString())
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(cfg_path), "--export-format", "binary"]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device owner to Forced Binary", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_autodetects_binary_by_content(tmp_path, capsys):
|
||||||
|
"""Binary DeviceProfile is detected by content even without .cfg extension"""
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.long_name = "Content Detected"
|
||||||
|
# Use .bin extension — autodetection must rely on content, not extension
|
||||||
|
cfg_path = tmp_path / "backup.bin"
|
||||||
|
cfg_path.write_bytes(profile.SerializeToString())
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(cfg_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device owner to Content Detected", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_autodetects_yaml_by_content(tmp_path, capsys):
|
||||||
|
"""YAML config is detected by content even with a non-standard extension"""
|
||||||
|
yaml_path = tmp_path / "config.txt"
|
||||||
|
yaml_path.write_text("owner: YAML Detected\n")
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device owner to YAML Detected", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_export_config_binary_round_trip(tmp_path, capsys):
|
||||||
|
"""Round-trip: export a .cfg via main(), then --configure it back via main()."""
|
||||||
|
cfg_path = tmp_path / "roundtrip.cfg"
|
||||||
|
|
||||||
|
# Export
|
||||||
|
sys.argv = ["", "--export-config", str(cfg_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.getLongName.return_value = "Round Trip"
|
||||||
|
iface.getShortName.return_value = "RT"
|
||||||
|
iface.localNode.getURL.return_value = "https://meshtastic.org/e/#rt"
|
||||||
|
iface.getCannedMessage.return_value = "Yes|No"
|
||||||
|
iface.getRingtone.return_value = None
|
||||||
|
iface.getMyNodeInfo.return_value = {
|
||||||
|
"position": {"latitude": 1.0, "longitude": 2.0, "altitude": 3}
|
||||||
|
}
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.localConfig.position.fixed_position = True
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Exported profile to", out, re.MULTILINE)
|
||||||
|
assert cfg_path.exists()
|
||||||
|
|
||||||
|
# Verify the exported file is a valid DeviceProfile
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(cfg_path.read_bytes())
|
||||||
|
assert profile.long_name == "Round Trip"
|
||||||
|
assert profile.short_name == "RT"
|
||||||
|
assert profile.HasField("fixed_position")
|
||||||
|
assert profile.fixed_position.latitude_i == int(1.0 * 1e7)
|
||||||
|
assert profile.fixed_position.longitude_i == int(2.0 * 1e7)
|
||||||
|
assert profile.fixed_position.altitude == 3
|
||||||
|
|
||||||
|
# Re-import the file we just wrote
|
||||||
|
sys.argv = ["", "--configure", str(cfg_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device owner to Round Trip and short name to RT", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Setting device position", out, re.MULTILINE)
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_does_not_set_fixed_position_for_legacy_yaml(tmp_path, capsys):
|
||||||
|
"""Legacy YAML with location and no position config does NOT call setFixedPosition"""
|
||||||
|
yaml_path = tmp_path / "legacy.yaml"
|
||||||
|
yaml_path.write_text("location:\n lat: 35.0\n lon: -93.0\n alt: 100\n")
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert not re.search(r"Setting device position", out, re.MULTILINE)
|
||||||
|
iface.getNode.return_value.setFixedPosition.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_skips_setfixedposition_when_config_disables_it(tmp_path, capsys):
|
||||||
|
"""YAML with location but position.fixed_position=false skips setFixedPosition"""
|
||||||
|
yaml_path = tmp_path / "no_fixed.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"location:\n lat: 35.0\n lon: -93.0\n alt: 100\n"
|
||||||
|
"config:\n position:\n fixed_position: false\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert not re.search(r"Setting device position", out, re.MULTILINE)
|
||||||
|
iface.getNode.return_value.setFixedPosition.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_calls_setfixedposition_when_config_opt_in(tmp_path, capsys):
|
||||||
|
"""YAML with location and position.fixed_position=true calls setFixedPosition"""
|
||||||
|
yaml_path = tmp_path / "fixed_on.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"location:\n lat: 35.0\n lon: -93.0\n alt: 100\n"
|
||||||
|
"config:\n position:\n fixed_position: true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.localNode.localConfig = LocalConfig()
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Setting device position", out, re.MULTILINE)
|
||||||
|
iface.getNode.return_value.setFixedPosition.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_yaml_preserves_unmentioned_fields(tmp_path, capsys):
|
||||||
|
"""Partial YAML config does not reset unspecified fields in the same section."""
|
||||||
|
yaml_path = tmp_path / "partial.yaml"
|
||||||
|
yaml_path.write_text("config:\n bluetooth:\n enabled: false\n")
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path)]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
local_config = LocalConfig()
|
||||||
|
local_config.bluetooth.enabled = True
|
||||||
|
local_config.bluetooth.fixed_pin = 654321
|
||||||
|
iface.localNode.localConfig = local_config
|
||||||
|
iface.localNode.moduleConfig = LocalModuleConfig()
|
||||||
|
# Make getNode return the same node object so apply writes back to our real config.
|
||||||
|
iface.getNode.return_value = iface.localNode
|
||||||
|
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
||||||
|
assert iface.localNode.localConfig.bluetooth.enabled is False
|
||||||
|
assert iface.localNode.localConfig.bluetooth.fixed_pin == 654321
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_main_configure_rejects_remote_node(tmp_path, capsys):
|
||||||
|
"""--configure with --dest pointing to a remote node is rejected."""
|
||||||
|
yaml_path = tmp_path / "remote.yaml"
|
||||||
|
yaml_path.write_text("owner: Remote Owner\n")
|
||||||
|
|
||||||
|
sys.argv = ["", "--configure", str(yaml_path), "--dest", "!12345678"]
|
||||||
|
mt_config.args = sys.argv
|
||||||
|
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||||
|
main()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
assert re.search(r"Configuring remote nodes is not supported", out, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
# recursion depth exceeded error
|
# recursion depth exceeded error
|
||||||
#@pytest.mark.unit
|
#@pytest.mark.unit
|
||||||
@@ -2077,6 +2547,124 @@ position_flags: 35"""
|
|||||||
# mo.assert_called()
|
# mo.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
_TRUE_DEFAULTS = [
|
||||||
|
"bluetooth.enabled",
|
||||||
|
"lora.sx126x_rx_boosted_gain",
|
||||||
|
"lora.tx_enabled",
|
||||||
|
"lora.use_preset",
|
||||||
|
"position.position_broadcast_smart_enabled",
|
||||||
|
"security.serial_enabled",
|
||||||
|
]
|
||||||
|
|
||||||
|
_MOD_TRUE_DEFAULTS = [
|
||||||
|
"mqtt.encryption_enabled",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_config_bool(lc, path, value):
|
||||||
|
section, field = path.split(".")
|
||||||
|
setattr(getattr(lc, section), field, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_config_bool(profile, path, expected):
|
||||||
|
section, field = path.split(".")
|
||||||
|
assert getattr(getattr(profile.config, section), field) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
@pytest.mark.parametrize("fmt", ["binary", "yaml"])
|
||||||
|
@pytest.mark.parametrize("value", [True, False])
|
||||||
|
def test_round_trip_preserves_config_true_defaults(fmt, value):
|
||||||
|
"""Export-->import preserves config_true_defaults fields for both formats"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
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()
|
||||||
|
|
||||||
|
for path in _TRUE_DEFAULTS:
|
||||||
|
_set_config_bool(iface.localNode.localConfig, path, value)
|
||||||
|
for path in _MOD_TRUE_DEFAULTS:
|
||||||
|
_set_config_bool(iface.localNode.moduleConfig, path, value)
|
||||||
|
|
||||||
|
if fmt == "binary":
|
||||||
|
raw = export_profile(iface)
|
||||||
|
profile = DeviceProfile()
|
||||||
|
profile.ParseFromString(raw)
|
||||||
|
else:
|
||||||
|
yaml_str = export_config(iface)
|
||||||
|
configuration = yaml.safe_load(yaml_str)
|
||||||
|
profile = _profile_from_yaml(configuration)
|
||||||
|
|
||||||
|
assert profile.HasField("config")
|
||||||
|
assert profile.HasField("module_config")
|
||||||
|
for path in _TRUE_DEFAULTS:
|
||||||
|
_assert_config_bool(profile, path, value)
|
||||||
|
for path in _MOD_TRUE_DEFAULTS:
|
||||||
|
section, field = path.split(".")
|
||||||
|
assert getattr(getattr(profile.module_config, section), field) is value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
|
def test_binary_and_yaml_export_consistent():
|
||||||
|
"""Binary and YAML export paths produce equivalent DeviceProfile protos"""
|
||||||
|
iface = MagicMock(autospec=SerialInterface)
|
||||||
|
iface.getLongName.return_value = "Consistency"
|
||||||
|
iface.getShortName.return_value = "CON"
|
||||||
|
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()
|
||||||
|
|
||||||
|
lc = iface.localNode.localConfig
|
||||||
|
lc.bluetooth.enabled = True
|
||||||
|
lc.bluetooth.fixed_pin = 123456
|
||||||
|
lc.lora.sx126x_rx_boosted_gain = False
|
||||||
|
lc.lora.tx_enabled = True
|
||||||
|
lc.lora.use_preset = True
|
||||||
|
lc.position.position_broadcast_smart_enabled = False
|
||||||
|
lc.security.serial_enabled = True
|
||||||
|
|
||||||
|
lc_mod = iface.localNode.moduleConfig
|
||||||
|
lc_mod.mqtt.encryption_enabled = True
|
||||||
|
|
||||||
|
# Binary path
|
||||||
|
raw = export_profile(iface)
|
||||||
|
binary_profile = DeviceProfile()
|
||||||
|
binary_profile.ParseFromString(raw)
|
||||||
|
|
||||||
|
# YAML path
|
||||||
|
yaml_str = export_config(iface)
|
||||||
|
configuration = yaml.safe_load(yaml_str)
|
||||||
|
yaml_profile = _profile_from_yaml(configuration)
|
||||||
|
|
||||||
|
# Both should have config
|
||||||
|
assert binary_profile.HasField("config") == yaml_profile.HasField("config")
|
||||||
|
assert binary_profile.HasField("module_config") == yaml_profile.HasField("module_config")
|
||||||
|
|
||||||
|
# Compare individual field values
|
||||||
|
assert binary_profile.config.bluetooth.enabled == yaml_profile.config.bluetooth.enabled
|
||||||
|
assert binary_profile.config.bluetooth.fixed_pin == yaml_profile.config.bluetooth.fixed_pin
|
||||||
|
assert binary_profile.config.lora.sx126x_rx_boosted_gain == yaml_profile.config.lora.sx126x_rx_boosted_gain
|
||||||
|
assert binary_profile.config.lora.tx_enabled == yaml_profile.config.lora.tx_enabled
|
||||||
|
assert binary_profile.config.lora.use_preset == yaml_profile.config.lora.use_preset
|
||||||
|
assert binary_profile.config.position.position_broadcast_smart_enabled == yaml_profile.config.position.position_broadcast_smart_enabled
|
||||||
|
assert binary_profile.config.security.serial_enabled == yaml_profile.config.security.serial_enabled
|
||||||
|
assert binary_profile.module_config.mqtt.encryption_enabled == yaml_profile.module_config.mqtt.encryption_enabled
|
||||||
|
|
||||||
|
# Owner fields also match
|
||||||
|
assert binary_profile.long_name == yaml_profile.long_name
|
||||||
|
assert binary_profile.short_name == yaml_profile.short_name
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_set_missing_flags_false():
|
def test_set_missing_flags_false():
|
||||||
"""Test set_missing_flags_false() function"""
|
"""Test set_missing_flags_false() function"""
|
||||||
@@ -2906,7 +3494,7 @@ def test_tunnel_subnet_arg_with_no_devices(mock_platform_system, caplog, capsys)
|
|||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("platform.system")
|
@patch("platform.system")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_tunnel_tunnel_arg(
|
def test_tunnel_tunnel_arg(
|
||||||
|
|||||||
Reference in New Issue
Block a user