mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-22 03:02:08 +10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccb2c918f | ||
|
|
6b49b7228a | ||
|
|
c194666885 | ||
|
|
d7cb5d7885 | ||
|
|
ffd144da83 | ||
|
|
1e8c9e7071 | ||
|
|
3c83e01d0e | ||
|
|
dc3947117e | ||
|
|
87d0850f95 | ||
|
|
5831952636 | ||
|
|
a6d61413c3 | ||
|
|
e393a5c410 | ||
|
|
8f2ecbdb4d | ||
|
|
6c28d11cee | ||
|
|
69f1b502cc | ||
|
|
3a498fbbe4 | ||
|
|
8f7dea0580 | ||
|
|
322f0262a8 | ||
|
|
00762393cf | ||
|
|
866c89f801 | ||
|
|
8c248927c8 | ||
|
|
188d895eb4 | ||
|
|
6f476f3475 | ||
|
|
50e1fe88e8 | ||
|
|
f9bedd8adc | ||
|
|
5a619c9031 | ||
|
|
8d3f9222ff | ||
|
|
f5335f22ea | ||
|
|
4a3f449555 | ||
|
|
608713470b | ||
|
|
f475be19c6 | ||
|
|
27cc76d5ed |
@@ -575,8 +575,6 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
|
|||||||
|
|
||||||
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
|
`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
|
||||||
|
|
||||||
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
|
|
||||||
|
|
||||||
### Hardware test suite (`mcp-server/run-tests.sh`)
|
### Hardware test suite (`mcp-server/run-tests.sh`)
|
||||||
|
|
||||||
The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
|
The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
|
||||||
|
|||||||
@@ -32,15 +32,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
working-directory: meshtasticd
|
working-directory: meshtasticd
|
||||||
run: |
|
run: |
|
||||||
# Build-tools (notably platformio) come from the Meshtastic project
|
|
||||||
# on the OpenSUSE Build Service:
|
|
||||||
# https://build.opensuse.org/project/show/network:Meshtastic:build-tools
|
|
||||||
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \
|
|
||||||
| sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list
|
|
||||||
curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \
|
|
||||||
| gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null
|
|
||||||
sudo apt-get update -y --fix-missing
|
sudo apt-get update -y --fix-missing
|
||||||
sudo apt-get install -y build-essential devscripts equivs
|
sudo apt-get install -y software-properties-common build-essential devscripts equivs
|
||||||
|
sudo add-apt-repository ppa:meshtastic/build-tools -y
|
||||||
|
sudo apt-get update -y --fix-missing
|
||||||
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
|
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
|
||||||
|
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
brew update
|
brew update
|
||||||
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius
|
brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config
|
||||||
|
|
||||||
- name: Get release version string
|
- name: Get release version string
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ plugins:
|
|||||||
uri: https://github.com/trunk-io/plugins
|
uri: https://github.com/trunk-io/plugins
|
||||||
lint:
|
lint:
|
||||||
enabled:
|
enabled:
|
||||||
- checkov@3.2.526
|
- checkov@3.2.525
|
||||||
- renovate@43.150.0
|
- renovate@43.150.0
|
||||||
- prettier@3.8.3
|
- prettier@3.8.3
|
||||||
- trufflehog@3.95.2
|
- trufflehog@3.95.2
|
||||||
|
|||||||
@@ -127,11 +127,10 @@ Sequence these; don't parallelize on the same port.
|
|||||||
## Environment variables (test harness)
|
## Environment variables (test harness)
|
||||||
|
|
||||||
| Var | Purpose |
|
| Var | Purpose |
|
||||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `MESHTASTIC_MCP_ENV_<ROLE>` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. |
|
| `MESHTASTIC_MCP_ENV_<ROLE>` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. |
|
||||||
| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp-<user>-<host>`. |
|
| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp-<user>-<host>`. |
|
||||||
| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. |
|
| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. |
|
||||||
| `MESHTASTIC_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. |
|
|
||||||
| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. |
|
| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. |
|
||||||
| `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. |
|
| `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. |
|
||||||
| `MESHTASTIC_UHUBCTL_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
|
| `MESHTASTIC_UHUBCTL_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
|
||||||
|
|||||||
+1
-3
@@ -3,17 +3,15 @@
|
|||||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||||
|
|
||||||
FROM debian:trixie AS builder
|
FROM python:3.14-slim-trixie AS builder
|
||||||
ARG PIO_ENV=native
|
ARG PIO_ENV=native
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
ENV TZ=Etc/UTC
|
ENV TZ=Etc/UTC
|
||||||
|
|
||||||
# Install Dependencies
|
# Install Dependencies
|
||||||
ENV PIP_ROOT_USER_ACTION=ignore
|
ENV PIP_ROOT_USER_ACTION=ignore
|
||||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
|
||||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||||
curl wget g++ zip git ca-certificates pkg-config \
|
curl wget g++ zip git ca-certificates pkg-config \
|
||||||
python3-pip python3-grpc-tools \
|
|
||||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||||
|
|||||||
+2
-8
@@ -4,18 +4,12 @@
|
|||||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||||
|
|
||||||
# Ensure the Alpine version is updated in both stages of the container!
|
# Ensure the Alpine version is updated in both stages of the container!
|
||||||
FROM alpine:3.23 AS builder
|
FROM python:3.14-alpine3.23 AS builder
|
||||||
ARG PIO_ENV=native
|
ARG PIO_ENV=native
|
||||||
|
|
||||||
# Enable Alpine community repository (for 'py3-grpcio-tools')
|
|
||||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories
|
|
||||||
|
|
||||||
# Install Dependencies
|
|
||||||
ENV PIP_ROOT_USER_ACTION=ignore
|
ENV PIP_ROOT_USER_ACTION=ignore
|
||||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
|
||||||
RUN apk --no-cache add \
|
RUN apk --no-cache add \
|
||||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||||
py3-pip py3-grpcio-tools \
|
|
||||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||||
|
|||||||
@@ -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
-59
@@ -167,7 +167,7 @@ rather than auto-`sudo`'ing mid-run.
|
|||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Var | Default | Purpose |
|
| Var | Default | Purpose |
|
||||||
| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||||
@@ -175,64 +175,6 @@ rather than auto-`sudo`'ing mid-run.
|
|||||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||||
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
|
|
||||||
|
|
||||||
## TCP / native-host nodes
|
|
||||||
|
|
||||||
The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd`
|
|
||||||
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
|
|
||||||
The daemon exposes the meshtastic TCP API on port `4403` rather than a USB
|
|
||||||
serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
|
|
||||||
# for full Homebrew prereqs and CH341 LoRa-adapter setup).
|
|
||||||
pio run -e native-macos
|
|
||||||
~/.meshtasticd/meshtasticd
|
|
||||||
|
|
||||||
# 2. Point the MCP server at it.
|
|
||||||
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
|
|
||||||
```
|
|
||||||
|
|
||||||
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
|
|
||||||
USB adapter's serial-number / product strings. Many cheap CH341 dongles
|
|
||||||
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
|
|
||||||
and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address
|
|
||||||
not allowed!`. Set the MAC explicitly in `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Under General:
|
|
||||||
MACAddress: 02:CA:FE:BA:BE:01
|
|
||||||
```
|
|
||||||
|
|
||||||
Use a locally-administered address (first byte's second-LSB set, e.g.
|
|
||||||
`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI.
|
|
||||||
|
|
||||||
There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in
|
|
||||||
`meshtasticd --help`, but it is **currently broken** in
|
|
||||||
`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the
|
|
||||||
function strips colons from its parameter but then reads bytes from the
|
|
||||||
global `portduino_config.mac_address`, so `--hwid` is silently overridden
|
|
||||||
when `MACAddress:` is also set, and crashes the daemon (uncaught
|
|
||||||
`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML
|
|
||||||
form until that's fixed upstream.
|
|
||||||
|
|
||||||
`list_devices` will surface the daemon as `tcp://localhost:4403` with
|
|
||||||
`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`,
|
|
||||||
`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs
|
|
||||||
auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"`
|
|
||||||
explicitly to target a different daemon.
|
|
||||||
|
|
||||||
**Tools that don't apply to a TCP/native node** (no USB hardware to operate
|
|
||||||
on) raise a clear `ConnectionError` rather than failing mysteriously:
|
|
||||||
`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`,
|
|
||||||
`serial_open` (use info/admin tools directly), and the vendor escape hatches
|
|
||||||
`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env
|
|
||||||
similarly raises — there's no upload step; use `build` and run the binary
|
|
||||||
directly.
|
|
||||||
|
|
||||||
The pytest harness in `tests/` still assumes USB-attached devices per role —
|
|
||||||
TCP-aware fixtures are not part of this surface yet.
|
|
||||||
|
|
||||||
## Hardware Test Suite
|
## Hardware Test Suite
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Context manager for meshtastic interface connections (serial + TCP).
|
"""Context manager for meshtastic.SerialInterface connections.
|
||||||
|
|
||||||
Every info/admin tool goes through `connect(port)` so we have a single place
|
Every info/admin tool goes through `connect(port)` so we have a single place
|
||||||
that:
|
that:
|
||||||
@@ -6,16 +6,8 @@ that:
|
|||||||
- fails fast if a serial_session is already holding the port,
|
- fails fast if a serial_session is already holding the port,
|
||||||
- guarantees `.close()` is called, even on exception.
|
- guarantees `.close()` is called, even on exception.
|
||||||
|
|
||||||
Two transports:
|
The `SerialInterface` blocks on construction waiting for the node database;
|
||||||
- Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`.
|
that's fine for v1 since every tool is a short-lived request.
|
||||||
- TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino
|
|
||||||
headless build) addressed as `tcp://host[:port]` (default port 4403).
|
|
||||||
Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is
|
|
||||||
set, so `resolve_port(None)` auto-selects it like a USB candidate.
|
|
||||||
|
|
||||||
Both `SerialInterface` and `TCPInterface` block on construction waiting for
|
|
||||||
the node database; that's fine for v1 since every tool is a short-lived
|
|
||||||
request.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,107 +17,20 @@ from typing import Iterator
|
|||||||
|
|
||||||
from . import devices, registry
|
from . import devices, registry
|
||||||
|
|
||||||
DEFAULT_TCP_PORT = 4403
|
|
||||||
TCP_SCHEME = "tcp://"
|
|
||||||
TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST"
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionError(RuntimeError):
|
class ConnectionError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def is_tcp_port(port: str | None) -> bool:
|
|
||||||
return bool(port) and port.startswith(TCP_SCHEME)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_tcp_port(port: str) -> tuple[str, int]:
|
|
||||||
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
|
|
||||||
|
|
||||||
Validates host shape (non-empty, no path separators) and port range
|
|
||||||
(1..65535). Raises `ConnectionError` on malformed input — never lets
|
|
||||||
a raw `ValueError` bubble up to a tool surface.
|
|
||||||
"""
|
|
||||||
if not port.startswith(TCP_SCHEME):
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'."
|
|
||||||
)
|
|
||||||
rest = port[len(TCP_SCHEME) :]
|
|
||||||
if ":" in rest:
|
|
||||||
host, port_str = rest.rsplit(":", 1)
|
|
||||||
try:
|
|
||||||
tcp_port = int(port_str)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer."
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
host, tcp_port = rest, DEFAULT_TCP_PORT
|
|
||||||
if not host:
|
|
||||||
raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.")
|
|
||||||
if any(c in host for c in ("/", "\\")):
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
|
|
||||||
"separator. TCP hostnames cannot contain '/' or '\\' — did you "
|
|
||||||
"pass a serial port path or a Windows drive path by mistake?"
|
|
||||||
)
|
|
||||||
if not (1 <= tcp_port <= 65535):
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range "
|
|
||||||
"(must be 1..65535)."
|
|
||||||
)
|
|
||||||
return host, tcp_port
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_tcp_endpoint(endpoint: str) -> str:
|
|
||||||
r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical
|
|
||||||
`tcp://host:port` form. One place that owns the lock-key shape.
|
|
||||||
|
|
||||||
Defers all validation to `parse_tcp_port`, so path-like inputs
|
|
||||||
(`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports,
|
|
||||||
and out-of-range ports raise `ConnectionError` here too.
|
|
||||||
"""
|
|
||||||
if endpoint.startswith(TCP_SCHEME):
|
|
||||||
canonical = endpoint
|
|
||||||
elif ":" in endpoint:
|
|
||||||
canonical = f"{TCP_SCHEME}{endpoint}"
|
|
||||||
else:
|
|
||||||
canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}"
|
|
||||||
host, port = parse_tcp_port(canonical)
|
|
||||||
return f"{TCP_SCHEME}{host}:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
def reject_if_tcp(port: str | None, tool_name: str) -> None:
|
|
||||||
"""Raise if `port` is a TCP endpoint — for tools that need real USB
|
|
||||||
hardware (flash, bootloader, vendor escape hatches, serial monitor).
|
|
||||||
|
|
||||||
Only checks the explicit arg; auto-selection via env var is the caller's
|
|
||||||
responsibility to handle if it matters.
|
|
||||||
"""
|
|
||||||
if is_tcp_port(port):
|
|
||||||
raise ConnectionError(
|
|
||||||
f"{tool_name} is not applicable to TCP/native nodes ({port}). "
|
|
||||||
"This tool requires USB-attached hardware."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_port(port: str | None) -> str:
|
def resolve_port(port: str | None) -> str:
|
||||||
"""Pick a port: explicit > sole likely_meshtastic candidate > error.
|
"""Pick a port: explicit > sole likely_meshtastic candidate > error."""
|
||||||
|
|
||||||
A `tcp://` string passes through (after canonicalization). When `port`
|
|
||||||
is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST`
|
|
||||||
is consulted via `devices.list_devices()`.
|
|
||||||
"""
|
|
||||||
if port:
|
if port:
|
||||||
if is_tcp_port(port):
|
|
||||||
return normalize_tcp_endpoint(port)
|
|
||||||
return port
|
return port
|
||||||
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
|
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
"No Meshtastic devices detected. Plug one in, set "
|
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
|
||||||
f"{TCP_HOST_ENV}=<host[:port]> for a meshtasticd daemon, "
|
"Run `list_devices` with include_unknown=True to see all serial ports."
|
||||||
"or pass `port` explicitly. Run `list_devices` with "
|
|
||||||
"include_unknown=True to see all serial ports."
|
|
||||||
)
|
)
|
||||||
if len(candidates) > 1:
|
if len(candidates) > 1:
|
||||||
ports = ", ".join(c["port"] for c in candidates)
|
ports = ", ".join(c["port"] for c in candidates)
|
||||||
@@ -138,62 +43,17 @@ def resolve_port(port: str | None) -> str:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||||
"""Open a meshtastic interface (serial or TCP) and always close it.
|
"""Open a `meshtastic.SerialInterface` and always close it.
|
||||||
|
|
||||||
For serial: raises `ConnectionError` immediately if another serial
|
Raises `ConnectionError` immediately if another serial session holds the
|
||||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
port (a `pio device monitor` in `serial_sessions/`, for instance).
|
||||||
For TCP: no exclusive-access requirement, so the serial-session check
|
|
||||||
is skipped — but the `port_lock` still serializes parallel `connect()`
|
|
||||||
calls to the same daemon endpoint.
|
|
||||||
|
|
||||||
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
|
|
||||||
and `TCPInterface(timeout=...)`. The meshtastic library uses the value
|
|
||||||
as the reply-wait deadline for `localNode.waitForConfig()` during
|
|
||||||
construction and for any subsequent admin RPC. `int()`-converted at
|
|
||||||
the boundary because the upstream API expects whole seconds.
|
|
||||||
"""
|
"""
|
||||||
resolved = resolve_port(port)
|
|
||||||
timeout = int(timeout_s)
|
|
||||||
|
|
||||||
if is_tcp_port(resolved):
|
|
||||||
from meshtastic.tcp_interface import (
|
|
||||||
TCPInterface, # type: ignore[import-untyped]
|
|
||||||
)
|
|
||||||
|
|
||||||
host, tcp_port = parse_tcp_port(resolved)
|
|
||||||
lock = registry.port_lock(resolved)
|
|
||||||
if not lock.acquire(blocking=False):
|
|
||||||
raise ConnectionError(
|
|
||||||
f"TCP endpoint {resolved} is busy — another device operation "
|
|
||||||
"is in flight. Retry shortly."
|
|
||||||
)
|
|
||||||
|
|
||||||
iface = None
|
|
||||||
try:
|
|
||||||
iface = TCPInterface(
|
|
||||||
hostname=host,
|
|
||||||
portNumber=tcp_port,
|
|
||||||
connectNow=True,
|
|
||||||
noProto=False,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
yield iface
|
|
||||||
finally:
|
|
||||||
if iface is not None:
|
|
||||||
try:
|
|
||||||
iface.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
lock.release()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
from meshtastic.serial_interface import (
|
from meshtastic.serial_interface import (
|
||||||
SerialInterface, # type: ignore[import-untyped]
|
SerialInterface, # type: ignore[import-untyped]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resolved = resolve_port(port)
|
||||||
|
|
||||||
active = registry.active_session_for_port(resolved)
|
active = registry.active_session_for_port(resolved)
|
||||||
if active is not None:
|
if active is not None:
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
@@ -210,12 +70,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
|||||||
|
|
||||||
iface = None
|
iface = None
|
||||||
try:
|
try:
|
||||||
iface = SerialInterface(
|
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
|
||||||
devPath=resolved,
|
|
||||||
connectNow=True,
|
|
||||||
noProto=False,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
yield iface
|
yield iface
|
||||||
finally:
|
finally:
|
||||||
if iface is not None:
|
if iface is not None:
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
"""USB/serial + TCP device discovery.
|
"""USB/serial device discovery.
|
||||||
|
|
||||||
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
|
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
|
||||||
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
|
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
|
||||||
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
|
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
|
||||||
a Meshtastic device" signal.
|
a Meshtastic device" signal.
|
||||||
|
|
||||||
If `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` is set, a synthetic entry for the
|
|
||||||
`meshtasticd` daemon at that endpoint is prepended to the result, so
|
|
||||||
`resolve_port(None)` auto-selects it like a USB candidate.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
@@ -24,45 +19,6 @@ def _to_hex(value: int | None) -> str | None:
|
|||||||
return f"0x{value:04x}"
|
return f"0x{value:04x}"
|
||||||
|
|
||||||
|
|
||||||
def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
|
||||||
"""Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set.
|
|
||||||
|
|
||||||
If the env var is malformed (non-integer port, path-like host, etc.),
|
|
||||||
return an entry with `likely_meshtastic=False` and the parser error in
|
|
||||||
the description, rather than raising — `list_devices` is the diagnostic
|
|
||||||
tool a user reaches for when their env var isn't working, so it must
|
|
||||||
not crash on misconfiguration.
|
|
||||||
"""
|
|
||||||
host = os.environ.get("MESHTASTIC_MCP_TCP_HOST")
|
|
||||||
if not host:
|
|
||||||
return None
|
|
||||||
# Lazy import to avoid a circular dependency (connection imports devices).
|
|
||||||
from . import connection
|
|
||||||
|
|
||||||
try:
|
|
||||||
port = connection.normalize_tcp_endpoint(host)
|
|
||||||
description = "meshtasticd (TCP)"
|
|
||||||
likely = True
|
|
||||||
except connection.ConnectionError as e:
|
|
||||||
# Surface the raw env-var value plus the parser's reason so the
|
|
||||||
# user can see exactly what they set and why it was rejected.
|
|
||||||
# Don't double the scheme if the user already prefixed `tcp://`.
|
|
||||||
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
|
|
||||||
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
|
||||||
likely = False
|
|
||||||
return {
|
|
||||||
"port": port,
|
|
||||||
"vid": None,
|
|
||||||
"pid": None,
|
|
||||||
"description": description,
|
|
||||||
"manufacturer": None,
|
|
||||||
"product": None,
|
|
||||||
"serial_number": None,
|
|
||||||
"likely_meshtastic": likely,
|
|
||||||
"blacklisted": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Return enriched info for serial ports, flagging Meshtastic candidates.
|
"""Return enriched info for serial ports, flagging Meshtastic candidates.
|
||||||
|
|
||||||
@@ -114,22 +70,6 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Append the TCP endpoint (if env var set) and sort everything together.
|
# Stable ordering: likely_meshtastic first, then by port path
|
||||||
tcp_entry = _tcp_endpoint_from_env()
|
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
|
||||||
if tcp_entry is not None:
|
|
||||||
results.append(tcp_entry)
|
|
||||||
|
|
||||||
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
|
|
||||||
# USB (explicit env-var configuration takes precedence over USB
|
|
||||||
# enumeration); then by port path. A misconfigured TCP entry has
|
|
||||||
# likely_meshtastic=False and lands among the other ignored entries —
|
|
||||||
# it does NOT pre-empt real USB devices at the top of the list.
|
|
||||||
results.sort(
|
|
||||||
key=lambda r: (
|
|
||||||
not r["likely_meshtastic"],
|
|
||||||
not r["port"].startswith("tcp://"),
|
|
||||||
r["port"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import Any
|
|||||||
|
|
||||||
import serial
|
import serial
|
||||||
|
|
||||||
from . import boards, config, connection, devices, pio, userprefs
|
from . import boards, config, devices, pio, userprefs
|
||||||
|
|
||||||
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
|
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
|
||||||
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
|
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
|
||||||
@@ -46,18 +46,6 @@ def _require_confirm(confirm: bool, operation: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reject_native_env(env: str, operation: str) -> None:
|
|
||||||
"""`native*` envs build a host executable, not firmware — there's no
|
|
||||||
upload step. The user wants `build` (or just runs the binary directly).
|
|
||||||
"""
|
|
||||||
if env.startswith("native"):
|
|
||||||
raise FlashError(
|
|
||||||
f"{operation} is not applicable for env {env!r}: native envs "
|
|
||||||
"produce a host executable, not flashable firmware. Use `build` "
|
|
||||||
"instead, then run the resulting binary directly."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _artifacts_for(env: str) -> list[Path]:
|
def _artifacts_for(env: str) -> list[Path]:
|
||||||
build_dir = config.firmware_root() / ".pio" / "build" / env
|
build_dir = config.firmware_root() / ".pio" / "build" / env
|
||||||
if not build_dir.is_dir():
|
if not build_dir.is_dir():
|
||||||
@@ -153,8 +141,6 @@ def flash(
|
|||||||
that pio performs will pick up the injected values.
|
that pio performs will pick up the injected values.
|
||||||
"""
|
"""
|
||||||
_require_confirm(confirm, "flash")
|
_require_confirm(confirm, "flash")
|
||||||
_reject_native_env(env, "flash")
|
|
||||||
connection.reject_if_tcp(port, "flash")
|
|
||||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||||
result = pio.run(
|
result = pio.run(
|
||||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||||
@@ -214,7 +200,6 @@ def erase_and_flash(
|
|||||||
in that case) since a cached factory.bin would not reflect the new prefs.
|
in that case) since a cached factory.bin would not reflect the new prefs.
|
||||||
"""
|
"""
|
||||||
_require_confirm(confirm, "erase_and_flash")
|
_require_confirm(confirm, "erase_and_flash")
|
||||||
connection.reject_if_tcp(port, "erase_and_flash")
|
|
||||||
_check_esp32_env(env)
|
_check_esp32_env(env)
|
||||||
|
|
||||||
if userprefs_overrides and skip_build:
|
if userprefs_overrides and skip_build:
|
||||||
@@ -272,7 +257,6 @@ def update_flash(
|
|||||||
overrides are provided we always force a rebuild.
|
overrides are provided we always force a rebuild.
|
||||||
"""
|
"""
|
||||||
_require_confirm(confirm, "update_flash")
|
_require_confirm(confirm, "update_flash")
|
||||||
connection.reject_if_tcp(port, "update_flash")
|
|
||||||
_check_esp32_env(env)
|
_check_esp32_env(env)
|
||||||
|
|
||||||
if userprefs_overrides and skip_build:
|
if userprefs_overrides and skip_build:
|
||||||
@@ -407,7 +391,6 @@ def touch_1200bps(
|
|||||||
|
|
||||||
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
|
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
|
||||||
"""
|
"""
|
||||||
connection.reject_if_tcp(port, "touch_1200bps")
|
|
||||||
before_list = devices.list_devices(include_unknown=True)
|
before_list = devices.list_devices(include_unknown=True)
|
||||||
before_ports = {d["port"] for d in before_list}
|
before_ports = {d["port"] for d in before_list}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from . import config, connection, pio
|
from . import config, pio
|
||||||
|
|
||||||
_TIMEOUT_SHORT = 30
|
_TIMEOUT_SHORT = 30
|
||||||
_TIMEOUT_LONG = 600
|
_TIMEOUT_LONG = 600
|
||||||
@@ -102,7 +102,6 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def esptool_chip_info(port: str) -> dict[str, Any]:
|
def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||||
connection.reject_if_tcp(port, "esptool_chip_info")
|
|
||||||
binary = config.esptool_bin()
|
binary = config.esptool_bin()
|
||||||
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
||||||
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
||||||
@@ -117,7 +116,6 @@ def esptool_chip_info(port: str) -> dict[str, Any]:
|
|||||||
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
||||||
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
||||||
_require_confirm(confirm, "esptool_erase_flash")
|
_require_confirm(confirm, "esptool_erase_flash")
|
||||||
connection.reject_if_tcp(port, "esptool_erase_flash")
|
|
||||||
binary = config.esptool_bin()
|
binary = config.esptool_bin()
|
||||||
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
||||||
# first; if it fails with unknown command, retry old.
|
# first; if it fails with unknown command, retry old.
|
||||||
@@ -136,7 +134,6 @@ def esptool_raw(
|
|||||||
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
||||||
if not args:
|
if not args:
|
||||||
raise ToolError("args must not be empty")
|
raise ToolError("args must not be empty")
|
||||||
connection.reject_if_tcp(port, "esptool_raw")
|
|
||||||
# Find the first non-flag arg (the subcommand).
|
# Find the first non-flag arg (the subcommand).
|
||||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||||
if subcommand and subcommand.replace("-", "_") in {
|
if subcommand and subcommand.replace("-", "_") in {
|
||||||
@@ -159,7 +156,6 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
|
|||||||
|
|
||||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||||
_require_confirm(confirm, "nrfutil_dfu")
|
_require_confirm(confirm, "nrfutil_dfu")
|
||||||
connection.reject_if_tcp(port, "nrfutil_dfu")
|
|
||||||
pkg = Path(package_path).expanduser()
|
pkg = Path(package_path).expanduser()
|
||||||
if not pkg.is_file():
|
if not pkg.is_file():
|
||||||
raise ToolError(f"Package not found: {pkg}")
|
raise ToolError(f"Package not found: {pkg}")
|
||||||
@@ -217,7 +213,6 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
|||||||
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
def picotool_info(port: str | None = None) -> dict[str, Any]:
|
||||||
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
|
||||||
only — picotool auto-detects."""
|
only — picotool auto-detects."""
|
||||||
connection.reject_if_tcp(port, "picotool_info")
|
|
||||||
binary = config.picotool_bin()
|
binary = config.picotool_bin()
|
||||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||||
if res["exit_code"] != 0:
|
if res["exit_code"] != 0:
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ def open_session(
|
|||||||
If `env` is supplied, pio resolves baud and filters from platformio.ini.
|
If `env` is supplied, pio resolves baud and filters from platformio.ini.
|
||||||
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
|
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
|
||||||
"""
|
"""
|
||||||
# Lazy import to avoid circular: registry imports serial_session.
|
|
||||||
from . import connection
|
|
||||||
|
|
||||||
connection.reject_if_tcp(port, "serial_open")
|
|
||||||
args = ["device", "monitor", "--port", port, "--no-reconnect"]
|
args = ["device", "monitor", "--port", port, "--no-reconnect"]
|
||||||
effective_filters: list[str]
|
effective_filters: list[str]
|
||||||
effective_baud: int = baud
|
effective_baud: int = baud
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
"""TCP transport plumbing in connection.py + devices.py.
|
|
||||||
|
|
||||||
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
|
|
||||||
when exercising `connect()`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from meshtastic_mcp import connection, devices
|
|
||||||
|
|
||||||
# ---------- helpers --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsTcpPort:
|
|
||||||
def test_tcp_scheme(self) -> None:
|
|
||||||
assert connection.is_tcp_port("tcp://localhost") is True
|
|
||||||
assert connection.is_tcp_port("tcp://localhost:4403") is True
|
|
||||||
assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True
|
|
||||||
|
|
||||||
def test_serial_paths(self) -> None:
|
|
||||||
assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False
|
|
||||||
assert connection.is_tcp_port("/dev/ttyUSB0") is False
|
|
||||||
assert connection.is_tcp_port("COM3") is False
|
|
||||||
|
|
||||||
def test_empty_or_none(self) -> None:
|
|
||||||
assert connection.is_tcp_port(None) is False
|
|
||||||
assert connection.is_tcp_port("") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseTcpPort:
|
|
||||||
def test_default_port(self) -> None:
|
|
||||||
assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403)
|
|
||||||
|
|
||||||
def test_explicit_port(self) -> None:
|
|
||||||
assert connection.parse_tcp_port("tcp://localhost:9999") == (
|
|
||||||
"localhost",
|
|
||||||
9999,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_ip_with_port(self) -> None:
|
|
||||||
assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == (
|
|
||||||
"192.168.1.50",
|
|
||||||
4403,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeTcpEndpoint:
|
|
||||||
def test_bare_host(self) -> None:
|
|
||||||
assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403"
|
|
||||||
|
|
||||||
def test_host_port(self) -> None:
|
|
||||||
assert (
|
|
||||||
connection.normalize_tcp_endpoint("localhost:5000")
|
|
||||||
== "tcp://localhost:5000"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_full_url(self) -> None:
|
|
||||||
assert (
|
|
||||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999")
|
|
||||||
== "tcp://1.2.3.4:9999"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_idempotent(self) -> None:
|
|
||||||
once = connection.normalize_tcp_endpoint("localhost:4403")
|
|
||||||
twice = connection.normalize_tcp_endpoint(once)
|
|
||||||
assert once == twice == "tcp://localhost:4403"
|
|
||||||
|
|
||||||
def test_path_like_endpoint_rejected(self) -> None:
|
|
||||||
# Serial port paths and Windows drive paths are common config typos
|
|
||||||
# (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject
|
|
||||||
# rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL.
|
|
||||||
with pytest.raises(connection.ConnectionError, match="path separator"):
|
|
||||||
connection.normalize_tcp_endpoint("/dev/cu.foo")
|
|
||||||
with pytest.raises(connection.ConnectionError):
|
|
||||||
connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403")
|
|
||||||
with pytest.raises(connection.ConnectionError):
|
|
||||||
connection.normalize_tcp_endpoint(r"C:\Windows\System32")
|
|
||||||
|
|
||||||
def test_non_integer_port_rejected(self) -> None:
|
|
||||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
|
||||||
connection.normalize_tcp_endpoint("tcp://host:notaport")
|
|
||||||
with pytest.raises(connection.ConnectionError, match="not an integer"):
|
|
||||||
connection.normalize_tcp_endpoint("host:notaport")
|
|
||||||
|
|
||||||
def test_empty_host_rejected(self) -> None:
|
|
||||||
with pytest.raises(connection.ConnectionError, match="empty host"):
|
|
||||||
connection.normalize_tcp_endpoint("tcp://:4403")
|
|
||||||
|
|
||||||
def test_port_out_of_range_rejected(self) -> None:
|
|
||||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
|
||||||
connection.normalize_tcp_endpoint("tcp://host:0")
|
|
||||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
|
||||||
connection.normalize_tcp_endpoint("tcp://host:65536")
|
|
||||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
|
||||||
connection.normalize_tcp_endpoint("host:99999")
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseTcpPortValidation:
|
|
||||||
def test_missing_scheme_rejected(self) -> None:
|
|
||||||
# parse_tcp_port is a low-level helper that requires the scheme.
|
|
||||||
# Misuse should fail loudly rather than silently mis-parsing.
|
|
||||||
with pytest.raises(connection.ConnectionError, match="expected"):
|
|
||||||
connection.parse_tcp_port("localhost:4403")
|
|
||||||
|
|
||||||
def test_negative_port_rejected(self) -> None:
|
|
||||||
with pytest.raises(connection.ConnectionError, match="out of range"):
|
|
||||||
connection.parse_tcp_port("tcp://host:-1")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- reject_if_tcp --------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestRejectIfTcp:
|
|
||||||
def test_rejects_tcp(self) -> None:
|
|
||||||
with pytest.raises(connection.ConnectionError, match="not applicable"):
|
|
||||||
connection.reject_if_tcp("tcp://localhost", "esptool_chip_info")
|
|
||||||
|
|
||||||
def test_passes_through_serial(self) -> None:
|
|
||||||
connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise
|
|
||||||
|
|
||||||
def test_passes_through_none(self) -> None:
|
|
||||||
# None means "auto-detect"; not the explicit-arg case we guard.
|
|
||||||
connection.reject_if_tcp(None, "esptool_chip_info") # no raise
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- resolve_port ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolvePort:
|
|
||||||
def test_explicit_serial_passthrough(self) -> None:
|
|
||||||
assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999"
|
|
||||||
|
|
||||||
def test_explicit_tcp_normalized(self) -> None:
|
|
||||||
assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403"
|
|
||||||
|
|
||||||
def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
with patch.object(devices, "list_devices", return_value=[]):
|
|
||||||
with pytest.raises(
|
|
||||||
connection.ConnectionError, match="No Meshtastic devices"
|
|
||||||
):
|
|
||||||
connection.resolve_port(None)
|
|
||||||
|
|
||||||
def test_no_port_one_candidate_selected(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}]
|
|
||||||
with patch.object(devices, "list_devices", return_value=fake):
|
|
||||||
assert connection.resolve_port(None) == "/dev/cu.usbmodem1"
|
|
||||||
|
|
||||||
def test_no_port_multiple_candidates_errors(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
fake = [
|
|
||||||
{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True},
|
|
||||||
{"port": "/dev/cu.usbmodem2", "likely_meshtastic": True},
|
|
||||||
]
|
|
||||||
with patch.object(devices, "list_devices", return_value=fake):
|
|
||||||
with pytest.raises(connection.ConnectionError, match="Multiple"):
|
|
||||||
connection.resolve_port(None)
|
|
||||||
|
|
||||||
def test_env_var_surfaces_tcp_via_devices(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
|
||||||
# Don't patch list_devices — let the real env-var path run, but stub
|
|
||||||
# the USB enumeration to keep the test hermetic.
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
assert connection.resolve_port(None) == "tcp://localhost:4403"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- devices.list_devices TCP entry --------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestDevicesTcpEntry:
|
|
||||||
def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
ds = devices.list_devices()
|
|
||||||
assert all(not d["port"].startswith("tcp://") for d in ds)
|
|
||||||
|
|
||||||
def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999")
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
ds = devices.list_devices()
|
|
||||||
tcp = [d for d in ds if d["port"].startswith("tcp://")]
|
|
||||||
assert len(tcp) == 1
|
|
||||||
assert tcp[0]["port"] == "tcp://myhost:9999"
|
|
||||||
assert tcp[0]["likely_meshtastic"] is True
|
|
||||||
assert tcp[0]["description"] == "meshtasticd (TCP)"
|
|
||||||
|
|
||||||
def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
ds = devices.list_devices()
|
|
||||||
assert ds, "expected at least the TCP entry"
|
|
||||||
assert ds[0]["port"].startswith("tcp://")
|
|
||||||
|
|
||||||
def test_invalid_env_var_does_not_break_list_devices(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
# `list_devices` is the diagnostic tool reached for when an env var
|
|
||||||
# isn't working — it must not throw on misconfiguration.
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
ds = devices.list_devices(include_unknown=True)
|
|
||||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
|
||||||
assert len(tcp) == 1
|
|
||||||
assert tcp[0]["likely_meshtastic"] is False
|
|
||||||
assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"]
|
|
||||||
assert "not an integer" in tcp[0]["description"]
|
|
||||||
|
|
||||||
def test_invalid_env_var_excluded_from_resolve_port_autodetect(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
# `likely_meshtastic=False` keeps the bad TCP entry out of the
|
|
||||||
# auto-select path — `resolve_port(None)` should still report
|
|
||||||
# "no Meshtastic devices" rather than picking a broken endpoint.
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
with pytest.raises(connection.ConnectionError, match="No Meshtastic"):
|
|
||||||
connection.resolve_port(None)
|
|
||||||
|
|
||||||
def test_invalid_env_var_does_not_double_tcp_scheme(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
# If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`,
|
|
||||||
# the diagnostic entry must surface the raw value as-is rather than
|
|
||||||
# producing `tcp://tcp://host:bad`.
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport")
|
|
||||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
|
||||||
ds = devices.list_devices(include_unknown=True)
|
|
||||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
|
||||||
assert len(tcp) == 1
|
|
||||||
assert tcp[0]["port"] == "tcp://host:notaport"
|
|
||||||
assert "tcp://tcp://" not in tcp[0]["port"]
|
|
||||||
|
|
||||||
def test_invalid_env_var_does_not_pre_empt_real_usb_devices(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
# Sort ordering: a misconfigured TCP env var must NOT take position 0
|
|
||||||
# ahead of real USB candidates. Position 0 is reserved for the highest
|
|
||||||
# rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker
|
|
||||||
# within rank.
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
|
||||||
|
|
||||||
# Stub a USB Meshtastic candidate (Espressif VID, port present in
|
|
||||||
# findPorts).
|
|
||||||
class FakeInfo:
|
|
||||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
|
||||||
self.device = device
|
|
||||||
self.vid = vid
|
|
||||||
self.pid = pid
|
|
||||||
self.description = "Heltec V3"
|
|
||||||
self.manufacturer = "Espressif"
|
|
||||||
self.product = "USB JTAG/serial"
|
|
||||||
self.serial_number = "abc"
|
|
||||||
|
|
||||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
|
||||||
with patch(
|
|
||||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
|
||||||
), patch(
|
|
||||||
"meshtastic.util.findPorts",
|
|
||||||
return_value=["/dev/cu.usbmodem4201"],
|
|
||||||
):
|
|
||||||
ds = devices.list_devices(include_unknown=True)
|
|
||||||
|
|
||||||
assert ds, "expected at least the USB + TCP entries"
|
|
||||||
# Real USB candidate must be at position 0 — it's likely_meshtastic.
|
|
||||||
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
|
|
||||||
assert ds[0]["likely_meshtastic"] is True
|
|
||||||
# The malformed TCP entry exists but lands among the unlikely entries.
|
|
||||||
tcp = [d for d in ds if "TCP" in (d["description"] or "")]
|
|
||||||
assert len(tcp) == 1
|
|
||||||
assert tcp[0]["likely_meshtastic"] is False
|
|
||||||
assert ds.index(tcp[0]) > 0
|
|
||||||
|
|
||||||
def test_likely_tcp_entry_wins_tiebreak_over_usb(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
# Conversely, a *valid* TCP env var should sort ahead of USB
|
|
||||||
# candidates of equal likely_meshtastic rank — explicit env-var
|
|
||||||
# configuration is a precedence signal.
|
|
||||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
|
|
||||||
|
|
||||||
class FakeInfo:
|
|
||||||
def __init__(self, device: str, vid: int, pid: int) -> None:
|
|
||||||
self.device = device
|
|
||||||
self.vid = vid
|
|
||||||
self.pid = pid
|
|
||||||
self.description = "Heltec V3"
|
|
||||||
self.manufacturer = "Espressif"
|
|
||||||
self.product = "USB JTAG/serial"
|
|
||||||
self.serial_number = "abc"
|
|
||||||
|
|
||||||
fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001)
|
|
||||||
with patch(
|
|
||||||
"meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port]
|
|
||||||
), patch(
|
|
||||||
"meshtastic.util.findPorts",
|
|
||||||
return_value=["/dev/cu.usbmodem4201"],
|
|
||||||
):
|
|
||||||
ds = devices.list_devices()
|
|
||||||
|
|
||||||
assert ds[0]["port"] == "tcp://localhost:4403"
|
|
||||||
assert ds[0]["likely_meshtastic"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- connect() routing ---------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestConnectRoutesTcp:
|
|
||||||
def test_connect_uses_tcp_interface_for_tcp_port(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)`
|
|
||||||
and never touches `SerialInterface`."""
|
|
||||||
# Make sure the env var doesn't leak in and confuse resolve_port.
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
|
|
||||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch(
|
|
||||||
"meshtastic.serial_interface.SerialInterface"
|
|
||||||
) as mock_serial:
|
|
||||||
mock_tcp.return_value.close.return_value = None
|
|
||||||
with connection.connect(port="tcp://example.com:1234", timeout_s=12.0):
|
|
||||||
pass
|
|
||||||
|
|
||||||
mock_tcp.assert_called_once_with(
|
|
||||||
hostname="example.com",
|
|
||||||
portNumber=1234,
|
|
||||||
connectNow=True,
|
|
||||||
noProto=False,
|
|
||||||
timeout=12,
|
|
||||||
)
|
|
||||||
mock_serial.assert_not_called()
|
|
||||||
|
|
||||||
def test_connect_plumbs_timeout_to_serial_interface(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Verify the serial branch also propagates `timeout_s` so callers
|
|
||||||
passing a custom timeout to `device_info` / `list_nodes` / etc. don't
|
|
||||||
silently get the library default."""
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
|
|
||||||
with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch(
|
|
||||||
"meshtastic.tcp_interface.TCPInterface"
|
|
||||||
) as mock_tcp:
|
|
||||||
mock_serial.return_value.close.return_value = None
|
|
||||||
with connection.connect(port="/dev/cu.fake", timeout_s=20.0):
|
|
||||||
pass
|
|
||||||
|
|
||||||
mock_serial.assert_called_once_with(
|
|
||||||
devPath="/dev/cu.fake",
|
|
||||||
connectNow=True,
|
|
||||||
noProto=False,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
mock_tcp.assert_not_called()
|
|
||||||
|
|
||||||
def test_connect_releases_lock_on_tcp_failure(
|
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False)
|
|
||||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
|
||||||
mock_tcp.side_effect = RuntimeError("boom")
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
with connection.connect(port="tcp://locktest:4403"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Lock should be released — a second connect attempt must not fail
|
|
||||||
# with "busy".
|
|
||||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
|
||||||
mock_tcp.return_value.close.return_value = None
|
|
||||||
with connection.connect(port="tcp://locktest:4403"):
|
|
||||||
pass
|
|
||||||
+3
-3
@@ -120,7 +120,7 @@ lib_deps =
|
|||||||
[radiolib_base]
|
[radiolib_base]
|
||||||
lib_deps =
|
lib_deps =
|
||||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||||
|
|
||||||
[device-ui_base]
|
[device-ui_base]
|
||||||
lib_deps =
|
lib_deps =
|
||||||
@@ -170,8 +170,8 @@ lib_deps =
|
|||||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||||
|
|||||||
+18
-15
@@ -26,8 +26,6 @@ SOFTWARE.*/
|
|||||||
|
|
||||||
#include "DebugConfiguration.h"
|
#include "DebugConfiguration.h"
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
#ifdef ARCH_PORTDUINO
|
#ifdef ARCH_PORTDUINO
|
||||||
#include "platform/portduino/PortduinoGlue.h"
|
#include "platform/portduino/PortduinoGlue.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -121,22 +119,27 @@ bool Syslog::vlogf(uint16_t pri, const char *fmt, va_list args)
|
|||||||
|
|
||||||
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
bool Syslog::vlogf(uint16_t pri, const char *appName, const char *fmt, va_list args)
|
||||||
{
|
{
|
||||||
// First measure the formatted length using a copy of args; passing args directly
|
char *message;
|
||||||
// to vsnprintf consumes it, and reusing a consumed va_list is undefined behavior.
|
size_t initialLen;
|
||||||
va_list args_measure;
|
size_t len;
|
||||||
va_copy(args_measure, args);
|
bool result;
|
||||||
int needed = vsnprintf(nullptr, 0, fmt, args_measure);
|
|
||||||
va_end(args_measure);
|
|
||||||
|
|
||||||
if (needed < 0)
|
initialLen = strlen(fmt);
|
||||||
return false; // encoding error
|
|
||||||
|
|
||||||
auto message = std::unique_ptr<char[]>(new char[static_cast<size_t>(needed) + 1]);
|
message = new char[initialLen + 1];
|
||||||
int written = vsnprintf(message.get(), static_cast<size_t>(needed) + 1, fmt, args);
|
|
||||||
if (written < 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return this->_sendLog(pri, appName, message.get());
|
len = vsnprintf(message, initialLen + 1, fmt, args);
|
||||||
|
if (len > initialLen) {
|
||||||
|
delete[] message;
|
||||||
|
message = new char[len + 1];
|
||||||
|
|
||||||
|
vsnprintf(message, len + 1, fmt, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = this->_sendLog(pri, appName, message);
|
||||||
|
|
||||||
|
delete[] message;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *message)
|
||||||
|
|||||||
+89
-59
@@ -79,46 +79,28 @@ bool copyFile(const char *from, const char *to)
|
|||||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
|
||||||
|
#ifdef ARCH_ESP32
|
||||||
|
// take SPI Lock
|
||||||
spiLock->lock();
|
spiLock->lock();
|
||||||
|
// rename was fixed for ESP32 IDF LittleFS in April
|
||||||
bool result = FSCom.rename(pathFrom, pathTo);
|
bool result = FSCom.rename(pathFrom, pathTo);
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
return result;
|
return result;
|
||||||
#else
|
#else
|
||||||
|
// copyFile does its own locking.
|
||||||
|
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Platform-agnostic filesystem format / wipe.
|
|
||||||
*
|
|
||||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
|
||||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
|
||||||
* partition.
|
|
||||||
*
|
|
||||||
* On Portduino the fs::FS backend has no format() method. We instead
|
|
||||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
|
||||||
* and return. rmDir("/prefs") is already called unconditionally by
|
|
||||||
* factoryReset() so this is a proven primitive on Portduino.
|
|
||||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
|
||||||
*
|
|
||||||
* @return true on success, false on failure or if no filesystem is configured.
|
|
||||||
*/
|
|
||||||
bool fsFormat()
|
|
||||||
{
|
|
||||||
#ifdef FSCom
|
|
||||||
#if defined(ARCH_PORTDUINO)
|
|
||||||
rmDir("/prefs");
|
|
||||||
return FSBegin();
|
|
||||||
#else
|
|
||||||
return FSCom.format();
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the list of files in a directory.
|
* @brief Get the list of files in a directory.
|
||||||
*
|
*
|
||||||
@@ -141,21 +123,23 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
|||||||
|
|
||||||
File file = root.openNextFile();
|
File file = root.openNextFile();
|
||||||
while (file) {
|
while (file) {
|
||||||
#ifdef ARCH_ESP32
|
|
||||||
const char *filepath = file.path();
|
|
||||||
#else
|
|
||||||
const char *filepath = file.name();
|
|
||||||
#endif
|
|
||||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||||
if (levels) {
|
if (levels) {
|
||||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
#ifdef ARCH_ESP32
|
||||||
|
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||||
|
#else
|
||||||
|
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||||
|
#endif
|
||||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
#ifdef ARCH_ESP32
|
||||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
strcpy(fileInfo.file_name, file.path());
|
||||||
|
#else
|
||||||
|
strcpy(fileInfo.file_name, file.name());
|
||||||
|
#endif
|
||||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||||
filenames.push_back(fileInfo);
|
filenames.push_back(fileInfo);
|
||||||
}
|
}
|
||||||
@@ -179,59 +163,98 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
|||||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
char buffer[255];
|
char buffer[255];
|
||||||
|
#endif
|
||||||
File root = FSCom.open(dirname, FILE_O_READ);
|
File root = FSCom.open(dirname, FILE_O_READ);
|
||||||
if (!root || !root.isDirectory())
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
File file = root.openNextFile();
|
File file = root.openNextFile();
|
||||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
while (
|
||||||
#ifdef ARCH_ESP32
|
file &&
|
||||||
const char *filepath = file.path();
|
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||||
#else
|
|
||||||
const char *filepath = file.name();
|
|
||||||
#endif
|
|
||||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||||
if (levels) {
|
if (levels) {
|
||||||
listDir(filepath, levels - 1, del);
|
#ifdef ARCH_ESP32
|
||||||
|
listDir(file.path(), levels - 1, del);
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Remove %s", filepath);
|
LOG_DEBUG("Remove %s", file.path());
|
||||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
strncpy(buffer, file.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
file.close();
|
file.close();
|
||||||
FSCom.rmdir(buffer);
|
FSCom.rmdir(buffer);
|
||||||
} else {
|
} else {
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
|
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
|
listDir(file.name(), levels - 1, del);
|
||||||
|
if (del) {
|
||||||
|
LOG_DEBUG("Remove %s", file.name());
|
||||||
|
strncpy(buffer, file.name(), sizeof(buffer));
|
||||||
|
file.close();
|
||||||
|
FSCom.rmdir(buffer);
|
||||||
|
} else {
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
LOG_DEBUG(" %s (directory)", file.name());
|
||||||
|
listDir(file.name(), levels - 1, del);
|
||||||
|
file.close();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
#ifdef ARCH_ESP32
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Delete %s", filepath);
|
LOG_DEBUG("Delete %s", file.path());
|
||||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
strncpy(buffer, file.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
file.close();
|
file.close();
|
||||||
FSCom.remove(buffer);
|
FSCom.remove(buffer);
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
|
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
|
if (del) {
|
||||||
|
LOG_DEBUG("Delete %s", file.name());
|
||||||
|
strncpy(buffer, file.name(), sizeof(buffer));
|
||||||
|
file.close();
|
||||||
|
FSCom.remove(buffer);
|
||||||
|
} else {
|
||||||
|
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||||
|
file.close();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
file = root.openNextFile();
|
file = root.openNextFile();
|
||||||
}
|
}
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
const char *rootpath = root.path();
|
|
||||||
#else
|
|
||||||
const char *rootpath = root.name();
|
|
||||||
#endif
|
|
||||||
if (del) {
|
if (del) {
|
||||||
LOG_DEBUG("Remove %s", rootpath);
|
LOG_DEBUG("Remove %s", root.path());
|
||||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
strncpy(buffer, root.path(), sizeof(buffer));
|
||||||
buffer[sizeof(buffer) - 1] = '\0';
|
|
||||||
root.close();
|
root.close();
|
||||||
FSCom.rmdir(buffer);
|
FSCom.rmdir(buffer);
|
||||||
} else {
|
} else {
|
||||||
root.close();
|
root.close();
|
||||||
}
|
}
|
||||||
|
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
|
if (del) {
|
||||||
|
LOG_DEBUG("Remove %s", root.name());
|
||||||
|
strncpy(buffer, root.name(), sizeof(buffer));
|
||||||
|
root.close();
|
||||||
|
FSCom.rmdir(buffer);
|
||||||
|
} else {
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
root.close();
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +268,14 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
|||||||
void rmDir(const char *dirname)
|
void rmDir(const char *dirname)
|
||||||
{
|
{
|
||||||
#ifdef FSCom
|
#ifdef FSCom
|
||||||
|
|
||||||
|
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||||
listDir(dirname, 10, true);
|
listDir(dirname, 10, true);
|
||||||
|
#elif defined(ARCH_NRF52)
|
||||||
|
// nRF52 implementation of LittleFS has a recursive delete function
|
||||||
|
FSCom.rmdir_r(dirname);
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ void fsInit();
|
|||||||
void fsListFiles();
|
void fsListFiles();
|
||||||
bool copyFile(const char *from, const char *to);
|
bool copyFile(const char *from, const char *to);
|
||||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||||
bool fsFormat();
|
|
||||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||||
void rmDir(const char *dirname);
|
void rmDir(const char *dirname);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ static File openFile(const char *filename, bool fullAtomic)
|
|||||||
{
|
{
|
||||||
concurrency::LockGuard g(spiLock);
|
concurrency::LockGuard g(spiLock);
|
||||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||||
|
#ifdef ARCH_NRF52
|
||||||
|
FSCom.remove(filename);
|
||||||
|
return FSCom.open(filename, FILE_O_WRITE);
|
||||||
|
#endif
|
||||||
if (!fullAtomic) {
|
if (!fullAtomic) {
|
||||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||||
}
|
}
|
||||||
@@ -63,6 +67,9 @@ bool SafeFile::close()
|
|||||||
f.close();
|
f.close();
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
|
|
||||||
|
#ifdef ARCH_NRF52
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
if (!testReadback())
|
if (!testReadback())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ Lock::Lock() : handle(xSemaphoreCreateBinary())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Lock::~Lock()
|
|
||||||
{
|
|
||||||
vSemaphoreDelete(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Lock::lock()
|
void Lock::lock()
|
||||||
{
|
{
|
||||||
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
if (xSemaphoreTake(handle, portMAX_DELAY) == false) {
|
||||||
@@ -35,8 +30,6 @@ void Lock::unlock()
|
|||||||
#else
|
#else
|
||||||
Lock::Lock() {}
|
Lock::Lock() {}
|
||||||
|
|
||||||
Lock::~Lock() {}
|
|
||||||
|
|
||||||
void Lock::lock() {}
|
void Lock::lock() {}
|
||||||
|
|
||||||
void Lock::unlock() {}
|
void Lock::unlock() {}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class Lock
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
Lock();
|
Lock();
|
||||||
~Lock();
|
|
||||||
|
|
||||||
Lock(const Lock &) = delete;
|
Lock(const Lock &) = delete;
|
||||||
Lock &operator=(const Lock &) = delete;
|
Lock &operator=(const Lock &) = delete;
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
// Pre-hop drop handling (compile-time flag).
|
// Pre-hop drop handling (compile-time flag).
|
||||||
#ifndef MESHTASTIC_PREHOP_DROP
|
#ifndef MESHTASTIC_PREHOP_DROP
|
||||||
#define MESHTASTIC_PREHOP_DROP 1
|
#define MESHTASTIC_PREHOP_DROP 0
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Convert a preprocessor name into a quoted string
|
/// Convert a preprocessor name into a quoted string
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ enum LoRaRadioType {
|
|||||||
SX1280_RADIO,
|
SX1280_RADIO,
|
||||||
LR1110_RADIO,
|
LR1110_RADIO,
|
||||||
LR1120_RADIO,
|
LR1120_RADIO,
|
||||||
LR1121_RADIO,
|
LR1121_RADIO
|
||||||
LR2021_RADIO
|
|
||||||
};
|
};
|
||||||
|
|
||||||
extern LoRaRadioType radioType;
|
extern LoRaRadioType radioType;
|
||||||
+2
-10
@@ -1025,13 +1025,10 @@ void GPS::up()
|
|||||||
setPowerState(GPS_ACTIVE);
|
setPowerState(GPS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
|
// We've got a GPS lock. Enter a low power state, potentially.
|
||||||
void GPS::down()
|
void GPS::down()
|
||||||
{
|
{
|
||||||
if (hasValidLocation)
|
|
||||||
scheduling.informGotLock();
|
scheduling.informGotLock();
|
||||||
else
|
|
||||||
scheduling.informSearchFailed();
|
|
||||||
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
uint32_t predictedSearchDuration = scheduling.predictedSearchDurationMs();
|
||||||
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
uint32_t sleepTime = scheduling.msUntilNextSearch();
|
||||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||||
@@ -1556,12 +1553,7 @@ std::unique_ptr<GPS> GPS::createGps()
|
|||||||
_en_gpio = PIN_GPS_EN;
|
_en_gpio = PIN_GPS_EN;
|
||||||
#endif
|
#endif
|
||||||
#ifdef ARCH_PORTDUINO
|
#ifdef ARCH_PORTDUINO
|
||||||
if (portduino_config.has_gps) {
|
if (!portduino_config.has_gps)
|
||||||
// These need to set as flags so later checks will pass on native and GPS will work.
|
|
||||||
// They are not used for any hardware access.
|
|
||||||
_rx_gpio = 1;
|
|
||||||
_tx_gpio = 1;
|
|
||||||
} else
|
|
||||||
return nullptr;
|
return nullptr;
|
||||||
#endif
|
#endif
|
||||||
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
if (!_rx_gpio || !_serial_gps) // Configured to have no GPS at all
|
||||||
|
|||||||
@@ -15,19 +15,6 @@ void GPSUpdateScheduling::informGotLock()
|
|||||||
searchEndedMs = millis();
|
searchEndedMs = millis();
|
||||||
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000);
|
||||||
updateLockTimePrediction();
|
updateLockTimePrediction();
|
||||||
consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search finished without obtaining a fix. We still need to mark the end time so
|
|
||||||
// the next sleep is timed correctly, but we must not feed the timeout duration
|
|
||||||
// into predictedMsToGetLock — doing so poisons msUntilNextSearch() and causes
|
|
||||||
// down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles.
|
|
||||||
void GPSUpdateScheduling::informSearchFailed()
|
|
||||||
{
|
|
||||||
searchEndedMs = millis();
|
|
||||||
consecutiveFailures++;
|
|
||||||
LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000,
|
|
||||||
consecutiveFailures);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear old lock-time prediction data.
|
// Clear old lock-time prediction data.
|
||||||
@@ -38,7 +25,6 @@ void GPSUpdateScheduling::reset()
|
|||||||
searchEndedMs = 0;
|
searchEndedMs = 0;
|
||||||
searchCount = 0;
|
searchCount = 0;
|
||||||
predictedMsToGetLock = 0;
|
predictedMsToGetLock = 0;
|
||||||
consecutiveFailures = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many milliseconds before we should next search for GPS position
|
// How many milliseconds before we should next search for GPS position
|
||||||
@@ -50,20 +36,6 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch()
|
|||||||
// Target interval (seconds), between GPS updates
|
// Target interval (seconds), between GPS updates
|
||||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval);
|
||||||
|
|
||||||
// After a failed search, back off: indoors / no-sky environments will keep failing,
|
|
||||||
// so wake at most once per broadcast interval rather than once per gps_update_interval.
|
|
||||||
// Capped at 1 hour so a user-configured very-long broadcast interval still retries
|
|
||||||
// periodically (in case conditions change). Reset on any successful lock.
|
|
||||||
if (consecutiveFailures > 0) {
|
|
||||||
constexpr uint32_t failureRetryCapMs = 60UL * 60UL * 1000UL; // 1 hour cap
|
|
||||||
uint32_t failureSleepMs =
|
|
||||||
Default::getConfiguredOrDefaultMs(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
|
||||||
if (failureSleepMs > failureRetryCapMs)
|
|
||||||
failureSleepMs = failureRetryCapMs;
|
|
||||||
if (updateInterval < failureSleepMs)
|
|
||||||
updateInterval = failureSleepMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check how long until we should start searching, to hopefully hit our target interval
|
// Check how long until we should start searching, to hopefully hit our target interval
|
||||||
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
uint32_t dueAtMs = searchEndedMs + updateInterval;
|
||||||
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
uint32_t compensatedStart = dueAtMs - predictedMsToGetLock;
|
||||||
@@ -100,17 +72,13 @@ bool GPSUpdateScheduling::searchedTooLong()
|
|||||||
{
|
{
|
||||||
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
constexpr uint32_t oneMinuteMs = 60UL * 1000UL;
|
||||||
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long
|
||||||
constexpr uint32_t postFailureSearchMs = 5UL * oneMinuteMs; // Tighter dwell once we know the environment is hostile
|
|
||||||
uint32_t elapsed = elapsedSearchMs();
|
uint32_t elapsed = elapsedSearchMs();
|
||||||
|
|
||||||
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
// Anything over 15 minutes is too long, regardless of the broadcast interval.
|
||||||
|
// TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock.
|
||||||
if (elapsed > maxSearchClampMs)
|
if (elapsed > maxSearchClampMs)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// After a prior failed search, shorten the dwell
|
|
||||||
if (consecutiveFailures > 0 && elapsed > postFailureSearchMs)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
uint32_t minimumOrConfiguredSecs =
|
uint32_t minimumOrConfiguredSecs =
|
||||||
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs);
|
||||||
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class GPSUpdateScheduling
|
|||||||
// Marks the time of these events, for calculation use
|
// Marks the time of these events, for calculation use
|
||||||
void informSearching();
|
void informSearching();
|
||||||
void informGotLock(); // Predicted lock-time is recalculated here
|
void informGotLock(); // Predicted lock-time is recalculated here
|
||||||
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
|
|
||||||
|
|
||||||
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
|
||||||
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
|
||||||
@@ -25,7 +24,6 @@ class GPSUpdateScheduling
|
|||||||
uint32_t searchEndedMs = 0;
|
uint32_t searchEndedMs = 0;
|
||||||
uint32_t searchCount = 0;
|
uint32_t searchCount = 0;
|
||||||
uint32_t predictedMsToGetLock = 0;
|
uint32_t predictedMsToGetLock = 0;
|
||||||
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
|
|
||||||
|
|
||||||
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
|
||||||
};
|
};
|
||||||
@@ -13,6 +13,7 @@ RotaryEncoderImpl *rotaryEncoderImpl;
|
|||||||
|
|
||||||
RotaryEncoderImpl::RotaryEncoderImpl()
|
RotaryEncoderImpl::RotaryEncoderImpl()
|
||||||
{
|
{
|
||||||
|
rotary = nullptr;
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
isFirstInit = true;
|
isFirstInit = true;
|
||||||
#endif
|
#endif
|
||||||
@@ -22,6 +23,11 @@ RotaryEncoderImpl::~RotaryEncoderImpl()
|
|||||||
{
|
{
|
||||||
LOG_DEBUG("RotaryEncoderImpl destructor");
|
LOG_DEBUG("RotaryEncoderImpl destructor");
|
||||||
detachRotaryEncoderInterrupts();
|
detachRotaryEncoderInterrupts();
|
||||||
|
|
||||||
|
if (rotary != nullptr) {
|
||||||
|
delete rotary;
|
||||||
|
rotary = nullptr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RotaryEncoderImpl::init()
|
bool RotaryEncoderImpl::init()
|
||||||
@@ -37,9 +43,8 @@ bool RotaryEncoderImpl::init()
|
|||||||
eventPressed = static_cast<input_broker_event>(moduleConfig.canned_message.inputbroker_event_press);
|
eventPressed = static_cast<input_broker_event>(moduleConfig.canned_message.inputbroker_event_press);
|
||||||
|
|
||||||
if (rotary == nullptr) {
|
if (rotary == nullptr) {
|
||||||
rotary.reset(new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a,
|
rotary = new RotaryEncoder(moduleConfig.canned_message.inputbroker_pin_a, moduleConfig.canned_message.inputbroker_pin_b,
|
||||||
moduleConfig.canned_message.inputbroker_pin_b,
|
moduleConfig.canned_message.inputbroker_pin_press);
|
||||||
moduleConfig.canned_message.inputbroker_pin_press));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attachRotaryEncoderInterrupts();
|
attachRotaryEncoderInterrupts();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
#include "InputBroker.h"
|
#include "InputBroker.h"
|
||||||
#include "concurrency/OSThread.h"
|
#include "concurrency/OSThread.h"
|
||||||
#include "mesh/NodeDB.h"
|
#include "mesh/NodeDB.h"
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
class RotaryEncoder;
|
class RotaryEncoder;
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ class RotaryEncoderImpl final : public InputPollable
|
|||||||
input_broker_event eventCcw = INPUT_BROKER_NONE;
|
input_broker_event eventCcw = INPUT_BROKER_NONE;
|
||||||
input_broker_event eventPressed = INPUT_BROKER_NONE;
|
input_broker_event eventPressed = INPUT_BROKER_NONE;
|
||||||
|
|
||||||
std::unique_ptr<RotaryEncoder> rotary;
|
RotaryEncoder *rotary;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
#ifdef ARCH_ESP32
|
#ifdef ARCH_ESP32
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
#include "configuration.h"
|
|
||||||
|
|
||||||
#include "LR11x0Interface.cpp"
|
#include "LR11x0Interface.cpp"
|
||||||
#include "LR11x0Interface.h"
|
#include "LR11x0Interface.h"
|
||||||
#include "LR20x0Interface.cpp"
|
|
||||||
#include "LR20x0Interface.h"
|
|
||||||
#include "SX126xInterface.cpp"
|
#include "SX126xInterface.cpp"
|
||||||
#include "SX126xInterface.h"
|
#include "SX126xInterface.h"
|
||||||
#include "SX128xInterface.cpp"
|
#include "SX128xInterface.cpp"
|
||||||
@@ -25,9 +21,6 @@ template class LR11x0Interface<LR1110>;
|
|||||||
template class LR11x0Interface<LR1120>;
|
template class LR11x0Interface<LR1120>;
|
||||||
template class LR11x0Interface<LR1121>;
|
template class LR11x0Interface<LR1121>;
|
||||||
#endif
|
#endif
|
||||||
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
template class LR20x0Interface<LR2021>;
|
|
||||||
#endif
|
|
||||||
#ifdef ARCH_STM32WL
|
#ifdef ARCH_STM32WL
|
||||||
template class SX126xInterface<STM32WLx>;
|
template class SX126xInterface<STM32WLx>;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -57,19 +57,16 @@ template <typename T> bool LR11x0Interface<T>::init()
|
|||||||
#if ARCH_PORTDUINO
|
#if ARCH_PORTDUINO
|
||||||
float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000;
|
float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000;
|
||||||
// FIXME: correct logic to default to not using TCXO if no voltage is specified for LR11x0_DIO3_TCXO_VOLTAGE
|
// FIXME: correct logic to default to not using TCXO if no voltage is specified for LR11x0_DIO3_TCXO_VOLTAGE
|
||||||
#elif defined(LR11X0_DIO3_TCXO_VOLTAGE)
|
#elif !defined(LR11X0_DIO3_TCXO_VOLTAGE)
|
||||||
float tcxoVoltage = LR11X0_DIO3_TCXO_VOLTAGE;
|
|
||||||
LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR11X0_DIO3_TCXO_VOLTAGE);
|
|
||||||
// (DIO3 is not free to be used as an IRQ)
|
|
||||||
#elif defined(TCXO_OPTIONAL)
|
|
||||||
float tcxoVoltage = 1.6f; // TCXO_OPTIONAL: try default 1.6 V first, fall back to XTAL on failure
|
|
||||||
LOG_DEBUG("TCXO_OPTIONAL: no LR11X0_DIO3_TCXO_VOLTAGE defined, trying default TCXO Vref 1.6 V first");
|
|
||||||
#else
|
|
||||||
float tcxoVoltage =
|
float tcxoVoltage =
|
||||||
0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per
|
0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per
|
||||||
// https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104
|
// https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104
|
||||||
// (DIO3 is free to be used as an IRQ)
|
// (DIO3 is free to be used as an IRQ)
|
||||||
LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage");
|
LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage");
|
||||||
|
#else
|
||||||
|
float tcxoVoltage = LR11X0_DIO3_TCXO_VOLTAGE;
|
||||||
|
LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR11X0_DIO3_TCXO_VOLTAGE);
|
||||||
|
// (DIO3 is not free to be used as an IRQ)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
RadioLibInterface::init();
|
RadioLibInterface::init();
|
||||||
@@ -104,17 +101,6 @@ template <typename T> bool LR11x0Interface<T>::init()
|
|||||||
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(TCXO_OPTIONAL)
|
|
||||||
// If init failed for any reason other than chip not found, retry without TCXO (XTAL mode)
|
|
||||||
if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) {
|
|
||||||
LOG_WARN("LR11x0 init failed with TCXO Vref %f V (err %d), retrying without TCXO", tcxoVoltage, res);
|
|
||||||
tcxoVoltage = 0;
|
|
||||||
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
|
||||||
if (res == RADIOLIB_ERR_NONE)
|
|
||||||
LOG_INFO("LR11x0 init success without TCXO (XTAL mode)");
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// \todo Display actual typename of the adapter, not just `LR11x0`
|
// \todo Display actual typename of the adapter, not just `LR11x0`
|
||||||
LOG_INFO("LR11x0 init result %d", res);
|
LOG_INFO("LR11x0 init result %d", res);
|
||||||
if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED)
|
if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED)
|
||||||
@@ -212,7 +198,7 @@ template <typename T> bool LR11x0Interface<T>::reconfigure()
|
|||||||
|
|
||||||
startReceive(); // restart receiving
|
startReceive(); // restart receiving
|
||||||
|
|
||||||
return true;
|
return RADIOLIB_ERR_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename T> void LR11x0Interface<T>::disableInterrupt()
|
template <typename T> void LR11x0Interface<T>::disableInterrupt()
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
#include "configuration.h"
|
|
||||||
|
|
||||||
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
|
|
||||||
#include "LR2021Interface.h"
|
|
||||||
#include "error.h"
|
|
||||||
|
|
||||||
LR2021Interface::LR2021Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
|
|
||||||
RADIOLIB_PIN_TYPE busy)
|
|
||||||
: LR20x0Interface(hal, cs, irq, rst, busy)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
bool LR2021Interface::wideLora()
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#if RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
#include "LR20x0Interface.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Our adapter for LR2021 radios
|
|
||||||
*/
|
|
||||||
class LR2021Interface : public LR20x0Interface<LR2021>
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
LR2021Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
|
|
||||||
RADIOLIB_PIN_TYPE busy);
|
|
||||||
bool wideLora() override;
|
|
||||||
};
|
|
||||||
#endif
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
#include "configuration.h"
|
|
||||||
|
|
||||||
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
#include "LR20x0Interface.h"
|
|
||||||
#include "error.h"
|
|
||||||
#include "mesh/NodeDB.h"
|
|
||||||
|
|
||||||
// Keep LR20x0 naming while RadioLib exposes LR2021 symbols.
|
|
||||||
#ifndef LR20x0
|
|
||||||
#define LR20x0 LR2021
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef LR2021_DIO_AS_RF_SWITCH
|
|
||||||
#include "rfswitch.h"
|
|
||||||
#elif ARCH_PORTDUINO
|
|
||||||
#include "PortduinoGlue.h"
|
|
||||||
#define lr20x0_rfswitch_dio_pins portduino_config.rfswitch_dio_pins
|
|
||||||
#define lr20x0_rfswitch_table portduino_config.rfswitch_table
|
|
||||||
#else
|
|
||||||
static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC};
|
|
||||||
static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = {
|
|
||||||
{LR20x0::MODE_STBY, {}}, {LR20x0::MODE_RX, {}}, {LR20x0::MODE_TX, {}},
|
|
||||||
{LR20x0::MODE_RX_HF, {}}, {LR20x0::MODE_TX_HF, {}}, END_OF_MODE_TABLE,
|
|
||||||
};
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Particular boards might define a different max power based on what their hardware can do, default to max power output if not
|
|
||||||
// specified (may be dangerous if using external PA and LR20x0 power config forgotten)
|
|
||||||
#if ARCH_PORTDUINO
|
|
||||||
#define LR2021_MAX_POWER portduino_config.lr2021_max_power
|
|
||||||
#endif
|
|
||||||
#ifndef LR2021_MAX_POWER
|
|
||||||
#define LR2021_MAX_POWER 22
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// the 2.4G part maxes at 12dBm
|
|
||||||
|
|
||||||
#if ARCH_PORTDUINO
|
|
||||||
#define LR2021_MAX_POWER_HF portduino_config.lr2021_max_power_hf
|
|
||||||
#endif
|
|
||||||
#ifndef LR2021_MAX_POWER_HF
|
|
||||||
#define LR2021_MAX_POWER_HF 12
|
|
||||||
#endif
|
|
||||||
|
|
||||||
template <typename T>
|
|
||||||
LR20x0Interface<T>::LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
|
|
||||||
RADIOLIB_PIN_TYPE busy)
|
|
||||||
: RadioLibInterface(hal, cs, irq, rst, busy, &lora), lora(&module)
|
|
||||||
{
|
|
||||||
LOG_WARN("LR20x0Interface(cs=%d, irq=%d, rst=%d, busy=%d)", cs, irq, rst, busy);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialise the Driver transport hardware and software.
|
|
||||||
/// Make sure the Driver is properly configured before calling init().
|
|
||||||
/// \return true if initialisation succeeded.
|
|
||||||
template <typename T> bool LR20x0Interface<T>::init()
|
|
||||||
{
|
|
||||||
#ifdef LR2021_POWER_EN
|
|
||||||
pinMode(LR2021_POWER_EN, OUTPUT);
|
|
||||||
digitalWrite(LR2021_POWER_EN, HIGH);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if ARCH_PORTDUINO
|
|
||||||
float tcxoVoltage = (float)portduino_config.dio3_tcxo_voltage / 1000;
|
|
||||||
// FIXME: correct logic to default to not using TCXO if no voltage is specified for LR20x0_DIO3_TCXO_VOLTAGE
|
|
||||||
#elif defined(LR2021_DIO3_TCXO_VOLTAGE)
|
|
||||||
float tcxoVoltage = LR2021_DIO3_TCXO_VOLTAGE;
|
|
||||||
LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE defined, using DIO3 as TCXO reference voltage at %f V", LR2021_DIO3_TCXO_VOLTAGE);
|
|
||||||
// (DIO3 is not free to be used as an IRQ)
|
|
||||||
#elif defined(TCXO_OPTIONAL)
|
|
||||||
float tcxoVoltage = 1.6f; // TCXO_OPTIONAL: try default 1.6 V first, fall back to XTAL on failure
|
|
||||||
LOG_DEBUG("TCXO_OPTIONAL: no LR2021_DIO3_TCXO_VOLTAGE defined, trying default TCXO Vref 1.6 V first");
|
|
||||||
#else
|
|
||||||
float tcxoVoltage =
|
|
||||||
0; // "TCXO reference voltage to be set on DIO3. Defaults to 1.6 V, set to 0 to skip." per
|
|
||||||
// https://github.com/jgromes/RadioLib/blob/690a050ebb46e6097c5d00c371e961c1caa3b52e/src/modules/LR11x0/LR11x0.h#L471C26-L471C104
|
|
||||||
// (DIO3 is free to be used as an IRQ)
|
|
||||||
LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage");
|
|
||||||
#endif
|
|
||||||
|
|
||||||
RadioLibInterface::init();
|
|
||||||
|
|
||||||
#ifdef LR2021_IRQ_DIO_NUM
|
|
||||||
lora.irqDioNum = LR2021_IRQ_DIO_NUM;
|
|
||||||
LOG_DEBUG("Set irqDioNum %d", lora.irqDioNum);
|
|
||||||
#elif defined(IRQ_DIO_NUM)
|
|
||||||
lora.irqDioNum = IRQ_DIO_NUM;
|
|
||||||
LOG_DEBUG("Set irqDioNum %d", lora.irqDioNum);
|
|
||||||
#else
|
|
||||||
LOG_DEBUG("Use default irqDioNum %d", lora.irqDioNum);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
|
|
||||||
limitPower(LR2021_MAX_POWER_HF);
|
|
||||||
} else {
|
|
||||||
limitPower(LR2021_MAX_POWER); // default clamp for non-wide freq range
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef LR2021_RF_SWITCH_SUBGHZ
|
|
||||||
pinMode(LR2021_RF_SWITCH_SUBGHZ, OUTPUT);
|
|
||||||
digitalWrite(LR2021_RF_SWITCH_SUBGHZ, getFreq() < 1e9 ? HIGH : LOW);
|
|
||||||
LOG_DEBUG("Set RF0 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz");
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef LR2021_RF_SWITCH_2_4GHZ
|
|
||||||
pinMode(LR2021_RF_SWITCH_2_4GHZ, OUTPUT);
|
|
||||||
digitalWrite(LR2021_RF_SWITCH_2_4GHZ, getFreq() < 1e9 ? LOW : HIGH);
|
|
||||||
LOG_DEBUG("Set RF1 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz");
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Allow extra time for TCXO to stabilize after power-on
|
|
||||||
delay(10);
|
|
||||||
|
|
||||||
int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
|
||||||
|
|
||||||
// Retry if we get SPI command failed - some units need extra TCXO stabilization time
|
|
||||||
if (res == RADIOLIB_ERR_SPI_CMD_FAILED) {
|
|
||||||
LOG_WARN("LR20x0 init failed with %d (SPI_CMD_FAILED), retrying after delay...", res);
|
|
||||||
delay(100);
|
|
||||||
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
|
||||||
}
|
|
||||||
|
|
||||||
#if defined(TCXO_OPTIONAL)
|
|
||||||
// If init failed for any reason other than chip not found, retry without TCXO (XTAL mode)
|
|
||||||
if (res != RADIOLIB_ERR_NONE && res != RADIOLIB_ERR_CHIP_NOT_FOUND && tcxoVoltage > 0) {
|
|
||||||
LOG_WARN("LR20x0 init failed with TCXO Vref %f V (err %d), retrying without TCXO", tcxoVoltage, res);
|
|
||||||
tcxoVoltage = 0;
|
|
||||||
res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage);
|
|
||||||
if (res == RADIOLIB_ERR_NONE)
|
|
||||||
LOG_INFO("LR20x0 init success without TCXO (XTAL mode)");
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// \todo Display actual typename of the adapter, not just `LR20x0`
|
|
||||||
LOG_INFO("LR20x0 init result %d", res);
|
|
||||||
if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Some basic info about the module's explicit firmware version - no other info available
|
|
||||||
// Currently requires radiolib godmode
|
|
||||||
|
|
||||||
#if RADIOLIB_GODMODE
|
|
||||||
uint8_t fwMajor = 0;
|
|
||||||
uint8_t fwMinor = 0;
|
|
||||||
int versionRes = lora.getVersion(&fwMajor, &fwMinor);
|
|
||||||
if (versionRes == RADIOLIB_ERR_NONE)
|
|
||||||
LOG_DEBUG("LR20x0 FW %d.%d", fwMajor, fwMinor);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
LOG_INFO("Frequency set to %f", getFreq());
|
|
||||||
LOG_INFO("Bandwidth set to %f", bw);
|
|
||||||
LOG_INFO("Power output set to %d", power);
|
|
||||||
|
|
||||||
if (res == RADIOLIB_ERR_NONE)
|
|
||||||
res = lora.setCRC(2);
|
|
||||||
|
|
||||||
// Standard DCDC ramp timing from RadioLib workarounds (register 0x00F20024)
|
|
||||||
// Currently requires radiolib godmode
|
|
||||||
#if RADIOLIB_GODMODE
|
|
||||||
if (res == RADIOLIB_ERR_NONE) {
|
|
||||||
uint8_t rampTimes[4] = {15, 15, 15, 15}; // Standard case for all conditions
|
|
||||||
res = lora.setRegMode(RADIOLIB_LR2021_REG_MODE_SIMO_NORMAL, rampTimes);
|
|
||||||
if (res != RADIOLIB_ERR_NONE)
|
|
||||||
LOG_WARN("LR2021 setRegMode failed: %d", res);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#ifdef LR2021_DIO_AS_RF_SWITCH
|
|
||||||
bool dioAsRfSwitch = true;
|
|
||||||
#elif defined(ARCH_PORTDUINO)
|
|
||||||
bool dioAsRfSwitch = portduino_config.has_rfswitch_table;
|
|
||||||
#else
|
|
||||||
bool dioAsRfSwitch = false;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (dioAsRfSwitch) {
|
|
||||||
lora.setRfSwitchTable(lr20x0_rfswitch_dio_pins, lr20x0_rfswitch_table);
|
|
||||||
LOG_DEBUG("Set DIO RF switch");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == RADIOLIB_ERR_NONE) {
|
|
||||||
if (config.lora.sx126x_rx_boosted_gain) { // the name is unfortunate but historically accurate
|
|
||||||
res = lora.setRxBoostedGainMode(true);
|
|
||||||
LOG_INFO("Set RX gain to boosted mode; result: %d", res);
|
|
||||||
} else {
|
|
||||||
res = lora.setRxBoostedGainMode(false);
|
|
||||||
LOG_INFO("Set RX gain to power saving mode (boosted mode off); result: %d", res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res == RADIOLIB_ERR_NONE)
|
|
||||||
startReceive(); // start receiving
|
|
||||||
|
|
||||||
return res == RADIOLIB_ERR_NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
template <typename T> bool LR20x0Interface<T>::reconfigure()
|
|
||||||
{
|
|
||||||
RadioLibInterface::reconfigure();
|
|
||||||
|
|
||||||
// set mode to standby
|
|
||||||
setStandby();
|
|
||||||
|
|
||||||
// configure publicly accessible settings
|
|
||||||
int err = lora.setSpreadingFactor(sf);
|
|
||||||
if (err != RADIOLIB_ERR_NONE)
|
|
||||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
|
||||||
|
|
||||||
err = lora.setBandwidth(bw); // different form than LR11xx
|
|
||||||
if (err != RADIOLIB_ERR_NONE)
|
|
||||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
|
||||||
|
|
||||||
err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it
|
|
||||||
if (err != RADIOLIB_ERR_NONE)
|
|
||||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
|
||||||
|
|
||||||
err = lora.setSyncWord(syncWord);
|
|
||||||
assert(err == RADIOLIB_ERR_NONE);
|
|
||||||
|
|
||||||
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range
|
|
||||||
limitPower(LR2021_MAX_POWER_HF);
|
|
||||||
} else {
|
|
||||||
limitPower(LR2021_MAX_POWER); // default clamp for non-wide freq range
|
|
||||||
}
|
|
||||||
|
|
||||||
err = lora.setPreambleLength(preambleLength);
|
|
||||||
assert(err == RADIOLIB_ERR_NONE);
|
|
||||||
|
|
||||||
err = lora.setFrequency(getFreq());
|
|
||||||
if (err != RADIOLIB_ERR_NONE)
|
|
||||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
|
||||||
|
|
||||||
err = lora.setOutputPower(power);
|
|
||||||
assert(err == RADIOLIB_ERR_NONE);
|
|
||||||
|
|
||||||
// Apply RX gain mode — valid in STDBY, matches resetAGC() pattern
|
|
||||||
err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
|
|
||||||
if (err != RADIOLIB_ERR_NONE)
|
|
||||||
LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err);
|
|
||||||
|
|
||||||
startReceive(); // restart receiving
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
template <typename T> void LR20x0Interface<T>::disableInterrupt()
|
|
||||||
{
|
|
||||||
lora.clearIrqAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
template <typename T> void LR20x0Interface<T>::setStandby()
|
|
||||||
{
|
|
||||||
checkNotification(); // handle any pending interrupts before we force standby
|
|
||||||
|
|
||||||
int err = lora.standby();
|
|
||||||
|
|
||||||
if (err != RADIOLIB_ERR_NONE) {
|
|
||||||
LOG_DEBUG("LR20x0 standby failed with error %d", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(err == RADIOLIB_ERR_NONE);
|
|
||||||
|
|
||||||
isReceiving = false; // If we were receiving, not any more
|
|
||||||
activeReceiveStart = 0;
|
|
||||||
disableInterrupt();
|
|
||||||
completeSending(); // If we were sending, not anymore
|
|
||||||
RadioLibInterface::setStandby();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add SNR data to received messages
|
|
||||||
*/
|
|
||||||
template <typename T> void LR20x0Interface<T>::addReceiveMetadata(meshtastic_MeshPacket *mp)
|
|
||||||
{
|
|
||||||
// LOG_DEBUG("PacketStatus %x", lora.getPacketStatus());
|
|
||||||
mp->rx_snr = lora.getSNR();
|
|
||||||
mp->rx_rssi = lround(lora.getRSSI());
|
|
||||||
// LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); // not implemented for LR20x0, but noop for LR11x0
|
|
||||||
// too(!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** We override to turn on transmitter power as needed.
|
|
||||||
*/
|
|
||||||
template <typename T> void LR20x0Interface<T>::configHardwareForSend()
|
|
||||||
{
|
|
||||||
RadioLibInterface::configHardwareForSend();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For power draw measurements, helpful to force radio to stay sleeping
|
|
||||||
// #define SLEEP_ONLY
|
|
||||||
|
|
||||||
template <typename T> void LR20x0Interface<T>::startReceive()
|
|
||||||
{
|
|
||||||
#ifdef SLEEP_ONLY
|
|
||||||
sleep();
|
|
||||||
#else
|
|
||||||
|
|
||||||
setStandby();
|
|
||||||
|
|
||||||
lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed.
|
|
||||||
|
|
||||||
// We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly.
|
|
||||||
int err =
|
|
||||||
lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0);
|
|
||||||
if (err)
|
|
||||||
LOG_ERROR("StartReceive error: %d", err);
|
|
||||||
assert(err == RADIOLIB_ERR_NONE);
|
|
||||||
|
|
||||||
RadioLibInterface::startReceive();
|
|
||||||
|
|
||||||
// Must be done AFTER starting receive, because startReceive clears (possibly stale) interrupt pending register bits
|
|
||||||
enableInterrupt(isrRxLevel0);
|
|
||||||
checkRxDoneIrqFlag();
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is the channel currently active? */
|
|
||||||
template <typename T> bool LR20x0Interface<T>::isChannelActive()
|
|
||||||
{
|
|
||||||
// check if we can detect a LoRa preamble on the current channel
|
|
||||||
ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD,
|
|
||||||
.detPeak = RADIOLIB_LR2021_CAD_PARAM_DEFAULT,
|
|
||||||
.detMin = RADIOLIB_LR2021_CAD_PARAM_DEFAULT,
|
|
||||||
.exitMode = RADIOLIB_LR2021_CAD_PARAM_DEFAULT,
|
|
||||||
.timeout = 0,
|
|
||||||
.irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS,
|
|
||||||
.irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}};
|
|
||||||
int16_t result;
|
|
||||||
|
|
||||||
setStandby();
|
|
||||||
result = lora.scanChannel(cfg);
|
|
||||||
if (result == RADIOLIB_LORA_DETECTED)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
assert(result != RADIOLIB_ERR_WRONG_MODEM);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Could we send right now (i.e. either not actively receiving or transmitting)? */
|
|
||||||
template <typename T> bool LR20x0Interface<T>::isActivelyReceiving()
|
|
||||||
{
|
|
||||||
// The IRQ status will be cleared when we start our read operation. Check if we've started a header, but haven't yet
|
|
||||||
// received and handled the interrupt for reading the packet/handling errors.
|
|
||||||
return receiveDetected(lora.getIrqStatus(), RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID, RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#ifdef LR20X0_AGC_RESET
|
|
||||||
template <typename T> void LR20x0Interface<T>::resetAGC()
|
|
||||||
{
|
|
||||||
// Safety: don't reset mid-packet
|
|
||||||
if (sendingPacket != NULL || (isReceiving && isActivelyReceiving()))
|
|
||||||
return;
|
|
||||||
|
|
||||||
LOG_DEBUG("LR20x0 AGC reset: warm sleep + Calibrate(0x3F)");
|
|
||||||
|
|
||||||
// 1. Warm sleep — powers down the analog frontend, resetting AGC state
|
|
||||||
lora.sleep(true, 0);
|
|
||||||
|
|
||||||
// 2. Wake to RC standby for stable calibration
|
|
||||||
lora.standby(RADIOLIB_LR20X0_STANDBY_RC, true);
|
|
||||||
|
|
||||||
// 3. Calibrate all blocks (PLL, ADC, image, RC oscillators)
|
|
||||||
// calibrate() is protected on LR20x0, so use raw SPI (same as internal implementation)
|
|
||||||
uint8_t calData = RADIOLIB_LR20X0_CALIBRATE_ALL;
|
|
||||||
module.SPIwriteStream(RADIOLIB_LR20X0_CMD_CALIBRATE, &calData, 1, true, true);
|
|
||||||
|
|
||||||
// 4. Re-calibrate image rejection for actual operating frequency
|
|
||||||
// Calibrate(0x3F) defaults to 902-928 MHz which is wrong for other regions.
|
|
||||||
lora.calibrateImageRejection(getFreq() - 4.0f, getFreq() + 4.0f);
|
|
||||||
|
|
||||||
// 5. Re-apply RX boosted gain mode
|
|
||||||
lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain);
|
|
||||||
|
|
||||||
// 6. Resume receiving
|
|
||||||
startReceive();
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
template <typename T> bool LR20x0Interface<T>::sleep()
|
|
||||||
{
|
|
||||||
// \todo Display actual typename of the adapter, not just `LR20x0`
|
|
||||||
LOG_DEBUG("LR20x0 entering sleep mode");
|
|
||||||
setStandby(); // Stop any pending operations
|
|
||||||
|
|
||||||
// turn off TCXO if it was powered
|
|
||||||
lora.setTCXO(0);
|
|
||||||
|
|
||||||
// put chipset into sleep mode (we've already disabled interrupts by now)
|
|
||||||
bool keepConfig = false;
|
|
||||||
lora.sleep(keepConfig, 0); // Note: we do not keep the config, full reinit will be needed
|
|
||||||
|
|
||||||
#ifdef LR2021_POWER_EN
|
|
||||||
digitalWrite(LR2021_POWER_EN, LOW);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#if RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
#include "RadioLibInterface.h"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* \brief Adapter for LR20x0 radio family. Implements common logic for child classes.
|
|
||||||
* \tparam T RadioLib module type for LR20x0, e.g. LR2021.
|
|
||||||
*/
|
|
||||||
template <class T> class LR20x0Interface : public RadioLibInterface
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst,
|
|
||||||
RADIOLIB_PIN_TYPE busy);
|
|
||||||
|
|
||||||
/// Initialise the Driver transport hardware and software.
|
|
||||||
/// Make sure the Driver is properly configured before calling init().
|
|
||||||
/// \return true if initialisation succeeded.
|
|
||||||
virtual bool init() override;
|
|
||||||
|
|
||||||
/// Apply any radio provisioning changes
|
|
||||||
/// Make sure the Driver is properly configured before calling init().
|
|
||||||
/// \return true if initialisation succeeded.
|
|
||||||
virtual bool reconfigure() override;
|
|
||||||
|
|
||||||
/// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep.
|
|
||||||
virtual bool sleep() override;
|
|
||||||
|
|
||||||
bool isIRQPending() override { return lora.getIrqFlags() != 0; }
|
|
||||||
|
|
||||||
#ifdef LR20X0_AGC_RESET
|
|
||||||
void resetAGC() override;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
protected:
|
|
||||||
/**
|
|
||||||
* Specific module instance
|
|
||||||
*/
|
|
||||||
T lora;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Glue functions called from ISR land
|
|
||||||
*/
|
|
||||||
virtual void disableInterrupt() override;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable a particular ISR callback glue function
|
|
||||||
*/
|
|
||||||
virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); }
|
|
||||||
|
|
||||||
/** can we detect a LoRa preamble on the current channel? */
|
|
||||||
virtual bool isChannelActive() override;
|
|
||||||
|
|
||||||
/** are we actively receiving a packet (only called during receiving state) */
|
|
||||||
virtual bool isActivelyReceiving() override;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start waiting to receive a message
|
|
||||||
*/
|
|
||||||
virtual void startReceive() override;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We override to turn on transmitter power as needed.
|
|
||||||
*/
|
|
||||||
virtual void configHardwareForSend() override;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add SNR data to received messages
|
|
||||||
*/
|
|
||||||
virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) override;
|
|
||||||
|
|
||||||
virtual void setStandby() override;
|
|
||||||
|
|
||||||
uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); }
|
|
||||||
};
|
|
||||||
#endif
|
|
||||||
+3
-1
@@ -1611,10 +1611,12 @@ bool NodeDB::saveToDisk(int saveWhat)
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
LOG_ERROR("Failed to save to disk, retrying");
|
LOG_ERROR("Failed to save to disk, retrying");
|
||||||
|
#ifdef ARCH_NRF52 // @geeksville is not ready yet to say we should do this on other platforms. See bug #4184 discussion
|
||||||
spiLock->lock();
|
spiLock->lock();
|
||||||
fsFormat();
|
FSCom.format();
|
||||||
spiLock->unlock();
|
spiLock->unlock();
|
||||||
|
|
||||||
|
#endif
|
||||||
success = saveToDiskNoRetry(saveWhat);
|
success = saveToDiskNoRetry(saveWhat);
|
||||||
|
|
||||||
RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
|
RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
|
||||||
|
|||||||
+41
-26
@@ -17,7 +17,7 @@
|
|||||||
#define VERBOSE_PACKET_HISTORY 0 // Set to 1 for verbose logging, 2 for heavy debugging
|
#define VERBOSE_PACKET_HISTORY 0 // Set to 1 for verbose logging, 2 for heavy debugging
|
||||||
#define PACKET_HISTORY_TRACE_AGING 1 // Set to 1 to enable logging of the age of re/used history slots
|
#define PACKET_HISTORY_TRACE_AGING 1 // Set to 1 to enable logging of the age of re/used history slots
|
||||||
|
|
||||||
PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initialize members
|
PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPackets(NULL) // Initialize members
|
||||||
{
|
{
|
||||||
if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense
|
if (size < 4 || size > PACKETHISTORY_MAX) { // Copilot suggested - makes sense
|
||||||
LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX);
|
LOG_WARN("Packet History - Invalid size %d, using default %d", size, PACKETHISTORY_MAX);
|
||||||
@@ -34,7 +34,7 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia
|
|||||||
|
|
||||||
// Allocate memory for the recent packets array
|
// Allocate memory for the recent packets array
|
||||||
recentPacketsCapacity = size;
|
recentPacketsCapacity = size;
|
||||||
recentPackets.reset(new PacketRecord[recentPacketsCapacity]);
|
recentPackets = new PacketRecord[recentPacketsCapacity];
|
||||||
if (!recentPackets) { // No logging here, console/log probably uninitialized yet.
|
if (!recentPackets) { // No logging here, console/log probably uninitialized yet.
|
||||||
LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size,
|
LOG_ERROR("Packet History - Memory allocation failed for size=%d entries / %d Bytes", size,
|
||||||
sizeof(PacketRecord) * recentPacketsCapacity);
|
sizeof(PacketRecord) * recentPacketsCapacity);
|
||||||
@@ -43,20 +43,33 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0) // Initia
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the recent packets array to zero
|
// Initialize the recent packets array to zero
|
||||||
memset(recentPackets.get(), 0, sizeof(PacketRecord) * recentPacketsCapacity);
|
memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity);
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
|
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
|
||||||
// Allocate hash index with load factor <= 0.5 for short probe chains
|
// Allocate hash index with load factor <= 0.5 for short probe chains
|
||||||
hashCapacity = nextPowerOf2(recentPacketsCapacity * 2);
|
hashCapacity = nextPowerOf2(recentPacketsCapacity * 2);
|
||||||
hashMask = hashCapacity - 1;
|
hashMask = hashCapacity - 1;
|
||||||
hashIndex.reset(new uint16_t[hashCapacity]);
|
hashIndex = new uint16_t[hashCapacity];
|
||||||
if (!hashIndex) {
|
if (!hashIndex) {
|
||||||
LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity);
|
LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity);
|
||||||
hashCapacity = 0;
|
hashCapacity = 0;
|
||||||
hashMask = 0;
|
hashMask = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
memset(hashIndex.get(), 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF)
|
memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketHistory::~PacketHistory()
|
||||||
|
{
|
||||||
|
recentPacketsCapacity = 0;
|
||||||
|
delete[] recentPackets;
|
||||||
|
recentPackets = NULL;
|
||||||
|
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
|
||||||
|
delete[] hashIndex;
|
||||||
|
hashIndex = NULL;
|
||||||
|
hashCapacity = 0;
|
||||||
|
hashMask = 0;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +285,7 @@ void PacketHistory::hashRebuild()
|
|||||||
{
|
{
|
||||||
if (!hashIndex)
|
if (!hashIndex)
|
||||||
return;
|
return;
|
||||||
memset(hashIndex.get(), 0xFF, sizeof(uint16_t) * hashCapacity);
|
memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity);
|
||||||
for (uint32_t i = 0; i < recentPacketsCapacity; i++) {
|
for (uint32_t i = 0; i < recentPacketsCapacity; i++) {
|
||||||
if (recentPackets[i].rxTimeMsec != 0)
|
if (recentPackets[i].rxTimeMsec != 0)
|
||||||
hashInsert(recentPackets[i].sender, recentPackets[i].id, (uint16_t)i);
|
hashInsert(recentPackets[i].sender, recentPackets[i].id, (uint16_t)i);
|
||||||
@@ -319,8 +332,7 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Linear scan (sole path when hash excluded, fallback when hash allocation failed)
|
// Linear scan (sole path when hash excluded, fallback when hash allocation failed)
|
||||||
PacketRecord *base = recentPackets.get();
|
for (PacketRecord *it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
|
||||||
for (PacketRecord *it = base; it < (base + recentPacketsCapacity); ++it) {
|
|
||||||
if (it->id == id && it->sender == sender) {
|
if (it->id == id && it->sender == sender) {
|
||||||
return it;
|
return it;
|
||||||
}
|
}
|
||||||
@@ -334,38 +346,39 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||||||
{
|
{
|
||||||
uint32_t now_millis = millis(); // Should not jump with time changes
|
uint32_t now_millis = millis(); // Should not jump with time changes
|
||||||
uint32_t OldtrxTimeMsec = 0;
|
uint32_t OldtrxTimeMsec = 0;
|
||||||
PacketRecord *base = recentPackets.get();
|
|
||||||
PacketRecord *tu = NULL; // Will insert here.
|
PacketRecord *tu = NULL; // Will insert here.
|
||||||
PacketRecord *it = NULL;
|
PacketRecord *it = NULL;
|
||||||
|
|
||||||
// Find a free, matching or oldest used slot in the recentPackets array
|
// Find a free, matching or oldest used slot in the recentPackets array
|
||||||
for (it = base; it < (base + recentPacketsCapacity); ++it) {
|
for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
|
||||||
if (it->id == 0 && it->sender == 0 /*&& rxTimeMsec == 0*/) { // Record is empty
|
if (it->id == 0 && it->sender == 0 /*&& rxTimeMsec == 0*/) { // Record is empty
|
||||||
tu = it; // Remember the free slot
|
tu = it; // Remember the free slot
|
||||||
#if VERBOSE_PACKET_HISTORY >= 2
|
#if VERBOSE_PACKET_HISTORY >= 2
|
||||||
LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - base, recentPacketsCapacity);
|
LOG_DEBUG("Packet History - insert: Free slot@ %d/%d", tu - recentPackets, recentPacketsCapacity);
|
||||||
#endif
|
#endif
|
||||||
// We have that, Exit the loop
|
// We have that, Exit the loop
|
||||||
it = (base + recentPacketsCapacity);
|
it = (recentPackets + recentPacketsCapacity);
|
||||||
} else if (it->id == r.id && it->sender == r.sender) { // Record matches the packet we want to insert
|
} else if (it->id == r.id && it->sender == r.sender) { // Record matches the packet we want to insert
|
||||||
tu = it; // Remember the matching slot
|
tu = it; // Remember the matching slot
|
||||||
OldtrxTimeMsec = now_millis - it->rxTimeMsec; // ..and save current entry's age
|
OldtrxTimeMsec = now_millis - it->rxTimeMsec; // ..and save current entry's age
|
||||||
#if VERBOSE_PACKET_HISTORY >= 2
|
#if VERBOSE_PACKET_HISTORY >= 2
|
||||||
LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
LOG_DEBUG("Packet History - insert: Matched slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||||
|
OldtrxTimeMsec);
|
||||||
#endif
|
#endif
|
||||||
// We have that, Exit the loop
|
// We have that, Exit the loop
|
||||||
it = (base + recentPacketsCapacity);
|
it = (recentPackets + recentPacketsCapacity);
|
||||||
} else {
|
} else {
|
||||||
if (it->rxTimeMsec == 0) {
|
if (it->rxTimeMsec == 0) {
|
||||||
LOG_WARN(
|
LOG_WARN(
|
||||||
"Packet History - insert: Found packet s=%08x id=%08x with rxTimeMsec = 0, slot %d/%d. Should never happen!",
|
"Packet History - insert: Found packet s=%08x id=%08x with rxTimeMsec = 0, slot %d/%d. Should never happen!",
|
||||||
it->sender, it->id, it - base, recentPacketsCapacity);
|
it->sender, it->id, it - recentPackets, recentPacketsCapacity);
|
||||||
}
|
}
|
||||||
if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly
|
if ((now_millis - it->rxTimeMsec) > OldtrxTimeMsec) { // 49.7 days rollover friendly
|
||||||
OldtrxTimeMsec = now_millis - it->rxTimeMsec;
|
OldtrxTimeMsec = now_millis - it->rxTimeMsec;
|
||||||
tu = it; // remember the oldest packet
|
tu = it; // remember the oldest packet
|
||||||
#if VERBOSE_PACKET_HISTORY >= 2
|
#if VERBOSE_PACKET_HISTORY >= 2
|
||||||
LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
LOG_DEBUG("Packet History - insert: Older slot@ %d/%d age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||||
|
OldtrxTimeMsec);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
// keep looking for oldest till entire array is checked
|
// keep looking for oldest till entire array is checked
|
||||||
@@ -380,11 +393,13 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||||||
|
|
||||||
#if VERBOSE_PACKET_HISTORY
|
#if VERBOSE_PACKET_HISTORY
|
||||||
if (tu->id == 0 && tu->sender == 0) {
|
if (tu->id == 0 && tu->sender == 0) {
|
||||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - base, recentPacketsCapacity);
|
LOG_DEBUG("Packet History - insert: slot@ %d/%d is NEW", tu - recentPackets, recentPacketsCapacity);
|
||||||
} else if (tu->id == r.id && tu->sender == r.sender) {
|
} else if (tu->id == r.id && tu->sender == r.sender) {
|
||||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
LOG_DEBUG("Packet History - insert: slot@ %d/%d MATCHED, age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||||
|
OldtrxTimeMsec);
|
||||||
} else {
|
} else {
|
||||||
LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - base, recentPacketsCapacity, OldtrxTimeMsec);
|
LOG_DEBUG("Packet History - insert: slot@ %d/%d REUSE OLDEST, age=%d", tu - recentPackets, recentPacketsCapacity,
|
||||||
|
OldtrxTimeMsec);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -417,9 +432,9 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if VERBOSE_PACKET_HISTORY
|
#if VERBOSE_PACKET_HISTORY
|
||||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE", tu - base,
|
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d BEFORE",
|
||||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
|
||||||
tu->rxTimeMsec);
|
tu->relayed_by[2], tu->rxTimeMsec);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (r.rxTimeMsec == 0) {
|
if (r.rxTimeMsec == 0) {
|
||||||
@@ -439,16 +454,16 @@ void PacketHistory::insert(const PacketRecord &r)
|
|||||||
*tu = r; // store the packet
|
*tu = r; // store the packet
|
||||||
|
|
||||||
if (!isMatchingSlot) {
|
if (!isMatchingSlot) {
|
||||||
hashInsert(r.sender, r.id, (uint16_t)(tu - base));
|
hashInsert(r.sender, r.id, (uint16_t)(tu - recentPackets));
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
*tu = r; // store the packet
|
*tu = r; // store the packet
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if VERBOSE_PACKET_HISTORY
|
#if VERBOSE_PACKET_HISTORY
|
||||||
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER", tu - base,
|
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER",
|
||||||
recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1], tu->relayed_by[2],
|
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
|
||||||
tu->rxTimeMsec);
|
tu->relayed_by[2], tu->rxTimeMsec);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "NodeDB.h"
|
#include "NodeDB.h"
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
// Number of relayers we keep track of. Use 6 to be efficient with memory alignment of PacketRecord to 20 bytes
|
// Number of relayers we keep track of. Use 6 to be efficient with memory alignment of PacketRecord to 20 bytes
|
||||||
#define NUM_RELAYERS 6
|
#define NUM_RELAYERS 6
|
||||||
@@ -27,7 +26,7 @@ class PacketHistory
|
|||||||
|
|
||||||
uint32_t recentPacketsCapacity =
|
uint32_t recentPacketsCapacity =
|
||||||
0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets.
|
0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets.
|
||||||
std::unique_ptr<PacketRecord[]> recentPackets; // Simple and fixed in size. Debloat.
|
PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat.
|
||||||
|
|
||||||
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
|
#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
|
||||||
// Open-addressing hash table for O(1) lookup in find(), replacing the O(N) linear scan.
|
// Open-addressing hash table for O(1) lookup in find(), replacing the O(N) linear scan.
|
||||||
@@ -35,7 +34,7 @@ class PacketHistory
|
|||||||
// The load factor invariant holds permanently: hashCapacity = 2 * nextPowerOf2(recentPacketsCapacity),
|
// The load factor invariant holds permanently: hashCapacity = 2 * nextPowerOf2(recentPacketsCapacity),
|
||||||
// and at most recentPacketsCapacity entries can ever be live (one per recentPackets[] slot).
|
// and at most recentPacketsCapacity entries can ever be live (one per recentPackets[] slot).
|
||||||
static constexpr uint16_t HASH_EMPTY = 0xFFFF;
|
static constexpr uint16_t HASH_EMPTY = 0xFFFF;
|
||||||
std::unique_ptr<uint16_t[]> hashIndex;
|
uint16_t *hashIndex = NULL;
|
||||||
uint32_t hashCapacity = 0; // Always a power of 2
|
uint32_t hashCapacity = 0; // Always a power of 2
|
||||||
uint32_t hashMask = 0; // hashCapacity - 1, for fast modular indexing
|
uint32_t hashMask = 0; // hashCapacity - 1, for fast modular indexing
|
||||||
|
|
||||||
@@ -65,8 +64,11 @@ class PacketHistory
|
|||||||
uint8_t getOurTxHopLimit(const PacketRecord &r);
|
uint8_t getOurTxHopLimit(const PacketRecord &r);
|
||||||
void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit);
|
void setOurTxHopLimit(PacketRecord &r, uint8_t hopLimit);
|
||||||
|
|
||||||
|
PacketHistory(const PacketHistory &); // non construction-copyable
|
||||||
|
PacketHistory &operator=(const PacketHistory &); // non copyable
|
||||||
public:
|
public:
|
||||||
explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX
|
explicit PacketHistory(uint32_t size = -1); // Constructor with size parameter, default is PACKETHISTORY_MAX
|
||||||
|
~PacketHistory();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update recentBroadcasts and return true if we have already seen this packet
|
* Update recentBroadcasts and return true if we have already seen this packet
|
||||||
@@ -98,5 +100,5 @@ class PacketHistory
|
|||||||
void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender);
|
void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender);
|
||||||
|
|
||||||
// To check if the PacketHistory was initialized correctly by constructor
|
// To check if the PacketHistory was initialized correctly by constructor
|
||||||
bool initOk(void) { return recentPackets != nullptr && recentPacketsCapacity != 0; }
|
bool initOk(void) { return recentPackets != NULL && recentPacketsCapacity != 0; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -522,6 +522,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
|||||||
// LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard,
|
// LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard,
|
||||||
// nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name);
|
// nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name);
|
||||||
|
|
||||||
|
// Occasional progress logging. (readIndex==2 will be true for the first non-us node)
|
||||||
|
if (readIndex == 2 || readIndex % 20 == 0) {
|
||||||
|
LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes());
|
||||||
|
}
|
||||||
|
|
||||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag;
|
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag;
|
||||||
fromRadioScratch.node_info = infoToSend;
|
fromRadioScratch.node_info = infoToSend;
|
||||||
prefetchNodeInfos();
|
prefetchNodeInfos();
|
||||||
@@ -652,11 +657,9 @@ void PhoneAPI::releaseQueueStatusPhonePacket()
|
|||||||
void PhoneAPI::prefetchNodeInfos()
|
void PhoneAPI::prefetchNodeInfos()
|
||||||
{
|
{
|
||||||
bool added = false;
|
bool added = false;
|
||||||
bool wasEmpty = false;
|
|
||||||
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
|
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
|
||||||
{
|
{
|
||||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||||
wasEmpty = nodeInfoQueue.empty();
|
|
||||||
while (nodeInfoQueue.size() < kNodePrefetchDepth) {
|
while (nodeInfoQueue.size() < kNodePrefetchDepth) {
|
||||||
auto nextNode = nodeDB->readNextMeshNode(readIndex);
|
auto nextNode = nodeDB->readNextMeshNode(readIndex);
|
||||||
if (!nextNode)
|
if (!nextNode)
|
||||||
@@ -670,15 +673,11 @@ void PhoneAPI::prefetchNodeInfos()
|
|||||||
info.via_mqtt = isUs ? false : info.via_mqtt;
|
info.via_mqtt = isUs ? false : info.via_mqtt;
|
||||||
info.is_favorite = info.is_favorite || isUs;
|
info.is_favorite = info.is_favorite || isUs;
|
||||||
nodeInfoQueue.push_back(info);
|
nodeInfoQueue.push_back(info);
|
||||||
// Log progress here (at fetch time) so readIndex is accurate and each value logs only once.
|
|
||||||
if (readIndex == 2 || readIndex % 20 == 0) {
|
|
||||||
LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes());
|
|
||||||
}
|
|
||||||
added = true;
|
added = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added && wasEmpty)
|
if (added)
|
||||||
onNowHasData(0);
|
onNowHasData(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ bool RF95Interface::reconfigure()
|
|||||||
|
|
||||||
startReceive(); // restart receiving
|
startReceive(); // restart receiving
|
||||||
|
|
||||||
return true;
|
return RADIOLIB_ERR_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
#include "LR1110Interface.h"
|
#include "LR1110Interface.h"
|
||||||
#include "LR1120Interface.h"
|
#include "LR1120Interface.h"
|
||||||
#include "LR1121Interface.h"
|
#include "LR1121Interface.h"
|
||||||
#include "LR2021Interface.h"
|
|
||||||
#include "MeshRadio.h"
|
#include "MeshRadio.h"
|
||||||
#include "MeshService.h"
|
#include "MeshService.h"
|
||||||
#include "NodeDB.h"
|
#include "NodeDB.h"
|
||||||
@@ -479,20 +478,6 @@ std::unique_ptr<RadioInterface> initLoRa()
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1
|
|
||||||
if (!rIf) {
|
|
||||||
rIf = std::unique_ptr<LR2021Interface>(
|
|
||||||
new LR2021Interface(loraHal, LR2021_SPI_NSS_PIN, LR2021_IRQ_PIN, LR2021_NRESET_PIN, LR2021_BUSY_PIN));
|
|
||||||
if (!rIf->init()) {
|
|
||||||
LOG_WARN("No LR2021 radio");
|
|
||||||
rIf = nullptr;
|
|
||||||
} else {
|
|
||||||
LOG_INFO("LR2021 init success");
|
|
||||||
radioType = LR2021_RADIO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(USE_SX1280) && RADIOLIB_EXCLUDE_SX128X != 1
|
#if defined(USE_SX1280) && RADIOLIB_EXCLUDE_SX128X != 1
|
||||||
if (!rIf) {
|
if (!rIf) {
|
||||||
rIf = std::unique_ptr<SX1280Interface>(new SX1280Interface(loraHal, SX128X_CS, SX128X_DIO1, SX128X_RESET, SX128X_BUSY));
|
rIf = std::unique_ptr<SX1280Interface>(new SX1280Interface(loraHal, SX128X_CS, SX128X_DIO1, SX128X_RESET, SX128X_BUSY));
|
||||||
|
|||||||
@@ -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. */
|
/** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */
|
||||||
virtual bool findInTxQueue(NodeNum from, PacketId id) override;
|
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.
|
* Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface.
|
||||||
* @return true if len bytes were produced, false otherwise.
|
* @return true if len bytes were produced, false otherwise.
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ template <typename T> bool SX126xInterface<T>::reconfigure()
|
|||||||
|
|
||||||
startReceive(); // restart receiving
|
startReceive(); // restart receiving
|
||||||
|
|
||||||
return true;
|
return RADIOLIB_ERR_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename T> void SX126xInterface<T>::disableInterrupt()
|
template <typename T> void SX126xInterface<T>::disableInterrupt()
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ template <typename T> bool SX128xInterface<T>::reconfigure()
|
|||||||
|
|
||||||
startReceive(); // restart receiving
|
startReceive(); // restart receiving
|
||||||
|
|
||||||
return true;
|
return RADIOLIB_ERR_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename T> void SX128xInterface<T>::disableInterrupt()
|
template <typename T> void SX128xInterface<T>::disableInterrupt()
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
Adds a WebServer and WebService callbacks to meshtastic via the Portduino/native target (Linux and
|
Adds a WebServer and WebService callbacks to meshtastic as Linux Version. The WebServer & Webservices
|
||||||
macOS). The WebServer & Webservices run in a real host thread beside the Portduino threading
|
runs in a real linux thread beside the portdunio threading emulation. It replaces the complete ESP32
|
||||||
emulation. It replaces the complete ESP32 Webserver libs including generation of SSL certificates,
|
Webserver libs including generation of SSL certifcicates, because the use ESP specific details in
|
||||||
because those libs use ESP-specific details that can't be emulated.
|
the lib that can't be emulated.
|
||||||
|
|
||||||
The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio"
|
The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio"
|
||||||
The WebServer just adds basic support to deliver WebContent, so it can be used to
|
The WebServer just adds basaic support to deliver WebContent, so it can be used to
|
||||||
deliver the WebGui defined by the WebClient Project.
|
deliver the WebGui definded by the WebClient Project.
|
||||||
|
|
||||||
Steps to get it running:
|
Steps to get it running:
|
||||||
|
1.) Add these Linux Libs to the compile and target machine:
|
||||||
Linux (apt):
|
|
||||||
1.) Add these libs to the compile and target machine:
|
|
||||||
|
|
||||||
sudo apt update && \
|
sudo apt update && \
|
||||||
apt -y install openssl libssl-dev libsdl2-dev \
|
apt -y install openssl libssl-dev libopenssl libsdl2-dev \
|
||||||
libulfius-dev liborcania-dev
|
libulfius-dev liborcania-dev
|
||||||
|
|
||||||
macOS (Homebrew):
|
|
||||||
1.) Install prerequisites via Homebrew:
|
|
||||||
|
|
||||||
brew install ulfius openssl@3
|
|
||||||
|
|
||||||
The PlatformIO env (native-macos) picks up compiler/linker flags via
|
|
||||||
`pkg-config`. In particular, OpenSSL needs `pkg-config --cflags --libs openssl@3`
|
|
||||||
so both the Homebrew include path and linker flags are provided; ulfius and its
|
|
||||||
dependencies (liborcania, libyder) are also resolved via `pkg-config`.
|
|
||||||
|
|
||||||
2.) Configure the root directory of the web Content in the config.yaml file.
|
2.) Configure the root directory of the web Content in the config.yaml file.
|
||||||
The following tags should be included and set at your needs
|
The followinng tags should be included and set at your needs
|
||||||
|
|
||||||
Example entry in the config.yaml
|
Example entry in the config.yaml
|
||||||
Webserver:
|
Webserver:
|
||||||
@@ -46,10 +34,7 @@ Author: Marc Philipp Hammermann
|
|||||||
mail: marchammermann@googlemail.com
|
mail: marchammermann@googlemail.com
|
||||||
|
|
||||||
*/
|
*/
|
||||||
// Mirrors the guard in PiWebServer.h — see comment there. macOS Homebrew
|
#ifdef PORTDUINO_LINUX_HARDWARE
|
||||||
// provides ulfius + deps; Linux pulls them via apt. Either way, this
|
|
||||||
// translation unit only compiles when the headers are present.
|
|
||||||
#ifdef ARCH_PORTDUINO
|
|
||||||
#if __has_include(<ulfius.h>)
|
#if __has_include(<ulfius.h>)
|
||||||
#include "PiWebServer.h"
|
#include "PiWebServer.h"
|
||||||
#include "NodeDB.h"
|
#include "NodeDB.h"
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// Portduino webserver is built whenever the ulfius headers are reachable,
|
#ifdef PORTDUINO_LINUX_HARDWARE
|
||||||
// not only on Linux. macOS users can `brew install ulfius` to enable it;
|
|
||||||
// without ulfius the entire body is skipped and main.cpp's matching
|
|
||||||
// __has_include guard avoids referencing the type.
|
|
||||||
#ifdef ARCH_PORTDUINO
|
|
||||||
#if __has_include(<ulfius.h>)
|
#if __has_include(<ulfius.h>)
|
||||||
#include "PhoneAPI.h"
|
#include "PhoneAPI.h"
|
||||||
#include "ulfius-cfg.h"
|
#include "ulfius-cfg.h"
|
||||||
|
|||||||
@@ -1412,9 +1412,7 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
|||||||
|
|
||||||
void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg)
|
void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg)
|
||||||
{
|
{
|
||||||
#if HAS_SCREEN
|
|
||||||
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg);
|
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg);
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
#include "modules/WaypointModule.h"
|
#include "modules/WaypointModule.h"
|
||||||
#endif
|
#endif
|
||||||
#if ARCH_PORTDUINO
|
#if ARCH_PORTDUINO
|
||||||
|
#include "modules/DMShell.h"
|
||||||
#include "modules/Telemetry/HostMetrics.h"
|
#include "modules/Telemetry/HostMetrics.h"
|
||||||
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
|
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
|
||||||
#include "modules/StoreForwardModule.h"
|
#include "modules/StoreForwardModule.h"
|
||||||
@@ -195,6 +196,7 @@ void setupModules()
|
|||||||
#endif
|
#endif
|
||||||
#if ARCH_PORTDUINO
|
#if ARCH_PORTDUINO
|
||||||
new HostMetricsModule();
|
new HostMetricsModule();
|
||||||
|
dmShellModule = new DMShellModule();
|
||||||
#endif
|
#endif
|
||||||
#if HAS_TELEMETRY
|
#if HAS_TELEMETRY
|
||||||
new DeviceTelemetryModule();
|
new DeviceTelemetryModule();
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ void StoreForwardModule::populatePSRAM()
|
|||||||
(this->records ? this->records : (((memGet.getFreePsram() / 4) * 3) / sizeof(PacketHistoryStruct)));
|
(this->records ? this->records : (((memGet.getFreePsram() / 4) * 3) / sizeof(PacketHistoryStruct)));
|
||||||
this->records = numberOfPackets;
|
this->records = numberOfPackets;
|
||||||
#if defined(ARCH_ESP32)
|
#if defined(ARCH_ESP32)
|
||||||
this->packetHistory.reset(static_cast<PacketHistoryStruct *>(ps_calloc(numberOfPackets, sizeof(PacketHistoryStruct))));
|
this->packetHistory = static_cast<PacketHistoryStruct *>(ps_calloc(numberOfPackets, sizeof(PacketHistoryStruct)));
|
||||||
#elif defined(ARCH_PORTDUINO)
|
#elif defined(ARCH_PORTDUINO)
|
||||||
this->packetHistory.reset(static_cast<PacketHistoryStruct *>(calloc(numberOfPackets, sizeof(PacketHistoryStruct))));
|
this->packetHistory = static_cast<PacketHistoryStruct *>(calloc(numberOfPackets, sizeof(PacketHistoryStruct)));
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
struct PacketHistoryStruct {
|
struct PacketHistoryStruct {
|
||||||
@@ -30,17 +29,11 @@ struct PacketHistoryStruct {
|
|||||||
|
|
||||||
class StoreForwardModule : private concurrency::OSThread, public ProtobufModule<meshtastic_StoreAndForward>
|
class StoreForwardModule : private concurrency::OSThread, public ProtobufModule<meshtastic_StoreAndForward>
|
||||||
{
|
{
|
||||||
// packetHistory is allocated with ps_calloc / calloc, so it must be released with free(),
|
|
||||||
// not delete[].
|
|
||||||
struct CFreeDeleter {
|
|
||||||
void operator()(PacketHistoryStruct *p) const noexcept { free(p); }
|
|
||||||
};
|
|
||||||
|
|
||||||
bool busy = 0;
|
bool busy = 0;
|
||||||
uint32_t busyTo = 0;
|
uint32_t busyTo = 0;
|
||||||
char routerMessage[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0};
|
char routerMessage[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0};
|
||||||
|
|
||||||
std::unique_ptr<PacketHistoryStruct[], CFreeDeleter> packetHistory;
|
PacketHistoryStruct *packetHistory = 0;
|
||||||
uint32_t packetHistoryTotalCount = 0;
|
uint32_t packetHistoryTotalCount = 0;
|
||||||
uint32_t last_time = 0;
|
uint32_t last_time = 0;
|
||||||
uint32_t requestCount = 0;
|
uint32_t requestCount = 0;
|
||||||
|
|||||||
@@ -16,28 +16,10 @@ int32_t INA3221Sensor::runOnce()
|
|||||||
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
|
return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS;
|
||||||
}
|
}
|
||||||
if (!status) {
|
if (!status) {
|
||||||
// Re-initialise with the address and Wire bus from the telemetry sensors map.
|
ina3221.begin(nodeTelemetrySensorsMap[sensorType].second);
|
||||||
// (Rob Tillaart INA3221_RT takes address + TwoWire*, unlike sgtwilko which took Wire in begin().)
|
ina3221.setShuntRes(100, 100, 100); // 0.1 Ohm shunt resistors
|
||||||
ina3221 = INA3221(nodeTelemetrySensorsMap[sensorType].first, nodeTelemetrySensorsMap[sensorType].second);
|
status = true;
|
||||||
status = ina3221.begin();
|
|
||||||
if (status) {
|
|
||||||
// Default all three channels to a 0.1 Ω shunt resistor.
|
|
||||||
// Override per-variant by defining INA3221_SHUNT_R_CH1/CH2/CH3 (in Ohms) in variant.h.
|
|
||||||
#ifndef INA3221_SHUNT_R_CH1
|
|
||||||
#define INA3221_SHUNT_R_CH1 0.1f
|
|
||||||
#endif
|
|
||||||
#ifndef INA3221_SHUNT_R_CH2
|
|
||||||
#define INA3221_SHUNT_R_CH2 0.1f
|
|
||||||
#endif
|
|
||||||
#ifndef INA3221_SHUNT_R_CH3
|
|
||||||
#define INA3221_SHUNT_R_CH3 0.1f
|
|
||||||
#endif
|
|
||||||
ina3221.setShuntR(0, INA3221_SHUNT_R_CH1);
|
|
||||||
ina3221.setShuntR(1, INA3221_SHUNT_R_CH2);
|
|
||||||
ina3221.setShuntR(2, INA3221_SHUNT_R_CH3);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Already initialised; status stays true and initI2CSensor() returns next poll interval.
|
|
||||||
status = true;
|
status = true;
|
||||||
}
|
}
|
||||||
return initI2CSensor();
|
return initI2CSensor();
|
||||||
@@ -45,14 +27,12 @@ int32_t INA3221Sensor::runOnce()
|
|||||||
|
|
||||||
void INA3221Sensor::setup() {}
|
void INA3221Sensor::setup() {}
|
||||||
|
|
||||||
struct _INA3221Measurement INA3221Sensor::getMeasurement(uint8_t ch)
|
struct _INA3221Measurement INA3221Sensor::getMeasurement(ina3221_ch_t ch)
|
||||||
{
|
{
|
||||||
struct _INA3221Measurement measurement;
|
struct _INA3221Measurement measurement;
|
||||||
|
|
||||||
measurement.voltage = ina3221.getBusVoltage(ch); // Volts
|
measurement.voltage = ina3221.getVoltage(ch);
|
||||||
// getCurrent_mA() is used instead of getCurrent() because Rob Tillaart's getCurrent()
|
measurement.current = ina3221.getCurrent(ch);
|
||||||
// returns Amperes; the telemetry proto and VoltageSensor/CurrentSensor interfaces expect mA.
|
|
||||||
measurement.current = ina3221.getCurrent_mA(ch); // milliAmps
|
|
||||||
|
|
||||||
return measurement;
|
return measurement;
|
||||||
}
|
}
|
||||||
@@ -63,7 +43,7 @@ struct _INA3221Measurements INA3221Sensor::getMeasurements()
|
|||||||
|
|
||||||
// INA3221 has 3 channels starting from 0
|
// INA3221 has 3 channels starting from 0
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
measurements.measurements[i] = getMeasurement((uint8_t)i);
|
measurements.measurements[i] = getMeasurement((ina3221_ch_t)i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return measurements;
|
return measurements;
|
||||||
@@ -107,41 +87,24 @@ bool INA3221Sensor::getPowerMetrics(meshtastic_Telemetry *measurement)
|
|||||||
measurement->variant.power_metrics.has_ch3_voltage = true;
|
measurement->variant.power_metrics.has_ch3_voltage = true;
|
||||||
measurement->variant.power_metrics.has_ch3_current = true;
|
measurement->variant.power_metrics.has_ch3_current = true;
|
||||||
|
|
||||||
// INA3221 channel indices are zero-based (0=CH1, 1=CH2, 2=CH3).
|
measurement->variant.power_metrics.ch1_voltage = m.measurements[INA3221_CH1].voltage;
|
||||||
measurement->variant.power_metrics.ch1_voltage = m.measurements[0].voltage;
|
measurement->variant.power_metrics.ch1_current = m.measurements[INA3221_CH1].current;
|
||||||
measurement->variant.power_metrics.ch1_current = m.measurements[0].current;
|
measurement->variant.power_metrics.ch2_voltage = m.measurements[INA3221_CH2].voltage;
|
||||||
measurement->variant.power_metrics.ch2_voltage = m.measurements[1].voltage;
|
measurement->variant.power_metrics.ch2_current = m.measurements[INA3221_CH2].current;
|
||||||
measurement->variant.power_metrics.ch2_current = m.measurements[1].current;
|
measurement->variant.power_metrics.ch3_voltage = m.measurements[INA3221_CH3].voltage;
|
||||||
measurement->variant.power_metrics.ch3_voltage = m.measurements[2].voltage;
|
measurement->variant.power_metrics.ch3_current = m.measurements[INA3221_CH3].current;
|
||||||
measurement->variant.power_metrics.ch3_current = m.measurements[2].current;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t INA3221Sensor::getBusVoltageMv()
|
uint16_t INA3221Sensor::getBusVoltageMv()
|
||||||
{
|
{
|
||||||
return lround(ina3221.getBusVoltage_mV(BAT_CH));
|
return lround(ina3221.getVoltage(BAT_CH) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
int16_t INA3221Sensor::getCurrentMa()
|
int16_t INA3221Sensor::getCurrentMa()
|
||||||
{
|
{
|
||||||
return lround(ina3221.getCurrent_mA(BAT_CH));
|
return lround(ina3221.getCurrent(BAT_CH));
|
||||||
}
|
|
||||||
|
|
||||||
// Bus voltage register (0x02 + ch*2): bits [15:3] unsigned, 1 LSB = 8 mV (datasheet p.6).
|
|
||||||
// Voltage raw units: 1 count = 8 mV, so V_mV = raw * 8.
|
|
||||||
int16_t INA3221Sensor::getRawBusVoltage(uint8_t ch)
|
|
||||||
{
|
|
||||||
return (int16_t)(ina3221.getRegister(0x02 + ch * 2) >> 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shunt voltage register (0x01 + ch*2): bits [15:3] signed two's complement, 1 LSB = 40 µV (datasheet p.6).
|
|
||||||
// Current raw units are shunt-voltage counts: 1 count = 40 uV, signed.
|
|
||||||
// I_mA = (raw * 40 uV) / R_mOhm, because uV / mOhm = mA.
|
|
||||||
// Example for 100 mOhm shunt: I_mA = raw * 40 / 100 = raw * 0.4.
|
|
||||||
int16_t INA3221Sensor::getRawShuntCurrent(uint8_t ch)
|
|
||||||
{
|
|
||||||
return (int16_t)(ina3221.getRegister(0x01 + ch * 2) >> 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
// INA3221 channel aliases (zero-based: 0 = CH1, 1 = CH2, 2 = CH3).
|
|
||||||
// Defined before configuration.h so variant.h can use them in INA3221_ENV_CH / INA3221_BAT_CH.
|
|
||||||
#define INA3221_CH1 0
|
|
||||||
#define INA3221_CH2 1
|
|
||||||
#define INA3221_CH3 2
|
|
||||||
|
|
||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
|
|
||||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<INA3221.h>)
|
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<INA3221.h>)
|
||||||
@@ -15,29 +9,26 @@
|
|||||||
#include <INA3221.h>
|
#include <INA3221.h>
|
||||||
|
|
||||||
#ifndef INA3221_ENV_CH
|
#ifndef INA3221_ENV_CH
|
||||||
#define INA3221_ENV_CH INA3221_CH1 // channel to report in environment metrics (default: CH1)
|
#define INA3221_ENV_CH INA3221_CH1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef INA3221_BAT_CH
|
#ifndef INA3221_BAT_CH
|
||||||
#define INA3221_BAT_CH INA3221_CH1 // channel for device_battery_ina_address (default: CH1)
|
#define INA3221_BAT_CH INA3221_CH1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor
|
class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
// Placeholder constructor; re-initialised with correct address and Wire in runOnce().
|
INA3221 ina3221 = INA3221(INA3221_ADDR42_SDA);
|
||||||
INA3221 ina3221 = INA3221(INA3221_ADDR);
|
|
||||||
|
|
||||||
// channel to report voltage/current for environment metrics
|
// channel to report voltage/current for environment metrics
|
||||||
static const uint8_t ENV_CH = INA3221_ENV_CH;
|
static const ina3221_ch_t ENV_CH = INA3221_ENV_CH;
|
||||||
static_assert(INA3221_ENV_CH >= 0 && INA3221_ENV_CH <= 2, "INA3221_ENV_CH must be 0, 1, or 2");
|
|
||||||
|
|
||||||
// channel to report battery voltage for device_battery_ina_address
|
// channel to report battery voltage for device_battery_ina_address
|
||||||
static const uint8_t BAT_CH = INA3221_BAT_CH;
|
static const ina3221_ch_t BAT_CH = INA3221_BAT_CH;
|
||||||
static_assert(INA3221_BAT_CH >= 0 && INA3221_BAT_CH <= 2, "INA3221_BAT_CH must be 0, 1, or 2");
|
|
||||||
|
|
||||||
// get a single measurement for a channel
|
// get a single measurement for a channel
|
||||||
struct _INA3221Measurement getMeasurement(uint8_t ch);
|
struct _INA3221Measurement getMeasurement(ina3221_ch_t ch);
|
||||||
|
|
||||||
// get all measurements for all channels
|
// get all measurements for all channels
|
||||||
struct _INA3221Measurements getMeasurements();
|
struct _INA3221Measurements getMeasurements();
|
||||||
@@ -54,10 +45,6 @@ class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor
|
|||||||
bool getMetrics(meshtastic_Telemetry *measurement) override;
|
bool getMetrics(meshtastic_Telemetry *measurement) override;
|
||||||
virtual uint16_t getBusVoltageMv() override;
|
virtual uint16_t getBusVoltageMv() override;
|
||||||
virtual int16_t getCurrentMa() override;
|
virtual int16_t getCurrentMa() override;
|
||||||
|
|
||||||
// Raw register reads (bits [15:3] right-shifted), no conversion applied.
|
|
||||||
int16_t getRawBusVoltage(uint8_t ch);
|
|
||||||
int16_t getRawShuntCurrent(uint8_t ch);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct _INA3221Measurement {
|
struct _INA3221Measurement {
|
||||||
|
|||||||
@@ -409,9 +409,6 @@ void MQTT::onReceive(char *topic, byte *payload, size_t length)
|
|||||||
|
|
||||||
void mqttInit()
|
void mqttInit()
|
||||||
{
|
{
|
||||||
if (!moduleConfig.mqtt.enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
new MQTT();
|
new MQTT();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
#include <ErriezCRC32.h>
|
#include <ErriezCRC32.h>
|
||||||
#include <Utility.h>
|
#include <Utility.h>
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
#include <cctype>
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <sys/ioctl.h>
|
#include <sys/ioctl.h>
|
||||||
@@ -34,16 +32,6 @@
|
|||||||
#include <cxxabi.h>
|
#include <cxxabi.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef __APPLE__
|
|
||||||
// Used by getMacAddr()'s macOS fallback to read the en0 link-layer address.
|
|
||||||
// `getifaddrs()` is the BSD-portable way; `<net/if_dl.h>` provides the
|
|
||||||
// `sockaddr_dl` cast and the `LLADDR()` macro that points at the 6-byte MAC.
|
|
||||||
#include <cstring> // strcmp, memcpy
|
|
||||||
#include <ifaddrs.h>
|
|
||||||
#include <net/if.h>
|
|
||||||
#include <net/if_dl.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#include "platform/portduino/USBHal.h"
|
#include "platform/portduino/USBHal.h"
|
||||||
|
|
||||||
portduino_config_struct portduino_config;
|
portduino_config_struct portduino_config;
|
||||||
@@ -167,35 +155,9 @@ void getMacAddr(uint8_t *dmac)
|
|||||||
dmac[3] = di.bdaddr.b[2];
|
dmac[3] = di.bdaddr.b[2];
|
||||||
dmac[4] = di.bdaddr.b[1];
|
dmac[4] = di.bdaddr.b[1];
|
||||||
dmac[5] = di.bdaddr.b[0];
|
dmac[5] = di.bdaddr.b[0];
|
||||||
#elif defined(__APPLE__)
|
|
||||||
// No BlueZ on macOS, but we can fall back to the host's primary
|
|
||||||
// network interface MAC. `en0` is Wi-Fi on every shipping Mac
|
|
||||||
// (Ethernet, when present, is en1 or higher), which gives the user
|
|
||||||
// the same kind of stable, host-derived identifier that the BlueZ
|
|
||||||
// path provides on Linux. If en0 isn't found or has no MAC, dmac is
|
|
||||||
// left untouched and the caller's "Blank MAC Address not allowed!"
|
|
||||||
// check will still fire — preserving existing behavior for users
|
|
||||||
// who deliberately rely on --hwid or YAML override.
|
|
||||||
struct ifaddrs *ifap = nullptr;
|
|
||||||
if (getifaddrs(&ifap) == 0) {
|
|
||||||
for (struct ifaddrs *p = ifap; p != nullptr; p = p->ifa_next) {
|
|
||||||
if (p->ifa_addr == nullptr || p->ifa_addr->sa_family != AF_LINK) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (strcmp(p->ifa_name, "en0") != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
auto *sdl = reinterpret_cast<struct sockaddr_dl *>(p->ifa_addr);
|
|
||||||
if (sdl->sdl_alen == 6) {
|
|
||||||
memcpy(dmac, LLADDR(sdl), 6);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freeifaddrs(ifap);
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
// No platform-specific MAC source; leave dmac at its default. Caller
|
// No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default;
|
||||||
// can override via the --hwid CLI flag or the YAML config.
|
// the caller can override via the --hwid CLI flag or the YAML config.
|
||||||
(void)dmac;
|
(void)dmac;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -302,9 +264,10 @@ void portduinoSetup()
|
|||||||
// Try CH341
|
// Try CH341
|
||||||
try {
|
try {
|
||||||
std::cout << "autoconf: Looking for CH341 device..." << std::endl;
|
std::cout << "autoconf: Looking for CH341 device..." << std::endl;
|
||||||
auto probe = std::unique_ptr<Ch341Hal>(new Ch341Hal(0, portduino_config.lora_usb_serial_num,
|
ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid,
|
||||||
portduino_config.lora_usb_vid, portduino_config.lora_usb_pid));
|
portduino_config.lora_usb_pid);
|
||||||
probe->getProductString(autoconf_product, 95);
|
ch341Hal->getProductString(autoconf_product, 95);
|
||||||
|
delete ch341Hal;
|
||||||
std::cout << "autoconf: Found CH341 device " << autoconf_product << std::endl;
|
std::cout << "autoconf: Found CH341 device " << autoconf_product << std::endl;
|
||||||
|
|
||||||
found_ch341 = true;
|
found_ch341 = true;
|
||||||
@@ -1093,31 +1056,17 @@ static bool ends_with(std::string_view str, std::string_view suffix)
|
|||||||
bool MAC_from_string(std::string mac_str, uint8_t *dmac)
|
bool MAC_from_string(std::string mac_str, uint8_t *dmac)
|
||||||
{
|
{
|
||||||
mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end());
|
mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end());
|
||||||
if (mac_str.length() != 12) {
|
if (mac_str.length() == 12) {
|
||||||
return false;
|
dmac[0] = std::stoi(portduino_config.mac_address.substr(0, 2), nullptr, 16);
|
||||||
}
|
dmac[1] = std::stoi(portduino_config.mac_address.substr(2, 2), nullptr, 16);
|
||||||
// Validate every character is a hex digit before parsing. std::stoi
|
dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16);
|
||||||
// would otherwise skip leading whitespace and silently truncate at the
|
dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16);
|
||||||
// first non-digit, which is too lenient for a MAC address.
|
dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16);
|
||||||
for (char c : mac_str) {
|
dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16);
|
||||||
if (!isxdigit(static_cast<unsigned char>(c))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Parse into a temporary so dmac is not partially modified if a later
|
|
||||||
// byte fails. At least one caller in getMacAddr() ignores the bool
|
|
||||||
// return, so leaving stale bytes in dmac on failure would silently
|
|
||||||
// produce a wrong MAC.
|
|
||||||
uint8_t tmp[6];
|
|
||||||
try {
|
|
||||||
for (int i = 0; i < 6; i++) {
|
|
||||||
tmp[i] = static_cast<uint8_t>(std::stoi(mac_str.substr(i * 2, 2), nullptr, 16));
|
|
||||||
}
|
|
||||||
} catch (const std::exception &) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
memcpy(dmac, tmp, 6);
|
|
||||||
return true;
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string exec(const char *cmd)
|
std::string exec(const char *cmd)
|
||||||
|
|||||||
@@ -25,27 +25,23 @@
|
|||||||
#include "LittleFS.h"
|
#include "LittleFS.h"
|
||||||
#include "stm32wlxx_hal_flash.h"
|
#include "stm32wlxx_hal_flash.h"
|
||||||
|
|
||||||
/** Suppress unused-parameter warnings. */
|
/**********************************************************************************************************************
|
||||||
|
* Macro definitions
|
||||||
|
**********************************************************************************************************************/
|
||||||
|
/** This macro is used to suppress compiler messages about a parameter not being used in a function. */
|
||||||
#define LFS_UNUSED(p) (void)((p))
|
#define LFS_UNUSED(p) (void)((p))
|
||||||
|
|
||||||
#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) // physical flash erase granularity: 2048 B
|
#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE)
|
||||||
#define STM32WL_PAGE_COUNT (FLASH_PAGE_NB)
|
#define STM32WL_PAGE_COUNT (FLASH_PAGE_NB)
|
||||||
#define STM32WL_FLASH_BASE (FLASH_BASE)
|
#define STM32WL_FLASH_BASE (FLASH_BASE)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* LFS tunables — all of these are stored in the LFS superblock.
|
* FLASH_SIZE from stm32wle5xx.h will read the actual FLASH size from the chip.
|
||||||
* Changing ANY of them is incompatible with the existing on-disk format;
|
* FLASH_END_ADDR is calculated from FLASH_SIZE.
|
||||||
* the filesystem will be detected as corrupted and reformatted on first boot.
|
* Use the last 28 KiB of the FLASH
|
||||||
*
|
|
||||||
* LFS_FLASH_TOTAL_SIZE and LFS_BLOCK_SIZE are the only values to edit here.
|
|
||||||
* All other parameters are derived.
|
|
||||||
*
|
|
||||||
* FLASH_END_ADDR is computed from FLASH_SIZE (read from the chip at link time).
|
|
||||||
*/
|
*/
|
||||||
#define LFS_FLASH_TOTAL_SIZE \
|
#define LFS_FLASH_TOTAL_SIZE (14 * 2048) /* needs to be a multiple of LFS_BLOCK_SIZE */
|
||||||
(7 * STM32WL_PAGE_SIZE) /* 14 KiB — last 7 physical pages (FORMAT BREAK: reduced from 10 pages / 20 KiB) */
|
#define LFS_BLOCK_SIZE (2048)
|
||||||
#define LFS_BLOCK_SIZE (256) /* virtual block size (FORMAT BREAK if changed) */
|
|
||||||
|
|
||||||
#define LFS_FLASH_ADDR_END (FLASH_END_ADDR)
|
#define LFS_FLASH_ADDR_END (FLASH_END_ADDR)
|
||||||
#define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1)
|
#define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1)
|
||||||
|
|
||||||
@@ -55,80 +51,6 @@
|
|||||||
#define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__)
|
#define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
//--------------------------------------------------------------------+
|
|
||||||
// Write-behind page cache
|
|
||||||
//
|
|
||||||
// LFS requires block_size == erase granularity, but the STM32WL flash
|
|
||||||
// erases in 2048-byte pages. To use smaller virtual LFS blocks we
|
|
||||||
// maintain a single-page RAM cache: the LFS erase/prog callbacks only
|
|
||||||
// update this buffer; the physical erase+reprogram is deferred to
|
|
||||||
// _internal_flash_sync() (or triggered automatically when a different
|
|
||||||
// physical page is addressed).
|
|
||||||
//
|
|
||||||
// This mirrors the approach used by the NRF52 Adafruit driver
|
|
||||||
// (flash_cache.c / flash_nrf5x.c) but adapted for the 2048-byte STM32WL
|
|
||||||
// page size and HAL doubleword-program requirement.
|
|
||||||
//--------------------------------------------------------------------+
|
|
||||||
|
|
||||||
alignas(8) static uint8_t _page_cache[STM32WL_PAGE_SIZE];
|
|
||||||
static uint32_t _page_cache_addr = UINT32_MAX; // UINT32_MAX = no page cached
|
|
||||||
static bool _page_cache_dirty = false;
|
|
||||||
|
|
||||||
/** Flush the cached page to flash (physical erase + doubleword program). */
|
|
||||||
static int _flash_cache_flush(void)
|
|
||||||
{
|
|
||||||
if (!_page_cache_dirty)
|
|
||||||
return LFS_ERR_OK;
|
|
||||||
|
|
||||||
FLASH_EraseInitTypeDef erase = {
|
|
||||||
.TypeErase = FLASH_TYPEERASE_PAGES,
|
|
||||||
.Page = (_page_cache_addr - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE,
|
|
||||||
.NbPages = 1,
|
|
||||||
};
|
|
||||||
uint32_t page_error = 0;
|
|
||||||
|
|
||||||
if (HAL_FLASH_Unlock() != HAL_OK)
|
|
||||||
return LFS_ERR_IO;
|
|
||||||
__HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS);
|
|
||||||
|
|
||||||
HAL_StatusTypeDef rc = HAL_FLASHEx_Erase(&erase, &page_error);
|
|
||||||
if (rc == HAL_OK) {
|
|
||||||
const uint64_t *p = (const uint64_t *)_page_cache;
|
|
||||||
uint32_t addr = _page_cache_addr;
|
|
||||||
for (size_t i = 0; i < STM32WL_PAGE_SIZE / 8; i++) {
|
|
||||||
rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr, *p++);
|
|
||||||
if (rc != HAL_OK)
|
|
||||||
break;
|
|
||||||
addr += 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HAL_StatusTypeDef lock_rc = HAL_FLASH_Lock();
|
|
||||||
if (rc == HAL_OK)
|
|
||||||
rc = lock_rc;
|
|
||||||
|
|
||||||
if (rc == HAL_OK)
|
|
||||||
_page_cache_dirty = false;
|
|
||||||
return rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the physical page containing `page_addr` is loaded into the cache.
|
|
||||||
* If a different dirty page is already cached it is flushed first.
|
|
||||||
*/
|
|
||||||
static int _flash_cache_load(uint32_t page_addr)
|
|
||||||
{
|
|
||||||
if (_page_cache_addr == page_addr)
|
|
||||||
return LFS_ERR_OK; // already cached
|
|
||||||
|
|
||||||
int rc = _flash_cache_flush();
|
|
||||||
if (rc != LFS_ERR_OK)
|
|
||||||
return rc;
|
|
||||||
|
|
||||||
memcpy(_page_cache, (const void *)page_addr, STM32WL_PAGE_SIZE);
|
|
||||||
_page_cache_addr = page_addr;
|
|
||||||
return LFS_ERR_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
//--------------------------------------------------------------------+
|
//--------------------------------------------------------------------+
|
||||||
// LFS Disk IO
|
// LFS Disk IO
|
||||||
//--------------------------------------------------------------------+
|
//--------------------------------------------------------------------+
|
||||||
@@ -137,65 +59,95 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l
|
|||||||
{
|
{
|
||||||
LFS_UNUSED(c);
|
LFS_UNUSED(c);
|
||||||
|
|
||||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off;
|
if (!buffer || !size) {
|
||||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
_LFS_DBG("%s Invalid parameter!\r\n", __func__);
|
||||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
return LFS_ERR_INVAL;
|
||||||
|
}
|
||||||
|
|
||||||
if (_page_cache_addr == page_addr)
|
lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off);
|
||||||
memcpy(buffer, _page_cache + page_off, size);
|
|
||||||
else
|
memcpy(buffer, (void *)address, size);
|
||||||
memcpy(buffer, (const void *)addr, size);
|
|
||||||
|
|
||||||
return LFS_ERR_OK;
|
return LFS_ERR_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Program a region in a block. The block must have previously been erased.
|
// Program a region in a block. The block must have previously
|
||||||
// Writes are accumulated in the page cache and flushed on sync or page eviction.
|
// been erased. Negative error codes are propogated to the user.
|
||||||
|
// May return LFS_ERR_CORRUPT if the block should be considered bad.
|
||||||
static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size)
|
static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size)
|
||||||
{
|
{
|
||||||
|
lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off);
|
||||||
|
HAL_StatusTypeDef hal_rc = HAL_OK;
|
||||||
|
uint32_t dw_count = size / 8;
|
||||||
|
uint64_t *bufp = (uint64_t *)buffer;
|
||||||
|
|
||||||
LFS_UNUSED(c);
|
LFS_UNUSED(c);
|
||||||
|
|
||||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off;
|
_LFS_DBG("Programming %d bytes/%d doublewords at address 0x%08x/block %d, offset %d.", size, dw_count, address, block, off);
|
||||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
if (HAL_FLASH_Unlock() != HAL_OK) {
|
||||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
return LFS_ERR_IO;
|
||||||
|
}
|
||||||
int rc = _flash_cache_load(page_addr);
|
for (uint32_t i = 0; i < dw_count; i++) {
|
||||||
if (rc != LFS_ERR_OK)
|
if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) {
|
||||||
return rc;
|
_LFS_DBG("Wanted to program out of bound of FLASH: 0x%08x.\n", address);
|
||||||
|
HAL_FLASH_Lock();
|
||||||
memcpy(_page_cache + page_off, buffer, size);
|
return LFS_ERR_INVAL;
|
||||||
_page_cache_dirty = true;
|
}
|
||||||
return LFS_ERR_OK;
|
hal_rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, address, *bufp);
|
||||||
|
if (hal_rc != HAL_OK) {
|
||||||
|
/* Error occurred while writing data in Flash memory.
|
||||||
|
* User can add here some code to deal with this error.
|
||||||
|
*/
|
||||||
|
_LFS_DBG("Program error at (0x%08x), 0x%X, error: 0x%08x\n", address, hal_rc, HAL_FLASH_GetError());
|
||||||
|
}
|
||||||
|
address += 8;
|
||||||
|
bufp += 1;
|
||||||
|
}
|
||||||
|
if (HAL_FLASH_Lock() != HAL_OK) {
|
||||||
|
return LFS_ERR_IO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erase a virtual block. Marks the corresponding region in the page cache as 0xFF.
|
return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO
|
||||||
// Physical erase of the containing page is deferred until sync or page eviction.
|
}
|
||||||
|
|
||||||
|
// Erase a block. A block must be erased before being programmed.
|
||||||
|
// The state of an erased block is undefined. Negative error codes
|
||||||
|
// are propogated to the user.
|
||||||
|
// May return LFS_ERR_CORRUPT if the block should be considered bad.
|
||||||
static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block)
|
static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block)
|
||||||
{
|
{
|
||||||
|
lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE);
|
||||||
|
HAL_StatusTypeDef hal_rc;
|
||||||
|
FLASH_EraseInitTypeDef EraseInitStruct = {.TypeErase = FLASH_TYPEERASE_PAGES, .Page = 0, .NbPages = 1};
|
||||||
|
uint32_t PAGEError = 0;
|
||||||
|
|
||||||
LFS_UNUSED(c);
|
LFS_UNUSED(c);
|
||||||
|
|
||||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE;
|
if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) {
|
||||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
_LFS_DBG("Wanted to erase out of bound of FLASH: 0x%08x.\n", address);
|
||||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
return LFS_ERR_INVAL;
|
||||||
|
}
|
||||||
|
/* calculate the absolute page, i.e. what the ST wants */
|
||||||
|
EraseInitStruct.Page = (address - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE;
|
||||||
|
_LFS_DBG("Erasing block %d at 0x%08x... ", block, address);
|
||||||
|
HAL_FLASH_Unlock();
|
||||||
|
hal_rc = HAL_FLASHEx_Erase(&EraseInitStruct, &PAGEError);
|
||||||
|
HAL_FLASH_Lock();
|
||||||
|
|
||||||
int rc = _flash_cache_load(page_addr);
|
return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO
|
||||||
if (rc != LFS_ERR_OK)
|
|
||||||
return rc;
|
|
||||||
|
|
||||||
memset(_page_cache + page_off, 0xFF, LFS_BLOCK_SIZE);
|
|
||||||
_page_cache_dirty = true;
|
|
||||||
return LFS_ERR_OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush the write-behind cache to flash.
|
// Sync the state of the underlying block device. Negative error codes
|
||||||
|
// are propogated to the user.
|
||||||
static int _internal_flash_sync(const struct lfs_config *c)
|
static int _internal_flash_sync(const struct lfs_config *c)
|
||||||
{
|
{
|
||||||
LFS_UNUSED(c);
|
LFS_UNUSED(c);
|
||||||
return _flash_cache_flush();
|
// write function performs no caching. No need for sync.
|
||||||
|
|
||||||
|
return LFS_ERR_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
static struct lfs_config _InternalFSConfig = {
|
static struct lfs_config _InternalFSConfig = {.context = NULL,
|
||||||
.context = NULL,
|
|
||||||
|
|
||||||
.read = _internal_flash_read,
|
.read = _internal_flash_read,
|
||||||
.prog = _internal_flash_prog,
|
.prog = _internal_flash_prog,
|
||||||
@@ -211,8 +163,7 @@ static struct lfs_config _InternalFSConfig = {
|
|||||||
.read_buffer = NULL,
|
.read_buffer = NULL,
|
||||||
.prog_buffer = NULL,
|
.prog_buffer = NULL,
|
||||||
.lookahead_buffer = NULL,
|
.lookahead_buffer = NULL,
|
||||||
.file_buffer = NULL,
|
.file_buffer = NULL};
|
||||||
};
|
|
||||||
|
|
||||||
LittleFS InternalFS;
|
LittleFS InternalFS;
|
||||||
|
|
||||||
@@ -228,17 +179,17 @@ bool LittleFS::begin(void)
|
|||||||
/* There is not enough space on this device for a filesystem. */
|
/* There is not enough space on this device for a filesystem. */
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// failed to mount, erase all virtual blocks then format and mount again
|
// failed to mount, erase all pages then format and mount again
|
||||||
if (!STM32_LittleFS::begin()) {
|
if (!STM32_LittleFS::begin()) {
|
||||||
for (lfs_block_t block = 0; block < _InternalFSConfig.block_count; block++) {
|
// Erase all pages of internal flash region for Filesystem.
|
||||||
_internal_flash_erase(&_InternalFSConfig, block);
|
for (uint32_t addr = LFS_FLASH_ADDR_BASE; addr < (LFS_FLASH_ADDR_END + 1); addr += STM32WL_PAGE_SIZE) {
|
||||||
|
_internal_flash_erase(&_InternalFSConfig, (addr - LFS_FLASH_ADDR_BASE) / STM32WL_PAGE_SIZE);
|
||||||
}
|
}
|
||||||
_flash_cache_flush(); // flush the last cached page
|
|
||||||
|
|
||||||
// lfs format
|
// lfs format
|
||||||
this->format();
|
this->format();
|
||||||
|
|
||||||
// mount again; if still failed, give up
|
// mount again if still failed, give up
|
||||||
if (!STM32_LittleFS::begin())
|
if (!STM32_LittleFS::begin())
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
* THE SOFTWARE.
|
* THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "LittleFS.h"
|
|
||||||
#include "STM32_LittleFS.h"
|
#include "STM32_LittleFS.h"
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
@@ -392,8 +391,3 @@ void File::rewindDirectory(void)
|
|||||||
}
|
}
|
||||||
_fs->_unlockFS();
|
_fs->_unlockFS();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default constructor — binds to the global InternalFS instance.
|
|
||||||
// Allows File to be declared without an explicit filesystem argument,
|
|
||||||
// matching the API of ESP32/RP2040/Portduino File objects.
|
|
||||||
File::File() : File(InternalFS) {}
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class File : public Stream
|
|||||||
public:
|
public:
|
||||||
explicit File(STM32_LittleFS &fs);
|
explicit File(STM32_LittleFS &fs);
|
||||||
File(char const *filename, uint8_t mode, STM32_LittleFS &fs);
|
File(char const *filename, uint8_t mode, STM32_LittleFS &fs);
|
||||||
File(); // default-constructs against InternalFS; defined in STM32_LittleFS_File.cpp
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
bool open(char const *filename, uint8_t mode);
|
bool open(char const *filename, uint8_t mode);
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ class XModemAdapter
|
|||||||
|
|
||||||
uint16_t packetno = 0;
|
uint16_t packetno = 0;
|
||||||
|
|
||||||
|
#if defined(ARCH_NRF52) || defined(ARCH_STM32WL)
|
||||||
|
File file = File(FSCom);
|
||||||
|
#else
|
||||||
File file;
|
File file;
|
||||||
|
#endif
|
||||||
|
|
||||||
char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
|
char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
// Unit tests for MAC_from_string in src/platform/portduino/PortduinoGlue.cpp.
|
|
||||||
//
|
|
||||||
// Regression coverage for when the function stripped colons from
|
|
||||||
// its mac_str parameter but then read bytes from the global
|
|
||||||
// portduino_config.mac_address. Symptoms: --hwid silently ignored when
|
|
||||||
// MACAddress: was also set, and SIGABRT (stoi: no conversion) when --hwid
|
|
||||||
// was used without MACAddress: in config.yaml.
|
|
||||||
#include "Arduino.h"
|
|
||||||
#include "TestUtil.h"
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
|
||||||
#include <unity.h>
|
|
||||||
|
|
||||||
// Forward-declare instead of including PortduinoGlue.h to avoid pulling in
|
|
||||||
// LR11x0Interface, USBHal, mesh.pb.h, yaml-cpp, and the full portduino_config
|
|
||||||
// struct just to test a self-contained string parser. The symbol is defined
|
|
||||||
// in PortduinoGlue.cpp and resolved at link time.
|
|
||||||
bool MAC_from_string(std::string mac_str, uint8_t *dmac);
|
|
||||||
|
|
||||||
void setUp(void) {}
|
|
||||||
void tearDown(void) {}
|
|
||||||
|
|
||||||
// --- Happy-path parsing ---
|
|
||||||
|
|
||||||
void test_colon_separated_uppercase()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xBB, dmac[1]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xCC, dmac[2]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xDD, dmac[3]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xEE, dmac[4]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_colon_separated_lowercase()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("02:ca:fe:ba:be:01", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0x02, dmac[0]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xCA, dmac[1]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xFE, dmac[2]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xBA, dmac[3]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xBE, dmac[4]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0x01, dmac[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_no_colons_packed_hex()
|
|
||||||
{
|
|
||||||
// The CLI form produced by some tools — 12 hex chars, no separators.
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("AABBCCDDEEFF", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_two_distinct_inputs_yield_distinct_outputs()
|
|
||||||
{
|
|
||||||
// Direct regression for the original bug: parsing two different MAC
|
|
||||||
// strings in succession must produce two different byte sequences.
|
|
||||||
// Pre-fix, both calls would have produced identical bytes derived from
|
|
||||||
// the (untouched) global portduino_config.mac_address.
|
|
||||||
uint8_t a[6] = {0};
|
|
||||||
uint8_t b[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", a));
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("02:CA:FE:BA:BE:01", b));
|
|
||||||
TEST_ASSERT_NOT_EQUAL(0, std::memcmp(a, b, 6));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xAA, a[0]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0x02, b[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_does_not_read_external_state()
|
|
||||||
{
|
|
||||||
// The function must derive every byte from its parameter, not from any
|
|
||||||
// global. Provide a unique MAC and verify all six bytes match the input
|
|
||||||
// exactly — leaves no room for the function to be smuggling bytes from
|
|
||||||
// elsewhere.
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("12:34:56:78:9A:BC", dmac));
|
|
||||||
const uint8_t expected[6] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC};
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Rejected inputs ---
|
|
||||||
// Pre-fix, the empty/short cases either crashed (stoi exception on substr("")
|
|
||||||
// of the empty global) or silently filled dmac with stale bytes. Post-fix,
|
|
||||||
// the length guard rejects them cleanly with `false` and dmac is unchanged.
|
|
||||||
|
|
||||||
void test_empty_string_returns_false()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11};
|
|
||||||
uint8_t before[6];
|
|
||||||
std::memcpy(before, dmac, 6);
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("", dmac));
|
|
||||||
// dmac must be untouched on failure.
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_too_short_returns_false()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11};
|
|
||||||
uint8_t before[6];
|
|
||||||
std::memcpy(before, dmac, 6);
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_too_long_returns_false()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
// 14 hex chars after colon-strip > 12.
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:FF:00", dmac));
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_only_colons_returns_false()
|
|
||||||
{
|
|
||||||
uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11};
|
|
||||||
uint8_t before[6];
|
|
||||||
std::memcpy(before, dmac, 6);
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string(":::::", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_extra_colons_still_parses()
|
|
||||||
{
|
|
||||||
// Colon stripping happens before length check, so an unconventional
|
|
||||||
// grouping that totals 12 hex chars after stripping is still accepted.
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_TRUE(MAC_from_string("AABB:CCDD:EEFF", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]);
|
|
||||||
TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_non_hex_input_returns_false()
|
|
||||||
{
|
|
||||||
// 12 chars of non-hex would have made std::stoi throw before the
|
|
||||||
// try/catch wrapper was added, killing the daemon. Now must return false
|
|
||||||
// and leave dmac untouched.
|
|
||||||
uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11};
|
|
||||||
uint8_t before[6];
|
|
||||||
std::memcpy(before, dmac, 6);
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_partial_hex_failure_preserves_dmac()
|
|
||||||
{
|
|
||||||
// First five bytes are valid hex; the sixth ("ZZ") is not. Without the
|
|
||||||
// temp-buffer staging, dmac would be partially overwritten with the five
|
|
||||||
// good bytes plus stale data in slot 5 — silently producing a wrong MAC
|
|
||||||
// since the only caller that uses this in getMacAddr() ignores the bool
|
|
||||||
// return value.
|
|
||||||
uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11};
|
|
||||||
uint8_t before[6];
|
|
||||||
std::memcpy(before, dmac, 6);
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:ZZ", dmac));
|
|
||||||
TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
void test_embedded_non_hex_returns_false()
|
|
||||||
{
|
|
||||||
// std::stoi tolerates leading whitespace and a "0x" prefix, so a stray
|
|
||||||
// space inside a 2-char window like " F" would silently parse as 0xF.
|
|
||||||
// The per-character isxdigit() pre-check rejects these. The 14-char
|
|
||||||
// "0xAABBCCDDEEFF" is also rejected by the length check.
|
|
||||||
uint8_t dmac[6] = {0};
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE: F", dmac));
|
|
||||||
TEST_ASSERT_FALSE(MAC_from_string("0xAABBCCDDEEFF", dmac));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Unity lifecycle ---
|
|
||||||
|
|
||||||
void setup()
|
|
||||||
{
|
|
||||||
initializeTestEnvironment();
|
|
||||||
UNITY_BEGIN();
|
|
||||||
RUN_TEST(test_colon_separated_uppercase);
|
|
||||||
RUN_TEST(test_colon_separated_lowercase);
|
|
||||||
RUN_TEST(test_no_colons_packed_hex);
|
|
||||||
RUN_TEST(test_two_distinct_inputs_yield_distinct_outputs);
|
|
||||||
RUN_TEST(test_does_not_read_external_state);
|
|
||||||
RUN_TEST(test_empty_string_returns_false);
|
|
||||||
RUN_TEST(test_too_short_returns_false);
|
|
||||||
RUN_TEST(test_too_long_returns_false);
|
|
||||||
RUN_TEST(test_only_colons_returns_false);
|
|
||||||
RUN_TEST(test_extra_colons_still_parses);
|
|
||||||
RUN_TEST(test_non_hex_input_returns_false);
|
|
||||||
RUN_TEST(test_partial_hex_failure_preserves_dmac);
|
|
||||||
RUN_TEST(test_embedded_non_hex_returns_false);
|
|
||||||
exit(UNITY_END());
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {}
|
|
||||||
@@ -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_telemetry_environment_metrics_unset_fields();
|
||||||
void test_encrypted_packet_serialization();
|
void test_encrypted_packet_serialization();
|
||||||
void test_empty_encrypted_packet();
|
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()
|
void setup()
|
||||||
{
|
{
|
||||||
@@ -52,6 +56,12 @@ void setup()
|
|||||||
RUN_TEST(test_encrypted_packet_serialization);
|
RUN_TEST(test_encrypted_packet_serialization);
|
||||||
RUN_TEST(test_empty_encrypted_packet);
|
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();
|
UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -800,17 +800,6 @@ void test_disabled(void)
|
|||||||
TEST_ASSERT_FALSE(mqtt->isEnabled());
|
TEST_ASSERT_FALSE(mqtt->isEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_mqttInitSkipsAllocationWhenDisabled(void)
|
|
||||||
{
|
|
||||||
delete unitTest;
|
|
||||||
mqtt = unitTest = NULL;
|
|
||||||
|
|
||||||
moduleConfig.mqtt.enabled = false;
|
|
||||||
mqttInit();
|
|
||||||
|
|
||||||
TEST_ASSERT_NULL(mqtt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscriptions contain the moduleConfig.mqtt.root prefix.
|
// Subscriptions contain the moduleConfig.mqtt.root prefix.
|
||||||
void test_customMqttRoot(void)
|
void test_customMqttRoot(void)
|
||||||
{
|
{
|
||||||
@@ -923,7 +912,6 @@ void setup()
|
|||||||
RUN_TEST(test_usingCustomServer);
|
RUN_TEST(test_usingCustomServer);
|
||||||
RUN_TEST(test_enabled);
|
RUN_TEST(test_enabled);
|
||||||
RUN_TEST(test_disabled);
|
RUN_TEST(test_disabled);
|
||||||
RUN_TEST(test_mqttInitSkipsAllocationWhenDisabled);
|
|
||||||
RUN_TEST(test_customMqttRoot);
|
RUN_TEST(test_customMqttRoot);
|
||||||
RUN_TEST(test_configEmptyIsValid);
|
RUN_TEST(test_configEmptyIsValid);
|
||||||
RUN_TEST(test_configEnabledEmptyIsValid);
|
RUN_TEST(test_configEnabledEmptyIsValid);
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32_base.lib_deps}
|
${esp32_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ lib_deps =
|
|||||||
# renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino
|
# renovate: datasource=custom.pio depName=NimBLE-Arduino packageName=h2zero/library/NimBLE-Arduino
|
||||||
h2zero/NimBLE-Arduino@1.4.3
|
h2zero/NimBLE-Arduino@1.4.3
|
||||||
# renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master
|
# renovate: datasource=git-refs depName=libpax packageName=https://github.com/dbinfrago/libpax gitBranch=master
|
||||||
https://github.com/dbinfrago/libpax/archive/df424747f9acb86ab07c5a206ded1e8e3650726a.zip
|
https://github.com/dbinfrago/libpax/archive/3cdc0371c375676a97967547f4065607d4c53fd1.zip
|
||||||
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
|
# renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib
|
||||||
lewisxhe/XPowersLib@0.3.3
|
lewisxhe/XPowersLib@0.3.3
|
||||||
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ lib_ignore =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32_base.lib_deps}
|
${esp32_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32_base.lib_deps}
|
${esp32_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander
|
# renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander
|
||||||
sparkfun/SX1509 IO Expander@3.0.6
|
sparkfun/SX1509 IO Expander@3.0.6
|
||||||
# renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102
|
# renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102
|
||||||
|
|||||||
@@ -133,6 +133,6 @@ build_flags =
|
|||||||
lib_deps = ${heltec_v4_base.lib_deps}
|
lib_deps = ${heltec_v4_base.lib_deps}
|
||||||
${device-ui_base.lib_deps}
|
${device-ui_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
|
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
|
||||||
https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
|
https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
|
||||||
|
|||||||
@@ -140,6 +140,6 @@ build_flags =
|
|||||||
lib_deps = ${heltec_v4_r8_base.lib_deps}
|
lib_deps = ${heltec_v4_r8_base.lib_deps}
|
||||||
${device-ui_base.lib_deps}
|
${device-ui_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
|
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
|
||||||
https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip
|
https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip
|
||||||
@@ -24,4 +24,4 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
#define ST7789_BUSY -1
|
#define ST7789_BUSY -1
|
||||||
// #define VTFT_CTRL 38
|
// #define VTFT_CTRL 38
|
||||||
#define VTFT_LEDA 38
|
#define VTFT_LEDA 38
|
||||||
#define TFT_BACKLIGHT_ON HIGH
|
|
||||||
// #define ST7789_BL (32+6)
|
// #define ST7789_BL (32+6)
|
||||||
#define ST7789_SPI_HOST SPI2_HOST
|
#define ST7789_SPI_HOST SPI2_HOST
|
||||||
// #define TFT_BL (32+6)
|
// #define TFT_BL (32+6)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ lib_deps =
|
|||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
${device-ui_base.lib_deps}
|
${device-ui_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|
||||||
[mesh_tab_xpt2046]
|
[mesh_tab_xpt2046]
|
||||||
extends = mesh_tab_base
|
extends = mesh_tab_base
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|
||||||
build_src_filter =
|
build_src_filter =
|
||||||
${esp32s3_base.build_src_filter}
|
${esp32s3_base.build_src_filter}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|
||||||
[env:rak_wismesh_tap_v2-tft]
|
[env:rak_wismesh_tap_v2-tft]
|
||||||
extends = env:rak_wismesh_tap_v2
|
extends = env:rak_wismesh_tap_v2
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags}
|
|||||||
|
|
||||||
lib_deps = ${esp32s3_base.lib_deps}
|
lib_deps = ${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
|
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
|
||||||
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
|
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
|
||||||
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
|
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags}
|
|||||||
|
|
||||||
lib_deps = ${esp32s3_base.lib_deps}
|
lib_deps = ${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib
|
# renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib
|
||||||
lewisxhe/SensorLib@0.3.4
|
lewisxhe/SensorLib@0.3.4
|
||||||
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
|
# renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags}
|
|||||||
|
|
||||||
lib_deps = ${esp32s3_base.lib_deps}
|
lib_deps = ${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
|
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
|
||||||
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
|
https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip
|
||||||
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
|
# renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|
||||||
[env:tracksenger-lcd]
|
[env:tracksenger-lcd]
|
||||||
custom_meshtastic_hw_model = 48
|
custom_meshtastic_hw_model = 48
|
||||||
@@ -48,7 +48,7 @@ build_flags =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${esp32s3_base.lib_deps}
|
${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
|
|
||||||
[env:tracksenger-oled]
|
[env:tracksenger-oled]
|
||||||
custom_meshtastic_hw_model = 48
|
custom_meshtastic_hw_model = 48
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ build_src_filter =
|
|||||||
|
|
||||||
lib_deps = ${esp32s3_base.lib_deps}
|
lib_deps = ${esp32s3_base.lib_deps}
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
# TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0
|
# TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0
|
||||||
https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip
|
https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
[portduino_base]
|
[portduino_base]
|
||||||
platform =
|
platform =
|
||||||
# renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop
|
# renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop
|
||||||
https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip
|
https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip
|
||||||
framework = arduino
|
framework = arduino
|
||||||
|
|
||||||
build_src_filter =
|
build_src_filter =
|
||||||
@@ -27,7 +27,7 @@ lib_deps =
|
|||||||
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
# renovate: datasource=custom.pio depName=rweather/Crypto packageName=rweather/library/Crypto
|
||||||
rweather/Crypto@0.4.0
|
rweather/Crypto@0.4.0
|
||||||
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
|
||||||
lovyan03/LovyanGFX@1.2.21
|
lovyan03/LovyanGFX@1.2.19
|
||||||
; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main
|
; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main
|
||||||
https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip
|
https://github.com/pine64/libch341-spi-userspace/archive/23c42319a69cffcb65868e3c72e6bed83974a393.zip
|
||||||
# renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library
|
# renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ test_testing_command =
|
|||||||
;
|
;
|
||||||
; Prerequisites (Homebrew):
|
; Prerequisites (Homebrew):
|
||||||
; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config
|
; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config
|
||||||
; # Optional: enable the HTTP API (PiWebServer) on macOS:
|
|
||||||
; brew install ulfius
|
|
||||||
;
|
;
|
||||||
; The macOS-side patches now live upstream:
|
; The macOS-side patches now live upstream:
|
||||||
; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`,
|
; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`,
|
||||||
@@ -193,16 +191,7 @@ build_flags = ${portduino_base.build_flags_common}
|
|||||||
; style screen-driver hooks scattered through sensor sources.
|
; style screen-driver hooks scattered through sensor sources.
|
||||||
-DHAS_SCREEN=0
|
-DHAS_SCREEN=0
|
||||||
-DMESHTASTIC_EXCLUDE_SCREEN=1
|
-DMESHTASTIC_EXCLUDE_SCREEN=1
|
||||||
; openssl@3 is the keg-only Homebrew formula; --cflags is required so the
|
!pkg-config --libs openssl --silence-errors || :
|
||||||
; compiler finds <openssl/*.h> in the Homebrew prefix (not just the linker).
|
|
||||||
!pkg-config --cflags --libs openssl --silence-errors || :
|
|
||||||
; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius
|
|
||||||
; headers are reachable via `#if __has_include(<ulfius.h>)`. The `|| :`
|
|
||||||
; tail keeps the build green when the user hasn't run `brew install ulfius`
|
|
||||||
; — they just don't get the HTTP API in that case.
|
|
||||||
!pkg-config --cflags --libs liborcania --silence-errors || :
|
|
||||||
!pkg-config --cflags --libs libyder --silence-errors || :
|
|
||||||
!pkg-config --cflags --libs libulfius --silence-errors || :
|
|
||||||
; src/input/Linux*.{cpp,h} drive evdev (`<linux/input.h>`) which doesn't exist
|
; src/input/Linux*.{cpp,h} drive evdev (`<linux/input.h>`) which doesn't exist
|
||||||
; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX
|
; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX
|
||||||
; (which we lib_ignore on macOS for the <malloc.h> issue). Neither is needed
|
; (which we lib_ignore on macOS for the <malloc.h> issue). Neither is needed
|
||||||
@@ -217,28 +206,3 @@ build_src_filter = ${native_base.build_src_filter}
|
|||||||
lib_ignore =
|
lib_ignore =
|
||||||
${portduino_base.lib_ignore}
|
${portduino_base.lib_ignore}
|
||||||
LovyanGFX
|
LovyanGFX
|
||||||
|
|
||||||
; ---------------------------------------------------------------------------
|
|
||||||
; Same as [env:native-macos] but built with AddressSanitizer for catching
|
|
||||||
; use-after-free, leaks, and OOB access during local development. Headless
|
|
||||||
; (no SDL/X11/libinput) so it stays cheap to build. Mirrors the shape of
|
|
||||||
; [env:native-tft-debug] but without the TFT/X11 dependencies.
|
|
||||||
;
|
|
||||||
; pio run -e native-macos-debug
|
|
||||||
; .pio/build/native-macos-debug/meshtasticd -s
|
|
||||||
;
|
|
||||||
; ASan runtime tuning (set in the shell before launching):
|
|
||||||
; ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:abort_on_error=1
|
|
||||||
; MallocStackLogging=1 # macOS: nicer stack traces in malloc reports
|
|
||||||
; ---------------------------------------------------------------------------
|
|
||||||
[env:native-macos-debug]
|
|
||||||
extends = native_base
|
|
||||||
build_type = debug
|
|
||||||
build_unflags = ${env:native-macos.build_unflags}
|
|
||||||
build_flags = ${env:native-macos.build_flags}
|
|
||||||
-O0
|
|
||||||
-g
|
|
||||||
-fsanitize=address
|
|
||||||
-fno-omit-frame-pointer
|
|
||||||
build_src_filter = ${env:native-macos.build_src_filter}
|
|
||||||
lib_ignore = ${env:native-macos.lib_ignore}
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ board = promicro-nrf52840
|
|||||||
build_flags = ${nrf52840_base.build_flags}
|
build_flags = ${nrf52840_base.build_flags}
|
||||||
-I variants/nrf52840/diy/nrf52_promicro_diy_tcxo
|
-I variants/nrf52840/diy/nrf52_promicro_diy_tcxo
|
||||||
-D NRF52_PROMICRO_DIY
|
-D NRF52_PROMICRO_DIY
|
||||||
; -D RADIOLIB_GODMODE=1 ; needed for some LR2021 items, but not enabled by default.
|
|
||||||
build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo>
|
build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo>
|
||||||
debug_tool = jlink
|
debug_tool = jlink
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
#pragma once
|
|
||||||
#include "RadioLib.h"
|
#include "RadioLib.h"
|
||||||
|
|
||||||
// Keep LR20x0 naming while RadioLib exposes LR2021 symbols.
|
|
||||||
#ifndef LR20x0
|
|
||||||
#define LR20x0 LR2021
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// This is rewritten to match the requirements of the E80-900M2213S
|
// This is rewritten to match the requirements of the E80-900M2213S
|
||||||
// The E80 does not conform to the reference Semtech switches(!) and therefore needs a custom matrix.
|
// The E80 does not conform to the reference Semtech switches(!) and therefore needs a custom matrix.
|
||||||
// See footnote #3 in "https://www.cdebyte.com/products/E80-900M2213S/2#Pin"
|
// See footnote #3 in "https://www.cdebyte.com/products/E80-900M2213S/2#Pin"
|
||||||
@@ -30,24 +24,3 @@ static const Module::RfSwitchMode_t rfswitch_table[] = {
|
|||||||
END_OF_MODE_TABLE,
|
END_OF_MODE_TABLE,
|
||||||
// clang-format on
|
// clang-format on
|
||||||
};
|
};
|
||||||
|
|
||||||
// LR2021 RF switch matrix following the standard Semtech / Seeed T1000-E reference topology.
|
|
||||||
// DIO5 -> antenna path select (HIGH = sub-GHz LF)
|
|
||||||
// DIO6 -> TX enable / HP PA select
|
|
||||||
// DIO7 -> not connected (no GNSS on LR2021)
|
|
||||||
// DIO8 -> RF front-end power enable
|
|
||||||
|
|
||||||
static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO5, RADIOLIB_LR2021_DIO6, RADIOLIB_LR2021_DIO7,
|
|
||||||
RADIOLIB_LR2021_DIO8, RADIOLIB_NC};
|
|
||||||
|
|
||||||
static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = {
|
|
||||||
// clang-format off
|
|
||||||
// mode DIO5 DIO6 DIO7 DIO8
|
|
||||||
{LR20x0::MODE_STBY, {LOW, LOW, LOW, LOW}},
|
|
||||||
{LR20x0::MODE_RX, {HIGH, LOW, LOW, HIGH}},
|
|
||||||
{LR20x0::MODE_TX, {HIGH, HIGH, LOW, HIGH}},
|
|
||||||
{LR20x0::MODE_RX_HF, {LOW, LOW, LOW, LOW}},
|
|
||||||
{LR20x0::MODE_TX_HF, {LOW, LOW, LOW, LOW}},
|
|
||||||
END_OF_MODE_TABLE,
|
|
||||||
// clang-format on
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -155,16 +155,6 @@ NRF52 PRO MICRO PIN ASSIGNMENT
|
|||||||
#define LR11X0_DIO_AS_RF_SWITCH
|
#define LR11X0_DIO_AS_RF_SWITCH
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// LR2021
|
|
||||||
#define USE_LR2021
|
|
||||||
#define LR2021_IRQ_PIN (0 + 10) // P0.10 IRQ
|
|
||||||
#define LR2021_NRESET_PIN LORA_RESET // P0.09 NRST
|
|
||||||
#define LR2021_BUSY_PIN (0 + 29) // P0.29 BUSY
|
|
||||||
#define LR2021_SPI_NSS_PIN LORA_CS // P1.13
|
|
||||||
#define LR2021_DIO3_TCXO_VOLTAGE 1.8
|
|
||||||
#define LR2021_DIO_AS_RF_SWITCH
|
|
||||||
#define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10
|
|
||||||
|
|
||||||
// #define SX126X_MAX_POWER 8 set this if using a high-power board!
|
// #define SX126X_MAX_POWER 8 set this if using a high-power board!
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -166,9 +166,7 @@ static const uint8_t SCK = PIN_SPI_SCK;
|
|||||||
// Testing USB detection
|
// Testing USB detection
|
||||||
#define NRF_APM
|
#define NRF_APM
|
||||||
// If using a power chip like the INA3221 you can override the default battery voltage channel below
|
// If using a power chip like the INA3221 you can override the default battery voltage channel below
|
||||||
// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging.
|
// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging
|
||||||
// INA3221Sensor.h provides INA3221_CH1/INA3221_CH2/INA3221_CH3 compatibility aliases, so
|
|
||||||
// board variants can continue to use the named channel constants here.
|
|
||||||
// #define INA3221_BAT_CH INA3221_CH2
|
// #define INA3221_BAT_CH INA3221_CH2
|
||||||
// #define INA3221_ENV_CH INA3221_CH1
|
// #define INA3221_ENV_CH INA3221_CH1
|
||||||
|
|
||||||
|
|||||||
@@ -220,9 +220,7 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG
|
|||||||
// Testing USB detection
|
// Testing USB detection
|
||||||
#define NRF_APM
|
#define NRF_APM
|
||||||
// If using a power chip like the INA3221 you can override the default battery voltage channel below
|
// If using a power chip like the INA3221 you can override the default battery voltage channel below
|
||||||
// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging.
|
// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging
|
||||||
// INA3221Sensor.h provides compatibility aliases such as INA3221_CH1/INA3221_CH2/INA3221_CH3,
|
|
||||||
// so board variants can continue to use the channel names below.
|
|
||||||
// #define INA3221_BAT_CH INA3221_CH2
|
// #define INA3221_BAT_CH INA3221_CH2
|
||||||
// #define INA3221_ENV_CH INA3221_CH1
|
// #define INA3221_ENV_CH INA3221_CH1
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[env:CDEBYTE_E77-MBL]
|
[env:CDEBYTE_E77-MBL]
|
||||||
extends = stm32_base
|
extends = stm32_base
|
||||||
board = ebyte_e77_dev
|
board = ebyte_e77_dev
|
||||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||||
board_level = extra
|
board_level = extra
|
||||||
build_flags =
|
build_flags =
|
||||||
${stm32_base.build_flags}
|
${stm32_base.build_flags}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
extends = stm32_base
|
extends = stm32_base
|
||||||
board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug
|
board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug
|
||||||
board_level = extra
|
board_level = extra
|
||||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||||
build_flags =
|
build_flags =
|
||||||
${stm32_base.build_flags}
|
${stm32_base.build_flags}
|
||||||
-Ivariants/stm32/milesight_gs301
|
-Ivariants/stm32/milesight_gs301
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
extends = stm32_base
|
extends = stm32_base
|
||||||
board = wiscore_rak3172
|
board = wiscore_rak3172
|
||||||
board_level = pr
|
board_level = pr
|
||||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||||
build_flags =
|
build_flags =
|
||||||
${stm32_base.build_flags}
|
${stm32_base.build_flags}
|
||||||
-Ivariants/stm32/rak3172
|
-Ivariants/stm32/rak3172
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
extends = stm32_base
|
extends = stm32_base
|
||||||
board = wiscore_rak3172
|
board = wiscore_rak3172
|
||||||
board_level = extra
|
board_level = extra
|
||||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||||
build_flags =
|
build_flags =
|
||||||
${stm32_base.build_flags}
|
${stm32_base.build_flags}
|
||||||
-Ivariants/stm32/russell
|
-Ivariants/stm32/russell
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
extends = stm32_base
|
extends = stm32_base
|
||||||
board = lora_e5_dev_board
|
board = lora_e5_dev_board
|
||||||
board_level = pr
|
board_level = pr
|
||||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||||
build_flags =
|
build_flags =
|
||||||
${stm32_base.build_flags}
|
${stm32_base.build_flags}
|
||||||
-Ivariants/stm32/wio-e5
|
-Ivariants/stm32/wio-e5
|
||||||
|
|||||||
Reference in New Issue
Block a user