Compare commits

..
Author SHA1 Message Date
Jonathan Bennett 8ccb2c918f dmshell reliability tweaks 2026-04-30 21:28:28 -05:00
Jonathan Bennett 6b49b7228a Merge branch 'develop' into vibe-coded-dmshell 2026-04-30 10:56:15 -05:00
Jonathan Bennett 989b8620ba Merge remote-tracking branch 'origin/master' into develop 2026-04-30 10:49:26 -05:00
github-actions[bot]andthebentern 173ac58ed7 Update protobufs (#10357)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-30 10:45:20 -05:00
github-actions[bot]andvidplace7 83adfd417a Upgrade trunk (#10354)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-30 06:39:52 -05:00
JoeandJonathan Bennett a0951f23c3 fix: MQTT connection on Portduino/Linux native nodes (#10330)
isConnectedToNetwork() always returned false on ARCH_PORTDUINO
because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined
for Linux native builds. This caused wantsLink() to always return
false, preventing the MQTT thread from ever connecting at boot.

Fix: return true for ARCH_PORTDUINO since Linux always has network
access available.

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-30 06:00:50 -05:00
HarukiToreda e19f531059 Update Screen.cpp (#10344) 2026-04-29 21:05:16 -05:00
AustinandCopilot 24d64a0013 Docker: Build for riscv64 (#10345)
Upstream support has been added in Debian and Alpine.
Only build as part of `docker_manifest` (Beta/Alpha/Daily) releases, because these will take a **while** thanks to qemu.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:04:49 -05:00
Ben Meadors 3a87fc82c0 Add documentation for macOS support in Copilot and Agent instructions 2026-04-29 19:54:05 -05:00
Austin 478444eb02 Docker-Alpine: Align version between build/main stages (#10347)
FROM python:3.14-alpine3.23 AS builder
FROM alpine:3.23

the alpine version needs to match in both stages 😅
2026-04-29 20:31:59 -04:00
renovate[bot] ad23c42fcc Update meshtastic/device-ui digest to 4bf593a (#10346)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-29 19:09:21 -05:00
Ben Meadors 195f42af82 Doesn't FSCom 2026-04-29 16:57:21 -05:00
Ben Meadors 089af764ec Replace FSCom.format() with FSCom.rmDir() for directory cleanup in NodeDB::loadFromDisk() 2026-04-29 16:41:21 -05:00
Austin 7be5426f34 Do not FACTORY_INSTALL on ARCH_PORTDUINO (#10343) 2026-04-29 13:00:01 -05:00
github-actions[bot]andvidplace7 9ec63b5eb2 Upgrade trunk (#10336)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-29 12:55:48 -05:00
Austin 22a9346fe0 Debian: Correctly fail upon failure (#10341)
Fake success is BS! We should fail when we fail.
Fixes issues with Debian sourcedebs silently failing to build ocassionally (due to github 502s, etc).
2026-04-29 11:16:25 -05:00
Jonathan Bennett c194666885 Merge branch 'develop' into vibe-coded-dmshell 2026-04-28 22:03:03 -05:00
renovate[bot] c0e52e6e1c Update meshtastic/device-ui digest to 1ddcc9d (#10328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 19:10:17 -05:00
github-actions[bot]andvidplace7 11df30a85f Upgrade trunk (#10324)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-04-28 19:04:16 -05:00
Ben Meadors 8d8ff21e7c Add clamping logic for milliseconds conversion and unit tests (#10326)
* Add clamping logic for milliseconds conversion and unit tests

* Simplify comments in secondsToMsClamped function

Removed detailed comments about seconds to milliseconds conversion.
2026-04-28 19:03:50 -05:00
9c72767c01 macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320)
* macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup

Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86,
PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing
`pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's
bundled CH34x driver only matches the CH340 *UART* variant
(PID 0x7523), so the CH341A's interface 0 is left unclaimed and
libusb opens / configures / claims it directly via IOUSBHostInterface.
End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init
returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow.

Two changes:

1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of
   the literal `8` to `Ch341Hal::getSerialString()`. The function in
   `USBHal.h:61-68` treats `len` as buffer size and reserves one slot
   for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`),
   so passing 8 produced a 7-char serial — which then broke the
   `strlen(serial) == 8` check at line 502, skipping the auto-MAC
   derivation from serial + product string. On Linux this was masked
   by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157,
   but on macOS that fallback is `__linux__`-guarded so the serial path
   is mandatory and the truncation left `mac_address` empty, causing
   the daemon to exit with `*** Blank MAC Address not allowed!`.

2. **`variants/native/portduino/platformio.ini`**: expand the
   `[env:native-macos]` comment block with a "Real LoRa hardware on
   macOS" section. Documents:
   - Why no upstream library change is needed (Apple kext targets
     CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is
     correct for macOS in this case).
   - How to point `meshtasticd` at an existing platform-agnostic
     `bin/config.d/lora-*.yaml` for CH341 hardware.
   - The auto-MAC-derivation contract (now working with this fix).
   - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure
     mode where a third-party WCH `CH34xVCPDriver` *would* claim
     interface 0 (`kmutil unload -b <bundleID>` workaround).

No upstream library forks, no PR chain, no additional lib_deps —
the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does
the right thing on macOS already.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:54:01 -05:00
AustinandCopilot c0425d7444 Actions: Build MacOS binary (#10319)
Preliminary CI for the MacOS builds

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 13:33:19 -05:00
d7db0f5829 add heltec-v4-r8 board (#10268)
* add heltec-v4-r8 board

* Fixed default SPI pin and macro definition errors.

* update platformio.ini according device-ui LGFX display definitions

Co-authored-by: Copilot <copilot@github.com>

* fix commit reference

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-04-27 09:47:41 -05:00
Jonathan Bennett d7cb5d7885 Merge branch 'develop' into vibe-coded-dmshell 2026-04-17 12:17:55 -05:00
Jonathan Bennett ffd144da83 Merge branch 'develop' into vibe-coded-dmshell 2026-04-16 22:48:26 -05:00
Jonathan Bennett 1e8c9e7071 Merge branch 'develop' into vibe-coded-dmshell 2026-04-16 21:29:43 -05:00
github-actions[bot]andjp-bennett 3c83e01d0e Update protobufs (#10188)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-04-16 21:28:53 -05:00
Jonathan Bennett dc3947117e Make new protobuf value consistent 2026-04-14 18:47:53 -05:00
Jonathan Bennett 87d0850f95 Refactor and Simplify 2026-04-14 17:16:36 -05:00
Jonathan Bennett 5831952636 simplify pt 1 2026-04-14 12:57:29 -05:00
Jonathan Bennett a6d61413c3 Add PortduinoSetOptions to overwrite the realhardware bool 2026-04-13 22:20:38 -05:00
Jonathan Bennett e393a5c410 Make consoleInit() Reentrant, and initialize it earlier on native 2026-04-13 20:56:44 -05:00
Jonathan Bennett 8f2ecbdb4d No Child Left Behind 2026-04-13 20:32:38 -05:00
Jonathan Bennett 6c28d11cee Minor cleanups 2026-04-13 20:23:31 -05:00
Jonathan Bennett 69f1b502cc Harden against possible memory overflows 2026-04-13 20:05:50 -05:00
Jonathan Bennett 3a498fbbe4 Make the DMShell tests compile 2026-04-13 19:47:33 -05:00
Jonathan Bennett 8f7dea0580 Use RemoteShell in protobufs 2026-04-13 19:25:09 -05:00
Jonathan Bennett 322f0262a8 Trunk 2026-04-13 12:11:31 -05:00
Jonathan Bennett 00762393cf Merge branch 'develop' into vibe-coded-dmshell 2026-04-13 12:07:56 -05:00
Jonathan Bennett 866c89f801 Merge branch 'develop' into vibe-coded-dmshell 2026-04-12 22:41:58 -05:00
Jonathan Bennett 8c248927c8 Remove some dead code and LLM overcomplication 2026-04-10 16:01:35 -05:00
Ben Meadors 188d895eb4 Merge branch 'develop' into vibe-coded-dmshell 2026-04-10 07:21:42 -05:00
Jonathan Bennett 6f476f3475 Merge branch 'develop' into vibe-coded-dmshell 2026-04-09 21:46:10 -05:00
Jonathan Bennett 50e1fe88e8 dmshell client serial support and tweaks 2026-04-09 18:31:29 -05:00
Jonathan Bennett f9bedd8adc DMShell heartbeat 2026-04-08 23:19:02 -05:00
Jonathan Bennett 5a619c9031 Attempt at better responsiveness 2026-04-08 21:15:23 -05:00
Jonathan Bennett 8d3f9222ff Don't firehose missing packets 2026-04-08 17:52:47 -05:00
Jonathan Bennett f5335f22ea troubleshoot dropped packets 2026-04-08 16:42:46 -05:00
Jonathan Bennett 4a3f449555 Try to re-request missed sequences 2026-04-08 15:16:33 -05:00
Jonathan Bennett 608713470b Interactive mode 2026-04-08 14:55:37 -05:00
Jonathan Bennett f475be19c6 Dumb fixes 2026-04-08 14:27:15 -05:00
Jonathan Bennett 27cc76d5ed Very WIP dmshell 2026-04-08 13:18:21 -05:00
34 changed files with 2351 additions and 535 deletions
+3 -1
View File
@@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po
- **RP2040/RP2350** - Raspberry Pi Pico variants
- **STM32WL** - STM32 with integrated LoRa
- **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.)
- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup
### Supported Radio Chips
@@ -369,7 +370,7 @@ To reduce avoidable agent mistakes, assume these tools are available (or install
- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs`
- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing
- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`)
- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts)
- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`)
Fallback expectations for agents:
@@ -388,6 +389,7 @@ Build commands:
pio run -e tbeam # Build specific target
pio run -e tbeam -t upload # Build and upload
pio run -e native # Build native/Linux version
pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini)
```
### Build Manifest
+51
View File
@@ -0,0 +1,51 @@
name: Build MacOS Binary
on:
workflow_call:
inputs:
macos_ver:
required: false
default: "26" # ARM64
type: string
permissions:
contents: read
jobs:
build-MacOS:
runs-on: macos-${{ inputs.macos_ver }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install deps
shell: bash
run: |
brew update
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config
- name: Get release version string
run: |
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
id: version
- name: Build for MacOS
run: |
platformio run -e native-macos
env:
PKG_VERSION: ${{ steps.version.outputs.long }}
# Errors in this step should not fail the entire workflow while MacOS support is in development.
continue-on-error: true
- name: List output files
run: ls -lah .pio/build/native-macos/
- name: Store binaries as an artifact
uses: actions/upload-artifact@v7
with:
name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }}
overwrite: true
path: |
.pio/build/native-macos/meshtasticd
+3 -1
View File
@@ -73,7 +73,9 @@ jobs:
- name: Sanitize platform string
id: sanitize_platform
# Replace slashes with underscores
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
env:
plat: ${{ inputs.platform }}
run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
- name: Docker login
if: ${{ inputs.push }}
+22
View File
@@ -43,6 +43,15 @@ jobs:
push: true
secrets: inherit
docker-debian-riscv64:
uses: ./.github/workflows/docker_build.yml
with:
distro: debian
platform: linux/riscv64
runs-on: ubuntu-24.04-arm
push: true
secrets: inherit
docker-alpine-amd64:
uses: ./.github/workflows/docker_build.yml
with:
@@ -70,16 +79,27 @@ jobs:
push: true
secrets: inherit
docker-alpine-riscv64:
uses: ./.github/workflows/docker_build.yml
with:
distro: alpine
platform: linux/riscv64
runs-on: ubuntu-24.04-arm
push: true
secrets: inherit
docker-manifest:
needs:
# Debian
- docker-debian-amd64
- docker-debian-arm64
- docker-debian-armv7
- docker-debian-riscv64
# Alpine
- docker-alpine-amd64
- docker-alpine-arm64
- docker-alpine-armv7
- docker-alpine-riscv64
runs-on: ubuntu-24.04
steps:
- name: Checkout code
@@ -162,6 +182,7 @@ jobs:
meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }}
- name: Docker meta (Alpine)
id: meta_alpine
@@ -182,3 +203,4 @@ jobs:
meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }}
meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }}
+15
View File
@@ -116,6 +116,20 @@ jobs:
build_location: local
secrets: inherit
MacOS:
strategy:
fail-fast: false
matrix:
macos_ver:
- "26" # ARM64
# - '26-intel' # x86_64
- "15" # ARM64
# - '15-intel' # x86_64
uses: ./.github/workflows/build_macos_bin.yml
with:
macos_ver: ${{ matrix.macos_ver }}
# secrets: inherit
package-pio-deps-native-tft:
if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/package_pio_deps.yml
@@ -286,6 +300,7 @@ jobs:
- gather-artifacts
- build-debian-src
- package-pio-deps-native-tft
# - MacOS
steps:
- name: Checkout
uses: actions/checkout@v6
+3 -3
View File
@@ -4,12 +4,12 @@ cli:
plugins:
sources:
- id: trunk
ref: v1.7.6
ref: v1.8.0
uri: https://github.com/trunk-io/plugins
lint:
enabled:
- checkov@3.2.525
- renovate@43.142.0
- renovate@43.150.0
- prettier@3.8.3
- trufflehog@3.95.2
- yamllint@1.38.0
@@ -36,7 +36,7 @@ lint:
- bin/**
runtimes:
enabled:
- python@3.10.8
- python@3.14.4
- go@1.21.0
- node@22.16.0
actions:
+13 -11
View File
@@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
## Quick command reference
| Action | Command |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
| Action | Command |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
## MCP server (device + test automation)
@@ -121,6 +122,7 @@ Sequence these; don't parallelize on the same port.
- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access.
- **Port busy?** `lsof <port>` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it.
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned.
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit.
## Environment variables (test harness)
+3 -2
View File
@@ -3,7 +3,8 @@
# trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
FROM python:3.14-alpine3.22 AS builder
# Ensure the Alpine version is updated in both stages of the container!
FROM python:3.14-alpine3.23 AS builder
ARG PIO_ENV=native
ENV PIP_ROOT_USER_ACTION=ignore
@@ -60,4 +61,4 @@ EXPOSE 4403
CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ]
HEALTHCHECK NONE
HEALTHCHECK NONE
+941
View File
@@ -0,0 +1,941 @@
#!/usr/bin/env python3
import argparse
import os
import queue
import random
import shutil
import socket
import select
import subprocess
import sys
import tempfile
import termios
import threading
import time
import tty
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, TextIO
START1 = 0x94
START2 = 0xC3
HEADER_LEN = 4
DEFAULT_API_PORT = 4403
DEFAULT_HOP_LIMIT = 0
LOCAL_ESCAPE_BYTE = b"\x1d" # Ctrl+]
MISSING_SEQ_RETRY_INTERVAL_SEC = 1.0
INPUT_BATCH_WINDOW_SEC = .5
INPUT_BATCH_MAX_BYTES = 64
HEARTBEAT_IDLE_DELAY_SEC = 5.0
HEARTBEAT_REPEAT_SEC = 15.0
HEARTBEAT_POLL_INTERVAL_SEC = 0.25
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Tiny DMShell client for Meshtastic native TCP API",
epilog=(
"Examples:\n"
" bin/dmshell_client.py --to !170896f7\n"
" bin/dmshell_client.py --to 0x170896f7 --command 'uname -a' --command 'id'"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--host", default="127.0.0.1", help="meshtasticd API host")
parser.add_argument("--port", type=int, default=DEFAULT_API_PORT, help="meshtasticd API port")
parser.add_argument(
"--serial",
nargs="?",
const="auto",
default=None,
help="use USB serial transport (optionally provide device path, default: auto-detect)",
)
parser.add_argument("--baud", type=int, default=115200, help="serial baud rate when using --serial")
parser.add_argument("--to", required=True, help="destination node number, e.g. !170896f7 or 0x170896f7")
parser.add_argument("--channel", type=int, default=0, help="channel index to use")
parser.add_argument("--cols", type=int, default=None, help="initial terminal columns (default: detect local terminal)")
parser.add_argument("--rows", type=int, default=None, help="initial terminal rows (default: detect local terminal)")
parser.add_argument("--command", action="append", default=[], help="send a command line after opening")
parser.add_argument("--close-after", type=float, default=2.0, help="seconds to wait before closing in command mode")
parser.add_argument("--timeout", type=float, default=10.0, help="seconds to wait for API/session events")
parser.add_argument("--verbose", action="store_true", help="print extra protocol events")
return parser.parse_args()
def repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def load_proto_modules() -> object:
try:
import google.protobuf # noqa: F401
except ImportError as exc:
raise SystemExit("python package 'protobuf' is required to run this client") from exc
protoc = shutil.which("protoc")
if not protoc:
raise SystemExit("'protoc' is required to generate temporary Python protobuf bindings")
out_dir = Path(tempfile.mkdtemp(prefix="meshtastic_dmshell_proto_"))
proto_dir = repo_root() / "protobufs"
# Compile all required protos for DMShell client (mesh and dependencies)
# Excludes nanopb.proto and other complex build artifacts
required_protos = [
"mesh.proto",
"channel.proto",
"config.proto",
"device_ui.proto",
"module_config.proto",
"atak.proto",
"portnums.proto",
"telemetry.proto",
"xmodem.proto",
]
proto_files = [proto_dir / "meshtastic" / name for name in required_protos]
for pf in proto_files:
if not pf.exists():
raise SystemExit(f"could not find required proto file: {pf}")
# Create __init__.py to make meshtastic a package
(out_dir / "meshtastic").mkdir(exist_ok=True)
(out_dir / "meshtastic" / "__init__.py").touch()
# Build protoc command with just the meshtastic proto directory as include path
# protoc will use its built-in includes for standard google protobuf types
cmd = [protoc, f"-I{proto_dir}", f"--python_out={out_dir}", *[str(path) for path in proto_files]]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"protoc stderr: {result.stderr}", file=sys.stderr)
print(f"protoc stdout: {result.stdout}", file=sys.stderr)
print(f"protoc command: {' '.join(cmd)}", file=sys.stderr)
raise SystemExit(f"protoc failed with return code {result.returncode}")
# Create _pb2_grpc module stub if not present (protoc 3.20+)
mesh_pb2_file = out_dir / "meshtastic" / "mesh_pb2.py"
if not mesh_pb2_file.exists():
raise SystemExit(f"protoc did not generate mesh_pb2.py in {out_dir / 'meshtastic'}")
sys.path.insert(0, str(out_dir))
try:
from meshtastic import mesh_pb2, portnums_pb2 # type: ignore
except ImportError as exc:
print(f"Failed to import protobuf modules. Output dir contents:", file=sys.stderr)
for item in (out_dir / "meshtastic").iterdir():
print(f" {item.name}", file=sys.stderr)
raise SystemExit(f"could not import meshtastic proto modules: {exc}") from exc
# Return an object that has both modules accessible
class ProtoModules:
pass
pb2 = ProtoModules()
pb2.mesh = mesh_pb2
pb2.portnums = portnums_pb2
return pb2
def parse_node_num(raw: str) -> int:
value = raw.strip()
if value.startswith("!"):
value = value[1:]
if value.lower().startswith("0x"):
return int(value, 16)
if any(ch in "abcdefABCDEF" for ch in value):
return int(value, 16)
return int(value, 10)
class SerialTransport:
def __init__(self, serial_obj):
self._serial = serial_obj
def recv(self, length: int) -> bytes:
return self._serial.read(length)
def sendall(self, data: bytes) -> None:
self._serial.write(data)
self._serial.flush()
def close(self) -> None:
self._serial.close()
def detect_meshtastic_serial_port() -> str:
try:
from serial.tools import list_ports
except ImportError as exc:
raise SystemExit("python package 'pyserial' is required for --serial mode") from exc
ports = list(list_ports.comports())
if not ports:
raise SystemExit("no serial ports found for --serial mode")
scored: list[tuple[int, str]] = []
for port in ports:
text = " ".join(
filter(
None,
[port.device, port.description, port.manufacturer, port.product, port.hwid],
)
).lower()
score = 0
if "meshtastic" in text:
score += 100
if "lora" in text or "mesh" in text:
score += 10
if "ttyacm" in (port.device or "").lower() or "ttyusb" in (port.device or "").lower():
score += 1
scored.append((score, port.device))
scored.sort(reverse=True)
best_score, best_device = scored[0]
if best_score <= 0 and len(scored) > 1:
raise SystemExit(
"could not confidently auto-detect a Meshtastic serial port; pass --serial /dev/ttyXXX explicitly"
)
return best_device
def open_transport(args: argparse.Namespace):
if args.serial is None:
sock = socket.create_connection((args.host, args.port), timeout=args.timeout)
sock.settimeout(None)
return sock
serial_path = args.serial
if serial_path == "auto":
serial_path = detect_meshtastic_serial_port()
print(f"[dmshell] using serial port {serial_path}", file=sys.stderr)
try:
import serial
except ImportError as exc:
raise SystemExit("python package 'pyserial' is required for --serial mode") from exc
try:
serial_obj = serial.Serial(serial_path, baudrate=args.baud, timeout=None, write_timeout=2)
except Exception as exc:
raise SystemExit(f"failed to open serial device {serial_path}: {exc}") from exc
return SerialTransport(serial_obj)
def recv_exact(transport, length: int) -> bytes:
chunks = bytearray()
while len(chunks) < length:
piece = transport.recv(length - len(chunks))
if not piece:
raise ConnectionError("connection closed by transport")
chunks.extend(piece)
return bytes(chunks)
def detect_local_terminal_size() -> tuple[int, int]:
size = shutil.get_terminal_size(fallback=(100, 40))
cols = max(1, int(size.columns))
rows = max(1, int(size.lines))
return cols, rows
def resolve_initial_terminal_size(cols_override: Optional[int], rows_override: Optional[int]) -> tuple[int, int]:
detected_cols, detected_rows = detect_local_terminal_size()
cols = detected_cols if cols_override is None else max(1, cols_override)
rows = detected_rows if rows_override is None else max(1, rows_override)
return cols, rows
def recv_stream_frame(transport) -> bytes:
while True:
start = recv_exact(transport, 1)[0]
if start != START1:
continue
if recv_exact(transport, 1)[0] != START2:
continue
header = recv_exact(transport, 2)
length = (header[0] << 8) | header[1]
return recv_exact(transport, length)
def send_stream_frame(transport, payload: bytes) -> None:
if len(payload) > 0xFFFF:
raise ValueError("payload too large for stream API")
header = bytes((START1, START2, (len(payload) >> 8) & 0xFF, len(payload) & 0xFF))
transport.sendall(header + payload)
@dataclass
class SentShellFrame:
op: int
session_id: int
seq: int
ack_seq: int
payload: bytes = b""
cols: int = 0
rows: int = 0
flags: int = 0
last_tx_seq: int = 0
last_rx_seq: int = 0
@dataclass
class SessionState:
pb2: object # ProtoModules with mesh and portnums attributes
target: int
channel: int
verbose: bool
session_id: int = field(default_factory=lambda: random.randint(1, 0x7FFFFFFF))
next_seq: int = 1
last_rx_seq: int = 0
next_expected_rx_seq: int = 1
highest_seen_rx_seq: int = 0
active: bool = False
stopped: bool = False
opened_event: threading.Event = field(default_factory=threading.Event)
closed_event: threading.Event = field(default_factory=threading.Event)
event_queue: "queue.Queue[str]" = field(default_factory=queue.Queue)
tx_lock: threading.Lock = field(default_factory=threading.Lock)
socket_lock: threading.Lock = field(default_factory=threading.Lock)
tx_history: deque[SentShellFrame] = field(default_factory=lambda: deque(maxlen=50))
pending_rx_frames: dict[int, object] = field(default_factory=dict)
last_requested_missing_seq: int = 0
last_missing_request_time: float = 0.0
requested_missing_seqs: set[int] = field(default_factory=set)
replay_log_lock: threading.Lock = field(default_factory=threading.Lock)
replay_log_file: Optional[TextIO] = None
replay_log_path: Optional[Path] = None
last_transport_activity_time: float = field(default_factory=time.monotonic)
last_heartbeat_sent_time: float = 0.0
def alloc_seq(self) -> int:
with self.tx_lock:
value = self.next_seq
self.next_seq += 1
return value
def current_ack_seq(self) -> int:
with self.tx_lock:
return self.last_rx_seq
def highest_sent_seq(self) -> int:
with self.tx_lock:
return max(0, self.next_seq - 1)
def note_outbound_packet(self, heartbeat: bool = False) -> None:
with self.tx_lock:
now = time.monotonic()
if heartbeat:
self.last_heartbeat_sent_time = now
else:
self.last_transport_activity_time = now
def note_inbound_packet(self) -> None:
with self.tx_lock:
self.last_transport_activity_time = time.monotonic()
def heartbeat_due(self) -> bool:
with self.tx_lock:
now = time.monotonic()
if (now - self.last_transport_activity_time) < HEARTBEAT_IDLE_DELAY_SEC:
return False
if self.last_heartbeat_sent_time <= self.last_transport_activity_time:
return True
return (now - self.last_heartbeat_sent_time) >= HEARTBEAT_REPEAT_SEC
def note_peer_reported_tx_seq(self, seq: int) -> None:
with self.tx_lock:
if seq > self.highest_seen_rx_seq:
self.highest_seen_rx_seq = seq
def note_received_seq(self, seq: int) -> tuple[str, Optional[int]]:
with self.tx_lock:
if seq == 0:
return ("process", None)
if seq < self.next_expected_rx_seq:
if self.highest_seen_rx_seq >= self.next_expected_rx_seq:
return ("gap", self.next_expected_rx_seq)
return ("duplicate", None)
if seq > self.next_expected_rx_seq:
if seq > self.highest_seen_rx_seq:
self.highest_seen_rx_seq = seq
return ("gap", self.next_expected_rx_seq)
self.last_rx_seq = seq
self.next_expected_rx_seq = seq + 1
if self.last_requested_missing_seq != 0 and self.next_expected_rx_seq > self.last_requested_missing_seq:
self.last_requested_missing_seq = 0
if seq > self.highest_seen_rx_seq:
self.highest_seen_rx_seq = seq
if self.highest_seen_rx_seq < self.next_expected_rx_seq:
self.highest_seen_rx_seq = 0
return ("process", None)
def remember_out_of_order_frame(self, shell) -> None:
with self.tx_lock:
if shell.seq <= self.next_expected_rx_seq:
return
if shell.seq not in self.pending_rx_frames:
self.pending_rx_frames[shell.seq] = shell
if shell.seq > self.highest_seen_rx_seq:
self.highest_seen_rx_seq = shell.seq
def pop_next_buffered_frame(self):
with self.tx_lock:
return self.pending_rx_frames.pop(self.next_expected_rx_seq, None)
def pending_missing_seq(self) -> Optional[int]:
with self.tx_lock:
if self.highest_seen_rx_seq >= self.next_expected_rx_seq:
return self.next_expected_rx_seq
return None
def request_missing_seq_once(self) -> Optional[int]:
with self.tx_lock:
if self.highest_seen_rx_seq < self.next_expected_rx_seq:
return None
now = time.monotonic()
if (
self.last_requested_missing_seq == self.next_expected_rx_seq
and (now - self.last_missing_request_time) < MISSING_SEQ_RETRY_INTERVAL_SEC
):
return None
self.last_requested_missing_seq = self.next_expected_rx_seq
self.last_missing_request_time = now
return self.last_requested_missing_seq
def set_receive_cursor(self, seq: int) -> None:
with self.tx_lock:
self.last_rx_seq = seq
self.next_expected_rx_seq = seq + 1
self.highest_seen_rx_seq = seq
def open_replay_log(self, session_id: int) -> None:
with self.replay_log_lock:
if self.replay_log_file is not None:
return
path = Path.cwd() / f"{session_id:08x}.log"
self.replay_log_file = path.open("a", encoding="utf-8")
self.replay_log_path = path
self.replay_log_file.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} session_open session=0x{session_id:08x}\n")
self.replay_log_file.flush()
def log_replay_event(self, event: str, seq: int, detail: str = "") -> None:
with self.replay_log_lock:
if self.replay_log_file is None:
return
extra = f" {detail}" if detail else ""
self.replay_log_file.write(
f"{time.strftime('%Y-%m-%d %H:%M:%S')} {event} seq={seq}{extra}\n"
)
self.replay_log_file.flush()
def note_missing_seq_requested(self, seq: int, reason: str) -> None:
with self.tx_lock:
self.requested_missing_seqs.add(seq)
self.log_replay_event("missing_requested", seq, f"reason={reason}")
def note_replayed_seq_received(self, seq: int) -> None:
with self.tx_lock:
was_requested = seq in self.requested_missing_seqs
if was_requested:
self.requested_missing_seqs.remove(seq)
if was_requested:
self.log_replay_event("replay_received", seq)
def close_replay_log(self) -> None:
with self.replay_log_lock:
if self.replay_log_file is None:
return
self.replay_log_file.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} session_close\n")
self.replay_log_file.flush()
self.replay_log_file.close()
self.replay_log_file = None
def remember_sent_frame(self, frame: SentShellFrame) -> None:
if frame.seq == 0 or frame.op == self.pb2.mesh.RemoteShell.ACK:
return
with self.tx_lock:
self.tx_history.append(frame)
def prune_sent_frames(self, ack_seq: int) -> None:
if ack_seq <= 0:
return
with self.tx_lock:
self.tx_history = deque((frame for frame in self.tx_history if frame.seq > ack_seq), maxlen=50)
def replay_frames_from(self, start_seq: int) -> list[SentShellFrame]:
with self.tx_lock:
return [frame for frame in self.tx_history if frame.seq >= start_seq]
def send_toradio(transport, toradio) -> None:
send_stream_frame(transport, toradio.SerializeToString())
def make_toradio_packet(pb2, state: SessionState, shell_msg) -> object:
packet = pb2.mesh.MeshPacket()
packet.id = random.randint(1, 0x7FFFFFFF)
packet.to = state.target
# The 'from' field is a reserved keyword in Python, so use setattr
setattr(packet, "from", 0)
packet.channel = state.channel
packet.hop_limit = DEFAULT_HOP_LIMIT
packet.want_ack = False
packet.decoded.portnum = pb2.portnums.REMOTE_SHELL_APP
packet.decoded.payload = shell_msg.SerializeToString()
packet.decoded.want_response = False
packet.decoded.dest = state.target
packet.decoded.source = 0
toradio = pb2.mesh.ToRadio()
toradio.packet.CopyFrom(packet)
return toradio
def send_shell_frame(
transport,
state: SessionState,
op: int,
payload: bytes = b"",
cols: int = 0,
rows: int = 0,
session_id: Optional[int] = None,
ack_seq: Optional[int] = None,
seq: Optional[int] = None,
flags: int = 0,
last_tx_seq: int = 0,
last_rx_seq: int = 0,
remember: bool = True,
heartbeat: bool = False,
) -> int:
if seq is None:
seq = 0 if op == state.pb2.mesh.RemoteShell.ACK else state.alloc_seq()
if ack_seq is None:
ack_seq = state.current_ack_seq()
if session_id is None:
session_id = state.session_id
shell = state.pb2.mesh.RemoteShell()
shell.op = op
shell.session_id = session_id
shell.seq = seq
shell.ack_seq = ack_seq
shell.cols = cols
shell.rows = rows
shell.flags = flags
shell.last_tx_seq = last_tx_seq
shell.last_rx_seq = last_rx_seq
if payload:
shell.payload = payload
with state.socket_lock:
send_toradio(transport, make_toradio_packet(state.pb2, state, shell))
if remember:
state.remember_sent_frame(
SentShellFrame(
op=op,
session_id=session_id,
seq=seq,
ack_seq=ack_seq,
payload=payload,
cols=cols,
rows=rows,
flags=flags,
last_tx_seq=last_tx_seq,
last_rx_seq=last_rx_seq,
)
)
state.note_outbound_packet(heartbeat=heartbeat)
return seq
def send_ack_frame(transport, state: SessionState, replay_from: Optional[int] = None) -> None:
send_shell_frame(
transport,
state,
state.pb2.mesh.RemoteShell.ACK,
seq=0,
last_rx_seq=0 if replay_from is None else replay_from - 1,
remember=False,
)
def replay_frames_from(transport, state: SessionState, start_seq: int) -> None:
frame = next((f for f in state.replay_frames_from(start_seq) if f.seq == start_seq), None)
if frame is None:
#state.event_queue.put(f"replay unavailable from seq={start_seq}")
state.log_replay_event("replay_unavailable", start_seq)
return
state.log_replay_event("replay_sent", start_seq)
#state.event_queue.put(f"replay frame seq={start_seq}")
send_shell_frame(
transport,
state,
frame.op,
payload=frame.payload,
cols=frame.cols,
rows=frame.rows,
session_id=frame.session_id,
ack_seq=frame.ack_seq,
seq=frame.seq,
flags=frame.flags,
last_tx_seq=frame.last_tx_seq,
last_rx_seq=frame.last_rx_seq,
remember=False,
)
def wait_for_config_complete(transport, pb2, timeout: float, verbose: bool) -> None:
nonce = random.randint(1, 0x7FFFFFFF)
toradio = pb2.mesh.ToRadio()
toradio.want_config_id = nonce
send_toradio(transport, toradio)
deadline = time.time() + timeout
while time.time() < deadline:
fromradio = pb2.mesh.FromRadio()
fromradio.ParseFromString(recv_stream_frame(transport))
variant = fromradio.WhichOneof("payload_variant")
if verbose and variant:
print(f"[api] fromradio {variant}", file=sys.stderr)
if variant == "config_complete_id" and fromradio.config_complete_id == nonce:
return
raise TimeoutError("timed out waiting for config handshake to complete")
def decode_shell_packet(state: SessionState, packet) -> Optional[object]:
if packet.WhichOneof("payload_variant") != "decoded":
return None
if packet.decoded.portnum != state.pb2.portnums.REMOTE_SHELL_APP:
return None
shell = state.pb2.mesh.RemoteShell()
shell.ParseFromString(packet.decoded.payload)
return shell
def reader_loop(transport, state: SessionState) -> None:
def handle_in_order_shell(shell) -> bool:
state.note_replayed_seq_received(shell.seq)
if shell.op == state.pb2.mesh.RemoteShell.OPEN_OK:
state.session_id = shell.session_id
state.open_replay_log(state.session_id)
state.set_receive_cursor(shell.seq)
state.active = True
state.opened_event.set()
state.event_queue.put(
f"opened session=0x{shell.session_id:08x} cols={shell.cols} rows={shell.rows}"
)
if state.replay_log_path is not None:
state.event_queue.put(f"replay log: {state.replay_log_path}")
elif shell.op == state.pb2.mesh.RemoteShell.OUTPUT:
if shell.payload:
sys.stdout.buffer.write(shell.payload)
sys.stdout.buffer.flush()
elif shell.op == state.pb2.mesh.RemoteShell.ERROR:
message = shell.payload.decode("utf-8", errors="replace")
if state.replay_log_file is None:
state.open_replay_log(shell.session_id or state.session_id)
sanitized = message.replace("\n", "\\n")
state.log_replay_event("error_received", shell.seq, f"message={sanitized}")
state.event_queue.put(f"remote error: {message}")
elif shell.op == state.pb2.mesh.RemoteShell.CLOSED:
message = shell.payload.decode("utf-8", errors="replace")
state.event_queue.put(f"session closed: {message}")
state.closed_event.set()
state.active = False
return True
elif shell.op == state.pb2.mesh.RemoteShell.PONG:
remote_last_tx_seq = shell.last_tx_seq
remote_last_rx_seq = shell.last_rx_seq
local_latest_tx_seq = state.highest_sent_seq()
if remote_last_rx_seq != 0 and remote_last_rx_seq < local_latest_tx_seq:
replay_frames_from(transport, state, remote_last_rx_seq + 1)
if remote_last_tx_seq > state.current_ack_seq():
state.note_peer_reported_tx_seq(remote_last_tx_seq)
req = state.request_missing_seq_once()
if req is not None:
state.note_missing_seq_requested(req, "heartbeat_status")
send_ack_frame(transport, state, replay_from=req)
#state.event_queue.put("pong")
return False
while not state.stopped:
try:
fromradio = state.pb2.mesh.FromRadio()
fromradio.ParseFromString(recv_stream_frame(transport))
except Exception as exc:
if not state.stopped:
state.event_queue.put(f"connection error: {exc}")
state.closed_event.set()
return
variant = fromradio.WhichOneof("payload_variant")
if variant == "packet":
shell = decode_shell_packet(state, fromradio.packet)
if not shell:
continue
state.note_inbound_packet()
#state.prune_sent_frames(shell.ack_seq)
if shell.op == state.pb2.mesh.RemoteShell.ACK:
#state.event_queue.put("peer requested replay")
replay_from = shell.last_rx_seq + 1 if shell.last_rx_seq > 0 else None
if replay_from is not None:
#state.event_queue.put(f"peer requested replay from seq={replay_from}")
replay_frames_from(transport, state, replay_from)
continue
action, missing_from = state.note_received_seq(shell.seq)
if action == "duplicate":
req = state.request_missing_seq_once()
if req is not None:
state.note_missing_seq_requested(req, "duplicate")
send_ack_frame(transport, state, replay_from=req)
continue
if action == "gap":
state.remember_out_of_order_frame(shell)
req = state.request_missing_seq_once()
if req is not None:
state.note_missing_seq_requested(req, "gap")
send_ack_frame(transport, state, replay_from=req)
continue
if handle_in_order_shell(shell):
return
while True:
buffered_shell = state.pop_next_buffered_frame()
if buffered_shell is None:
break
buffered_action, _ = state.note_received_seq(buffered_shell.seq)
if buffered_action != "process":
state.remember_out_of_order_frame(buffered_shell)
break
if handle_in_order_shell(buffered_shell):
return
req = state.request_missing_seq_once()
if req is not None:
state.note_missing_seq_requested(req, "post_process_gap")
send_ack_frame(transport, state, replay_from=req)
elif state.verbose and variant:
state.event_queue.put(f"fromradio {variant}")
def drain_events(state: SessionState) -> None:
while True:
try:
event = state.event_queue.get_nowait()
except queue.Empty:
return
print(f"[dmshell] {event}", file=sys.stderr)
def heartbeat_loop(transport, state: SessionState) -> None:
while not state.stopped and not state.closed_event.is_set():
if not state.active:
time.sleep(HEARTBEAT_POLL_INTERVAL_SEC)
continue
if state.heartbeat_due():
try:
send_shell_frame(
transport,
state,
state.pb2.mesh.RemoteShell.PING,
last_tx_seq=state.highest_sent_seq(),
last_rx_seq=state.current_ack_seq(),
remember=True,
heartbeat=True,
)
except Exception as exc:
if not state.stopped:
state.event_queue.put(f"heartbeat error: {exc}")
state.closed_event.set()
return
time.sleep(HEARTBEAT_POLL_INTERVAL_SEC)
def run_command_mode(transport, state: SessionState, commands: list[str], close_after: float) -> None:
for command in commands:
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.INPUT, (command + "\n").encode("utf-8"))
time.sleep(close_after)
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.CLOSE)
state.closed_event.wait(timeout=close_after + 5.0)
def run_interactive_mode(transport, state: SessionState) -> None:
def read_local_command() -> str:
prompt = "\r\n[dmshell] local command (resume|close|ping|resize C R): "
sys.stderr.write(prompt)
sys.stderr.flush()
buf = bytearray()
while True:
ch = os.read(sys.stdin.fileno(), 1)
if not ch:
sys.stderr.write("\r\n")
sys.stderr.flush()
return "close"
b = ch[0]
if b in (10, 13):
sys.stderr.write("\r\n")
sys.stderr.flush()
return buf.decode("utf-8", errors="replace").strip()
if b in (8, 127):
if buf:
buf.pop()
sys.stderr.write("\b \b")
sys.stderr.flush()
continue
if b < 32:
continue
buf.append(b)
sys.stderr.write(chr(b))
sys.stderr.flush()
def handle_local_command(cmd: str) -> bool:
if cmd in ("", "resume"):
return True
if cmd == "close":
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.CLOSE)
return False
if cmd == "ping":
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.PING)
return True
if cmd.startswith("resize "):
parts = cmd.split()
if len(parts) != 3:
state.event_queue.put("usage: resize COLS ROWS")
return True
try:
cols = int(parts[1])
rows = int(parts[2])
except ValueError:
state.event_queue.put("usage: resize COLS ROWS")
return True
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.RESIZE, cols=cols, rows=rows)
return True
state.event_queue.put(f"unknown local command: {cmd}")
return True
print(
"Raw input mode active. All keys (including Ctrl+C/Ctrl+X) are sent to remote. Ctrl+] for local commands.",
file=sys.stderr,
)
if not sys.stdin.isatty():
# Fallback for non-TTY stdin: still send input as it arrives.
while not state.closed_event.is_set():
drain_events(state)
data = sys.stdin.buffer.read(INPUT_BATCH_MAX_BYTES)
if not data:
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.CLOSE)
break
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.INPUT, data)
return
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while not state.closed_event.is_set():
drain_events(state)
ready, _, _ = select.select([sys.stdin], [], [], 0.05)
if not ready:
continue
data = os.read(fd, 1)
if not data:
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.CLOSE)
break
if data == LOCAL_ESCAPE_BYTE:
keep_running = handle_local_command(read_local_command())
if not keep_running:
break
continue
# Coalesce a short burst of bytes to reduce packet overhead for fast typing.
batched = bytearray(data)
enter_local_command = False
deadline = time.monotonic() + INPUT_BATCH_WINDOW_SEC
while len(batched) < INPUT_BATCH_MAX_BYTES:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
more_ready, _, _ = select.select([sys.stdin], [], [], remaining)
if not more_ready:
break
next_byte = os.read(fd, 1)
if not next_byte:
break
if next_byte == LOCAL_ESCAPE_BYTE:
enter_local_command = True
break
batched.extend(next_byte)
if next_byte == b'\r' or next_byte == b'\t':
break
deadline = time.monotonic() + INPUT_BATCH_WINDOW_SEC
if batched:
send_shell_frame(transport, state, state.pb2.mesh.RemoteShell.INPUT, bytes(batched))
if enter_local_command:
keep_running = handle_local_command(read_local_command())
if not keep_running:
break
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
def main() -> int:
args = parse_args()
pb2 = load_proto_modules()
state = SessionState(
pb2=pb2,
target=parse_node_num(args.to),
channel=args.channel,
verbose=args.verbose,
)
cols, rows = resolve_initial_terminal_size(args.cols, args.rows)
transport = open_transport(args)
try:
wait_for_config_complete(transport, pb2, args.timeout, args.verbose)
reader = threading.Thread(target=reader_loop, args=(transport, state), daemon=True)
reader.start()
send_shell_frame(transport, state, pb2.mesh.RemoteShell.OPEN, cols=cols, rows=rows)
if not state.opened_event.wait(timeout=args.timeout):
raise SystemExit("timed out waiting for OPEN_OK from remote DMShell")
heartbeat = threading.Thread(target=heartbeat_loop, args=(transport, state), daemon=True)
heartbeat.start()
drain_events(state)
if args.command:
run_command_mode(transport, state, args.command, args.close_after)
else:
run_interactive_mode(transport, state)
state.stopped = True
drain_events(state)
reader.join(timeout=1.0)
heartbeat.join(timeout=1.0)
state.close_replay_log()
finally:
transport.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/bash
set -e
export DEBEMAIL="jbennett@incomsystems.biz"
export PLATFORMIO_LIBDEPS_DIR=pio/libdeps
export PLATFORMIO_PACKAGES_DIR=pio/packages
+1 -1
View File
@@ -125,7 +125,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
https://github.com/meshtastic/device-ui/archive/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip
https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
+236 -456
View File
@@ -505,15 +505,12 @@ bool GPS::setup()
int msglen = 0;
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
if (probeTries < GPS_PROBETRIES) {
bootString = "Probing GPS...";
gnssModel = probe(serialSpeeds[speedSelect]);
if (gnssModel == GNSS_MODEL_UNKNOWN) {
if (currentStep == 0 && ++speedSelect == array_count(serialSpeeds)) {
speedSelect = 0;
++probeTries;
}
} else {
currentStep = 0;
}
}
// Rare Serial Speeds
@@ -525,8 +522,6 @@ bool GPS::setup()
LOG_WARN("Give up on GPS probe and set to %d", GPS_BAUDRATE);
return true;
}
} else {
currentStep = 0;
}
}
#endif
@@ -543,133 +538,86 @@ bool GPS::setup()
* t-beam-s3-core uses the same L76K GNSS module as t-echo.
* Unlike t-echo, L76K uses 9600 baud rate for communication by default.
* */
if (currentStep == 0) {
// Initialize the L76K Chip, use GPS + GLONASS + BEIDOU
_serial_gps->write("$PCAS04,7*1E\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 1) {
// only ask for RMC and GGA
_serial_gps->write("$PCAS03,1,0,0,0,1,0,0,0,0,0,,,0,0*02\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 2) {
// Switch to Vehicle Mode, since SoftRF enables Aviation < 2g
_serial_gps->write("$PCAS11,3*1E\r\n");
currentDelay = 250;
}
// Initialize the L76K Chip, use GPS + GLONASS + BEIDOU
_serial_gps->write("$PCAS04,7*1E\r\n");
delay(250);
// only ask for RMC and GGA
_serial_gps->write("$PCAS03,1,0,0,0,1,0,0,0,0,0,,,0,0*02\r\n");
delay(250);
// Switch to Vehicle Mode, since SoftRF enables Aviation < 2g
_serial_gps->write("$PCAS11,3*1E\r\n");
delay(250);
} else if (gnssModel == GNSS_MODEL_MTK_L76B) {
// Waveshare Pico-GPS hat uses the L76B with 9600 baud
// Initialize the L76B Chip, use GPS + GLONASS
// See note in L76_Series_GNSS_Protocol_Specification, chapter 3.29
if (currentStep == 0) {
_serial_gps->write("$PMTK353,1,1,0,0,0*2B\r\n");
currentStep++;
currentDelay = 1000;
return false;
} else if (currentStep == 1) {
// Above command will reset the GPS and takes longer before it will accept new commands
// only ask for RMC and GGA (GNRMC and GNGGA)
// See note in L76_Series_GNSS_Protocol_Specification, chapter 2.1
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 2) {
// Enable SBAS
_serial_gps->write("$PMTK301,2*2E\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 3) {
// Enable PPS for 2D/3D fix only
_serial_gps->write("$PMTK285,3,100*3F\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 4) {
// Switch to Fitness Mode, for running and walking purpose with low speed (<5 m/s)
_serial_gps->write("$PMTK886,1*29\r\n");
currentDelay = 250;
}
_serial_gps->write("$PMTK353,1,1,0,0,0*2B\r\n");
// Above command will reset the GPS and takes longer before it will accept new commands
delay(1000);
// only ask for RMC and GGA (GNRMC and GNGGA)
// See note in L76_Series_GNSS_Protocol_Specification, chapter 2.1
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
delay(250);
// Enable SBAS
_serial_gps->write("$PMTK301,2*2E\r\n");
delay(250);
// Enable PPS for 2D/3D fix only
_serial_gps->write("$PMTK285,3,100*3F\r\n");
delay(250);
// Switch to Fitness Mode, for running and walking purpose with low speed (<5 m/s)
_serial_gps->write("$PMTK886,1*29\r\n");
delay(250);
} else if (gnssModel == GNSS_MODEL_MTK_PA1010D) {
// PA1010D is used in the Pimoroni GPS board.
if (currentStep == 0) {
// Enable all constellations.
_serial_gps->write("$PMTK353,1,1,1,1,1*2A\r\n");
currentStep++;
currentDelay = 1000;
return false;
} else if (currentStep == 1) {
// Above command will reset the GPS and takes longer before it will accept new commands
// Only ask for RMC and GGA (GNRMC and GNGGA)
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 2) {
// Enable SBAS / WAAS
_serial_gps->write("$PMTK301,2*2E\r\n");
currentDelay = 250;
}
// Enable all constellations.
_serial_gps->write("$PMTK353,1,1,1,1,1*2A\r\n");
// Above command will reset the GPS and takes longer before it will accept new commands
delay(1000);
// Only ask for RMC and GGA (GNRMC and GNGGA)
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
delay(250);
// Enable SBAS / WAAS
_serial_gps->write("$PMTK301,2*2E\r\n");
delay(250);
} else if (gnssModel == GNSS_MODEL_MTK_PA1616S) {
// PA1616S is used in some GPS breakout boards from Adafruit
// PA1616S does not have GLONASS capability. PA1616D does, but is not implemented here.
if (currentStep == 0) {
_serial_gps->write("$PMTK353,1,0,0,0,0*2A\r\n");
currentStep++;
currentDelay = 1000;
return false;
} else if (currentStep == 1) {
// Above command will reset the GPS and takes longer before it will accept new commands
// Only ask for RMC and GGA (GNRMC and GNGGA)
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 2) {
// Enable SBAS / WAAS
_serial_gps->write("$PMTK301,2*2E\r\n");
currentDelay = 250;
}
_serial_gps->write("$PMTK353,1,0,0,0,0*2A\r\n");
// Above command will reset the GPS and takes longer before it will accept new commands
delay(1000);
// Only ask for RMC and GGA (GNRMC and GNGGA)
_serial_gps->write("$PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*28\r\n");
delay(250);
// Enable SBAS / WAAS
_serial_gps->write("$PMTK301,2*2E\r\n");
delay(250);
} else if (gnssModel == GNSS_MODEL_ATGM336H) {
if (currentStep == 0) {
// Set the initial configuration of the device - these _should_ work for most AT6558 devices
msglen = makeCASPacket(0x06, 0x07, sizeof(_message_CAS_CFG_NAVX_CONF), _message_CAS_CFG_NAVX_CONF);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x07, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not set Config");
}
currentStep++;
return false;
} else if (currentStep == 1) {
// Set the update frequency to 1Hz
msglen = makeCASPacket(0x06, 0x04, sizeof(_message_CAS_CFG_RATE_1HZ), _message_CAS_CFG_RATE_1HZ);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x04, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not set Update Frequency");
}
currentStep++;
return false;
} else if (currentStep == 2) {
// Set the NEMA output messages - Ask for only RMC and GGA
// Set the initial configuration of the device - these _should_ work for most AT6558 devices
msglen = makeCASPacket(0x06, 0x07, sizeof(_message_CAS_CFG_NAVX_CONF), _message_CAS_CFG_NAVX_CONF);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x07, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not set Config");
}
// Set the update frequency to 1Hz
msglen = makeCASPacket(0x06, 0x04, sizeof(_message_CAS_CFG_RATE_1HZ), _message_CAS_CFG_RATE_1HZ);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x04, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not set Update Frequency");
}
// Set the NEMA output messages
// Ask for only RMC and GGA
uint8_t fields[] = {CAS_NEMA_RMC, CAS_NEMA_GGA};
for (unsigned int i = 0; i < sizeof(fields); i++) {
// Construct a CAS-CFG-MSG packet
uint8_t cas_cfg_msg_packet[] = {0x4e, CAS_NEMA_RMC, 0x01, 0x00};
uint8_t cas_cfg_msg_packet[] = {0x4e, fields[i], 0x01, 0x00};
msglen = makeCASPacket(0x06, 0x01, sizeof(cas_cfg_msg_packet), cas_cfg_msg_packet);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x01, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", CAS_NEMA_RMC);
}
currentStep++;
return false;
} else if (currentStep == 3) {
uint8_t cas_cfg_msg_packet[] = {0x4e, CAS_NEMA_GGA, 0x01, 0x00};
msglen = makeCASPacket(0x06, 0x01, sizeof(cas_cfg_msg_packet), cas_cfg_msg_packet);
_serial_gps->write(UBXscratch, msglen);
if (getACKCas(0x06, 0x01, 250) != GNSS_RESPONSE_OK) {
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", CAS_NEMA_GGA);
LOG_WARN("ATGM336H: Could not enable NMEA MSG: %d", fields[i]);
}
}
} else if (gnssModel == GNSS_MODEL_UC6580) {
@@ -677,363 +625,195 @@ bool GPS::setup()
// use GPS L1 & L5 + BDS B1I & B2a + GLONASS L1 + GALILEO E1 & E5a + SBAS + QZSS
// This will reset the receiver, so wait a bit afterwards
// The paranoid will wait for the OK*04 confirmation response after each command.
if (currentStep == 0) {
_serial_gps->write("$CFGSYS,h35155\r\n");
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 1) {
// Must be done after the CFGSYS command
// Turn off GSV messages, we don't really care about which and where the sats are, maybe someday.
_serial_gps->write("$CFGMSG,0,3,0\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 2) {
// Turn off GSA messages, TinyGPS++ doesn't use this message.
_serial_gps->write("$CFGMSG,0,2,0\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 3) {
// Turn off NOTICE __TXT messages, these may provide Unicore some info but we don't care.
_serial_gps->write("$CFGMSG,6,0,0\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 4) {
_serial_gps->write("$CFGMSG,6,1,0\r\n");
currentDelay = 250;
}
_serial_gps->write("$CFGSYS,h35155\r\n");
delay(750);
// Must be done after the CFGSYS command
// Turn off GSV messages, we don't really care about which and where the sats are, maybe someday.
_serial_gps->write("$CFGMSG,0,3,0\r\n");
delay(250);
// Turn off GSA messages, TinyGPS++ doesn't use this message.
_serial_gps->write("$CFGMSG,0,2,0\r\n");
delay(250);
// Turn off NOTICE __TXT messages, these may provide Unicore some info but we don't care.
_serial_gps->write("$CFGMSG,6,0,0\r\n");
delay(250);
_serial_gps->write("$CFGMSG,6,1,0\r\n");
delay(250);
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_AG3335, GNSS_MODEL_AG3352)) {
if (currentStep == 0) {
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_IN ||
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_NP_865) {
_serial_gps->write("$PAIR066,1,0,1,0,0,1*3B\r\n"); // Enable GPS+GALILEO+NAVIC
// GPS GLONASS GALILEO BDS QZSS NAVIC
// 1 0 1 0 0 1
} else {
_serial_gps->write("$PAIR066,1,1,1,1,0,0*3A\r\n"); // Enable GPS+GLONASS+GALILEO+BDS
// GPS GLONASS GALILEO BDS QZSS NAVIC
// 1 1 1 1 0 0
}
currentStep++;
return false;
} else if (currentStep == 1) {
// Configure NMEA (sentences will output once per fix)
_serial_gps->write("$PAIR062,0,1*3F\r\n"); // GGA ON
currentStep++;
return false;
} else if (currentStep == 2) {
_serial_gps->write("$PAIR062,1,0*3F\r\n"); // GLL OFF
currentStep++;
return false;
} else if (currentStep == 3) {
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF
currentStep++;
return false;
} else if (currentStep == 4) {
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF
currentStep++;
return false;
} else if (currentStep == 5) {
_serial_gps->write("$PAIR062,4,1*3B\r\n"); // RMC ON
currentStep++;
return false;
} else if (currentStep == 6) {
_serial_gps->write("$PAIR062,5,0*3B\r\n"); // VTG OFF
currentStep++;
return false;
} else if (currentStep == 7) {
_serial_gps->write("$PAIR062,6,0*38\r\n"); // ZDA ON
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 8) {
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_IN ||
config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_NP_865) {
_serial_gps->write("$PAIR066,1,0,1,0,0,1*3B\r\n"); // Enable GPS+GALILEO+NAVIC
// GPS GLONASS GALILEO BDS QZSS NAVIC
// 1 0 1 0 0 1
} else {
_serial_gps->write("$PAIR066,1,1,1,1,0,0*3A\r\n"); // Enable GPS+GLONASS+GALILEO+BDS
// GPS GLONASS GALILEO BDS QZSS NAVIC
// 1 1 1 1 0 0
}
// Configure NMEA (sentences will output once per fix)
_serial_gps->write("$PAIR062,0,1*3F\r\n"); // GGA ON
_serial_gps->write("$PAIR062,1,0*3F\r\n"); // GLL OFF
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF
_serial_gps->write("$PAIR062,4,1*3B\r\n"); // RMC ON
_serial_gps->write("$PAIR062,5,0*3B\r\n"); // VTG OFF
_serial_gps->write("$PAIR062,6,0*38\r\n"); // ZDA ON
delay(250);
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
} else if (gnssModel == GNSS_MODEL_UBLOX6) {
if (currentStep == 0) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
currentStep++;
return false;
} else if (currentStep == 1) {
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
currentStep++;
return false;
} else if (currentStep == 2) {
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
currentStep++;
return false;
} else if (currentStep == 3) {
// Turn off unwanted NMEA messages, set update rate
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
currentStep++;
return false;
} else if (currentStep == 4) {
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
currentStep++;
return false;
} else if (currentStep == 5) {
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
currentStep++;
return false;
} else if (currentStep == 6) {
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
currentStep++;
return false;
} else if (currentStep == 7) {
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
currentStep++;
return false;
} else if (currentStep == 8) {
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
currentStep++;
return false;
} else if (currentStep == 9) {
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
currentStep++;
return false;
} else if (currentStep == 10) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_ECO, "enable powersave ECO mode for Neo-6", 500);
currentStep++;
return false;
} else if (currentStep == 11) {
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
currentStep++;
return false;
} else if (currentStep == 12) {
SEND_UBX_PACKET(0x06, 0x01, _message_AID, "disable UBX-AID", 500);
currentStep++;
return false;
} else if (currentStep == 13) {
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module config saved!");
}
clearBuffer();
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
// Turn off unwanted NMEA messages, set update rate
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_ECO, "enable powersave ECO mode for Neo-6", 500);
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_AID, "disable UBX-AID", 500);
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module config saved!");
}
} else if (IS_ONE_OF(gnssModel, GNSS_MODEL_UBLOX7, GNSS_MODEL_UBLOX8, GNSS_MODEL_UBLOX9)) {
if (currentStep == 0) {
if (gnssModel == GNSS_MODEL_UBLOX7) {
LOG_DEBUG("Set GPS+SBAS");
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_7), _message_GNSS_7);
_serial_gps->write(UBXscratch, msglen);
} else { // 8,9
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_8), _message_GNSS_8);
_serial_gps->write(UBXscratch, msglen);
}
if (getACK(0x06, 0x3e, 800) == GNSS_RESPONSE_NAK) {
// It's not critical if the module doesn't acknowledge this configuration.
LOG_DEBUG("reconfigure GNSS - defaults maintained. Is this module GPS-only?");
} else {
if (gnssModel == GNSS_MODEL_UBLOX7) {
LOG_DEBUG("Set GPS+SBAS");
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_7), _message_GNSS_7);
_serial_gps->write(UBXscratch, msglen);
LOG_INFO("GPS+SBAS configured");
} else { // 8,9
msglen = makeUBXPacket(0x06, 0x3e, sizeof(_message_GNSS_8), _message_GNSS_8);
_serial_gps->write(UBXscratch, msglen);
LOG_INFO("GPS+SBAS+GLONASS+Galileo configured");
}
if (getACK(0x06, 0x3e, 800) == GNSS_RESPONSE_NAK) {
// It's not critical if the module doesn't acknowledge this configuration.
LOG_DEBUG("reconfigure GNSS - defaults maintained. Is this module GPS-only?");
} else {
if (gnssModel == GNSS_MODEL_UBLOX7) {
LOG_INFO("GPS+SBAS configured");
} else { // 8,9
LOG_INFO("GPS+SBAS+GLONASS+Galileo configured");
}
// Documentation say, we need wait at least 0.5s after reconfiguration of GNSS module, before sending next
// commands for the M8 it tends to be more... 1 sec should be enough ;>)
delay(1000);
}
currentStep++;
return false;
} else if (currentStep == 1) {
// Disable Text Info messages //6,7,8,9
// Documentation say, we need wait at least 0.5s after reconfiguration of GNSS module, before sending next
// commands for the M8 it tends to be more... 1 sec should be enough ;>)
delay(1000);
}
// Disable Text Info messages //6,7,8,9
clearBuffer();
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
clearBuffer();
SEND_UBX_PACKET(0x06, 0x02, _message_DISABLE_TXT_INFO, "disable text info messages", 500);
currentStep++;
return false;
} else if (currentStep == 2) {
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
clearBuffer();
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_8, "enable interference resistance", 500);
} else { // 7,9
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
}
currentStep++;
return false;
} else if (currentStep == 3) {
if (gnssModel == GNSS_MODEL_UBLOX8) { // 8
clearBuffer();
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5_8, "configure NAVX5_8 settings", 500);
} else { // 7,9
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
}
currentStep++;
return false;
} else if (currentStep == 4) {
// Turn off unwanted NMEA messages, set update rate
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
currentStep++;
return false;
} else if (currentStep == 5) {
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
currentStep++;
return false;
} else if (currentStep == 6) {
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
currentStep++;
return false;
} else if (currentStep == 7) {
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
currentStep++;
return false;
} else if (currentStep == 8) {
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
currentStep++;
return false;
} else if (currentStep == 9) {
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
currentStep++;
return false;
} else if (currentStep == 10) {
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
currentStep++;
return false;
} else if (currentStep == 11) {
if (ublox_info.protocol_version >= 18) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x86, _message_PMS, "enable powersave for GPS", 500);
} else {
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_PSM, "enable powersave mode for GPS", 500);
}
currentStep++;
return false;
} else if (currentStep == 12) {
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_8, "enable interference resistance", 500);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5_8, "configure NAVX5_8 settings", 500);
} else { // 6,7,9
SEND_UBX_PACKET(0x06, 0x39, _message_JAM_6_7, "enable interference resistance", 500);
SEND_UBX_PACKET(0x06, 0x23, _message_NAVX5, "configure NAVX5 settings", 500);
}
// Turn off unwanted NMEA messages, set update rate
SEND_UBX_PACKET(0x06, 0x08, _message_1HZ, "set GPS update rate", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GLL, "disable NMEA GLL", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GSA, "enable NMEA GSA", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GSV, "disable NMEA GSV", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_VTG, "disable NMEA VTG", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_RMC, "enable NMEA RMC", 500);
SEND_UBX_PACKET(0x06, 0x01, _message_GGA, "enable NMEA GGA", 500);
if (ublox_info.protocol_version >= 18) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x86, _message_PMS, "enable powersave for GPS", 500);
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
currentStep++;
return false;
} else if (currentStep == 13) {
// For M8 we want to enable NMEA version 4.10 so we can see the additional sats.
if (ublox_info.protocol_version >= 18 && gnssModel == GNSS_MODEL_UBLOX8) {
if (gnssModel == GNSS_MODEL_UBLOX8) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x17, _message_NMEA, "enable NMEA 4.10", 500);
currentStep++;
return false;
}
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module configuration saved!");
}
} else if (currentStep == 14) {
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module configuration saved!");
}
} else {
SEND_UBX_PACKET(0x06, 0x11, _message_CFG_RXM_PSM, "enable powersave mode for GPS", 500);
SEND_UBX_PACKET(0x06, 0x3B, _message_CFG_PM2, "enable powersave details for GPS", 500);
}
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE), _message_SAVE);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module configuration saved!");
}
} else if (gnssModel == GNSS_MODEL_UBLOX10) {
if (currentStep == 0) {
LOG_INFO("Configuring M10 GPS step 0");
delay(1000);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_RAM, "disable NMEA messages in M10 RAM", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 1) {
LOG_INFO("Configuring M10 GPS step 1");
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_BBR, "disable NMEA messages in M10 BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 2) {
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_RAM, "disable Info messages for M10 GPS RAM", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 3) {
// Next disable Info txt messages in BBR layer
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_BBR, "disable Info messages for M10 GPS BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 4) {
// Do M10 configuration for Power Management.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_RAM, "enable powersave for M10 GPS RAM", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 5) {
// Next enable powersave in BBR layer
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_BBR, "enable powersave for M10 GPS BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 6) {
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_RAM, "enable jam detection M10 GPS RAM", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 7) {
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_BBR, "enable jam detection M10 GPS BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 8) {
// Here is where the init commands should go to do further M10 initialization.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_RAM, "disable SBAS M10 GPS RAM", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 9) {
// will cause a receiver restart so wait a bit
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_BBR, "disable SBAS M10 GPS BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 10) {
// Done with initialization, Now enable wanted NMEA messages in BBR layer so they will survive a periodic
// sleep.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_BBR, "enable messages for M10 GPS BBR", 300);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 11) {
// Next enable wanted NMEA messages in RAM layer
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_RAM, "enable messages for M10 GPS RAM", 500);
currentStep++;
currentDelay = 750;
return false;
} else if (currentStep == 12) {
// As the M10 has no flash, the best we can do to preserve the config is to set it in RAM and BBR.
// BBR will survive a restart, and power off for a while, but modules with small backup
// batteries or super caps will not retain the config for a long power off time.
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE_10), _message_SAVE_10);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module configuration saved!");
}
didSerialInit = true;
delay(1000);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_RAM, "disable NMEA messages in M10 RAM", 300);
delay(750);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_NMEA_BBR, "disable NMEA messages in M10 BBR", 300);
delay(750);
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_RAM, "disable Info messages for M10 GPS RAM", 300);
delay(750);
// Next disable Info txt messages in BBR layer
clearBuffer();
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_TXT_INFO_BBR, "disable Info messages for M10 GPS BBR", 300);
delay(750);
// Do M10 configuration for Power Management.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_RAM, "enable powersave for M10 GPS RAM", 300);
delay(750);
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_PM_BBR, "enable powersave for M10 GPS BBR", 300);
delay(750);
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_RAM, "enable jam detection M10 GPS RAM", 300);
delay(750);
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ITFM_BBR, "enable jam detection M10 GPS BBR", 300);
delay(750);
// Here is where the init commands should go to do further M10 initialization.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_RAM, "disable SBAS M10 GPS RAM", 300);
delay(750); // will cause a receiver restart so wait a bit
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_DISABLE_SBAS_BBR, "disable SBAS M10 GPS BBR", 300);
delay(750); // will cause a receiver restart so wait a bit
// Done with initialization, Now enable wanted NMEA messages in BBR layer so they will survive a periodic
// sleep.
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_BBR, "enable messages for M10 GPS BBR", 300);
delay(750);
// Next enable wanted NMEA messages in RAM layer
SEND_UBX_PACKET(0x06, 0x8A, _message_VALSET_ENABLE_NMEA_RAM, "enable messages for M10 GPS RAM", 500);
delay(750);
// As the M10 has no flash, the best we can do to preserve the config is to set it in RAM and BBR.
// BBR will survive a restart, and power off for a while, but modules with small backup
// batteries or super caps will not retain the config for a long power off time.
msglen = makeUBXPacket(0x06, 0x09, sizeof(_message_SAVE_10), _message_SAVE_10);
_serial_gps->write(UBXscratch, msglen);
if (getACK(0x06, 0x09, 2000) != GNSS_RESPONSE_OK) {
LOG_WARN("Unable to save GNSS module config");
} else {
LOG_INFO("GNSS module configuration saved!");
}
} else if (gnssModel == GNSS_MODEL_CM121) {
// only ask for RMC and GGA
if (currentStep == 0) {
// enable GGA
_serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n");
currentStep++;
currentDelay = 250;
return false;
} else if (currentStep == 1) {
// enable RMC
_serial_gps->write("$CFGMSG,0,4,1,1*1F\r\n");
currentDelay = 250;
}
// enable GGA
_serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n");
delay(250);
// enable RMC
_serial_gps->write("$CFGMSG,0,4,1,1*1F\r\n");
delay(250);
}
didSerialInit = true;
}
-2
View File
@@ -128,8 +128,6 @@ class GPS : private concurrency::OSThread
// Let the GPS hardware save power between updates
void down();
bool initFinished() const { return GPSInitFinished; };
private:
GPS() : concurrency::OSThread("GPS") {}
+18 -18
View File
@@ -524,6 +524,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
delay(100);
#endif
#if !ARCH_PORTDUINO
#if defined(USE_ST7789) && defined(VTFT_CTRL)
// Ensure panel power rail is enabled before sending wake commands.
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, LOW);
#endif
dispdev->displayOn();
#endif
@@ -545,10 +550,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
ui->init();
#endif
#if defined(USE_ST7789) && defined(VTFT_LEDA)
#ifdef VTFT_CTRL
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, LOW);
#endif
ui->init();
#ifdef ESP_PLATFORM
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
@@ -589,23 +590,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
#endif
#ifdef USE_ST7789
SPI1.end();
#if defined(ARCH_ESP32)
// Keep TFT control pins in deterministic states while timed-off.
// Floating/default pin states can corrupt panel edge rows on wake.
#ifdef VTFT_LEDA
pinMode(VTFT_LEDA, ANALOG);
pinMode(VTFT_LEDA, OUTPUT);
digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON);
#endif
#ifdef VTFT_CTRL
pinMode(VTFT_CTRL, ANALOG);
#endif
pinMode(ST7789_RESET, ANALOG);
pinMode(ST7789_RS, ANALOG);
pinMode(ST7789_NSS, ANALOG);
#else
nrf_gpio_cfg_default(VTFT_LEDA);
nrf_gpio_cfg_default(VTFT_CTRL);
nrf_gpio_cfg_default(ST7789_RESET);
nrf_gpio_cfg_default(ST7789_RS);
nrf_gpio_cfg_default(ST7789_NSS);
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, HIGH);
#endif
pinMode(ST7789_RESET, OUTPUT);
digitalWrite(ST7789_RESET, HIGH);
pinMode(ST7789_RS, OUTPUT);
digitalWrite(ST7789_RS, HIGH);
pinMode(ST7789_NSS, OUTPUT);
digitalWrite(ST7789_NSS, HIGH);
#endif
#ifdef USE_ST7796
SPI1.end();
@@ -886,7 +886,7 @@ int32_t Screen::runOnce()
// Show boot screen for first logo_timeout seconds, then switch to normal operation.
// serialSinceMsec adjusts for additional serial wait time during nRF52 bootup
static bool showingBootScreen = true;
if (showingBootScreen && (!gps || gps->initFinished()) && (millis() > (logo_timeout + serialSinceMsec))) {
if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) {
LOG_INFO("Done with boot screen");
stopBootScreen();
showingBootScreen = false;
-18
View File
@@ -1508,24 +1508,6 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title);
}
display->setFont(FONT_SMALL);
if (bootString != nullptr) {
static uint8_t bootStringStep = 0;
char stringCharacter = '.';
uint32_t stringWidth = getStringCenteredX(bootString);
if (bootStringStep == 0) {
stringCharacter = '.';
bootStringStep++;
} else if (bootStringStep == 1) {
stringCharacter = 'o';
bootStringStep++;
} else if (bootStringStep == 2) {
stringCharacter = 'O';
bootStringStep = 0;
}
char tmpBootString[40];
snprintf(tmpBootString, sizeof(tmpBootString), "%s %c", bootString, stringCharacter);
display->drawString(x + stringWidth, y + SCREEN_HEIGHT - 2 * FONT_HEIGHT_MEDIUM, tmpBootString);
}
// Draw region in upper left
if (upperMsg) {
display->drawString(x + 5, y + 5, upperMsg);
-2
View File
@@ -83,8 +83,6 @@ NRF52Bluetooth *nrf52Bluetooth = nullptr;
#include <string>
#endif
const char *bootString =
nullptr; // Pointer to a string that will be drawn on the boot screen, if set (used for GPS probing status)
#ifdef ARCH_ESP32
#ifdef DEBUG_PARTITION_TABLE
#include "esp_partition.h"
-2
View File
@@ -101,5 +101,3 @@ void scannerToSensorsMap(const std::unique_ptr<ScanI2CTwoWire> &i2cScanner, Scan
// We default to 4MHz SPI, SPI mode 0
extern SPISettings spiSettings;
extern const char *bootString;
+18 -8
View File
@@ -2,18 +2,21 @@
#include "meshUtils.h"
// Convert seconds to ms, clamping at INT32_MAX (~24.86 days)
static inline uint32_t secondsToMsClamped(uint32_t secs)
{
constexpr uint32_t MAX_MS = static_cast<uint32_t>(INT32_MAX);
return (secs > MAX_MS / 1000U) ? MAX_MS : secs * 1000U;
}
uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval)
{
if (configuredInterval > 0)
return configuredInterval * 1000;
return defaultInterval * 1000;
return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : defaultInterval);
}
uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval)
{
if (configuredInterval > 0)
return configuredInterval * 1000;
return default_broadcast_interval_secs * 1000;
return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : default_broadcast_interval_secs);
}
uint32_t Default::getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue)
@@ -47,7 +50,14 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d
meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
return getConfiguredOrDefaultMs(configured, defaultValue);
return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes);
// Saturate at INT32_MAX to match secondsToMsClamped: float→uint32_t when
// out of range is UB, and the result is consumed as an int32_t downstream.
constexpr uint32_t MAX_MS = static_cast<uint32_t>(INT32_MAX);
uint32_t base = getConfiguredOrDefaultMs(configured, defaultValue);
float coef = congestionScalingCoefficient(numOnlineNodes);
if (static_cast<double>(base) * static_cast<double>(coef) >= static_cast<double>(MAX_MS))
return MAX_MS;
return base * coef;
}
uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue)
@@ -66,4 +76,4 @@ uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured)
#else
return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit;
#endif
}
}
+2 -2
View File
@@ -1205,11 +1205,11 @@ void NodeDB::loadFromDisk()
spiLock->unlock();
#endif
#ifdef FSCom
#ifdef FACTORY_INSTALL
#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO)
spiLock->lock();
if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) {
LOG_WARN("Factory Install Reset!");
FSCom.format();
rmDir("/prefs");
FSCom.mkdir("/prefs");
File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE);
if (f2) {
+2
View File
@@ -179,6 +179,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified
/** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */
virtual bool findInTxQueue(NodeNum from, PacketId id) override;
uint8_t packetsInTxQueue() { return txQueue.getMaxLen() - txQueue.getFree(); }
/**
* Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface.
* @return true if len bytes were produced, false otherwise.
+1 -1
View File
@@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg;
/* Maximum encoded size of messages (where known) */
#define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size
#define meshtastic_ChannelSet_size 682
#define meshtastic_ChannelSet_size 685
#ifdef __cplusplus
} /* extern "C" */
+8 -4
View File
@@ -618,6 +618,8 @@ typedef struct _meshtastic_Config_LoRaConfig {
bool config_ok_to_mqtt;
/* Set where LORA FEM is enabled, disabled, or not present */
meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode;
/* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */
bool serial_hal_only;
} meshtastic_Config_LoRaConfig;
typedef struct _meshtastic_Config_BluetoothConfig {
@@ -779,7 +781,7 @@ extern "C" {
#define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0}
#define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0}
#define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0}
#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN}
#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0}
#define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0}
#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0}
#define meshtastic_Config_SessionkeyConfig_init_default {0}
@@ -790,7 +792,7 @@ extern "C" {
#define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0}
#define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0}
#define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0}
#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN}
#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0}
#define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0}
#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0}
#define meshtastic_Config_SessionkeyConfig_init_zero {0}
@@ -877,6 +879,7 @@ extern "C" {
#define meshtastic_Config_LoRaConfig_ignore_mqtt_tag 104
#define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105
#define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106
#define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107
#define meshtastic_Config_BluetoothConfig_enabled_tag 1
#define meshtastic_Config_BluetoothConfig_mode_tag 2
#define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3
@@ -1029,7 +1032,8 @@ X(a, STATIC, SINGULAR, BOOL, pa_fan_disabled, 15) \
X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \
X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \
X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \
X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106)
X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \
X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107)
#define meshtastic_Config_LoRaConfig_CALLBACK NULL
#define meshtastic_Config_LoRaConfig_DEFAULT NULL
@@ -1086,7 +1090,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg;
#define meshtastic_Config_BluetoothConfig_size 10
#define meshtastic_Config_DeviceConfig_size 100
#define meshtastic_Config_DisplayConfig_size 36
#define meshtastic_Config_LoRaConfig_size 88
#define meshtastic_Config_LoRaConfig_size 91
#define meshtastic_Config_NetworkConfig_IpV4Config_size 20
#define meshtastic_Config_NetworkConfig_size 204
#define meshtastic_Config_PositionConfig_size 62
@@ -361,7 +361,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
/* Maximum encoded size of messages (where known) */
/* meshtastic_NodeDatabase_size depends on runtime parameters */
#define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size
#define meshtastic_BackupPreferences_size 2429
#define meshtastic_BackupPreferences_size 2432
#define meshtastic_ChannelFile_size 718
#define meshtastic_DeviceState_size 1737
#define meshtastic_NodeInfoLite_size 196
+1 -1
View File
@@ -205,7 +205,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg;
/* Maximum encoded size of messages (where known) */
#define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size
#define meshtastic_LocalConfig_size 754
#define meshtastic_LocalConfig_size 757
#define meshtastic_LocalModuleConfig_size 820
#ifdef __cplusplus
@@ -0,0 +1,19 @@
/* Automatically generated nanopb constant definitions */
/* Generated by nanopb-0.4.9.1 */
#include "meshtastic/serial_hal.pb.h"
#if PB_PROTO_HEADER_VERSION != 40
#error Regenerate this file with the current version of nanopb generator.
#endif
PB_BIND(meshtastic_SerialHalCommand, meshtastic_SerialHalCommand, 2)
PB_BIND(meshtastic_SerialHalResponse, meshtastic_SerialHalResponse, 2)
@@ -0,0 +1,135 @@
/* Automatically generated nanopb header */
/* Generated by nanopb-0.4.9.1 */
#ifndef PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED
#define PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED
#include <pb.h>
#if PB_PROTO_HEADER_VERSION != 40
#error Regenerate this file with the current version of nanopb generator.
#endif
/* Enum definitions */
typedef enum _meshtastic_SerialHalCommand_Type {
meshtastic_SerialHalCommand_Type_UNSET = 0,
meshtastic_SerialHalCommand_Type_PIN_MODE = 1,
meshtastic_SerialHalCommand_Type_DIGITAL_WRITE = 2,
meshtastic_SerialHalCommand_Type_DIGITAL_READ = 3,
meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT = 4,
meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT = 5,
meshtastic_SerialHalCommand_Type_SPI_TRANSFER = 6,
meshtastic_SerialHalCommand_Type_NOOP = 7
} meshtastic_SerialHalCommand_Type;
typedef enum _meshtastic_SerialHalResponse_Result {
meshtastic_SerialHalResponse_Result_OK = 0,
meshtastic_SerialHalResponse_Result_ERROR = 1,
meshtastic_SerialHalResponse_Result_BAD_REQUEST = 2,
meshtastic_SerialHalResponse_Result_UNSUPPORTED = 3
} meshtastic_SerialHalResponse_Result;
/* Struct definitions */
typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalCommand_data_t;
typedef struct _meshtastic_SerialHalCommand {
/* Host-assigned request id. Replies echo this id back in
SerialHalResponse.transaction_id. */
uint32_t transaction_id;
meshtastic_SerialHalCommand_Type type;
uint32_t pin;
uint32_t value;
uint32_t mode;
meshtastic_SerialHalCommand_data_t data;
} meshtastic_SerialHalCommand;
typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalResponse_data_t;
typedef struct _meshtastic_SerialHalResponse {
/* Matches the originating SerialHalCommand.transaction_id for normal
request/response traffic.
A value of 0 indicates an unsolicited interrupt notification generated by
the device. In that case, the host should interpret value as the GPIO pin
that triggered. */
uint32_t transaction_id;
meshtastic_SerialHalResponse_Result result;
/* Used by DIGITAL_READ replies and interrupt notifications. For interrupt
notifications (transaction_id == 0), this carries the pin number. */
uint32_t value;
meshtastic_SerialHalResponse_data_t data;
char error[80];
} meshtastic_SerialHalResponse;
#ifdef __cplusplus
extern "C" {
#endif
/* Helper constants for enums */
#define _meshtastic_SerialHalCommand_Type_MIN meshtastic_SerialHalCommand_Type_UNSET
#define _meshtastic_SerialHalCommand_Type_MAX meshtastic_SerialHalCommand_Type_NOOP
#define _meshtastic_SerialHalCommand_Type_ARRAYSIZE ((meshtastic_SerialHalCommand_Type)(meshtastic_SerialHalCommand_Type_NOOP+1))
#define _meshtastic_SerialHalResponse_Result_MIN meshtastic_SerialHalResponse_Result_OK
#define _meshtastic_SerialHalResponse_Result_MAX meshtastic_SerialHalResponse_Result_UNSUPPORTED
#define _meshtastic_SerialHalResponse_Result_ARRAYSIZE ((meshtastic_SerialHalResponse_Result)(meshtastic_SerialHalResponse_Result_UNSUPPORTED+1))
#define meshtastic_SerialHalCommand_type_ENUMTYPE meshtastic_SerialHalCommand_Type
#define meshtastic_SerialHalResponse_result_ENUMTYPE meshtastic_SerialHalResponse_Result
/* Initializer values for message structs */
#define meshtastic_SerialHalCommand_init_default {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}}
#define meshtastic_SerialHalResponse_init_default {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""}
#define meshtastic_SerialHalCommand_init_zero {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}}
#define meshtastic_SerialHalResponse_init_zero {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""}
/* Field tags (for use in manual encoding/decoding) */
#define meshtastic_SerialHalCommand_transaction_id_tag 1
#define meshtastic_SerialHalCommand_type_tag 2
#define meshtastic_SerialHalCommand_pin_tag 3
#define meshtastic_SerialHalCommand_value_tag 4
#define meshtastic_SerialHalCommand_mode_tag 5
#define meshtastic_SerialHalCommand_data_tag 6
#define meshtastic_SerialHalResponse_transaction_id_tag 1
#define meshtastic_SerialHalResponse_result_tag 2
#define meshtastic_SerialHalResponse_value_tag 3
#define meshtastic_SerialHalResponse_data_tag 4
#define meshtastic_SerialHalResponse_error_tag 5
/* Struct field encoding specification for nanopb */
#define meshtastic_SerialHalCommand_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \
X(a, STATIC, SINGULAR, UENUM, type, 2) \
X(a, STATIC, SINGULAR, UINT32, pin, 3) \
X(a, STATIC, SINGULAR, UINT32, value, 4) \
X(a, STATIC, SINGULAR, UINT32, mode, 5) \
X(a, STATIC, SINGULAR, BYTES, data, 6)
#define meshtastic_SerialHalCommand_CALLBACK NULL
#define meshtastic_SerialHalCommand_DEFAULT NULL
#define meshtastic_SerialHalResponse_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \
X(a, STATIC, SINGULAR, UENUM, result, 2) \
X(a, STATIC, SINGULAR, UINT32, value, 3) \
X(a, STATIC, SINGULAR, BYTES, data, 4) \
X(a, STATIC, SINGULAR, STRING, error, 5)
#define meshtastic_SerialHalResponse_CALLBACK NULL
#define meshtastic_SerialHalResponse_DEFAULT NULL
extern const pb_msgdesc_t meshtastic_SerialHalCommand_msg;
extern const pb_msgdesc_t meshtastic_SerialHalResponse_msg;
/* Defines for backwards compatibility with code written before nanopb-0.4.0 */
#define meshtastic_SerialHalCommand_fields &meshtastic_SerialHalCommand_msg
#define meshtastic_SerialHalResponse_fields &meshtastic_SerialHalResponse_msg
/* Maximum encoded size of messages (where known) */
#define MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_MAX_SIZE meshtastic_SerialHalResponse_size
#define meshtastic_SerialHalCommand_size 541
#define meshtastic_SerialHalResponse_size 610
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif
+604
View File
@@ -0,0 +1,604 @@
#include "DMShell.h"
#if defined(ARCH_PORTDUINO)
#include "Channels.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "Throttle.h"
#include "configuration.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include "mesh/mesh-pb-constants.h"
#include "pb_decode.h"
#include "pb_encode.h"
#include <errno.h>
#include <fcntl.h>
#include <pty.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
DMShellModule *dmShellModule;
namespace
{
constexpr uint16_t PTY_COLS_DEFAULT = 120;
constexpr uint16_t PTY_ROWS_DEFAULT = 40;
constexpr size_t MAX_MESSAGE_SIZE = 200;
} // namespace
DMShellModule::DMShellModule()
: SinglePortModule("DMShellModule", meshtastic_PortNum_REMOTE_SHELL_APP), concurrency::OSThread("DMShell", 100)
{
LOG_WARN("DMShell enabled on Portduino: remote shell access is dangerous and intended for trusted debugging only");
}
ProcessMessage DMShellModule::handleReceived(const meshtastic_MeshPacket &mp)
{
meshtastic_RemoteShell frame = meshtastic_RemoteShell_init_zero;
if (!mp.pki_encrypted) {
LOG_WARN("DMShell: ignoring packet without PKI from 0x%x", mp.from);
return ProcessMessage::STOP;
}
if (!parseFrame(mp, frame)) {
LOG_WARN("DMShell: ignoring malformed frame");
return ProcessMessage::STOP;
}
if (frame.op == meshtastic_RemoteShell_OpCode_ACK) {
if (session.active && frame.session_id == session.sessionId && getFrom(&mp) == session.peer && frame.last_rx_seq > 0) {
resendFramesFrom(frame.last_rx_seq + 1);
}
return ProcessMessage::CONTINUE;
}
if (frame.op >= 64) {
LOG_WARN("DMShell: ignoring frame with op code %d, seq %d", frame.op, frame.seq);
return ProcessMessage::CONTINUE;
}
if (!isAuthorizedPacket(mp)) {
LOG_WARN("DMShell: unauthorized sender 0x%x, %u", mp.from, frame.op);
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
return ProcessMessage::STOP;
}
if (frame.op == meshtastic_RemoteShell_OpCode_OPEN) {
LOG_WARN("DMShell: received OPEN from 0x%x sessionId=0x%x", mp.from, frame.session_id);
if (!openSession(mp, frame)) {
sendError("open_failed", getFrom(&mp));
}
return ProcessMessage::STOP;
}
if (!session.active || frame.session_id != session.sessionId || getFrom(&mp) != session.peer) {
if (!session.active) {
LOG_WARN("DMShell: no active session, rejecting op %d from 0x%x", frame.op, mp.from);
} else {
LOG_WARN("DMShell: session ID mismatch (got 0x%x expected 0x%x) or peer mismatch (got 0x%x expected 0x%x), rejecting "
"op %d",
frame.session_id, session.sessionId, mp.from, session.peer, frame.op);
}
sendError("invalid_session", getFrom(&mp));
return ProcessMessage::STOP;
}
if (!shouldProcessIncomingFrame(frame)) {
return ProcessMessage::STOP;
}
session.lastActivityMs = millis();
switch (frame.op) {
case meshtastic_RemoteShell_OpCode_INPUT:
if (!writeSessionInput(frame)) {
sendError("input_write_failed");
} else {
uint8_t outBuf[MAX_MESSAGE_SIZE];
const ssize_t bytesRead = read(session.masterFd, outBuf, sizeof(outBuf));
if (bytesRead > 0) {
LOG_WARN("DMShell: read %zd bytes from PTY", bytesRead);
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_OUTPUT,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
};
assert(bytesRead <= sizeof(frame.payload.bytes));
memcpy(frame.payload.bytes, outBuf, bytesRead);
frame.payload.size = bytesRead;
sendFrameToPeer(session.peer, frame, true);
session.lastActivityMs = millis();
}
}
break;
case meshtastic_RemoteShell_OpCode_RESIZE:
if (frame.rows > 0 && frame.cols > 0) {
struct winsize ws = {};
ws.ws_row = frame.rows;
ws.ws_col = frame.cols;
if (session.masterFd >= 0) {
ioctl(session.masterFd, TIOCSWINSZ, &ws);
}
}
break;
case meshtastic_RemoteShell_OpCode_PING: {
uint32_t peerLastRxSeq = frame.ack_seq;
if (frame.last_rx_seq > 0) {
peerLastRxSeq = frame.last_rx_seq;
}
const uint32_t nextMissingForPeer = peerLastRxSeq + 1;
if (nextMissingForPeer > 0 && nextMissingForPeer < session.nextTxSeq) {
resendFramesFrom(nextMissingForPeer);
}
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_PONG,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
.last_tx_seq = session.nextTxSeq > 0 ? session.nextTxSeq - 1 : 0,
.last_rx_seq = session.lastAckedRxSeq,
};
frame.payload.size = 0;
sendFrameToPeer(session.peer, frame, true);
break;
}
case meshtastic_RemoteShell_OpCode_CLOSE:
closeSession("peer_close", true);
break;
default:
sendError("unsupported_op");
break;
}
return ProcessMessage::STOP;
}
int32_t DMShellModule::runOnce()
{
processPendingChildReap();
if (!session.active) {
return 100;
}
reapChildIfExited();
if (!session.active) {
return 100;
}
if (Throttle::isWithinTimespanMs(session.lastActivityMs, SESSION_IDLE_TIMEOUT_MS) == false) {
closeSession("idle_timeout", true);
return 100;
}
if (RadioLibInterface::instance->packetsInTxQueue() > 1) {
return 50;
}
uint8_t outBuf[MAX_MESSAGE_SIZE];
while (session.masterFd >= 0) {
const ssize_t bytesRead = read(session.masterFd, outBuf, sizeof(outBuf));
if (bytesRead > 0) {
LOG_WARN("DMShell: read %zd bytes from PTY", bytesRead);
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_OUTPUT,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
};
assert(bytesRead <= sizeof(frame.payload.bytes));
memcpy(frame.payload.bytes, outBuf, bytesRead);
frame.payload.size = bytesRead;
sendFrameToPeer(session.peer, frame, true);
session.lastActivityMs = millis();
// continue;
// do we want to ack every data message, and only send the next on ack?
// would require some retry logic. Maybe re-use the wantAck bit
return 50;
}
if (bytesRead == 0) {
closeSession("pty_eof", true);
break;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
LOG_WARN("DMShell: PTY read error errno=%d", errno);
closeSession("pty_read_error", true);
break;
}
return 100;
}
bool DMShellModule::parseFrame(const meshtastic_MeshPacket &mp, meshtastic_RemoteShell &outFrame)
{
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
return false;
}
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_RemoteShell_fields, &outFrame)) {
LOG_INFO("Received a DMShell message");
} else {
LOG_ERROR("Error decoding DMShell message!");
return false;
}
return true;
}
bool DMShellModule::isAuthorizedPacket(const meshtastic_MeshPacket &mp) const
{
if (mp.from == 0) {
return !config.security.is_managed;
}
const meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
return config.security.admin_channel_enabled;
}
if (mp.pki_encrypted) {
for (uint8_t i = 0; i < 3; ++i) {
if (config.security.admin_key[i].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[i].bytes, 32) == 0) {
return true;
}
}
}
return false;
}
bool DMShellModule::openSession(const meshtastic_MeshPacket &mp, const meshtastic_RemoteShell &frame)
{
if (session.active) {
closeSession("preempted", false);
}
int masterFd = -1;
struct winsize ws = {};
if (frame.rows > 0) {
ws.ws_row = frame.rows;
} else {
ws.ws_row = PTY_ROWS_DEFAULT;
}
if (frame.cols > 0) {
ws.ws_col = frame.cols;
} else {
ws.ws_col = PTY_COLS_DEFAULT;
}
const pid_t childPid = forkpty(&masterFd, nullptr, nullptr, &ws);
if (childPid < 0) {
LOG_ERROR("DMShell: forkpty failed errno=%d", errno);
return false;
}
if (childPid == 0) {
const char *shell = getenv("SHELL");
if (!shell || !*shell) {
shell = "/bin/sh";
}
execl(shell, shell, "-i", static_cast<char *>(nullptr));
_exit(127);
}
const int flags = fcntl(masterFd, F_GETFL, 0);
if (flags >= 0) {
fcntl(masterFd, F_SETFL, flags | O_NONBLOCK);
}
session.active = true;
session.sessionId = (frame.session_id != 0) ? frame.session_id : static_cast<uint32_t>(random(1, 0x7fffffff));
session.peer = getFrom(&mp);
session.channel = mp.channel;
session.masterFd = masterFd;
session.childPid = childPid;
session.nextTxSeq = 1;
session.lastAckedRxSeq = frame.seq;
session.nextExpectedRxSeq = frame.seq + 1;
session.highestSeenRxSeq = frame.seq;
session.lastActivityMs = millis();
meshtastic_RemoteShell newFrame = {
.op = meshtastic_RemoteShell_OpCode_OPEN_OK,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = frame.seq,
.cols = ws.ws_col,
.rows = ws.ws_row,
.flags = 0,
};
newFrame.payload.size = 0;
sendFrameToPeer(session.peer, newFrame, true);
LOG_INFO("DMShell: opened session=0x%x peer=0x%x pid=%d", session.sessionId, session.peer, session.childPid);
return true;
}
bool DMShellModule::writeSessionInput(const meshtastic_RemoteShell &frame)
{
if (session.masterFd < 0) {
return false;
}
if (frame.payload.size == 0) {
return true;
}
const ssize_t bytesWritten = write(session.masterFd, frame.payload.bytes, frame.payload.size);
return bytesWritten >= 0;
}
void DMShellModule::closeSession(const char *reason, bool notifyPeer)
{
if (!session.active) {
return;
}
if (notifyPeer) {
const size_t reasonLen = strnlen(reason, 256);
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_CLOSED,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
};
assert(reasonLen <= sizeof(frame.payload.bytes));
memcpy(frame.payload.bytes, reason, reasonLen);
frame.payload.size = reasonLen;
sendFrameToPeer(session.peer, frame, true);
}
if (session.masterFd >= 0) {
close(session.masterFd);
session.masterFd = -1;
}
if (session.childPid > 0) {
// Run this to avoid forgetting a child
processPendingChildReap();
if (kill(session.childPid, SIGTERM) < 0 && errno != ESRCH) {
LOG_WARN("DMShell: failed to send SIGTERM to pid=%d errno=%d", session.childPid, errno);
}
pendingChildPid = session.childPid;
session.childPid = -1;
}
LOG_INFO("DMShell: closed session=0x%x reason=%s", session.sessionId, reason);
session = DMShellSession{};
}
void DMShellModule::reapChildIfExited()
{
if (!session.active || session.childPid <= 0) {
return;
}
int status = 0;
const pid_t result = waitpid(session.childPid, &status, WNOHANG);
if (result == session.childPid) {
closeSession("shell_exited", true);
}
}
void DMShellModule::processPendingChildReap()
{
if (pendingChildPid <= 0) {
return;
}
int status = 0;
const pid_t result = waitpid(pendingChildPid, &status, WNOHANG);
if (result == pendingChildPid || (result < 0 && errno == ECHILD)) {
pendingChildPid = -1;
return;
}
if (result < 0) {
LOG_WARN("DMShell: waitpid failed for pid=%d errno=%d", pendingChildPid, errno);
pendingChildPid = -1;
return;
}
if (pendingChildPid > 0) {
if (kill(pendingChildPid, SIGKILL) < 0 && errno != ESRCH) {
LOG_WARN("DMShell: failed to send SIGKILL to pid=%d errno=%d", pendingChildPid, errno);
}
pendingChildPid = -1;
}
}
void DMShellModule::rememberSentFrame(meshtastic_RemoteShell frame)
{
if (frame.seq == 0 || frame.op == meshtastic_RemoteShell_OpCode_ACK) {
return;
}
auto &entry = session.txHistory[session.txHistoryNext];
entry.valid = true;
entry.op = frame.op;
entry.sessionId = frame.session_id;
entry.seq = frame.seq;
entry.ackSeq = frame.ack_seq;
entry.cols = frame.cols;
entry.rows = frame.rows;
entry.flags = frame.flags;
entry.payloadLen = frame.payload.size;
if (frame.payload.size > 0) {
memcpy(entry.payload, frame.payload.bytes, frame.payload.size);
}
session.txHistoryNext = (session.txHistoryNext + 1) % session.txHistory.size();
}
void DMShellModule::resendFramesFrom(uint32_t startSeq)
{
if (startSeq == 0) {
return;
}
DMShellSession::SentFrame *match = nullptr;
for (auto &entry : session.txHistory) {
if (!entry.valid || entry.seq != startSeq) {
continue;
}
match = &entry;
break;
}
if (!match) {
LOG_WARN("DMShell: replay request for seq=%u not found in history", startSeq);
return;
}
LOG_INFO("DMShell: replaying frame seq=%u op=%d", match->seq, match->op);
meshtastic_RemoteShell frame = {
.op = match->op,
.session_id = match->sessionId,
.seq = match->seq,
.ack_seq = match->ackSeq,
.cols = match->cols,
.rows = match->rows,
.flags = match->flags,
};
assert(match->payloadLen <= sizeof(frame.payload.bytes));
memcpy(frame.payload.bytes, match->payload, match->payloadLen);
frame.payload.size = match->payloadLen;
sendFrameToPeer(session.peer, frame, false);
}
void DMShellModule::sendAck(uint32_t replayFromSeq)
{
if (replayFromSeq > 0) {
LOG_WARN("DMShell: requesting replay from seq=%u", replayFromSeq);
}
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_ACK,
.session_id = session.sessionId,
.seq = 0,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
.last_rx_seq = replayFromSeq - 1,
};
frame.payload.size = 0;
sendFrameToPeer(session.peer, frame, false);
}
bool DMShellModule::shouldProcessIncomingFrame(const meshtastic_RemoteShell &frame)
{
if (frame.seq == 0) {
return true;
}
if (frame.seq < session.nextExpectedRxSeq) {
if (session.highestSeenRxSeq >= session.nextExpectedRxSeq) {
sendAck(session.nextExpectedRxSeq);
} else {
sendAck();
}
return false;
}
if (frame.seq > session.nextExpectedRxSeq) {
if (frame.seq > session.highestSeenRxSeq) {
session.highestSeenRxSeq = frame.seq;
}
sendAck(session.nextExpectedRxSeq);
return false;
}
session.lastAckedRxSeq = frame.seq;
session.nextExpectedRxSeq = frame.seq + 1;
if (frame.seq > session.highestSeenRxSeq) {
session.highestSeenRxSeq = frame.seq;
}
if (session.highestSeenRxSeq >= session.nextExpectedRxSeq) {
sendAck(session.nextExpectedRxSeq);
} else {
session.highestSeenRxSeq = 0;
}
return true;
}
void DMShellModule::sendFrameToPeer(NodeNum peer, meshtastic_RemoteShell frame, bool remember)
{
meshtastic_MeshPacket *packet = allocDataPacket();
if (!packet) {
return;
}
LOG_WARN("DMShell: building packet op=%u session=0x%x seq=%u payloadLen=%zu", frame.op, frame.session_id, frame.seq,
frame.payload.size);
const size_t encoded = pb_encode_to_bytes(packet->decoded.payload.bytes, sizeof(packet->decoded.payload.bytes),
meshtastic_RemoteShell_fields, &frame);
if (encoded == 0) {
return;
}
packet->decoded.payload.size = encoded;
if (remember) {
rememberSentFrame(frame);
}
packet->to = peer;
packet->hop_limit = 0;
packet->hop_start = 0;
packet->channel = 0;
packet->want_ack = false;
packet->pki_encrypted = true;
packet->priority = meshtastic_MeshPacket_Priority_RELIABLE;
service->sendToMesh(packet);
}
void DMShellModule::sendError(const char *message, NodeNum peer)
{
const size_t len = strnlen(message, MAX_MESSAGE_SIZE);
meshtastic_RemoteShell frame = {
.op = meshtastic_RemoteShell_OpCode_ERROR,
.session_id = session.sessionId,
.seq = session.nextTxSeq++,
.ack_seq = session.lastAckedRxSeq,
.cols = 0,
.rows = 0,
.flags = 0,
};
if (message && len > 0) {
assert(len <= sizeof(frame.payload.bytes));
memcpy(frame.payload.bytes, message, len);
frame.payload.size = len;
}
if (peer == 0) {
peer = session.peer;
}
sendFrameToPeer(peer, frame, true);
}
#endif
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include "MeshModule.h"
#include "Router.h"
#include "SinglePortModule.h"
#include "concurrency/OSThread.h"
#include "configuration.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <Arduino.h>
#include <array>
#include <functional>
#if defined(ARCH_PORTDUINO)
struct DMShellSession {
bool active = false;
uint32_t sessionId = 0;
NodeNum peer = 0;
uint8_t channel = 0;
int masterFd = -1;
int childPid = -1;
uint32_t nextTxSeq = 1;
uint32_t lastAckedRxSeq = 0;
uint32_t nextExpectedRxSeq = 1;
uint32_t highestSeenRxSeq = 0;
uint32_t lastActivityMs = 0;
struct SentFrame {
bool valid = false;
meshtastic_RemoteShell_OpCode op = meshtastic_RemoteShell_OpCode_ERROR;
uint32_t sessionId = 0;
uint32_t seq = 0;
uint32_t ackSeq = 0;
uint32_t cols = 0;
uint32_t rows = 0;
uint32_t flags = 0;
uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0};
size_t payloadLen = 0;
};
std::array<SentFrame, 50> txHistory = {};
size_t txHistoryNext = 0;
};
class DMShellModule : private concurrency::OSThread, public SinglePortModule
{
public:
DMShellModule();
protected:
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
virtual int32_t runOnce() override;
private:
static constexpr uint32_t SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
DMShellSession session;
pid_t pendingChildPid = -1;
bool parseFrame(const meshtastic_MeshPacket &mp, meshtastic_RemoteShell &outFrame);
bool isAuthorizedPacket(const meshtastic_MeshPacket &mp) const;
bool openSession(const meshtastic_MeshPacket &mp, const meshtastic_RemoteShell &frame);
bool shouldProcessIncomingFrame(const meshtastic_RemoteShell &frame);
bool writeSessionInput(const meshtastic_RemoteShell &frame);
void closeSession(const char *reason, bool notifyPeer);
void reapChildIfExited();
void processPendingChildReap();
void rememberSentFrame(meshtastic_RemoteShell frame);
void resendFramesFrom(uint32_t startSeq);
void sendAck(uint32_t replayFromSeq = 0);
void sendFrameToPeer(NodeNum peer, meshtastic_RemoteShell frame, bool remember = true);
void sendError(const char *message, NodeNum peer = 0);
};
extern DMShellModule *dmShellModule;
#endif
+2
View File
@@ -49,6 +49,7 @@
#include "modules/WaypointModule.h"
#endif
#if ARCH_PORTDUINO
#include "modules/DMShell.h"
#include "modules/Telemetry/HostMetrics.h"
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
#include "modules/StoreForwardModule.h"
@@ -195,6 +196,7 @@ void setupModules()
#endif
#if ARCH_PORTDUINO
new HostMetricsModule();
dmShellModule = new DMShellModule();
#endif
#if HAS_TELEMETRY
new DeviceTelemetryModule();
+2
View File
@@ -350,6 +350,8 @@ inline bool isConnectedToNetwork()
return WiFi.isConnected();
#elif HAS_ETHERNET
return Ethernet.linkStatus() == LinkON;
#elif defined(ARCH_PORTDUINO)
return true;
#else
return false;
#endif
+61
View File
@@ -127,6 +127,60 @@ void test_client_uses_public_channel_minimums()
TEST_ASSERT_EQUAL_UINT32(60 * 60, position);
}
// --- Saturation/clamp tests for getConfiguredOrDefaultMs[Scaled] ---
// These guard the INT32_MAX clamp added to avoid uint32 wrap of secs*1000 and
// to keep results safe to cast to int32_t for OSThread runOnce returns.
void test_ms_below_threshold()
{
// Ordinary value passes through unchanged.
TEST_ASSERT_EQUAL_UINT32(60000U, Default::getConfiguredOrDefaultMs(60, 0));
}
void test_ms_at_threshold()
{
// INT32_MAX / 1000 = 2,147,483 — largest secs that does not clamp.
TEST_ASSERT_EQUAL_UINT32(2147483000U, Default::getConfiguredOrDefaultMs(2147483U, 0));
}
void test_ms_just_above_threshold()
{
// One second over the boundary must saturate, not wrap.
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(INT32_MAX), Default::getConfiguredOrDefaultMs(2147484U, 0));
}
void test_ms_uint32_max()
{
// default_sds_secs == UINT32_MAX on non-routers must not wrap.
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(INT32_MAX), Default::getConfiguredOrDefaultMs(UINT32_MAX, 0));
}
void test_ms_default_clamps()
{
// Clamp also applies when the default-arg path is taken (configured == 0).
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(INT32_MAX), Default::getConfiguredOrDefaultMs(0, UINT32_MAX));
}
void test_ms_result_is_int32_safe()
{
// Regression guard for runOnce returns: cast to int32_t must not go negative.
int32_t result = static_cast<int32_t>(Default::getConfiguredOrDefaultMs(UINT32_MAX, 0));
TEST_ASSERT_GREATER_OR_EQUAL_INT32(0, result);
}
void test_scaled_overflow_saturates()
{
// long_fast (SF11/BW250) with a 24h base and heavy congestion overflows
// the uint32 result without the double-precision guard. Must saturate.
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
config.lora.use_preset = false;
config.lora.spread_factor = 11;
config.lora.bandwidth = 250;
uint32_t res = Default::getConfiguredOrDefaultMsScaled(0, ONE_DAY, 1000);
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(INT32_MAX), res);
}
void setup()
{
// Small delay to match other test mains
@@ -140,6 +194,13 @@ void setup()
RUN_TEST(test_router_uses_router_minimums);
RUN_TEST(test_router_late_uses_router_minimums);
RUN_TEST(test_client_uses_public_channel_minimums);
RUN_TEST(test_ms_below_threshold);
RUN_TEST(test_ms_at_threshold);
RUN_TEST(test_ms_just_above_threshold);
RUN_TEST(test_ms_uint32_max);
RUN_TEST(test_ms_default_clamps);
RUN_TEST(test_ms_result_is_int32_safe);
RUN_TEST(test_scaled_overflow_saturates);
exit(UNITY_END());
}
@@ -0,0 +1,97 @@
#include "../test_helpers.h"
#include "mesh/mesh-pb-constants.h"
namespace
{
struct BytesDecodeState {
uint8_t *buffer;
size_t capacity;
size_t length;
};
struct BytesEncodeState {
const uint8_t *buffer;
size_t length;
};
bool decodeBytesField(pb_istream_t *stream, const pb_field_iter_t *field, void **arg)
{
(void)field;
auto *state = static_cast<BytesDecodeState *>(*arg);
if (!state) {
return false;
}
const size_t fieldLen = stream->bytes_left;
if (fieldLen > state->capacity) {
return false;
}
if (!pb_read(stream, state->buffer, fieldLen)) {
return false;
}
state->length = fieldLen;
return true;
}
bool encodeBytesField(pb_ostream_t *stream, const pb_field_iter_t *field, void *const *arg)
{
auto *state = static_cast<const BytesEncodeState *>(*arg);
if (!state || !state->buffer || state->length == 0) {
return true;
}
if (!pb_encode_tag_for_field(stream, field)) {
return false;
}
return pb_encode_string(stream, state->buffer, state->length);
}
void assert_dmshell_roundtrip(meshtastic_RemoteShell_OpCode op, uint32_t sessionId, uint32_t seq, const uint8_t *payload,
size_t payloadLen, uint32_t cols = 0, uint32_t rows = 0)
{
meshtastic_RemoteShell tx = meshtastic_RemoteShell_init_zero;
tx.op = op;
tx.session_id = sessionId;
tx.seq = seq;
tx.cols = cols;
tx.rows = rows;
uint8_t encoded[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0};
size_t encodedLen = pb_encode_to_bytes(encoded, sizeof(encoded), meshtastic_RemoteShell_fields, &tx);
TEST_ASSERT_GREATER_THAN_UINT32(0, encodedLen);
meshtastic_RemoteShell rx = meshtastic_RemoteShell_init_zero;
TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedLen, meshtastic_RemoteShell_fields, &rx));
TEST_ASSERT_EQUAL(op, rx.op);
TEST_ASSERT_EQUAL_UINT32(sessionId, rx.session_id);
TEST_ASSERT_EQUAL_UINT32(seq, rx.seq);
TEST_ASSERT_EQUAL_UINT32(cols, rx.cols);
TEST_ASSERT_EQUAL_UINT32(rows, rx.rows);
}
} // namespace
void test_dmshell_open_roundtrip()
{
assert_dmshell_roundtrip(meshtastic_RemoteShell_OpCode_OPEN, 0x101, 1, nullptr, 0, 120, 40);
}
void test_dmshell_input_roundtrip()
{
const uint8_t payload[] = {'l', 's', '\n'};
assert_dmshell_roundtrip(meshtastic_RemoteShell_OpCode_INPUT, 0x202, 2, payload, sizeof(payload));
}
void test_dmshell_resize_roundtrip()
{
assert_dmshell_roundtrip(meshtastic_RemoteShell_OpCode_RESIZE, 0x303, 3, nullptr, 0, 180, 55);
}
void test_dmshell_close_roundtrip()
{
const uint8_t reason[] = {'b', 'y', 'e'};
assert_dmshell_roundtrip(meshtastic_RemoteShell_OpCode_CLOSE, 0x404, 4, reason, sizeof(reason));
}
@@ -19,6 +19,10 @@ void test_telemetry_environment_metrics_complete_coverage();
void test_telemetry_environment_metrics_unset_fields();
void test_encrypted_packet_serialization();
void test_empty_encrypted_packet();
void test_dmshell_open_roundtrip();
void test_dmshell_input_roundtrip();
void test_dmshell_resize_roundtrip();
void test_dmshell_close_roundtrip();
void setup()
{
@@ -52,6 +56,12 @@ void setup()
RUN_TEST(test_encrypted_packet_serialization);
RUN_TEST(test_empty_encrypted_packet);
// DMShell protobuf transport tests
RUN_TEST(test_dmshell_open_roundtrip);
RUN_TEST(test_dmshell_input_roundtrip);
RUN_TEST(test_dmshell_resize_roundtrip);
RUN_TEST(test_dmshell_close_roundtrip);
UNITY_END();
}