Add companion terminal mode and LoRa OTA tooling

This commit is contained in:
mikecarper
2026-08-07 19:19:44 -07:00
parent cfdf33b7ca
commit bf62afa240
16 changed files with 2956 additions and 8 deletions
+1
View File
@@ -10,6 +10,7 @@ Below are a few quick start guides.
- [Telemetry History Decoder](./telemetry_decoder.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
- [Scripted LoRa OTA: Bash and PowerShell](./lora_ota_automation.md)
- [MeshTower V2 microSD self-updates](./ota_meshtower_v2_sdcard.md)
- [GPS Tracking](./gps_tracking.md)
- [Companion Protocol](./companion_protocol.md)
+385
View File
@@ -0,0 +1,385 @@
# Scripted LoRa OTA from start to finish
[`tools/lora_ota/lora_ota.sh`](../tools/lora_ota/lora_ota.sh) and
[`tools/lora_ota/lora_ota.ps1`](../tools/lora_ota/lora_ota.ps1) automate a
MeshCore LoRa firmware update from a release `.zip` or ready `.mota`. They
identify the destination, validate the hardware and running firmware, prepare
the right container, move the participating nodes to a temporary radio
channel, serve and monitor the download, request installation, restore the
controller, and check the rebooted node.
The script cannot install the destination's first OTA-capable firmware or its
nRF52 bootloader. Do those one-time jobs over USB before using LoRa OTA.
## Required topology
The reliable serial topology uses two local radios:
```text
authenticated admin commands
computer -- MeshCore binary API --> controller Companion -------------------+
| |
+-- raw text CLI + mOTA seeder --> OTA source ---- LoRa OTA blocks ----> target
| ^
+-- relay(s) --------+
```
- **Controller:** a Companion connected through serial, TCP, or BLE. The
script uses `meshcli` to send remote admin commands to the target and changes
the controller's live radio parameters during the transfer. A serial
Companion stays in its normal **Binary** USB mode at 115200 baud.
- **OTA source:** a separate OTA-enabled repeater or FULL node whose USB port
is a raw text CLI and supports `ota folder on`. `motatool` uses that same
link for its binary seeder frames after enabling the folder.
- **Target:** an OTA-enabled ESP32 or nRF52 node present in the controller's
contact list. Its admin password is required.
- **Relays:** optional. They do not need to install OTA themselves, but every
relay on the path must be running current firmware and have an overlapping
TempRadio window.
One serial port cannot serve both controller roles: `meshcli` must keep
reopening the controller while `motatool` owns the source port. The script
rejects an attempt to use the same port for both.
The Companion USB ASCII switch (`+++MESHCORE-TERM-START`) is an interactive
chat terminal, not the raw repeater/FULL management CLI expected by
`motatool`. It does not make a Companion's serial port an OTA seeder. For a
WiFi seeder, use a FULL/repeater source's port 5001 plus its raw USB CLI, as
shown below.
## Destination requirements
| Destination | Package installed | One-time prerequisite | Raw ZIP handling |
| --- | --- | --- | --- |
| ESP32 | Full application image | OTA-enabled image with an A/B partition table | Builds a full mOTA from the matching non-merged application `.bin` |
| nRF52, internal flash | In-place delta | Exact-board OTAFIX bootloader with mOTA apply support | Requires `--base` with the exact image currently running |
| MeshTower V2 nRF52, microSD | Full image or in-place delta | SD-aware exact-board OTAFIX bootloader and compatible card | Builds a full mOTA; adding `--base` requests a delta |
The firmware inside a raw ZIP must have a valid MeshCore `EndF` trailer. An
ESP32 merged/factory image is not an application image and is rejected. A
generic vendor DFU ZIP may also be unusable if it does not contain the raw
EndF-bearing `.hex` or `.bin`.
For an internal-flash nRF52, the exact base image is irreducible information.
The node reports its eight-byte body hash, but that hash cannot reconstruct the
firmware bytes needed to create a delta. Keep the `.pio/build/ENV/firmware.hex`
that was actually flashed. A matching filename or version alone is not enough.
## 1. Install the host tools
Install Python 3.10 or newer, [Rust](https://rustup.rs/), Git, the official
[`meshcore-cli`](https://github.com/meshcore-dev/meshcore-cli), and the official
[`motatool`](https://github.com/vk496/motatool).
On Bash:
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install meshcore-cli
git clone https://github.com/vk496/motatool.git
cargo install --path ./motatool
meshcli -v
motatool --version
```
On PowerShell:
```powershell
py -m pip install --user pipx
py -m pipx ensurepath
pipx install meshcore-cli
git clone https://github.com/vk496/motatool.git
cargo install --path .\motatool
meshcli -v
motatool --version
```
Restart the shell if `pipx` or Cargo reports that it changed `PATH`.
## 2. Identify and test both local links
List serial devices:
```bash
meshcli -l
```
The examples below assume `/dev/ttyACM0` is the controller and
`/dev/ttyACM1` is the OTA source. On Windows they might be `COM7` and `COM8`.
Close picocom, a serial monitor, the phone app, and any other program holding
either link.
Test the controller's binary API:
```bash
meshcli -s /dev/ttyACM0 -b 115200 ver
```
Test the source's raw text CLI and OTA support:
```bash
meshcli -r -s /dev/ttyACM1 -b 115200 "ota status"
```
The second command must print an `OTA | ... target:XXXXXXXX` status. The
automation repeats this preflight and stops before changing any radios if the
source is the wrong build or interface.
Changing a terminal to 57600 baud does not select ASCII mode. USB Companion
builds and the normal raw management CLI use 115200 unless a particular build
was explicitly configured otherwise.
## 3. Check the destination once
The destination must be in the controller's contacts and remotely reachable
on the normal channel. The script runs these authenticated checks itself:
```text
ota status
ota self
ota stats
```
For nRF52, `ota self` must report `bootloader: apply OK` or
`bootloader: SD apply OK`. The script also checks the reported bootloader ABI
and codec mask against the selected package.
The default TempRadio tuple is:
```text
909.950,250,7,5,120
```
The 250 kHz bandwidth, SF7, and CR5 combination is supported by every current
sub-GHz radio family used in USB Companion builds, including older SX127x
controllers (which do not support SF5). The frequency is only a North American
example: choose a legal frequency supported by every participating radio and
appropriate to your location. Pass the complete replacement tuple with
`--temp-radio`.
## 4. Run an ESP32 update
The ZIP can contain a compatible ready `.mota` or the exact board-and-role
non-merged application `.bin`:
```bash
export MESHCORE_ADMIN_PASSWORD='target-admin-password'
./tools/lora_ota/lora_ota.sh ./release.zip "Roof ESP32" \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1
```
The script shows the detected target, hardware, running hash, chosen package,
version, manifest ID, and action before asking for confirmation. For an
unattended job, add `--yes`:
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Roof ESP32" \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1 \
--yes
```
PowerShell equivalents:
```powershell
$env:MESHCORE_ADMIN_PASSWORD = 'target-admin-password'
& .\tools\lora_ota\lora_ota.ps1 '.\release.zip' 'Roof ESP32' `
--controller-serial COM7 `
--source-serial COM8
& .\tools\lora_ota\lora_ota.ps1 '.\release.mota' 'Roof ESP32' `
--controller-serial COM7 `
--source-serial COM8 `
--yes
```
Prefer the environment variable or the interactive password prompt. Passing
`--password` works, but the wrapper's own command line may be visible to other
local processes. The runner keeps the password out of child `meshcli` command
lines and removes its protected temporary command file after each call.
## 5. Run an internal-flash nRF52 update
If the input ZIP already contains a compatible in-place delta `.mota`, no
base argument is needed: its embedded base hash is compared with the live
node. If the ZIP contains raw new firmware, supply the exact running image:
```bash
./tools/lora_ota/lora_ota.sh ./nrf52-new-release.zip "Hill nRF52" \
--base ./firmware-that-is-running.hex \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1
```
```powershell
& .\tools\lora_ota\lora_ota.ps1 '.\nrf52-new-release.zip' 'Hill nRF52' `
--base '.\firmware-that-is-running.hex' `
--controller-serial COM7 `
--source-serial COM8
```
Before building the delta, the runner proves that the base's target ID,
hardware identity, firmware version when available, and `EndF` body hash match
the live destination. It then asks `motatool` for codec 2, the nRF52 in-place
format. The normal workspace is `0x98000`.
For the SD-backed MeshTower V2 target, a raw ZIP becomes a full image without
`--base`. Supplying an exact base requests a smaller in-place delta and
automatically selects its `0xC7000` workspace. An explicit
`--inplace-memory` overrides the automatic value.
## 6. Add intermediate relays
List relays from farthest to nearest so each command is sent before its route
moves to TempRadio. A bare relay name uses the destination password; use
`NAME=PASSWORD` when it differs:
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
--source-serial /dev/ttyACM1 \
--relay "Far Relay=far-password" \
--relay "Near Relay=near-password"
```
PowerShell uses the same arguments:
```powershell
& .\tools\lora_ota\lora_ota.ps1 '.\release.mota' 'Remote Target' `
--controller-serial COM7 `
--source-serial COM8 `
--relay 'Far Relay=far-password' `
--relay 'Near Relay=near-password'
```
## Other connection choices
The controller can use any one of:
```text
--controller-serial PORT
--controller-tcp HOST[:PORT] # default port 5000
--controller-ble ADDRESS_OR_NAME
```
An ESP32 FULL/repeater source can serve over its dedicated WiFi seeder port
while its raw USB CLI is used to start TempRadio:
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
--source-tcp 192.168.1.50:5001 \
--source-cli-serial /dev/ttyACM1
```
If the source is already on the exact TempRadio tuple through a scheduled or
manual operation, `--source-already-temp` lets a TCP source run without a raw
CLI link. The script cannot verify or extend that source window, so leave a
comfortable time margin.
Use `--controller-baud` or `--source-baud` only for a build whose corresponding
interface is genuinely configured to another speed.
## Package selection and safety gates
For a ZIP, the runner first examines every `.mota` without extracting paths.
It keeps only packages matching the live target, hardware, base, platform,
codec, and bootloader capabilities. It chooses the newest compatible version
and prefers a delta over a full image at the same version. If equally suitable
files differ, select one explicitly:
```text
--zip-member path/inside/archive/update.mota
```
If no ready mOTA is usable, it searches `.bin` and `.hex` members for a valid,
matching `EndF`, then builds the platform-appropriate container. Every result
is structurally checked by the runner and independently passed through
`motatool verify` before any radio changes.
Useful controls:
- `--public-key signer.key.pub` requires a particular Ed25519 signer during
verification.
- `--sign-key signer.key` signs a newly built container.
- `--no-install` downloads and verifies the image but leaves it staged.
- `--allow-non-upgrade` deliberately permits the same or an older version.
- `--replace-active-download` deliberately discards a different update already
downloading or staged on the target. Without it, that update is preserved.
- `--work-dir PATH` chooses a new, non-existent work directory.
- `--meshcli PATH` and `--motatool PATH` select binaries not on `PATH`.
For offline package preparation only:
```bash
./tools/lora_ota/lora_ota.sh ./release.zip offline \
--prepare-only \
--platform nrf52 \
--target-id 1234ABCD \
--target-base-hash 0011223344556677 \
--target-hw Heltec_T114 \
--base ./firmware-that-is-running.hex
```
Live operation is safer because the script obtains these values directly from
the destination.
## What happens during a run
1. Validate the input paths and host tools, then prove the source is an
OTA-enabled raw CLI.
2. Authenticate to the target and query its target ID, hardware, running body
hash, version, and nRF52 bootloader capabilities.
3. Select or build one compatible mOTA and verify all block hashes, Merkle
root, full-image hash where applicable, identity fields, signature, codec,
and base.
4. Save the controller's normal radio tuple and show the confirmation prompt.
5. Start TempRadio on the target, then far-to-near relays, then the source;
finally switch the controller to the same tuple.
6. Start `motatool serve`, discover the exact eight-hex manifest ID, request
`ota pull <id> flash`, and poll until the target reports ready.
7. Request `ota install`, restore the controller's original radio, wait for
reboot, then query the new running identity and version.
The working directory is retained and printed at exit. It contains the exact
served mOTA, `motatool-serve.log`, extracted build inputs when needed, and
`controller-radio.txt`. It contains no saved admin password.
## Interruption and recovery
Ctrl-C stops the seeder, detaches its serial folder, and attempts to restore
the controller. The target and relays remain on TempRadio only until their
bounded windows end; rebooting also restores their saved radio settings. A
partial download remains safe. Once the target is reachable again (after its
TempRadio window ends, or after putting the controller back on that tuple),
rerunning the same package recognizes its manifest ID and resumes the existing
session instead of clearing it.
A hard process kill or host power loss cannot run cleanup. Recover a serial
controller using the tuple saved in the printed work directory:
```bash
radio=$(tr -d '\r\n' < ./meshcore-lora-ota-20260807-123456-1234/controller-radio.txt)
meshcli -s /dev/ttyACM0 set radio "$radio"
```
```powershell
$radio = (Get-Content '.\meshcore-lora-ota-...\controller-radio.txt' -Raw).Trim()
meshcli -s COM7 set radio $radio
```
If installation was accepted but the final confirmation timed out, reconnect
on the node's normal channel and run `ota self` and `ver`. Do not immediately
replace a staged image: the default active-download guard preserves it until
you explicitly use `--replace-active-download` or run `ota cancel`.
Exit status is `0` for success, `2` for a validation or operational error, and
`130` for Ctrl-C.
+3
View File
@@ -3,6 +3,9 @@
This guide shows the shortest manual path for sending firmware from a computer to a MeshCore node over
LoRa. Choose the package type for the **destination** node:
For an end-to-end controller that accepts a release ZIP or ready mOTA, see
[Scripted LoRa OTA from start to finish](lora_ota_automation.md).
| Destination | Update type | Files needed to build the `.mota` | Installer |
| --- | --- | --- | --- |
| ESP32 | Full firmware | New non-merged application `.bin` | ESP32 A/B firmware slots |
+35
View File
@@ -2,6 +2,41 @@
Below are the commands you can enter into the Terminal Chat clients:
## Companion USB mode
A Companion USB build starts in the normal binary Companion protocol at
115200 baud. To use this terminal from the same firmware image, connect a
serial terminal and send this exact sequence:
```
+++MESHCORE-TERM-START
```
Send the following exact sequence to return to the binary protocol:
```
+++MESHCORE-TERM-STOP
```
Closing the serial connection also returns native-USB devices to binary mode.
Boards whose USB connector is implemented by a USB-to-UART bridge cannot
observe the host closing the port; on those boards, use the stop sequence or
reboot the device.
Both modes use the same port at 115200. Selecting 57600 is not a portable mode
switch: native USB CDC devices ignore the requested baud, while USB-to-UART
devices really change the UART timing and receive corrupt data. Binary mode is
the framed Companion API used by apps and `meshcli`; close the terminal before
opening that port from an app.
For example:
```sh
picocom --baud 115200 /dev/ttyACM0
```
## Commands
```
set freq {frequency}
```
+309
View File
@@ -172,6 +172,16 @@ static bool save_filter(const ContactInfo& c);
#define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg=="
#ifdef ENABLE_USB_INTERFACE
static const char* terminalContactTypeName(uint8_t type) {
if (type == ADV_TYPE_CHAT) return "Chat";
if (type == ADV_TYPE_REPEATER) return "Repeater";
if (type == ADV_TYPE_ROOM) return "Room";
if (type == ADV_TYPE_SENSOR) return "Sensor";
return "Unknown";
}
#endif
// these are _pushed_ to client app at any time
#define PUSH_CODE_ADVERT 0x80
#define PUSH_CODE_PATH_UPDATED 0x81
@@ -511,6 +521,16 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path
#endif
}
#ifdef ENABLE_USB_INTERFACE
if (_terminal_mode) {
Serial.printf("\r\nADVERT from -> %s\r\n", contact.name);
Serial.printf(" type: %s\r\n", terminalContactTypeName(contact.type));
Serial.print(" public key: ");
mesh::Utils::printHex(Serial, contact.id.pub_key, PUB_KEY_SIZE);
Serial.print("\r\n> ");
}
#endif
// add inbound-path to mem cache
if (path && mesh::Packet::isValidPathLen(path_len)) { // check path is valid
AdvertPath* p = advert_paths;
@@ -551,12 +571,30 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
return max_num;
}
#ifdef ENABLE_USB_INTERFACE
void MyMesh::onContactVisit(const ContactInfo& contact) {
if (contact.type == ADV_TYPE_NONE) return;
Serial.printf(" %s (%s) - ", contact.name, terminalContactTypeName(contact.type));
char relative_time[40];
int32_t seconds_from_now = contact.last_advert_timestamp - getRTCClock()->getCurrentTime();
AdvertTimeHelper::formatRelativeTimeDiff(relative_time, seconds_from_now, false);
Serial.println(relative_time);
}
#endif
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
out_frame[0] = PUSH_CODE_PATH_UPDATED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected
scheduleContactWrite(contact);
#ifdef ENABLE_USB_INTERFACE
if (_terminal_mode) {
Serial.printf("\r\nPATH updated -> %s\r\n> ", contact.name);
}
#endif
}
void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retries) {
@@ -579,6 +617,11 @@ void MyMesh::expireExpectedAcks() {
if (entry.expires_at == now || millisHasNowPassed(entry.expires_at)) {
if (!hasActiveRetries(entry.retry_key)) {
#ifdef ENABLE_USB_INTERFACE
if (entry.terminal_origin && _terminal_mode) {
Serial.print("\r\n ERROR: timed out, no ACK.\r\n> ");
}
#endif
clearExpectedAck(entry, false);
continue;
}
@@ -625,6 +668,13 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) {
memcpy(&out_frame[5], &trip_time, 4);
_serial->writeFrame(out_frame, 9);
#ifdef ENABLE_USB_INTERFACE
if (expected_ack_table[i].terminal_origin && _terminal_mode) {
Serial.printf("\r\n Got ACK! (round trip: %lu ms)\r\n> ",
(unsigned long)trip_time);
}
#endif
// NOTE: the same ACK can be received multiple times!
ContactInfo* contact = expected_ack_table[i].contact;
clearExpectedAck(expected_ack_table[i]);
@@ -670,6 +720,15 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
_serial->writeFrame(frame, 1);
}
#ifdef ENABLE_USB_INTERFACE
if (_terminal_mode) {
const char* kind = txt_type == TXT_TYPE_CLI_DATA ? "CLI" : "MSG";
Serial.printf("\r\n(%s) %s -> from %s\r\n %s\r\n> ",
pkt->isRouteDirect() ? "DIRECT" : "FLOOD", kind,
from.name, text);
}
#endif
#ifdef DISPLAY_CLASS
// we only want to show text messages on display, not cli data
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
@@ -815,6 +874,15 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
if (_ui) _ui->notify(UIEventType::channelMessage);
#endif
}
#ifdef ENABLE_USB_INTERFACE
if (_terminal_mode) {
ChannelDetails details;
const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown";
Serial.printf("\r\nPUBLIC CHANNEL MSG -> %s (%s)\r\n %s\r\n> ",
channel_name, pkt->isRouteDirect() ? "DIRECT" : "FLOOD", text);
}
#endif
#ifdef DISPLAY_CLASS
// Get the channel name from the channel index
const char *channel_name = "Unknown";
@@ -1127,6 +1195,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
_iter_started = false;
_cli_rescue = false;
#ifdef ENABLE_USB_INTERFACE
_terminal_mode = false;
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
#endif
saved_radio_apply_pending = false;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
@@ -3090,6 +3163,242 @@ void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) {
}
}
#ifdef ENABLE_USB_INTERFACE
void MyMesh::enterTerminalMode() {
_terminal_mode = true;
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
Serial.print("\r\n===== MeshCore Chat Terminal =====\r\n\r\n");
Serial.printf("WELCOME %s\r\n", _prefs.node_name);
mesh::Utils::printHex(Serial, self_id.pub_key, PUB_KEY_SIZE);
Serial.printf("\r\nCompanion %s\r\n", FIRMWARE_VERSION);
Serial.print(" (enter 'help' for commands)\r\n");
Serial.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n\r\n> ");
}
void MyMesh::exitTerminalMode() {
_terminal_mode = false;
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
}
ContactInfo* MyMesh::getTerminalRecipient() {
if (!_terminal_recipient_set) return NULL;
ContactInfo* recipient = lookupContactByPubKey(_terminal_recipient_key, PUB_KEY_SIZE);
if (recipient == NULL) {
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
}
return recipient;
}
void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t expected_ack, uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE]) {
if (expected_ack == 0) return;
AckTableEntry& entry = expected_ack_table[next_ack_idx];
clearExpectedAck(entry, false);
entry.msg_sent = _ms->getMillis();
entry.expires_at = futureMillis(est_timeout);
entry.ack = expected_ack;
entry.contact = &recipient;
mesh::Utils::sha256(entry.text_fingerprint, sizeof(entry.text_fingerprint),
recipient.id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, strlen(text));
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
entry.terminal_origin = true;
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
expireExpectedAcks();
}
void MyMesh::importTerminalCard(char* command) {
while (*command == ' ') command++;
if (strncmp(command, "meshcore://", 11) != 0) {
Serial.print(" ERROR: invalid card format\r\n");
return;
}
char* encoded = command + 11;
char* end = encoded + strlen(encoded);
while (end > encoded && !mesh::Utils::isHexChar(end[-1])) {
*--end = 0;
}
size_t encoded_len = strlen(encoded);
if (encoded_len == 0 || (encoded_len & 1) != 0
|| encoded_len / 2 > sizeof(_terminal_tmp_buf)) {
Serial.print(" ERROR: invalid card format\r\n");
return;
}
size_t raw_len = encoded_len / 2;
if (!mesh::Utils::fromHex(_terminal_tmp_buf, raw_len, encoded)
|| !importContact(_terminal_tmp_buf, raw_len)) {
Serial.print(" ERROR: invalid card\r\n");
return;
}
Serial.print(" OK - contact import queued\r\n");
}
void MyMesh::handleTerminalCommand(char* command) {
while (*command == ' ') command++;
if (*command == 0) return;
if (strncmp(command, "send ", 5) == 0) {
ContactInfo* recipient = getTerminalRecipient();
const char* text = command + 5;
if (recipient == NULL) {
Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n");
} else if (*text == 0 || strlen(text) > MAX_TEXT_LEN) {
Serial.printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN);
} else {
uint32_t expected_ack = 0;
uint32_t est_timeout = 0;
uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 };
int result = sendMessage(*recipient, getRTCClock()->getCurrentTimeUnique(), 0,
text, expected_ack, est_timeout, packet_retry_key);
if (result == MSG_SEND_FAILED) {
Serial.print(" ERROR: unable to send\r\n");
} else {
rememberTerminalAck(*recipient, text, expected_ack, est_timeout, packet_retry_key);
Serial.printf(" message sent - %s\r\n",
result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT");
}
}
} else if (strncmp(command, "public ", 7) == 0) {
ChannelDetails channel;
const char* text = command + 7;
if (*text == 0) {
Serial.print(" ERROR: message is empty\r\n");
} else if (!getChannel(0, channel)) {
Serial.print(" ERROR: Public channel is unavailable\r\n");
} else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel,
_prefs.node_name, text, strlen(text))) {
Serial.print(" Sent.\r\n");
} else {
Serial.print(" ERROR: unable to send\r\n");
}
} else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) {
int count = command[4] == ' ' ? atoi(command + 5) : 0;
scanRecentContacts(count, this);
} else if (strcmp(command, "clock") == 0) {
DateTime dt(getRTCClock()->getCurrentTime());
Serial.printf("%02d:%02d - %d/%d/%d UTC\r\n",
dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else if (strncmp(command, "time ", 5) == 0) {
uint32_t timestamp = strtoul(command + 5, NULL, 10);
uint32_t current = getRTCClock()->getCurrentTime();
if (timestamp >= current) {
getRTCClock()->setCurrentTime(timestamp);
Serial.print(" OK - clock set\r\n");
} else {
Serial.print(" ERROR: clock cannot go backwards\r\n");
}
} else if (strncmp(command, "to ", 3) == 0) {
const char* prefix = command + 3;
ContactInfo* recipient = NULL;
if (*prefix != 0 && strlen(prefix) < sizeof(ContactInfo::name)) {
recipient = searchContactsByPrefix(prefix);
}
if (recipient == NULL || recipient->type == ADV_TYPE_NONE) {
Serial.print(" ERROR: name prefix not found\r\n");
} else {
memcpy(_terminal_recipient_key, recipient->id.pub_key, PUB_KEY_SIZE);
_terminal_recipient_set = true;
Serial.printf(" Recipient %s selected\r\n", recipient->name);
}
} else if (strcmp(command, "to") == 0) {
ContactInfo* recipient = getTerminalRecipient();
if (recipient != NULL) {
Serial.printf(" Current recipient: %s\r\n", recipient->name);
} else {
Serial.print(" No recipient selected\r\n");
}
} else if (strcmp(command, "advert") == 0) {
Serial.print(advert() ? " advert sent (zero hop)\r\n"
: " ERROR: unable to send advert\r\n");
} else if (strcmp(command, "reset path") == 0) {
ContactInfo* recipient = getTerminalRecipient();
if (recipient == NULL) {
Serial.print(" ERROR: no recipient selected\r\n");
} else {
resetPathTo(*recipient);
scheduleContactWrite(*recipient);
Serial.print(" Done.\r\n");
}
} else if (strcmp(command, "card") == 0) {
mesh::Packet* packet = _prefs.advert_loc_policy == ADVERT_LOC_NONE
? createSelfAdvert(_prefs.node_name)
: createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
if (packet == NULL) {
Serial.print(" ERROR: unable to create card\r\n");
} else {
packet->header |= ROUTE_TYPE_FLOOD;
uint8_t raw_len = packet->writeTo(_terminal_tmp_buf);
releasePacket(packet);
Serial.print("meshcore://");
mesh::Utils::printHex(Serial, _terminal_tmp_buf, raw_len);
Serial.print("\r\n");
}
} else if (strncmp(command, "import ", 7) == 0) {
importTerminalCard(command + 7);
} else if (strncmp(command, "set ", 4) == 0) {
const char* config = command + 4;
if (strncmp(config, "af ", 3) == 0) {
_prefs.airtime_factor = constrain((float)atof(config + 3), 0.0f, 9.0f);
savePrefs();
Serial.print(" OK\r\n");
} else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) {
StrHelper::strncpy(_prefs.node_name, config + 5, sizeof(_prefs.node_name));
savePrefs();
Serial.print(" OK\r\n");
} else if (strncmp(config, "lat ", 4) == 0) {
sensors.node_lat = constrain(atof(config + 4), -90.0, 90.0);
savePrefs();
Serial.print(" OK\r\n");
} else if (strncmp(config, "lon ", 4) == 0) {
sensors.node_lon = constrain(atof(config + 4), -180.0, 180.0);
savePrefs();
Serial.print(" OK\r\n");
} else if (strncmp(config, "tx ", 3) == 0) {
_prefs.tx_power_dbm = constrain(atoi(config + 3), -9, MAX_LORA_TX_POWER);
savePrefs();
Serial.print(" OK - reboot to apply\r\n");
} else if (strncmp(config, "freq ", 5) == 0) {
_prefs.freq = constrain((float)atof(config + 5), 150.0f, 2500.0f);
savePrefs();
Serial.print(" OK - reboot to apply\r\n");
} else {
Serial.printf(" ERROR: unknown setting: %s\r\n", config);
}
} else if (strcmp(command, "ver") == 0) {
Serial.printf("Companion %s (protocol %u, build %s)\r\n",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
} else if (strcmp(command, "help") == 0) {
Serial.print("Commands:\r\n");
Serial.print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
Serial.print(" card\r\n");
Serial.print(" import <meshcore://card>\r\n");
Serial.print(" clock\r\n");
Serial.print(" time <epoch-seconds>\r\n");
Serial.print(" list [n]\r\n");
Serial.print(" to [recipient name or prefix]\r\n");
Serial.print(" send <text>\r\n");
Serial.print(" advert\r\n");
Serial.print(" reset path\r\n");
Serial.print(" public <text>\r\n");
Serial.print(" ver\r\n");
Serial.print(" +++MESHCORE-TERM-STOP\r\n");
} else {
Serial.printf(" ERROR: unknown command: %s\r\n", command);
}
}
#endif
void MyMesh::enterCLIRescue() {
_cli_rescue = true;
cli_command[0] = 0;
+29
View File
@@ -100,6 +100,9 @@ struct AdvertPath {
};
class MyMesh : public BaseChatMesh, public DataStoreHost
#ifdef ENABLE_USB_INTERFACE
, public ContactVisitor
#endif
#ifdef WITH_WEBCONFIG
, public WebConfigServer::Callbacks
#endif
@@ -139,6 +142,13 @@ public:
bool advert();
void enterCLIRescue();
#ifdef ENABLE_USB_INTERFACE
void enterTerminalMode();
void exitTerminalMode();
bool isTerminalMode() const { return _terminal_mode; }
void handleTerminalCommand(char* command);
#endif
int getRecentlyHeard(AdvertPath dest[], int max_num);
protected:
@@ -175,6 +185,9 @@ protected:
bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override;
void onContactPathUpdated(const ContactInfo &contact) override;
#ifdef ENABLE_USB_INTERFACE
void onContactVisit(const ContactInfo& contact) override;
#endif
ContactInfo* processAck(const uint8_t *data) override;
void queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *extra, int extra_len, const char *text);
@@ -247,6 +260,13 @@ private:
void checkCLIRescueCmd();
void checkSerialInterface();
#ifdef ENABLE_USB_INTERFACE
ContactInfo* getTerminalRecipient();
void importTerminalCard(char* command);
void rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t expected_ack, uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE]);
#endif
bool isValidClientRepeatFreq(uint32_t f) const;
bool hasLocationTelemetryRecipient();
void updateGpsTelemetryPolicy();
@@ -287,6 +307,12 @@ private:
uint32_t _active_ble_pin;
bool _iter_started;
bool _cli_rescue;
#ifdef ENABLE_USB_INTERFACE
bool _terminal_mode;
bool _terminal_recipient_set;
uint8_t _terminal_recipient_key[PUB_KEY_SIZE];
uint8_t _terminal_tmp_buf[MAX_TRANS_UNIT];
#endif
bool saved_radio_apply_pending;
unsigned long radio_apply_retry_at;
uint8_t radio_apply_failures;
@@ -326,6 +352,9 @@ private:
ContactInfo* contact;
uint8_t text_fingerprint[MAX_HASH_SIZE];
uint8_t retry_key[MAX_HASH_SIZE];
#ifdef ENABLE_USB_INTERFACE
bool terminal_origin;
#endif
};
#define EXPECTED_ACK_TABLE_SIZE 8
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
+105 -1
View File
@@ -58,6 +58,8 @@ MultiSerialInterface interface_manager;
// include usb interface
#if defined(ENABLE_USB_INTERFACE)
#include <helpers/ArduinoSerialInterface.h>
static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP";
ArduinoSerialInterface usb_serial_interface;
#endif
@@ -113,6 +115,105 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
/* END GLOBAL OBJECTS */
#if defined(ENABLE_USB_INTERFACE)
static char usb_terminal_line[MAX_TRANS_UNIT * 2 + 32];
static size_t usb_terminal_line_len = 0;
static bool usb_terminal_discard_line = false;
static bool usb_terminal_disconnect_armed = false;
static bool isUsbTerminalDataConnected() {
#if defined(RP2040_PLATFORM)
return (bool)Serial;
#else
return board.isUsbDataConnected();
#endif
}
static void enterUsbTerminalMode() {
usb_serial_interface.setPassthroughMode(true);
usb_terminal_line_len = 0;
usb_terminal_line[0] = 0;
usb_terminal_discard_line = false;
usb_terminal_disconnect_armed = isUsbTerminalDataConnected();
the_mesh.enterTerminalMode();
}
static void leaveUsbTerminalMode(bool acknowledge) {
if (acknowledge) {
Serial.print("\r\nOK - Binary mode\r\n");
}
the_mesh.exitTerminalMode();
usb_serial_interface.setPassthroughMode(false);
usb_terminal_line_len = 0;
usb_terminal_line[0] = 0;
usb_terminal_discard_line = false;
usb_terminal_disconnect_armed = false;
}
static void serviceUsbTerminal() {
if (!the_mesh.isTerminalMode()) {
if (usb_serial_interface.takeControlSequence()) enterUsbTerminalMode();
return;
}
if (isUsbTerminalDataConnected()) {
usb_terminal_disconnect_armed = true;
} else if (usb_terminal_disconnect_armed) {
leaveUsbTerminalMode(false);
return;
}
while (Serial.available()) {
int value = Serial.read();
if (value < 0) break;
char c = (char)value;
if (usb_terminal_discard_line) {
if (c == '\r' || c == '\n') {
usb_terminal_discard_line = false;
Serial.print("> ");
}
continue;
}
if (c == '\b' || c == 0x7F) {
if (usb_terminal_line_len > 0) {
usb_terminal_line[--usb_terminal_line_len] = 0;
Serial.print("\b \b");
}
continue;
}
if (c == '\r' || c == '\n') {
if (usb_terminal_line_len == 0) continue;
Serial.print("\r\n");
the_mesh.handleTerminalCommand(usb_terminal_line);
usb_terminal_line_len = 0;
usb_terminal_line[0] = 0;
Serial.print("> ");
return; // service at most one command per mesh loop
}
if (usb_terminal_line_len >= sizeof(usb_terminal_line) - 1) {
usb_terminal_line_len = 0;
usb_terminal_line[0] = 0;
usb_terminal_discard_line = true;
Serial.print("\r\n ERROR: command too long\r\n");
continue;
}
usb_terminal_line[usb_terminal_line_len++] = c;
usb_terminal_line[usb_terminal_line_len] = 0;
Serial.print(c);
if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) {
leaveUsbTerminalMode(true);
return;
}
}
}
#endif
void halt() {
while (1) ;
}
@@ -388,7 +489,7 @@ void setup() {
// add usb interface
#if defined(ENABLE_USB_INTERFACE)
usb_serial_interface.begin(Serial);
usb_serial_interface.begin(Serial, USB_TERMINAL_START_TOKEN);
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
#endif
@@ -464,6 +565,9 @@ void loop() {
board.feedWatchdog();
#endif
the_mesh.loop();
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminal();
#endif
interface_manager.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
+1
View File
@@ -273,6 +273,7 @@ build_src_filter =
+<../src/helpers/ota/detools/detools.c>
+<../src/helpers/UserGpio.cpp>
+<../src/helpers/ConfigSerializer.cpp>
+<../src/helpers/ArduinoSerialInterface.cpp>
lib_deps =
google/googletest @ 1.17.0
bblanchon/ArduinoJson @ 7.4.3
+49 -2
View File
@@ -5,12 +5,52 @@
#define RECV_STATE_LEN1_FOUND 2
#define RECV_STATE_LEN2_FOUND 3
void ArduinoSerialInterface::enable() {
_isEnabled = true;
void ArduinoSerialInterface::resetReceiveState() {
_state = RECV_STATE_IDLE;
_controlSequencePos = 0;
_frame_len = 0;
rx_len = 0;
}
bool ArduinoSerialInterface::checkControlSequence(uint8_t c) {
if (_controlSequence == nullptr || _controlSequence[0] == 0) return false;
if (c == (uint8_t)_controlSequence[_controlSequencePos]) {
_controlSequencePos++;
if (_controlSequence[_controlSequencePos] == 0) {
_controlSequencePos = 0;
_controlSequenceReceived = true;
return true;
}
} else {
// Preserve a possible new match when this byte is also the first byte of
// the sequence (notably useful for sequences beginning with "+++").
_controlSequencePos = c == (uint8_t)_controlSequence[0] ? 1 : 0;
}
return false;
}
void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
_passthroughMode = enabled;
_controlSequenceReceived = false;
resetReceiveState();
}
bool ArduinoSerialInterface::takeControlSequence() {
bool received = _controlSequenceReceived;
_controlSequenceReceived = false;
return received;
}
void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
resetReceiveState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
resetReceiveState();
}
bool ArduinoSerialInterface::isConnected() const {
@@ -30,6 +70,7 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
// frame is too big!
return 0;
}
if (_passthroughMode) return len;
uint8_t hdr[3];
hdr[0] = '>';
@@ -41,12 +82,18 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
}
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
if (_passthroughMode) return 0;
while (_serial->available()) {
int c = _serial->read();
if (c < 0) break;
switch (_state) {
case RECV_STATE_IDLE:
if (checkControlSequence((uint8_t)c)) {
// Leave any following bytes buffered for the passthrough consumer.
return 0;
}
if (c == '<') {
_state = RECV_STATE_HDR_FOUND;
}
+29 -5
View File
@@ -5,22 +5,46 @@
class ArduinoSerialInterface : public BaseSerialInterface {
bool _isEnabled;
bool _passthroughMode;
bool _controlSequenceReceived;
uint8_t _state;
size_t _controlSequencePos;
uint16_t _frame_len;
uint16_t rx_len;
Stream* _serial;
const char* _controlSequence;
uint8_t rx_buf[MAX_FRAME_SIZE];
public:
ArduinoSerialInterface() { _isEnabled = false; _state = 0; }
bool checkControlSequence(uint8_t c);
void resetReceiveState();
void begin(Stream& serial) {
_serial = &serial;
public:
ArduinoSerialInterface()
: _isEnabled(false), _passthroughMode(false),
_controlSequenceReceived(false), _state(0), _controlSequencePos(0),
_frame_len(0), rx_len(0), _serial(nullptr),
_controlSequence(nullptr) {}
void begin(Stream& serial, const char* controlSequence = nullptr) {
_serial = &serial;
_controlSequence = controlSequence;
_passthroughMode = false;
_controlSequenceReceived = false;
resetReceiveState();
#ifdef RAK_4631
pinMode(WB_IO2, OUTPUT);
#endif
#endif
}
// In passthrough mode another line-oriented consumer owns the Stream. Binary
// frames are neither read from nor written to this interface.
void setPassthroughMode(bool enabled);
bool isPassthroughMode() const { return _passthroughMode; }
// Returns true once for each complete control sequence received while the
// binary frame parser was idle.
bool takeControlSequence();
// BaseSerialInterface methods
void enable() override;
void disable() override;
+1
View File
@@ -43,6 +43,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_logical_message_cache` | `src/helpers/LogicalMessageCache.h` | bounded logical-message mapping; stable retry timestamps; exact older retries after newer messages; stale and same-timestamp mismatch rejection |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp` | exact terminal control-sequence recognition across reads and binary-frame boundaries; passthrough ownership of USB input and suppression of binary output |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
@@ -0,0 +1,139 @@
#include <gtest/gtest.h>
#include <deque>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
class BufferStream : public Stream {
public:
std::deque<uint8_t> input;
std::vector<uint8_t> output;
void push(const char* data) {
while (*data) input.push_back((uint8_t)*data++);
}
void push(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; i++) input.push_back(data[i]);
}
int available() override { return (int)input.size(); }
int read() override {
if (input.empty()) return -1;
uint8_t value = input.front();
input.pop_front();
return value;
}
size_t write(uint8_t value) override {
output.push_back(value);
return 1;
}
size_t write(const uint8_t* data, size_t len) override {
output.insert(output.end(), data, data + len);
return len;
}
};
static const char START_TOKEN[] = "+++MESHCORE-TERM-START";
TEST(SerialModeSwitch, RecognizesControlSequenceAcrossReads) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
stream.push("+++MESHCORE-");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_FALSE(interface.takeControlSequence());
stream.push("TERM-START\r");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.takeControlSequence());
EXPECT_FALSE(interface.takeControlSequence());
EXPECT_EQ(stream.available(), 1); // trailing CR belongs to the terminal
}
TEST(SerialModeSwitch, DoesNotScanInsideBinaryFrame) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const size_t token_len = strlen(START_TOKEN);
uint8_t header[] = {'<', (uint8_t)token_len, 0};
stream.push(header, sizeof(header));
stream.push(START_TOKEN);
EXPECT_EQ(interface.checkRecvFrame(frame), token_len);
EXPECT_EQ(memcmp(frame, START_TOKEN, token_len), 0);
EXPECT_FALSE(interface.takeControlSequence());
}
TEST(SerialModeSwitch, RecognizesControlSequenceAfterBinaryFrame) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const uint8_t input[] = {'<', 2, 0, 0xA5, 0x5A};
stream.push(input, sizeof(input));
stream.push(START_TOKEN);
EXPECT_EQ(interface.checkRecvFrame(frame), 2u);
EXPECT_EQ(frame[0], 0xA5);
EXPECT_EQ(frame[1], 0x5A);
EXPECT_FALSE(interface.takeControlSequence());
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.takeControlSequence());
}
TEST(SerialModeSwitch, MismatchedPrefixDoesNotTrigger) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
stream.push("+++MESHCORE-TERM-ST0P");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_FALSE(interface.takeControlSequence());
stream.push(START_TOKEN);
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.takeControlSequence());
}
TEST(SerialModeSwitch, PassthroughLeavesInputAndSuppressesBinaryOutput) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream, START_TOKEN);
interface.enable();
interface.setPassthroughMode(true);
uint8_t frame[MAX_FRAME_SIZE] = {};
stream.push("help\r");
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_EQ(stream.available(), 5);
const uint8_t payload[] = {1, 2, 3};
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
EXPECT_TRUE(stream.output.empty());
interface.setPassthroughMode(false);
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
ASSERT_EQ(stream.output.size(), 6u);
EXPECT_EQ(stream.output[0], '>');
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+20
View File
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$scriptPath = Join-Path $PSScriptRoot 'lora_ota.py'
$otaArguments = @($args)
if (Get-Command py -ErrorAction SilentlyContinue) {
& py -3 $scriptPath @otaArguments
exit $LASTEXITCODE
}
if (Get-Command python3 -ErrorAction SilentlyContinue) {
& python3 $scriptPath @otaArguments
exit $LASTEXITCODE
}
if (Get-Command python -ErrorAction SilentlyContinue) {
& python $scriptPath @otaArguments
exit $LASTEXITCODE
}
throw 'Python 3.10 or newer was not found on PATH.'
+1458
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
python_cmd=${PYTHON:-python3}
exec "$python_cmd" "$script_dir/lora_ota.py" "$@"
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Offline tests for the LoRa OTA orchestration helper."""
from __future__ import annotations
import argparse
import contextlib
import hashlib
import io
import os
from pathlib import Path
import struct
import subprocess
import tempfile
import unittest
import zipfile
import lora_ota as ota
TARGET = 0x1234ABCD
VERSION_OLD = 0x01100000
VERSION_NEW = 0x01110000
def firmware(body: bytes, version: int, target: int = TARGET, hw: str = "TestBoard") -> bytes:
hw_bytes = hw.encode("ascii")[:32].ljust(32, b"\0")
return (
body
+ ota.ENDF_MAGIC
+ struct.pack("<I", len(body))
+ hashlib.sha256(body).digest()[:8]
+ struct.pack("<II", version, target)
+ hw_bytes
)
def mota_blob(
image: bytes,
*,
full: bool = True,
version: int = VERSION_NEW,
target: int = TARGET,
hw: str = "TestBoard",
base_hash: bytes = b"\0" * 8,
payload: bytes | None = None,
codec: int | None = None,
) -> bytes:
if payload is None:
payload = image if full else b"synthetic delta payload" * 100
codec = ota.MOTA_CODEC_FULL if codec is None and full else (
ota.MOTA_CODEC_SEQUENTIAL if codec is None else codec
)
block_size = 1024
leaves = [
hashlib.sha256(payload[offset:offset + block_size]).digest()[:4]
for offset in range(0, len(payload), block_size)
]
manifest = bytearray((ota.MOTA_FORMAT_VERSION, ota.MOTA_FLAG_FULL if full else 0, 0x12))
manifest += struct.pack(
"<IIII", target, version, len(image), len(payload)
)
manifest.append(10)
manifest += ota.merkle_root(leaves)
manifest += hashlib.sha256(image).digest()
manifest.append(codec)
manifest += hw.encode("ascii")[:32].ljust(32, b"\0")
manifest += (b"\0" * 8 if full else base_hash)
manifest += b"\0" * 32
manifest += b"\0" * 64
manifest += b"\xFF" * 4
assert len(manifest) == ota.MOTA_FIXED_MANIFEST_SIZE
manifest += b"".join(leaves)
total = 8 + len(manifest) + len(payload) + len(ota.MOTA_TRAILER)
return ota.MOTA_MAGIC + struct.pack("<I", total) + manifest + payload + ota.MOTA_TRAILER
def target(
*,
platform: str = "esp32",
base_hash: bytes = b"\0" * 8,
nrf_sd: bool = False,
boot_codecs: int | None = None,
current_version: str | None = None,
) -> ota.TargetInfo:
return ota.TargetInfo(
"remote", TARGET, base_hash, platform, nrf_sd, "TestBoard",
2 if platform == "nrf52" else None, boot_codecs,
"status", "self", current_version,
)
def prepare_args(package: Path, motatool: str, base: Path | None = None) -> argparse.Namespace:
return argparse.Namespace(
package=package,
zip_member=None,
motatool=motatool,
base=base,
inplace_memory=None,
sign_key=None,
public_key=None,
)
class FormatTests(unittest.TestCase):
def test_endf_and_full_mota_round_trip(self) -> None:
image = firmware(bytes(range(251)) * 20, VERSION_NEW)
identity = ota.parse_endf(image)
self.assertEqual(identity.target_id, TARGET)
self.assertEqual(identity.fw_version, VERSION_NEW)
parsed = ota.parse_mota(mota_blob(image))
self.assertTrue(parsed.is_full)
self.assertEqual(parsed.payload, image)
self.assertEqual(parsed.version, "v1.17.0")
def test_corrupt_payload_is_rejected(self) -> None:
blob = bytearray(mota_blob(firmware(b"A" * 5000, VERSION_NEW)))
blob[-10] ^= 0x01
with self.assertRaisesRegex(ota.OtaError, "block hashes"):
ota.parse_mota(bytes(blob))
def test_manifest_endf_identity_mismatch_is_rejected(self) -> None:
wrong_image = firmware(b"B" * 5000, VERSION_NEW, target=TARGET + 1)
with self.assertRaisesRegex(ota.OtaError, "target IDs differ"):
ota.parse_mota(mota_blob(wrong_image))
def test_version_conversion_is_numeric(self) -> None:
self.assertEqual(ota.parse_version("v1.10.2"), 0x010A0200)
self.assertGreater(ota.parse_version("v1.10.0"), ota.parse_version("v1.9.9"))
self.assertIsNone(ota.parse_version("release-one"))
def test_temp_radio_accepts_only_cli_bandwidths(self) -> None:
self.assertEqual(
ota.parse_temp_radio("909.950,250,7,5,120"),
(909.95, 250.0, 7, 5, 120),
)
with self.assertRaisesRegex(argparse.ArgumentTypeError, "bandwidth must be"):
ota.parse_temp_radio("909.950,200,7,5,120")
def test_offline_sd_nrf52_does_not_require_a_base_hash(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
"release.zip", "offline", "--prepare-only", "--platform", "nrf52",
"--target-id", f"{TARGET:08X}", "--nrf-sd",
])
ota.validate_args(args, parser)
def test_nrf_sd_is_rejected_for_esp32(self) -> None:
parser = ota.build_parser()
args = parser.parse_args([
"release.zip", "offline", "--prepare-only", "--platform", "esp32",
"--target-id", f"{TARGET:08X}", "--nrf-sd",
])
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
ota.validate_args(args, parser)
def test_intel_hex_rejects_an_excessive_address_span(self) -> None:
def record(address: int, record_type: int, data: bytes) -> str:
raw = bytes((len(data), address >> 8, address & 0xFF, record_type)) + data
checksum = (-sum(raw)) & 0xFF
return ":" + (raw + bytes((checksum,))).hex().upper()
raw = "\n".join((
record(0, 4, b"\x00\x00"),
record(0, 0, b"A"),
record(0, 4, b"\x10\x00"),
record(0, 0, b"B"),
record(0, 1, b""),
)).encode("ascii")
with self.assertRaisesRegex(ota.OtaError, "address span"):
ota.parse_intel_hex(raw)
class CompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
self.base_image = firmware(b"old" * 2000, VERSION_OLD)
self.new_image = firmware(b"new" * 2100, VERSION_NEW)
self.base_hash = ota.parse_endf(self.base_image).body_hash
def test_internal_nrf52_requires_matching_in_place_delta(self) -> None:
full = ota.parse_mota(mota_blob(self.new_image))
nrf = target(platform="nrf52", base_hash=self.base_hash, boot_codecs=1 << 2)
self.assertFalse(ota.compatible_mota(full, nrf)[0])
delta = ota.parse_mota(mota_blob(
self.new_image, full=False, base_hash=self.base_hash,
codec=ota.MOTA_CODEC_IN_PLACE,
))
self.assertTrue(ota.compatible_mota(delta, nrf)[0])
wrong_base = ota.parse_mota(mota_blob(
self.new_image, full=False, base_hash=b"X" * 8,
codec=ota.MOTA_CODEC_IN_PLACE,
))
self.assertFalse(ota.compatible_mota(wrong_base, nrf)[0])
def test_nrf52_bootloader_codec_mask_is_checked(self) -> None:
delta = ota.parse_mota(mota_blob(
self.new_image, full=False, base_hash=self.base_hash,
codec=ota.MOTA_CODEC_IN_PLACE,
))
nrf = target(platform="nrf52", base_hash=self.base_hash, boot_codecs=1)
good, reason = ota.compatible_mota(delta, nrf)
self.assertFalse(good)
self.assertIn("codec mask", reason)
def test_sd_nrf52_accepts_full_when_bootloader_does(self) -> None:
full = ota.parse_mota(mota_blob(self.new_image))
nrf = target(platform="nrf52", nrf_sd=True, boot_codecs=1)
self.assertTrue(ota.compatible_mota(full, nrf)[0])
def test_zip_prefers_equal_version_delta(self) -> None:
full = mota_blob(self.new_image)
delta = mota_blob(
self.new_image, full=False, base_hash=self.base_hash,
codec=ota.MOTA_CODEC_SEQUENTIAL,
)
with tempfile.TemporaryDirectory() as directory:
archive_path = Path(directory) / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("full.mota", full)
archive.writestr("delta.mota", delta)
with zipfile.ZipFile(archive_path) as archive:
selected = ota.select_mota_from_zip(
archive, target(base_hash=self.base_hash), None
)
self.assertIsNotNone(selected)
self.assertFalse(selected[0].is_full)
def test_base_zip_selects_running_hash_not_newest_file(self) -> None:
other = firmware(b"other" * 1300, VERSION_NEW)
with tempfile.TemporaryDirectory() as directory:
archive_path = Path(directory) / "bases.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("newer.bin", other)
archive.writestr("running.bin", self.base_image)
selected = ota.load_base_image(
archive_path,
target(
base_hash=self.base_hash,
current_version="v1.16.0",
),
)
self.assertEqual(selected.image, self.base_image)
class DownloadSessionTests(unittest.TestCase):
class Controller:
def __init__(self, replies: list[str]):
self.replies = iter(replies)
self.commands: list[str] = []
def remote_command(self, _target: str, command: str) -> str:
self.commands.append(command)
return next(self.replies)
def setUp(self) -> None:
image = firmware(b"download" * 900, VERSION_NEW)
self.package = ota.parse_mota(mota_blob(image))
def args(self, replace: bool = False) -> argparse.Namespace:
return argparse.Namespace(
target="remote", replace_active_download=replace,
discovery_timeout=1, discovery_interval=1,
)
def test_matching_active_session_is_resumed(self) -> None:
controller = self.Controller([
f"OTA | download: downloading 3/9 id={self.package.manifest_id} 2s"
])
ota.find_and_start_pull(controller, self.args(), self.package)
self.assertEqual(controller.commands, ["ota status"])
def test_different_active_session_is_preserved_by_default(self) -> None:
controller = self.Controller(["OTA | download: ready to install 9/9 id=DEADBEEF 2s"])
with self.assertRaisesRegex(ota.OtaError, "already has mOTA"):
ota.find_and_start_pull(controller, self.args(), self.package)
self.assertEqual(controller.commands, ["ota status"])
def test_replace_active_session_requires_explicit_flag(self) -> None:
controller = self.Controller([
"OTA | download: downloading 3/9 id=DEADBEEF 2s",
"OK dropped session",
"Updates 1/1",
"OK pulling mid=12345678 -> flash (low priority)",
])
ota.find_and_start_pull(controller, self.args(replace=True), self.package)
self.assertEqual(
controller.commands,
["ota status", "ota cancel", "ota ls", f"ota pull {self.package.manifest_id} flash"],
)
class MotatoolIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.motatool = os.environ.get("MOTATOOL_TEST_BIN")
if not cls.motatool or not Path(cls.motatool).is_file():
raise unittest.SkipTest("set MOTATOOL_TEST_BIN to run motatool integration tests")
subprocess.run([cls.motatool, "--version"], check=True, capture_output=True)
def test_raw_esp32_zip_becomes_full_mota(self) -> None:
image = firmware(b"ESP32-new" * 900, VERSION_NEW)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive_path = root / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("firmware.bin", image)
work = root / "work"
work.mkdir()
_path, package, expected = ota.prepare_package(
prepare_args(archive_path, self.motatool), target(), work
)
self.assertTrue(package.is_full)
self.assertEqual(expected, ota.parse_endf(image).body_hash)
def test_bash_wrapper_prepare_only_from_zip(self) -> None:
image = firmware(b"wrapper-new" * 700, VERSION_NEW)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive_path = root / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("firmware.bin", image)
result = subprocess.run(
[
str(Path(__file__).with_name("lora_ota.sh")),
str(archive_path), "offline",
"--prepare-only", "--platform", "esp32",
"--target-id", f"{TARGET:08X}",
"--target-hw", "TestBoard",
"--motatool", self.motatool,
"--work-dir", str(root / "work"),
],
text=True, capture_output=True, check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Prepared and verified:", result.stdout)
def test_raw_internal_nrf52_zip_becomes_in_place_delta(self) -> None:
base_image = firmware(b"nrf-old" * 1000, VERSION_OLD)
new_image = firmware(b"nrf-new" * 1010, VERSION_NEW)
base_hash = ota.parse_endf(base_image).body_hash
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
base_path = root / "running.bin"
base_path.write_bytes(base_image)
archive_path = root / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("firmware.bin", new_image)
work = root / "work"
work.mkdir()
_path, package, expected = ota.prepare_package(
prepare_args(archive_path, self.motatool, base_path),
target(
platform="nrf52", base_hash=base_hash,
boot_codecs=1 << ota.MOTA_CODEC_IN_PLACE,
current_version="v1.16.0",
),
work,
)
self.assertFalse(package.is_full)
self.assertEqual(package.codec_id, ota.MOTA_CODEC_IN_PLACE)
self.assertEqual(package.base_hash, base_hash)
self.assertEqual(expected, ota.parse_endf(new_image).body_hash)
def test_raw_sd_nrf52_zip_becomes_full_without_base(self) -> None:
image = firmware(b"nrf-sd-new" * 800, VERSION_NEW)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive_path = root / "release.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("firmware.bin", image)
work = root / "work"
work.mkdir()
_path, package, _expected = ota.prepare_package(
prepare_args(archive_path, self.motatool),
target(platform="nrf52", nrf_sd=True, boot_codecs=1),
work,
)
self.assertTrue(package.is_full)
if __name__ == "__main__":
unittest.main(verbosity=2)