mirror of
https://github.com/meshtastic/python.git
synced 2026-08-01 01:26:55 -04:00
Compare commits
1 Commits
master
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f2ce3dfc2 |
@@ -1,16 +0,0 @@
|
|||||||
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,7 +1,4 @@
|
|||||||
name: CI
|
name: CI
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -11,29 +8,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
needs: validate
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -42,14 +17,10 @@ jobs:
|
|||||||
- "3.10"
|
- "3.10"
|
||||||
- "3.11"
|
- "3.11"
|
||||||
- "3.12"
|
- "3.12"
|
||||||
- "3.13"
|
|
||||||
- "3.14"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install Python ${{ matrix.python-version }}
|
- name: Install Python 3
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
- name: Uninstall meshtastic
|
- name: Uninstall meshtastic
|
||||||
run: |
|
run: |
|
||||||
pip3 uninstall -y meshtastic
|
pip3 uninstall -y meshtastic
|
||||||
@@ -61,8 +32,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
poetry install --all-extras --with dev,powermon
|
poetry install --all-extras --with dev,powermon
|
||||||
poetry run meshtastic --version
|
poetry run meshtastic --version
|
||||||
- name: Run lint, type check, and tests in parallel
|
- name: Run pylint
|
||||||
run: make -j3 --output-sync=target ci
|
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: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -73,37 +51,22 @@ jobs:
|
|||||||
name: codecov-umbrella
|
name: codecov-umbrella
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: true
|
||||||
verbose: true
|
verbose: true
|
||||||
|
validate:
|
||||||
simradio_testing:
|
|
||||||
needs: [validate, build]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
channel:
|
python-version:
|
||||||
- beta
|
- "3.9"
|
||||||
- alpha
|
- "3.10"
|
||||||
- daily
|
- "3.11"
|
||||||
continue-on-error: ${{ matrix.channel == 'daily' }}
|
- "3.12"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Python 3
|
- name: Install Python 3
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install meshtastic from local
|
- name: Install meshtastic from local
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip3 install poetry
|
pip3 install poetry
|
||||||
poetry install --all-extras --with dev
|
poetry install
|
||||||
poetry run meshtastic --version
|
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
36
.github/workflows/daily_simradio.yml
vendored
@@ -1,36 +0,0 @@
|
|||||||
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,19 +42,5 @@ cov:
|
|||||||
examples: FORCE
|
examples: FORCE
|
||||||
pytest -mexamples
|
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
|
# Makefile hack to get the examples to always run
|
||||||
FORCE: ;
|
FORCE: ;
|
||||||
|
|||||||
@@ -5,11 +5,9 @@
|
|||||||
# later we can have a separate changelist to refactor main.py into smaller files
|
# later we can have a separate changelist to refactor main.py into smaller files
|
||||||
# pylint: disable=R0917,C0302
|
# pylint: disable=R0917,C0302
|
||||||
|
|
||||||
from typing import Any, Callable, List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
argcomplete: Union[None, ModuleType] = None
|
argcomplete: Union[None, ModuleType] = None
|
||||||
@@ -63,19 +61,11 @@ except ImportError as e:
|
|||||||
have_powermon = False
|
have_powermon = False
|
||||||
powermon_exception = e
|
powermon_exception = e
|
||||||
meter = None
|
meter = None
|
||||||
from meshtastic.protobuf import admin_pb2, channel_pb2, clientonly_pb2, config_pb2, portnums_pb2, mesh_pb2
|
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, portnums_pb2, mesh_pb2
|
||||||
from meshtastic.version import get_active_version
|
from meshtastic.version import get_active_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 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:
|
def onReceive(packet, interface) -> None:
|
||||||
"""Callback invoked when a packet arrives"""
|
"""Callback invoked when a packet arrives"""
|
||||||
args = mt_config.args
|
args = mt_config.args
|
||||||
@@ -248,28 +238,13 @@ def setPref(config, comp_name, raw_val) -> bool:
|
|||||||
print("Warning: network.wifi_psk must be 8 or more characters.")
|
print("Warning: network.wifi_psk must be 8 or more characters.")
|
||||||
return False
|
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
|
enumType = pref.enum_type
|
||||||
# pylint: disable=C0123
|
# pylint: disable=C0123
|
||||||
if enumType and type(val) == str:
|
if enumType and type(val) == str:
|
||||||
# We've failed so far to convert this string into an enum, try to find it by reflection
|
# We've failed so far to convert this string into an enum, try to find it by reflection
|
||||||
ev = enumType.values_by_name.get(val)
|
e = enumType.values_by_name.get(val)
|
||||||
if ev:
|
if e:
|
||||||
val = ev.number
|
val = e.number
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
|
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
|
||||||
@@ -744,105 +719,115 @@ def onConnected(interface):
|
|||||||
printConfig(node.moduleConfig)
|
printConfig(node.moduleConfig)
|
||||||
|
|
||||||
if args.configure:
|
if args.configure:
|
||||||
if args.dest != BROADCAST_ADDR:
|
with open(args.configure[0], encoding="utf8") as file:
|
||||||
print("Configuring remote nodes is not supported.")
|
configuration = yaml.safe_load(file)
|
||||||
return
|
closeNow = True
|
||||||
|
|
||||||
filename = args.configure[0]
|
interface.getNode(args.dest, False, **getNode_kwargs).beginSettingsTransaction()
|
||||||
fmt = getattr(args, "export_format", "auto")
|
|
||||||
|
|
||||||
def _seed_config(section: str, is_module: bool) -> Optional[Any]:
|
if "owner" in configuration:
|
||||||
"""Return the current local-node config section, if present."""
|
# Validate owner name before setting
|
||||||
config_obj = (
|
owner_name = str(configuration["owner"]).strip()
|
||||||
interface.localNode.moduleConfig
|
if not owner_name:
|
||||||
if is_module
|
meshtastic.util.our_exit("ERROR: Long Name cannot be empty or contain only whitespace characters")
|
||||||
else interface.localNode.localConfig
|
print(f"Setting device owner to {configuration['owner']}")
|
||||||
)
|
waitForAckNak = True
|
||||||
return getattr(config_obj, section) if config_obj.HasField(section) else None
|
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(configuration["owner"])
|
||||||
|
|
||||||
profile = _read_profile(filename, fmt, seed_fn=_seed_config)
|
|
||||||
|
|
||||||
closeNow = True
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).beginSettingsTransaction()
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
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")
|
|
||||||
|
|
||||||
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=long_name, short_name=short_name
|
|
||||||
)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
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 profile.canned_messages:
|
|
||||||
print(f"Setting canned message messages to {profile.canned_messages}")
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_canned_message(profile.canned_messages)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if profile.ringtone:
|
|
||||||
print(f"Setting ringtone to {profile.ringtone}")
|
|
||||||
interface.getNode(args.dest, **getNode_kwargs).set_ringtone(profile.ringtone)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
if profile.HasField("fixed_position"):
|
|
||||||
# Only send the admin message when the position config
|
|
||||||
# explicitly opts into fixed_position. The admin message
|
|
||||||
# unconditionally enables the flag on the device, so we
|
|
||||||
# require an explicit opt-in from the profile's config.
|
|
||||||
if (
|
|
||||||
profile.HasField("config")
|
|
||||||
and profile.config.HasField("position")
|
|
||||||
and profile.config.position.fixed_position
|
|
||||||
):
|
|
||||||
pos = profile.fixed_position
|
|
||||||
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")
|
|
||||||
print(f"Fixing latitude at {lat} degrees")
|
|
||||||
print(f"Fixing longitude at {lon} degrees")
|
|
||||||
print("Setting device position")
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).setFixedPosition(lat, lon, alt)
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if profile.HasField("config"):
|
if "owner_short" in configuration:
|
||||||
localConfig = interface.getNode(args.dest, **getNode_kwargs).localConfig
|
# Validate owner short name before setting
|
||||||
for field in profile.config.DESCRIPTOR.fields:
|
owner_short_name = str(configuration["owner_short"]).strip()
|
||||||
if field.message_type is not None and profile.config.HasField(field.name):
|
if not owner_short_name:
|
||||||
getattr(localConfig, field.name).CopyFrom(getattr(profile.config, field.name))
|
meshtastic.util.our_exit("ERROR: Short Name cannot be empty or contain only whitespace characters")
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
print(
|
||||||
|
f"Setting device owner short to {configuration['owner_short']}"
|
||||||
|
)
|
||||||
|
waitForAckNak = True
|
||||||
|
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
||||||
|
long_name=None, short_name=configuration["owner_short"]
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "ownerShort" in configuration:
|
||||||
|
# Validate owner short name before setting
|
||||||
|
owner_short_name = str(configuration["ownerShort"]).strip()
|
||||||
|
if not owner_short_name:
|
||||||
|
meshtastic.util.our_exit("ERROR: Short Name cannot be empty or contain only whitespace characters")
|
||||||
|
print(
|
||||||
|
f"Setting device owner short to {configuration['ownerShort']}"
|
||||||
|
)
|
||||||
|
waitForAckNak = True
|
||||||
|
interface.getNode(args.dest, False, **getNode_kwargs).setOwner(
|
||||||
|
long_name=None, short_name=configuration["ownerShort"]
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "channel_url" in configuration:
|
||||||
|
print("Setting channel url to", configuration["channel_url"])
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).setURL(configuration["channel_url"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "channelUrl" in configuration:
|
||||||
|
print("Setting channel url to", configuration["channelUrl"])
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).setURL(configuration["channelUrl"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "canned_messages" in configuration:
|
||||||
|
print("Setting canned message messages to", configuration["canned_messages"])
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).set_canned_message(configuration["canned_messages"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "ringtone" in configuration:
|
||||||
|
print("Setting ringtone to", configuration["ringtone"])
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).set_ringtone(configuration["ringtone"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "location" in configuration:
|
||||||
|
alt = 0
|
||||||
|
lat = 0.0
|
||||||
|
lon = 0.0
|
||||||
|
localConfig = interface.localNode.localConfig
|
||||||
|
|
||||||
|
if "alt" in configuration["location"]:
|
||||||
|
alt = int(configuration["location"]["alt"] or 0)
|
||||||
|
print(f"Fixing altitude at {alt} meters")
|
||||||
|
if "lat" in configuration["location"]:
|
||||||
|
lat = float(configuration["location"]["lat"] or 0)
|
||||||
|
print(f"Fixing latitude at {lat} degrees")
|
||||||
|
if "lon" in configuration["location"]:
|
||||||
|
lon = float(configuration["location"]["lon"] or 0)
|
||||||
|
print(f"Fixing longitude at {lon} degrees")
|
||||||
|
print("Setting device position")
|
||||||
|
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if "config" in configuration:
|
||||||
|
localConfig = interface.getNode(args.dest, **getNode_kwargs).localConfig
|
||||||
|
for section in configuration["config"]:
|
||||||
|
traverseConfig(
|
||||||
|
section, configuration["config"][section], localConfig
|
||||||
|
)
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).writeConfig(
|
||||||
|
meshtastic.util.camel_to_snake(section)
|
||||||
|
)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if profile.HasField("module_config"):
|
if "module_config" in configuration:
|
||||||
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
moduleConfig = interface.getNode(args.dest, **getNode_kwargs).moduleConfig
|
||||||
for field in profile.module_config.DESCRIPTOR.fields:
|
for section in configuration["module_config"]:
|
||||||
if field.message_type is not None and profile.module_config.HasField(field.name):
|
traverseConfig(
|
||||||
getattr(moduleConfig, field.name).CopyFrom(getattr(profile.module_config, field.name))
|
section,
|
||||||
interface.getNode(args.dest, **getNode_kwargs).writeConfig(field.name)
|
configuration["module_config"][section],
|
||||||
|
moduleConfig,
|
||||||
|
)
|
||||||
|
interface.getNode(args.dest, **getNode_kwargs).writeConfig(
|
||||||
|
meshtastic.util.camel_to_snake(section)
|
||||||
|
)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
interface.getNode(args.dest, False, **getNode_kwargs).commitSettingsTransaction()
|
interface.getNode(args.dest, False, **getNode_kwargs).commitSettingsTransaction()
|
||||||
print("Writing modified configuration to device")
|
print("Writing modified configuration to device")
|
||||||
|
|
||||||
if args.export_config:
|
if args.export_config:
|
||||||
if args.dest != BROADCAST_ADDR:
|
if args.dest != BROADCAST_ADDR:
|
||||||
@@ -850,40 +835,18 @@ def onConnected(interface):
|
|||||||
return
|
return
|
||||||
|
|
||||||
closeNow = True
|
closeNow = True
|
||||||
|
config_txt = export_config(interface)
|
||||||
|
|
||||||
is_binary = False
|
if args.export_config == "-":
|
||||||
fmt = getattr(args, "export_format", "auto")
|
# Output to stdout (preserves legacy use of `> file.yaml`)
|
||||||
if fmt in ("binary", "protobuf"):
|
print(config_txt)
|
||||||
is_binary = True
|
|
||||||
elif fmt == "yaml":
|
|
||||||
is_binary = False
|
|
||||||
else:
|
else:
|
||||||
is_binary = args.export_config.endswith(".cfg")
|
try:
|
||||||
|
with open(args.export_config, "w", encoding="utf-8") as f:
|
||||||
if is_binary:
|
f.write(config_txt)
|
||||||
config_bytes = export_profile(interface)
|
print(f"Exported configuration to {args.export_config}")
|
||||||
if args.export_config == "-":
|
except Exception as e:
|
||||||
sys.stdout.buffer.write(config_bytes)
|
meshtastic.util.our_exit(f"ERROR: Failed to write config file: {e}")
|
||||||
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 == "-":
|
|
||||||
# Output to stdout (preserves legacy use of `> file.yaml`)
|
|
||||||
print(config_txt)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
with open(args.export_config, "w", encoding="utf-8") as f:
|
|
||||||
f.write(config_txt)
|
|
||||||
print(f"Exported configuration to {args.export_config}")
|
|
||||||
except Exception as e:
|
|
||||||
meshtastic.util.our_exit(f"ERROR: Failed to write config file: {e}")
|
|
||||||
|
|
||||||
if args.ch_set_url:
|
if args.ch_set_url:
|
||||||
closeNow = True
|
closeNow = True
|
||||||
@@ -969,15 +932,9 @@ def onConnected(interface):
|
|||||||
if args.ch_longslow:
|
if args.ch_longslow:
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_SLOW)
|
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_SLOW)
|
||||||
|
|
||||||
if args.ch_longmod:
|
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_MODERATE)
|
|
||||||
|
|
||||||
if args.ch_longfast:
|
if args.ch_longfast:
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST)
|
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST)
|
||||||
|
|
||||||
if args.ch_longturbo:
|
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_TURBO)
|
|
||||||
|
|
||||||
if args.ch_medslow:
|
if args.ch_medslow:
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_SLOW)
|
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_SLOW)
|
||||||
|
|
||||||
@@ -990,9 +947,6 @@ def onConnected(interface):
|
|||||||
if args.ch_shortfast:
|
if args.ch_shortfast:
|
||||||
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.SHORT_FAST)
|
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:
|
if args.ch_set or args.ch_enable or args.ch_disable:
|
||||||
closeNow = True
|
closeNow = True
|
||||||
|
|
||||||
@@ -1286,7 +1240,7 @@ def export_config(interface) -> str:
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
alt = None
|
alt = None
|
||||||
if pos and interface.localNode.localConfig.position.fixed_position:
|
if pos:
|
||||||
lat = pos.get("latitude")
|
lat = pos.get("latitude")
|
||||||
lon = pos.get("longitude")
|
lon = pos.get("longitude")
|
||||||
alt = pos.get("altitude")
|
alt = pos.get("altitude")
|
||||||
@@ -1330,7 +1284,7 @@ def export_config(interface) -> str:
|
|||||||
for i in range(len(prefs[pref]['adminKey'])):
|
for i in range(len(prefs[pref]['adminKey'])):
|
||||||
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
|
||||||
if mt_config.camel_case:
|
if mt_config.camel_case:
|
||||||
configObj["config"] = prefs
|
configObj["config"] = config #Identical command here and 2 lines below?
|
||||||
else:
|
else:
|
||||||
configObj["config"] = config
|
configObj["config"] = config
|
||||||
|
|
||||||
@@ -1355,162 +1309,6 @@ def export_config(interface) -> str:
|
|||||||
config_txt += yaml.dump(configObj)
|
config_txt += yaml.dump(configObj)
|
||||||
return config_txt
|
return config_txt
|
||||||
|
|
||||||
def _set_if_populated(profile, field_name, value):
|
|
||||||
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():
|
def create_power_meter():
|
||||||
"""Setup the power meter."""
|
"""Setup the power meter."""
|
||||||
@@ -1585,11 +1383,6 @@ def common():
|
|||||||
if not stripped_ham_name:
|
if not stripped_ham_name:
|
||||||
meshtastic.util.our_exit("ERROR: Ham radio callsign cannot be empty or contain only whitespace characters")
|
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:
|
if have_powermon:
|
||||||
create_power_meter()
|
create_power_meter()
|
||||||
|
|
||||||
@@ -1823,12 +1616,10 @@ def addConnectionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParse
|
|||||||
"--host",
|
"--host",
|
||||||
"--tcp",
|
"--tcp",
|
||||||
"-t",
|
"-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="?",
|
nargs="?",
|
||||||
default=None,
|
default=None,
|
||||||
const="localhost",
|
const="localhost",
|
||||||
metavar="HOST[:PORT]",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
@@ -1881,9 +1672,7 @@ def addImportExportArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
|
|||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--configure",
|
"--configure",
|
||||||
"--import-config",
|
help="Specify a path to a yaml(.yml) file containing the desired settings for the connected device.",
|
||||||
dest="configure",
|
|
||||||
help="Specify a path to a configuration file to import. Autodetects format (yaml or binary protobuf).",
|
|
||||||
action="append",
|
action="append",
|
||||||
)
|
)
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
@@ -1891,13 +1680,7 @@ def addImportExportArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
|
|||||||
nargs="?",
|
nargs="?",
|
||||||
const="-", # default to "-" if no value provided
|
const="-", # default to "-" if no value provided
|
||||||
metavar="FILE",
|
metavar="FILE",
|
||||||
help="Export device config (to stdout if no file given). Autodetects format by extension if possible."
|
help="Export device config as YAML (to stdout if no file given)"
|
||||||
)
|
|
||||||
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
|
return parser
|
||||||
|
|
||||||
@@ -1969,61 +1752,43 @@ def addConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-vlongslow",
|
"--ch-vlongslow",
|
||||||
help="Change to the VERY_LONG_SLOW modem preset. Deprecated since 2.5 firmware.",
|
help="Change to the very long-range and slow modem preset",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-longslow",
|
"--ch-longslow",
|
||||||
help="Change to the LONG_SLOW modem preset. Deprecated since 2.7 firmware.",
|
help="Change to the long-range and slow modem preset",
|
||||||
action="store_true",
|
|
||||||
)
|
|
||||||
|
|
||||||
group.add_argument(
|
|
||||||
"--ch-longmod", "--ch-longmoderate",
|
|
||||||
help="Change to the LONG_MODERATE modem preset",
|
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-longfast",
|
"--ch-longfast",
|
||||||
help="Change to the LONG_FAST modem preset",
|
help="Change to the long-range and fast modem preset",
|
||||||
action="store_true",
|
|
||||||
)
|
|
||||||
|
|
||||||
group.add_argument(
|
|
||||||
"--ch-longturbo",
|
|
||||||
help="Change to the LONG_TURBO preset",
|
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-medslow",
|
"--ch-medslow",
|
||||||
help="Change to the MEDIUM_SLOW modem preset",
|
help="Change to the med-range and slow modem preset",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-medfast",
|
"--ch-medfast",
|
||||||
help="Change to the MEDIUM_FAST modem preset",
|
help="Change to the med-range and fast modem preset",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-shortslow",
|
"--ch-shortslow",
|
||||||
help="Change to the SHORT_SLOW modem preset",
|
help="Change to the short-range and slow modem preset",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--ch-shortfast",
|
"--ch-shortfast",
|
||||||
help="Change to the SHORT_FAST modem preset",
|
help="Change to the short-range and fast modem preset",
|
||||||
action="store_true",
|
|
||||||
)
|
|
||||||
|
|
||||||
group.add_argument(
|
|
||||||
"--ch-shortturbo",
|
|
||||||
help="Change to the SHORT_TURBO modem preset",
|
|
||||||
action="store_true",
|
action="store_true",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2184,8 +1949,7 @@ def addPositionConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentP
|
|||||||
|
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--pos-fields",
|
"--pos-fields",
|
||||||
help="Deprecated: use '--set position.position_flags FLAG1,FLAG2' instead. "
|
help="Specify fields to send when sending a position. Use no argument for a list of valid values. "
|
||||||
"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 "
|
"Can pass multiple values as a space separated list like "
|
||||||
"this: '--pos-fields ALTITUDE HEADING SPEED'",
|
"this: '--pos-fields ALTITUDE HEADING SPEED'",
|
||||||
nargs="*",
|
nargs="*",
|
||||||
|
|||||||
@@ -287,22 +287,12 @@ class Node:
|
|||||||
# for sending admin channels will also change
|
# for sending admin channels will also change
|
||||||
adminIndex = self.iface.localNode._getAdminChannelIndex()
|
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.channels.pop(channelIndex)
|
||||||
self._fixupChannels() # expand back to 8 channels
|
self._fixupChannels() # expand back to 8 channels
|
||||||
|
|
||||||
index = channelIndex
|
index = channelIndex
|
||||||
for old_ch in old_channels:
|
while index < 8:
|
||||||
if self.channels[index].SerializeToString() != old_ch:
|
self.writeChannel(index, adminIndex=adminIndex)
|
||||||
self.writeChannel(index, adminIndex=adminIndex)
|
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
# if we are updating the local node, we might end up
|
# if we are updating the local node, we might end up
|
||||||
|
|||||||
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,22 +594,6 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
|
|||||||
"""
|
"""
|
||||||
Lilygo T-Echo Card
|
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
|
PRIVATE_HW: _HardwareModel.ValueType # 255
|
||||||
"""
|
"""
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
@@ -1187,22 +1171,6 @@ T_ECHO_CARD: HardwareModel.ValueType # 136
|
|||||||
"""
|
"""
|
||||||
Lilygo T-Echo Card
|
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
|
PRIVATE_HW: HardwareModel.ValueType # 255
|
||||||
"""
|
"""
|
||||||
------------------------------------------------------------------------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ from typing import Optional
|
|||||||
from meshtastic.stream_interface import StreamInterface
|
from meshtastic.stream_interface import StreamInterface
|
||||||
|
|
||||||
DEFAULT_TCP_PORT = 4403
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,18 +80,6 @@ class TCPInterface(StreamInterface):
|
|||||||
server_address = (self.hostname, self.portNumber)
|
server_address = (self.hostname, self.portNumber)
|
||||||
self.socket = socket.create_connection(server_address)
|
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:
|
def close(self) -> None:
|
||||||
"""Close a connection to the device."""
|
"""Close a connection to the device."""
|
||||||
logger.debug("Closing TCP stream")
|
logger.debug("Closing TCP stream")
|
||||||
@@ -104,20 +87,6 @@ class TCPInterface(StreamInterface):
|
|||||||
# Therefore force a shutdown first to unblock reader thread reads.
|
# Therefore force a shutdown first to unblock reader thread reads.
|
||||||
self._wantExit = True
|
self._wantExit = True
|
||||||
if self.socket is not None:
|
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(
|
with contextlib.suppress(
|
||||||
Exception
|
Exception
|
||||||
): # Ignore errors in shutdown, because we might have a race with the server
|
): # Ignore errors in shutdown, because we might have a race with the server
|
||||||
|
|||||||
@@ -8,87 +8,6 @@ import pytest
|
|||||||
from meshtastic import mt_config
|
from meshtastic import mt_config
|
||||||
|
|
||||||
from ..mesh_interface import MeshInterface
|
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
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
"""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,29 +10,22 @@ import tempfile
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import mock_open, MagicMock, patch
|
from unittest.mock import mock_open, MagicMock, patch
|
||||||
|
|
||||||
import yaml
|
|
||||||
import pytest
|
import pytest
|
||||||
import meshtastic.__main__ as mt_main
|
import meshtastic.__main__ as mt_main
|
||||||
|
|
||||||
from meshtastic.__main__ import (
|
from meshtastic.__main__ import (
|
||||||
export_config,
|
export_config,
|
||||||
export_profile,
|
|
||||||
initParser,
|
initParser,
|
||||||
main,
|
main,
|
||||||
onConnection,
|
onConnection,
|
||||||
onNode,
|
onNode,
|
||||||
onReceive,
|
onReceive,
|
||||||
setPref,
|
|
||||||
tunnelMain,
|
tunnelMain,
|
||||||
set_missing_flags_false,
|
set_missing_flags_false,
|
||||||
_profile_from_yaml,
|
|
||||||
)
|
)
|
||||||
from meshtastic import mt_config
|
from meshtastic import mt_config
|
||||||
|
|
||||||
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
|
from ..protobuf.channel_pb2 import Channel # pylint: disable=E0611
|
||||||
from ..protobuf.config_pb2 import Config # pylint: disable=E0611
|
|
||||||
from ..protobuf.clientonly_pb2 import DeviceProfile # pylint: disable=E0611
|
|
||||||
from ..protobuf.localonly_pb2 import LocalConfig, LocalModuleConfig # pylint: disable=E0611
|
|
||||||
|
|
||||||
# from ..ble_interface import BLEInterface
|
# from ..ble_interface import BLEInterface
|
||||||
from ..mesh_interface import MeshInterface
|
from ..mesh_interface import MeshInterface
|
||||||
@@ -876,7 +869,7 @@ def test_main_sendtext_with_invalid_channel_nine(caplog, capsys):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_sendtext_with_dest(mock_findPorts, mock_serial, mocked_open, mock_hupcl, capsys, caplog, iface_with_nodes):
|
def test_main_sendtext_with_dest(mock_findPorts, mock_serial, mocked_open, mock_hupcl, capsys, caplog, iface_with_nodes):
|
||||||
@@ -1074,7 +1067,7 @@ def test_main_seturl(capsys):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1098,7 +1091,7 @@ def test_main_set_valid(mocked_findports, mocked_serial, mocked_open, mocked_hup
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1122,7 +1115,7 @@ def test_main_set_valid_wifi_psk(mocked_findports, mocked_serial, mocked_open, m
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1149,7 +1142,7 @@ def test_main_set_invalid_wifi_psk(mocked_findports, mocked_serial, mocked_open,
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1174,7 +1167,7 @@ def test_main_set_valid_camel_case(mocked_findports, mocked_serial, mocked_open,
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1199,7 +1192,7 @@ def test_main_set_with_invalid(mocked_findports, mocked_serial, mocked_open, moc
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -1231,7 +1224,7 @@ def test_main_configure_with_snake_case(mocked_findports, mocked_serial, mocked_
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_main_configure_with_camel_case_keys(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
def test_main_configure_with_camel_case_keys(mocked_findports, mocked_serial, mocked_open, mocked_hupcl, capsys):
|
||||||
@@ -2017,471 +2010,6 @@ position_flags: 35"""
|
|||||||
assert err == ""
|
assert err == ""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_export_profile_serializes_deviceprofile():
|
|
||||||
"""export_profile() returns a parseable DeviceProfile protobuf"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = "foo"
|
|
||||||
iface.getShortName.return_value = "oof"
|
|
||||||
iface.localNode.getURL.return_value = "https://meshtastic.org/e/#test"
|
|
||||||
iface.getCannedMessage.return_value = "Hi|Bye"
|
|
||||||
iface.getRingtone.return_value = "24:d=32,o=5"
|
|
||||||
iface.getMyNodeInfo.return_value = {
|
|
||||||
"position": {"latitude": 35.88888, "longitude": -93.88888, "altitude": 304}
|
|
||||||
}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.localConfig.position.fixed_position = True
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
raw = export_profile(iface)
|
|
||||||
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(raw) # must not raise
|
|
||||||
assert profile.long_name == "foo"
|
|
||||||
assert profile.short_name == "oof"
|
|
||||||
assert profile.channel_url == "https://meshtastic.org/e/#test"
|
|
||||||
assert profile.canned_messages == "Hi|Bye"
|
|
||||||
assert profile.ringtone == "24:d=32,o=5"
|
|
||||||
assert profile.HasField("fixed_position")
|
|
||||||
assert profile.fixed_position.latitude_i == 358888800
|
|
||||||
assert profile.fixed_position.longitude_i == -938888800
|
|
||||||
assert profile.fixed_position.altitude == 304
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_export_profile_omits_fixed_position_when_not_enabled():
|
|
||||||
"""export_profile() omits fixed_position when position.fixed_position is False"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = "foo"
|
|
||||||
iface.getShortName.return_value = "oof"
|
|
||||||
iface.localNode.getURL.return_value = ""
|
|
||||||
iface.getCannedMessage.return_value = None
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {
|
|
||||||
"position": {"latitude": 35.88888, "longitude": -93.88888, "altitude": 304}
|
|
||||||
}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
raw = export_profile(iface)
|
|
||||||
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(raw)
|
|
||||||
assert not profile.HasField("fixed_position"), \
|
|
||||||
"fixed_position should be omitted when position.fixed_position is not enabled"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_export_profile_omits_empty_fields():
|
|
||||||
"""export_profile() should not populate protobuf oneofs for missing values"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = None
|
|
||||||
iface.getShortName.return_value = None
|
|
||||||
iface.localNode.getURL.return_value = ""
|
|
||||||
iface.getCannedMessage.return_value = None
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
raw = export_profile(iface)
|
|
||||||
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(raw)
|
|
||||||
assert not profile.HasField("long_name")
|
|
||||||
assert not profile.HasField("short_name")
|
|
||||||
assert not profile.HasField("channel_url")
|
|
||||||
assert not profile.HasField("canned_messages")
|
|
||||||
assert not profile.HasField("ringtone")
|
|
||||||
assert not profile.HasField("fixed_position")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_export_profile_converts_precisely():
|
|
||||||
"""export_profile() uses exact arithmetic for lat/lon conversion"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = None
|
|
||||||
iface.getShortName.return_value = None
|
|
||||||
iface.localNode.getURL.return_value = ""
|
|
||||||
iface.getCannedMessage.return_value = None
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {
|
|
||||||
"position": {"latitude": -49.82207, "longitude": 0, "altitude": 0},
|
|
||||||
}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.localConfig.position.fixed_position = True
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
raw = export_profile(iface)
|
|
||||||
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(raw)
|
|
||||||
assert profile.fixed_position.latitude_i == -498220700
|
|
||||||
assert profile.fixed_position.longitude_i == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_profile_from_yaml_maps_all_fields():
|
|
||||||
"""_profile_from_yaml() converts a YAML dict to an equivalent DeviceProfile"""
|
|
||||||
configuration = {
|
|
||||||
"owner": "YAML Owner",
|
|
||||||
"owner_short": "YO",
|
|
||||||
"channel_url": "https://meshtastic.org/e/#test",
|
|
||||||
"canned_messages": "Hi|Bye",
|
|
||||||
"ringtone": "24:d=32,o=5",
|
|
||||||
"location": {"lat": 35.88888, "lon": -93.88888, "alt": 304},
|
|
||||||
"config": {"bluetooth": {"enabled": True, "fixedPin": 123456}},
|
|
||||||
"module_config": {"telemetry": {"deviceUpdateInterval": 900}},
|
|
||||||
}
|
|
||||||
profile = _profile_from_yaml(configuration)
|
|
||||||
|
|
||||||
assert profile.long_name == "YAML Owner"
|
|
||||||
assert profile.short_name == "YO"
|
|
||||||
assert profile.channel_url == "https://meshtastic.org/e/#test"
|
|
||||||
assert profile.canned_messages == "Hi|Bye"
|
|
||||||
assert profile.ringtone == "24:d=32,o=5"
|
|
||||||
assert profile.HasField("fixed_position")
|
|
||||||
assert profile.fixed_position.latitude_i == 358888800
|
|
||||||
assert profile.fixed_position.longitude_i == -938888800
|
|
||||||
assert profile.fixed_position.altitude == 304
|
|
||||||
assert profile.HasField("config")
|
|
||||||
assert profile.config.bluetooth.enabled is True
|
|
||||||
assert profile.config.bluetooth.fixed_pin == 123456
|
|
||||||
assert profile.HasField("module_config")
|
|
||||||
assert profile.module_config.telemetry.device_update_interval == 900
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_profile_from_yaml_camelcase_keys():
|
|
||||||
"""_profile_from_yaml() handles camelCase YAML keys"""
|
|
||||||
configuration = {
|
|
||||||
"ownerShort": "CB",
|
|
||||||
"channelUrl": "https://meshtastic.org/e/#camel",
|
|
||||||
}
|
|
||||||
profile = _profile_from_yaml(configuration)
|
|
||||||
|
|
||||||
assert profile.short_name == "CB"
|
|
||||||
assert profile.channel_url == "https://meshtastic.org/e/#camel"
|
|
||||||
assert not profile.HasField("long_name")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_profile_from_yaml_converts_precisely():
|
|
||||||
"""_profile_from_yaml() uses exact arithmetic for lat/lon conversion"""
|
|
||||||
# -49.82207 is a value where int(float * 1e7) truncates to -498220699
|
|
||||||
# instead of the correct -498220700
|
|
||||||
configuration = {
|
|
||||||
"location": {"lat": -49.82207, "lon": 0, "alt": 0},
|
|
||||||
}
|
|
||||||
profile = _profile_from_yaml(configuration)
|
|
||||||
|
|
||||||
assert profile.fixed_position.latitude_i == -498220700
|
|
||||||
assert profile.fixed_position.longitude_i == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_combines_setowner_into_single_call(tmp_path, capsys):
|
|
||||||
"""Both long_name and short_name are set in a single setOwner call"""
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.long_name = "Single Call"
|
|
||||||
profile.short_name = "SC"
|
|
||||||
cfg_path = tmp_path / "combined.cfg"
|
|
||||||
cfg_path.write_bytes(profile.SerializeToString())
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(cfg_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
# Single combined message, not two separate ones
|
|
||||||
assert re.search(r"Setting device owner to Single Call and short name to SC", out, re.MULTILINE)
|
|
||||||
assert not re.search(r"Setting device owner short to SC$", out, re.MULTILINE)
|
|
||||||
# setOwner should be called once, not twice
|
|
||||||
iface.getNode.return_value.setOwner.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_with_binary_cfg(tmp_path, capsys):
|
|
||||||
"""--configure with a .cfg binary file parses and applies a DeviceProfile"""
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.long_name = "Binary Bob"
|
|
||||||
profile.short_name = "BB"
|
|
||||||
cfg_path = tmp_path / "backup.cfg"
|
|
||||||
cfg_path.write_bytes(profile.SerializeToString())
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(cfg_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device owner to Binary Bob and short name to BB", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_rejects_invalid_yaml_and_extension(tmp_path, capsys):
|
|
||||||
"""A non-dict YAML file with a non-.cfg extension should error cleanly."""
|
|
||||||
bad = tmp_path / "bad.yaml"
|
|
||||||
bad.write_text("just a scalar string")
|
|
||||||
sys.argv = ["", "--configure", str(bad)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"is not a valid YAML config or DeviceProfile", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_rejects_corrupt_cfg(tmp_path, capsys):
|
|
||||||
"""A .cfg file that is not a valid DeviceProfile should error cleanly."""
|
|
||||||
bad = tmp_path / "corrupt.cfg"
|
|
||||||
bad.write_bytes(b"\x00\x01\x02 not really protobuf \xff\xfe")
|
|
||||||
sys.argv = ["", "--configure", str(bad)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"is not a valid YAML config or DeviceProfile", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_binary_with_explicit_format(tmp_path, capsys):
|
|
||||||
"""--configure --export-format binary forces binary parsing regardless of extension"""
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.long_name = "Forced Binary"
|
|
||||||
cfg_path = tmp_path / "notcfg.dat" # non-.cfg extension, but format forced
|
|
||||||
cfg_path.write_bytes(profile.SerializeToString())
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(cfg_path), "--export-format", "binary"]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device owner to Forced Binary", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_autodetects_binary_by_content(tmp_path, capsys):
|
|
||||||
"""Binary DeviceProfile is detected by content even without .cfg extension"""
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.long_name = "Content Detected"
|
|
||||||
# Use .bin extension — autodetection must rely on content, not extension
|
|
||||||
cfg_path = tmp_path / "backup.bin"
|
|
||||||
cfg_path.write_bytes(profile.SerializeToString())
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(cfg_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device owner to Content Detected", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_autodetects_yaml_by_content(tmp_path, capsys):
|
|
||||||
"""YAML config is detected by content even with a non-standard extension"""
|
|
||||||
yaml_path = tmp_path / "config.txt"
|
|
||||||
yaml_path.write_text("owner: YAML Detected\n")
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device owner to YAML Detected", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_export_config_binary_round_trip(tmp_path, capsys):
|
|
||||||
"""Round-trip: export a .cfg via main(), then --configure it back via main()."""
|
|
||||||
cfg_path = tmp_path / "roundtrip.cfg"
|
|
||||||
|
|
||||||
# Export
|
|
||||||
sys.argv = ["", "--export-config", str(cfg_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = "Round Trip"
|
|
||||||
iface.getShortName.return_value = "RT"
|
|
||||||
iface.localNode.getURL.return_value = "https://meshtastic.org/e/#rt"
|
|
||||||
iface.getCannedMessage.return_value = "Yes|No"
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {
|
|
||||||
"position": {"latitude": 1.0, "longitude": 2.0, "altitude": 3}
|
|
||||||
}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.localConfig.position.fixed_position = True
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Exported profile to", out, re.MULTILINE)
|
|
||||||
assert cfg_path.exists()
|
|
||||||
|
|
||||||
# Verify the exported file is a valid DeviceProfile
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(cfg_path.read_bytes())
|
|
||||||
assert profile.long_name == "Round Trip"
|
|
||||||
assert profile.short_name == "RT"
|
|
||||||
assert profile.HasField("fixed_position")
|
|
||||||
assert profile.fixed_position.latitude_i == int(1.0 * 1e7)
|
|
||||||
assert profile.fixed_position.longitude_i == int(2.0 * 1e7)
|
|
||||||
assert profile.fixed_position.altitude == 3
|
|
||||||
|
|
||||||
# Re-import the file we just wrote
|
|
||||||
sys.argv = ["", "--configure", str(cfg_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device owner to Round Trip and short name to RT", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Setting device position", out, re.MULTILINE)
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_does_not_set_fixed_position_for_legacy_yaml(tmp_path, capsys):
|
|
||||||
"""Legacy YAML with location and no position config does NOT call setFixedPosition"""
|
|
||||||
yaml_path = tmp_path / "legacy.yaml"
|
|
||||||
yaml_path.write_text("location:\n lat: 35.0\n lon: -93.0\n alt: 100\n")
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert not re.search(r"Setting device position", out, re.MULTILINE)
|
|
||||||
iface.getNode.return_value.setFixedPosition.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_skips_setfixedposition_when_config_disables_it(tmp_path, capsys):
|
|
||||||
"""YAML with location but position.fixed_position=false skips setFixedPosition"""
|
|
||||||
yaml_path = tmp_path / "no_fixed.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
"location:\n lat: 35.0\n lon: -93.0\n alt: 100\n"
|
|
||||||
"config:\n position:\n fixed_position: false\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert not re.search(r"Setting device position", out, re.MULTILINE)
|
|
||||||
iface.getNode.return_value.setFixedPosition.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_calls_setfixedposition_when_config_opt_in(tmp_path, capsys):
|
|
||||||
"""YAML with location and position.fixed_position=true calls setFixedPosition"""
|
|
||||||
yaml_path = tmp_path / "fixed_on.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
"location:\n lat: 35.0\n lon: -93.0\n alt: 100\n"
|
|
||||||
"config:\n position:\n fixed_position: true\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Setting device position", out, re.MULTILINE)
|
|
||||||
iface.getNode.return_value.setFixedPosition.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_yaml_preserves_unmentioned_fields(tmp_path, capsys):
|
|
||||||
"""Partial YAML config does not reset unspecified fields in the same section."""
|
|
||||||
yaml_path = tmp_path / "partial.yaml"
|
|
||||||
yaml_path.write_text("config:\n bluetooth:\n enabled: false\n")
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path)]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
local_config = LocalConfig()
|
|
||||||
local_config.bluetooth.enabled = True
|
|
||||||
local_config.bluetooth.fixed_pin = 654321
|
|
||||||
iface.localNode.localConfig = local_config
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
# Make getNode return the same node object so apply writes back to our real config.
|
|
||||||
iface.getNode.return_value = iface.localNode
|
|
||||||
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Writing modified configuration to device", out, re.MULTILINE)
|
|
||||||
assert iface.localNode.localConfig.bluetooth.enabled is False
|
|
||||||
assert iface.localNode.localConfig.bluetooth.fixed_pin == 654321
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_main_configure_rejects_remote_node(tmp_path, capsys):
|
|
||||||
"""--configure with --dest pointing to a remote node is rejected."""
|
|
||||||
yaml_path = tmp_path / "remote.yaml"
|
|
||||||
yaml_path.write_text("owner: Remote Owner\n")
|
|
||||||
|
|
||||||
sys.argv = ["", "--configure", str(yaml_path), "--dest", "!12345678"]
|
|
||||||
mt_config.args = sys.argv
|
|
||||||
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface", return_value=iface):
|
|
||||||
main()
|
|
||||||
out, _ = capsys.readouterr()
|
|
||||||
assert re.search(r"Configuring remote nodes is not supported", out, re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
# recursion depth exceeded error
|
# recursion depth exceeded error
|
||||||
#@pytest.mark.unit
|
#@pytest.mark.unit
|
||||||
@@ -2547,124 +2075,6 @@ def test_main_configure_rejects_remote_node(tmp_path, capsys):
|
|||||||
# mo.assert_called()
|
# mo.assert_called()
|
||||||
|
|
||||||
|
|
||||||
_TRUE_DEFAULTS = [
|
|
||||||
"bluetooth.enabled",
|
|
||||||
"lora.sx126x_rx_boosted_gain",
|
|
||||||
"lora.tx_enabled",
|
|
||||||
"lora.use_preset",
|
|
||||||
"position.position_broadcast_smart_enabled",
|
|
||||||
"security.serial_enabled",
|
|
||||||
]
|
|
||||||
|
|
||||||
_MOD_TRUE_DEFAULTS = [
|
|
||||||
"mqtt.encryption_enabled",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _set_config_bool(lc, path, value):
|
|
||||||
section, field = path.split(".")
|
|
||||||
setattr(getattr(lc, section), field, value)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_config_bool(profile, path, expected):
|
|
||||||
section, field = path.split(".")
|
|
||||||
assert getattr(getattr(profile.config, section), field) is expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
@pytest.mark.parametrize("fmt", ["binary", "yaml"])
|
|
||||||
@pytest.mark.parametrize("value", [True, False])
|
|
||||||
def test_round_trip_preserves_config_true_defaults(fmt, value):
|
|
||||||
"""Export-->import preserves config_true_defaults fields for both formats"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = None
|
|
||||||
iface.getShortName.return_value = None
|
|
||||||
iface.localNode.getURL.return_value = ""
|
|
||||||
iface.getCannedMessage.return_value = None
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
for path in _TRUE_DEFAULTS:
|
|
||||||
_set_config_bool(iface.localNode.localConfig, path, value)
|
|
||||||
for path in _MOD_TRUE_DEFAULTS:
|
|
||||||
_set_config_bool(iface.localNode.moduleConfig, path, value)
|
|
||||||
|
|
||||||
if fmt == "binary":
|
|
||||||
raw = export_profile(iface)
|
|
||||||
profile = DeviceProfile()
|
|
||||||
profile.ParseFromString(raw)
|
|
||||||
else:
|
|
||||||
yaml_str = export_config(iface)
|
|
||||||
configuration = yaml.safe_load(yaml_str)
|
|
||||||
profile = _profile_from_yaml(configuration)
|
|
||||||
|
|
||||||
assert profile.HasField("config")
|
|
||||||
assert profile.HasField("module_config")
|
|
||||||
for path in _TRUE_DEFAULTS:
|
|
||||||
_assert_config_bool(profile, path, value)
|
|
||||||
for path in _MOD_TRUE_DEFAULTS:
|
|
||||||
section, field = path.split(".")
|
|
||||||
assert getattr(getattr(profile.module_config, section), field) is value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.usefixtures("reset_mt_config")
|
|
||||||
def test_binary_and_yaml_export_consistent():
|
|
||||||
"""Binary and YAML export paths produce equivalent DeviceProfile protos"""
|
|
||||||
iface = MagicMock(autospec=SerialInterface)
|
|
||||||
iface.getLongName.return_value = "Consistency"
|
|
||||||
iface.getShortName.return_value = "CON"
|
|
||||||
iface.localNode.getURL.return_value = ""
|
|
||||||
iface.getCannedMessage.return_value = None
|
|
||||||
iface.getRingtone.return_value = None
|
|
||||||
iface.getMyNodeInfo.return_value = {}
|
|
||||||
iface.localNode.localConfig = LocalConfig()
|
|
||||||
iface.localNode.moduleConfig = LocalModuleConfig()
|
|
||||||
|
|
||||||
lc = iface.localNode.localConfig
|
|
||||||
lc.bluetooth.enabled = True
|
|
||||||
lc.bluetooth.fixed_pin = 123456
|
|
||||||
lc.lora.sx126x_rx_boosted_gain = False
|
|
||||||
lc.lora.tx_enabled = True
|
|
||||||
lc.lora.use_preset = True
|
|
||||||
lc.position.position_broadcast_smart_enabled = False
|
|
||||||
lc.security.serial_enabled = True
|
|
||||||
|
|
||||||
lc_mod = iface.localNode.moduleConfig
|
|
||||||
lc_mod.mqtt.encryption_enabled = True
|
|
||||||
|
|
||||||
# Binary path
|
|
||||||
raw = export_profile(iface)
|
|
||||||
binary_profile = DeviceProfile()
|
|
||||||
binary_profile.ParseFromString(raw)
|
|
||||||
|
|
||||||
# YAML path
|
|
||||||
yaml_str = export_config(iface)
|
|
||||||
configuration = yaml.safe_load(yaml_str)
|
|
||||||
yaml_profile = _profile_from_yaml(configuration)
|
|
||||||
|
|
||||||
# Both should have config
|
|
||||||
assert binary_profile.HasField("config") == yaml_profile.HasField("config")
|
|
||||||
assert binary_profile.HasField("module_config") == yaml_profile.HasField("module_config")
|
|
||||||
|
|
||||||
# Compare individual field values
|
|
||||||
assert binary_profile.config.bluetooth.enabled == yaml_profile.config.bluetooth.enabled
|
|
||||||
assert binary_profile.config.bluetooth.fixed_pin == yaml_profile.config.bluetooth.fixed_pin
|
|
||||||
assert binary_profile.config.lora.sx126x_rx_boosted_gain == yaml_profile.config.lora.sx126x_rx_boosted_gain
|
|
||||||
assert binary_profile.config.lora.tx_enabled == yaml_profile.config.lora.tx_enabled
|
|
||||||
assert binary_profile.config.lora.use_preset == yaml_profile.config.lora.use_preset
|
|
||||||
assert binary_profile.config.position.position_broadcast_smart_enabled == yaml_profile.config.position.position_broadcast_smart_enabled
|
|
||||||
assert binary_profile.config.security.serial_enabled == yaml_profile.config.security.serial_enabled
|
|
||||||
assert binary_profile.module_config.mqtt.encryption_enabled == yaml_profile.module_config.mqtt.encryption_enabled
|
|
||||||
|
|
||||||
# Owner fields also match
|
|
||||||
assert binary_profile.long_name == yaml_profile.long_name
|
|
||||||
assert binary_profile.short_name == yaml_profile.short_name
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_set_missing_flags_false():
|
def test_set_missing_flags_false():
|
||||||
"""Test set_missing_flags_false() function"""
|
"""Test set_missing_flags_false() function"""
|
||||||
@@ -3494,7 +2904,7 @@ def test_tunnel_subnet_arg_with_no_devices(mock_platform_system, caplog, capsys)
|
|||||||
@pytest.mark.usefixtures("reset_mt_config")
|
@pytest.mark.usefixtures("reset_mt_config")
|
||||||
@patch("platform.system")
|
@patch("platform.system")
|
||||||
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
@patch("meshtastic.serial_interface.SerialInterface._set_hupcl_with_termios")
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data=b"{}")
|
@patch("builtins.open", new_callable=mock_open, read_data="data")
|
||||||
@patch("serial.Serial")
|
@patch("serial.Serial")
|
||||||
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
@patch("meshtastic.util.findPorts", return_value=["/dev/ttyUSBfake"])
|
||||||
def test_tunnel_tunnel_arg(
|
def test_tunnel_tunnel_arg(
|
||||||
@@ -3747,9 +3157,6 @@ def test_main_ota_update_file_not_found(capsys):
|
|||||||
|
|
||||||
assert pytest_wrapped_e.type == SystemExit
|
assert pytest_wrapped_e.type == SystemExit
|
||||||
assert pytest_wrapped_e.value.code == 1
|
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
|
@pytest.mark.unit
|
||||||
@@ -3794,48 +3201,3 @@ def test_main_ota_update_retries(mock_our_exit, mock_ota_class, capsys):
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(firmware_file)
|
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
|
|
||||||
|
|||||||
@@ -669,78 +669,6 @@ def test_getChannelByChannelIndex():
|
|||||||
assert anode.getChannelByChannelIndex(9) is None
|
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
|
# TODO
|
||||||
# @pytest.mark.unit
|
# @pytest.mark.unit
|
||||||
# def test_deleteChannel_try_to_delete_primary_channel(capsys):
|
# def test_deleteChannel_try_to_delete_primary_channel(capsys):
|
||||||
|
|||||||
@@ -153,6 +153,25 @@ def test_showNodes_favorite_asterisk_display(capsys, _iface_with_favorite_nodes)
|
|||||||
assert err == ""
|
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
|
@pytest.mark.unit
|
||||||
def test_showNodes_with_custom_fields_including_favorite(capsys, _iface_with_favorite_nodes):
|
def test_showNodes_with_custom_fields_including_favorite(capsys, _iface_with_favorite_nodes):
|
||||||
"""Test that isFavorite can be specified in custom showFields"""
|
"""Test that isFavorite can be specified in custom showFields"""
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"""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,7 +1,6 @@
|
|||||||
"""Meshtastic unit tests for tcp_interface.py"""
|
"""Meshtastic unit tests for tcp_interface.py"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -79,56 +78,6 @@ def test_TCPInterface_close_shutdowns_socket_before_super_close():
|
|||||||
assert iface.socket is None
|
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
|
@pytest.mark.unit
|
||||||
def test_TCPInterface_reconnect():
|
def test_TCPInterface_reconnect():
|
||||||
"""Test that _reconnect correctly reconnects"""
|
"""Test that _reconnect correctly reconnects"""
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from hypothesis import given, strategies as st
|
from hypothesis import given, strategies as st
|
||||||
|
|
||||||
from meshtastic.supported_device import SupportedDevice
|
from meshtastic.supported_device import SupportedDevice
|
||||||
from meshtastic.protobuf import mesh_pb2, config_pb2
|
from meshtastic.protobuf import mesh_pb2
|
||||||
from meshtastic.util import (
|
from meshtastic.util import (
|
||||||
DEFAULT_KEY,
|
DEFAULT_KEY,
|
||||||
Timeout,
|
Timeout,
|
||||||
@@ -22,8 +20,6 @@ from meshtastic.util import (
|
|||||||
convert_mac_addr,
|
convert_mac_addr,
|
||||||
eliminate_duplicate_port,
|
eliminate_duplicate_port,
|
||||||
findPorts,
|
findPorts,
|
||||||
flags_from_list,
|
|
||||||
flags_to_list,
|
|
||||||
fixme,
|
fixme,
|
||||||
fromPSK,
|
fromPSK,
|
||||||
fromStr,
|
fromStr,
|
||||||
@@ -239,111 +235,20 @@ def test_remove_keys_from_dict_nested():
|
|||||||
assert remove_keys_from_dict(keys, adict) == exp
|
assert remove_keys_from_dict(keys, adict) == exp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unitslow
|
||||||
def test_Timeout_waitForSet_found():
|
def test_Timeout_not_found():
|
||||||
"""waitForSet returns True when all required attrs are truthy on target."""
|
"""Test Timeout()"""
|
||||||
to = Timeout(0.01)
|
to = Timeout(0.2)
|
||||||
target = SimpleNamespace(foo=1, bar="ok")
|
attrs = "foo"
|
||||||
assert to.waitForSet(target, ("foo", "bar")) is True
|
to.waitForSet("bar", attrs)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unitslow
|
||||||
@patch("meshtastic.util.time.sleep")
|
def test_Timeout_found():
|
||||||
def test_Timeout_waitForSet_not_found(mock_sleep): # pylint: disable=unused-argument
|
"""Test Timeout()"""
|
||||||
"""waitForSet returns False when attrs remain falsy until timeout."""
|
to = Timeout(0.2)
|
||||||
to = Timeout(0.01)
|
attrs = ()
|
||||||
target = SimpleNamespace(foo=None, bar=0)
|
to.waitForSet("bar", attrs)
|
||||||
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
|
@pytest.mark.unitslow
|
||||||
@@ -877,96 +782,3 @@ def test_to_node_num_hypothesis_roundtrip(n):
|
|||||||
assert to_node_num(f"!{n:08x}") == n
|
assert to_node_num(f"!{n:08x}") == n
|
||||||
assert to_node_num(f"0x{n:x}") == n
|
assert to_node_num(f"0x{n:x}") == n
|
||||||
assert to_node_num(str(n)) == 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
|
|
||||||
|
|||||||
@@ -737,41 +737,14 @@ def to_node_num(node_id: Union[int, str]) -> int:
|
|||||||
return int(s, 16)
|
return int(s, 16)
|
||||||
|
|
||||||
def flags_to_list(flag_type, flags: int) -> List[str]:
|
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 = []
|
ret = []
|
||||||
for key in flag_type.keys():
|
for key in flag_type.keys():
|
||||||
|
if key == "EXCLUDED_NONE":
|
||||||
|
continue
|
||||||
if flags & flag_type.Value(key):
|
if flags & flag_type.Value(key):
|
||||||
ret.append(key)
|
ret.append(key)
|
||||||
flags = flags - flag_type.Value(key)
|
flags = flags - flag_type.Value(key)
|
||||||
if flags > 0:
|
if flags > 0:
|
||||||
ret.append(f"UNKNOWN_ADDITIONAL_FLAGS({flags})")
|
ret.append(f"UNKNOWN_ADDITIONAL_FLAGS({flags})")
|
||||||
return ret
|
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
|
|
||||||
|
|||||||
250
poetry.lock
generated
250
poetry.lock
generated
@@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "altgraph"
|
name = "altgraph"
|
||||||
@@ -608,7 +608,7 @@ files = [
|
|||||||
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
||||||
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
||||||
]
|
]
|
||||||
markers = {main = "python_version >= \"3.11\" and extra == \"analysis\"", powermon = "python_version >= \"3.11\""}
|
markers = {main = "extra == \"analysis\" and python_version >= \"3.11\"", powermon = "python_version >= \"3.11\""}
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||||
@@ -3284,7 +3284,7 @@ version = "2.0.2"
|
|||||||
description = "Fundamental package for array computing in Python"
|
description = "Fundamental package for array computing in Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main", "analysis"]
|
groups = ["main", "analysis", "powermon"]
|
||||||
files = [
|
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_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"},
|
||||||
{file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"},
|
{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-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"},
|
||||||
{file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"},
|
{file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"},
|
||||||
]
|
]
|
||||||
markers = {main = "python_version < \"3.11\" and extra == \"analysis\"", analysis = "python_version < \"3.11\""}
|
markers = {main = "python_version < \"3.11\" and extra == \"analysis\"", analysis = "python_version < \"3.11\"", powermon = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
@@ -3340,7 +3340,7 @@ version = "2.3.4"
|
|||||||
description = "Fundamental package for array computing in Python"
|
description = "Fundamental package for array computing in Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.11"
|
python-versions = ">=3.11"
|
||||||
groups = ["main", "analysis"]
|
groups = ["main", "analysis", "powermon"]
|
||||||
files = [
|
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_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb"},
|
||||||
{file = "numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f"},
|
{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-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f"},
|
||||||
{file = "numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a"},
|
{file = "numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a"},
|
||||||
]
|
]
|
||||||
markers = {main = "python_version >= \"3.11\" and extra == \"analysis\"", analysis = "python_version >= \"3.11\""}
|
markers = {main = "extra == \"analysis\" and python_version >= \"3.11\"", analysis = "python_version >= \"3.11\"", powermon = "python_version >= \"3.11\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "overrides"
|
name = "overrides"
|
||||||
@@ -3552,7 +3552,7 @@ description = "Type annotations for pandas"
|
|||||||
optional = true
|
optional = true
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
markers = "python_version < \"3.11\" and extra == \"analysis\""
|
markers = "extra == \"analysis\""
|
||||||
files = [
|
files = [
|
||||||
{file = "pandas_stubs-2.2.2.240807-py3-none-any.whl", hash = "sha256:893919ad82be4275f0d07bb47a95d08bae580d3fdea308a7acfcb3f02e76186e"},
|
{file = "pandas_stubs-2.2.2.240807-py3-none-any.whl", hash = "sha256:893919ad82be4275f0d07bb47a95d08bae580d3fdea308a7acfcb3f02e76186e"},
|
||||||
{file = "pandas_stubs-2.2.2.240807.tar.gz", hash = "sha256:64a559725a57a449f46225fbafc422520b7410bff9252b661a225b5559192a93"},
|
{file = "pandas_stubs-2.2.2.240807.tar.gz", hash = "sha256:64a559725a57a449f46225fbafc422520b7410bff9252b661a225b5559192a93"},
|
||||||
@@ -3562,23 +3562,6 @@ files = [
|
|||||||
numpy = ">=1.23.5"
|
numpy = ">=1.23.5"
|
||||||
types-pytz = ">=2022.1.1"
|
types-pytz = ">=2022.1.1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pandas-stubs"
|
|
||||||
version = "2.3.2.250926"
|
|
||||||
description = "Type annotations for pandas"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.10"
|
|
||||||
groups = ["main"]
|
|
||||||
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"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
numpy = ">=1.23.5"
|
|
||||||
types-pytz = ">=2022.1.1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pandocfilters"
|
name = "pandocfilters"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -4054,7 +4037,6 @@ description = ""
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main", "dev"]
|
groups = ["main", "dev"]
|
||||||
markers = "python_version < \"3.11\""
|
|
||||||
files = [
|
files = [
|
||||||
{file = "protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3"},
|
{file = "protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3"},
|
||||||
{file = "protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326"},
|
{file = "protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326"},
|
||||||
@@ -4068,25 +4050,6 @@ files = [
|
|||||||
{file = "protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135"},
|
{file = "protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protobuf"
|
|
||||||
version = "7.35.0"
|
|
||||||
description = ""
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.10"
|
|
||||||
groups = ["main", "dev"]
|
|
||||||
markers = "python_version >= \"3.11\""
|
|
||||||
files = [
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda"},
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5"},
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee"},
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011"},
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6"},
|
|
||||||
{file = "protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201"},
|
|
||||||
{file = "protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0"},
|
|
||||||
{file = "protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psutil"
|
name = "psutil"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -4150,120 +4113,52 @@ tests = ["pytest"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyarrow"
|
name = "pyarrow"
|
||||||
version = "18.1.0"
|
version = "16.1.0"
|
||||||
description = "Python library for Apache Arrow"
|
description = "Python library for Apache Arrow"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.8"
|
||||||
groups = ["powermon"]
|
groups = ["powermon"]
|
||||||
markers = "python_version < \"3.14\""
|
|
||||||
files = [
|
files = [
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e21488d5cfd3d8b500b3238a6c4b075efabc18f0f6d80b29239737ebd69caa6c"},
|
{file = "pyarrow-16.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:17e23b9a65a70cc733d8b738baa6ad3722298fa0c81d88f63ff94bf25eaa77b9"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:b516dad76f258a702f7ca0250885fc93d1fa5ac13ad51258e39d402bd9e2e1e4"},
|
{file = "pyarrow-16.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4740cc41e2ba5d641071d0ab5e9ef9b5e6e8c7611351a5cb7c1d175eaf43674a"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f443122c8e31f4c9199cb23dca29ab9427cef990f283f80fe15b8e124bcc49b"},
|
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98100e0268d04e0eec47b73f20b39c45b4006f3c4233719c3848aa27a03c1aef"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a03da7f2758645d17b7b4f83c8bffeae5bbb7f974523fe901f36288d2eab71"},
|
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f68f409e7b283c085f2da014f9ef81e885d90dcd733bd648cfba3ef265961848"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ba17845efe3aa358ec266cf9cc2800fa73038211fb27968bfa88acd09261a470"},
|
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a8914cd176f448e09746037b0c6b3a9d7688cef451ec5735094055116857580c"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3c35813c11a059056a22a3bef520461310f2f7eea5c8a11ef9de7062a23f8d56"},
|
{file = "pyarrow-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:48be160782c0556156d91adbdd5a4a7e719f8d407cb46ae3bb4eaee09b3111bd"},
|
||||||
{file = "pyarrow-18.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9736ba3c85129d72aefa21b4f3bd715bc4190fe4426715abfff90481e7d00812"},
|
{file = "pyarrow-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cf389d444b0f41d9fe1444b70650fea31e9d52cfcb5f818b7888b91b586efff"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eaeabf638408de2772ce3d7793b2668d4bb93807deed1725413b70e3156a7854"},
|
{file = "pyarrow-16.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:d0ebea336b535b37eee9eee31761813086d33ed06de9ab6fc6aaa0bace7b250c"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:3b2e2239339c538f3464308fd345113f886ad031ef8266c6f004d49769bb074c"},
|
{file = "pyarrow-16.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e73cfc4a99e796727919c5541c65bb88b973377501e39b9842ea71401ca6c1c"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39a2e0ed32a0970e4e46c262753417a60c43a3246972cfc2d3eb85aedd01b21"},
|
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf9251264247ecfe93e5f5a0cd43b8ae834f1e61d1abca22da55b20c788417f6"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31e9417ba9c42627574bdbfeada7217ad8a4cbbe45b9d6bdd4b62abbca4c6f6"},
|
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddf5aace92d520d3d2a20031d8b0ec27b4395cab9f74e07cc95edf42a5cc0147"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:01c034b576ce0eef554f7c3d8c341714954be9b3f5d5bc7117006b85fcf302fe"},
|
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:25233642583bf658f629eb230b9bb79d9af4d9f9229890b3c878699c82f7d11e"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f266a2c0fc31995a06ebd30bcfdb7f615d7278035ec5b1cd71c48d56daaf30b0"},
|
{file = "pyarrow-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a33a64576fddfbec0a44112eaf844c20853647ca833e9a647bfae0582b2ff94b"},
|
||||||
{file = "pyarrow-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d4f13eee18433f99adefaeb7e01d83b59f73360c231d4782d9ddfaf1c3fbde0a"},
|
{file = "pyarrow-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:185d121b50836379fe012753cf15c4ba9638bda9645183ab36246923875f8d1b"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d"},
|
{file = "pyarrow-16.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2e51ca1d6ed7f2e9d5c3c83decf27b0d17bb207a7dea986e8dc3e24f80ff7d6f"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee"},
|
{file = "pyarrow-16.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06ebccb6f8cb7357de85f60d5da50e83507954af617d7b05f48af1621d331c9a"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992"},
|
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b04707f1979815f5e49824ce52d1dceb46e2f12909a48a6a753fe7cafbc44a0c"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54"},
|
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d32000693deff8dc5df444b032b5985a48592c0697cb6e3071a5d59888714e2"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33"},
|
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8785bb10d5d6fd5e15d718ee1d1f914fe768bf8b4d1e5e9bf253de8a26cb1628"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30"},
|
{file = "pyarrow-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e1369af39587b794873b8a307cc6623a3b1194e69399af0efd05bb202195a5a7"},
|
||||||
{file = "pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99"},
|
{file = "pyarrow-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:febde33305f1498f6df85e8020bca496d0e9ebf2093bab9e0f65e2b4ae2b3444"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b"},
|
{file = "pyarrow-16.1.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b5f5705ab977947a43ac83b52ade3b881eb6e95fcc02d76f501d549a210ba77f"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2"},
|
{file = "pyarrow-16.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0d27bf89dfc2576f6206e9cd6cf7a107c9c06dc13d53bbc25b0bd4556f19cf5f"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191"},
|
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d07de3ee730647a600037bc1d7b7994067ed64d0eba797ac74b2bc77384f4c2"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa"},
|
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbef391b63f708e103df99fbaa3acf9f671d77a183a07546ba2f2c297b361e83"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c"},
|
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19741c4dbbbc986d38856ee7ddfdd6a00fc3b0fc2d928795b95410d38bb97d15"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c"},
|
{file = "pyarrow-16.1.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:f2c5fb249caa17b94e2b9278b36a05ce03d3180e6da0c4c3b3ce5b2788f30eed"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181"},
|
{file = "pyarrow-16.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:e6b6d3cd35fbb93b70ade1336022cc1147b95ec6af7d36906ca7fe432eb09710"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc"},
|
{file = "pyarrow-16.1.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:18da9b76a36a954665ccca8aa6bd9f46c1145f79c0bb8f4f244f5f8e799bca55"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386"},
|
{file = "pyarrow-16.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:99f7549779b6e434467d2aa43ab2b7224dd9e41bdde486020bae198978c9e05e"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324"},
|
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f07fdffe4fd5b15f5ec15c8b64584868d063bc22b86b46c9695624ca3505b7b4"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8"},
|
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddfe389a08ea374972bd4065d5f25d14e36b43ebc22fc75f7b951f24378bf0b5"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9"},
|
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b20bd67c94b3a2ea0a749d2a5712fc845a69cb5d52e78e6449bbd295611f3aa"},
|
||||||
{file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba"},
|
{file = "pyarrow-16.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:ba8ac20693c0bb0bf4b238751d4409e62852004a8cf031c73b0e0962b03e45e3"},
|
||||||
{file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:0b331e477e40f07238adc7ba7469c36b908f07c89b95dd4bd3a0ec84a3d1e21e"},
|
{file = "pyarrow-16.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:31a1851751433d89a986616015841977e0a188662fcffd1a5677453f1df2de0a"},
|
||||||
{file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2c4dd0c9010a25ba03e198fe743b1cc03cd33c08190afff371749c52ccbbaf76"},
|
{file = "pyarrow-16.1.0.tar.gz", hash = "sha256:15fbb22ea96d11f0b5768504a3f961edab25eaf4197c341720c4a387f6c60315"},
|
||||||
{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.extras]
|
[package.dependencies]
|
||||||
test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"]
|
numpy = ">=1.16.6"
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "pyarrow-stubs"
|
name = "pyarrow-stubs"
|
||||||
@@ -4511,6 +4406,7 @@ python-versions = ">=3.3, <4"
|
|||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "Pypubsub-4.0.3-py3-none-any.whl", hash = "sha256:7f716bae9388afe01ff82b264ba8a96a8ae78b42bb1f114f2716ca8f9e404e2a"},
|
{file = "Pypubsub-4.0.3-py3-none-any.whl", hash = "sha256:7f716bae9388afe01ff82b264ba8a96a8ae78b42bb1f114f2716ca8f9e404e2a"},
|
||||||
|
{file = "pypubsub-4.0.3.tar.gz", hash = "sha256:32d662de3ade0fb0880da92df209c62a4803684de5ccb8d19421c92747a258c7"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4942,7 +4838,6 @@ description = "Python HTTP for Humans."
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main", "analysis"]
|
groups = ["main", "analysis"]
|
||||||
markers = "python_version < \"3.11\""
|
|
||||||
files = [
|
files = [
|
||||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||||
@@ -4958,29 +4853,6 @@ urllib3 = ">=1.21.1,<3"
|
|||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.34.2"
|
|
||||||
description = "Python HTTP for Humans."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.10"
|
|
||||||
groups = ["main", "analysis"]
|
|
||||||
markers = "python_version >= \"3.11\""
|
|
||||||
files = [
|
|
||||||
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
|
|
||||||
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
certifi = ">=2023.5.7"
|
|
||||||
charset_normalizer = ">=2,<4"
|
|
||||||
idna = ">=2.5,<4"
|
|
||||||
urllib3 = ">=1.26,<3"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
|
||||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "retrying"
|
name = "retrying"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -5583,22 +5455,22 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tornado"
|
name = "tornado"
|
||||||
version = "6.5.6"
|
version = "6.5.7"
|
||||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["analysis"]
|
groups = ["analysis", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c"},
|
{file = "tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e"},
|
{file = "tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104"},
|
{file = "tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79"},
|
{file = "tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7"},
|
{file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3"},
|
{file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86"},
|
{file = "tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79"},
|
{file = "tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4"},
|
||||||
{file = "tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac"},
|
{file = "tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796"},
|
||||||
{file = "tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d"},
|
{file = "tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6185,4 +6057,4 @@ tunnel = ["pytap2"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.9,<3.15"
|
python-versions = "^3.9,<3.15"
|
||||||
content-hash = "7dd0dc28e13f7569f1a8cc28453d5983a6f742790d9328bafe86d1a2914d88ad"
|
content-hash = "674308d6eb7c3730031cc3e73c98b2413c7f59002a9317bfad387bc34a17c64d"
|
||||||
|
|||||||
Submodule protobufs updated: da60cee584...dd6c3f850a
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "meshtastic"
|
name = "meshtastic"
|
||||||
version = "2.7.11"
|
version = "2.7.9"
|
||||||
description = "Python API & client shell for talking to Meshtastic devices"
|
description = "Python API & client shell for talking to Meshtastic devices"
|
||||||
authors = ["Meshtastic Developers <contact@meshtastic.org>"]
|
authors = ["Meshtastic Developers <contact@meshtastic.org>"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -51,10 +51,7 @@ optional = true
|
|||||||
riden = { git = "https://github.com/geeksville/riden.git#1.2.1" }
|
riden = { git = "https://github.com/geeksville/riden.git#1.2.1" }
|
||||||
ppk2-api = "^0.9.2"
|
ppk2-api = "^0.9.2"
|
||||||
parse = "^1.20.2"
|
parse = "^1.20.2"
|
||||||
pyarrow = [
|
pyarrow = "^16.1.0"
|
||||||
{ version = "^18.0.0", python = "<3.14" },
|
|
||||||
{ version = "^22.0.0", python = ">=3.14" }
|
|
||||||
]
|
|
||||||
platformdirs = "^4.2.2"
|
platformdirs = "^4.2.2"
|
||||||
|
|
||||||
# If you are doing power analysis you might want these extra devtools
|
# If you are doing power analysis you might want these extra devtools
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
|
|
||||||
addopts = -m "not int and not smoke1 and not smoke2 and not smokewifi and not examples and not smokevirt and not smokemesh"
|
addopts = -m "not int and not smoke1 and not smoke2 and not smokewifi and not examples and not smokevirt"
|
||||||
|
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
ignore::DeprecationWarning
|
ignore::DeprecationWarning
|
||||||
@@ -13,5 +13,4 @@ markers =
|
|||||||
smoke1: runs smoke tests on a single device connected via USB
|
smoke1: runs smoke tests on a single device connected via USB
|
||||||
smoke2: runs smoke tests on a two devices 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
|
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
|
examples: runs the examples tests which validates the library
|
||||||
|
|||||||
Reference in New Issue
Block a user