Skip to content

Commit dcdbda0

Browse files
committed
feat: add named-flag/power-of-2 bitfield support to --set
This enables e.g. `--set position.position_flags ALTITUDE,SPEED` rather than the dedicated `--pos-flags`, and similarly for `network.enabled_protocols`. Displaying these named values when printing out configurations is not yet implemented.
1 parent 9479fb2 commit dcdbda0

4 files changed

Lines changed: 137 additions & 4 deletions

File tree

meshtastic/__main__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@
6666

6767
logger = logging.getLogger(__name__)
6868

69+
# Map dotted preference paths to the protobuf enum that defines their flags.
70+
# These fields are stored as uint32 bitmasks in the protobuf but have an
71+
# associated enum that names the individual flags.
72+
BITFIELD_ENUMS = {
73+
"network.enabled_protocols": config_pb2.Config.NetworkConfig.ProtocolFlags,
74+
"position.position_flags": config_pb2.Config.PositionConfig.PositionFlags,
75+
}
76+
6977
def onReceive(packet, interface) -> None:
7078
"""Callback invoked when a packet arrives"""
7179
args = mt_config.args
@@ -238,6 +246,21 @@ def setPref(config, comp_name, raw_val) -> bool:
238246
print("Warning: network.wifi_psk must be 8 or more characters.")
239247
return False
240248

249+
# Handle uint32 bitfields that have an associated enum of flag names.
250+
bitfield_enum = None
251+
if config_type.message_type is not None:
252+
bitfield_path = f"{config_type.name}.{pref.name}"
253+
bitfield_enum = BITFIELD_ENUMS.get(bitfield_path)
254+
if bitfield_enum and isinstance(val, str):
255+
# At this point fromStr() could not parse val as int/float/bool/bytes,
256+
# so treat it as a comma-separated list of bitfield flag names.
257+
flag_names = [name.strip() for name in val.split(",") if name.strip()]
258+
try:
259+
val = meshtastic.util.flags_from_list(bitfield_enum, flag_names)
260+
except ValueError as e:
261+
print(f"ERROR: {e}")
262+
return False
263+
241264
enumType = pref.enum_type
242265
# pylint: disable=C0123
243266
if enumType and type(val) == str:
@@ -1956,7 +1979,8 @@ def addPositionConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentP
19561979

19571980
group.add_argument(
19581981
"--pos-fields",
1959-
help="Specify fields to send when sending a position. Use no argument for a list of valid values. "
1982+
help="Deprecated: use '--set position.position_flags FLAG1,FLAG2' instead. "
1983+
"Specify fields to send when sending a position. Use no argument for a list of valid values. "
19601984
"Can pass multiple values as a space separated list like "
19611985
"this: '--pos-fields ALTITUDE HEADING SPEED'",
19621986
nargs="*",

meshtastic/tests/test_main.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
onConnection,
2121
onNode,
2222
onReceive,
23+
setPref,
2324
tunnelMain,
2425
set_missing_flags_false,
2526
)
2627
from meshtastic import mt_config
2728

2829
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
30+
from ..protobuf.config_pb2 import Config # pylint: disable=E0611
2931

3032
# from ..ble_interface import BLEInterface
3133
from ..mesh_interface import MeshInterface
@@ -3204,3 +3206,48 @@ def test_main_ota_update_retries(mock_our_exit, mock_ota_class, capsys):
32043206

32053207
finally:
32063208
os.unlink(firmware_file)
3209+
3210+
3211+
@pytest.mark.unit
3212+
@pytest.mark.usefixtures("reset_mt_config")
3213+
def test_main_setPref_network_enabled_protocols_by_name(capsys):
3214+
"""Test setPref() accepts bitfield flag names for network.enabled_protocols."""
3215+
config = Config()
3216+
assert setPref(config, "network.enabled_protocols", "UDP_BROADCAST") is True
3217+
assert config.network.enabled_protocols == 1
3218+
out, _ = capsys.readouterr()
3219+
assert "Set network.enabled_protocols to UDP_BROADCAST" in out
3220+
3221+
3222+
@pytest.mark.unit
3223+
@pytest.mark.usefixtures("reset_mt_config")
3224+
def test_main_setPref_position_flags_multiple(capsys):
3225+
"""Test setPref() accepts comma-separated bitfield flag names."""
3226+
config = Config()
3227+
assert setPref(config, "position.position_flags", "ALTITUDE,SPEED") is True
3228+
assert config.position.position_flags == 513
3229+
out, _ = capsys.readouterr()
3230+
assert "Set position.position_flags to ALTITUDE,SPEED" in out
3231+
3232+
3233+
@pytest.mark.unit
3234+
@pytest.mark.usefixtures("reset_mt_config")
3235+
def test_main_setPref_bitfield_raw_integer(capsys):
3236+
"""Test setPref() still accepts raw integers for bitfields."""
3237+
config = Config()
3238+
assert setPref(config, "network.enabled_protocols", "0") is True
3239+
assert config.network.enabled_protocols == 0
3240+
out, _ = capsys.readouterr()
3241+
assert "Set network.enabled_protocols to 0" in out
3242+
3243+
3244+
@pytest.mark.unit
3245+
@pytest.mark.usefixtures("reset_mt_config")
3246+
def test_main_setPref_bitfield_invalid_name(capsys):
3247+
"""Test setPref() rejects unknown bitfield flag names."""
3248+
config = Config()
3249+
assert setPref(config, "network.enabled_protocols", "TCP") is False
3250+
out, _ = capsys.readouterr()
3251+
assert "Unknown flag 'TCP'" in out
3252+
assert "NO_BROADCAST" in out
3253+
assert "UDP_BROADCAST" in out

meshtastic/tests/test_util.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
convert_mac_addr,
2323
eliminate_duplicate_port,
2424
findPorts,
25+
flags_from_list,
2526
flags_to_list,
2627
fixme,
2728
fromPSK,
@@ -880,6 +881,7 @@ def test_to_node_num_hypothesis_roundtrip(n):
880881

881882
_EXCLUDED_MODULES = mesh_pb2.ExcludedModules
882883
_POSITION_FLAGS = config_pb2.Config.PositionConfig.PositionFlags
884+
_NETWORK_PROTOCOLS = config_pb2.Config.NetworkConfig.ProtocolFlags
883885

884886

885887
@pytest.mark.unit
@@ -935,3 +937,36 @@ def test_flags_to_list_conservation(flags):
935937

936938
assert accounted == (flags & known_union)
937939
assert (accounted | leftover) == flags
940+
941+
942+
@pytest.mark.unit
943+
@pytest.mark.parametrize("flag_type, flags, expected", [
944+
(_NETWORK_PROTOCOLS, ["UDP_BROADCAST"], 1),
945+
(_NETWORK_PROTOCOLS, ["NO_BROADCAST"], 0),
946+
(_NETWORK_PROTOCOLS, [], 0),
947+
(_POSITION_FLAGS, ["ALTITUDE"], 1),
948+
(_POSITION_FLAGS, ["ALTITUDE", "SPEED"], 513),
949+
(_POSITION_FLAGS, ["ALTITUDE", " SPEED "], 513),
950+
])
951+
def test_flags_from_list(flag_type, flags, expected):
952+
"""Test flags_from_list combines named flags into the expected bitmask."""
953+
assert flags_from_list(flag_type, flags) == expected
954+
955+
956+
@pytest.mark.unit
957+
def test_flags_from_list_unknown_flag():
958+
"""Test flags_from_list raises ValueError for an unknown flag name."""
959+
with pytest.raises(ValueError, match="Unknown flag 'TCP'"):
960+
flags_from_list(_NETWORK_PROTOCOLS, ["UDP_BROADCAST", "TCP"])
961+
962+
963+
@pytest.mark.unit
964+
@given(st.lists(st.sampled_from(list(_POSITION_FLAGS.keys())), unique=True))
965+
def test_flags_from_list_roundtrip(flags):
966+
"""Property: flags_from_list and flags_to_list are inverses for known position flags."""
967+
combined = flags_from_list(_POSITION_FLAGS, flags)
968+
decoded = flags_to_list(_POSITION_FLAGS, combined)
969+
# flags_to_list drops zero-value flags and may report unknown remainders,
970+
# but for combinations of known non-zero flags it should return the same set of names.
971+
nonzero_flags = {f for f in flags if _POSITION_FLAGS.Value(f)}
972+
assert set(decoded) == nonzero_flags

meshtastic/util.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -737,14 +737,41 @@ def to_node_num(node_id: Union[int, str]) -> int:
737737
return int(s, 16)
738738

739739
def flags_to_list(flag_type, flags: int) -> List[str]:
740-
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a flag int, give a list of flags enabled."""
740+
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a flag int, give a list of flags enabled.
741+
742+
Zero-valued members (e.g. EXCLUDED_NONE, UNSET, NO_BROADCAST) never appear in the
743+
result: they hold no bit, so `flags & value` is always False for them, and a flags
744+
value of 0 therefore decodes to an empty list rather than a named "no flags" entry.
745+
Any leftover bits not corresponding to a known member are reported via
746+
`UNKNOWN_ADDITIONAL_FLAGS(<leftover>)`.
747+
"""
741748
ret = []
742749
for key in flag_type.keys():
743-
if key == "EXCLUDED_NONE":
744-
continue
745750
if flags & flag_type.Value(key):
746751
ret.append(key)
747752
flags = flags - flag_type.Value(key)
748753
if flags > 0:
749754
ret.append(f"UNKNOWN_ADDITIONAL_FLAGS({flags})")
750755
return ret
756+
757+
758+
def flags_from_list(flag_type, flags: List[str]) -> int:
759+
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a list of flag names, return the combined bitmask.
760+
761+
Zero-valued members (e.g. EXCLUDED_NONE, UNSET, NO_BROADCAST) are accepted but are
762+
no-ops: they OR in 0 and thus set nothing. A list consisting solely of such a member
763+
(or an empty list) yields 0, which round-trips back through flags_to_list as an
764+
empty list rather than the original member name -- see flags_to_list's docstring.
765+
"""
766+
result = 0
767+
valid_names = list(flag_type.keys())
768+
for flag_name in flags:
769+
flag_name = flag_name.strip()
770+
if not flag_name:
771+
continue
772+
if flag_name not in valid_names:
773+
raise ValueError(
774+
f"Unknown flag '{flag_name}'. Valid choices: {', '.join(sorted(valid_names))}"
775+
)
776+
result |= flag_type.Value(flag_name)
777+
return result

0 commit comments

Comments
 (0)