Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v4
- name: Install Python 3
Expand Down Expand Up @@ -56,12 +58,11 @@ jobs:
strategy:
matrix:
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install Python 3
uses: actions/setup-python@v5
- name: Install meshtastic from local
Expand All @@ -70,3 +71,36 @@ jobs:
pip3 install poetry
poetry install
poetry run meshtastic --version

simradio_testing:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
channel:
- beta
- alpha
- daily
continue-on-error: ${{ matrix.channel == 'daily' }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install Python 3
uses: actions/setup-python@v5
with:
python-version: "3.12"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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 (${{ 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 changes: 36 additions & 0 deletions .github/workflows/daily_simradio.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Daily SimRadio Tests

on:
schedule:
- cron: 0 6 * * *
workflow_dispatch:

permissions:
contents: read

jobs:
simradio_testing:
runs-on: ubuntu-latest
if: github.repository == 'meshtastic/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
41 changes: 34 additions & 7 deletions meshtastic/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,9 +969,15 @@ def setSimpleConfig(modem_preset):
if args.ch_longslow:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_SLOW)

if args.ch_longmod:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_MODERATE)

if args.ch_longfast:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST)

if args.ch_longturbo:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.LONG_TURBO)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if args.ch_medslow:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.MEDIUM_SLOW)

Expand All @@ -984,6 +990,9 @@ def setSimpleConfig(modem_preset):
if args.ch_shortfast:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.SHORT_FAST)

if args.ch_shortturbo:
setSimpleConfig(config_pb2.Config.LoRaConfig.ModemPreset.SHORT_TURBO)

if args.ch_set or args.ch_enable or args.ch_disable:
closeNow = True

Expand Down Expand Up @@ -1960,43 +1969,61 @@ def addConfigArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:

group.add_argument(
"--ch-vlongslow",
help="Change to the very long-range and slow modem preset",
help="Change to the VERY_LONG_SLOW modem preset. Deprecated since 2.5 firmware.",
action="store_true",
)

group.add_argument(
"--ch-longslow",
help="Change to the long-range and slow modem preset",
help="Change to the LONG_SLOW modem preset. Deprecated since 2.7 firmware.",
action="store_true",
)

group.add_argument(
"--ch-longmod", "--ch-longmoderate",
help="Change to the LONG_MODERATE modem preset",
action="store_true",
)

group.add_argument(
"--ch-longfast",
help="Change to the long-range and fast modem preset",
help="Change to the LONG_FAST modem preset",
action="store_true",
)

group.add_argument(
"--ch-longturbo",
help="Change to the LONG_TURBO preset",
action="store_true",
)

group.add_argument(
"--ch-medslow",
help="Change to the med-range and slow modem preset",
help="Change to the MEDIUM_SLOW modem preset",
action="store_true",
)

group.add_argument(
"--ch-medfast",
help="Change to the med-range and fast modem preset",
help="Change to the MEDIUM_FAST modem preset",
action="store_true",
)

group.add_argument(
"--ch-shortslow",
help="Change to the short-range and slow modem preset",
help="Change to the SHORT_SLOW modem preset",
action="store_true",
)

group.add_argument(
"--ch-shortfast",
help="Change to the short-range and fast modem preset",
help="Change to the SHORT_FAST modem preset",
action="store_true",
)

group.add_argument(
"--ch-shortturbo",
help="Change to the SHORT_TURBO modem preset",
action="store_true",
)

Expand Down
14 changes: 12 additions & 2 deletions meshtastic/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,22 @@ def deleteChannel(self, channelIndex):
# for sending admin channels will also change
adminIndex = self.iface.localNode._getAdminChannelIndex()

# Snapshot serialized channel payloads from channelIndex onward so we
# can avoid writing slots whose protobuf content did not change after
# the shift. Use bytes (not message objects), because _fixupChannels()
# mutates message fields in-place.
old_channels = [
self.channels[i].SerializeToString()
for i in range(channelIndex, len(self.channels))
]

self.channels.pop(channelIndex)
self._fixupChannels() # expand back to 8 channels

index = channelIndex
while index < 8:
self.writeChannel(index, adminIndex=adminIndex)
for old_ch in old_channels:
if self.channels[index].SerializeToString() != old_ch:
self.writeChannel(index, adminIndex=adminIndex)
index += 1

# if we are updating the local node, we might end up
Expand Down
81 changes: 81 additions & 0 deletions meshtastic/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,87 @@
from meshtastic import mt_config

from ..mesh_interface import MeshInterface
from .firmware_harness import (
CHAIN_TOPOLOGY,
DEFAULT_BASE_PORT,
SimMesh,
find_meshtasticd,
is_compatible_host,
)
from .fw_helpers import set_region

# Use a different base port for the single-node fixture so it doesn't
# conflict with the multi-node mesh fixture.
SINGLE_NODE_BASE_PORT = DEFAULT_BASE_PORT + 100


def _skip_firmware_if_unavailable() -> None:
"""Skip the test when meshtasticd can't run on this host."""
if not is_compatible_host():
pytest.skip("meshtasticd firmware tests require Linux")
if find_meshtasticd() is None:
pytest.skip(
"meshtasticd not found — set MESHTASTICD_BIN or install it on PATH"
)


@pytest.fixture(scope="function")
def firmware_node():
"""A single meshtasticd sim node for smokevirt tests.

Function-scoped so every test gets a freshly-erased node with no
state leaking from previous tests. This makes destructive commands
(``--reboot``, ``--set factory_reset true``) safe to run and lets
tests be order-independent.

Yields the SimNode instance. The node is booted with a fresh erased
config and listens on localhost at its TCP port. Region is set to US
so modem-preset tests work against firmware >= 2.8, which clamps
presets to the legal set for the current region.
"""
_skip_firmware_if_unavailable()
mesh = SimMesh(n_nodes=1, base_port=SINGLE_NODE_BASE_PORT)
mesh.start()
node = mesh.get_node(0)
set_region(node.port, "US")
# The region commit restarts the TCP listener, so reconnect the harness
# interface in case a test wants to use it directly.
if node.iface is not None:
try:
node.iface.close()
except Exception: # pylint: disable=broad-except
pass
node.connect()
yield node
mesh.stop()
Comment thread
ianmcorvidae marked this conversation as resolved.


@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()
Comment on lines +35 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect SimMesh.start/stop and SimNode.start/close for partial-failure cleanup handling
fd firmware_harness.py meshtastic/tests --exec cat -n {}

Repository: meshtastic/python

Length of output: 14743


Handle partial startup cleanup in SimMesh.start()
mesh.start() can fail after some meshtasticd processes are already running, but SimMesh.stop() returns early until _started is set. Clean up already-started nodes inside firmware_harness.py so a failed connect() or later startup step doesn’t leave orphaned processes behind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@meshtastic/tests/conftest.py` around lines 34 - 65, SimMesh startup can fail
after some meshtasticd processes are already launched, leaving orphaned nodes
because SimMesh.stop() exits early before _started is set. Update
SimMesh.start() and/or the startup path in firmware_harness.py to track
partially started nodes and invoke cleanup on any failure from connect() or
later initialization steps. Use the SimMesh.start, SimMesh.stop, and connect
flow to ensure already-started processes are stopped even when startup does not
complete.



@pytest.fixture
Expand Down
Loading
Loading