mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-05 03:38:32 +10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7555242661 | ||
|
|
bb86cf4e81 | ||
|
|
6ea0d5ebba | ||
|
|
c5a8fbc157 | ||
|
|
b2bda3b07a | ||
|
|
41f53177a1 | ||
|
|
2d761f6453 | ||
|
|
7cb071c780 | ||
|
|
b7a9555905 | ||
|
|
0240a00d09 | ||
|
|
c0fcf807c0 | ||
|
|
55f40ecdfd | ||
|
|
90744ee0b7 | ||
|
|
1eb860a3fc | ||
|
|
fbccf910d4 | ||
|
|
b02193f341 | ||
|
|
4ee9598107 | ||
|
|
097f77cbcf | ||
|
|
7066abbb86 | ||
|
|
21cef8c2e5 | ||
|
|
8995a68b07 | ||
|
|
ebe6ddc7aa | ||
|
|
b27f80131a | ||
|
|
e3d68645c1 |
@@ -575,6 +575,8 @@ 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`.
|
||||
|
||||
**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`)
|
||||
|
||||
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,10 +32,15 @@ jobs:
|
||||
shell: bash
|
||||
working-directory: meshtasticd
|
||||
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 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 apt-get install -y build-essential devscripts equivs
|
||||
sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
|
||||
|
||||
- name: Import GPG key
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
brew update
|
||||
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 ulfius
|
||||
|
||||
- name: Get release version string
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ plugins:
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.525
|
||||
- checkov@3.2.526
|
||||
- renovate@43.150.0
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.2
|
||||
|
||||
@@ -126,16 +126,17 @@ Sequence these; don't parallelize on the same port.
|
||||
|
||||
## Environment variables (test harness)
|
||||
|
||||
| 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_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_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_PORT_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
|
||||
| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). |
|
||||
| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. |
|
||||
| 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_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_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_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_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
|
||||
| `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). |
|
||||
| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). |
|
||||
| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. |
|
||||
|
||||
+3
-1
@@ -3,15 +3,17 @@
|
||||
# trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
FROM python:3.14-slim-trixie AS builder
|
||||
FROM debian:trixie AS builder
|
||||
ARG PIO_ENV=native
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
# Install Dependencies
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
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 \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
|
||||
+8
-2
@@ -4,12 +4,18 @@
|
||||
# trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions
|
||||
|
||||
# Ensure the Alpine version is updated in both stages of the container!
|
||||
FROM python:3.14-alpine3.23 AS builder
|
||||
FROM alpine:3.23 AS builder
|
||||
ARG PIO_ENV=native
|
||||
ENV PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# 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_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
|
||||
+67
-9
@@ -166,15 +166,73 @@ rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `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_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `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) |
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `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_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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Context manager for meshtastic.SerialInterface connections.
|
||||
"""Context manager for meshtastic interface connections (serial + TCP).
|
||||
|
||||
Every info/admin tool goes through `connect(port)` so we have a single place
|
||||
that:
|
||||
@@ -6,8 +6,16 @@ that:
|
||||
- fails fast if a serial_session is already holding the port,
|
||||
- guarantees `.close()` is called, even on exception.
|
||||
|
||||
The `SerialInterface` blocks on construction waiting for the node database;
|
||||
that's fine for v1 since every tool is a short-lived request.
|
||||
Two transports:
|
||||
- Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`.
|
||||
- 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
|
||||
@@ -17,20 +25,107 @@ from typing import Iterator
|
||||
|
||||
from . import devices, registry
|
||||
|
||||
DEFAULT_TCP_PORT = 4403
|
||||
TCP_SCHEME = "tcp://"
|
||||
TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST"
|
||||
|
||||
|
||||
class ConnectionError(RuntimeError):
|
||||
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:
|
||||
"""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 is_tcp_port(port):
|
||||
return normalize_tcp_endpoint(port)
|
||||
return port
|
||||
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
|
||||
if not candidates:
|
||||
raise ConnectionError(
|
||||
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
|
||||
"Run `list_devices` with include_unknown=True to see all serial ports."
|
||||
"No Meshtastic devices detected. Plug one in, set "
|
||||
f"{TCP_HOST_ENV}=<host[:port]> for a meshtasticd daemon, "
|
||||
"or pass `port` explicitly. Run `list_devices` with "
|
||||
"include_unknown=True to see all serial ports."
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
ports = ", ".join(c["port"] for c in candidates)
|
||||
@@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str:
|
||||
|
||||
@contextmanager
|
||||
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
"""Open a `meshtastic.SerialInterface` and always close it.
|
||||
"""Open a meshtastic interface (serial or TCP) and always close it.
|
||||
|
||||
Raises `ConnectionError` immediately if another serial session holds the
|
||||
port (a `pio device monitor` in `serial_sessions/`, for instance).
|
||||
For serial: raises `ConnectionError` immediately if another serial
|
||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
||||
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 (
|
||||
SerialInterface, # type: ignore[import-untyped]
|
||||
)
|
||||
|
||||
resolved = resolve_port(port)
|
||||
|
||||
active = registry.active_session_for_port(resolved)
|
||||
if active is not None:
|
||||
raise ConnectionError(
|
||||
@@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
|
||||
iface = None
|
||||
try:
|
||||
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
|
||||
iface = SerialInterface(
|
||||
devPath=resolved,
|
||||
connectNow=True,
|
||||
noProto=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
yield iface
|
||||
finally:
|
||||
if iface is not None:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""USB/serial device discovery.
|
||||
"""USB/serial + TCP device discovery.
|
||||
|
||||
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
|
||||
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
|
||||
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
|
||||
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
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from serial.tools import list_ports
|
||||
@@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None:
|
||||
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]]:
|
||||
"""Return enriched info for serial ports, flagging Meshtastic candidates.
|
||||
|
||||
@@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
}
|
||||
)
|
||||
|
||||
# Stable ordering: likely_meshtastic first, then by port path
|
||||
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
|
||||
# Append the TCP endpoint (if env var set) and sort everything together.
|
||||
tcp_entry = _tcp_endpoint_from_env()
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any
|
||||
|
||||
import serial
|
||||
|
||||
from . import boards, config, devices, pio, userprefs
|
||||
from . import boards, config, connection, devices, pio, userprefs
|
||||
|
||||
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
|
||||
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
|
||||
@@ -46,6 +46,18 @@ 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]:
|
||||
build_dir = config.firmware_root() / ".pio" / "build" / env
|
||||
if not build_dir.is_dir():
|
||||
@@ -141,6 +153,8 @@ def flash(
|
||||
that pio performs will pick up the injected values.
|
||||
"""
|
||||
_require_confirm(confirm, "flash")
|
||||
_reject_native_env(env, "flash")
|
||||
connection.reject_if_tcp(port, "flash")
|
||||
with userprefs.temporary_overrides(userprefs_overrides) as effective:
|
||||
result = pio.run(
|
||||
["run", "-e", env, "-t", "upload", "--upload-port", port],
|
||||
@@ -200,6 +214,7 @@ def erase_and_flash(
|
||||
in that case) since a cached factory.bin would not reflect the new prefs.
|
||||
"""
|
||||
_require_confirm(confirm, "erase_and_flash")
|
||||
connection.reject_if_tcp(port, "erase_and_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -257,6 +272,7 @@ def update_flash(
|
||||
overrides are provided we always force a rebuild.
|
||||
"""
|
||||
_require_confirm(confirm, "update_flash")
|
||||
connection.reject_if_tcp(port, "update_flash")
|
||||
_check_esp32_env(env)
|
||||
|
||||
if userprefs_overrides and skip_build:
|
||||
@@ -391,6 +407,7 @@ def touch_1200bps(
|
||||
|
||||
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_ports = {d["port"] for d in before_list}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from . import config, pio
|
||||
from . import config, connection, pio
|
||||
|
||||
_TIMEOUT_SHORT = 30
|
||||
_TIMEOUT_LONG = 600
|
||||
@@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: 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()
|
||||
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
|
||||
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
|
||||
@@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]:
|
||||
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
|
||||
"""Full-chip erase. Leaves the device unbootable until reflashed."""
|
||||
_require_confirm(confirm, "esptool_erase_flash")
|
||||
connection.reject_if_tcp(port, "esptool_erase_flash")
|
||||
binary = config.esptool_bin()
|
||||
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
|
||||
# first; if it fails with unknown command, retry old.
|
||||
@@ -134,6 +136,7 @@ def esptool_raw(
|
||||
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
|
||||
if not args:
|
||||
raise ToolError("args must not be empty")
|
||||
connection.reject_if_tcp(port, "esptool_raw")
|
||||
# Find the first non-flag arg (the subcommand).
|
||||
subcommand = next((a for a in args if not a.startswith("-")), None)
|
||||
if subcommand and subcommand.replace("-", "_") in {
|
||||
@@ -156,6 +159,7 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
|
||||
|
||||
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "nrfutil_dfu")
|
||||
connection.reject_if_tcp(port, "nrfutil_dfu")
|
||||
pkg = Path(package_path).expanduser()
|
||||
if not pkg.is_file():
|
||||
raise ToolError(f"Package not found: {pkg}")
|
||||
@@ -213,6 +217,7 @@ def _parse_picotool_info(stdout: str) -> 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
|
||||
only — picotool auto-detects."""
|
||||
connection.reject_if_tcp(port, "picotool_info")
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
if res["exit_code"] != 0:
|
||||
|
||||
@@ -71,6 +71,10 @@ def open_session(
|
||||
If `env` is supplied, pio resolves baud and filters from platformio.ini.
|
||||
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"]
|
||||
effective_filters: list[str]
|
||||
effective_baud: int = baud
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""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
|
||||
+1
-1
@@ -120,7 +120,7 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
|
||||
[device-ui_base]
|
||||
lib_deps =
|
||||
|
||||
+64
-94
@@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#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.
|
||||
*
|
||||
@@ -123,23 +141,21 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
|
||||
File file = root.openNextFile();
|
||||
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 (levels) {
|
||||
#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
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
strcpy(fileInfo.file_name, file.path());
|
||||
#else
|
||||
strcpy(fileInfo.file_name, file.name());
|
||||
#endif
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
@@ -163,98 +179,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root) {
|
||||
if (!root || !root.isDirectory())
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
listDir(filepath, levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
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 {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
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();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
root.close();
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "memGet.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <assert.h>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
@@ -20,6 +21,22 @@
|
||||
#if HAS_NETWORKING
|
||||
extern meshtastic::Syslog syslog;
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
std::atomic<bool> serialHalLogSuppressed{false};
|
||||
}
|
||||
|
||||
void RedirectablePrint::setSerialHalLogSuppressed(bool suppressed)
|
||||
{
|
||||
serialHalLogSuppressed.store(suppressed);
|
||||
}
|
||||
|
||||
bool RedirectablePrint::isSerialHalLogSuppressed()
|
||||
{
|
||||
return serialHalLogSuppressed.load();
|
||||
}
|
||||
|
||||
void RedirectablePrint::rpInit()
|
||||
{
|
||||
#ifdef HAS_FREE_RTOS
|
||||
@@ -277,6 +294,10 @@ meshtastic_LogRecord_Level RedirectablePrint::getLogLevel(const char *logLevel)
|
||||
void RedirectablePrint::log(const char *logLevel, const char *format, ...)
|
||||
{
|
||||
|
||||
if (isSerialHalLogSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// append \n to format
|
||||
size_t len = strlen(format);
|
||||
auto newFormat = std::unique_ptr<char[]>(new char[len + 2]);
|
||||
|
||||
@@ -24,6 +24,11 @@ class RedirectablePrint : public Print
|
||||
public:
|
||||
explicit RedirectablePrint(Print *_dest) : dest(_dest) {}
|
||||
|
||||
/// Suppress all log output while a SerialHal transaction is in progress.
|
||||
// Unclear if this is necessary, but it seems to help with response speeds.
|
||||
static void setSerialHalLogSuppressed(bool suppressed);
|
||||
static bool isSerialHalLogSuppressed();
|
||||
|
||||
/**
|
||||
* Set a new destination
|
||||
*/
|
||||
|
||||
@@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic);
|
||||
#ifdef ARCH_NRF52
|
||||
FSCom.remove(filename);
|
||||
return FSCom.open(filename, FILE_O_WRITE);
|
||||
#endif
|
||||
if (!fullAtomic) {
|
||||
FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists)
|
||||
}
|
||||
@@ -67,9 +63,6 @@ bool SafeFile::close()
|
||||
f.close();
|
||||
spiLock->unlock();
|
||||
|
||||
#ifdef ARCH_NRF52
|
||||
return true;
|
||||
#endif
|
||||
if (!testReadback())
|
||||
return false;
|
||||
|
||||
|
||||
+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).
|
||||
#ifndef MESHTASTIC_PREHOP_DROP
|
||||
#define MESHTASTIC_PREHOP_DROP 0
|
||||
#define MESHTASTIC_PREHOP_DROP 1
|
||||
#endif
|
||||
|
||||
/// Convert a preprocessor name into a quoted string
|
||||
|
||||
+8
-5
@@ -951,8 +951,12 @@ void setup()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
auto rIf = initLoRa();
|
||||
|
||||
std::unique_ptr<RadioInterface> rIf;
|
||||
if (!config.lora.serial_hal_only) {
|
||||
rIf = initLoRa();
|
||||
} else {
|
||||
LOG_INFO("skipping LoRa radio init, for serialHal");
|
||||
}
|
||||
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
@@ -996,10 +1000,9 @@ void setup()
|
||||
|
||||
// Start airtime logger thread.
|
||||
airTime = new AirTime();
|
||||
|
||||
if (!rIf)
|
||||
if (!rIf && !config.lora.serial_hal_only)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO);
|
||||
else {
|
||||
else if (rIf) {
|
||||
// Log bit rate to debug output
|
||||
LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) /
|
||||
(float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) *
|
||||
|
||||
+1
-3
@@ -1611,12 +1611,10 @@ bool NodeDB::saveToDisk(int saveWhat)
|
||||
|
||||
if (!success) {
|
||||
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();
|
||||
FSCom.format();
|
||||
fsFormat();
|
||||
spiLock->unlock();
|
||||
|
||||
#endif
|
||||
success = saveToDiskNoRetry(saveWhat);
|
||||
|
||||
RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
|
||||
|
||||
@@ -522,11 +522,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
// LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard,
|
||||
// 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.node_info = infoToSend;
|
||||
prefetchNodeInfos();
|
||||
@@ -657,9 +652,11 @@ void PhoneAPI::releaseQueueStatusPhonePacket()
|
||||
void PhoneAPI::prefetchNodeInfos()
|
||||
{
|
||||
bool added = false;
|
||||
bool wasEmpty = false;
|
||||
// Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment.
|
||||
{
|
||||
concurrency::LockGuard guard(&nodeInfoMutex);
|
||||
wasEmpty = nodeInfoQueue.empty();
|
||||
while (nodeInfoQueue.size() < kNodePrefetchDepth) {
|
||||
auto nextNode = nodeDB->readNextMeshNode(readIndex);
|
||||
if (!nextNode)
|
||||
@@ -673,11 +670,15 @@ void PhoneAPI::prefetchNodeInfos()
|
||||
info.via_mqtt = isUs ? false : info.via_mqtt;
|
||||
info.is_favorite = info.is_favorite || isUs;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (added)
|
||||
if (added && wasEmpty)
|
||||
onNowHasData(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#include "platform/portduino/SerialHal.h"
|
||||
#include "platform/portduino/SimRadio.h"
|
||||
#include "platform/portduino/USBHal.h"
|
||||
#endif
|
||||
@@ -292,6 +293,9 @@ std::unique_ptr<RadioInterface> initLoRa()
|
||||
portduino_config.lora_spi_dev.c_str());
|
||||
if (portduino_config.lora_spi_dev == "ch341") {
|
||||
RadioLibHAL = ch341Hal;
|
||||
} else if (portduino_config.lora_spi_dev == "serial") {
|
||||
RadioLibHAL = new SerialHal(portduino_config.lora_serial_device, portduino_config.lora_serial_baud,
|
||||
(uint32_t)portduino_config.lora_serial_timeout_ms);
|
||||
} else {
|
||||
if (RadioLibHAL != nullptr) {
|
||||
delete RadioLibHAL;
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#include "mesh/SerialHalDevice.h"
|
||||
#include "NodeDB.h"
|
||||
#include "SPILock.h"
|
||||
#include "concurrency/Periodic.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh/StreamAPI.h"
|
||||
#include "mesh/generated/meshtastic/config.pb.h"
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <cstring>
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
#if defined(HW_SPI1_DEVICE)
|
||||
extern SPIClass SPI1;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t SERIAL_PI_RISING = 1;
|
||||
constexpr uint32_t SERIAL_PI_FALLING = 2;
|
||||
constexpr uint32_t SERIAL_PI_INPUT = 0;
|
||||
constexpr uint32_t SERIAL_PI_OUTPUT = 1;
|
||||
constexpr size_t MAX_INTERRUPT_SLOTS = 8;
|
||||
constexpr int32_t INTERRUPT_POLL_MS = 5;
|
||||
|
||||
struct InterruptSlot {
|
||||
bool used = false;
|
||||
uint32_t pin = 0;
|
||||
uint32_t mode = 0;
|
||||
volatile bool pending = false;
|
||||
};
|
||||
|
||||
concurrency::Lock interruptMutex;
|
||||
InterruptSlot interruptSlots[MAX_INTERRUPT_SLOTS];
|
||||
StreamAPI *interruptStreamApi = nullptr;
|
||||
concurrency::Periodic *interruptEmitter = nullptr;
|
||||
|
||||
int findSlotByPinLocked(uint32_t pin)
|
||||
{
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (interruptSlots[i].used && interruptSlots[i].pin == pin) {
|
||||
return (int)i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int allocateSlotLocked()
|
||||
{
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (!interruptSlots[i].used) {
|
||||
return (int)i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(ARCH_PORTDUINO) || defined(ARCH_RP2040)
|
||||
PinStatus toInterruptMode(uint32_t serialMode)
|
||||
{
|
||||
if (serialMode == SERIAL_PI_RISING) {
|
||||
return PinStatus::RISING;
|
||||
}
|
||||
if (serialMode == SERIAL_PI_FALLING) {
|
||||
return PinStatus::FALLING;
|
||||
}
|
||||
return PinStatus::CHANGE;
|
||||
}
|
||||
#else
|
||||
int toInterruptMode(uint32_t serialMode)
|
||||
{
|
||||
if (serialMode == SERIAL_PI_RISING) {
|
||||
return RISING;
|
||||
}
|
||||
if (serialMode == SERIAL_PI_FALLING) {
|
||||
return FALLING;
|
||||
}
|
||||
return CHANGE;
|
||||
}
|
||||
#endif
|
||||
|
||||
int32_t pumpInterruptEvents();
|
||||
|
||||
void ensureInterruptEmitter()
|
||||
{
|
||||
if (!interruptEmitter) {
|
||||
interruptEmitter = new concurrency::Periodic("SerialHalIrqEmitter", pumpInterruptEvents);
|
||||
}
|
||||
}
|
||||
|
||||
void emitInterruptEvent(uint32_t pin, StreamAPI *streamApi)
|
||||
{
|
||||
if (streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalResponse event = meshtastic_SerialHalResponse_init_zero;
|
||||
event.transaction_id = 0; // asynchronous interrupt notification
|
||||
event.result = meshtastic_SerialHalResponse_Result_OK;
|
||||
event.value = pin; // host-side SerialHal treats value as interrupt pin
|
||||
SerialHalDevice::emitResponse(event, streamApi);
|
||||
}
|
||||
|
||||
void markPendingBySlot(uint8_t slot)
|
||||
{
|
||||
if (slot < MAX_INTERRUPT_SLOTS && interruptSlots[slot].used) {
|
||||
interruptSlots[slot].pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
void isr0()
|
||||
{
|
||||
markPendingBySlot(0);
|
||||
}
|
||||
void isr1()
|
||||
{
|
||||
markPendingBySlot(1);
|
||||
}
|
||||
void isr2()
|
||||
{
|
||||
markPendingBySlot(2);
|
||||
}
|
||||
void isr3()
|
||||
{
|
||||
markPendingBySlot(3);
|
||||
}
|
||||
void isr4()
|
||||
{
|
||||
markPendingBySlot(4);
|
||||
}
|
||||
void isr5()
|
||||
{
|
||||
markPendingBySlot(5);
|
||||
}
|
||||
void isr6()
|
||||
{
|
||||
markPendingBySlot(6);
|
||||
}
|
||||
void isr7()
|
||||
{
|
||||
markPendingBySlot(7);
|
||||
}
|
||||
|
||||
void (*const isrTable[MAX_INTERRUPT_SLOTS])() = {isr0, isr1, isr2, isr3, isr4, isr5, isr6, isr7};
|
||||
|
||||
int32_t pumpInterruptEvents()
|
||||
{
|
||||
uint32_t toEmit[MAX_INTERRUPT_SLOTS] = {0};
|
||||
size_t emitCount = 0;
|
||||
StreamAPI *streamApi = nullptr;
|
||||
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
streamApi = interruptStreamApi;
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (interruptSlots[i].used && interruptSlots[i].pending) {
|
||||
interruptSlots[i].pending = false;
|
||||
toEmit[emitCount++] = interruptSlots[i].pin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < emitCount; ++i) {
|
||||
emitInterruptEvent(toEmit[i], streamApi);
|
||||
}
|
||||
|
||||
return INTERRUPT_POLL_MS;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Helper to safely set response result
|
||||
static inline void setResponseError(meshtastic_SerialHalResponse &response, meshtastic_SerialHalResponse_Result result,
|
||||
const char *error = nullptr)
|
||||
{
|
||||
response.result = result;
|
||||
if (error != nullptr) {
|
||||
snprintf(response.error, sizeof(response.error), "%s", error);
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi)
|
||||
{
|
||||
if (buf == nullptr || streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate role - SerialHal commands only handled when config.lora.serial_hal_only
|
||||
if (!config.lora.serial_hal_only) {
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "SerialHal not enabled for this role");
|
||||
emitResponse(response, streamApi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode the command
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
if (!pb_decode_from_bytes(buf, len, &meshtastic_SerialHalCommand_msg, &cmd)) {
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.result = meshtastic_SerialHalResponse_Result_BAD_REQUEST;
|
||||
snprintf(response.error, sizeof(response.error), "Failed to decode SerialHalCommand");
|
||||
emitResponse(response, streamApi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize response with matching transaction_id
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.transaction_id = cmd.transaction_id;
|
||||
response.result = meshtastic_SerialHalResponse_Result_OK;
|
||||
|
||||
// Dispatch to operation handler
|
||||
switch (cmd.type) {
|
||||
case meshtastic_SerialHalCommand_Type_PIN_MODE:
|
||||
handlePinMode(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DIGITAL_WRITE:
|
||||
handleDigitalWrite(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DIGITAL_READ:
|
||||
handleDigitalRead(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT:
|
||||
handleAttachInterrupt(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT:
|
||||
handleDetachInterrupt(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_SPI_TRANSFER:
|
||||
handleSpiTransfer(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_NOOP:
|
||||
// NOOP: just return OK
|
||||
break;
|
||||
default:
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "Unknown SerialHal operation type");
|
||||
break;
|
||||
}
|
||||
|
||||
emitResponse(response, streamApi);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: pinMode pin=%u mode=%u", cmd.pin, cmd.mode);
|
||||
if (cmd.mode == SERIAL_PI_INPUT) {
|
||||
pinMode((int)cmd.pin, INPUT);
|
||||
} else if (cmd.mode == SERIAL_PI_OUTPUT) {
|
||||
pinMode((int)cmd.pin, OUTPUT);
|
||||
} else {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_BAD_REQUEST, "Unsupported pin mode");
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: digitalWrite pin=%u value=%u", cmd.pin, cmd.value);
|
||||
digitalWrite((int)cmd.pin, cmd.value ? HIGH : LOW);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: digitalRead pin=%u", cmd.pin);
|
||||
response.value = (uint32_t)digitalRead((int)cmd.pin);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: attachInterrupt pin=%u mode=%u", cmd.pin, cmd.mode);
|
||||
|
||||
ensureInterruptEmitter();
|
||||
|
||||
int slot = -1;
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
slot = findSlotByPinLocked(cmd.pin);
|
||||
if (slot < 0) {
|
||||
slot = allocateSlotLocked();
|
||||
}
|
||||
|
||||
if (slot >= 0) {
|
||||
interruptSlots[slot].used = true;
|
||||
interruptSlots[slot].pin = cmd.pin;
|
||||
interruptSlots[slot].mode = cmd.mode;
|
||||
interruptSlots[slot].pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (slot < 0) {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "No interrupt slots available");
|
||||
return;
|
||||
}
|
||||
|
||||
::attachInterrupt((int)cmd.pin, isrTable[slot], toInterruptMode(cmd.mode));
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: detachInterrupt pin=%u", cmd.pin);
|
||||
|
||||
::detachInterrupt((int)cmd.pin);
|
||||
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
const int slot = findSlotByPinLocked(cmd.pin);
|
||||
if (slot >= 0) {
|
||||
interruptSlots[slot] = InterruptSlot{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
if (cmd.data.size == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
#if !ARCH_PORTDUINO
|
||||
if (spiLock == nullptr) {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "SPI lock not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(HW_SPI1_DEVICE)
|
||||
SPIClass &spiBus = SPI1;
|
||||
#else
|
||||
SPIClass &spiBus = SPI;
|
||||
#endif
|
||||
|
||||
response.data.size = cmd.data.size;
|
||||
|
||||
{
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
spiBus.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
#ifdef ARCH_ESP32
|
||||
spiBus.transferBytes(cmd.data.bytes, response.data.bytes, cmd.data.size);
|
||||
#else
|
||||
spiBus.transfer(cmd.data.bytes, response.data.bytes, cmd.data.size);
|
||||
#endif
|
||||
spiBus.endTransaction();
|
||||
}
|
||||
#else
|
||||
// SPI wiring is board/radio-specific; keep this explicit for now.
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "SPI not supported on this platform");
|
||||
#endif
|
||||
}
|
||||
|
||||
void SerialHalDevice::emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi)
|
||||
{
|
||||
if (streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode the response
|
||||
uint8_t encoded[meshtastic_SerialHalResponse_size] = {0};
|
||||
const size_t responseLen =
|
||||
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalResponse_msg, static_cast<const void *>(&response));
|
||||
|
||||
if (responseLen == 0 || responseLen > 0xFFFF) {
|
||||
LOG_ERROR("SerialHalDevice: Failed to encode response (len=%zu)", responseLen);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build frame with StreamAPI framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload]
|
||||
constexpr uint8_t START1 = 0x94;
|
||||
constexpr uint8_t SERIALHAL_MAGIC = 0xA5;
|
||||
|
||||
uint8_t hdr[4];
|
||||
hdr[0] = START1;
|
||||
hdr[1] = SERIALHAL_MAGIC;
|
||||
hdr[2] = (uint8_t)((responseLen >> 8) & 0xFF); // LEN_H
|
||||
hdr[3] = (uint8_t)(responseLen & 0xFF); // LEN_L
|
||||
|
||||
// Emit via StreamAPI (this uses the internal txBuf + framing)
|
||||
streamApi->emitSerialHalResponse(hdr, sizeof(hdr), encoded, responseLen);
|
||||
|
||||
// Keep a recent stream instance so async interrupt events can be emitted.
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
interruptStreamApi = streamApi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "mesh/generated/meshtastic/serial_hal.pb.h"
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @brief Device-side handler for SerialHal GPIO/SPI operations over StreamAPI framing.
|
||||
*
|
||||
* This module decodes SerialHalCommand protobufs received from a host and executes
|
||||
* the requested GPIO (pinMode, digitalWrite, digitalRead, attach/detachInterrupt) or
|
||||
* SPI operations, then returns results via SerialHalResponse.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Override StreamAPI::handleSerialHalCommand() in a subclass
|
||||
* 2. Call SerialHalDevice::handleCommand(buf, len, streamApi)
|
||||
* 3. SerialHalDevice will decode, execute, and emit the response
|
||||
*
|
||||
* The handler is only active when config.lora.serial_hal_only is true.
|
||||
*/
|
||||
|
||||
class StreamAPI; // forward declaration
|
||||
|
||||
class SerialHalDevice
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* @brief Process a SerialHalCommand and emit a response.
|
||||
*
|
||||
* Decodes the protobuf, validates the operation, executes it on the device,
|
||||
* and writes the response back via the StreamAPI instance.
|
||||
*
|
||||
* @param buf Pointer to the encoded SerialHalCommand protobuf payload (not including framing)
|
||||
* @param len Length of the encoded payload
|
||||
* @param streamApi Pointer to the StreamAPI instance (used for emitting responses)
|
||||
*/
|
||||
static void handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi);
|
||||
|
||||
/**
|
||||
* @brief Emit a SerialHalResponse back to the host via StreamAPI framing.
|
||||
*
|
||||
* Encodes the response protobuf and sends it with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
|
||||
*
|
||||
* @param response The response to send
|
||||
* @param streamApi Pointer to the StreamAPI instance
|
||||
*/
|
||||
static void emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Execute a GPIO pinMode operation.
|
||||
* @param cmd Decoded SerialHalCommand with PIN_MODE type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a GPIO digitalWrite operation.
|
||||
* @param cmd Decoded SerialHalCommand with DIGITAL_WRITE type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a GPIO digitalRead operation.
|
||||
* @param cmd Decoded SerialHalCommand with DIGITAL_READ type
|
||||
* @param response Response object to fill with result (value field contains read result)
|
||||
*/
|
||||
static void handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute an attachInterrupt operation.
|
||||
* @param cmd Decoded SerialHalCommand with ATTACH_INTERRUPT type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a detachInterrupt operation.
|
||||
* @param cmd Decoded SerialHalCommand with DETACH_INTERRUPT type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute an SPI transfer operation.
|
||||
* @param cmd Decoded SerialHalCommand with SPI_TRANSFER type and data to send
|
||||
* @param response Response object to fill with result (data field contains received bytes)
|
||||
*/
|
||||
static void handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
};
|
||||
+95
-17
@@ -1,12 +1,15 @@
|
||||
#include "StreamAPI.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "RedirectablePrint.h"
|
||||
#include "SerialHalDevice.h"
|
||||
#include "Throttle.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#define START1 0x94
|
||||
#define START2 0xc3
|
||||
#define SERIALHAL_MAGIC 0xa5 // second framing byte for SerialHal frames (START1 SH_MAGIC LEN_H LEN_L PAYLOAD)
|
||||
#define HEADER_LEN 4
|
||||
|
||||
int32_t StreamAPI::runOncePart()
|
||||
@@ -79,18 +82,29 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
|
||||
if (ptr == 0) { // looking for START1
|
||||
if (c != START1)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // looking for START2
|
||||
if (c != START2)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // discriminate frame type on second byte
|
||||
if (c == START2) {
|
||||
rxIsSerialHal = false; // standard ToRadio frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
} else if (c == SERIALHAL_MAGIC) {
|
||||
rxIsSerialHal = true; // SerialHal command frame
|
||||
serialHalRxActive.store(true);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(true);
|
||||
} else {
|
||||
rxPtr = 0; // unrecognised second byte — not our frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
||||
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
||||
|
||||
// console->printf("len %d\n", len);
|
||||
|
||||
if (ptr == HEADER_LEN - 1) {
|
||||
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
||||
// protobuf also)
|
||||
if (len > MAX_TO_FROM_RADIO_SIZE)
|
||||
// we _just_ finished our 4 byte header, validate length now
|
||||
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
|
||||
if (len > maxLen)
|
||||
rxPtr = 0; // length is bogus, restart search for framing
|
||||
}
|
||||
|
||||
@@ -98,8 +112,16 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
|
||||
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
||||
rxPtr = 0; // start over again on the next packet
|
||||
|
||||
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
// Dispatch based on which frame type we identified at byte 1
|
||||
if (rxIsSerialHal)
|
||||
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
|
||||
else
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
|
||||
if (rxIsSerialHal)
|
||||
serialHalRxActive.store(false);
|
||||
if (rxIsSerialHal)
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +136,12 @@ int32_t StreamAPI::readStream()
|
||||
if (!stream->available()) {
|
||||
// Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time
|
||||
bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000);
|
||||
return recentRx ? 5 : 250;
|
||||
if (!recentRx)
|
||||
return 250; // Sleep a long time if we haven't heard from the computer in a while
|
||||
if (serialHalRxActive.load())
|
||||
return 0; // If we are in the middle of a SerialHal transaction, don't sleep at all because we want to be as
|
||||
// responsive as possible to incoming SerialHal bytes
|
||||
return 5; // Otherwise, poll frequently for new data
|
||||
} else {
|
||||
while (stream->available()) { // Currently we never want to block
|
||||
int cInt = stream->read();
|
||||
@@ -135,18 +162,30 @@ int32_t StreamAPI::readStream()
|
||||
if (ptr == 0) { // looking for START1
|
||||
if (c != START1)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // looking for START2
|
||||
if (c != START2)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // discriminate frame type on second byte
|
||||
if (c == START2) {
|
||||
rxIsSerialHal = false; // standard ToRadio frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
} else if (c == SERIALHAL_MAGIC) {
|
||||
rxIsSerialHal = true; // SerialHal command frame
|
||||
serialHalRxActive.store(true);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(true);
|
||||
LOG_WARN("StreamAPI: Detected SerialHal command frame");
|
||||
} else {
|
||||
rxPtr = 0; // unrecognised second byte — not our frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
||||
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
||||
|
||||
// console->printf("len %d\n", len);
|
||||
|
||||
if (ptr == HEADER_LEN - 1) {
|
||||
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
||||
// protobuf also)
|
||||
if (len > MAX_TO_FROM_RADIO_SIZE)
|
||||
// we _just_ finished our 4 byte header, validate length now
|
||||
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
|
||||
if (len > maxLen)
|
||||
rxPtr = 0; // length is bogus, restart search for framing
|
||||
}
|
||||
|
||||
@@ -154,8 +193,16 @@ int32_t StreamAPI::readStream()
|
||||
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
||||
rxPtr = 0; // start over again on the next packet
|
||||
|
||||
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
// Dispatch based on which frame type we identified at byte 1
|
||||
if (rxIsSerialHal)
|
||||
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
|
||||
else
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
|
||||
if (rxIsSerialHal)
|
||||
serialHalRxActive.store(false);
|
||||
if (rxIsSerialHal)
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +246,10 @@ void StreamAPI::emitRebooted()
|
||||
|
||||
void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg)
|
||||
{
|
||||
if (serialHalRxActive.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here — those
|
||||
// belong to the main packet-emission path and a LOG_ firing during
|
||||
// `writeStream()` would corrupt an in-flight encode. We keep a
|
||||
@@ -249,4 +300,31 @@ void StreamAPI::onConnectionChanged(bool connected)
|
||||
// received a packet in a while
|
||||
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
void StreamAPI::handleSerialHalCommand(const uint8_t *buf, size_t len)
|
||||
{
|
||||
// Default implementation: dispatch to SerialHalDevice for GPIO/SPI handling
|
||||
SerialHalDevice::handleCommand(buf, len, this);
|
||||
}
|
||||
|
||||
void StreamAPI::emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen)
|
||||
{
|
||||
if (hdr == nullptr || hdrLen != 4 || payload == nullptr || payloadLen > meshtastic_SerialHalResponse_size) {
|
||||
LOG_ERROR("StreamAPI: Invalid SerialHal response parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete frame in a temporary buffer
|
||||
uint8_t frame[4 + meshtastic_SerialHalResponse_size];
|
||||
memcpy(frame, hdr, hdrLen);
|
||||
memcpy(frame + hdrLen, payload, payloadLen);
|
||||
|
||||
size_t totalLen = hdrLen + payloadLen;
|
||||
|
||||
// Serialize stream writes against other emit operations via streamLock
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
stream->write(frame, totalLen);
|
||||
stream->flush();
|
||||
LOG_WARN("StreamAPI: Emitted SerialHal response frame (len=%zu)", totalLen);
|
||||
}
|
||||
+26
-2
@@ -4,10 +4,15 @@
|
||||
#include "Stream.h"
|
||||
#include "concurrency/Lock.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "generated/meshtastic/serial_hal.pb.h"
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
|
||||
// A To/FromRadio packet + our 32 bit header
|
||||
#define MAX_STREAM_BUF_SIZE (MAX_TO_FROM_RADIO_SIZE + sizeof(uint32_t))
|
||||
// Buffer sized for the larger of a full ToRadio/FromRadio payload or a full SerialHalCommand payload, plus header.
|
||||
#define MAX_STREAM_PAYLOAD_SIZE \
|
||||
(MAX_TO_FROM_RADIO_SIZE > (int)meshtastic_SerialHalCommand_size ? MAX_TO_FROM_RADIO_SIZE \
|
||||
: (int)meshtastic_SerialHalCommand_size)
|
||||
#define MAX_STREAM_BUF_SIZE (MAX_STREAM_PAYLOAD_SIZE + (int)sizeof(uint32_t))
|
||||
|
||||
/**
|
||||
* A version of our 'phone' API that talks over a Stream. So therefore well suited to use with serial links
|
||||
@@ -39,6 +44,8 @@ class StreamAPI : public PhoneAPI
|
||||
|
||||
uint8_t rxBuf[MAX_STREAM_BUF_SIZE] = {0};
|
||||
size_t rxPtr = 0;
|
||||
bool rxIsSerialHal = false; ///< true when the current in-progress frame is a SerialHal frame (START1 SH_MAGIC ...)
|
||||
std::atomic<bool> serialHalRxActive{false};
|
||||
|
||||
/// time of last rx, used, to slow down our polling if we haven't heard from anyone
|
||||
uint32_t lastRxMsec = 0;
|
||||
@@ -56,6 +63,17 @@ class StreamAPI : public PhoneAPI
|
||||
/// Check the current underlying physical link to see if the client is currently connected
|
||||
virtual bool checkIsConnected() override = 0;
|
||||
|
||||
/**
|
||||
* Emit a SerialHal response frame with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
|
||||
* Called by SerialHalDevice to send responses back to the host.
|
||||
*
|
||||
* @param hdr 4-byte header (START1 SERIALHAL_MAGIC LEN_H LEN_L)
|
||||
* @param hdrLen Length of header (should be 4)
|
||||
* @param payload Encoded SerialHalResponse protobuf payload
|
||||
* @param payloadLen Length of payload
|
||||
*/
|
||||
void emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Read any rx chars from the link and call handleToRadio
|
||||
@@ -75,6 +93,12 @@ class StreamAPI : public PhoneAPI
|
||||
*/
|
||||
void emitRebooted();
|
||||
|
||||
/**
|
||||
* Called when a complete SerialHal-framed packet has been received.
|
||||
* Default implementation dispatches to SerialHalDevice for GPIO/SPI handling.
|
||||
*/
|
||||
virtual void handleSerialHalCommand(const uint8_t *buf, size_t len);
|
||||
|
||||
virtual void onConnectionChanged(bool connected) override;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
/*
|
||||
Adds a WebServer and WebService callbacks to meshtastic as Linux Version. The WebServer & Webservices
|
||||
runs in a real linux thread beside the portdunio threading emulation. It replaces the complete ESP32
|
||||
Webserver libs including generation of SSL certifcicates, because the use ESP specific details in
|
||||
the lib that can't be emulated.
|
||||
Adds a WebServer and WebService callbacks to meshtastic via the Portduino/native target (Linux and
|
||||
macOS). The WebServer & Webservices run in a real host thread beside the Portduino threading
|
||||
emulation. It replaces the complete ESP32 Webserver libs including generation of SSL certificates,
|
||||
because those libs use ESP-specific details that can't be emulated.
|
||||
|
||||
The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio"
|
||||
The WebServer just adds basaic support to deliver WebContent, so it can be used to
|
||||
deliver the WebGui definded by the WebClient Project.
|
||||
The WebServer just adds basic support to deliver WebContent, so it can be used to
|
||||
deliver the WebGui defined by the WebClient Project.
|
||||
|
||||
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 && \
|
||||
apt -y install openssl libssl-dev libopenssl libsdl2-dev \
|
||||
apt -y install openssl libssl-dev libsdl2-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.
|
||||
The followinng tags should be included and set at your needs
|
||||
The following tags should be included and set at your needs
|
||||
|
||||
Example entry in the config.yaml
|
||||
Webserver:
|
||||
@@ -34,7 +46,10 @@ Author: Marc Philipp Hammermann
|
||||
mail: marchammermann@googlemail.com
|
||||
|
||||
*/
|
||||
#ifdef PORTDUINO_LINUX_HARDWARE
|
||||
// Mirrors the guard in PiWebServer.h — see comment there. macOS Homebrew
|
||||
// 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>)
|
||||
#include "PiWebServer.h"
|
||||
#include "NodeDB.h"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#pragma once
|
||||
#ifdef PORTDUINO_LINUX_HARDWARE
|
||||
// Portduino webserver is built whenever the ulfius headers are reachable,
|
||||
// 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>)
|
||||
#include "PhoneAPI.h"
|
||||
#include "ulfius-cfg.h"
|
||||
|
||||
@@ -1412,7 +1412,9 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
|
||||
|
||||
void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg)
|
||||
{
|
||||
#if HAS_SCREEN
|
||||
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg);
|
||||
#endif
|
||||
}
|
||||
|
||||
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <ErriezCRC32.h>
|
||||
#include <Utility.h>
|
||||
#include <assert.h>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -32,6 +33,16 @@
|
||||
#include <cxxabi.h>
|
||||
#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"
|
||||
|
||||
portduino_config_struct portduino_config;
|
||||
@@ -155,9 +166,35 @@ void getMacAddr(uint8_t *dmac)
|
||||
dmac[3] = di.bdaddr.b[2];
|
||||
dmac[4] = di.bdaddr.b[1];
|
||||
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
|
||||
// No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default;
|
||||
// the caller can override via the --hwid CLI flag or the YAML config.
|
||||
// No platform-specific MAC source; leave dmac at its default. Caller
|
||||
// can override via the --hwid CLI flag or the YAML config.
|
||||
(void)dmac;
|
||||
#endif
|
||||
}
|
||||
@@ -561,7 +598,8 @@ void portduinoSetup()
|
||||
for (const auto *i : portduino_config.all_pins) {
|
||||
// In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora
|
||||
// Those GPIO are handled in our usermode driver instead.
|
||||
if (i->config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") {
|
||||
if (i->config_section == "Lora" &&
|
||||
(portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) {
|
||||
continue;
|
||||
}
|
||||
if (i->enabled) {
|
||||
@@ -580,7 +618,8 @@ void portduinoSetup()
|
||||
for (auto i : portduino_config.extra_pins) {
|
||||
// In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora
|
||||
// Those GPIO are handled in our usermode driver instead.
|
||||
if (i.config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") {
|
||||
if (i.config_section == "Lora" &&
|
||||
(portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) {
|
||||
continue;
|
||||
}
|
||||
if (i.enabled) {
|
||||
@@ -627,7 +666,8 @@ void portduinoSetup()
|
||||
for (auto i : portduino_config.extra_pins) {
|
||||
// In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora
|
||||
// Those GPIO are handled in our usermode driver instead.
|
||||
if (i.config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") {
|
||||
if (i.config_section == "Lora" &&
|
||||
(portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) {
|
||||
continue;
|
||||
}
|
||||
if (i.enabled && i.default_high) {
|
||||
@@ -637,7 +677,8 @@ void portduinoSetup()
|
||||
}
|
||||
|
||||
// Only initialize the radio pins when dealing with real, kernel controlled SPI hardware
|
||||
if (portduino_config.lora_spi_dev != "" && portduino_config.lora_spi_dev != "ch341") {
|
||||
if (portduino_config.lora_spi_dev != "" && portduino_config.lora_spi_dev != "ch341" &&
|
||||
portduino_config.lora_spi_dev != "serial") {
|
||||
SPI.begin(portduino_config.lora_spi_dev.c_str());
|
||||
}
|
||||
|
||||
@@ -671,6 +712,7 @@ void portduinoSetup()
|
||||
}
|
||||
if (portduino_config.lora_spi_dev != "") {
|
||||
portduinoSetOptions({.realHardware = true});
|
||||
LOG_DEBUG("Running with real hardware SPI device %s", portduino_config.lora_spi_dev.c_str());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -807,12 +849,15 @@ bool loadConfig(const char *configPath)
|
||||
}
|
||||
|
||||
portduino_config.spiSpeed = yamlConfig["Lora"]["spiSpeed"].as<int>(2000000);
|
||||
portduino_config.lora_serial_device = yamlConfig["Lora"]["SerialDevice"].as<std::string>("");
|
||||
portduino_config.lora_serial_baud = yamlConfig["Lora"]["SerialBaud"].as<int>(115200);
|
||||
portduino_config.lora_serial_timeout_ms = yamlConfig["Lora"]["SerialTimeoutMs"].as<int>(500);
|
||||
portduino_config.lora_usb_serial_num = yamlConfig["Lora"]["USB_Serialnum"].as<std::string>("");
|
||||
portduino_config.lora_usb_pid = yamlConfig["Lora"]["USB_PID"].as<int>(0x5512);
|
||||
portduino_config.lora_usb_vid = yamlConfig["Lora"]["USB_VID"].as<int>(0x1A86);
|
||||
|
||||
portduino_config.lora_spi_dev = yamlConfig["Lora"]["spidev"].as<std::string>("spidev0.0");
|
||||
if (portduino_config.lora_spi_dev != "ch341") {
|
||||
if (portduino_config.lora_spi_dev != "ch341" && portduino_config.lora_spi_dev != "serial") {
|
||||
portduino_config.lora_spi_dev = "/dev/" + portduino_config.lora_spi_dev;
|
||||
if (portduino_config.lora_spi_dev.length() == 14) {
|
||||
int x = portduino_config.lora_spi_dev.at(11) - '0';
|
||||
@@ -1056,17 +1101,31 @@ static bool ends_with(std::string_view str, std::string_view suffix)
|
||||
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());
|
||||
if (mac_str.length() == 12) {
|
||||
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);
|
||||
dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16);
|
||||
dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16);
|
||||
dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16);
|
||||
dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16);
|
||||
return true;
|
||||
} else {
|
||||
if (mac_str.length() != 12) {
|
||||
return false;
|
||||
}
|
||||
// Validate every character is a hex digit before parsing. std::stoi
|
||||
// would otherwise skip leading whitespace and silently truncate at the
|
||||
// first non-digit, which is too lenient for a MAC address.
|
||||
for (char c : mac_str) {
|
||||
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;
|
||||
}
|
||||
|
||||
std::string exec(const char *cmd)
|
||||
|
||||
@@ -86,6 +86,9 @@ extern struct portduino_config_struct {
|
||||
bool has_device_id = false;
|
||||
uint8_t device_id[16] = {0};
|
||||
std::string lora_spi_dev = "";
|
||||
std::string lora_serial_device = "";
|
||||
int lora_serial_baud = 115200;
|
||||
int lora_serial_timeout_ms = 500;
|
||||
std::string lora_usb_serial_num = "";
|
||||
int lora_spi_dev_int = 0;
|
||||
int lora_default_gpiochip = 0;
|
||||
@@ -276,6 +279,12 @@ extern struct portduino_config_struct {
|
||||
}
|
||||
if (lora_usb_serial_num != "")
|
||||
out << YAML::Key << "USB_Serialnum" << YAML::Value << lora_usb_serial_num;
|
||||
if (lora_serial_device != "")
|
||||
out << YAML::Key << "SerialDevice" << YAML::Value << lora_serial_device;
|
||||
if (lora_serial_baud != 115200)
|
||||
out << YAML::Key << "SerialBaud" << YAML::Value << lora_serial_baud;
|
||||
if (lora_serial_timeout_ms != 500)
|
||||
out << YAML::Key << "SerialTimeoutMs" << YAML::Value << lora_serial_timeout_ms;
|
||||
if (spiSpeed != 2000000)
|
||||
out << YAML::Key << "spiSpeed" << YAML::Value << spiSpeed;
|
||||
if (rfswitch_dio_pins[0] != RADIOLIB_NC) {
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
#include "platform/portduino/SerialHal.h"
|
||||
|
||||
#include "mesh/mesh-pb-constants.h"
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <sched.h>
|
||||
#include <sys/time.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint8_t START1 = 0x94;
|
||||
constexpr uint8_t SERIALHAL_MAGIC = 0xA5;
|
||||
constexpr size_t HEADER_SIZE = 4; // START1 + SERIALHAL_MAGIC + LEN_H + LEN_L
|
||||
constexpr uint8_t START2 = 0xC3; // second byte of a normal FromRadio frame
|
||||
|
||||
speed_t toTermiosBaud(uint32_t baud)
|
||||
{
|
||||
switch (baud) {
|
||||
case 9600:
|
||||
return B9600;
|
||||
case 19200:
|
||||
return B19200;
|
||||
case 38400:
|
||||
return B38400;
|
||||
case 57600:
|
||||
return B57600;
|
||||
case 115200:
|
||||
return B115200;
|
||||
case 230400:
|
||||
return B230400;
|
||||
case 460800:
|
||||
return B460800;
|
||||
case 921600:
|
||||
return B921600;
|
||||
default:
|
||||
return B115200;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SerialHal::SerialHal(const std::string &devicePath, uint32_t baudRate, uint32_t opTimeoutMs)
|
||||
: RadioLibHal(SERIAL_PI_INPUT, SERIAL_PI_OUTPUT, SERIAL_PI_LOW, SERIAL_PI_HIGH, SERIAL_PI_RISING, SERIAL_PI_FALLING),
|
||||
device(devicePath), baud(baudRate), timeoutMs(opTimeoutMs)
|
||||
{
|
||||
if (!openPort()) {
|
||||
setTransportError("unable to open serial device");
|
||||
}
|
||||
}
|
||||
|
||||
SerialHal::~SerialHal()
|
||||
{
|
||||
closePort();
|
||||
}
|
||||
|
||||
bool SerialHal::openPort()
|
||||
{
|
||||
closePort();
|
||||
fd = ::open(device.c_str(), O_RDWR | O_NOCTTY | O_SYNC);
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
termios tty = {};
|
||||
if (tcgetattr(fd, &tty) != 0) {
|
||||
closePort();
|
||||
return false;
|
||||
}
|
||||
|
||||
cfsetospeed(&tty, toTermiosBaud(baud));
|
||||
cfsetispeed(&tty, toTermiosBaud(baud));
|
||||
|
||||
tty.c_cflag = (tty.c_cflag & ~CSIZE) | CS8;
|
||||
tty.c_iflag &= ~(IGNBRK | IXON | IXOFF | IXANY);
|
||||
tty.c_lflag = 0;
|
||||
tty.c_oflag = 0;
|
||||
tty.c_cc[VMIN] = 0;
|
||||
tty.c_cc[VTIME] = 0;
|
||||
tty.c_cflag |= (CLOCAL | CREAD);
|
||||
tty.c_cflag &= ~(PARENB | PARODD);
|
||||
tty.c_cflag &= ~CSTOPB;
|
||||
tty.c_cflag &= ~CRTSCTS;
|
||||
|
||||
if (tcsetattr(fd, TCSANOW, &tty) != 0) {
|
||||
closePort();
|
||||
return false;
|
||||
}
|
||||
|
||||
tcflush(fd, TCIOFLUSH);
|
||||
inError = false;
|
||||
startReaderThread();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialHal::closePort()
|
||||
{
|
||||
stopReaderThread();
|
||||
if (fd >= 0) {
|
||||
::close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHal::setTransportError(const char *msg)
|
||||
{
|
||||
if (!inError.load() || !hasWarned) {
|
||||
LOG_ERROR("SerialHal: %s (%s)", msg, device.c_str());
|
||||
}
|
||||
inError = true;
|
||||
hasWarned = true;
|
||||
portduino_status.LoRa_in_error = true;
|
||||
}
|
||||
|
||||
bool SerialHal::waitForReadable(int timeout)
|
||||
{
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
pollfd pfd = {};
|
||||
pfd.fd = fd;
|
||||
pfd.events = POLLIN;
|
||||
int ret = poll(&pfd, 1, timeout);
|
||||
return ret > 0 && (pfd.revents & POLLIN);
|
||||
}
|
||||
|
||||
bool SerialHal::writeAll(const uint8_t *data, size_t len)
|
||||
{
|
||||
size_t off = 0;
|
||||
while (off < len) {
|
||||
ssize_t rc = ::write(fd, data + off, len - off);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
off += (size_t)rc;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SerialHal::readExact(uint8_t *data, size_t len)
|
||||
{
|
||||
size_t off = 0;
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
while (off < len) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
int elapsed = (int)std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count();
|
||||
int remaining = (int)timeoutMs - elapsed;
|
||||
if (remaining <= 0 || !waitForReadable(remaining)) {
|
||||
return false;
|
||||
}
|
||||
ssize_t rc = ::read(fd, data + off, len - off);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (rc == 0) {
|
||||
return false;
|
||||
}
|
||||
off += (size_t)rc;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t SerialHal::crc16(const uint8_t *data, size_t len) const
|
||||
{
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
crc ^= ((uint16_t)data[i] << 8);
|
||||
for (int bit = 0; bit < 8; ++bit) {
|
||||
if (crc & 0x8000) {
|
||||
crc = (uint16_t)((crc << 1) ^ 0x1021);
|
||||
} else {
|
||||
crc = (uint16_t)(crc << 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool SerialHal::sendRequest(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse *response)
|
||||
{
|
||||
if (fd < 0 && !openPort()) {
|
||||
setTransportError("serial open failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t encoded[meshtastic_SerialHalCommand_size] = {0};
|
||||
const size_t payloadLen =
|
||||
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalCommand_msg, static_cast<const void *>(&cmd));
|
||||
if (payloadLen == 0 || payloadLen > 0xFFFF) {
|
||||
setTransportError("serial command encode failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build frame with StreamAPI canonical framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload]
|
||||
std::vector<uint8_t> frame;
|
||||
frame.resize(HEADER_SIZE + payloadLen);
|
||||
|
||||
frame[0] = START1;
|
||||
frame[1] = SERIALHAL_MAGIC;
|
||||
frame[2] = (uint8_t)((payloadLen >> 8) & 0xFF); // LEN_H (big-endian)
|
||||
frame[3] = (uint8_t)(payloadLen & 0xFF); // LEN_L
|
||||
memcpy(frame.data() + HEADER_SIZE, encoded, payloadLen);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> writeGuard(writeMutex);
|
||||
if (!writeAll(frame.data(), frame.size())) {
|
||||
setTransportError("serial write failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
meshtastic_SerialHalResponse got = meshtastic_SerialHalResponse_init_zero;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(stateMutex);
|
||||
const auto timeout = std::chrono::milliseconds(timeoutMs);
|
||||
const bool arrived = responseCv.wait_for(lock, timeout, [&]() { return pendingResponses.count(cmd.transaction_id) > 0; });
|
||||
if (!arrived) {
|
||||
setTransportError("serial response timeout");
|
||||
LOG_WARN("SerialHal: response timeout for transaction_id %u, cmd type %u", cmd.transaction_id, cmd.type);
|
||||
return false;
|
||||
}
|
||||
|
||||
got = pendingResponses[cmd.transaction_id];
|
||||
pendingResponses.erase(cmd.transaction_id);
|
||||
}
|
||||
|
||||
if (got.result != meshtastic_SerialHalResponse_Result_OK) {
|
||||
setTransportError("serial response reported error");
|
||||
LOG_WARN("SerialHal: response error: %s, %u, %u", got.error, cmd.type, cmd.data.size);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response != nullptr) {
|
||||
*response = got;
|
||||
}
|
||||
|
||||
inError = false;
|
||||
hasWarned = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SerialHal::pinMode(uint32_t pin, uint32_t mode)
|
||||
{
|
||||
if (checkError() || pin == RADIOLIB_NC) {
|
||||
return;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_PIN_MODE;
|
||||
cmd.pin = pin;
|
||||
cmd.mode = mode;
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
sendRequest(cmd, &response);
|
||||
}
|
||||
|
||||
void SerialHal::digitalWrite(uint32_t pin, uint32_t value)
|
||||
{
|
||||
if (checkError() || pin == RADIOLIB_NC) {
|
||||
return;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_DIGITAL_WRITE;
|
||||
cmd.pin = pin;
|
||||
cmd.value = value;
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
sendRequest(cmd, &response);
|
||||
}
|
||||
|
||||
uint32_t SerialHal::digitalRead(uint32_t pin)
|
||||
{
|
||||
if (checkError() || pin == RADIOLIB_NC) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_DIGITAL_READ;
|
||||
cmd.pin = pin;
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
if (!sendRequest(cmd, &response)) {
|
||||
return 0;
|
||||
}
|
||||
return response.value;
|
||||
}
|
||||
|
||||
void SerialHal::attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode)
|
||||
{
|
||||
if (checkError() || interruptNum == RADIOLIB_NC) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(stateMutex);
|
||||
interruptCallbacks[interruptNum] = interruptCb;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT;
|
||||
cmd.pin = interruptNum;
|
||||
cmd.mode = mode;
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
sendRequest(cmd, &response);
|
||||
}
|
||||
|
||||
void SerialHal::detachInterrupt(uint32_t interruptNum)
|
||||
{
|
||||
if (checkError() || interruptNum == RADIOLIB_NC) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(stateMutex);
|
||||
interruptCallbacks.erase(interruptNum);
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT;
|
||||
cmd.pin = interruptNum;
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
sendRequest(cmd, &response);
|
||||
}
|
||||
|
||||
void SerialHal::delay(unsigned long ms)
|
||||
{
|
||||
delayMicroseconds(ms * 1000);
|
||||
}
|
||||
|
||||
void SerialHal::delayMicroseconds(unsigned long us)
|
||||
{
|
||||
if (us == 0) {
|
||||
sched_yield();
|
||||
return;
|
||||
}
|
||||
usleep(us);
|
||||
}
|
||||
|
||||
void SerialHal::yield()
|
||||
{
|
||||
sched_yield();
|
||||
}
|
||||
|
||||
unsigned long SerialHal::millis()
|
||||
{
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
return (tv.tv_sec * 1000ULL) + (tv.tv_usec / 1000ULL);
|
||||
}
|
||||
|
||||
unsigned long SerialHal::micros()
|
||||
{
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, nullptr);
|
||||
return (tv.tv_sec * 1000000ULL) + tv.tv_usec;
|
||||
}
|
||||
|
||||
long SerialHal::pulseIn(uint32_t pin, uint32_t state, unsigned long timeout)
|
||||
{
|
||||
(void)pin;
|
||||
(void)state;
|
||||
(void)timeout;
|
||||
LOG_WARN("SerialHal pulseIn is not supported");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void SerialHal::spiTransfer(uint8_t *out, size_t len, uint8_t *in)
|
||||
{
|
||||
if (checkError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
cmd.transaction_id = txId.fetch_add(1);
|
||||
cmd.type = meshtastic_SerialHalCommand_Type_SPI_TRANSFER;
|
||||
|
||||
const size_t maxTx = sizeof(cmd.data.bytes);
|
||||
const size_t txLen = len < maxTx ? len : maxTx;
|
||||
cmd.data.size = txLen;
|
||||
if (out != nullptr) {
|
||||
memcpy(cmd.data.bytes, out, txLen);
|
||||
} else {
|
||||
memset(cmd.data.bytes, 0, txLen);
|
||||
}
|
||||
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
if (!sendRequest(cmd, &response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in != nullptr) {
|
||||
size_t copyLen = response.data.size < len ? response.data.size : len;
|
||||
memcpy(in, response.data.bytes, copyLen);
|
||||
if (copyLen < len) {
|
||||
memset(in + copyLen, 0, len - copyLen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SerialHal::checkError()
|
||||
{
|
||||
if (inError.load()) {
|
||||
if (!hasWarned) {
|
||||
LOG_ERROR("SerialHal in_error detected");
|
||||
hasWarned = true;
|
||||
}
|
||||
portduino_status.LoRa_in_error = true;
|
||||
return true;
|
||||
}
|
||||
hasWarned = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SerialHal::readFrame(std::vector<uint8_t> &payload, int firstByteTimeoutMs)
|
||||
{
|
||||
payload.clear();
|
||||
|
||||
// Loop so that normal FromRadio frames (START1 START2 ...) emitted by the
|
||||
// device on the same serial port are drained and discarded rather than
|
||||
// causing the byte stream to desync.
|
||||
for (;;) {
|
||||
uint8_t hdr[HEADER_SIZE] = {0};
|
||||
for (;;) {
|
||||
|
||||
ssize_t rc = ::read(fd, &hdr[0], 1);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (rc == 0) {
|
||||
return false;
|
||||
}
|
||||
if (hdr[0] == START1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!readExact(hdr + 1, HEADER_SIZE - 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t len = ((uint16_t)hdr[2] << 8) | (uint16_t)hdr[3];
|
||||
|
||||
if (hdr[1] == SERIALHAL_MAGIC) {
|
||||
// SerialHal response frame — this is what we want.
|
||||
if (len > meshtastic_SerialHalResponse_size) {
|
||||
return false;
|
||||
}
|
||||
payload.resize(len);
|
||||
if (len > 0 && !readExact(payload.data(), len)) {
|
||||
payload.clear();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else if (hdr[1] == START2) {
|
||||
// Normal FromRadio frame emitted by the device — drain and discard
|
||||
// its payload so we stay in sync, then loop to find a SerialHal frame.
|
||||
if (len > 0) {
|
||||
std::vector<uint8_t> discard(len);
|
||||
if (!readExact(discard.data(), len)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// continue looping, look for next frame
|
||||
} else {
|
||||
// Unknown second byte after START1 — restart search for framing.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHal::readerLoop()
|
||||
{
|
||||
readerRunning = true;
|
||||
while (!readerStopRequested.load()) {
|
||||
if (fd < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!waitForReadable(100)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> payload;
|
||||
if (!readFrame(payload, 40)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalResponse resp = meshtastic_SerialHalResponse_init_zero;
|
||||
if (payload.empty() || !pb_decode_from_bytes(payload.data(), payload.size(), &meshtastic_SerialHalResponse_msg, &resp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resp.transaction_id == 0) {
|
||||
LOG_WARN("SerialHal: received unsolicited interrupt event: pin=%u", resp.value);
|
||||
// transaction_id 0 is reserved for unsolicited interrupt events.
|
||||
// The device reports the triggered pin in resp.value instead of
|
||||
// matching one of the synchronous request/response transactions.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(stateMutex);
|
||||
if (interruptCallbacks.count(resp.value) > 0) {
|
||||
pendingInterruptPins.push_back(resp.value);
|
||||
}
|
||||
}
|
||||
interruptCv.notify_one();
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(stateMutex);
|
||||
pendingResponses[resp.transaction_id] = resp;
|
||||
}
|
||||
responseCv.notify_all();
|
||||
}
|
||||
readerRunning = false;
|
||||
}
|
||||
|
||||
void SerialHal::interruptDispatchLoop()
|
||||
{
|
||||
interruptDispatcherRunning = true;
|
||||
while (!readerStopRequested.load()) {
|
||||
uint32_t pin = 0;
|
||||
void (*cb)(void) = nullptr;
|
||||
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(stateMutex);
|
||||
interruptCv.wait(lock, [&]() { return readerStopRequested.load() || !pendingInterruptPins.empty(); });
|
||||
if (readerStopRequested.load()) {
|
||||
break;
|
||||
}
|
||||
|
||||
pin = pendingInterruptPins.front();
|
||||
pendingInterruptPins.pop_front();
|
||||
|
||||
auto it = interruptCallbacks.find(pin);
|
||||
if (it != interruptCallbacks.end()) {
|
||||
cb = it->second;
|
||||
}
|
||||
}
|
||||
|
||||
if (cb != nullptr) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
interruptDispatcherRunning = false;
|
||||
}
|
||||
|
||||
void SerialHal::startReaderThread()
|
||||
{
|
||||
stopReaderThread();
|
||||
readerStopRequested = false;
|
||||
readerThread = std::thread(&SerialHal::readerLoop, this);
|
||||
interruptThread = std::thread(&SerialHal::interruptDispatchLoop, this);
|
||||
}
|
||||
|
||||
void SerialHal::stopReaderThread()
|
||||
{
|
||||
readerStopRequested = true;
|
||||
interruptCv.notify_all();
|
||||
if (readerThread.joinable()) {
|
||||
readerThread.join();
|
||||
}
|
||||
if (interruptThread.joinable()) {
|
||||
interruptThread.join();
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(stateMutex);
|
||||
pendingInterruptPins.clear();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#ifndef PI_HAL_SERIAL_H
|
||||
#define PI_HAL_SERIAL_H
|
||||
|
||||
#include <RadioLib.h>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mesh/generated/meshtastic/serial_hal.pb.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#define SERIAL_PI_INPUT (0)
|
||||
#define SERIAL_PI_OUTPUT (1)
|
||||
#define SERIAL_PI_LOW (0)
|
||||
#define SERIAL_PI_HIGH (1)
|
||||
#define SERIAL_PI_RISING (1)
|
||||
#define SERIAL_PI_FALLING (2)
|
||||
|
||||
class SerialHal : public RadioLibHal
|
||||
{
|
||||
public:
|
||||
explicit SerialHal(const std::string &device, uint32_t baud = 115200, uint32_t timeoutMs = 500);
|
||||
~SerialHal() override;
|
||||
|
||||
void init() override {}
|
||||
void term() override {}
|
||||
|
||||
void pinMode(uint32_t pin, uint32_t mode) override;
|
||||
void digitalWrite(uint32_t pin, uint32_t value) override;
|
||||
uint32_t digitalRead(uint32_t pin) override;
|
||||
|
||||
void attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) override;
|
||||
void detachInterrupt(uint32_t interruptNum) override;
|
||||
|
||||
void delay(unsigned long ms) override;
|
||||
void delayMicroseconds(unsigned long us) override;
|
||||
void yield() override;
|
||||
|
||||
unsigned long millis() override;
|
||||
unsigned long micros() override;
|
||||
|
||||
long pulseIn(uint32_t pin, uint32_t state, unsigned long timeout) override;
|
||||
|
||||
void spiBegin() override {}
|
||||
void spiBeginTransaction() override {}
|
||||
void spiTransfer(uint8_t *out, size_t len, uint8_t *in) override;
|
||||
void spiEndTransaction() override {}
|
||||
void spiEnd() override {}
|
||||
|
||||
bool checkError();
|
||||
|
||||
private:
|
||||
bool openPort();
|
||||
void closePort();
|
||||
bool sendRequest(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse *response);
|
||||
bool writeAll(const uint8_t *data, size_t len);
|
||||
bool readExact(uint8_t *data, size_t len);
|
||||
bool waitForReadable(int timeoutMs);
|
||||
bool readFrame(std::vector<uint8_t> &payload, int firstByteTimeoutMs);
|
||||
void readerLoop();
|
||||
void interruptDispatchLoop();
|
||||
void startReaderThread();
|
||||
void stopReaderThread();
|
||||
|
||||
uint16_t crc16(const uint8_t *data, size_t len) const;
|
||||
void setTransportError(const char *msg);
|
||||
|
||||
std::string device;
|
||||
uint32_t baud;
|
||||
uint32_t timeoutMs;
|
||||
int fd = -1;
|
||||
bool hasWarned = false;
|
||||
std::atomic<bool> inError{false};
|
||||
std::atomic<uint16_t> txId{1};
|
||||
|
||||
std::mutex fdMutex;
|
||||
std::mutex writeMutex;
|
||||
std::mutex stateMutex;
|
||||
std::condition_variable responseCv;
|
||||
|
||||
std::thread readerThread;
|
||||
std::thread interruptThread;
|
||||
std::atomic<bool> readerStopRequested{false};
|
||||
std::atomic<bool> readerRunning{false};
|
||||
std::atomic<bool> interruptDispatcherRunning{false};
|
||||
|
||||
std::condition_variable interruptCv;
|
||||
std::deque<uint32_t> pendingInterruptPins;
|
||||
|
||||
std::unordered_map<uint32_t, void (*)(void)> interruptCallbacks;
|
||||
std::unordered_map<uint16_t, meshtastic_SerialHalResponse> pendingResponses;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -25,23 +25,27 @@
|
||||
#include "LittleFS.h"
|
||||
#include "stm32wlxx_hal_flash.h"
|
||||
|
||||
/**********************************************************************************************************************
|
||||
* Macro definitions
|
||||
**********************************************************************************************************************/
|
||||
/** This macro is used to suppress compiler messages about a parameter not being used in a function. */
|
||||
/** Suppress unused-parameter warnings. */
|
||||
#define LFS_UNUSED(p) (void)((p))
|
||||
|
||||
#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE)
|
||||
#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) // physical flash erase granularity: 2048 B
|
||||
#define STM32WL_PAGE_COUNT (FLASH_PAGE_NB)
|
||||
#define STM32WL_FLASH_BASE (FLASH_BASE)
|
||||
|
||||
/*
|
||||
* FLASH_SIZE from stm32wle5xx.h will read the actual FLASH size from the chip.
|
||||
* FLASH_END_ADDR is calculated from FLASH_SIZE.
|
||||
* Use the last 28 KiB of the FLASH
|
||||
* LFS tunables — all of these are stored in the LFS superblock.
|
||||
* Changing ANY of them is incompatible with the existing on-disk format;
|
||||
* the filesystem will be detected as corrupted and reformatted on first boot.
|
||||
*
|
||||
* 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 (14 * 2048) /* needs to be a multiple of LFS_BLOCK_SIZE */
|
||||
#define LFS_BLOCK_SIZE (2048)
|
||||
#define LFS_FLASH_TOTAL_SIZE \
|
||||
(7 * STM32WL_PAGE_SIZE) /* 14 KiB — last 7 physical pages (FORMAT BREAK: reduced from 10 pages / 20 KiB) */
|
||||
#define LFS_BLOCK_SIZE (256) /* virtual block size (FORMAT BREAK if changed) */
|
||||
|
||||
#define LFS_FLASH_ADDR_END (FLASH_END_ADDR)
|
||||
#define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1)
|
||||
|
||||
@@ -51,6 +55,80 @@
|
||||
#define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__)
|
||||
#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
|
||||
//--------------------------------------------------------------------+
|
||||
@@ -59,111 +137,82 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l
|
||||
{
|
||||
LFS_UNUSED(c);
|
||||
|
||||
if (!buffer || !size) {
|
||||
_LFS_DBG("%s Invalid parameter!\r\n", __func__);
|
||||
return LFS_ERR_INVAL;
|
||||
}
|
||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off;
|
||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
|
||||
lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off);
|
||||
|
||||
memcpy(buffer, (void *)address, size);
|
||||
if (_page_cache_addr == page_addr)
|
||||
memcpy(buffer, _page_cache + page_off, size);
|
||||
else
|
||||
memcpy(buffer, (const void *)addr, size);
|
||||
|
||||
return LFS_ERR_OK;
|
||||
}
|
||||
|
||||
// Program a region in a block. The block must have previously
|
||||
// been erased. Negative error codes are propogated to the user.
|
||||
// May return LFS_ERR_CORRUPT if the block should be considered bad.
|
||||
// Program a region in a block. The block must have previously been erased.
|
||||
// Writes are accumulated in the page cache and flushed on sync or page eviction.
|
||||
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_DBG("Programming %d bytes/%d doublewords at address 0x%08x/block %d, offset %d.", size, dw_count, address, block, off);
|
||||
if (HAL_FLASH_Unlock() != HAL_OK) {
|
||||
return LFS_ERR_IO;
|
||||
}
|
||||
for (uint32_t i = 0; i < dw_count; i++) {
|
||||
if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) {
|
||||
_LFS_DBG("Wanted to program out of bound of FLASH: 0x%08x.\n", address);
|
||||
HAL_FLASH_Lock();
|
||||
return LFS_ERR_INVAL;
|
||||
}
|
||||
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;
|
||||
}
|
||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off;
|
||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
|
||||
return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO
|
||||
int rc = _flash_cache_load(page_addr);
|
||||
if (rc != LFS_ERR_OK)
|
||||
return rc;
|
||||
|
||||
memcpy(_page_cache + page_off, buffer, size);
|
||||
_page_cache_dirty = true;
|
||||
return LFS_ERR_OK;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Erase a virtual block. Marks the corresponding region in the page cache as 0xFF.
|
||||
// Physical erase of the containing page is deferred until sync or page eviction.
|
||||
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);
|
||||
|
||||
if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) {
|
||||
_LFS_DBG("Wanted to erase out of bound of FLASH: 0x%08x.\n", address);
|
||||
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();
|
||||
uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE;
|
||||
uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1);
|
||||
|
||||
return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO
|
||||
int rc = _flash_cache_load(page_addr);
|
||||
if (rc != LFS_ERR_OK)
|
||||
return rc;
|
||||
|
||||
memset(_page_cache + page_off, 0xFF, LFS_BLOCK_SIZE);
|
||||
_page_cache_dirty = true;
|
||||
return LFS_ERR_OK;
|
||||
}
|
||||
|
||||
// Sync the state of the underlying block device. Negative error codes
|
||||
// are propogated to the user.
|
||||
// Flush the write-behind cache to flash.
|
||||
static int _internal_flash_sync(const struct lfs_config *c)
|
||||
{
|
||||
LFS_UNUSED(c);
|
||||
// write function performs no caching. No need for sync.
|
||||
|
||||
return LFS_ERR_OK;
|
||||
return _flash_cache_flush();
|
||||
}
|
||||
|
||||
static struct lfs_config _InternalFSConfig = {.context = NULL,
|
||||
static struct lfs_config _InternalFSConfig = {
|
||||
.context = NULL,
|
||||
|
||||
.read = _internal_flash_read,
|
||||
.prog = _internal_flash_prog,
|
||||
.erase = _internal_flash_erase,
|
||||
.sync = _internal_flash_sync,
|
||||
.read = _internal_flash_read,
|
||||
.prog = _internal_flash_prog,
|
||||
.erase = _internal_flash_erase,
|
||||
.sync = _internal_flash_sync,
|
||||
|
||||
.read_size = LFS_BLOCK_SIZE,
|
||||
.prog_size = LFS_BLOCK_SIZE,
|
||||
.block_size = LFS_BLOCK_SIZE,
|
||||
.block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE,
|
||||
.lookahead = 128,
|
||||
.read_size = LFS_BLOCK_SIZE,
|
||||
.prog_size = LFS_BLOCK_SIZE,
|
||||
.block_size = LFS_BLOCK_SIZE,
|
||||
.block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE,
|
||||
.lookahead = 128,
|
||||
|
||||
.read_buffer = NULL,
|
||||
.prog_buffer = NULL,
|
||||
.lookahead_buffer = NULL,
|
||||
.file_buffer = NULL};
|
||||
.read_buffer = NULL,
|
||||
.prog_buffer = NULL,
|
||||
.lookahead_buffer = NULL,
|
||||
.file_buffer = NULL,
|
||||
};
|
||||
|
||||
LittleFS InternalFS;
|
||||
|
||||
@@ -179,17 +228,17 @@ bool LittleFS::begin(void)
|
||||
/* There is not enough space on this device for a filesystem. */
|
||||
return false;
|
||||
}
|
||||
// failed to mount, erase all pages then format and mount again
|
||||
// failed to mount, erase all virtual blocks then format and mount again
|
||||
if (!STM32_LittleFS::begin()) {
|
||||
// Erase all pages of internal flash region for Filesystem.
|
||||
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);
|
||||
for (lfs_block_t block = 0; block < _InternalFSConfig.block_count; block++) {
|
||||
_internal_flash_erase(&_InternalFSConfig, block);
|
||||
}
|
||||
_flash_cache_flush(); // flush the last cached page
|
||||
|
||||
// lfs format
|
||||
this->format();
|
||||
|
||||
// mount again if still failed, give up
|
||||
// mount again; if still failed, give up
|
||||
if (!STM32_LittleFS::begin())
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#include "LittleFS.h"
|
||||
#include "STM32_LittleFS.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
@@ -391,3 +392,8 @@ void File::rewindDirectory(void)
|
||||
}
|
||||
_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,6 +44,7 @@ class File : public Stream
|
||||
public:
|
||||
explicit File(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:
|
||||
bool open(char const *filename, uint8_t mode);
|
||||
|
||||
@@ -61,11 +61,7 @@ class XModemAdapter
|
||||
|
||||
uint16_t packetno = 0;
|
||||
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_STM32WL)
|
||||
File file = File(FSCom);
|
||||
#else
|
||||
File file;
|
||||
#endif
|
||||
|
||||
char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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() {}
|
||||
@@ -9,6 +9,7 @@
|
||||
#define ST7789_BUSY -1
|
||||
// #define VTFT_CTRL 38
|
||||
#define VTFT_LEDA 38
|
||||
#define TFT_BACKLIGHT_ON HIGH
|
||||
// #define ST7789_BL (32+6)
|
||||
#define ST7789_SPI_HOST SPI2_HOST
|
||||
// #define TFT_BL (32+6)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
[portduino_base]
|
||||
platform =
|
||||
# renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop
|
||||
https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip
|
||||
https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip
|
||||
framework = arduino
|
||||
|
||||
build_src_filter =
|
||||
|
||||
@@ -125,6 +125,8 @@ test_testing_command =
|
||||
;
|
||||
; Prerequisites (Homebrew):
|
||||
; 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:
|
||||
; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`,
|
||||
@@ -191,7 +193,16 @@ build_flags = ${portduino_base.build_flags_common}
|
||||
; style screen-driver hooks scattered through sensor sources.
|
||||
-DHAS_SCREEN=0
|
||||
-DMESHTASTIC_EXCLUDE_SCREEN=1
|
||||
!pkg-config --libs openssl --silence-errors || :
|
||||
; openssl@3 is the keg-only Homebrew formula; --cflags is required so the
|
||||
; 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
|
||||
; 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
|
||||
@@ -206,3 +217,28 @@ build_src_filter = ${native_base.build_src_filter}
|
||||
lib_ignore =
|
||||
${portduino_base.lib_ignore}
|
||||
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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[env:CDEBYTE_E77-MBL]
|
||||
extends = stm32_base
|
||||
board = ebyte_e77_dev
|
||||
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
||||
board_level = extra
|
||||
build_flags =
|
||||
${stm32_base.build_flags}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
extends = stm32_base
|
||||
board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug
|
||||
board_level = extra
|
||||
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
||||
build_flags =
|
||||
${stm32_base.build_flags}
|
||||
-Ivariants/stm32/milesight_gs301
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
extends = stm32_base
|
||||
board = wiscore_rak3172
|
||||
board_level = pr
|
||||
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
||||
build_flags =
|
||||
${stm32_base.build_flags}
|
||||
-Ivariants/stm32/rak3172
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
extends = stm32_base
|
||||
board = wiscore_rak3172
|
||||
board_level = extra
|
||||
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
||||
build_flags =
|
||||
${stm32_base.build_flags}
|
||||
-Ivariants/stm32/russell
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
extends = stm32_base
|
||||
board = lora_e5_dev_board
|
||||
board_level = pr
|
||||
board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem
|
||||
board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp)
|
||||
build_flags =
|
||||
${stm32_base.build_flags}
|
||||
-Ivariants/stm32/wio-e5
|
||||
|
||||
Reference in New Issue
Block a user