mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-08-29 06:08:18 +00:00
Add companion terminal mode and LoRa OTA tooling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,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 |
|
||||
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.'
|
||||
Executable
+1458
File diff suppressed because it is too large
Load Diff
Executable
+7
@@ -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" "$@"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user