mirror of
https://github.com/meshtastic/python.git
synced 2026-07-31 09:06:28 -04:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae710478fb | ||
|
|
3b084d7df2 | ||
|
|
3f62c099ae | ||
|
|
a509bca86d | ||
|
|
dca0b1fced | ||
|
|
11b71e9587 | ||
|
|
19d066970e | ||
|
|
485d860911 | ||
|
|
e6f07123f6 | ||
|
|
f623732694 | ||
|
|
82220ba49b | ||
|
|
4339cda6bd | ||
|
|
5760fb3ad9 | ||
|
|
2281ea5b64 | ||
|
|
7950798378 | ||
|
|
bffaff1c71 | ||
|
|
ceca7b3b10 | ||
|
|
15438c8a6e | ||
|
|
2f9b285dd5 | ||
|
|
9b892711df | ||
|
|
1cd1aed33c | ||
|
|
ab14fb2a86 | ||
|
|
7a7353ca15 | ||
|
|
e2844cb8d6 | ||
|
|
441c6e80a1 | ||
|
|
b05e545396 | ||
|
|
b6a13d58fe | ||
|
|
8c5efb926c | ||
|
|
6d76edf8a7 | ||
|
|
74cce6bab9 | ||
|
|
1309f4f344 | ||
|
|
fa01cb7a5d | ||
|
|
dcdbda0524 | ||
|
|
9479fb2a77 | ||
|
|
990ecc2350 | ||
|
|
7fc69e957c | ||
|
|
f0de977be5 | ||
|
|
c118334933 | ||
|
|
307d8d81e7 | ||
|
|
405a6bbc5d | ||
|
|
db746a0981 | ||
|
|
13b8cdcb04 | ||
|
|
9d445098f4 | ||
|
|
8c84074c1d | ||
|
|
1cffae6add | ||
|
|
e4f0fb222b | ||
|
|
953d01206b | ||
|
|
c71d3df332 | ||
|
|
0413d00825 |
16
.coderabbit.yaml
Normal file
16
.coderabbit.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
language: en-US
|
||||
reviews:
|
||||
profile: chill
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
ignore_usernames:
|
||||
- renovate
|
||||
- renovate[bot]
|
||||
abort_on_close: true
|
||||
|
||||
path_filters:
|
||||
- "!/protobufs/**"
|
||||
- "!/meshtastic/protobuf/**"
|
||||
71
.github/workflows/ci.yml
vendored
71
.github/workflows/ci.yml
vendored
@@ -1,4 +1,7 @@
|
||||
name: CI
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,7 +11,29 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install meshtastic from local
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip3 install poetry
|
||||
poetry install --all-extras --with dev,powermon
|
||||
poetry run meshtastic --version
|
||||
|
||||
build:
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -17,10 +42,14 @@ jobs:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python 3
|
||||
- name: Install Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Uninstall meshtastic
|
||||
run: |
|
||||
pip3 uninstall -y meshtastic
|
||||
@@ -32,15 +61,8 @@ jobs:
|
||||
run: |
|
||||
poetry install --all-extras --with dev,powermon
|
||||
poetry run meshtastic --version
|
||||
- name: Run pylint
|
||||
run: poetry run pylint meshtastic examples/ --ignore-patterns ".*_pb2.pyi?$"
|
||||
- name: Check types with mypy
|
||||
run: poetry run mypy meshtastic/
|
||||
- name: Run tests with pytest
|
||||
run: poetry run pytest --cov=meshtastic
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
poetry run pytest --cov=meshtastic --cov-report=xml
|
||||
- name: Run lint, type check, and tests in parallel
|
||||
run: make -j3 --output-sync=target ci
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
@@ -51,22 +73,37 @@ jobs:
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
validate:
|
||||
|
||||
simradio_testing:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.9"
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
channel:
|
||||
- beta
|
||||
- alpha
|
||||
- daily
|
||||
continue-on-error: ${{ matrix.channel == 'daily' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Python 3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install meshtastic from local
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip3 install poetry
|
||||
poetry install
|
||||
poetry install --all-extras --with dev
|
||||
poetry run meshtastic --version
|
||||
- name: Install meshtasticd (${{ matrix.channel }}) from PPA
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:meshtastic/${{ matrix.channel }}
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y meshtasticd
|
||||
- name: Run firmware smoke tests
|
||||
run: poetry run pytest -m "smokevirt or smokemesh" -v
|
||||
timeout-minutes: 15
|
||||
|
||||
36
.github/workflows/daily_simradio.yml
vendored
Normal file
36
.github/workflows/daily_simradio.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Daily SimRadio Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: 0 6 * * *
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
simradio_testing:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'meshtastic/python'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Python 3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install meshtastic from local
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip3 install poetry
|
||||
poetry install --all-extras --with dev
|
||||
poetry run meshtastic --version
|
||||
- name: Install meshtasticd (daily) from PPA
|
||||
run: |
|
||||
sudo add-apt-repository -y ppa:meshtastic/daily
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y meshtasticd
|
||||
- name: Run firmware smoke tests
|
||||
run: poetry run pytest -m "smokevirt or smokemesh" -v
|
||||
timeout-minutes: 15
|
||||
14
Makefile
14
Makefile
@@ -42,5 +42,19 @@ cov:
|
||||
examples: FORCE
|
||||
pytest -mexamples
|
||||
|
||||
# CI targets (run via poetry, executed in parallel by the CI workflow)
|
||||
.PHONY: ci-pylint ci-mypy ci-test ci
|
||||
|
||||
ci-pylint:
|
||||
poetry run pylint meshtastic examples/ --ignore-patterns ".*_pb2.pyi?$$"
|
||||
|
||||
ci-mypy:
|
||||
poetry run mypy meshtastic/
|
||||
|
||||
ci-test:
|
||||
poetry run pytest --cov=meshtastic --cov-report=xml
|
||||
|
||||
ci: ci-pylint ci-mypy ci-test
|
||||
|
||||
# Makefile hack to get the examples to always run
|
||||
FORCE: ;
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
# later we can have a separate changelist to refactor main.py into smaller files
|
||||
# pylint: disable=R0917,C0302
|
||||
|
||||
from typing import List, Optional, Union
|
||||
from typing import Any, Callable, List, Optional, Union
|
||||
from types import ModuleType
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import argparse
|
||||
|
||||
argcomplete: Union[None, ModuleType] = None
|
||||
@@ -61,11 +63,19 @@ except ImportError as e:
|
||||
have_powermon = False
|
||||
powermon_exception = e
|
||||
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
|
||||
|
||||
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 +248,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."
|
||||
@@ -552,6 +577,13 @@ def onConnected(interface):
|
||||
waitForAckNak = True
|
||||
interface.getNode(args.dest, False, **getNode_kwargs).resetNodeDb()
|
||||
|
||||
if args.add_contact:
|
||||
closeNow = True
|
||||
waitForAckNak = True
|
||||
interface.getNode(args.dest, False, **getNode_kwargs).addContactURL(
|
||||
args.add_contact
|
||||
)
|
||||
|
||||
if args.sendtext:
|
||||
closeNow = True
|
||||
channelIndex = mt_config.channel_index or 0
|
||||
@@ -712,111 +744,101 @@ def onConnected(interface):
|
||||
printConfig(node.moduleConfig)
|
||||
|
||||
if args.configure:
|
||||
with open(args.configure[0], encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
if args.dest != BROADCAST_ADDR:
|
||||
print("Configuring remote nodes is not supported.")
|
||||
return
|
||||
|
||||
filename = args.configure[0]
|
||||
fmt = getattr(args, "export_format", "auto")
|
||||
|
||||
def _seed_config(section: str, is_module: bool) -> Optional[Any]:
|
||||
"""Return the current local-node config section, if present."""
|
||||
config_obj = (
|
||||
interface.localNode.moduleConfig
|
||||
if is_module
|
||||
else interface.localNode.localConfig
|
||||
)
|
||||
return getattr(config_obj, section) if config_obj.HasField(section) else None
|
||||
|
||||
profile = _read_profile(filename, fmt, seed_fn=_seed_config)
|
||||
|
||||
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:
|
||||
# Owner: combine long_name and short_name into a single setOwner call.
|
||||
# NOTE: is_licensed and is_unmessagable are not yet in DeviceProfile;
|
||||
# ref: https://github.com/meshtastic/protobufs/pull/971
|
||||
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 long_name is not None and not long_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:
|
||||
if short_name is not None and not 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']}"
|
||||
)
|
||||
|
||||
if long_name or short_name:
|
||||
if long_name and short_name:
|
||||
print(f"Setting device owner to {long_name} and short name to {short_name}")
|
||||
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=None, short_name=configuration["owner_short"]
|
||||
long_name=long_name, short_name=short_name
|
||||
)
|
||||
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"]
|
||||
)
|
||||
if profile.channel_url:
|
||||
print(f"Setting channel url to {profile.channel_url}")
|
||||
interface.getNode(args.dest, **getNode_kwargs).setURL(profile.channel_url)
|
||||
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"])
|
||||
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 "channelUrl" in configuration:
|
||||
print("Setting channel url to", configuration["channelUrl"])
|
||||
interface.getNode(args.dest, **getNode_kwargs).setURL(configuration["channelUrl"])
|
||||
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 "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)
|
||||
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
|
||||
lat = float(pos.latitude_i * Decimal("1e-7")) if pos.latitude_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
|
||||
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)
|
||||
interface.getNode(args.dest, False, **getNode_kwargs).setFixedPosition(lat, lon, alt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "config" in configuration:
|
||||
if profile.HasField("config"):
|
||||
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)
|
||||
)
|
||||
for field in profile.config.DESCRIPTOR.fields:
|
||||
if field.message_type is not None and profile.config.HasField(field.name):
|
||||
getattr(localConfig, field.name).CopyFrom(getattr(profile.config, field.name))
|
||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "module_config" in configuration:
|
||||
if profile.HasField("module_config"):
|
||||
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(
|
||||
meshtastic.util.camel_to_snake(section)
|
||||
)
|
||||
for field in profile.module_config.DESCRIPTOR.fields:
|
||||
if field.message_type is not None and 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()
|
||||
@@ -828,6 +850,28 @@ def onConnected(interface):
|
||||
return
|
||||
|
||||
closeNow = True
|
||||
|
||||
is_binary = False
|
||||
fmt = getattr(args, "export_format", "auto")
|
||||
if fmt in ("binary", "protobuf"):
|
||||
is_binary = True
|
||||
elif fmt == "yaml":
|
||||
is_binary = False
|
||||
else:
|
||||
is_binary = args.export_config.endswith(".cfg")
|
||||
|
||||
if is_binary:
|
||||
config_bytes = export_profile(interface)
|
||||
if args.export_config == "-":
|
||||
sys.stdout.buffer.write(config_bytes)
|
||||
else:
|
||||
try:
|
||||
with open(args.export_config, "wb") as f:
|
||||
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:
|
||||
config_txt = export_config(interface)
|
||||
|
||||
if args.export_config == "-":
|
||||
@@ -925,9 +969,15 @@ def onConnected(interface):
|
||||
if args.ch_longslow:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_SLOW)
|
||||
|
||||
if args.ch_longmod:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_MODERATE)
|
||||
|
||||
if args.ch_longfast:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST)
|
||||
|
||||
if args.ch_longturbo:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_TURBO)
|
||||
|
||||
if args.ch_medslow:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_SLOW)
|
||||
|
||||
@@ -940,6 +990,9 @@ def onConnected(interface):
|
||||
if args.ch_shortfast:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.SHORT_FAST)
|
||||
|
||||
if args.ch_shortturbo:
|
||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.SHORT_TURBO)
|
||||
|
||||
if args.ch_set or args.ch_enable or args.ch_disable:
|
||||
closeNow = True
|
||||
|
||||
@@ -1074,6 +1127,20 @@ def onConnected(interface):
|
||||
else:
|
||||
print("Install pyqrcode to view a QR code printed to terminal.")
|
||||
|
||||
if args.contact_qr:
|
||||
closeNow = True
|
||||
url = interface.getNode(args.dest, True, **getNode_kwargs).getContactURL(
|
||||
args.contact_qr,
|
||||
should_ignore=args.contact_ignore,
|
||||
manually_verified=args.contact_verified,
|
||||
)
|
||||
print(f"Contact URL: {url}")
|
||||
if pyqrcode is not None:
|
||||
qr = pyqrcode.create(url)
|
||||
print(qr.terminal())
|
||||
else:
|
||||
print("Install pyqrcode to view a QR code printed to terminal.")
|
||||
|
||||
log_set: Optional = None # type: ignore[annotation-unchecked]
|
||||
# we need to keep a reference to the logset so it doesn't get GCed early
|
||||
|
||||
@@ -1219,7 +1286,7 @@ def export_config(interface) -> str:
|
||||
lat = None
|
||||
lon = None
|
||||
alt = None
|
||||
if pos:
|
||||
if pos and interface.localNode.localConfig.position.fixed_position:
|
||||
lat = pos.get("latitude")
|
||||
lon = pos.get("longitude")
|
||||
alt = pos.get("altitude")
|
||||
@@ -1263,7 +1330,7 @@ def export_config(interface) -> str:
|
||||
for i in range(len(prefs[pref]['adminKey'])):
|
||||
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
||||
if mt_config.camel_case:
|
||||
configObj["config"] = config #Identical command here and 2 lines below?
|
||||
configObj["config"] = prefs
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
@@ -1288,6 +1355,162 @@ def export_config(interface) -> str:
|
||||
config_txt += yaml.dump(configObj)
|
||||
return config_txt
|
||||
|
||||
def _set_if_populated(profile, field_name, value):
|
||||
if value is None:
|
||||
return
|
||||
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:
|
||||
"""used in --export-config for binary .cfg files"""
|
||||
profile = clientonly_pb2.DeviceProfile()
|
||||
|
||||
owner = interface.getLongName()
|
||||
owner_short = interface.getShortName()
|
||||
channel_url = interface.localNode.getURL()
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
canned_messages = interface.getCannedMessage()
|
||||
ringtone = interface.getRingtone()
|
||||
|
||||
if owner:
|
||||
profile.long_name = owner
|
||||
if owner_short:
|
||||
profile.short_name = owner_short
|
||||
if channel_url:
|
||||
profile.channel_url = channel_url
|
||||
if canned_messages:
|
||||
profile.canned_messages = canned_messages
|
||||
if ringtone:
|
||||
profile.ringtone = ringtone
|
||||
|
||||
profile.config.CopyFrom(interface.localNode.localConfig)
|
||||
profile.module_config.CopyFrom(interface.localNode.moduleConfig)
|
||||
|
||||
if interface.localNode.localConfig.position.fixed_position:
|
||||
pos = myinfo.get("position")
|
||||
if pos:
|
||||
lat = pos.get("latitude")
|
||||
lon = pos.get("longitude")
|
||||
alt = pos.get("altitude")
|
||||
|
||||
if lat or lon or alt:
|
||||
if lat:
|
||||
profile.fixed_position.latitude_i = int(Decimal(str(lat)) * Decimal("1e7"))
|
||||
if lon:
|
||||
profile.fixed_position.longitude_i = int(Decimal(str(lon)) * Decimal("1e7"))
|
||||
if alt:
|
||||
profile.fixed_position.altitude = int(alt)
|
||||
|
||||
return profile.SerializeToString()
|
||||
|
||||
|
||||
def create_power_meter():
|
||||
"""Setup the power meter."""
|
||||
@@ -1362,6 +1585,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()
|
||||
|
||||
@@ -1595,10 +1823,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(
|
||||
@@ -1651,7 +1881,9 @@ def addImportExportArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
|
||||
|
||||
group.add_argument(
|
||||
"--configure",
|
||||
help="Specify a path to a yaml(.yml) file containing the desired settings for the connected device.",
|
||||
"--import-config",
|
||||
dest="configure",
|
||||
help="Specify a path to a configuration file to import. Autodetects format (yaml or binary protobuf).",
|
||||
action="append",
|
||||
)
|
||||
group.add_argument(
|
||||
@@ -1659,7 +1891,13 @@ def addImportExportArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
|
||||
nargs="?",
|
||||
const="-", # default to "-" if no value provided
|
||||
metavar="FILE",
|
||||
help="Export device config as YAML (to stdout if no file given)"
|
||||
help="Export device config (to stdout if no file given). Autodetects format by extension if possible."
|
||||
)
|
||||
group.add_argument(
|
||||
"--export-format",
|
||||
choices=["auto", "yaml", "binary", "protobuf"],
|
||||
default="auto",
|
||||
help="Format for export or import. 'auto' uses file extension or contents. 'binary' or 'protobuf' forces binary format. 'yaml' forces yaml."
|
||||
)
|
||||
return parser
|
||||
|
||||
@@ -1731,43 +1969,61 @@ def addConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
||||
|
||||
group.add_argument(
|
||||
"--ch-vlongslow",
|
||||
help="Change to the very long-range and slow modem preset",
|
||||
help="Change to the VERY_LONG_SLOW modem preset. Deprecated since 2.5 firmware.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-longslow",
|
||||
help="Change to the long-range and slow modem preset",
|
||||
help="Change to the LONG_SLOW modem preset. Deprecated since 2.7 firmware.",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-longmod", "--ch-longmoderate",
|
||||
help="Change to the LONG_MODERATE modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-longfast",
|
||||
help="Change to the long-range and fast modem preset",
|
||||
help="Change to the LONG_FAST modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-longturbo",
|
||||
help="Change to the LONG_TURBO preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-medslow",
|
||||
help="Change to the med-range and slow modem preset",
|
||||
help="Change to the MEDIUM_SLOW modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-medfast",
|
||||
help="Change to the med-range and fast modem preset",
|
||||
help="Change to the MEDIUM_FAST modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-shortslow",
|
||||
help="Change to the short-range and slow modem preset",
|
||||
help="Change to the SHORT_SLOW modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-shortfast",
|
||||
help="Change to the short-range and fast modem preset",
|
||||
help="Change to the SHORT_FAST modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-shortturbo",
|
||||
help="Change to the SHORT_TURBO modem preset",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
@@ -1858,6 +2114,24 @@ def addChannelConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPa
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--contact-qr",
|
||||
help="Display a QR code for a node's contact data. "
|
||||
"Use the node ID with a '!' or '0x' prefix or the node number. "
|
||||
"Also shows the shareable contact URL.",
|
||||
metavar="!xxxxxxxx",
|
||||
)
|
||||
group.add_argument(
|
||||
"--contact-verified",
|
||||
help="Set the IS_KEY_MANUALLY_VERIFIED bit in the generated contact URL",
|
||||
action="store_true",
|
||||
)
|
||||
group.add_argument(
|
||||
"--contact-ignore",
|
||||
help="Mark this contact as blocked/ignored in the generated contact URL",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ch-enable",
|
||||
help="Enable the specified channel. Use --ch-add instead whenever possible.",
|
||||
@@ -1910,7 +2184,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="*",
|
||||
@@ -2095,6 +2370,13 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--add-contact",
|
||||
help="Add a contact (User) to the NodeDB from a shareable URL. "
|
||||
"Example: https://meshtastic.org/v/#<base64>",
|
||||
metavar="URL",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--set-time",
|
||||
help="Set the time to the provided unix epoch timestamp, or the system's current time if omitted or 0.",
|
||||
|
||||
@@ -687,6 +687,13 @@ class MeshInterface: # pylint: disable=R0902
|
||||
|
||||
def onResponseTraceRoute(self, p: dict):
|
||||
"""on response for trace route"""
|
||||
if p["decoded"]["portnum"] == "ROUTING_APP":
|
||||
error = p["decoded"]["routing"]["errorReason"]
|
||||
if error != "NONE":
|
||||
print(f"Traceroute failed: {error}")
|
||||
self._acknowledgment.receivedTraceRoute = True
|
||||
return
|
||||
|
||||
UNK_SNR = -128 # Value representing unknown SNR
|
||||
|
||||
routeDiscovery = mesh_pb2.RouteDiscovery()
|
||||
|
||||
@@ -287,11 +287,21 @@ class Node:
|
||||
# for sending admin channels will also change
|
||||
adminIndex = self.iface.localNode._getAdminChannelIndex()
|
||||
|
||||
# Snapshot serialized channel payloads from channelIndex onward so we
|
||||
# can avoid writing slots whose protobuf content did not change after
|
||||
# the shift. Use bytes (not message objects), because _fixupChannels()
|
||||
# mutates message fields in-place.
|
||||
old_channels = [
|
||||
self.channels[i].SerializeToString()
|
||||
for i in range(channelIndex, len(self.channels))
|
||||
]
|
||||
|
||||
self.channels.pop(channelIndex)
|
||||
self._fixupChannels() # expand back to 8 channels
|
||||
|
||||
index = channelIndex
|
||||
while index < 8:
|
||||
for old_ch in old_channels:
|
||||
if self.channels[index].SerializeToString() != old_ch:
|
||||
self.writeChannel(index, adminIndex=adminIndex)
|
||||
index += 1
|
||||
|
||||
@@ -380,6 +390,46 @@ class Node:
|
||||
s = s.replace("=", "").replace("+", "-").replace("/", "_")
|
||||
return f"https://meshtastic.org/e/#{s}"
|
||||
|
||||
def getContactURL(self, node_id: Union[int, str], should_ignore: bool = False, manually_verified: bool = False):
|
||||
"""Generate a shareable contact URL for the specified node"""
|
||||
nodeNum = to_node_num(node_id)
|
||||
|
||||
node = self.iface.nodesByNum.get(nodeNum)
|
||||
if not node or not node.get("user"):
|
||||
our_exit(f"Warning: Node {node_id} not found in NodeDB")
|
||||
|
||||
contact = admin_pb2.SharedContact()
|
||||
contact.node_num = nodeNum
|
||||
|
||||
u = node["user"]
|
||||
if u.get("id"):
|
||||
contact.user.id = u["id"]
|
||||
if u.get("macaddr"):
|
||||
contact.user.macaddr = base64.b64decode(u["macaddr"])
|
||||
if u.get("longName"):
|
||||
contact.user.long_name = u["longName"]
|
||||
if u.get("shortName"):
|
||||
contact.user.short_name = u["shortName"]
|
||||
if u.get("hwModel") and u["hwModel"] != "UNSET":
|
||||
contact.user.hw_model = mesh_pb2.HardwareModel.Value(u["hwModel"])
|
||||
if u.get("role"):
|
||||
contact.user.role = config_pb2.Config.DeviceConfig.Role.Value(u["role"])
|
||||
if u.get("publicKey"):
|
||||
contact.user.public_key = base64.b64decode(u["publicKey"])
|
||||
if u.get("isLicensed"):
|
||||
contact.user.is_licensed = u["isLicensed"]
|
||||
if u.get("isUnmessagable") is not None:
|
||||
contact.user.is_unmessagable = u["isUnmessagable"]
|
||||
if should_ignore:
|
||||
contact.should_ignore = True
|
||||
if manually_verified:
|
||||
contact.manually_verified = True
|
||||
|
||||
data = contact.SerializeToString()
|
||||
s = base64.urlsafe_b64encode(data).decode("ascii")
|
||||
s = s.replace("=", "").replace("+", "-").replace("/", "_")
|
||||
return f"https://meshtastic.org/v/#{s}"
|
||||
|
||||
def setURL(self, url: str, addOnly: bool = False):
|
||||
"""Set mesh network URL"""
|
||||
if self.localConfig is None or self.channels is None:
|
||||
@@ -445,6 +495,32 @@ class Node:
|
||||
self.ensureSessionKey()
|
||||
self._sendAdmin(p)
|
||||
|
||||
def addContactURL(self, url: str):
|
||||
"""Add a contact (User) to the NodeDB from a shareable URL"""
|
||||
self.ensureSessionKey()
|
||||
|
||||
splitURL = url.split("/#")
|
||||
if len(splitURL) == 1:
|
||||
our_exit(f"Warning: Invalid URL '{url}'")
|
||||
b64 = splitURL[-1]
|
||||
|
||||
missing_padding = len(b64) % 4
|
||||
if missing_padding:
|
||||
b64 += "=" * (4 - missing_padding)
|
||||
|
||||
decodedURL = base64.urlsafe_b64decode(b64)
|
||||
contact = admin_pb2.SharedContact()
|
||||
contact.ParseFromString(decodedURL)
|
||||
|
||||
p = admin_pb2.AdminMessage()
|
||||
p.add_contact.CopyFrom(contact)
|
||||
|
||||
if self == self.iface.localNode:
|
||||
onResponse = None
|
||||
else:
|
||||
onResponse = self.onAckNak
|
||||
return self._sendAdmin(p, onResponse=onResponse)
|
||||
|
||||
def onResponseRequestRingtone(self, p):
|
||||
"""Handle the response packet for requesting ringtone part 1"""
|
||||
logger.debug(f"onResponseRequestRingtone() p:{p}")
|
||||
|
||||
20
meshtastic/protobuf/mesh_pb2.py
generated
20
meshtastic/protobuf/mesh_pb2.py
generated
File diff suppressed because one or more lines are too long
32
meshtastic/protobuf/mesh_pb2.pyi
generated
32
meshtastic/protobuf/mesh_pb2.pyi
generated
@@ -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
|
||||
"""
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,11 @@ from typing import Optional
|
||||
from meshtastic.stream_interface import StreamInterface
|
||||
|
||||
DEFAULT_TCP_PORT = 4403
|
||||
|
||||
# How long close() gives the device to consume what we last wrote before forcing
|
||||
# the connection down. See close() for why this exists.
|
||||
GRACEFUL_CLOSE_TIMEOUT = 0.25
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -80,6 +85,18 @@ class TCPInterface(StreamInterface):
|
||||
server_address = (self.hostname, self.portNumber)
|
||||
self.socket = socket.create_connection(server_address)
|
||||
|
||||
def _wait_for_reader_exit(self, timeout: float) -> None:
|
||||
"""Wait briefly for the reader thread to drain and exit after a half-close.
|
||||
|
||||
Returns as soon as it exits, or after timeout: the device is not obliged
|
||||
to close just because we did.
|
||||
"""
|
||||
rx = getattr(self, "_rxThread", None)
|
||||
if rx is None or rx is threading.current_thread():
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
rx.join(timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close a connection to the device."""
|
||||
logger.debug("Closing TCP stream")
|
||||
@@ -87,6 +104,20 @@ class TCPInterface(StreamInterface):
|
||||
# Therefore force a shutdown first to unblock reader thread reads.
|
||||
self._wantExit = True
|
||||
if self.socket is not None:
|
||||
# Half-close first. shutdown(SHUT_WR) sends FIN, which tells the
|
||||
# device we are done writing and lets it consume what we last wrote
|
||||
# before the connection goes away -- typically the admin message a
|
||||
# one-shot command such as `--set` just sent.
|
||||
#
|
||||
# Going straight to shutdown(SHUT_RDWR) + close() while either side
|
||||
# still has unread data makes the stack send RST instead. Winsock
|
||||
# then discards data the peer had already received but not yet read,
|
||||
# so the write is lost; Linux delivers it before reporting
|
||||
# ECONNRESET, which is why this only bites on Windows.
|
||||
with contextlib.suppress(Exception):
|
||||
self.socket.shutdown(socket.SHUT_WR)
|
||||
self._wait_for_reader_exit(GRACEFUL_CLOSE_TIMEOUT)
|
||||
|
||||
with contextlib.suppress(
|
||||
Exception
|
||||
): # Ignore errors in shutdown, because we might have a race with the server
|
||||
|
||||
@@ -8,6 +8,87 @@ import pytest
|
||||
from meshtastic import mt_config
|
||||
|
||||
from ..mesh_interface import MeshInterface
|
||||
from .firmware_harness import (
|
||||
CHAIN_TOPOLOGY,
|
||||
DEFAULT_BASE_PORT,
|
||||
SimMesh,
|
||||
find_meshtasticd,
|
||||
is_compatible_host,
|
||||
)
|
||||
from .fw_helpers import set_region
|
||||
|
||||
# Use a different base port for the single-node fixture so it doesn't
|
||||
# conflict with the multi-node mesh fixture.
|
||||
SINGLE_NODE_BASE_PORT = DEFAULT_BASE_PORT + 100
|
||||
|
||||
|
||||
def _skip_firmware_if_unavailable() -> None:
|
||||
"""Skip the test when meshtasticd can't run on this host."""
|
||||
if not is_compatible_host():
|
||||
pytest.skip("meshtasticd firmware tests require Linux")
|
||||
if find_meshtasticd() is None:
|
||||
pytest.skip(
|
||||
"meshtasticd not found — set MESHTASTICD_BIN or install it on PATH"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def firmware_node():
|
||||
"""A single meshtasticd sim node for smokevirt tests.
|
||||
|
||||
Function-scoped so every test gets a freshly-erased node with no
|
||||
state leaking from previous tests. This makes destructive commands
|
||||
(``--reboot``, ``--set factory_reset true``) safe to run and lets
|
||||
tests be order-independent.
|
||||
|
||||
Yields the SimNode instance. The node is booted with a fresh erased
|
||||
config and listens on localhost at its TCP port. Region is set to US
|
||||
so modem-preset tests work against firmware >= 2.8, which clamps
|
||||
presets to the legal set for the current region.
|
||||
"""
|
||||
_skip_firmware_if_unavailable()
|
||||
mesh = SimMesh(n_nodes=1, base_port=SINGLE_NODE_BASE_PORT)
|
||||
mesh.start()
|
||||
node = mesh.get_node(0)
|
||||
set_region(node.port, "US")
|
||||
# The region commit restarts the TCP listener, so reconnect the harness
|
||||
# interface in case a test wants to use it directly.
|
||||
if node.iface is not None:
|
||||
try:
|
||||
node.iface.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
node.connect()
|
||||
yield node
|
||||
mesh.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def firmware_mesh():
|
||||
"""A 3-node chain (A-B-C) meshtasticd sim mesh for smokemesh tests.
|
||||
|
||||
Yields the SimMesh instance. Nodes are connected and the SIMULATOR_APP
|
||||
packet bridge is running. Region is set to US for firmware >= 2.8
|
||||
compatibility, interfaces are reconnected after the region change, and
|
||||
node DB convergence is awaited.
|
||||
"""
|
||||
_skip_firmware_if_unavailable()
|
||||
mesh = SimMesh(n_nodes=3, topology=CHAIN_TOPOLOGY)
|
||||
mesh.start()
|
||||
for node in mesh.nodes:
|
||||
set_region(node.port, "US")
|
||||
# The region commit restarts each node's TCP listener, so reconnect the
|
||||
# harness interfaces before waiting for convergence.
|
||||
for node in mesh.nodes:
|
||||
if node.iface is not None:
|
||||
try:
|
||||
node.iface.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
node.connect()
|
||||
mesh.wait_for_convergence(timeout=30)
|
||||
yield mesh
|
||||
mesh.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
357
meshtastic/tests/firmware_harness.py
Normal file
357
meshtastic/tests/firmware_harness.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""Test harness for running real meshtasticd firmware instances.
|
||||
|
||||
Launches one or more meshtasticd processes in simulator mode (-s) and bridges
|
||||
their "over-the-air" packets via the SIMULATOR_APP protocol so that multiple
|
||||
instances can communicate as if via LoRa, with a configurable topology.
|
||||
|
||||
The harness expects meshtasticd to be available on PATH or via the
|
||||
MESHTASTICD_BIN environment variable. It does not download or build the
|
||||
binary itself.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
from meshtastic import BROADCAST_NUM, mesh_pb2, portnums_pb2
|
||||
from meshtastic.tcp_interface import TCPInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HW_ID_OFFSET = 16
|
||||
DEFAULT_BASE_PORT = 4404
|
||||
DEFAULT_RSSI = -50
|
||||
DEFAULT_SNR = 10.0
|
||||
BOOT_TIMEOUT = 30
|
||||
CONNECT_TIMEOUT = 30
|
||||
|
||||
CHAIN_TOPOLOGY: Dict[int, Set[int]] = {
|
||||
0: {1},
|
||||
1: {0, 2},
|
||||
2: {1},
|
||||
}
|
||||
|
||||
|
||||
def find_meshtasticd() -> Optional[str]:
|
||||
"""Return the path to the meshtasticd binary, or None if not found."""
|
||||
env_path = os.environ.get("MESHTASTICD_BIN")
|
||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
return env_path
|
||||
return shutil.which("meshtasticd")
|
||||
|
||||
|
||||
def is_compatible_host() -> bool:
|
||||
"""True when the host can run meshtasticd natively (Linux only)."""
|
||||
return platform.system() == "Linux"
|
||||
|
||||
|
||||
def _wait_for_port(port: int, timeout: int = BOOT_TIMEOUT) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
s = socket.create_connection(("localhost", port), timeout=0.5)
|
||||
s.close()
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
class SimNode:
|
||||
"""A single meshtasticd simulator instance."""
|
||||
|
||||
def __init__(self, node_id: int, base_port: int = DEFAULT_BASE_PORT):
|
||||
self.node_id = node_id
|
||||
self.hw_id = node_id + HW_ID_OFFSET
|
||||
self.port = base_port + node_id
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.workdir: Optional[str] = None
|
||||
self.iface: Optional[TCPInterface] = None
|
||||
self._log_files: list = []
|
||||
|
||||
@property
|
||||
def node_num(self) -> int:
|
||||
"""The firmware-assigned node number (== hw_id)."""
|
||||
if self.iface and self.iface.myInfo:
|
||||
return self.iface.myInfo.my_node_num
|
||||
return self.hw_id
|
||||
|
||||
def start(self, binary: str) -> None:
|
||||
"""Launch the meshtasticd process in simulator mode."""
|
||||
self.workdir = tempfile.mkdtemp(prefix=f"mtd_node{self.node_id}_")
|
||||
vfs_dir = os.path.join(self.workdir, "vfs")
|
||||
os.mkdir(vfs_dir)
|
||||
# Files are closed in _kill(); keep them open for the process lifetime.
|
||||
log_stdout = open( # pylint: disable=consider-using-with
|
||||
os.path.join(self.workdir, "meshtasticd.log"), "wb", buffering=0
|
||||
)
|
||||
log_stderr = open( # pylint: disable=consider-using-with
|
||||
os.path.join(self.workdir, "meshtasticd.err"), "wb", buffering=0
|
||||
)
|
||||
self._log_files = [log_stdout, log_stderr]
|
||||
self.process = subprocess.Popen( # pylint: disable=consider-using-with
|
||||
[
|
||||
binary,
|
||||
"-s",
|
||||
"-h", str(self.hw_id),
|
||||
"-p", str(self.port),
|
||||
"-d", vfs_dir,
|
||||
"-e",
|
||||
],
|
||||
stdout=log_stdout,
|
||||
stderr=log_stderr,
|
||||
start_new_session=True,
|
||||
)
|
||||
if not _wait_for_port(self.port):
|
||||
self._kill()
|
||||
raise RuntimeError(
|
||||
f"meshtasticd node {self.node_id} did not start listening on port {self.port}"
|
||||
)
|
||||
def connect(self) -> None:
|
||||
"""Open a TCPInterface connection to this node."""
|
||||
self.iface = TCPInterface(
|
||||
hostname="localhost",
|
||||
portNumber=self.port,
|
||||
connectNow=False,
|
||||
)
|
||||
self.iface.myConnect()
|
||||
self.iface.connect()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the interface and kill the process."""
|
||||
if self.iface is not None:
|
||||
try:
|
||||
self.iface.localNode.exitSimulator()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.iface.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.iface = None
|
||||
self._kill()
|
||||
if self.workdir:
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
self.workdir = None
|
||||
|
||||
def _kill(self) -> None:
|
||||
for f in self._log_files:
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._log_files = []
|
||||
if self.process is not None:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
# Give OS time to release TCP port (avoid TIME_WAIT preventing
|
||||
# next instance from binding the same port)
|
||||
time.sleep(1.0)
|
||||
self.process = None
|
||||
|
||||
|
||||
class SimMesh:
|
||||
"""Manages N meshtasticd sim instances and bridges their SIMULATOR_APP packets.
|
||||
|
||||
When *topology* is None every node hears every other node (full mesh).
|
||||
Otherwise *topology* maps a transmitter's node index to the set of receiver
|
||||
node indices that can hear it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n_nodes: int = 1,
|
||||
topology: Optional[Dict[int, Set[int]]] = None,
|
||||
base_port: int = DEFAULT_BASE_PORT,
|
||||
):
|
||||
self.n_nodes = n_nodes
|
||||
self.topology = topology
|
||||
self.base_port = base_port
|
||||
self.nodes: List[SimNode] = [
|
||||
SimNode(i, base_port) for i in range(n_nodes)
|
||||
]
|
||||
self._port_to_idx: Dict[int, int] = {}
|
||||
self._started = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Launch all nodes, connect, and start the packet bridge."""
|
||||
binary = find_meshtasticd()
|
||||
if binary is None:
|
||||
raise RuntimeError(
|
||||
"meshtasticd not found. Set MESHTASTICD_BIN or install it on PATH."
|
||||
)
|
||||
|
||||
for node in self.nodes:
|
||||
node.start(binary)
|
||||
|
||||
for node in self.nodes:
|
||||
node.connect()
|
||||
self._port_to_idx[node.port] = node.node_id
|
||||
|
||||
pub.subscribe(self._on_sim_packet, "meshtastic.receive.simulator")
|
||||
self._started = True
|
||||
|
||||
if self.n_nodes > 1:
|
||||
self._trigger_convergence()
|
||||
|
||||
def _trigger_convergence(self) -> None:
|
||||
"""Actively trigger NodeInfo exchange instead of waiting passively.
|
||||
|
||||
Sends a NODEINFO_APP packet with wantResponse from each node so the
|
||||
firmware's NodeInfoModule responds with its own user info, populating
|
||||
all node DBs deterministically.
|
||||
"""
|
||||
for node in self.nodes:
|
||||
iface = node.iface
|
||||
if iface is None:
|
||||
continue
|
||||
user = mesh_pb2.User()
|
||||
user.id = f"!{node.node_num:08x}"
|
||||
user.long_name = f"Node {node.node_id}"
|
||||
user.short_name = f"{node.node_id:04d}"[:4]
|
||||
user.hw_model = mesh_pb2.HardwareModel.PORTDUINO
|
||||
try:
|
||||
iface.sendData(
|
||||
user,
|
||||
destinationId=BROADCAST_NUM,
|
||||
portNum=portnums_pb2.PortNum.NODEINFO_APP,
|
||||
wantAck=False,
|
||||
wantResponse=True,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.debug("NodeInfo trigger for node %d failed: %s", node.node_id, ex)
|
||||
time.sleep(5)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Shut down all nodes and clean up."""
|
||||
if not self._started:
|
||||
return
|
||||
try:
|
||||
pub.unsubscribe(self._on_sim_packet, "meshtastic.receive.simulator")
|
||||
except Exception:
|
||||
pass
|
||||
for node in self.nodes:
|
||||
node.close()
|
||||
self._started = False
|
||||
|
||||
def get_node(self, idx: int) -> SimNode:
|
||||
"""Return the SimNode at the given index."""
|
||||
return self.nodes[idx]
|
||||
|
||||
def get_iface(self, idx: int) -> TCPInterface:
|
||||
"""Return the TCPInterface for the node at the given index."""
|
||||
iface = self.nodes[idx].iface
|
||||
assert iface is not None, f"node {idx} has no interface"
|
||||
return iface
|
||||
|
||||
def wait_for_convergence(self, timeout: int = 30) -> bool:
|
||||
"""Wait until every node sees all others in its node DB.
|
||||
|
||||
Returns True if converged, False if timed out (non-fatal — the packet
|
||||
bridge forwards regardless of node DB state).
|
||||
"""
|
||||
if self.n_nodes <= 1:
|
||||
return True
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if all(
|
||||
node.iface is not None
|
||||
and node.iface.nodes is not None
|
||||
and len(node.iface.nodes) >= self.n_nodes
|
||||
for node in self.nodes
|
||||
):
|
||||
return True
|
||||
time.sleep(2)
|
||||
logger.warning("Mesh did not fully converge within %ds", timeout)
|
||||
return False
|
||||
|
||||
def _get_receivers(self, tx_idx: int) -> List[int]:
|
||||
"""Return node indices that can hear a transmission from *tx_idx*."""
|
||||
if self.topology is not None:
|
||||
return sorted(self.topology.get(tx_idx, set()))
|
||||
return [i for i in range(self.n_nodes) if i != tx_idx]
|
||||
|
||||
def _on_sim_packet(self, interface, packet) -> None:
|
||||
"""Bridge callback: forward a SIMULATOR_APP packet to receiving nodes."""
|
||||
tx_port = getattr(interface, "portNumber", None)
|
||||
tx_idx = self._port_to_idx.get(tx_port) if tx_port else None
|
||||
if tx_idx is None:
|
||||
return
|
||||
|
||||
rx_indices = self._get_receivers(tx_idx)
|
||||
if not rx_indices:
|
||||
return
|
||||
|
||||
data = packet["decoded"]["payload"]
|
||||
if hasattr(data, "SerializeToString"):
|
||||
data = data.SerializeToString()
|
||||
|
||||
if len(data) > mesh_pb2.Constants.DATA_PAYLOAD_LEN:
|
||||
logger.warning("Simulator payload too big (%d bytes), dropping", len(data))
|
||||
return
|
||||
|
||||
mesh_packet = _build_mesh_packet(packet, data)
|
||||
|
||||
for rx_idx in rx_indices:
|
||||
rx_iface = self.nodes[rx_idx].iface
|
||||
if rx_iface is None:
|
||||
continue
|
||||
mesh_packet.rx_rssi = DEFAULT_RSSI
|
||||
mesh_packet.rx_snr = DEFAULT_SNR
|
||||
to_radio = mesh_pb2.ToRadio()
|
||||
to_radio.packet.CopyFrom(mesh_packet)
|
||||
try:
|
||||
rx_iface._sendToRadio(to_radio)
|
||||
except Exception as ex:
|
||||
logger.error("Error forwarding packet to node %d: %s", rx_idx, ex)
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.stop()
|
||||
|
||||
|
||||
def _build_mesh_packet(packet: dict, data: bytes) -> mesh_pb2.MeshPacket:
|
||||
"""Reconstruct a MeshPacket for SIMULATOR_APP injection."""
|
||||
mp = mesh_pb2.MeshPacket()
|
||||
mp.decoded.payload = data
|
||||
mp.decoded.portnum = portnums_pb2.PortNum.SIMULATOR_APP
|
||||
mp.to = packet.get("to", BROADCAST_NUM)
|
||||
setattr(mp, "from", packet.get("from", 0))
|
||||
mp.id = packet.get("id", 0)
|
||||
mp.want_ack = packet.get("wantAck", False)
|
||||
mp.hop_limit = packet.get("hopLimit", 0)
|
||||
mp.hop_start = packet.get("hopStart", 0)
|
||||
mp.via_mqtt = packet.get("viaMQTT", False)
|
||||
mp.relay_node = packet.get("relayNode", 0)
|
||||
mp.next_hop = packet.get("nextHop", 0)
|
||||
mp.channel = int(packet.get("channel", 0))
|
||||
|
||||
decoded = packet.get("decoded", {})
|
||||
if "requestId" in decoded:
|
||||
mp.decoded.request_id = decoded["requestId"]
|
||||
if "wantResponse" in decoded:
|
||||
mp.decoded.want_response = decoded["wantResponse"]
|
||||
if "bitfield" in decoded:
|
||||
mp.decoded.bitfield = decoded["bitfield"]
|
||||
|
||||
return mp
|
||||
374
meshtastic/tests/fw_helpers.py
Normal file
374
meshtastic/tests/fw_helpers.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Shared helpers for meshtasticd-backed smoke tests.
|
||||
|
||||
Both ``test_smokevirt`` (single node) and ``test_smokemesh`` (multi-node
|
||||
chain) use these helpers to drive the ``meshtastic`` CLI against real
|
||||
``meshtasticd`` simulator instances and then verify the resulting firmware
|
||||
state through the Python library's ``TCPInterface``.
|
||||
|
||||
Verifying through the library (rather than regex on CLI stdout) is the
|
||||
core design choice: it makes tests robust against CLI wording changes
|
||||
while still exercising both the CLI argparse path and the firmware I/O
|
||||
path of every feature.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
from meshtastic.tcp_interface import TCPInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pause between a CLI command finishing and a verification interface
|
||||
# opening, to let the firmware flush its TCP bookkeeping. Keeps the
|
||||
# simulator happy when many short-lived connections are happening.
|
||||
PAUSE_AFTER_CLI = 0.2
|
||||
|
||||
# Pause after changing the LoRa region. The firmware may restart the TCP
|
||||
# listener while committing the new radio config, so give it time to settle
|
||||
# before the next CLI call or harness reconnect.
|
||||
PAUSE_AFTER_REGION_CHANGE = 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_cli() -> str:
|
||||
"""Return a shell-invokable ``meshtastic`` command.
|
||||
|
||||
Prefers the in-tree module run through the current interpreter so
|
||||
tests exercise the source we are editing, regardless of any
|
||||
separately-installed ``meshtastic`` entry point on PATH.
|
||||
"""
|
||||
# The PATH binary may live outside the nono sandbox's allowed paths;
|
||||
# ``python -m meshtastic`` is more portable and always available.
|
||||
return f"{sys.executable} -m meshtastic"
|
||||
|
||||
|
||||
def run_cli(
|
||||
port: int,
|
||||
*args: str,
|
||||
timeout: int = 60,
|
||||
retries: int = 2,
|
||||
retry_delay: float = 1.0,
|
||||
) -> Tuple[int, str]:
|
||||
"""Run the ``meshtastic`` CLI against the sim node on *port*.
|
||||
|
||||
Returns ``(return_code, merged_stdout_stderr)``. ``--host
|
||||
localhost:PORT`` is prefixed automatically. stderr is merged into
|
||||
stdout so callers can match warning text such as "Warning: Need to
|
||||
specify ..." regardless of which stream it lands on.
|
||||
|
||||
If the CLI fails to connect on the first attempt (which happens
|
||||
transiently when a freshly-booted sim node needs a moment to settle
|
||||
after the harness interface connects), retry up to *retries*
|
||||
additional times after *retry_delay* seconds.
|
||||
"""
|
||||
cli = resolve_cli()
|
||||
argv = [*_shlex_split(cli)]
|
||||
argv.extend(["--host", f"localhost:{port}"])
|
||||
argv.extend(args)
|
||||
logger.debug("run_cli: %s", argv)
|
||||
|
||||
last_out = ""
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
argv,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as ex:
|
||||
last_out = ex.stdout.decode("utf-8", errors="replace") if ex.stdout else ""
|
||||
if attempt < retries:
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
return 124, last_out
|
||||
|
||||
out = proc.stdout.decode("utf-8", errors="replace")
|
||||
rc = proc.returncode
|
||||
|
||||
# Retry on transient connection-refused / timed-out errors that
|
||||
# are common right after a sim node spins up.
|
||||
transient = (
|
||||
"Error connecting" in out
|
||||
or "Timed out waiting for connection" in out
|
||||
or "Connection reset by peer" in out
|
||||
)
|
||||
if rc != 0 and transient and attempt < retries:
|
||||
logger.debug("run_cli: transient failure, retry %d/%d", attempt + 1, retries)
|
||||
time.sleep(retry_delay)
|
||||
last_out = out
|
||||
continue
|
||||
return rc, out
|
||||
return rc, last_out
|
||||
|
||||
|
||||
def _shlex_split(cmd: str) -> List[str]:
|
||||
"""Split a shell string into argv, honoring quotes."""
|
||||
return shlex.split(cmd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fresh-connection state verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _wait_for_port(port: int, timeout: float = 30.0) -> None:
|
||||
"""Wait until *port* accepts a TCP connection (firmware sim comes up
|
||||
or comes back after a config-commit reboot)."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
s = socket.create_connection(("localhost", port), timeout=0.5)
|
||||
s.close()
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError(
|
||||
f"port {port} did not accept connections within {timeout}s"
|
||||
)
|
||||
|
||||
|
||||
def connect_iface(
|
||||
port: int,
|
||||
no_nodes: bool = False,
|
||||
retries: int = 4,
|
||||
wait_timeout: float = 30.0,
|
||||
) -> TCPInterface:
|
||||
"""Open a fresh ``TCPInterface`` to *port* and block on the config exchange.
|
||||
|
||||
Firmware config writes (``writeChannel``, ``--seturl``, ``--factory_reset``)
|
||||
can briefly restart the sim's TCP listener. We wait for the port to
|
||||
come up first, then retry the connect+config-exchange a few times.
|
||||
"""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
_wait_for_port(port, timeout=wait_timeout)
|
||||
return TCPInterface(
|
||||
hostname="localhost",
|
||||
portNumber=port,
|
||||
connectNow=True,
|
||||
noNodes=no_nodes,
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
last_exc = ex
|
||||
if attempt < retries:
|
||||
logger.debug(
|
||||
"connect_iface attempt %d/%d failed: %s",
|
||||
attempt + 1, retries, ex,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise
|
||||
assert last_exc is not None # type guard; unreachable
|
||||
raise last_exc # pragma: no cover
|
||||
|
||||
|
||||
def verify_state(
|
||||
port: int,
|
||||
verifier: Callable[[TCPInterface], None],
|
||||
*,
|
||||
no_nodes: bool = False,
|
||||
) -> None:
|
||||
"""Open a fresh interface and run *verifier(iface)*, then close.
|
||||
|
||||
Used after a CLI mutation to verify firmware state through the
|
||||
library. Always closes the interface so the next test starts clean.
|
||||
"""
|
||||
iface = connect_iface(port, no_nodes=no_nodes)
|
||||
try:
|
||||
verifier(iface)
|
||||
finally:
|
||||
try:
|
||||
iface.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
time.sleep(PAUSE_AFTER_CLI)
|
||||
|
||||
|
||||
def cli_then_verify(
|
||||
port: int,
|
||||
cli_args: List[str],
|
||||
verifier: Optional[Callable[[TCPInterface], None]],
|
||||
*,
|
||||
expect_rc: Optional[int] = 0,
|
||||
no_nodes: bool = False,
|
||||
cli_timeout: int = 60,
|
||||
) -> str:
|
||||
"""Run *cli_args* against *port*, optionally asserting *expect_rc*,
|
||||
then (if *verifier* is not None) open a fresh interface and run
|
||||
*verifier(iface)* against the just-mutated firmware state.
|
||||
|
||||
Returns the CLI stdout.
|
||||
"""
|
||||
rc, out = run_cli(port, *cli_args, timeout=cli_timeout)
|
||||
if expect_rc is not None:
|
||||
assert rc == expect_rc, f"CLI rc={rc} (expected {expect_rc}): {out}"
|
||||
time.sleep(PAUSE_AFTER_CLI)
|
||||
if verifier is not None:
|
||||
verify_state(port, verifier, no_nodes=no_nodes)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packet collectors (used by smokemesh receive-verification tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RECEIVE_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class PacketCollector:
|
||||
"""Collect packets received on a specific interface via pubsub.
|
||||
|
||||
``Listener`` (pubsub 4.x) wraps handlers with a weak reference, so
|
||||
we keep a strong reference to ``handler`` on the instance to prevent
|
||||
garbage collection before the publishing thread gets to call it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.packets: List[dict] = []
|
||||
self._handler: Optional[Callable] = None
|
||||
|
||||
def on_receive(self, packet, interface): # pylint: disable=unused-argument
|
||||
"""Append a received packet to the internal list."""
|
||||
self.packets.append(packet)
|
||||
|
||||
def wait_for(self, count: int, timeout: float = RECEIVE_TIMEOUT) -> bool:
|
||||
"""Wait until *count* packets have been collected or *timeout* expires."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if len(self.packets) >= count:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return len(self.packets) >= count
|
||||
|
||||
@property
|
||||
def texts(self) -> List[str]:
|
||||
"""Return text payloads from TEXT_MESSAGE_APP packets."""
|
||||
return [
|
||||
p.get("decoded", {}).get("text", "")
|
||||
for p in self.packets
|
||||
if p.get("decoded", {}).get("portnum") == "TEXT_MESSAGE_APP"
|
||||
]
|
||||
|
||||
@property
|
||||
def traceroutes(self) -> List[dict]:
|
||||
"""Return TRACEROUTE_APP packets."""
|
||||
return [
|
||||
p for p in self.packets
|
||||
if p.get("decoded", {}).get("portnum") == "TRACEROUTE_APP"
|
||||
]
|
||||
|
||||
@property
|
||||
def telemetries(self) -> List[dict]:
|
||||
"""Return TELEMETRY_APP packets."""
|
||||
return [
|
||||
p for p in self.packets
|
||||
if p.get("decoded", {}).get("portnum") == "TELEMETRY_APP"
|
||||
]
|
||||
|
||||
@property
|
||||
def positions(self) -> List[dict]:
|
||||
"""Return POSITION_APP packets."""
|
||||
return [
|
||||
p for p in self.packets
|
||||
if p.get("decoded", {}).get("portnum") == "POSITION_APP"
|
||||
]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all collected packets."""
|
||||
self.packets.clear()
|
||||
|
||||
|
||||
def _subscribe_topic(
|
||||
iface: TCPInterface, topic: str
|
||||
) -> PacketCollector:
|
||||
"""Internal: subscribe a fully-qualified *topic* and return a collector.
|
||||
|
||||
Filters by *interface* so multi-node tests can subscribe several
|
||||
collectors concurrently without cross-talk.
|
||||
"""
|
||||
collector = PacketCollector()
|
||||
|
||||
def handler(packet, interface):
|
||||
if interface is iface:
|
||||
collector.on_receive(packet, interface)
|
||||
|
||||
pub.subscribe(handler, topic)
|
||||
collector._handler = handler # strong ref; see PacketCollector docstring
|
||||
return collector
|
||||
|
||||
|
||||
def subscribe_texts(iface: TCPInterface) -> PacketCollector:
|
||||
"""Subscribe to ``meshtastic.receive.text`` filtered to *iface*."""
|
||||
return _subscribe_topic(iface, "meshtastic.receive.text")
|
||||
|
||||
|
||||
def subscribe_traceroutes(iface: TCPInterface) -> PacketCollector:
|
||||
"""Subscribe to ``meshtastic.receive.traceroute`` filtered to *iface*."""
|
||||
return _subscribe_topic(iface, "meshtastic.receive.traceroute")
|
||||
|
||||
|
||||
def subscribe_telemetries(iface: TCPInterface) -> PacketCollector:
|
||||
"""Subscribe to ``meshtastic.receive.telemetry`` filtered to *iface*."""
|
||||
return _subscribe_topic(iface, "meshtastic.receive.telemetry")
|
||||
|
||||
|
||||
def subscribe_positions(iface: TCPInterface) -> PacketCollector:
|
||||
"""Subscribe to ``meshtastic.receive.position`` filtered to *iface*."""
|
||||
return _subscribe_topic(iface, "meshtastic.receive.position")
|
||||
|
||||
|
||||
def set_region(port: int, region: str = "US") -> None:
|
||||
"""Set the LoRa region on a sim node via the CLI.
|
||||
|
||||
The node briefly restarts its TCP listener while committing the radio
|
||||
config; ``run_cli()`` handles the reconnect retry, and we sleep long
|
||||
enough for the new region to take effect before callers continue.
|
||||
"""
|
||||
rc, out = run_cli(port, "--set", "lora.region", region)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Failed to set lora.region={region}: {out}")
|
||||
time.sleep(PAUSE_AFTER_REGION_CHANGE)
|
||||
|
||||
|
||||
def unsubscribe_all(topic: str) -> None:
|
||||
"""Drop every handler currently registered on *topic*.
|
||||
|
||||
Tests use this in a ``finally`` block to keep pubsub clean across
|
||||
the function-scoped mesh fixtures.
|
||||
"""
|
||||
try:
|
||||
pub.unsubAll(topic)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PAUSE_AFTER_CLI",
|
||||
"PAUSE_AFTER_REGION_CHANGE",
|
||||
"PacketCollector",
|
||||
"RECEIVE_TIMEOUT",
|
||||
"cli_then_verify",
|
||||
"connect_iface",
|
||||
"resolve_cli",
|
||||
"run_cli",
|
||||
"set_region",
|
||||
"subscribe_positions",
|
||||
"subscribe_telemetries",
|
||||
"subscribe_texts",
|
||||
"subscribe_traceroutes",
|
||||
"unsubscribe_all",
|
||||
"verify_state",
|
||||
]
|
||||
@@ -10,22 +10,29 @@ import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import mock_open, MagicMock, patch
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
import meshtastic.__main__ as mt_main
|
||||
|
||||
from meshtastic.__main__ import (
|
||||
export_config,
|
||||
export_profile,
|
||||
initParser,
|
||||
main,
|
||||
onConnection,
|
||||
onNode,
|
||||
onReceive,
|
||||
setPref,
|
||||
tunnelMain,
|
||||
set_missing_flags_false,
|
||||
_profile_from_yaml,
|
||||
)
|
||||
from meshtastic import mt_config
|
||||
|
||||
from ..protobuf.channel_pb2 import Channel # 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 ..mesh_interface import MeshInterface
|
||||
@@ -869,7 +876,7 @@ def test_main_sendtext_with_invalid_channel_nine(caplog, capsys):
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("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):
|
||||
@@ -1067,7 +1074,7 @@ def test_main_seturl(capsys):
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1091,7 +1098,7 @@ def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hup
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1115,7 +1122,7 @@ def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, m
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1142,7 +1149,7 @@ def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open,
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1167,7 +1174,7 @@ def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open,
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1192,7 +1199,7 @@ def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, moc
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -1224,7 +1231,7 @@ def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_main_configure_with_camel_case_keys(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||
@@ -2010,6 +2017,471 @@ position_flags: 35"""
|
||||
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
|
||||
# recursion depth exceeded error
|
||||
#@pytest.mark.unit
|
||||
@@ -2075,6 +2547,124 @@ position_flags: 35"""
|
||||
# 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
|
||||
def test_set_missing_flags_false():
|
||||
"""Test set_missing_flags_false() function"""
|
||||
@@ -2904,7 +3494,7 @@ def test_tunnel_subnet_arg_with_no_devices(mock_platform_system, caplog, capsys)
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
@patch("platform.system")
|
||||
@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("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||
def test_tunnel_tunnel_arg(
|
||||
@@ -2998,6 +3588,56 @@ def test_remove_ignored_node():
|
||||
main()
|
||||
|
||||
mocked_node.removeIgnored.assert_called_once_with("!12345678")
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_add_contact_url():
|
||||
"""Test --add-contact with a shareable URL"""
|
||||
url = "https://meshtastic.org/v/#CKqkvZgIElEKCSE4MzBmNTIyYRIQUm9hZHJ1bm5lciBSaWRnZRoEUktTTiIGAAAAAAAAKAk4AkIgRxo_Fw_ergQIhRqBbrHasLYy3gU-Ay8hrhu4OVnIPQc=" # pylint: disable=line-too-long
|
||||
sys.argv = ["", "--add-contact", url]
|
||||
mt_config.args = sys.argv
|
||||
mocked_node = MagicMock(autospec=Node)
|
||||
iface = MagicMock(autospec=SerialInterface)
|
||||
iface.getNode.return_value = mocked_node
|
||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||
main()
|
||||
|
||||
mocked_node.addContactURL.assert_called_once_with(url)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_contact_qr():
|
||||
"""Test --contact-qr with a node ID"""
|
||||
sys.argv = ["", "--contact-qr", "!830f522a"]
|
||||
mt_config.args = sys.argv
|
||||
mocked_node = MagicMock(autospec=Node)
|
||||
iface = MagicMock(autospec=SerialInterface)
|
||||
iface.getNode.return_value = mocked_node
|
||||
mocked_node.iface = iface
|
||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||
main()
|
||||
|
||||
mocked_node.getContactURL.assert_called_once_with("!830f522a", should_ignore=False, manually_verified=False)
|
||||
mocked_node.getContactURL.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_contact_qr_with_flags():
|
||||
"""Test --contact-qr with --contact-verified and --contact-ignore"""
|
||||
sys.argv = ["", "--contact-qr", "!830f522a", "--contact-verified", "--contact-ignore"]
|
||||
mt_config.args = sys.argv
|
||||
mocked_node = MagicMock(autospec=Node)
|
||||
iface = MagicMock(autospec=SerialInterface)
|
||||
iface.getNode.return_value = mocked_node
|
||||
mocked_node.iface = iface
|
||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
||||
main()
|
||||
|
||||
mocked_node.getContactURL.assert_called_once_with("!830f522a", should_ignore=True, manually_verified=True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_main_set_owner_whitespace_only(capsys):
|
||||
@@ -3107,6 +3747,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
|
||||
@@ -3151,3 +3794,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
|
||||
|
||||
@@ -757,3 +757,43 @@ def test_timeago_fuzz(seconds):
|
||||
"""Fuzz _timeago to ensure it works with any integer"""
|
||||
val = _timeago(seconds)
|
||||
assert re.match(r"(now|\d+ (secs?|mins?|hours?|days?|months?|years?))", val)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_onResponseTraceRoute_routing_error(capsys):
|
||||
"""Test that onResponseTraceRoute handles ROUTING_APP error packets correctly."""
|
||||
iface = MeshInterface(noProto=True)
|
||||
|
||||
packet = {
|
||||
"decoded": {
|
||||
"portnum": "ROUTING_APP",
|
||||
"routing": {"errorReason": "MAX_RETRANSMIT"},
|
||||
}
|
||||
}
|
||||
|
||||
iface.onResponseTraceRoute(packet)
|
||||
|
||||
assert iface._acknowledgment.receivedTraceRoute is True
|
||||
out, _ = capsys.readouterr()
|
||||
assert "Traceroute failed: MAX_RETRANSMIT" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.usefixtures("reset_mt_config")
|
||||
def test_onResponseTraceRoute_routing_none(capsys):
|
||||
"""Test that onResponseTraceRoute does not print error for ROUTING_APP with NONE errorReason."""
|
||||
iface = MeshInterface(noProto=True)
|
||||
|
||||
packet = {
|
||||
"decoded": {
|
||||
"portnum": "ROUTING_APP",
|
||||
"routing": {"errorReason": "NONE"},
|
||||
}
|
||||
}
|
||||
|
||||
iface.onResponseTraceRoute(packet)
|
||||
|
||||
assert iface._acknowledgment.receivedTraceRoute is True
|
||||
out, _ = capsys.readouterr()
|
||||
assert "Traceroute failed" not in out
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""Meshtastic unit tests for node.py"""
|
||||
# pylint: disable=C0302
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, strategies as st
|
||||
|
||||
from ..protobuf import admin_pb2, localonly_pb2, config_pb2
|
||||
from ..protobuf import admin_pb2, localonly_pb2, config_pb2, mesh_pb2, nanopb_pb2
|
||||
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
|
||||
from ..node import Node
|
||||
from ..serial_interface import SerialInterface
|
||||
from ..mesh_interface import MeshInterface
|
||||
from ..util import to_node_num
|
||||
|
||||
# from ..config_pb2 import Config
|
||||
# from ..cannedmessages_pb2 import (CannedMessagePluginMessagePart1, CannedMessagePluginMessagePart2,
|
||||
@@ -19,6 +22,11 @@ from ..mesh_interface import MeshInterface
|
||||
# CannedMessagePluginMessagePart5)
|
||||
# from ..util import Timeout
|
||||
|
||||
# Extract nanopb max_size constraints from the User protobuf descriptor
|
||||
_USER_NANOPB = {
|
||||
field.name: field.GetOptions().Extensions[nanopb_pb2.nanopb]
|
||||
for field in mesh_pb2.User.DESCRIPTOR.fields
|
||||
}
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node(capsys):
|
||||
@@ -339,6 +347,248 @@ def test_setURL_valid_URL_but_no_settings(capsys):
|
||||
assert err == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("node_id,node_data,should_ignore,manually_verified", [
|
||||
pytest.param(
|
||||
"!830f522a",
|
||||
{
|
||||
"num": 2198819370,
|
||||
"user": {
|
||||
"id": "!830f522a",
|
||||
"longName": "Roadrunner Ridge",
|
||||
"shortName": "RKSN",
|
||||
"macaddr": "AAAAAAAAAAA=",
|
||||
"hwModel": "RAK4631",
|
||||
"role": "ROUTER",
|
||||
"publicKey": "Rx8XD96uBAiFGoFusdqwti3eBT4DLyGuG7g5Wcg9Bw==",
|
||||
"isLicensed": True,
|
||||
"isUnmessagable": False,
|
||||
},
|
||||
},
|
||||
True,
|
||||
True,
|
||||
id="all_fields_all_flags",
|
||||
),
|
||||
pytest.param(
|
||||
"!12345678",
|
||||
{
|
||||
"num": 305419896,
|
||||
"user": {
|
||||
"id": "!12345678",
|
||||
"longName": "Test Node",
|
||||
"shortName": "TN",
|
||||
"macaddr": "QkVTVEVWRVI=",
|
||||
"hwModel": "TBEAM",
|
||||
},
|
||||
},
|
||||
False,
|
||||
False,
|
||||
id="minimal_fields_no_flags",
|
||||
),
|
||||
pytest.param(
|
||||
305419896,
|
||||
{
|
||||
"num": 305419896,
|
||||
"user": {
|
||||
"id": "!12345678",
|
||||
"longName": "Another Node",
|
||||
"shortName": "AN",
|
||||
"macaddr": "QkVTVEVWRVI=",
|
||||
"hwModel": "HELTEC_V3",
|
||||
"role": "CLIENT",
|
||||
"publicKey": "AAAAAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"isLicensed": False,
|
||||
},
|
||||
},
|
||||
True,
|
||||
False,
|
||||
id="int_node_id_should_ignore_only",
|
||||
),
|
||||
pytest.param(
|
||||
"!deadbeef",
|
||||
{
|
||||
"num": 3735928559,
|
||||
"user": {
|
||||
"id": "!deadbeef",
|
||||
"longName": "Minimal Contact",
|
||||
"shortName": "MC",
|
||||
"macaddr": "BQYHCAkKCw==",
|
||||
"hwModel": "UNSET",
|
||||
"role": "CLIENT_MUTE",
|
||||
},
|
||||
},
|
||||
False,
|
||||
True,
|
||||
id="unset_hw_model_verified_only",
|
||||
),
|
||||
pytest.param(
|
||||
"!1a2b3c4d",
|
||||
{
|
||||
"num": 439041101,
|
||||
"user": {
|
||||
"id": "!1a2b3c4d",
|
||||
"longName": "Licensed Node",
|
||||
"shortName": "LN",
|
||||
"macaddr": "DA0ODxAREg==",
|
||||
"hwModel": "NANO_G1",
|
||||
"isLicensed": True,
|
||||
"isUnmessagable": True,
|
||||
},
|
||||
},
|
||||
False,
|
||||
False,
|
||||
id="licensed_unmessagable_no_flags",
|
||||
),
|
||||
])
|
||||
def test_contact_url_roundtrip(node_id, node_data, should_ignore, manually_verified):
|
||||
"""Verify that contact URL generation via getContactURL() and parsing via addContactURL() is fully reversible"""
|
||||
iface = MagicMock(autospec=MeshInterface)
|
||||
node_num = to_node_num(node_id)
|
||||
iface.nodesByNum = {node_num: node_data}
|
||||
iface.localNode = None
|
||||
|
||||
anode = Node(iface, node_num, noProto=True)
|
||||
|
||||
sent_admin = []
|
||||
def capture_send(p, *_args, **_kwargs):
|
||||
sent_admin.append(p)
|
||||
|
||||
with patch.object(anode, "_sendAdmin", side_effect=capture_send):
|
||||
url = anode.getContactURL(node_id, should_ignore=should_ignore, manually_verified=manually_verified)
|
||||
assert url.startswith("https://meshtastic.org/v/#")
|
||||
|
||||
anode.addContactURL(url)
|
||||
|
||||
assert len(sent_admin) == 1
|
||||
contact = sent_admin[0].add_contact
|
||||
u = node_data["user"]
|
||||
|
||||
assert contact.node_num == node_num
|
||||
assert contact.user.id == u["id"]
|
||||
assert contact.user.long_name == u["longName"]
|
||||
assert contact.user.short_name == u["shortName"]
|
||||
assert contact.user.macaddr == base64.b64decode(u["macaddr"])
|
||||
|
||||
if u.get("hwModel") and u["hwModel"] != "UNSET":
|
||||
assert contact.user.hw_model == mesh_pb2.HardwareModel.Value(u["hwModel"])
|
||||
if u.get("role"):
|
||||
assert contact.user.role == config_pb2.Config.DeviceConfig.Role.Value(u["role"])
|
||||
if u.get("publicKey"):
|
||||
assert contact.user.public_key == base64.b64decode(u["publicKey"])
|
||||
if u.get("isLicensed"):
|
||||
assert contact.user.is_licensed is True
|
||||
if u.get("isUnmessagable") is not None:
|
||||
assert contact.user.is_unmessagable == u["isUnmessagable"]
|
||||
|
||||
assert contact.should_ignore == should_ignore
|
||||
assert contact.manually_verified == manually_verified
|
||||
|
||||
|
||||
@st.composite
|
||||
def contact_url_roundtrip_params(draw):
|
||||
"""Hypothesis strategy: generate a full node config and roundtrip flags"""
|
||||
should_ignore = draw(st.booleans())
|
||||
manually_verified = draw(st.booleans())
|
||||
|
||||
node_num = draw(st.integers(min_value=6, max_value=2**32 - 2))
|
||||
node_id = f"!{node_num:08x}"
|
||||
|
||||
hw_model = draw(st.sampled_from(list(mesh_pb2.HardwareModel.keys())))
|
||||
role = draw(st.one_of(
|
||||
st.none(),
|
||||
st.sampled_from(list(config_pb2.Config.DeviceConfig.Role.keys())),
|
||||
))
|
||||
|
||||
long_name = draw(st.text(
|
||||
min_size=1, max_size=_USER_NANOPB['long_name'].max_size
|
||||
))
|
||||
short_name = draw(st.text(
|
||||
min_size=1, max_size=_USER_NANOPB['short_name'].max_size
|
||||
))
|
||||
|
||||
macaddr_bytes = draw(st.binary(
|
||||
min_size=_USER_NANOPB['macaddr'].max_size,
|
||||
max_size=_USER_NANOPB['macaddr'].max_size,
|
||||
))
|
||||
macaddr_b64 = base64.b64encode(macaddr_bytes).decode("ascii")
|
||||
|
||||
has_public_key = draw(st.booleans())
|
||||
public_key_b64 = None
|
||||
if has_public_key:
|
||||
pk_bytes = draw(st.binary(
|
||||
min_size=_USER_NANOPB['public_key'].max_size,
|
||||
max_size=_USER_NANOPB['public_key'].max_size,
|
||||
))
|
||||
public_key_b64 = base64.b64encode(pk_bytes).decode("ascii")
|
||||
|
||||
is_licensed = draw(st.booleans())
|
||||
is_unmessagable = draw(st.booleans())
|
||||
|
||||
node_data = {
|
||||
"num": node_num,
|
||||
"user": {
|
||||
"id": node_id,
|
||||
"longName": long_name,
|
||||
"shortName": short_name,
|
||||
"macaddr": macaddr_b64,
|
||||
"hwModel": hw_model,
|
||||
"isLicensed": is_licensed,
|
||||
"isUnmessagable": is_unmessagable,
|
||||
},
|
||||
}
|
||||
if role is not None:
|
||||
node_data["user"]["role"] = role
|
||||
if public_key_b64 is not None:
|
||||
node_data["user"]["publicKey"] = public_key_b64
|
||||
|
||||
return node_num, node_data, should_ignore, manually_verified
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@given(contact_url_roundtrip_params())
|
||||
def test_contact_url_roundtrip_hypothesis(params):
|
||||
"""Property: roundtrip preserves data across random field configurations"""
|
||||
node_num, node_data, should_ignore, manually_verified = params
|
||||
|
||||
iface = MagicMock(autospec=MeshInterface)
|
||||
iface.nodesByNum = {node_num: node_data}
|
||||
iface.localNode = None
|
||||
|
||||
anode = Node(iface, node_num, noProto=True)
|
||||
|
||||
sent_admin = []
|
||||
def capture_send(p, *_args, **_kwargs):
|
||||
sent_admin.append(p)
|
||||
|
||||
with patch.object(anode, "_sendAdmin", side_effect=capture_send):
|
||||
url = anode.getContactURL(
|
||||
node_num,
|
||||
should_ignore=should_ignore,
|
||||
manually_verified=manually_verified,
|
||||
)
|
||||
anode.addContactURL(url)
|
||||
|
||||
assert len(sent_admin) == 1
|
||||
contact = sent_admin[0].add_contact
|
||||
u = node_data["user"]
|
||||
|
||||
assert contact.node_num == node_num
|
||||
assert contact.user.id == u["id"]
|
||||
assert contact.user.long_name == u["longName"]
|
||||
assert contact.user.short_name == u["shortName"]
|
||||
assert contact.user.macaddr == base64.b64decode(u["macaddr"])
|
||||
assert contact.user.hw_model == mesh_pb2.HardwareModel.Value(u["hwModel"])
|
||||
|
||||
if "role" in u:
|
||||
assert contact.user.role == config_pb2.Config.DeviceConfig.Role.Value(u["role"])
|
||||
if "publicKey" in u:
|
||||
assert contact.user.public_key == base64.b64decode(u["publicKey"])
|
||||
assert contact.user.is_licensed == u["isLicensed"]
|
||||
assert contact.user.is_unmessagable == u["isUnmessagable"]
|
||||
assert contact.should_ignore == should_ignore
|
||||
assert contact.manually_verified == manually_verified
|
||||
|
||||
|
||||
# TODO
|
||||
# @pytest.mark.unit
|
||||
# def test_showChannels(capsys):
|
||||
@@ -419,6 +669,78 @@ def test_getChannelByChannelIndex():
|
||||
assert anode.getChannelByChannelIndex(9) is None
|
||||
|
||||
|
||||
def _build_channels(highest_secondary_index: int):
|
||||
"""Build an 8-slot channel table with contiguous active channels.
|
||||
|
||||
Slot 0 is PRIMARY. Slots 1..highest_secondary_index are SECONDARY.
|
||||
Remaining slots are DISABLED.
|
||||
"""
|
||||
channels = []
|
||||
for idx in range(8):
|
||||
ch = Channel()
|
||||
ch.index = idx
|
||||
if idx == 0:
|
||||
ch.role = Channel.Role.PRIMARY
|
||||
ch.settings.name = "primary"
|
||||
elif idx <= highest_secondary_index:
|
||||
ch.role = Channel.Role.SECONDARY
|
||||
ch.settings.name = f"ch{idx}"
|
||||
else:
|
||||
ch.role = Channel.Role.DISABLED
|
||||
channels.append(ch)
|
||||
return channels
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"highest_secondary_index,delete_index,expected_writes",
|
||||
[
|
||||
pytest.param(1, 1, [1], id="active-0-1-del-1"),
|
||||
pytest.param(2, 1, [1, 2], id="active-0-2-del-1"),
|
||||
pytest.param(3, 1, [1, 2, 3], id="active-0-3-del-1"),
|
||||
pytest.param(3, 2, [2, 3], id="active-0-3-del-2"),
|
||||
],
|
||||
)
|
||||
def test_delete_channel_writes_only_changed_suffix(
|
||||
highest_secondary_index, delete_index, expected_writes
|
||||
):
|
||||
"""deleteChannel should only write slots whose payload changed."""
|
||||
iface = MagicMock()
|
||||
anode = Node(iface, "bar", noProto=True)
|
||||
iface.localNode = anode
|
||||
anode.channels = _build_channels(highest_secondary_index)
|
||||
|
||||
writes = []
|
||||
|
||||
def fake_write(channel_index, adminIndex=0):
|
||||
writes.append((channel_index, adminIndex))
|
||||
|
||||
anode.writeChannel = fake_write
|
||||
|
||||
anode.deleteChannel(delete_index)
|
||||
|
||||
written_indices = [idx for idx, _ in writes]
|
||||
assert written_indices == expected_writes
|
||||
assert all(admin_idx == 0 for _, admin_idx in writes)
|
||||
assert 0 not in written_indices
|
||||
assert all(idx < 4 for idx in written_indices)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delete_channel_rejects_primary():
|
||||
"""deleteChannel should refuse deleting PRIMARY slot 0."""
|
||||
iface = MagicMock()
|
||||
anode = Node(iface, "bar", noProto=True)
|
||||
iface.localNode = anode
|
||||
anode.channels = _build_channels(3)
|
||||
|
||||
with pytest.raises(SystemExit) as pytest_wrapped_e:
|
||||
anode.deleteChannel(0)
|
||||
|
||||
assert pytest_wrapped_e.type is SystemExit
|
||||
assert pytest_wrapped_e.value.code == 1
|
||||
|
||||
|
||||
# TODO
|
||||
# @pytest.mark.unit
|
||||
# def test_deleteChannel_try_to_delete_primary_channel(capsys):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
128
meshtastic/tests/test_smokemesh.py
Normal file
128
meshtastic/tests/test_smokemesh.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Meshtastic smoke tests with multiple meshtasticd sim instances.
|
||||
|
||||
Uses the ``firmware_mesh`` session fixture which provides a 3-node chain
|
||||
topology (A-B-C) where A hears B, B hears A and C, and C hears B.
|
||||
The SIMULATOR_APP packet bridge forwards transmissions between nodes
|
||||
according to this topology.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from .fw_helpers import (
|
||||
RECEIVE_TIMEOUT,
|
||||
subscribe_texts,
|
||||
subscribe_traceroutes,
|
||||
unsubscribe_all,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_node_db_convergence(firmware_mesh):
|
||||
"""Each node should see all 3 nodes in its node DB after convergence."""
|
||||
counts = [len(n.iface.nodes) for n in firmware_mesh.nodes if n.iface]
|
||||
if any(c < 3 for c in counts):
|
||||
pytest.skip(f"Mesh did not converge (counts={counts})")
|
||||
for i, node in enumerate(firmware_mesh.nodes):
|
||||
iface = node.iface
|
||||
assert iface is not None
|
||||
assert len(iface.nodes) >= 3, f"node {i} only sees {len(iface.nodes)} nodes"
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_broadcast_text(firmware_mesh):
|
||||
"""A broadcast from node A should arrive on node B."""
|
||||
collector = subscribe_texts(firmware_mesh.get_iface(1))
|
||||
try:
|
||||
firmware_mesh.get_iface(0).sendText("hello mesh", wantAck=False)
|
||||
assert collector.wait_for(1)
|
||||
assert "hello mesh" in collector.texts
|
||||
finally:
|
||||
unsubscribe_all("meshtastic.receive.text")
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_dm(firmware_mesh):
|
||||
"""A DM from node A to node B should arrive on B."""
|
||||
dest = firmware_mesh.get_node(1).node_num
|
||||
collector = subscribe_texts(firmware_mesh.get_iface(1))
|
||||
try:
|
||||
firmware_mesh.get_iface(0).sendText(
|
||||
"hey B", destinationId=dest, wantAck=False
|
||||
)
|
||||
assert collector.wait_for(1)
|
||||
assert "hey B" in collector.texts
|
||||
finally:
|
||||
unsubscribe_all("meshtastic.receive.text")
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_dm_across_relay(firmware_mesh):
|
||||
"""A DM from node A to node C must relay through B (chain topology)."""
|
||||
dest = firmware_mesh.get_node(2).node_num
|
||||
collector = subscribe_texts(firmware_mesh.get_iface(2))
|
||||
try:
|
||||
firmware_mesh.get_iface(0).sendText(
|
||||
"relay test", destinationId=dest, wantAck=False
|
||||
)
|
||||
assert collector.wait_for(1), "node C did not receive the DM within timeout"
|
||||
assert "relay test" in collector.texts
|
||||
finally:
|
||||
unsubscribe_all("meshtastic.receive.text")
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_hop_limit_prevents_relay(firmware_mesh):
|
||||
"""A broadcast with hopLimit=0 from A reaches B but B does not relay to C."""
|
||||
col_b = subscribe_texts(firmware_mesh.get_iface(1))
|
||||
col_c = subscribe_texts(firmware_mesh.get_iface(2))
|
||||
try:
|
||||
firmware_mesh.get_iface(0).sendText(
|
||||
"hop0", wantAck=False, hopLimit=0
|
||||
)
|
||||
assert col_b.wait_for(1), "B should receive A's broadcast"
|
||||
assert "hop0" in col_b.texts, "B got wrong text"
|
||||
|
||||
time.sleep(RECEIVE_TIMEOUT)
|
||||
assert "hop0" not in col_c.texts, (
|
||||
"C should NOT receive — B must not relay hopLimit=0"
|
||||
)
|
||||
finally:
|
||||
unsubscribe_all("meshtastic.receive.text")
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_show_nodes(firmware_mesh):
|
||||
"""showNodes should report the other nodes in the mesh."""
|
||||
for i in range(3):
|
||||
iface = firmware_mesh.get_iface(i)
|
||||
iface.showNodes()
|
||||
|
||||
|
||||
@pytest.mark.smokemesh
|
||||
def test_smokemesh_traceroute_across_relay(firmware_mesh):
|
||||
"""Traceroute from A to C should show route via B in both directions."""
|
||||
col_a = subscribe_traceroutes(firmware_mesh.get_iface(0))
|
||||
col_c = subscribe_traceroutes(firmware_mesh.get_iface(2))
|
||||
try:
|
||||
src_a = firmware_mesh.get_node(0).node_num
|
||||
dest_c = firmware_mesh.get_node(2).node_num
|
||||
node_b = firmware_mesh.get_node(1).node_num
|
||||
|
||||
firmware_mesh.get_iface(0).sendTraceRoute(dest=dest_c, hopLimit=3)
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
assert len(col_a.traceroutes) >= 1, "A did not receive traceroute response"
|
||||
a_resp = col_a.traceroutes[0]
|
||||
assert a_resp["from"] == dest_c, "response source should be C"
|
||||
|
||||
route = a_resp["decoded"]["traceroute"]
|
||||
assert route.get("route") == [node_b], "forward route should be A→B→C"
|
||||
assert route.get("routeBack") == [node_b], "return route should be C→B→A"
|
||||
|
||||
assert len(col_c.traceroutes) >= 1, "C did not receive traceroute request"
|
||||
c_req = col_c.traceroutes[0]
|
||||
assert c_req["from"] == src_a, "request source should be A"
|
||||
finally:
|
||||
unsubscribe_all("meshtastic.receive.traceroute")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"""Meshtastic unit tests for tcp_interface.py"""
|
||||
|
||||
import re
|
||||
import socket
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -78,6 +79,56 @@ def test_TCPInterface_close_shutdowns_socket_before_super_close():
|
||||
assert iface.socket is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_TCPInterface_close_half_closes_before_shutdown():
|
||||
"""Close should send FIN and let the device drain before forcing the link down.
|
||||
|
||||
Going straight to shutdown(SHUT_RDWR)/close() with data still unread makes the
|
||||
stack send RST, and Winsock then discards data the device had received but not
|
||||
yet read, silently losing writes such as `--set`.
|
||||
"""
|
||||
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
||||
sock = MagicMock()
|
||||
iface.socket = sock
|
||||
call_order = []
|
||||
|
||||
with patch.object(TCPInterface, "_socket_shutdown", autospec=True) as mock_shutdown:
|
||||
with patch.object(
|
||||
TCPInterface, "_wait_for_reader_exit", autospec=True
|
||||
) as mock_wait:
|
||||
with patch(
|
||||
"meshtastic.stream_interface.StreamInterface.close", autospec=True
|
||||
) as mock_super_close:
|
||||
sock.shutdown.side_effect = lambda how: call_order.append(f"shutdown_{how}")
|
||||
mock_wait.side_effect = lambda _self, _t: call_order.append("wait")
|
||||
mock_shutdown.side_effect = lambda _self: call_order.append("full_shutdown")
|
||||
mock_super_close.side_effect = lambda _self: call_order.append("super_close")
|
||||
|
||||
iface.close()
|
||||
|
||||
assert call_order == [
|
||||
f"shutdown_{socket.SHUT_WR}",
|
||||
"wait",
|
||||
"full_shutdown",
|
||||
"super_close",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_TCPInterface_close_survives_half_close_failure():
|
||||
"""A peer that already vanished must not break close()."""
|
||||
iface = TCPInterface(hostname="localhost", noProto=True, connectNow=False)
|
||||
sock = MagicMock()
|
||||
sock.shutdown.side_effect = OSError("already gone")
|
||||
iface.socket = sock
|
||||
|
||||
with patch("meshtastic.stream_interface.StreamInterface.close", autospec=True):
|
||||
iface.close() # must not raise
|
||||
|
||||
sock.close.assert_called_once()
|
||||
assert iface.socket is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_TCPInterface_reconnect():
|
||||
"""Test that _reconnect correctly reconnects"""
|
||||
|
||||
@@ -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,
|
||||
@@ -37,6 +41,7 @@ from meshtastic.util import (
|
||||
stripnl,
|
||||
support_info,
|
||||
message_to_json,
|
||||
to_node_num,
|
||||
Acknowledgment
|
||||
)
|
||||
|
||||
@@ -234,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
|
||||
@@ -715,3 +811,162 @@ def test_generate_channel_hash_fuzz_aes256(channel_name, key_bytes):
|
||||
"Test generate_channel_hash with fuzzed channel names and 256-bit keys, ensuring it produces single-byte values"
|
||||
hashed = generate_channel_hash(channel_name, key_bytes)
|
||||
assert 0 <= hashed <= 0xFF
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("input_val,expected", [
|
||||
# int passthrough
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(6, 6),
|
||||
(502009325, 502009325),
|
||||
(2198819370, 2198819370),
|
||||
(0xFFFFFFFF, 0xFFFFFFFF),
|
||||
# !hex format (always treated as hex)
|
||||
("!00000000", 0x00000000),
|
||||
("!00000001", 0x00000001),
|
||||
("!00000010", 0x00000010),
|
||||
("!000000ff", 0x000000FF),
|
||||
("!830f522a", 0x830F522A),
|
||||
("!1dec0ded", 0x1DEC0DED),
|
||||
("!ffffffff", 0xFFFFFFFF),
|
||||
("!FFFFFFFF", 0xFFFFFFFF),
|
||||
# 0xhex format
|
||||
("0x00000000", 0x00000000),
|
||||
("0x00000010", 0x00000010),
|
||||
("0x830f522a", 0x830F522A),
|
||||
("0x1dec0ded", 0x1DEC0DED),
|
||||
("0xFFFFFFFF", 0xFFFFFFFF),
|
||||
# Unprefixed hex string (falls back to hex when decimal fails)
|
||||
("830f522a", 0x830F522A),
|
||||
("1dec0ded", 0x1DEC0DED),
|
||||
# Decimal string
|
||||
("42", 42),
|
||||
("12345678", 12345678),
|
||||
("0", 0),
|
||||
("1", 1),
|
||||
# With whitespace
|
||||
(" !830f522a ", 2198819370),
|
||||
(" !00000010 ", 16),
|
||||
(" 0x830f522a ", 2198819370),
|
||||
])
|
||||
def test_to_node_num(input_val, expected):
|
||||
"""Test to_node_num with various valid inputs"""
|
||||
assert to_node_num(input_val) == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("input_val", [
|
||||
"",
|
||||
"!",
|
||||
"!!",
|
||||
"!0x10",
|
||||
"!xyz",
|
||||
])
|
||||
def test_to_node_num_invalid(input_val):
|
||||
"""Test to_node_num raises ValueError for invalid inputs"""
|
||||
with pytest.raises(ValueError):
|
||||
to_node_num(input_val)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@given(st.integers(min_value=0, max_value=2**32 - 1))
|
||||
def test_to_node_num_hypothesis_roundtrip(n):
|
||||
"""Property: all supported input formats roundtrip for any valid node number"""
|
||||
assert to_node_num(n) == 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
|
||||
|
||||
@@ -728,7 +728,7 @@ def to_node_num(node_id: Union[int, str]) -> int:
|
||||
return node_id
|
||||
s = str(node_id).strip()
|
||||
if s.startswith("!"):
|
||||
s = s[1:]
|
||||
s = "0x" + s[1:]
|
||||
if s.lower().startswith("0x"):
|
||||
return int(s, 16)
|
||||
try:
|
||||
@@ -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
|
||||
|
||||
162
poetry.lock
generated
162
poetry.lock
generated
@@ -608,7 +608,7 @@ files = [
|
||||
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
||||
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
||||
]
|
||||
markers = {main = "extra == \"analysis\" and python_version >= \"3.11\"", powermon = "python_version >= \"3.11\""}
|
||||
markers = {main = "python_version >= \"3.11\" and extra == \"analysis\"", powermon = "python_version >= \"3.11\""}
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
@@ -3284,7 +3284,7 @@ version = "2.0.2"
|
||||
description = "Fundamental package for array computing in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "analysis", "powermon"]
|
||||
groups = ["main", "analysis"]
|
||||
files = [
|
||||
{file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"},
|
||||
{file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"},
|
||||
@@ -3332,7 +3332,7 @@ files = [
|
||||
{file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"},
|
||||
{file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"},
|
||||
]
|
||||
markers = {main = "python_version < \"3.11\" and extra == \"analysis\"", analysis = "python_version < \"3.11\"", powermon = "python_version < \"3.11\""}
|
||||
markers = {main = "python_version < \"3.11\" and extra == \"analysis\"", analysis = "python_version < \"3.11\""}
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -3340,7 +3340,7 @@ version = "2.3.4"
|
||||
description = "Fundamental package for array computing in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main", "analysis", "powermon"]
|
||||
groups = ["main", "analysis"]
|
||||
files = [
|
||||
{file = "numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb"},
|
||||
{file = "numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f"},
|
||||
@@ -3417,7 +3417,7 @@ files = [
|
||||
{file = "numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f"},
|
||||
{file = "numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a"},
|
||||
]
|
||||
markers = {main = "extra == \"analysis\" and python_version >= \"3.11\"", analysis = "python_version >= \"3.11\"", powermon = "python_version >= \"3.11\""}
|
||||
markers = {main = "python_version >= \"3.11\" and extra == \"analysis\"", analysis = "python_version >= \"3.11\""}
|
||||
|
||||
[[package]]
|
||||
name = "overrides"
|
||||
@@ -3569,7 +3569,7 @@ description = "Type annotations for pandas"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"analysis\" and python_version >= \"3.11\""
|
||||
markers = "python_version >= \"3.11\" and extra == \"analysis\""
|
||||
files = [
|
||||
{file = "pandas_stubs-2.3.2.250926-py3-none-any.whl", hash = "sha256:81121818453dcfe00f45c852f4dceee043640b813830f6e7bd084a4ef7ff7270"},
|
||||
{file = "pandas_stubs-2.3.2.250926.tar.gz", hash = "sha256:c64b9932760ceefb96a3222b953e6a251321a9832a28548be6506df473a66406"},
|
||||
@@ -4150,52 +4150,120 @@ tests = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "16.1.0"
|
||||
version = "18.1.0"
|
||||
description = "Python library for Apache Arrow"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["powermon"]
|
||||
markers = "python_version < \"3.14\""
|
||||
files = [
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:17e23b9a65a70cc733d8b738baa6ad3722298fa0c81d88f63ff94bf25eaa77b9"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4740cc41e2ba5d641071d0ab5e9ef9b5e6e8c7611351a5cb7c1d175eaf43674a"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98100e0268d04e0eec47b73f20b39c45b4006f3c4233719c3848aa27a03c1aef"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f68f409e7b283c085f2da014f9ef81e885d90dcd733bd648cfba3ef265961848"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a8914cd176f448e09746037b0c6b3a9d7688cef451ec5735094055116857580c"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:48be160782c0556156d91adbdd5a4a7e719f8d407cb46ae3bb4eaee09b3111bd"},
|
||||
{file = "pyarrow-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cf389d444b0f41d9fe1444b70650fea31e9d52cfcb5f818b7888b91b586efff"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:d0ebea336b535b37eee9eee31761813086d33ed06de9ab6fc6aaa0bace7b250c"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e73cfc4a99e796727919c5541c65bb88b973377501e39b9842ea71401ca6c1c"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf9251264247ecfe93e5f5a0cd43b8ae834f1e61d1abca22da55b20c788417f6"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddf5aace92d520d3d2a20031d8b0ec27b4395cab9f74e07cc95edf42a5cc0147"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:25233642583bf658f629eb230b9bb79d9af4d9f9229890b3c878699c82f7d11e"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a33a64576fddfbec0a44112eaf844c20853647ca833e9a647bfae0582b2ff94b"},
|
||||
{file = "pyarrow-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:185d121b50836379fe012753cf15c4ba9638bda9645183ab36246923875f8d1b"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2e51ca1d6ed7f2e9d5c3c83decf27b0d17bb207a7dea986e8dc3e24f80ff7d6f"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06ebccb6f8cb7357de85f60d5da50e83507954af617d7b05f48af1621d331c9a"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b04707f1979815f5e49824ce52d1dceb46e2f12909a48a6a753fe7cafbc44a0c"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d32000693deff8dc5df444b032b5985a48592c0697cb6e3071a5d59888714e2"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8785bb10d5d6fd5e15d718ee1d1f914fe768bf8b4d1e5e9bf253de8a26cb1628"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e1369af39587b794873b8a307cc6623a3b1194e69399af0efd05bb202195a5a7"},
|
||||
{file = "pyarrow-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:febde33305f1498f6df85e8020bca496d0e9ebf2093bab9e0f65e2b4ae2b3444"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b5f5705ab977947a43ac83b52ade3b881eb6e95fcc02d76f501d549a210ba77f"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0d27bf89dfc2576f6206e9cd6cf7a107c9c06dc13d53bbc25b0bd4556f19cf5f"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d07de3ee730647a600037bc1d7b7994067ed64d0eba797ac74b2bc77384f4c2"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbef391b63f708e103df99fbaa3acf9f671d77a183a07546ba2f2c297b361e83"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19741c4dbbbc986d38856ee7ddfdd6a00fc3b0fc2d928795b95410d38bb97d15"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:f2c5fb249caa17b94e2b9278b36a05ce03d3180e6da0c4c3b3ce5b2788f30eed"},
|
||||
{file = "pyarrow-16.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:e6b6d3cd35fbb93b70ade1336022cc1147b95ec6af7d36906ca7fe432eb09710"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:18da9b76a36a954665ccca8aa6bd9f46c1145f79c0bb8f4f244f5f8e799bca55"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:99f7549779b6e434467d2aa43ab2b7224dd9e41bdde486020bae198978c9e05e"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f07fdffe4fd5b15f5ec15c8b64584868d063bc22b86b46c9695624ca3505b7b4"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddfe389a08ea374972bd4065d5f25d14e36b43ebc22fc75f7b951f24378bf0b5"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b20bd67c94b3a2ea0a749d2a5712fc845a69cb5d52e78e6449bbd295611f3aa"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ba8ac20693c0bb0bf4b238751d4409e62852004a8cf031c73b0e0962b03e45e3"},
|
||||
{file = "pyarrow-16.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:31a1851751433d89a986616015841977e0a188662fcffd1a5677453f1df2de0a"},
|
||||
{file = "pyarrow-16.1.0.tar.gz", hash = "sha256:15fbb22ea96d11f0b5768504a3f961edab25eaf4197c341720c4a387f6c60315"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e21488d5cfd3d8b500b3238a6c4b075efabc18f0f6d80b29239737ebd69caa6c"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:b516dad76f258a702f7ca0250885fc93d1fa5ac13ad51258e39d402bd9e2e1e4"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f443122c8e31f4c9199cb23dca29ab9427cef990f283f80fe15b8e124bcc49b"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a03da7f2758645d17b7b4f83c8bffeae5bbb7f974523fe901f36288d2eab71"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ba17845efe3aa358ec266cf9cc2800fa73038211fb27968bfa88acd09261a470"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3c35813c11a059056a22a3bef520461310f2f7eea5c8a11ef9de7062a23f8d56"},
|
||||
{file = "pyarrow-18.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9736ba3c85129d72aefa21b4f3bd715bc4190fe4426715abfff90481e7d00812"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eaeabf638408de2772ce3d7793b2668d4bb93807deed1725413b70e3156a7854"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:3b2e2239339c538f3464308fd345113f886ad031ef8266c6f004d49769bb074c"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39a2e0ed32a0970e4e46c262753417a60c43a3246972cfc2d3eb85aedd01b21"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31e9417ba9c42627574bdbfeada7217ad8a4cbbe45b9d6bdd4b62abbca4c6f6"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:01c034b576ce0eef554f7c3d8c341714954be9b3f5d5bc7117006b85fcf302fe"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f266a2c0fc31995a06ebd30bcfdb7f615d7278035ec5b1cd71c48d56daaf30b0"},
|
||||
{file = "pyarrow-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d4f13eee18433f99adefaeb7e01d83b59f73360c231d4782d9ddfaf1c3fbde0a"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30"},
|
||||
{file = "pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9"},
|
||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:0b331e477e40f07238adc7ba7469c36b908f07c89b95dd4bd3a0ec84a3d1e21e"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2c4dd0c9010a25ba03e198fe743b1cc03cd33c08190afff371749c52ccbbaf76"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f97b31b4c4e21ff58c6f330235ff893cc81e23da081b1a4b1c982075e0ed4e9"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a4813cb8ecf1809871fd2d64a8eff740a1bd3691bbe55f01a3cf6c5ec869754"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:05a5636ec3eb5cc2a36c6edb534a38ef57b2ab127292a716d00eabb887835f1e"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:73eeed32e724ea3568bb06161cad5fa7751e45bc2228e33dcb10c614044165c7"},
|
||||
{file = "pyarrow-18.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:a1880dd6772b685e803011a6b43a230c23b566859a6e0c9a276c1e0faf4f4052"},
|
||||
{file = "pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
numpy = ">=1.16.6"
|
||||
[package.extras]
|
||||
test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "22.0.0"
|
||||
description = "Python library for Apache Arrow"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["powermon"]
|
||||
markers = "python_version == \"3.14\""
|
||||
files = [
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:77718810bd3066158db1e95a63c160ad7ce08c6b0710bc656055033e39cdad88"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:44d2d26cda26d18f7af7db71453b7b783788322d756e81730acb98f24eb90ace"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b9d71701ce97c95480fecb0039ec5bb889e75f110da72005743451339262f4ce"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:710624ab925dc2b05a6229d47f6f0dac1c1155e6ed559be7109f684eba048a48"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f963ba8c3b0199f9d6b794c90ec77545e05eadc83973897a4523c9e8d84e9340"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd0d42297ace400d8febe55f13fdf46e86754842b860c978dfec16f081e5c653"},
|
||||
{file = "pyarrow-22.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:00626d9dc0f5ef3a75fe63fd68b9c7c8302d2b5bbc7f74ecaedba83447a24f84"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3e294c5eadfb93d78b0763e859a0c16d4051fc1c5231ae8956d61cb0b5666f5a"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:69763ab2445f632d90b504a815a2a033f74332997052b721002298ed6de40f2e"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b41f37cabfe2463232684de44bad753d6be08a7a072f6a83447eeaf0e4d2a215"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:35ad0f0378c9359b3f297299c3309778bb03b8612f987399a0333a560b43862d"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8382ad21458075c2e66a82a29d650f963ce51c7708c7c0ff313a8c206c4fd5e8"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a812a5b727bc09c3d7ea072c4eebf657c2f7066155506ba31ebf4792f88f016"},
|
||||
{file = "pyarrow-22.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ec5d40dd494882704fb876c16fa7261a69791e784ae34e6b5992e977bd2e238c"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:bea79263d55c24a32b0d79c00a1c58bb2ee5f0757ed95656b01c0fb310c5af3d"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:12fe549c9b10ac98c91cf791d2945e878875d95508e1a5d14091a7aaa66d9cf8"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:334f900ff08ce0423407af97e6c26ad5d4e3b0763645559ece6fbf3747d6a8f5"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c6c791b09c57ed76a18b03f2631753a4960eefbbca80f846da8baefc6491fcfe"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c3200cb41cdbc65156e5f8c908d739b0dfed57e890329413da2748d1a2cd1a4e"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ac93252226cf288753d8b46280f4edf3433bf9508b6977f8dd8526b521a1bbb9"},
|
||||
{file = "pyarrow-22.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:44729980b6c50a5f2bfcc2668d36c569ce17f8b17bccaf470c4313dcbbf13c9d"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6e95176209257803a8b3d0394f21604e796dadb643d2f7ca21b66c9c0b30c9a"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:001ea83a58024818826a9e3f89bf9310a114f7e26dfe404a4c32686f97bd7901"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ce20fe000754f477c8a9125543f1936ea5b8867c5406757c224d745ed033e691"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e0a15757fccb38c410947df156f9749ae4a3c89b2393741a50521f39a8cf202a"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cedb9dd9358e4ea1d9bce3665ce0797f6adf97ff142c8e25b46ba9cdd508e9b6"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:252be4a05f9d9185bb8c18e83764ebcfea7185076c07a7a662253af3a8c07941"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:a4893d31e5ef780b6edcaf63122df0f8d321088bb0dee4c8c06eccb1ca28d145"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:f7fe3dbe871294ba70d789be16b6e7e52b418311e166e0e3cba9522f0f437fb1"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ba95112d15fd4f1105fb2402c4eab9068f0554435e9b7085924bcfaac2cc306f"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c064e28361c05d72eed8e744c9605cbd6d2bb7481a511c74071fd9b24bc65d7d"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6f9762274496c244d951c819348afbcf212714902742225f649cf02823a6a10f"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a9d9ffdc2ab696f6b15b4d1f7cec6658e1d788124418cb30030afbae31c64746"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ec1a15968a9d80da01e1d30349b2b0d7cc91e96588ee324ce1b5228175043e95"},
|
||||
{file = "pyarrow-22.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bba208d9c7decf9961998edf5c65e3ea4355d5818dd6cd0f6809bec1afb951cc"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9bddc2cade6561f6820d4cd73f99a0243532ad506bc510a75a5a65a522b2d74d"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e70ff90c64419709d38c8932ea9fe1cc98415c4f87ea8da81719e43f02534bc9"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:92843c305330aa94a36e706c16209cd4df274693e777ca47112617db7d0ef3d7"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6dda1ddac033d27421c20d7a7943eec60be44e0db4e079f33cc5af3b8280ccde"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:84378110dd9a6c06323b41b56e129c504d157d1a983ce8f5443761eb5256bafc"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:854794239111d2b88b40b6ef92aa478024d1e5074f364033e73e21e3f76b25e0"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:b883fe6fd85adad7932b3271c38ac289c65b7337c2c132e9569f9d3940620730"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a820d8ae11facf32585507c11f04e3f38343c1e784c9b5a8b1da5c930547fe2"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:c6ec3675d98915bf1ec8b3c7986422682f7232ea76cad276f4c8abd5b7319b70"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3e739edd001b04f654b166204fc7a9de896cf6007eaff33409ee9e50ceaff754"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7388ac685cab5b279a41dfe0a6ccd99e4dbf322edfb63e02fc0443bf24134e91"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f633074f36dbc33d5c05b5dc75371e5660f1dbf9c8b1d95669def05e5425989c"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4c19236ae2402a8663a2c8f21f1870a03cc57f0bef7e4b6eb3238cc82944de80"},
|
||||
{file = "pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae"},
|
||||
{file = "pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow-stubs"
|
||||
@@ -6117,4 +6185,4 @@ tunnel = ["pytap2"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.9,<3.15"
|
||||
content-hash = "674308d6eb7c3730031cc3e73c98b2413c7f59002a9317bfad387bc34a17c64d"
|
||||
content-hash = "7dd0dc28e13f7569f1a8cc28453d5983a6f742790d9328bafe86d1a2914d88ad"
|
||||
|
||||
Submodule protobufs updated: dd6c3f850a...da60cee584
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "meshtastic"
|
||||
version = "2.7.9"
|
||||
version = "2.7.11"
|
||||
description = "Python API & client shell for talking to Meshtastic devices"
|
||||
authors = ["Meshtastic Developers <contact@meshtastic.org>"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -51,7 +51,10 @@ optional = true
|
||||
riden = { git = "https://github.com/geeksville/riden.git#1.2.1" }
|
||||
ppk2-api = "^0.9.2"
|
||||
parse = "^1.20.2"
|
||||
pyarrow = "^16.1.0"
|
||||
pyarrow = [
|
||||
{ version = "^18.0.0", python = "<3.14" },
|
||||
{ version = "^22.0.0", python = ">=3.14" }
|
||||
]
|
||||
platformdirs = "^4.2.2"
|
||||
|
||||
# If you are doing power analysis you might want these extra devtools
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[pytest]
|
||||
|
||||
addopts = -m "not int and not smoke1 and not smoke2 and not smokewifi and not examples and not smokevirt"
|
||||
addopts = -m "not int and not smoke1 and not smoke2 and not smokewifi and not examples and not smokevirt and not smokemesh"
|
||||
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
@@ -13,4 +13,5 @@ markers =
|
||||
smoke1: runs smoke tests on a single device connected via USB
|
||||
smoke2: runs smoke tests on a two devices connected via USB
|
||||
smokewifi: runs smoke test on an esp32 device setup with wifi
|
||||
smokemesh: runs smoke tests against multiple meshtasticd sim instances
|
||||
examples: runs the examples tests which validates the library
|
||||
|
||||
Reference in New Issue
Block a user