Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions
6d76edf8a7 bump version to 2.7.10 2026-06-29 20:45:10 +00:00
Ian McEwen
74cce6bab9 protobufs: v2.7.26 2026-06-26 12:51:57 -07:00
Ian McEwen
1309f4f344 Merge pull request #943 from ianmcorvidae/flag-int-improvements
feat: add named-flag/power-of-2 bitfield support to --set
2026-06-26 11:13:28 -07:00
Ian McEwen
fa01cb7a5d Fix mypy complaint 2026-06-26 11:09:15 -07:00
Ian McEwen
dcdbda0524 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.
2026-06-26 11:01:19 -07:00
Ian McEwen
9479fb2a77 Merge pull request #942 from ianmcorvidae/document-port
Better document how to specify a port when using TCP connection
2026-06-25 14:47:45 -07:00
Ian McEwen
990ecc2350 Better document how to specify a port when using TCP connection
Fixes #868

Also, fix a pylint complaint.
2026-06-25 14:46:45 -07:00
Ian McEwen
7fc69e957c Fail earlier if the ota update file isn't found 2026-06-25 14:28:32 -07:00
Ian McEwen
f0de977be5 Some more test improvements 2026-06-25 14:07:36 -07:00
Ian McEwen
c118334933 Add tests for the flags_to_list util function 2026-06-18 20:55:13 -07:00
9 changed files with 361 additions and 52 deletions

View File

@@ -66,6 +66,14 @@ from meshtastic.version import get_active_version
logger = logging.getLogger(__name__)
# Map dotted preference paths to the protobuf enum that defines their flags.
# These fields are stored as uint32 bitmasks in the protobuf but have an
# associated enum that names the individual flags.
BITFIELD_ENUMS = {
"network.enabled_protocols": config_pb2.Config.NetworkConfig.ProtocolFlags,
"position.position_flags": config_pb2.Config.PositionConfig.PositionFlags,
}
def onReceive(packet, interface) -> None:
"""Callback invoked when a packet arrives"""
args = mt_config.args
@@ -238,13 +246,28 @@ def setPref(config, comp_name, raw_val) -> bool:
print("Warning: network.wifi_psk must be 8 or more characters.")
return False
# Handle uint32 bitfields that have an associated enum of flag names.
bitfield_enum = None
if config_type.message_type is not None:
bitfield_path = f"{config_type.name}.{pref.name}"
bitfield_enum = BITFIELD_ENUMS.get(bitfield_path)
if bitfield_enum and isinstance(val, str):
# At this point fromStr() could not parse val as int/float/bool/bytes,
# so treat it as a comma-separated list of bitfield flag names.
flag_names = [name.strip() for name in val.split(",") if name.strip()]
try:
val = meshtastic.util.flags_from_list(bitfield_enum, flag_names)
except ValueError as e:
print(f"ERROR: {e}")
return False
enumType = pref.enum_type
# pylint: disable=C0123
if enumType and type(val) == str:
# We've failed so far to convert this string into an enum, try to find it by reflection
e = enumType.values_by_name.get(val)
if e:
val = e.number
ev = enumType.values_by_name.get(val)
if ev:
val = ev.number
else:
print(
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
@@ -1383,6 +1406,11 @@ def common():
if not stripped_ham_name:
meshtastic.util.our_exit("ERROR: Ham radio callsign cannot be empty or contain only whitespace characters")
# Early validation for OTA firmware file before attempting device connection
if hasattr(args, 'ota_update') and args.ota_update is not None:
if not os.path.isfile(args.ota_update):
meshtastic.util.our_exit(f"Error: OTA firmware file not found: {args.ota_update}", 1)
if have_powermon:
create_power_meter()
@@ -1616,10 +1644,12 @@ def addConnectionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParse
"--host",
"--tcp",
"-t",
help="Connect to a device using TCP, optionally passing hostname or IP address to use. (defaults to '%(const)s')",
help=("Connect to a device using TCP, optionally passing hostname or IP address to use. (defaults to '%(const)s'). "
"A port number may be specified as well, e.g. meshtastic.local:4404. The default port is 4403."),
nargs="?",
default=None,
const="localhost",
metavar="HOST[:PORT]",
)
group.add_argument(
@@ -1949,7 +1979,8 @@ def addPositionConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentP
group.add_argument(
"--pos-fields",
help="Specify fields to send when sending a position. Use no argument for a list of valid values. "
help="Deprecated: use '--set position.position_flags FLAG1,FLAG2' instead. "
"Specify fields to send when sending a position. Use no argument for a list of valid values. "
"Can pass multiple values as a space separated list like "
"this: '--pos-fields ALTITUDE HEADING SPEED'",
nargs="*",

View File

File diff suppressed because one or more lines are too long

View File

@@ -594,6 +594,22 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
"""
Lilygo T-Echo Card
"""
SEEED_WIO_TRACKER_L2: _HardwareModel.ValueType # 137
"""
Seeed Tracker L2
"""
CROWPANEL_P4: _HardwareModel.ValueType # 138
"""
Elecrow CrowPanel Advance P4 models, ESP32-P4 and TFT with SX1262 radio plugin
"""
HELTEC_MESH_TOWER_V2: _HardwareModel.ValueType # 139
"""
Heltec Mesh Tower V2
"""
MESHNOLOGY_W10: _HardwareModel.ValueType # 140
"""
Meshnology W10
"""
PRIVATE_HW: _HardwareModel.ValueType # 255
"""
------------------------------------------------------------------------------------------------------------------------------------------
@@ -1171,6 +1187,22 @@ T_ECHO_CARD: HardwareModel.ValueType # 136
"""
Lilygo T-Echo Card
"""
SEEED_WIO_TRACKER_L2: HardwareModel.ValueType # 137
"""
Seeed Tracker L2
"""
CROWPANEL_P4: HardwareModel.ValueType # 138
"""
Elecrow CrowPanel Advance P4 models, ESP32-P4 and TFT with SX1262 radio plugin
"""
HELTEC_MESH_TOWER_V2: HardwareModel.ValueType # 139
"""
Heltec Mesh Tower V2
"""
MESHNOLOGY_W10: HardwareModel.ValueType # 140
"""
Meshnology W10
"""
PRIVATE_HW: HardwareModel.ValueType # 255
"""
------------------------------------------------------------------------------------------------------------------------------------------

View File

@@ -20,12 +20,14 @@ from meshtastic.__main__ import (
onConnection,
onNode,
onReceive,
setPref,
tunnelMain,
set_missing_flags_false,
)
from meshtastic import mt_config
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
from ..protobuf.config_pb2 import Config # pylint: disable=E0611
# from ..ble_interface import BLEInterface
from ..mesh_interface import MeshInterface
@@ -3157,6 +3159,9 @@ def test_main_ota_update_file_not_found(capsys):
assert pytest_wrapped_e.type == SystemExit
assert pytest_wrapped_e.value.code == 1
out, _ = capsys.readouterr()
assert "OTA firmware file not found" in out
assert "/nonexistent/firmware.bin" in out
@pytest.mark.unit
@@ -3201,3 +3206,48 @@ def test_main_ota_update_retries(mock_our_exit, mock_ota_class, capsys):
finally:
os.unlink(firmware_file)
@pytest.mark.unit
@pytest.mark.usefixtures("reset_mt_config")
def test_main_setPref_network_enabled_protocols_by_name(capsys):
"""Test setPref() accepts bitfield flag names for network.enabled_protocols."""
config = Config()
assert setPref(config, "network.enabled_protocols", "UDP_BROADCAST") is True
assert config.network.enabled_protocols == 1
out, _ = capsys.readouterr()
assert "Set network.enabled_protocols to UDP_BROADCAST" in out
@pytest.mark.unit
@pytest.mark.usefixtures("reset_mt_config")
def test_main_setPref_position_flags_multiple(capsys):
"""Test setPref() accepts comma-separated bitfield flag names."""
config = Config()
assert setPref(config, "position.position_flags", "ALTITUDE,SPEED") is True
assert config.position.position_flags == 513
out, _ = capsys.readouterr()
assert "Set position.position_flags to ALTITUDE,SPEED" in out
@pytest.mark.unit
@pytest.mark.usefixtures("reset_mt_config")
def test_main_setPref_bitfield_raw_integer(capsys):
"""Test setPref() still accepts raw integers for bitfields."""
config = Config()
assert setPref(config, "network.enabled_protocols", "0") is True
assert config.network.enabled_protocols == 0
out, _ = capsys.readouterr()
assert "Set network.enabled_protocols to 0" in out
@pytest.mark.unit
@pytest.mark.usefixtures("reset_mt_config")
def test_main_setPref_bitfield_invalid_name(capsys):
"""Test setPref() rejects unknown bitfield flag names."""
config = Config()
assert setPref(config, "network.enabled_protocols", "TCP") is False
out, _ = capsys.readouterr()
assert "Unknown flag 'TCP'" in out
assert "NO_BROADCAST" in out
assert "UDP_BROADCAST" in out

View File

@@ -153,25 +153,6 @@ def test_showNodes_favorite_asterisk_display(capsys, _iface_with_favorite_nodes)
assert err == ""
@pytest.mark.unit
def test_showNodes_favorite_field_formatting():
"""Test the formatting logic for isFavorite field"""
# Test favorite node
raw_value = True
formatted_value = "*" if raw_value else ""
assert formatted_value == "*"
# Test non-favorite node
raw_value = False
formatted_value = "*" if raw_value else ""
assert formatted_value == ""
# Test None/missing value
raw_value = None
formatted_value = "*" if raw_value else ""
assert formatted_value == ""
@pytest.mark.unit
def test_showNodes_with_custom_fields_including_favorite(capsys, _iface_with_favorite_nodes):
"""Test that isFavorite can be specified in custom showFields"""

View File

@@ -3,13 +3,15 @@
import json
import logging
import re
import time
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hypothesis import given, strategies as st
from meshtastic.supported_device import SupportedDevice
from meshtastic.protobuf import mesh_pb2
from meshtastic.protobuf import mesh_pb2, config_pb2
from meshtastic.util import (
DEFAULT_KEY,
Timeout,
@@ -20,6 +22,8 @@ from meshtastic.util import (
convert_mac_addr,
eliminate_duplicate_port,
findPorts,
flags_from_list,
flags_to_list,
fixme,
fromPSK,
fromStr,
@@ -235,20 +239,111 @@ def test_remove_keys_from_dict_nested():
assert remove_keys_from_dict(keys, adict) == exp
@pytest.mark.unitslow
def test_Timeout_not_found():
"""Test Timeout()"""
to = Timeout(0.2)
attrs = "foo"
to.waitForSet("bar", attrs)
@pytest.mark.unit
def test_Timeout_waitForSet_found():
"""waitForSet returns True when all required attrs are truthy on target."""
to = Timeout(0.01)
target = SimpleNamespace(foo=1, bar="ok")
assert to.waitForSet(target, ("foo", "bar")) is True
@pytest.mark.unitslow
def test_Timeout_found():
"""Test Timeout()"""
to = Timeout(0.2)
attrs = ()
to.waitForSet("bar", attrs)
@pytest.mark.unit
@patch("meshtastic.util.time.sleep")
def test_Timeout_waitForSet_not_found(mock_sleep): # pylint: disable=unused-argument
"""waitForSet returns False when attrs remain falsy until timeout."""
to = Timeout(0.01)
target = SimpleNamespace(foo=None, bar=0)
assert to.waitForSet(target, ("foo", "bar")) is False
@pytest.mark.unit
def test_Timeout_waitForSet_empty_attrs():
"""waitForSet returns True immediately when attrs is empty (vacuous truth)."""
to = Timeout(0.01)
assert to.waitForSet(object(), ()) is True
@pytest.mark.unit
def test_Timeout_reset():
"""reset() sets expireTime to now + expireTimeout."""
to = Timeout(maxSecs=5)
before = time.time()
to.reset()
assert to.expireTime == pytest.approx(before + 5, abs=0.1)
@pytest.mark.unit
def test_Timeout_reset_custom():
"""reset(expireTimeout) overrides the stored expireTimeout."""
to = Timeout(maxSecs=5)
before = time.time()
to.reset(expireTimeout=10)
assert to.expireTime == pytest.approx(before + 10, abs=0.1)
@pytest.mark.unit
def test_Timeout_waitForAckNak_found():
"""waitForAckNak returns True when any acknowledgment attr is set, and clears it."""
to = Timeout(0.01)
ack = Acknowledgment()
ack.receivedAck = True
assert to.waitForAckNak(ack) is True
assert ack.receivedAck is False # cleared after detection
@pytest.mark.unit
@patch("meshtastic.util.time.sleep")
def test_Timeout_waitForAckNak_not_found(mock_sleep): # pylint: disable=unused-argument
"""waitForAckNak returns False when no acknowledgment attr is set."""
to = Timeout(0.01)
ack = Acknowledgment()
assert to.waitForAckNak(ack) is False
@pytest.mark.unit
def test_Timeout_waitForTraceRoute_found():
"""waitForTraceRoute returns True on receivedTraceRoute, and clears it."""
to = Timeout(0.01)
ack = Acknowledgment()
ack.receivedTraceRoute = True
assert to.waitForTraceRoute(3.0, ack) is True
assert ack.receivedTraceRoute is False
@pytest.mark.unit
@patch("meshtastic.util.time.sleep")
def test_Timeout_waitForTraceRoute_not_found(mock_sleep): # pylint: disable=unused-argument
"""waitForTraceRoute returns False on timeout."""
to = Timeout(0.01)
ack = Acknowledgment()
assert to.waitForTraceRoute(3.0, ack) is False
@pytest.mark.unit
def test_Timeout_waitForTelemetry_found():
"""waitForTelemetry returns True when receivedTelemetry is set."""
to = Timeout(0.01)
ack = Acknowledgment()
ack.receivedTelemetry = True
assert to.waitForTelemetry(ack) is True
@pytest.mark.unit
def test_Timeout_waitForPosition_found():
"""waitForPosition returns True when receivedPosition is set."""
to = Timeout(0.01)
ack = Acknowledgment()
ack.receivedPosition = True
assert to.waitForPosition(ack) is True
@pytest.mark.unit
def test_Timeout_waitForWaypoint_found():
"""waitForWaypoint returns True when receivedWaypoint is set."""
to = Timeout(0.01)
ack = Acknowledgment()
ack.receivedWaypoint = True
assert to.waitForWaypoint(ack) is True
@pytest.mark.unitslow
@@ -782,3 +877,96 @@ def test_to_node_num_hypothesis_roundtrip(n):
assert to_node_num(f"!{n:08x}") == n
assert to_node_num(f"0x{n:x}") == n
assert to_node_num(str(n)) == n
_EXCLUDED_MODULES = mesh_pb2.ExcludedModules
_POSITION_FLAGS = config_pb2.Config.PositionConfig.PositionFlags
_NETWORK_PROTOCOLS = config_pb2.Config.NetworkConfig.ProtocolFlags
@pytest.mark.unit
@pytest.mark.parametrize("flag_type,flags,expected", [
(_EXCLUDED_MODULES, 0, []),
(_EXCLUDED_MODULES, 1, ["MQTT_CONFIG"]),
(_EXCLUDED_MODULES, 3, ["MQTT_CONFIG", "SERIAL_CONFIG"]),
(_EXCLUDED_MODULES, 0x7FFF, [
"MQTT_CONFIG", "SERIAL_CONFIG", "EXTNOTIF_CONFIG", "STOREFORWARD_CONFIG",
"RANGETEST_CONFIG", "TELEMETRY_CONFIG", "CANNEDMSG_CONFIG", "AUDIO_CONFIG",
"REMOTEHARDWARE_CONFIG", "NEIGHBORINFO_CONFIG", "AMBIENTLIGHTING_CONFIG",
"DETECTIONSENSOR_CONFIG", "PAXCOUNTER_CONFIG", "BLUETOOTH_CONFIG",
"NETWORK_CONFIG",
]),
(_EXCLUDED_MODULES, 0x8000, ["UNKNOWN_ADDITIONAL_FLAGS(32768)"]),
(_EXCLUDED_MODULES, 0x8001, ["MQTT_CONFIG", "UNKNOWN_ADDITIONAL_FLAGS(32768)"]),
(_POSITION_FLAGS, 0, []),
(_POSITION_FLAGS, 0x09, ["ALTITUDE", "DOP"]),
(_POSITION_FLAGS, 0x1FF, [
"ALTITUDE", "ALTITUDE_MSL", "GEOIDAL_SEPARATION", "DOP", "HVDOP",
"SATINVIEW", "SEQ_NO", "TIMESTAMP", "HEADING",
]),
])
def test_flags_to_list(flag_type, flags, expected):
"""Test flags_to_list decodes set bits in enum order and reports unknown remainders."""
assert flags_to_list(flag_type, flags) == expected
@pytest.mark.unit
@given(st.integers(min_value=0, max_value=0xFFFFF))
def test_flags_to_list_conservation(flags):
"""Property: flags_to_list partitions `flags` into known names plus an exact unknown remainder.
Every known bit that is set must appear as a name, and the leftover reported in
UNKNOWN_ADDITIONAL_FLAGS(...) must together with the named bits reconstruct the input.
"""
for flag_type in (_EXCLUDED_MODULES, _POSITION_FLAGS):
known_union = 0
for key in flag_type.keys():
value = flag_type.Value(key)
if key != "EXCLUDED_NONE" and value:
known_union |= value
result = flags_to_list(flag_type, flags)
accounted = 0
leftover = 0
for name in result:
if name.startswith("UNKNOWN_ADDITIONAL_FLAGS("):
leftover = int(name[len("UNKNOWN_ADDITIONAL_FLAGS("):-1])
else:
accounted |= flag_type.Value(name)
assert accounted == (flags & known_union)
assert (accounted | leftover) == flags
@pytest.mark.unit
@pytest.mark.parametrize("flag_type, flags, expected", [
(_NETWORK_PROTOCOLS, ["UDP_BROADCAST"], 1),
(_NETWORK_PROTOCOLS, ["NO_BROADCAST"], 0),
(_NETWORK_PROTOCOLS, [], 0),
(_POSITION_FLAGS, ["ALTITUDE"], 1),
(_POSITION_FLAGS, ["ALTITUDE", "SPEED"], 513),
(_POSITION_FLAGS, ["ALTITUDE", " SPEED "], 513),
])
def test_flags_from_list(flag_type, flags, expected):
"""Test flags_from_list combines named flags into the expected bitmask."""
assert flags_from_list(flag_type, flags) == expected
@pytest.mark.unit
def test_flags_from_list_unknown_flag():
"""Test flags_from_list raises ValueError for an unknown flag name."""
with pytest.raises(ValueError, match="Unknown flag 'TCP'"):
flags_from_list(_NETWORK_PROTOCOLS, ["UDP_BROADCAST", "TCP"])
@pytest.mark.unit
@given(st.lists(st.sampled_from(list(_POSITION_FLAGS.keys())), unique=True))
def test_flags_from_list_roundtrip(flags):
"""Property: flags_from_list and flags_to_list are inverses for known position flags."""
combined = flags_from_list(_POSITION_FLAGS, flags)
decoded = flags_to_list(_POSITION_FLAGS, combined)
# flags_to_list drops zero-value flags and may report unknown remainders,
# but for combinations of known non-zero flags it should return the same set of names.
nonzero_flags = {f for f in flags if _POSITION_FLAGS.Value(f)}
assert set(decoded) == nonzero_flags

View File

@@ -737,14 +737,41 @@ def to_node_num(node_id: Union[int, str]) -> int:
return int(s, 16)
def flags_to_list(flag_type, flags: int) -> List[str]:
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a flag int, give a list of flags enabled."""
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a flag int, give a list of flags enabled.
Zero-valued members (e.g. EXCLUDED_NONE, UNSET, NO_BROADCAST) never appear in the
result: they hold no bit, so `flags & value` is always False for them, and a flags
value of 0 therefore decodes to an empty list rather than a named "no flags" entry.
Any leftover bits not corresponding to a known member are reported via
`UNKNOWN_ADDITIONAL_FLAGS(<leftover>)`.
"""
ret = []
for key in flag_type.keys():
if key == "EXCLUDED_NONE":
continue
if flags & flag_type.Value(key):
ret.append(key)
flags = flags - flag_type.Value(key)
if flags > 0:
ret.append(f"UNKNOWN_ADDITIONAL_FLAGS({flags})")
return ret
def flags_from_list(flag_type, flags: List[str]) -> int:
"""Given a flag_type that's a protobuf EnumTypeWrapper, and a list of flag names, return the combined bitmask.
Zero-valued members (e.g. EXCLUDED_NONE, UNSET, NO_BROADCAST) are accepted but are
no-ops: they OR in 0 and thus set nothing. A list consisting solely of such a member
(or an empty list) yields 0, which round-trips back through flags_to_list as an
empty list rather than the original member name -- see flags_to_list's docstring.
"""
result = 0
valid_names = list(flag_type.keys())
for flag_name in flags:
flag_name = flag_name.strip()
if not flag_name:
continue
if flag_name not in valid_names:
raise ValueError(
f"Unknown flag '{flag_name}'. Valid choices: {', '.join(sorted(valid_names))}"
)
result |= flag_type.Value(flag_name)
return result

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "meshtastic"
version = "2.7.9"
version = "2.7.10"
description = "Python API & client shell for talking to Meshtastic devices"
authors = ["Meshtastic Developers <contact@meshtastic.org>"]
license = "GPL-3.0-only"