Make repeater RX watchdog MeshCore-aware and tune temporary radio
Use RX-only radio recovery and feed the repeater reboot watchdog after MeshCore parsing. Default to 24 hours, apply temporary watchdog and advert intervals in RAM, and report temporary durations as days, hours, and minutes. Add regression coverage and an ESP-NOW bridge setup guide.
This commit is contained in:
1 parent
91d636b8ff
commit
2b4a35adb7
19 files changed
+1265
-140
No files matched your search
@@ -73,6 +73,11 @@ jobs:
|
||||
PY
|
||||
python3 -B test/test_sx126x_receive_mode.py
|
||||
|
||||
- name: Verify temporary-radio timing and exact reply delivery
|
||||
run: |
|
||||
python3 -B test/test_repeater_radio_timing_integration.py
|
||||
python3 -B test/test_temp_radio_reply_delivery_contract.py
|
||||
|
||||
- name: Verify ESP32 static DRAM budget
|
||||
run: |
|
||||
python3 -B test/test_esp32_dram.py
|
||||
|
||||
@@ -4,6 +4,7 @@ For **1.17.1.5 USA Cascade**, start with the [release guide](docs/releases/1.17.
|
||||
[firmware picker](docs/firmware_picker.md), and [feature switches by role](docs/role_feature_switches.md).
|
||||
Use the [USB web console](https://flasher.meshcore.io/console) for the default
|
||||
ASCII terminal on Full Companion, Repeater, Room Server, and Sensor images.
|
||||
To connect two LoRa networks, follow the [ESP-NOW bridge setup guide](docs/espnow_bridge_setup.md).
|
||||
|
||||
MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet.
|
||||
|
||||
|
||||
+53
-8
@@ -1142,6 +1142,33 @@ settings use a safe effective power without replacing the saved preference.
|
||||
- `normalradio` cancels pending and active temporary-radio windows, then
|
||||
restores the saved radio tuple after its CLI reply has drained on the
|
||||
current channel. Permanent `radioat` entries are not removed.
|
||||
- On repeaters, the reply includes total minutes plus `NdNhNm` (days, hours,
|
||||
minutes) and the watchdog policy for that session. For example:
|
||||
|
||||
```text
|
||||
OK - temp params for 1440 mins (1d0h0m); rx.watchdog=12hours
|
||||
OK - temp params for 3075 mins (2d3h15m); rx.watchdog=12hours
|
||||
OK - temp params for 180 mins (0d3h0m); rx.watchdog=off (temp<12hours)
|
||||
```
|
||||
|
||||
Repeater timing overrides apply only while a temporary window is running:
|
||||
|
||||
| Temporary duration | No-RX reboot watchdog | Flood advert interval | Direct/local advert interval |
|
||||
| --- | --- | --- | --- |
|
||||
| Up to and including 3 hours | Off | Saved normal interval | 1 hour |
|
||||
| More than 3, less than 12 hours | Off | 3 hours | 1 hour |
|
||||
| 12 hours or longer | 12 hours | 3 hours | 1 hour |
|
||||
|
||||
The duration is the requested window length, not the decreasing time remaining.
|
||||
The first temporary adverts are scheduled from successful radio activation;
|
||||
hourly local adverts still run when a flood advert is also due. `get
|
||||
advert.interval` and `get flood.advert.interval` report the effective intervals.
|
||||
The web configuration form continues to show and edit the saved normal advert
|
||||
settings. These include any changes made during a temporary session.
|
||||
Expiry, cancellation, reboot, or power
|
||||
loss removes the temporary overrides. Normal mode uses the saved advert
|
||||
intervals and the 24-hour watchdog policy. An explicitly saved normal-mode
|
||||
`rx.watchdog off` remains off outside temporary sessions.
|
||||
|
||||
---
|
||||
|
||||
@@ -1168,6 +1195,12 @@ settings use a safe effective power without replacing the saved preference.
|
||||
- `del radioat` and `del tempradioat` delete all entries when `n` is omitted.
|
||||
- Each queue supports 3 entries. Scheduled entries are not saved across reboot.
|
||||
- `radioat` saves the new radio preferences when it fires. `tempradioat` applies temporarily, then reverts to the saved radio preferences.
|
||||
- `tempradioat` uses the repeater timing table above, with duration calculated
|
||||
from `end_time - start_time`. Its acceptance reply includes total minutes, `NdNhNm`,
|
||||
and the planned `rx.watchdog` setting. Queuing a future entry does not change
|
||||
the running watchdog or advert intervals; the overrides start only after
|
||||
the temporary radio parameters are successfully applied. If restoration
|
||||
needs retries after expiry, the timing overrides still end with the window.
|
||||
- A successful scheduling reply notes any RXPS effective-level/preamble change
|
||||
required by the scheduled tuple. `RXPS continuous-fast` means the tuple is
|
||||
accepted but will use continuous receive because no safe duty-cycle level is
|
||||
@@ -1651,12 +1684,16 @@ send text.flood checking ridge link
|
||||
- `set rx.watchdog on`
|
||||
- `set rx.watchdog off`
|
||||
|
||||
**Default:** `off`
|
||||
**Default:** `on`, with a 24-hour no-RX timeout in normal mode.
|
||||
|
||||
**Notes:**
|
||||
- When enabled, the first check is due after a full 12-hour observation window. The repeater then checks roughly every 12 hours and reboots if it has not successfully received a radio packet during the preceding 12 hours.
|
||||
- Enabling the watchdog starts a new 12-hour observation window. Rebooting also starts a new window, so a quiet mesh can reboot no more often than once every 12 hours.
|
||||
- The check reuses the radio driver's existing last-receive timestamp. It does not poll, sample, or wake the radio or CPU. A due check waits for the next normal loop/wake, so its actual cadence can drift around the 12-hour target. With RX power saving enabled, packets received during normal listening windows count as activity; the watchdog does not alter the RX/sleep timing.
|
||||
- In normal mode, the repeater reboots after 24 hours without a received packet passing MeshCore parsing. Each such reception restarts this timeout; checks use a sliding deadline rather than waiting for another 24-hour checkpoint.
|
||||
- While `tempradio` or `tempradioat` is running, windows of at least 12 hours use a 12-hour no-RX timeout. Shorter windows disable this reboot watchdog for the duration of the session. This temporary policy also overrides a saved normal-mode `off` setting.
|
||||
- Startup, a successful temporary-radio activation/replacement, expiry, cancellation, or manually changing this setting starts a fresh observation window. Expiry is processed before the watchdog, so a temporary window ending exactly at its watchdog deadline restores normal timing first.
|
||||
- `get rx.watchdog` reports both the effective on/off state and timeout or short-session disabled reason. `set rx.watchdog on|off` saves the normal-mode preference; temporary-session rules remain active until that session ends. Existing saved preferences are retained when upgrading; the new default applies to fresh settings or older preference files without this field.
|
||||
- The check uses the last successful MeshCore parse time, recorded before receive delays and routing filters. It does not require the packet to be addressed to this repeater or its encrypted payload to be decrypted; duplicate receptions also count. This checks MeshCore packet structure, not sender authentication.
|
||||
- It does not poll, sample, or wake the radio or CPU. A due check waits for the next normal loop/wake. With RX power saving enabled, qualifying packets received during normal listening windows count as activity; the watchdog does not alter the RX/sleep timing.
|
||||
- Transmitting, radio interrupts, and raw LoRa packets rejected by the MeshCore parser do not reset the timer. Packets dropped before parsing because the packet pool is full do not count either. This whole-board reboot setting is separate from the automatic radio-only recovery watchdog, which still measures successful raw radio reads.
|
||||
|
||||
---
|
||||
|
||||
@@ -2172,13 +2209,18 @@ hardware busy-result count.
|
||||
- `set radio.watchdog <minutes>`
|
||||
|
||||
**Parameters:**
|
||||
- `minutes`: `0` to disable, or `1-120` minutes
|
||||
- `minutes`: `0` to use the standard 30-minute soft recovery interval, or `1-120` minutes to override it
|
||||
|
||||
**Default:** `5`
|
||||
|
||||
**Note:** This watchdog belongs to the MQTT observer runtime and is not
|
||||
available on a standalone FULL repeater. On quiet meshes, increasing it can
|
||||
reduce false recoveries when no traffic is expected.
|
||||
**Note:** This setting belongs to the MQTT observer runtime and is not
|
||||
available on a standalone FULL repeater. It sets the interval without a
|
||||
successfully received packet before soft radio recovery. Transmissions and
|
||||
interrupts alone do not restart the timer. Setting it to `0` removes the
|
||||
observer override; automatic radio recovery remains active. The normal hard
|
||||
recovery threshold is 12 hours, where the radio supports it, and recovery does
|
||||
not reboot the whole board. On quiet meshes, increasing the soft interval can
|
||||
reduce unnecessary recoveries when no traffic is expected.
|
||||
|
||||
---
|
||||
|
||||
@@ -4032,6 +4074,9 @@ still overrides the first-boot default after an update.
|
||||
|
||||
### Bridge (When bridge support is compiled in)
|
||||
|
||||
For a complete two-repeater setup with each option explained, see the
|
||||
[ESP-NOW bridge setup guide](espnow_bridge_setup.md).
|
||||
|
||||
#### View the compiled bridge type
|
||||
**Usage:** `get bridge.type`
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# ESP-NOW bridge: simple setup
|
||||
|
||||
Use two ESP32 LoRa boards to join two MeshCore networks with different radio
|
||||
settings. Each board repeats on its own LoRa network, and the boards exchange
|
||||
packets directly over 2.4 GHz ESP-NOW. No router, internet connection, or manual
|
||||
peer pairing is needed.
|
||||
|
||||
```text
|
||||
LoRa network A <--> Repeater A <-- ESP-NOW --> Repeater B <--> LoRa network B
|
||||
```
|
||||
|
||||
## 1. Choose the firmware
|
||||
|
||||
Install the exact board's **Full ESP-NOW bridge repeater** image on each board.
|
||||
The target name ends in `_repeater_bridge_espnow`; a standard Heltec V4 uses
|
||||
`heltec_v4_repeater_bridge_espnow`. Full/MQTT and Full Companion images are
|
||||
different choices. See the [firmware picker guide](firmware_picker.md).
|
||||
|
||||
Open each board's [USB console](https://flasher.meshcore.io/console) at
|
||||
115200 baud and run:
|
||||
|
||||
```text
|
||||
get bridge.type
|
||||
```
|
||||
|
||||
The answer must be `espnow`. `mqtt`, `rs232`, or `none` means that image does
|
||||
not provide the ESP-NOW bridge described here.
|
||||
|
||||
## 2. Set each board's LoRa network
|
||||
|
||||
Configure one board at a time over USB. Replace the placeholders below with
|
||||
the radio settings used by the network on that board's side. Use `Bridge-A`
|
||||
for the first board and `Bridge-B` for the second, or choose your own names.
|
||||
|
||||
```text
|
||||
set bridge.enabled off
|
||||
set name Bridge-A
|
||||
set radio <freq>,<bw>,<sf>,<cr>
|
||||
reboot
|
||||
```
|
||||
|
||||
Do not type the angle-bracket placeholders literally.
|
||||
|
||||
| Radio option | Meaning |
|
||||
| --- | --- |
|
||||
| `freq` | LoRa frequency in MHz. Match the local network on this side. |
|
||||
| `bw` | LoRa bandwidth in kHz. Match that network's bandwidth. |
|
||||
| `sf` | Spreading factor, 5-12. Match that network's setting. |
|
||||
| `cr` | Coding-rate value, 5-8. Match that network's setting. |
|
||||
| `name` | A label to tell the two repeaters apart. It does not pair them. |
|
||||
|
||||
**`set radio` requires a reboot to apply.** After reconnecting, `get radio`
|
||||
shows the saved settings. Use permanent radio settings for this setup;
|
||||
`tempradio` sessions expire.
|
||||
|
||||
## 3. Configure the ESP-NOW link on both boards
|
||||
|
||||
Run this block on **both** boards. Replace `YourSharedKey` with the same
|
||||
case-sensitive secret on each board, using 1-15 characters. Channel `6` is
|
||||
an example; both boards must use the same channel.
|
||||
|
||||
```text
|
||||
set bridge.enabled off
|
||||
set bridge.channel 6
|
||||
set bridge.format wrapped
|
||||
set bridge.secret YourSharedKey
|
||||
set bridge.source tx
|
||||
set bridge.delay 500
|
||||
set repeat on
|
||||
set bridge.enabled on
|
||||
```
|
||||
|
||||
These settings are saved across reboot. Bridge changes apply immediately;
|
||||
changing an enabled bridge's channel, format, or secret restarts the bridge.
|
||||
Stopping it first lets you finish configuring both sides before connecting them.
|
||||
|
||||
## What each bridge option does
|
||||
|
||||
| Option | Choices and purpose |
|
||||
| --- | --- |
|
||||
| `bridge.enabled` | `on` starts the bridge; `off` stops it and keeps its saved settings. |
|
||||
| `bridge.channel` | The 2.4 GHz channel, 1-13. Match it on both boards. This is separate from the LoRa frequency in `set radio`. |
|
||||
| `bridge.format` | `wrapped` is the default for this two-repeater setup and uses the shared secret. `raw` connects to primary-ESP-NOW nodes such as `Generic_ESPNOW` or `SenseCapIndicator-ESPNow`; it ignores the secret. Both peers must use compatible formats. |
|
||||
| `bridge.secret` | A matching, case-sensitive value of 1-15 characters for `wrapped` mode. It separates bridge groups; it is not strong encryption. MeshCore's own message encryption remains in place. |
|
||||
| `bridge.source` | `tx` crosses packets this repeater transmits on LoRa, after its forwarding decisions; use this for the setup above. `rx` crosses received packets before those decisions and can pass traffic the local repeater would not forward. |
|
||||
| `bridge.delay` | Wait before processing a packet received from the other bridge, in milliseconds. Range: 0-10000. Start with the default `500`. |
|
||||
| `repeat` | `on` enables normal LoRa forwarding; `off` disables forwarding. Keep it `on` on both bridge repeaters. Normal routing, scope, and packet-filter rules still apply. |
|
||||
| `bridge.type` | Read-only firmware capability. It must report `espnow` for this guide. |
|
||||
| `bridge.running` | Read-only runtime state. `on` means the local bridge started; it does not prove the other board is reachable. |
|
||||
|
||||
This fork uses **`set bridge.source tx`** or **`rx`**. Some upstream guides
|
||||
use `logTx` and `logRx`; those are not the command values used here.
|
||||
|
||||
For a LoRa gateway connected to primary-ESP-NOW Companions instead of another
|
||||
LoRa repeater, see [raw ESP-NOW compatibility](WiFi.md#wifi-companion-setup).
|
||||
|
||||
## 4. Check the link
|
||||
|
||||
Run on both boards:
|
||||
|
||||
```text
|
||||
get bridge.enabled
|
||||
get bridge.running
|
||||
get bridge.channel
|
||||
get bridge.format
|
||||
get bridge.secret
|
||||
get bridge.source
|
||||
get bridge.delay
|
||||
get repeat
|
||||
get radio
|
||||
```
|
||||
|
||||
Expected: enabled and running `on`, matching channel/format/secret, source
|
||||
`tx`, delay `500`, repeat `on`, and the intended LoRa settings on each side.
|
||||
|
||||
Send a message from a Companion on network A to one on network B, then reply
|
||||
from B to A. Check both directions; configuration readback alone does not test
|
||||
delivery.
|
||||
|
||||
If it does not work:
|
||||
|
||||
- **Commands are unavailable:** check `get bridge.type` and the firmware choice.
|
||||
- **Enabled is on but running is off:** initialization failed. Reboot and
|
||||
check again; ensure another WiFi service is not occupying the radio.
|
||||
- **Both are running but traffic does not cross:** compare channel, format,
|
||||
and secret; confirm each board's LoRa settings were applied with a reboot,
|
||||
and check the networks' forwarding and scope rules.
|
||||
|
||||
A running bridge keeps the device awake, even with device power saving enabled.
|
||||
For every command's full details, see the [bridge CLI reference](cli_commands.md#bridge-when-bridge-support-is-compiled-in).
|
||||
|
||||
For a broader comparison with a Linux-based bridge, see the
|
||||
[MeshCore Nexus bridging guide](https://meshcore.nexus/guides/lora-bridging#method-2-meshcore-esp-now-bridge).
|
||||
@@ -175,6 +175,9 @@ RS-232 image. Choose Wi-Fi MQTT under **Logging / MQTT**; it is an output mode,
|
||||
not a second connection choice. Repeaters may still offer separate ESP-NOW or
|
||||
Ethernet bridge firmware because those paths use different compiled drivers.
|
||||
|
||||
For commands and option explanations, follow the
|
||||
[ESP-NOW bridge setup guide](espnow_bridge_setup.md).
|
||||
|
||||
An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Its runtime
|
||||
`bridge.format` setting chooses the peer protocol: `wrapped` (the
|
||||
backward-compatible bridge-to-bridge default using `bridge.secret`) or `raw`
|
||||
|
||||
@@ -54,10 +54,35 @@ where supported. An observed healthy RX state clears the sequence. Recovery
|
||||
preserves tuning, TX power, receive gain, and the operator's power-saving intent.
|
||||
The existing radio liveness and RXPS watchdogs remain active for other failures.
|
||||
|
||||
The radio liveness watchdog measures time without a successfully received
|
||||
packet. Only a positive `recvRaw()` result restarts its observation window;
|
||||
TX completion, CAD interrupts, and failed packet reads do not. Reception counts
|
||||
even if application parsing rejects the packet or the packet pool is full.
|
||||
The check runs after pending RX is drained and TX completes, before the next
|
||||
queued transmission, so sustained outbound traffic cannot postpone recovery.
|
||||
|
||||
The default silence thresholds are 30 minutes for a soft RX/AGC re-arm and
|
||||
12 hours for a radio-only hard recovery where supported. MQTT observer
|
||||
`radio.watchdog` settings can override the soft interval. A successful hard
|
||||
recovery starts a fresh observation window to limit repeated resets on a quiet
|
||||
mesh; a failed hard recovery remains escalated and retries after 30 seconds.
|
||||
Targets with `RADIO_LIVENESS_SOFT_ONLY` repeat soft recovery at the soft interval
|
||||
because their radio cannot be reset independently. These recoveries do not
|
||||
reboot the MCU. The repeater's `rx.watchdog` is a separate whole-board reboot
|
||||
watchdog, enabled by default with a 24-hour sliding deadline since the last
|
||||
received packet passed MeshCore parsing. Its timer is updated before receive
|
||||
delays and routing filters; raw LoRa reads rejected by the parser do not count.
|
||||
MeshCore parsing checks the packet structure, not sender authentication or
|
||||
decryption of traffic addressed to other nodes. Temporary
|
||||
radio windows of at least 12 hours use a 12-hour deadline; shorter windows
|
||||
disable that reboot watchdog until the window ends. The temporary timing
|
||||
overrides are held in RAM and do not replace saved normal-mode preferences.
|
||||
|
||||
The status read uses the two-byte GetStatus transaction. The pinned RadioLib
|
||||
getStatus() helper requests zero copied data bytes, so its return value is not
|
||||
usable for this mode check. The dedicated read restores the normal SPI command
|
||||
layout afterward and does not wake a duty-cycling radio to inspect its mode.
|
||||
|
||||
Validation lives in `test_noise_floor_estimator`, `test_rx_power_saving`,
|
||||
`test_radio_receive_contract.py`, and `test_sx126x_receive_mode.py`.
|
||||
`test_radio_liveness`, `test_radio_receive_contract.py`, and
|
||||
`test_sx126x_receive_mode.py`.
|
||||
@@ -193,7 +193,6 @@ static const char FLOOD_CHANNEL_SCOPE_USAGE[] =
|
||||
#define LOW_BATTERY_STARTUP_DELAY (30ULL * 60ULL * 1000ULL)
|
||||
#define LOW_BATTERY_CHECK_INTERVAL (30UL * 60UL * 1000UL)
|
||||
#define LOW_BATTERY_ALERT_INTERVAL (12UL * 60UL * 60UL * 1000UL)
|
||||
#define RX_INACTIVITY_WATCHDOG_INTERVAL (12UL * 60UL * 60UL * 1000UL)
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
#define TELEMETRY_GPS_HEAP_RESERVE_BYTES 2048U
|
||||
#define TELEMETRY_HISTORY_TX_PREFS_FILE "/telemetry_tx"
|
||||
@@ -3360,7 +3359,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
|
||||
pending_self_advert = false;
|
||||
pending_self_advert_flood = false;
|
||||
next_battery_alert_check = 0;
|
||||
next_rx_watchdog_check = 0;
|
||||
next_recent_repeater_sweep = 0;
|
||||
last_battery_alert_sent = 0;
|
||||
pending_battery_alert_packet = NULL;
|
||||
@@ -4013,31 +4011,22 @@ void MyMesh::checkBatteryAlert() {
|
||||
}
|
||||
|
||||
void MyMesh::checkRxInactivityWatchdog() {
|
||||
if (!_prefs.rx_watchdog_enabled) {
|
||||
next_rx_watchdog_check = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (next_rx_watchdog_check == 0) {
|
||||
next_rx_watchdog_check = futureMillis(RX_INACTIVITY_WATCHDOG_INTERVAL);
|
||||
if (next_rx_watchdog_check == 0) next_rx_watchdog_check = 1;
|
||||
return;
|
||||
}
|
||||
if (!millisHasNowPassed(next_rx_watchdog_check)) {
|
||||
return;
|
||||
}
|
||||
|
||||
next_rx_watchdog_check = futureMillis(RX_INACTIVITY_WATCHDOG_INTERVAL);
|
||||
if (next_rx_watchdog_check == 0) next_rx_watchdog_check = 1;
|
||||
|
||||
const unsigned long now = millis();
|
||||
const unsigned long last_rx = _radio->getLastRecvMillis();
|
||||
if (last_rx == 0 || (uint32_t)(now - last_rx) >= RX_INACTIVITY_WATCHDOG_INTERVAL) {
|
||||
MESH_DEBUG_PRINTLN("RX watchdog: no packet received in 12 hours, rebooting");
|
||||
const uint32_t interval = radio_timing.watchdogMillis(_prefs.rx_watchdog_enabled);
|
||||
if (rx_inactivity_watchdog.expired(millis(), getLastMeshCoreRecvMillis(), interval)) {
|
||||
MESH_DEBUG_PRINTLN("RX watchdog: no MeshCore packet received in %lu hours, rebooting",
|
||||
(unsigned long)(interval / mesh::RepeaterRadioTiming::HOUR_MS));
|
||||
rx_inactivity_watchdog.reset();
|
||||
_cli.getBoard()->reboot();
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::setTempRadioTiming(uint32_t duration_seconds) {
|
||||
radio_timing.setTempDuration(duration_seconds);
|
||||
rx_inactivity_watchdog.reset();
|
||||
updateAdvertTimer();
|
||||
updateFloodAdvertTimer();
|
||||
}
|
||||
|
||||
bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
uint32_t rx_us = _prefs.rx_ps_rx_us;
|
||||
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
|
||||
@@ -4153,6 +4142,13 @@ void MyMesh::refreshScheduledRadioState() {
|
||||
scheduled_radio_retry_at = 0;
|
||||
scheduled_radio_retry_failures = 0;
|
||||
}
|
||||
// A cancelled/expired lease releases its timing overrides even if restoring
|
||||
// the saved modulation tuple needs another attempt. A replacement TempRadio
|
||||
// handoff keeps the old overrides until the new tuple is actually applied.
|
||||
if (radio_timing.isTemporary() && !scheduled_temp_radio_started
|
||||
&& !temp_radio_handoff_pending) {
|
||||
setTempRadioTiming(0);
|
||||
}
|
||||
}
|
||||
|
||||
bool MyMesh::hasStartedScheduledTempRadio() const {
|
||||
@@ -4394,6 +4390,13 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8
|
||||
temporary ? "tempradioat" : "radioat",
|
||||
getScheduledRadioSettingIndex(temporary, slot),
|
||||
delay);
|
||||
if (temporary) {
|
||||
char duration[64];
|
||||
mesh::RepeaterRadioTiming::formatDuration(duration, sizeof(duration), end_time - start_time);
|
||||
const size_t used = strlen(reply);
|
||||
snprintf(reply + used, 160 - used, "; for %s", duration);
|
||||
appendTempRadioTimingNote(reply, 160, end_time - start_time);
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::formatScheduledRadioParams(bool temporary, const char* selector, char* reply) {
|
||||
@@ -4634,6 +4637,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
setting.started = true;
|
||||
temp_radio_applied = true;
|
||||
temp_radio_handoff_pending = false;
|
||||
setTempRadioTiming(setting.end_time - setting.start_time);
|
||||
} else {
|
||||
// setParams() can fail after changing only part of the modulation
|
||||
// tuple. Restore the saved tuple if this temporary window expires
|
||||
@@ -4657,6 +4661,7 @@ void MyMesh::processScheduledRadioSettings() {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
saved_radio_apply_pending = false;
|
||||
temp_radio_applied = false;
|
||||
if (radio_timing.isTemporary()) setTempRadioTiming(0);
|
||||
} else {
|
||||
apply_failed = true;
|
||||
}
|
||||
@@ -4859,16 +4864,18 @@ void MyMesh::sendSelfAdvertisementNow(uint32_t delay_millis, bool flood) {
|
||||
}
|
||||
|
||||
void MyMesh::updateAdvertTimer() {
|
||||
if (_prefs.advert_interval > 0) { // schedule local advert timer
|
||||
next_local_advert = futureMillis((int)((uint32_t)_prefs.advert_interval * 2 * 60 * 1000));
|
||||
const uint32_t minutes = radio_timing.localAdvertMinutes((uint32_t)_prefs.advert_interval * 2);
|
||||
if (minutes > 0) {
|
||||
next_local_advert = futureMillis(minutes * 60UL * 1000UL);
|
||||
} else {
|
||||
next_local_advert = 0; // stop the timer
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::updateFloodAdvertTimer() {
|
||||
if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer
|
||||
next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000);
|
||||
const uint32_t hours = radio_timing.floodAdvertHours(_prefs.flood_advert_interval);
|
||||
if (hours > 0) {
|
||||
next_flood_advert = futureMillis(hours * mesh::RepeaterRadioTiming::HOUR_MS);
|
||||
} else {
|
||||
next_flood_advert = 0; // stop the timer
|
||||
}
|
||||
@@ -10468,6 +10475,7 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
|
||||
s.power_saving = _prefs.powersaving_enabled;
|
||||
s.mqtt_enabled = _prefs.bridge_enabled != 0;
|
||||
s.repeat = !_prefs.disable_fwd;
|
||||
// Editable web configuration must retain the saved intervals during tempradio.
|
||||
s.advert_interval = _prefs.advert_interval * 2;
|
||||
s.flood_advert_interval = _prefs.flood_advert_interval;
|
||||
s.flood_max = _prefs.flood_max;
|
||||
@@ -11886,19 +11894,24 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
|
||||
} else if (strcmp(command, "get battery.alert.critical") == 0) {
|
||||
sprintf(reply, "> %u", (uint32_t)_prefs.battery_alert_critical_percent);
|
||||
} else if (strcmp(command, "get rx.watchdog") == 0) {
|
||||
sprintf(reply, "> %s", _prefs.rx_watchdog_enabled ? "on" : "off");
|
||||
snprintf(reply, 160, "> %s; rx.watchdog=%s%s",
|
||||
radio_timing.watchdogMillis(_prefs.rx_watchdog_enabled) ? "on" : "off",
|
||||
radio_timing.watchdogLabel(_prefs.rx_watchdog_enabled),
|
||||
radio_timing.isTemporary() ? " (tempradio)" : "");
|
||||
} else if (strncmp(command, "set rx.watchdog ", 16) == 0) {
|
||||
const char* value = command + 16;
|
||||
if (strcmp(value, "on") == 0) {
|
||||
_prefs.rx_watchdog_enabled = 1;
|
||||
next_rx_watchdog_check = 0;
|
||||
rx_inactivity_watchdog.reset();
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - RX watchdog enabled; first check in 12 hours");
|
||||
snprintf(reply, 160, "OK - saved on; rx.watchdog=%s",
|
||||
radio_timing.watchdogLabel(_prefs.rx_watchdog_enabled));
|
||||
} else if (strcmp(value, "off") == 0) {
|
||||
_prefs.rx_watchdog_enabled = 0;
|
||||
next_rx_watchdog_check = 0;
|
||||
rx_inactivity_watchdog.reset();
|
||||
savePrefs();
|
||||
strcpy(reply, "OK - RX watchdog disabled");
|
||||
snprintf(reply, 160, "OK - saved off; rx.watchdog=%s",
|
||||
radio_timing.watchdogLabel(_prefs.rx_watchdog_enabled));
|
||||
} else {
|
||||
strcpy(reply, "Err - usage: set rx.watchdog <on|off>");
|
||||
}
|
||||
@@ -12349,6 +12362,10 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
|
||||
pending_self_advert = false;
|
||||
sendSelfAdvertisementNow(delay_millis, flood);
|
||||
}
|
||||
#if !defined(PORTABLE_MQTT_OBSERVER)
|
||||
// Apply starts/expiries before checking temporary watchdog/advert deadlines.
|
||||
processScheduledRadioSettings();
|
||||
#endif
|
||||
checkRxInactivityWatchdog();
|
||||
#if MESH_ENABLE_TELEMETRY_HISTORY
|
||||
sampleTelemetryHistory();
|
||||
@@ -12371,18 +12388,17 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() {
|
||||
if (pkt) sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
|
||||
updateFloodAdvertTimer(); // schedule next flood advert
|
||||
updateAdvertTimer(); // also schedule local advert (so they don't overlap)
|
||||
} else if (next_local_advert && millisHasNowPassed(next_local_advert)) {
|
||||
if (!radio_timing.isTemporary()) updateAdvertTimer();
|
||||
}
|
||||
// During TempRadio the hourly direct advert remains independent, including
|
||||
// hours at which a three-hour flood advert is also due.
|
||||
if (next_local_advert && millisHasNowPassed(next_local_advert)) {
|
||||
mesh::Packet *pkt = createSelfAdvert();
|
||||
if (pkt) sendZeroHop(pkt);
|
||||
|
||||
updateAdvertTimer(); // schedule next local advert
|
||||
}
|
||||
|
||||
#if !defined(PORTABLE_MQTT_OBSERVER)
|
||||
processScheduledRadioSettings();
|
||||
#endif
|
||||
|
||||
#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)
|
||||
if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update`
|
||||
_ota_update_at = 0; // clear timer
|
||||
|
||||
@@ -351,7 +351,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool pending_self_advert;
|
||||
bool pending_self_advert_flood;
|
||||
unsigned long next_battery_alert_check;
|
||||
unsigned long next_rx_watchdog_check;
|
||||
mesh::RxInactivityWatchdog rx_inactivity_watchdog;
|
||||
mesh::RepeaterRadioTiming radio_timing;
|
||||
unsigned long next_recent_repeater_sweep;
|
||||
uint64_t last_battery_alert_sent;
|
||||
mesh::Packet* pending_battery_alert_packet;
|
||||
@@ -725,6 +726,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
bool resolveBatteryAlertScope(TransportKey& scope);
|
||||
void checkBatteryAlert();
|
||||
void checkRxInactivityWatchdog();
|
||||
void setTempRadioTiming(uint32_t duration_seconds);
|
||||
void expireRecentRepeatersIfDue();
|
||||
void printRecentRepeatersSerial();
|
||||
|
||||
@@ -854,6 +856,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
|
||||
void formatScheduledRadioSetting(char* reply, int setting_idx, int display_idx) const;
|
||||
|
||||
protected:
|
||||
uint32_t getTempRadioDurationSeconds() const override { return radio_timing.tempDuration(); }
|
||||
void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) const override {
|
||||
mesh::RepeaterRadioTiming::appendTempWatchdogNote(reply, size, seconds);
|
||||
}
|
||||
bool isTempRadioActive() const override;
|
||||
float getAirtimeBudgetFactor() const override {
|
||||
// A bounded TempRadio window is an explicitly coordinated private OTA
|
||||
|
||||
+52
-64
@@ -49,9 +49,8 @@ void Dispatcher::setRadioAvailable(bool available) {
|
||||
agc_reset_armed = false;
|
||||
_radio->begin();
|
||||
prev_isrecv_mode = _radio->isInRecvMode();
|
||||
last_observed_radio_irq = _radio->getLastRadioInterruptMillis();
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = now;
|
||||
rx_watchdog_window_start = now;
|
||||
#else
|
||||
radio_liveness.begin(now);
|
||||
nonrx_soft_recovery_attempted = false;
|
||||
@@ -62,6 +61,7 @@ void Dispatcher::setRadioAvailable(bool available) {
|
||||
void Dispatcher::begin() {
|
||||
n_sent_flood = n_sent_direct = 0;
|
||||
n_recv_flood = n_recv_direct = 0;
|
||||
last_meshcore_recv_millis = 0;
|
||||
_err_flags = 0;
|
||||
outbound_radio_retry_at = 0;
|
||||
outbound_radio_retry_pending = false;
|
||||
@@ -80,14 +80,12 @@ void Dispatcher::begin() {
|
||||
if (radio_available) {
|
||||
_radio->begin();
|
||||
prev_isrecv_mode = _radio->isInRecvMode();
|
||||
last_observed_radio_irq = _radio->getLastRadioInterruptMillis();
|
||||
} else {
|
||||
prev_isrecv_mode = false;
|
||||
last_observed_radio_irq = 0;
|
||||
MESH_DEBUG_PRINTLN("Dispatcher: starting without a radio transport");
|
||||
}
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = now;
|
||||
rx_watchdog_window_start = now;
|
||||
#else
|
||||
radio_liveness.begin(now);
|
||||
nonrx_soft_recovery_attempted = false;
|
||||
@@ -309,16 +307,6 @@ void Dispatcher::loop() {
|
||||
_radio->loop();
|
||||
|
||||
const unsigned long now = _ms->getMillis();
|
||||
const unsigned long latest_irq = _radio->getLastRadioInterruptMillis();
|
||||
if (latest_irq != 0 && latest_irq != last_observed_radio_irq) {
|
||||
last_observed_radio_irq = latest_irq;
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = now;
|
||||
#else
|
||||
radio_liveness.noteActivity(now);
|
||||
#endif
|
||||
}
|
||||
|
||||
// check for radio 'stuck' in mode other than Rx
|
||||
bool is_recv = _radio->isInRecvMode();
|
||||
if (is_recv != prev_isrecv_mode) {
|
||||
@@ -339,7 +327,7 @@ void Dispatcher::loop() {
|
||||
MESH_DEBUG_PRINTLN("Radio watchdog: radio outside RX for %lu ms; soft recovery",
|
||||
now - radio_nonrx_start);
|
||||
if (_radio->recoverRadio(false)) {
|
||||
last_radio_activity_ms = now;
|
||||
rx_watchdog_window_start = now;
|
||||
recovered_this_loop = true;
|
||||
}
|
||||
#else
|
||||
@@ -352,47 +340,6 @@ void Dispatcher::loop() {
|
||||
radio_nonrx_start = now; // bounded retry cadence if recovery is unsupported
|
||||
}
|
||||
|
||||
// Continuous-RX can fail while the MCU and main loop remain healthy, so a
|
||||
// CPU watchdog cannot detect it. Use packet/IRQ/TX activity as proof of
|
||||
// radio life and stage recovery from a harmless re-arm to a radio-only
|
||||
// hardware reset when the driver supports one. Observer configuration may
|
||||
// request a faster soft check.
|
||||
uint32_t soft_liveness_ms = RADIO_LIVENESS_SOFT_MS;
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
const uint32_t configured_watchdog_ms = getRadioWatchdogMillis();
|
||||
if (configured_watchdog_ms > 0) soft_liveness_ms = configured_watchdog_ms;
|
||||
#endif
|
||||
if (is_recv && outbound == NULL && !recovered_this_loop) {
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
if (soft_liveness_ms > 0 && now - last_radio_activity_ms >= soft_liveness_ms) {
|
||||
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
|
||||
MESH_DEBUG_PRINTLN("Radio watchdog: no hardware activity for %lu ms, state=%d, soft recovery",
|
||||
now - last_radio_activity_ms, _radio->getRadioState());
|
||||
_radio->recoverRadio(false);
|
||||
// This radio has no independently resettable RF peripheral. Retry its
|
||||
// only safe recovery at the soft interval until IRQ/TX/RX proves life.
|
||||
last_radio_activity_ms = now;
|
||||
}
|
||||
#else
|
||||
uint32_t hard_liveness_ms = RADIO_LIVENESS_HARD_MS;
|
||||
if (soft_liveness_ms > hard_liveness_ms / 2) {
|
||||
hard_liveness_ms = soft_liveness_ms <= 0x7FFFFFFFUL
|
||||
? soft_liveness_ms * 2 : 0xFFFFFFFFUL;
|
||||
}
|
||||
const RadioRecoveryAction action = radio_liveness.poll(
|
||||
now, soft_liveness_ms, hard_liveness_ms);
|
||||
if (action != RadioRecoveryAction::NONE) {
|
||||
const bool hard = action == RadioRecoveryAction::HARD;
|
||||
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
|
||||
MESH_DEBUG_PRINTLN("Radio watchdog: no hardware activity for %lu ms, state=%d, %s recovery",
|
||||
now - radio_liveness.lastActivity(), _radio->getRadioState(),
|
||||
hard ? "hard" : "soft");
|
||||
const bool recovered = _radio->recoverRadio(hard);
|
||||
if (hard) radio_liveness.noteHardRecoveryResult(now, recovered);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (outbound) { // waiting for outbound send to complete, or for its one radio retry
|
||||
if (outbound_radio_retry_pending) {
|
||||
if (!millisHasNowPassed(outbound_radio_retry_at)) return;
|
||||
@@ -434,11 +381,6 @@ void Dispatcher::loop() {
|
||||
// otherwise a later CAD pause can trip the eight-second watchdog using
|
||||
// the start time of the whole healthy burst.
|
||||
radio_nonrx_start = _ms->getMillis();
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = radio_nonrx_start; // TX success -> radio is alive
|
||||
#else
|
||||
radio_liveness.noteActivity(radio_nonrx_start); // TX success -> radio is alive
|
||||
#endif
|
||||
restoreOutboundTxOverrides();
|
||||
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
|
||||
onSendComplete(outbound);
|
||||
@@ -458,6 +400,7 @@ void Dispatcher::loop() {
|
||||
restoreOutboundTxOverrides();
|
||||
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
|
||||
const bool recovered = _radio->recoverRadio(true);
|
||||
recovered_this_loop = true;
|
||||
if (!recovered) {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): WARNING: hard radio recovery after TX timeout failed!",
|
||||
getLogDateTime());
|
||||
@@ -516,6 +459,48 @@ void Dispatcher::loop() {
|
||||
}
|
||||
}
|
||||
checkRecv();
|
||||
|
||||
// Count only successful recvRaw() delivery as RX activity. Check after
|
||||
// draining RX and completing TX, before starting the next queued transmit:
|
||||
// a packet arriving at the deadline prevents unnecessary recovery, and a
|
||||
// continuous TX queue cannot hide a deaf receiver. The driver defers any
|
||||
// recovery that would interrupt a pending packet or an active reception.
|
||||
if (_radio->isInRecvMode() && outbound == NULL && !recovered_this_loop) {
|
||||
const unsigned long now = _ms->getMillis();
|
||||
uint32_t soft_liveness_ms = RADIO_LIVENESS_SOFT_MS;
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
const uint32_t configured_watchdog_ms = getRadioWatchdogMillis();
|
||||
if (configured_watchdog_ms > 0) soft_liveness_ms = configured_watchdog_ms;
|
||||
#endif
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
if (soft_liveness_ms > 0 && (uint32_t)(now - rx_watchdog_window_start) >= soft_liveness_ms) {
|
||||
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
|
||||
MESH_DEBUG_PRINTLN("Radio watchdog: no packet received in observation window for %lu ms, state=%d, soft recovery",
|
||||
(unsigned long)(uint32_t)(now - rx_watchdog_window_start), _radio->getRadioState());
|
||||
_radio->recoverRadio(false);
|
||||
// This radio has no independently resettable RF peripheral. Allow a
|
||||
// full observation interval between attempts on a quiet mesh.
|
||||
rx_watchdog_window_start = now;
|
||||
}
|
||||
#else
|
||||
uint32_t hard_liveness_ms = RADIO_LIVENESS_HARD_MS;
|
||||
if (soft_liveness_ms > hard_liveness_ms / 2) {
|
||||
hard_liveness_ms = soft_liveness_ms <= 0x7FFFFFFFUL
|
||||
? soft_liveness_ms * 2 : 0xFFFFFFFFUL;
|
||||
}
|
||||
const RadioRecoveryAction action = radio_liveness.poll(
|
||||
now, soft_liveness_ms, hard_liveness_ms);
|
||||
if (action != RadioRecoveryAction::NONE) {
|
||||
const bool hard = action == RadioRecoveryAction::HARD;
|
||||
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
|
||||
MESH_DEBUG_PRINTLN("Radio watchdog: no packet received in observation window for %lu ms, state=%d, %s recovery",
|
||||
(unsigned long)(uint32_t)(now - radio_liveness.windowStart()), _radio->getRadioState(),
|
||||
hard ? "hard" : "soft");
|
||||
const bool recovered = _radio->recoverRadio(hard);
|
||||
if (hard) radio_liveness.noteHardRecoveryResult(now, recovered);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
checkSend();
|
||||
releaseDroppedOutbound();
|
||||
}
|
||||
@@ -595,10 +580,12 @@ void Dispatcher::checkRecv() {
|
||||
uint8_t raw[MAX_TRANS_UNIT+1];
|
||||
int len = _radio->recvRaw(raw, MAX_TRANS_UNIT);
|
||||
if (len > 0) {
|
||||
// A successful driver read proves RX works even if application parsing
|
||||
// rejects the packet or the packet pool has no space. IRQs alone do not.
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = _ms->getMillis();
|
||||
rx_watchdog_window_start = _ms->getMillis();
|
||||
#else
|
||||
radio_liveness.noteActivity(_ms->getMillis());
|
||||
radio_liveness.noteReceive(_ms->getMillis());
|
||||
#endif
|
||||
snr = _radio->getLastSNR();
|
||||
rssi = _radio->getLastRSSI();
|
||||
@@ -609,6 +596,7 @@ void Dispatcher::checkRecv() {
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): WARNING: received data, no unused packets available!", getLogDateTime());
|
||||
} else {
|
||||
if (tryParsePacket(pkt, raw, len)) {
|
||||
last_meshcore_recv_millis = _ms->getMillis();
|
||||
pkt->_snr = snr * 4.0f;
|
||||
pkt->_rssi = (int16_t)rssi;
|
||||
score = _radio->packetScore(snr, len);
|
||||
|
||||
+7
-4
@@ -239,9 +239,8 @@ class Dispatcher {
|
||||
Packet* outbound; // current outbound packet
|
||||
unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time;
|
||||
unsigned long outbound_radio_retry_at;
|
||||
unsigned long last_observed_radio_irq;
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
unsigned long last_radio_activity_ms;
|
||||
unsigned long rx_watchdog_window_start;
|
||||
#else
|
||||
RadioLivenessTracker radio_liveness;
|
||||
#endif
|
||||
@@ -262,6 +261,7 @@ class Dispatcher {
|
||||
#endif
|
||||
uint32_t n_sent_flood, n_sent_direct;
|
||||
uint32_t n_recv_flood, n_recv_direct;
|
||||
uint32_t last_meshcore_recv_millis;
|
||||
unsigned long tx_budget_ms;
|
||||
unsigned long last_budget_update;
|
||||
unsigned long duty_cycle_window_ms;
|
||||
@@ -289,6 +289,7 @@ protected:
|
||||
outbound_radio_retry_used = false;
|
||||
outbound_restore_cr = 0;
|
||||
total_air_time = rx_air_time = 0;
|
||||
last_meshcore_recv_millis = 0;
|
||||
next_tx_time = ms.getMillis();
|
||||
cad_busy_start = 0;
|
||||
next_floor_calib_time = next_agc_reset_time = 0;
|
||||
@@ -302,9 +303,8 @@ protected:
|
||||
tx_budget_ms = 0;
|
||||
last_budget_update = 0;
|
||||
duty_cycle_window_ms = 3600000;
|
||||
last_observed_radio_irq = 0;
|
||||
#ifdef RADIO_LIVENESS_SOFT_ONLY
|
||||
last_radio_activity_ms = 0;
|
||||
rx_watchdog_window_start = 0;
|
||||
#else
|
||||
nonrx_soft_recovery_attempted = false;
|
||||
#endif
|
||||
@@ -377,6 +377,9 @@ public:
|
||||
uint32_t getNumSentDirect() const { return n_sent_direct; }
|
||||
uint32_t getNumRecvFlood() const { return n_recv_flood; }
|
||||
uint32_t getNumRecvDirect() const { return n_recv_direct; }
|
||||
// Successful MeshCore packet parsing, before receive delays and routing filters.
|
||||
// Raw radio reads, local sends, and statistics resets do not update this clock.
|
||||
uint32_t getLastMeshCoreRecvMillis() const { return last_meshcore_recv_millis; }
|
||||
uint16_t getErrFlags() const { return _err_flags; } // Get error flags
|
||||
bool hasOutbound() const { return outbound != NULL; }
|
||||
bool isCurrentOutbound(const Packet* packet) const { return outbound == packet; }
|
||||
|
||||
@@ -975,7 +975,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
#endif
|
||||
_prefs->telemetry_access = TELEMETRY_ACCESS_ALL;
|
||||
_prefs->flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT;
|
||||
_prefs->rx_watchdog_enabled = 0;
|
||||
_prefs->rx_watchdog_enabled = mesh::RepeaterRadioTiming::DEFAULT_RX_WATCHDOG_ENABLED;
|
||||
_prefs->system_watchdog_enabled = 1;
|
||||
// A remainder larger than the smallest legacy MQTT gap (864) means an old fork
|
||||
// file with the zero-filled gap; detect and recover it below. Anything smaller
|
||||
@@ -2608,15 +2608,23 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
float bw = 0.0f;
|
||||
uint8_t sf = num > 2 ? atoi(parts[2]) : 0;
|
||||
uint8_t cr = num > 3 ? atoi(parts[3]) : 0;
|
||||
int temp_timeout_mins = num > 4 ? atoi(parts[4]) : 0;
|
||||
uint32_t temp_timeout_mins = 0;
|
||||
if (num == 5
|
||||
&& parseUint32Strict(parts[4], temp_timeout_mins)
|
||||
&& temp_timeout_mins > 0
|
||||
&& (uint64_t)getRTCClock()->getCurrentTime() + 2
|
||||
+ (uint64_t)temp_timeout_mins * 60 <= UINT32_MAX
|
||||
&& mesh::cli::parseDecimalStrict(parts[0], freq)
|
||||
&& mesh::cli::parseDecimalStrict(parts[1], bw)
|
||||
&& freq >= 150.0f && freq <= 2500.0f
|
||||
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
|
||||
&& isValidLoRaBandwidth(bw) && temp_timeout_mins > 0) {
|
||||
&& isValidLoRaBandwidth(bw)) {
|
||||
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins);
|
||||
sprintf(reply, "OK - temp params for %d mins", temp_timeout_mins);
|
||||
char duration[64];
|
||||
const uint32_t seconds = (uint32_t)temp_timeout_mins * 60UL;
|
||||
mesh::RepeaterRadioTiming::formatDuration(duration, sizeof(duration), seconds);
|
||||
snprintf(reply, 160, "OK - temp params for %s", duration);
|
||||
_callbacks->appendTempRadioTimingNote(reply, 160, seconds);
|
||||
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
|
||||
} else {
|
||||
strcpy(reply, "Error, invalid params");
|
||||
@@ -4463,9 +4471,13 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
} else if (configKeyEquals(config, "telemetry.access")) {
|
||||
sprintf(reply, "> %s", _prefs->telemetry_access == TELEMETRY_ACCESS_ACL ? "acl" : "all");
|
||||
} else if (configKeyEquals(config, "flood.advert.interval")) {
|
||||
sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval));
|
||||
mesh::RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(_callbacks->getTempRadioDurationSeconds());
|
||||
sprintf(reply, "> %lu", (unsigned long)timing.floodAdvertHours(_prefs->flood_advert_interval));
|
||||
} else if (configKeyEquals(config, "advert.interval")) {
|
||||
sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2);
|
||||
mesh::RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(_callbacks->getTempRadioDurationSeconds());
|
||||
sprintf(reply, "> %lu", (unsigned long)timing.localAdvertMinutes((uint32_t)_prefs->advert_interval * 2));
|
||||
} else if (configKeyEquals(config, "guest.password")) {
|
||||
sprintf(reply, "> %s", _prefs->guest_password);
|
||||
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // local connections only
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <helpers/bridges/ESPNowBridgeFormat.h>
|
||||
#include <helpers/CommonRadioPrefs.h>
|
||||
#include <helpers/DynamicConfigSerializer.h>
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
|
||||
#ifndef DEFAULT_CAD_ENABLED
|
||||
#define DEFAULT_CAD_ENABLED 0
|
||||
@@ -238,7 +239,7 @@ public:
|
||||
uint8_t rx_ps_preamble = 0; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
|
||||
char battery_alert_region[31] = {}; // named scope for low-battery floods; empty = no alert scope
|
||||
uint8_t flood_retry_group_max_path = 0; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate
|
||||
uint8_t rx_watchdog_enabled = 0; // repeater RX-inactivity reboot watchdog (boolean)
|
||||
uint8_t rx_watchdog_enabled = mesh::RepeaterRadioTiming::DEFAULT_RX_WATCHDOG_ENABLED;
|
||||
uint8_t system_watchdog_enabled = 0; // nRF52 main-loop hardware watchdog (boolean; default on)
|
||||
|
||||
private:
|
||||
@@ -511,6 +512,8 @@ public:
|
||||
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
|
||||
virtual void clearStats() = 0;
|
||||
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0;
|
||||
virtual uint32_t getTempRadioDurationSeconds() const { return 0; }
|
||||
virtual void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) const { }
|
||||
// Cancel pending/active temporary-radio windows and restore the saved tuple
|
||||
// after the command reply has drained on the current channel.
|
||||
virtual bool scheduleNormalRadio() { return false; }
|
||||
|
||||
@@ -10,41 +10,40 @@ enum class RadioRecoveryAction : uint8_t {
|
||||
HARD,
|
||||
};
|
||||
|
||||
// Tracks proof that the radio hardware is alive independently of the MCU main
|
||||
// loop. A successful packet, IRQ, or TX resets the schedule. Silence first
|
||||
// requests a non-destructive RX/AGC re-arm, then a radio-only hardware reset.
|
||||
// Tracks successful reception independently of TX and interrupt activity, which
|
||||
// can continue while the receiver is deaf. RX silence first requests an RX/AGC
|
||||
// re-arm, then a radio-only hardware reset. A completed hard recovery starts a
|
||||
// fresh observation window to bound repeated recovery on a quiet mesh.
|
||||
class RadioLivenessTracker {
|
||||
static const uint32_t HARD_RETRY_MS = 30000UL;
|
||||
|
||||
uint32_t _last_activity;
|
||||
uint32_t _window_start;
|
||||
uint32_t _last_hard_attempt;
|
||||
uint8_t _stage;
|
||||
|
||||
public:
|
||||
RadioLivenessTracker() : _last_activity(0), _last_hard_attempt(0), _stage(0) {}
|
||||
RadioLivenessTracker() : _window_start(0), _last_hard_attempt(0), _stage(0) {}
|
||||
|
||||
void begin(uint32_t now) {
|
||||
_last_activity = now;
|
||||
_window_start = now;
|
||||
_last_hard_attempt = 0;
|
||||
_stage = 0;
|
||||
}
|
||||
|
||||
void noteActivity(uint32_t now) {
|
||||
_last_activity = now;
|
||||
_last_hard_attempt = 0;
|
||||
_stage = 0;
|
||||
void noteReceive(uint32_t now) {
|
||||
begin(now);
|
||||
}
|
||||
|
||||
// A completed hard recovery is a safe point from which to restart the
|
||||
// liveness window. Failed recoveries remain escalated and retry at a bounded
|
||||
// cadence, instead of being silently deferred for another full interval.
|
||||
void noteHardRecoveryResult(uint32_t now, bool success) {
|
||||
if (success) noteActivity(now);
|
||||
if (success) begin(now);
|
||||
}
|
||||
|
||||
RadioRecoveryAction poll(uint32_t now, uint32_t soft_after_ms,
|
||||
uint32_t hard_after_ms) {
|
||||
const uint32_t silent_for = now - _last_activity;
|
||||
const uint32_t silent_for = now - _window_start;
|
||||
if (hard_after_ms > 0 && silent_for >= hard_after_ms) {
|
||||
if (_stage < 2 || now - _last_hard_attempt >= HARD_RETRY_MS) {
|
||||
_stage = 2;
|
||||
@@ -60,7 +59,7 @@ public:
|
||||
return RadioRecoveryAction::NONE;
|
||||
}
|
||||
|
||||
uint32_t lastActivity() const { return _last_activity; }
|
||||
uint32_t windowStart() const { return _window_start; }
|
||||
uint8_t stage() const { return _stage; }
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
// Runtime overrides only. A zero duration selects the saved normal settings.
|
||||
class RepeaterRadioTiming {
|
||||
uint32_t _temp_duration_secs = 0;
|
||||
|
||||
public:
|
||||
static constexpr bool DEFAULT_RX_WATCHDOG_ENABLED = true;
|
||||
static constexpr uint32_t HOUR_SECS = 3600UL;
|
||||
static constexpr uint32_t HOUR_MS = HOUR_SECS * 1000UL;
|
||||
|
||||
void setTempDuration(uint32_t seconds) { _temp_duration_secs = seconds; }
|
||||
uint32_t tempDuration() const { return _temp_duration_secs; }
|
||||
bool isTemporary() const { return _temp_duration_secs != 0; }
|
||||
|
||||
uint32_t watchdogMillis(bool normal_enabled) const {
|
||||
if (isTemporary()) {
|
||||
return _temp_duration_secs >= 12UL * HOUR_SECS ? 12UL * HOUR_MS : 0;
|
||||
}
|
||||
return normal_enabled ? 24UL * HOUR_MS : 0;
|
||||
}
|
||||
|
||||
const char* watchdogLabel(bool normal_enabled) const {
|
||||
if (isTemporary()) {
|
||||
return watchdogMillis(normal_enabled) ? "12hours" : "off (temp<12hours)";
|
||||
}
|
||||
return normal_enabled ? "24hours" : "off";
|
||||
}
|
||||
|
||||
uint32_t localAdvertMinutes(uint32_t normal_minutes) const {
|
||||
return isTemporary() ? 60 : normal_minutes;
|
||||
}
|
||||
|
||||
uint32_t floodAdvertHours(uint32_t normal_hours) const {
|
||||
return _temp_duration_secs > 3UL * HOUR_SECS ? 3 : normal_hours;
|
||||
}
|
||||
|
||||
static void formatDuration(char* dest, size_t size, uint32_t seconds) {
|
||||
const uint32_t days = seconds / (24UL * HOUR_SECS);
|
||||
const uint32_t hours = (seconds / HOUR_SECS) % 24;
|
||||
const uint32_t minutes = (seconds / 60) % 60;
|
||||
if (seconds % 60) {
|
||||
snprintf(dest, size, "%lu mins %lu sec (%lud%luh%lum)",
|
||||
(unsigned long)(seconds / 60), (unsigned long)(seconds % 60),
|
||||
(unsigned long)days, (unsigned long)hours, (unsigned long)minutes);
|
||||
} else {
|
||||
snprintf(dest, size, "%lu mins (%lud%luh%lum)",
|
||||
(unsigned long)(seconds / 60),
|
||||
(unsigned long)days, (unsigned long)hours, (unsigned long)minutes);
|
||||
}
|
||||
}
|
||||
|
||||
static void appendTempWatchdogNote(char* reply, size_t size, uint32_t seconds) {
|
||||
const size_t used = strlen(reply);
|
||||
if (used >= size) return;
|
||||
RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(seconds);
|
||||
snprintf(reply + used, size - used, "; rx.watchdog=%s", timing.watchdogLabel(true));
|
||||
}
|
||||
};
|
||||
|
||||
// A sliding no-RX deadline, rebased on activation and each successful receive.
|
||||
// No wall clock, periodic flash writes, or extra radio/CPU wakeups are needed.
|
||||
class RxInactivityWatchdog {
|
||||
uint32_t _interval = 0;
|
||||
uint32_t _window_start = 0;
|
||||
uint32_t _last_rx = 0;
|
||||
|
||||
public:
|
||||
void reset() { _interval = 0; }
|
||||
|
||||
bool expired(uint32_t now, uint32_t last_rx, uint32_t interval) {
|
||||
if (_interval != interval) {
|
||||
_interval = interval;
|
||||
_window_start = now;
|
||||
_last_rx = last_rx;
|
||||
return false;
|
||||
}
|
||||
if (interval == 0) return false;
|
||||
if (last_rx != _last_rx) {
|
||||
_last_rx = last_rx;
|
||||
_window_start = last_rx;
|
||||
}
|
||||
return (uint32_t)(now - _window_start) >= interval;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mesh
|
||||
@@ -48,6 +48,7 @@ python3 test/test_esp32_tinyusb_nonblocking.py # Native CDC stalled-host/64-byte
|
||||
python3 test/test_esp32_tinyusb_role_hygiene.py # Repeater/room nonblocking console and paced large replies
|
||||
python3 test/test_esp32_tinyusb_cooperative_output.py # Real role pumps: large logs/listings, EOF, backpressure
|
||||
python3 test/test_temp_radio_reply_delivery_contract.py # TempRadio ACK path/barrier integration
|
||||
python3 test/test_repeater_radio_timing_integration.py # Real scheduler, watchdog, hourly/flood adverts and restoration
|
||||
python3 test/test_tls_download_clock_gates.py # Fresh-NTP/TLS download integration contract
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/RxReservePacketManager.h>
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
|
||||
class TestClock : public mesh::MillisecondClock {
|
||||
public:
|
||||
@@ -245,6 +246,122 @@ TEST(Dispatcher, FloodPacketWaitsForConfiguredRxDelayWithoutBypass) {
|
||||
EXPECT_EQ(1, dispatcher.received_packets);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, RejectedRadioPacketsCannotFeedMeshCoreRxWatchdog) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
mesh::RxInactivityWatchdog watchdog;
|
||||
const uint32_t timeout = 24 * mesh::RepeaterRadioTiming::HOUR_MS;
|
||||
clock.now = 1;
|
||||
dispatcher.begin();
|
||||
ASSERT_FALSE(watchdog.expired(clock.now, dispatcher.getLastMeshCoreRecvMillis(), timeout));
|
||||
|
||||
const uint8_t rejected[][3] = {
|
||||
{ROUTE_TYPE_DIRECT | (PAYLOAD_VER_2 << PH_VER_SHIFT), 0, 0x42},
|
||||
{ROUTE_TYPE_DIRECT, 0xC0, 0x42}, // reserved path mode
|
||||
{ROUTE_TYPE_DIRECT, 2, 0x42}, // truncated path
|
||||
{ROUTE_TYPE_TRANSPORT_DIRECT, 0, 0}, // truncated transport header
|
||||
};
|
||||
for (const auto& raw : rejected) {
|
||||
clock.now += 1000;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0U, dispatcher.getLastMeshCoreRecvMillis());
|
||||
}
|
||||
EXPECT_EQ(0, dispatcher.received_packets);
|
||||
|
||||
clock.now = timeout + 1;
|
||||
radio.queueRx(rejected[0], sizeof(rejected[0]));
|
||||
dispatcher.loop();
|
||||
EXPECT_TRUE(watchdog.expired(clock.now, dispatcher.getLastMeshCoreRecvMillis(), timeout));
|
||||
// The separate radio-only watchdog can still use a successful physical read.
|
||||
EXPECT_EQ(0, radio.soft_recoveries);
|
||||
EXPECT_EQ(0, radio.hard_recoveries);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, MeshCorePacketAtDeadlineFeedsWatchdogBeforeForwardingDelay) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
mesh::RxInactivityWatchdog watchdog;
|
||||
const uint32_t timeout = 24 * mesh::RepeaterRadioTiming::HOUR_MS;
|
||||
dispatcher.forced_rx_delay = 32000;
|
||||
clock.now = 1;
|
||||
dispatcher.begin();
|
||||
ASSERT_FALSE(watchdog.expired(clock.now, dispatcher.getLastMeshCoreRecvMillis(), timeout));
|
||||
const uint8_t raw[] = {
|
||||
ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT), 0, 0x42
|
||||
};
|
||||
|
||||
clock.now = timeout + 1;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0, dispatcher.received_packets); // still waiting for forwarding
|
||||
EXPECT_EQ(clock.now, dispatcher.getLastMeshCoreRecvMillis());
|
||||
EXPECT_FALSE(watchdog.expired(clock.now, dispatcher.getLastMeshCoreRecvMillis(), timeout));
|
||||
|
||||
clock.now += 32000;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(1, dispatcher.received_packets);
|
||||
EXPECT_EQ(timeout + 1, dispatcher.getLastMeshCoreRecvMillis());
|
||||
clock.now = 2 * timeout + 1;
|
||||
EXPECT_TRUE(watchdog.expired(clock.now, dispatcher.getLastMeshCoreRecvMillis(), timeout));
|
||||
}
|
||||
|
||||
TEST(Dispatcher, RepeatedMeshCorePacketsFeedClockButTxAndStatsResetDoNot) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.begin();
|
||||
const uint8_t raw[] = {
|
||||
ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT), 0, 0x42
|
||||
};
|
||||
for (unsigned long received_at : {100UL, 200UL}) {
|
||||
clock.now = received_at;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(received_at, dispatcher.getLastMeshCoreRecvMillis());
|
||||
}
|
||||
|
||||
clock.now = 300;
|
||||
auto* outbound = dispatcher.obtainNewPacket();
|
||||
ASSERT_NE(outbound, nullptr);
|
||||
ASSERT_TRUE(outbound->readFrom(raw, sizeof(raw)));
|
||||
ASSERT_TRUE(dispatcher.sendPacket(outbound, 0));
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(1, radio.send_starts);
|
||||
radio.send_complete = true;
|
||||
clock.now = 301;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(1, dispatcher.completed_packets);
|
||||
dispatcher.resetStats();
|
||||
radio.last_irq = ++clock.now;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(200U, dispatcher.getLastMeshCoreRecvMillis());
|
||||
}
|
||||
|
||||
TEST(Dispatcher, UnparsedPacketWhenPoolIsFullDoesNotFeedMeshCoreRxClock) {
|
||||
RxReservePacketManager manager(1, 0);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.begin();
|
||||
auto* held = manager.allocNew();
|
||||
ASSERT_NE(held, nullptr);
|
||||
const uint8_t raw[] = {
|
||||
ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT), 0, 0x42
|
||||
};
|
||||
clock.now = 100;
|
||||
radio.queueRx(raw, sizeof(raw));
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(0U, dispatcher.getLastMeshCoreRecvMillis());
|
||||
EXPECT_EQ(0, dispatcher.received_packets);
|
||||
manager.free(held);
|
||||
}
|
||||
|
||||
TEST(Dispatcher, ScopeRewriteHookBypassesConfiguredRxDelay) {
|
||||
RxReservePacketManager manager(4, 1);
|
||||
TestClock clock;
|
||||
|
||||
@@ -41,11 +41,11 @@ TEST(RadioLivenessTracker, StagesSoftThenHardRecovery) {
|
||||
EXPECT_EQ(tracker.poll(37000, 1000, 5000), RadioRecoveryAction::SOFT);
|
||||
}
|
||||
|
||||
TEST(RadioLivenessTracker, HardwareActivityCancelsEscalation) {
|
||||
TEST(RadioLivenessTracker, SuccessfulReceiveCancelsEscalation) {
|
||||
RadioLivenessTracker tracker;
|
||||
tracker.begin(0);
|
||||
EXPECT_EQ(tracker.poll(1000, 1000, 5000), RadioRecoveryAction::SOFT);
|
||||
tracker.noteActivity(1200);
|
||||
tracker.noteReceive(1200);
|
||||
EXPECT_EQ(tracker.stage(), 0);
|
||||
EXPECT_EQ(tracker.poll(2199, 1000, 5000), RadioRecoveryAction::NONE);
|
||||
EXPECT_EQ(tracker.poll(2200, 1000, 5000), RadioRecoveryAction::SOFT);
|
||||
@@ -77,10 +77,21 @@ public:
|
||||
bool receiving = true;
|
||||
bool cad_busy = false;
|
||||
bool broken_rx = false;
|
||||
bool pending_rx = false;
|
||||
unsigned long last_irq = 0;
|
||||
int completed = 0;
|
||||
int recoveries = 0;
|
||||
int recvRaw(uint8_t*, int) override {
|
||||
int soft_recoveries = 0;
|
||||
int hard_recoveries = 0;
|
||||
int recvRaw(uint8_t* raw, int) override {
|
||||
if (!broken_rx) receiving = true;
|
||||
if (pending_rx) {
|
||||
pending_rx = false;
|
||||
raw[0] = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
|
||||
raw[1] = 0;
|
||||
raw[2] = 0x42;
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
uint32_t getEstAirtimeFor(int) override { return 1000; }
|
||||
@@ -91,14 +102,17 @@ public:
|
||||
}
|
||||
bool isSendComplete() override { ++completed; return true; }
|
||||
void onSendFinished() override { receiving = false; }
|
||||
unsigned long getLastRadioInterruptMillis() const override { return last_irq; }
|
||||
bool isInRecvMode() const override { return receiving; }
|
||||
bool isReceiving() override {
|
||||
// Active CAD may leave the radio outside RX until the next recvRaw().
|
||||
if (cad_busy) receiving = false;
|
||||
return cad_busy;
|
||||
}
|
||||
bool recoverRadio(bool) override {
|
||||
bool recoverRadio(bool hard) override {
|
||||
++recoveries;
|
||||
if (hard) ++hard_recoveries;
|
||||
else ++soft_recoveries;
|
||||
receiving = !broken_rx;
|
||||
return !broken_rx;
|
||||
}
|
||||
@@ -112,7 +126,139 @@ public:
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; }
|
||||
uint16_t errors() const { return _err_flags; }
|
||||
};
|
||||
|
||||
constexpr unsigned long SOFT_RX_TIMEOUT = 30UL * 60UL * 1000UL;
|
||||
constexpr unsigned long HARD_RX_TIMEOUT = 12UL * 60UL * 60UL * 1000UL;
|
||||
|
||||
void queueTransmission(WatchdogDispatcher& dispatcher, StaticPoolPacketManager& packets) {
|
||||
auto* packet = packets.allocNew();
|
||||
ASSERT_NE(packet, nullptr);
|
||||
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT);
|
||||
packet->path_len = 0;
|
||||
packet->payload_len = 1;
|
||||
packet->payload[0] = 0;
|
||||
ASSERT_TRUE(dispatcher.sendPacket(packet, 0));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(DispatcherRadioWatchdog, TransmitSuccessDoesNotPostponeRxRecovery) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(2);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT - 100;
|
||||
queueTransmission(dispatcher, packets);
|
||||
dispatcher.loop();
|
||||
clock.now = SOFT_RX_TIMEOUT - 50;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.completed, 1);
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
EXPECT_EQ(radio.hard_recoveries, 0);
|
||||
}
|
||||
|
||||
TEST(DispatcherRadioWatchdog, InterruptsWithoutReceivedPacketsDoNotPostponeRecovery) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(2);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = 1000;
|
||||
radio.last_irq = clock.now;
|
||||
dispatcher.loop();
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
radio.last_irq = clock.now;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
}
|
||||
|
||||
TEST(DispatcherRadioWatchdog, ReceivedPacketAtDeadlinePreventsUnnecessaryRecovery) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(2);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
radio.pending_rx = true;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.recoveries, 0);
|
||||
|
||||
clock.now += SOFT_RX_TIMEOUT - 100;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.recoveries, 0);
|
||||
clock.now += 200;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
}
|
||||
|
||||
TEST(DispatcherRadioWatchdog, ReceivedPacketCancelsRecoveryEscalation) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(2);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.soft_recoveries, 1);
|
||||
clock.now += 100;
|
||||
radio.pending_rx = true;
|
||||
dispatcher.loop();
|
||||
clock.now += SOFT_RX_TIMEOUT - 100;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
clock.now += 200;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.soft_recoveries, 2);
|
||||
EXPECT_EQ(radio.hard_recoveries, 0);
|
||||
}
|
||||
|
||||
TEST(DispatcherRadioWatchdog, ContinuousTransmitQueueCannotStarveRxRecovery) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(4);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT - 100;
|
||||
for (int i = 0; i < 3; ++i) queueTransmission(dispatcher, packets);
|
||||
dispatcher.loop();
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.completed, 1);
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
clock.now += 100;
|
||||
dispatcher.loop();
|
||||
EXPECT_EQ(radio.completed, 2);
|
||||
EXPECT_EQ(radio.soft_recoveries, 1);
|
||||
}
|
||||
|
||||
#ifndef RADIO_LIVENESS_SOFT_ONLY
|
||||
TEST(DispatcherRadioWatchdog, TransmitSuccessDoesNotPreventHardRxRecovery) {
|
||||
AdvancingClock clock;
|
||||
BurstRadio radio;
|
||||
StaticPoolPacketManager packets(2);
|
||||
WatchdogDispatcher dispatcher(radio, clock, packets);
|
||||
dispatcher.begin();
|
||||
|
||||
clock.now = SOFT_RX_TIMEOUT + 100;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.soft_recoveries, 1);
|
||||
clock.now = HARD_RX_TIMEOUT - 100;
|
||||
queueTransmission(dispatcher, packets);
|
||||
dispatcher.loop();
|
||||
clock.now = HARD_RX_TIMEOUT + 100;
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.completed, 1);
|
||||
EXPECT_EQ(radio.hard_recoveries, 1);
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(DispatcherRadioWatchdog, SuccessfulTxBurstDoesNotTriggerStuckRxRecovery) {
|
||||
AdvancingClock clock;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
|
||||
using mesh::RepeaterRadioTiming;
|
||||
using mesh::RxInactivityWatchdog;
|
||||
|
||||
constexpr uint32_t HOUR = RepeaterRadioTiming::HOUR_SECS;
|
||||
constexpr uint32_t HOUR_MS = RepeaterRadioTiming::HOUR_MS;
|
||||
|
||||
TEST(RepeaterRadioTiming, NormalDefaultsTo24HoursWithSavedAdvertIntervals) {
|
||||
RepeaterRadioTiming timing;
|
||||
EXPECT_TRUE(RepeaterRadioTiming::DEFAULT_RX_WATCHDOG_ENABLED);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 24 * HOUR_MS);
|
||||
EXPECT_EQ(timing.watchdogMillis(false), 0U);
|
||||
EXPECT_EQ(timing.localAdvertMinutes(120), 120U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(24), 24U);
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, ShortSessionsDisableWatchdogAndKeepNormalFloodInterval) {
|
||||
for (uint32_t seconds : {1U, HOUR, 3 * HOUR}) {
|
||||
RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(seconds);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 0U);
|
||||
EXPECT_EQ(timing.localAdvertMinutes(0), 60U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(24), 24U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(0), 0U);
|
||||
EXPECT_STREQ(timing.watchdogLabel(true), "off (temp<12hours)");
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, ThreeHourFloodOverrideUsesStrictGreaterThanBoundary) {
|
||||
RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(3 * HOUR + 1);
|
||||
EXPECT_EQ(timing.floodAdvertHours(24), 3U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(0), 3U);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 0U);
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, WatchdogUsesInclusive12HourBoundary) {
|
||||
RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(12 * HOUR - 1);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 0U);
|
||||
timing.setTempDuration(12 * HOUR);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 12 * HOUR_MS);
|
||||
EXPECT_EQ(timing.watchdogMillis(false), 12 * HOUR_MS);
|
||||
timing.setTempDuration(48 * HOUR);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 12 * HOUR_MS);
|
||||
EXPECT_STREQ(timing.watchdogLabel(true), "12hours");
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, EndingOrRebootingClearsAllOverrides) {
|
||||
RepeaterRadioTiming timing;
|
||||
timing.setTempDuration(48 * HOUR);
|
||||
timing.setTempDuration(0);
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 24 * HOUR_MS);
|
||||
EXPECT_EQ(timing.localAdvertMinutes(120), 120U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(24), 24U);
|
||||
EXPECT_EQ(timing.floodAdvertHours(0), 0U);
|
||||
timing.setTempDuration(48 * HOUR);
|
||||
timing = RepeaterRadioTiming();
|
||||
EXPECT_FALSE(timing.isTemporary());
|
||||
EXPECT_EQ(timing.watchdogMillis(true), 24 * HOUR_MS);
|
||||
EXPECT_EQ(timing.localAdvertMinutes(0), 0U);
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, RepliesIncludeDaysHoursMinutesAndDisabledReason) {
|
||||
char text[160];
|
||||
RepeaterRadioTiming::formatDuration(text, sizeof(text), 90 * 60);
|
||||
EXPECT_STREQ(text, "90 mins (0d1h30m)");
|
||||
RepeaterRadioTiming::appendTempWatchdogNote(text, sizeof(text), 90 * 60);
|
||||
EXPECT_STREQ(text, "90 mins (0d1h30m); rx.watchdog=off (temp<12hours)");
|
||||
RepeaterRadioTiming::formatDuration(text, sizeof(text), 24 * HOUR);
|
||||
RepeaterRadioTiming::appendTempWatchdogNote(text, sizeof(text), 24 * HOUR);
|
||||
EXPECT_STREQ(text, "1440 mins (1d0h0m); rx.watchdog=12hours");
|
||||
RepeaterRadioTiming::formatDuration(text, sizeof(text), 3 * HOUR + 1);
|
||||
EXPECT_STREQ(text, "180 mins 1 sec (0d3h0m)");
|
||||
RepeaterRadioTiming::formatDuration(text, sizeof(text), 3075 * 60);
|
||||
EXPECT_STREQ(text, "3075 mins (2d3h15m)");
|
||||
RepeaterRadioTiming::formatDuration(text, sizeof(text), 60);
|
||||
EXPECT_STREQ(text, "1 mins (0d0h1m)");
|
||||
}
|
||||
|
||||
TEST(RepeaterRadioTiming, ReplyFormattingBoundsOutputAndLargeDurations) {
|
||||
struct Buffer { char text[12]; char guard; } output = {{}, '!'};
|
||||
RepeaterRadioTiming::formatDuration(output.text, sizeof(output.text), UINT32_MAX);
|
||||
EXPECT_EQ(output.text[11], '\0');
|
||||
EXPECT_EQ(output.guard, '!');
|
||||
RepeaterRadioTiming::appendTempWatchdogNote(output.text, sizeof(output.text), 24 * HOUR);
|
||||
EXPECT_EQ(output.guard, '!');
|
||||
char large[160];
|
||||
RepeaterRadioTiming::formatDuration(large, sizeof(large), UINT32_MAX);
|
||||
EXPECT_STREQ(large, "71582788 mins 15 sec (49710d6h28m)");
|
||||
}
|
||||
|
||||
TEST(RxInactivityWatchdog, RebootsAfter24HoursWithoutRxRatherThanOnPeriodicCheck) {
|
||||
RxInactivityWatchdog watchdog;
|
||||
EXPECT_FALSE(watchdog.expired(1000, 0, 24 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(24 * HOUR_MS + 999, 0, 24 * HOUR_MS));
|
||||
EXPECT_TRUE(watchdog.expired(24 * HOUR_MS + 1000, 0, 24 * HOUR_MS));
|
||||
}
|
||||
|
||||
TEST(RxInactivityWatchdog, SuccessfulReceiveMovesDeadlineAndTransitionStartsFreshWindow) {
|
||||
RxInactivityWatchdog watchdog;
|
||||
watchdog.expired(1000, 500, 24 * HOUR_MS);
|
||||
EXPECT_FALSE(watchdog.expired(20 * HOUR_MS, 20 * HOUR_MS, 24 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(24 * HOUR_MS + 1000, 20 * HOUR_MS, 24 * HOUR_MS));
|
||||
EXPECT_TRUE(watchdog.expired(44 * HOUR_MS, 20 * HOUR_MS, 24 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(44 * HOUR_MS, 20 * HOUR_MS, 12 * HOUR_MS));
|
||||
EXPECT_TRUE(watchdog.expired(56 * HOUR_MS, 20 * HOUR_MS, 12 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(56 * HOUR_MS, 20 * HOUR_MS, 0));
|
||||
EXPECT_FALSE(watchdog.expired(80 * HOUR_MS, 20 * HOUR_MS, 24 * HOUR_MS));
|
||||
}
|
||||
|
||||
TEST(RxInactivityWatchdog, SameLengthReplacementAndManualEnableRestartObservation) {
|
||||
RxInactivityWatchdog watchdog;
|
||||
watchdog.expired(100, 0, 12 * HOUR_MS);
|
||||
watchdog.reset();
|
||||
EXPECT_FALSE(watchdog.expired(11 * HOUR_MS, 0, 12 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(12 * HOUR_MS + 100, 0, 12 * HOUR_MS));
|
||||
EXPECT_TRUE(watchdog.expired(23 * HOUR_MS, 0, 12 * HOUR_MS));
|
||||
}
|
||||
|
||||
TEST(RxInactivityWatchdog, ReceiveAndDeadlinesSurviveMillisRollover) {
|
||||
RxInactivityWatchdog watchdog;
|
||||
const uint32_t start = UINT32_MAX - HOUR_MS;
|
||||
EXPECT_FALSE(watchdog.expired(start, start - 1, 24 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(start + HOUR_MS + 1, 0, 24 * HOUR_MS));
|
||||
EXPECT_FALSE(watchdog.expired(24 * HOUR_MS - 1, 0, 24 * HOUR_MS));
|
||||
EXPECT_TRUE(watchdog.expired(24 * HOUR_MS, 0, 24 * HOUR_MS));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production repeater scheduling, watchdog and advert service methods."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def method(text, signature):
|
||||
start = text.index(signature)
|
||||
end = text.index("{", start) + 1
|
||||
depth = 1
|
||||
while depth:
|
||||
depth += (text[end] == "{") - (text[end] == "}")
|
||||
end += 1
|
||||
return text[start:end]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
#include <helpers/TempRadioLeaseDeadline.h>
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
#define MESH_ENABLE_TELEMETRY_HISTORY 0
|
||||
#define MAX_SCHEDULED_RADIO_SETTINGS 8
|
||||
#define MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE 4
|
||||
#define SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS 60
|
||||
#define RADIO_APPLY_RETRY_INTERVAL_MILLIS 100
|
||||
static uint32_t now_ms = 1;
|
||||
uint32_t millis() { return now_ms; }
|
||||
namespace mesh {
|
||||
struct Packet {};
|
||||
int clampLoRaTxPower(int power, float) { return power; }
|
||||
}
|
||||
struct CommonCLI {
|
||||
template <typename T> static void recalculateRxPowerSavingFromLevel(T*) {}
|
||||
};
|
||||
uint32_t nextRadioApplyRetryDelay(uint8_t& failures) { ++failures; return 100; }
|
||||
struct RadioDriver {
|
||||
void setRxBoostedGainMode(bool) {}
|
||||
void setTxPower(int) {}
|
||||
} radio_driver;
|
||||
struct RTC {
|
||||
uint32_t now = 100000;
|
||||
uint32_t getCurrentTime() const { return now; }
|
||||
};
|
||||
struct Board { int reboots = 0; void reboot() { ++reboots; } };
|
||||
struct CLI { Board board; Board* getBoard() { return &board; } };
|
||||
struct Radio { uint32_t last_rx = 0; uint32_t getLastRecvMillis() { return last_rx; } };
|
||||
struct Barrier { bool held = false; bool waiting() const { return held; } void clear() { held = false; } };
|
||||
struct ScheduledRadioSetting {
|
||||
bool active = false, temporary = false, started = false;
|
||||
float freq = 0, bw = 0;
|
||||
uint8_t sf = 0, cr = 0;
|
||||
uint32_t start_time = 0, end_time = 0;
|
||||
uint64_t hard_end_uptime_millis = 0;
|
||||
};
|
||||
class MyMesh {
|
||||
public:
|
||||
struct Prefs {
|
||||
bool rx_watchdog_enabled = true, rx_boosted_gain = true;
|
||||
uint8_t advert_interval = 60, flood_advert_interval = 24, path_hash_mode = 0;
|
||||
float freq = 909.5f, bw = 62.5f;
|
||||
uint8_t sf = 7, cr = 5;
|
||||
int tx_power_dbm = 20;
|
||||
} _prefs;
|
||||
CLI _cli;
|
||||
Radio radio; Radio* _radio = &radio;
|
||||
uint32_t last_meshcore_rx = 0;
|
||||
uint32_t getLastMeshCoreRecvMillis() const { return last_meshcore_rx; }
|
||||
RTC rtc;
|
||||
Barrier temp_radio_reply_barrier;
|
||||
mesh::RxInactivityWatchdog rx_inactivity_watchdog;
|
||||
mesh::RepeaterRadioTiming radio_timing;
|
||||
ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS];
|
||||
uint64_t uptime_millis = 1;
|
||||
uint32_t last_millis = 1;
|
||||
uint32_t next_local_advert = 0, next_flood_advert = 0;
|
||||
bool saved_radio_apply_pending = false, temp_radio_handoff_pending = false;
|
||||
bool temp_radio_applied = false, scheduled_temp_radio_started = false;
|
||||
uint32_t next_scheduled_radio_time = 0, next_scheduled_radio_check_at = 0;
|
||||
uint32_t scheduled_temp_radio_end_time = 0, scheduled_temp_radio_end_check_at = 0;
|
||||
bool scheduled_temp_radio_end_check_final = false;
|
||||
uint32_t scheduled_radio_retry_at = 0;
|
||||
uint8_t scheduled_radio_retry_failures = 0;
|
||||
bool pending_self_advert = false, pending_self_advert_flood = false;
|
||||
uint32_t pending_self_advert_delay = 0;
|
||||
bool outbound = false, apply_success = true, restore_success = true;
|
||||
int saves = 0, applies = 0, restores = 0, local_adverts = 0, flood_adverts = 0;
|
||||
int default_scope = 0;
|
||||
mesh::Packet packet;
|
||||
MyMesh() { now_ms = 1; updateAdvertTimer(); updateFloodAdvertTimer(); }
|
||||
RTC* getRTCClock() const { return const_cast<RTC*>(&rtc); }
|
||||
uint32_t futureMillis(uint32_t delay) const { return now_ms + delay; }
|
||||
bool millisHasNowPassed(uint32_t deadline) const { return (int32_t)(now_ms - deadline) >= 0; }
|
||||
bool hasOutbound() const { return outbound; }
|
||||
bool hasStartedScheduledTempRadio() const { return scheduled_temp_radio_started; }
|
||||
bool applyRadioParams(float, float, uint8_t, uint8_t) { ++applies; return apply_success; }
|
||||
bool applySavedRadioParams() { ++restores; return restore_success; }
|
||||
bool isValidScheduledRadioParams(float, float, uint8_t, uint8_t) { return true; }
|
||||
void savePrefs() { ++saves; }
|
||||
int getScheduledRadioSettingIndex(bool, int slot) const { return slot + 1; }
|
||||
void formatScheduledRadioDuration(char* dest, size_t size, uint32_t when) const {
|
||||
snprintf(dest, size, "%lus", (unsigned long)(when - rtc.now));
|
||||
}
|
||||
void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) const {
|
||||
mesh::RepeaterRadioTiming::appendTempWatchdogNote(reply, size, seconds);
|
||||
}
|
||||
void advance(uint32_t seconds) {
|
||||
now_ms += seconds * 1000UL;
|
||||
uptime_millis += (uint64_t)seconds * 1000UL;
|
||||
last_millis = now_ms;
|
||||
rtc.now += seconds;
|
||||
}
|
||||
mesh::Packet* createSelfAdvert() { return &packet; }
|
||||
void sendFloodScoped(int, mesh::Packet*, uint32_t, int) { ++flood_adverts; }
|
||||
void sendZeroHop(mesh::Packet*) { ++local_adverts; }
|
||||
void sendSelfAdvertisementNow(uint32_t, bool) {}
|
||||
void checkBatteryAlert() {}
|
||||
void expireRecentRepeatersIfDue() {}
|
||||
void checkRxInactivityWatchdog();
|
||||
void setTempRadioTiming(uint32_t);
|
||||
void updateAdvertTimer();
|
||||
void updateFloodAdvertTimer();
|
||||
void queueSavedRadioApply();
|
||||
void refreshScheduledRadioState();
|
||||
void processScheduledRadioSettings();
|
||||
void applyTempRadioParams(float, float, uint8_t, uint8_t, int);
|
||||
bool scheduleNormalRadio();
|
||||
void clearScheduledRadioSetting(int, bool);
|
||||
int findFreeScheduledRadioSlot() const;
|
||||
int countScheduledRadioSettings(bool) const;
|
||||
bool scheduledRadioConflicts(bool, uint32_t, uint32_t) const;
|
||||
void addScheduledRadioParams(bool, float, float, uint8_t, uint8_t, uint32_t, uint32_t, char*);
|
||||
void servicePostMeshLoop();
|
||||
};
|
||||
@METHODS@
|
||||
static constexpr uint32_t HOUR = 3600;
|
||||
static void startTemp(MyMesh& m, int minutes) {
|
||||
m.applyTempRadioParams(910, 250, 5, 5, minutes);
|
||||
m.advance(2);
|
||||
m.servicePostMeshLoop();
|
||||
assert(m.temp_radio_applied);
|
||||
}
|
||||
int main() {
|
||||
{ // Pending schedules do not change current timings, even on an apply failure.
|
||||
MyMesh m;
|
||||
const uint32_t normal_local = m.next_local_advert;
|
||||
char reply[160];
|
||||
m.addScheduledRadioParams(true, 910, 250, 5, 5, m.rtc.now + 60,
|
||||
m.rtc.now + 60 + 24 * HOUR, reply);
|
||||
assert(strstr(reply, "1440 mins (1d0h0m); rx.watchdog=12hours"));
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
assert(m.next_local_advert == normal_local);
|
||||
m.apply_success = false;
|
||||
m.advance(60); m.servicePostMeshLoop();
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
m.apply_success = true;
|
||||
m.advance(1); m.servicePostMeshLoop();
|
||||
assert(m.radio_timing.tempDuration() == 24 * HOUR);
|
||||
assert(m.next_local_advert == now_ms + HOUR * 1000);
|
||||
assert(m.next_flood_advert == now_ms + 3 * HOUR * 1000);
|
||||
assert(m.saves == 0);
|
||||
assert(m._prefs.advert_interval == 60 && m._prefs.flood_advert_interval == 24);
|
||||
}
|
||||
{ // The exact-reply barrier must still delay activation and its overrides.
|
||||
MyMesh m;
|
||||
m.applyTempRadioParams(910, 250, 5, 5, 1440);
|
||||
m.temp_radio_reply_barrier.held = true;
|
||||
m.advance(3); m.servicePostMeshLoop();
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
m.temp_radio_reply_barrier.clear();
|
||||
m.advance(1); m.servicePostMeshLoop();
|
||||
assert(m.radio_timing.tempDuration() == 24 * HOUR);
|
||||
}
|
||||
{ // Both adverts are sent at hour three; hourly local adverts cannot vanish.
|
||||
MyMesh m; startTemp(m, 1440);
|
||||
for (int hour = 1; hour <= 6; ++hour) { m.advance(HOUR); m.servicePostMeshLoop(); }
|
||||
assert(m.local_adverts == 6 && m.flood_adverts == 2);
|
||||
assert(m._cli.board.reboots == 0 && m.saves == 0);
|
||||
}
|
||||
{ // A short session keeps the normal flood setting, including disabled.
|
||||
MyMesh m;
|
||||
m._prefs.flood_advert_interval = 0;
|
||||
m._prefs.advert_interval = 0;
|
||||
startTemp(m, 180);
|
||||
assert(m.next_flood_advert == 0);
|
||||
assert(m.radio_timing.watchdogMillis(true) == 0);
|
||||
m.advance(HOUR); m.servicePostMeshLoop();
|
||||
assert(m.local_adverts == 1 && m.flood_adverts == 0);
|
||||
m.advance(2 * HOUR); m.servicePostMeshLoop();
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
assert(m.next_local_advert == 0 && m.next_flood_advert == 0);
|
||||
assert(m.radio_timing.watchdogMillis(true) == 24 * HOUR * 1000);
|
||||
}
|
||||
{ // At exactly 12 hours, expiry wins over the temporary watchdog deadline.
|
||||
MyMesh m; startTemp(m, 720);
|
||||
m.advance(12 * HOUR); m.servicePostMeshLoop();
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
assert(m._cli.board.reboots == 0);
|
||||
m.advance(24 * HOUR); m.servicePostMeshLoop();
|
||||
assert(m._cli.board.reboots == 1);
|
||||
}
|
||||
{ // A longer session really does reboot after a 12-hour no-RX window.
|
||||
MyMesh m; startTemp(m, 1440);
|
||||
m.advance(12 * HOUR); m.servicePostMeshLoop();
|
||||
assert(m._cli.board.reboots == 1);
|
||||
}
|
||||
{ // Replacing an active session retains old policy until the new tuple applies.
|
||||
MyMesh m; startTemp(m, 1440);
|
||||
m.applyTempRadioParams(911, 250, 5, 5, 60);
|
||||
assert(m.radio_timing.tempDuration() == 24 * HOUR);
|
||||
m.advance(2); m.servicePostMeshLoop();
|
||||
assert(m.radio_timing.tempDuration() == HOUR);
|
||||
assert(m.radio_timing.watchdogMillis(true) == 0);
|
||||
}
|
||||
{ // Failed restoration must not leave a short session's watchdog disabled.
|
||||
MyMesh m; startTemp(m, 60);
|
||||
m.restore_success = false;
|
||||
m.advance(HOUR); m.servicePostMeshLoop();
|
||||
assert(m.temp_radio_applied); // hardware restoration is still pending
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
assert(m.radio_timing.watchdogMillis(true) == 24 * HOUR * 1000);
|
||||
m.restore_success = true;
|
||||
m.advance(1); m.servicePostMeshLoop();
|
||||
assert(!m.temp_radio_applied && m.saves == 0);
|
||||
}
|
||||
{ // Manual cancellation restores saved timings without saving temp overrides.
|
||||
MyMesh m; startTemp(m, 1440);
|
||||
assert(m.scheduleNormalRadio());
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
m.servicePostMeshLoop();
|
||||
assert(!m.temp_radio_applied && m.saves == 0);
|
||||
assert(m.next_local_advert == now_ms + 2 * HOUR * 1000);
|
||||
assert(m.next_flood_advert == now_ms + 24 * HOUR * 1000);
|
||||
}
|
||||
{ // Deleting an active scheduled entry has the same restoration behavior.
|
||||
MyMesh m; startTemp(m, 1440);
|
||||
m.clearScheduledRadioSetting(0, true);
|
||||
assert(!m.radio_timing.isTemporary());
|
||||
m.servicePostMeshLoop();
|
||||
assert(!m.temp_radio_applied && m.saves == 0);
|
||||
}
|
||||
{ // Adjacent scheduled windows adopt the new duration at the boundary.
|
||||
MyMesh m;
|
||||
char reply[160];
|
||||
uint32_t start = m.rtc.now + 2;
|
||||
m.addScheduledRadioParams(true, 910, 250, 5, 5, start, start + HOUR, reply);
|
||||
m.addScheduledRadioParams(true, 911, 250, 5, 5, start + HOUR, start + 25 * HOUR, reply);
|
||||
m.advance(2); m.servicePostMeshLoop();
|
||||
assert(m.radio_timing.tempDuration() == HOUR);
|
||||
m.advance(HOUR); m.servicePostMeshLoop();
|
||||
assert(m.radio_timing.tempDuration() == 24 * HOUR);
|
||||
assert(m._cli.board.reboots == 0 && m.saves == 0);
|
||||
}
|
||||
{ // Raw driver receptions must not keep the whole-board watchdog alive.
|
||||
MyMesh m; m.servicePostMeshLoop();
|
||||
m.advance(23 * HOUR);
|
||||
m.radio.last_rx = now_ms;
|
||||
m.servicePostMeshLoop();
|
||||
m.advance(HOUR);
|
||||
m.radio.last_rx = now_ms;
|
||||
m.servicePostMeshLoop();
|
||||
assert(m._cli.board.reboots == 1);
|
||||
}
|
||||
{ // A parsed MeshCore reception moves the actual repeater reboot deadline.
|
||||
MyMesh m; m.servicePostMeshLoop();
|
||||
m.advance(23 * HOUR);
|
||||
m.last_meshcore_rx = now_ms;
|
||||
m.servicePostMeshLoop();
|
||||
m.advance(HOUR); m.servicePostMeshLoop();
|
||||
assert(m._cli.board.reboots == 0);
|
||||
m.advance(23 * HOUR); m.servicePostMeshLoop();
|
||||
assert(m._cli.board.reboots == 1);
|
||||
}
|
||||
puts("13 production scheduler/watchdog/advert scenarios passed");
|
||||
}
|
||||
'''
|
||||
|
||||
CLI_HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <helpers/RepeaterRadioTiming.h>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
namespace mesh {
|
||||
class Utils { public: static int parseTextParts(char*, const char*[], int, char separator=','); };
|
||||
@PARTS@
|
||||
}
|
||||
@PARSERS@
|
||||
struct RTC { uint32_t getCurrentTime() const { return 1789160000UL; } } rtc;
|
||||
RTC* getRTCClock() { return &rtc; }
|
||||
struct Callbacks {
|
||||
int calls = 0;
|
||||
uint32_t duration = 0;
|
||||
void applyTempRadioParams(float, float, uint8_t, uint8_t, int minutes) {
|
||||
++calls; duration = (uint32_t)minutes * 60;
|
||||
}
|
||||
void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) {
|
||||
mesh::RepeaterRadioTiming::appendTempWatchdogNote(reply, size, seconds);
|
||||
}
|
||||
} callbacks;
|
||||
void appendRxPowerSavingAdjustmentNote(char*, const void*, uint8_t, float) {}
|
||||
void handle(const char* command, char* reply) {
|
||||
char tmp[160];
|
||||
Callbacks* _callbacks = &callbacks;
|
||||
const void* _prefs = nullptr;
|
||||
if (false) {
|
||||
@BRANCH@
|
||||
}
|
||||
}
|
||||
int main() {
|
||||
struct Reply { char text[160]; char guard = '!'; } reply;
|
||||
handle("tempradio 910,250,5,5,1440", reply.text);
|
||||
assert(!strcmp(reply.text, "OK - temp params for 1440 mins (1d0h0m); rx.watchdog=12hours"));
|
||||
assert(!strncmp(reply.text, "OK - temp params for ", 21)); // exact reply-barrier marker
|
||||
handle("tempradio 910,250,5,5,90", reply.text);
|
||||
assert(!strcmp(reply.text, "OK - temp params for 90 mins (0d1h30m); rx.watchdog=off (temp<12hours)"));
|
||||
assert(callbacks.calls == 2 && callbacks.duration == 5400);
|
||||
for (const char* minutes : {"0", "-1", "12oops", "1.5", "4294967295", "4294967296"}) {
|
||||
char command[100];
|
||||
snprintf(command, sizeof(command), "tempradio 910,250,5,5,%s", minutes);
|
||||
handle(command, reply.text);
|
||||
assert(!strcmp(reply.text, "Error, invalid params"));
|
||||
assert(callbacks.calls == 2);
|
||||
}
|
||||
assert(reply.guard == '!');
|
||||
puts("production TempRadio replies and duration validation passed");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class RepeaterRadioTimingIntegrationTest(unittest.TestCase):
|
||||
def compile_and_run(self, source):
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-temp-timing-") as temp:
|
||||
cpp = Path(temp) / "test.cpp"
|
||||
exe = Path(temp) / ("test.exe" if os.name == "nt" else "test")
|
||||
cpp.write_text(source, encoding="utf-8")
|
||||
subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-I", str(ROOT / "src"),
|
||||
str(cpp), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
def test_production_scheduler_and_services(self):
|
||||
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="utf-8")
|
||||
signatures = [
|
||||
"void MyMesh::checkRxInactivityWatchdog()",
|
||||
"void MyMesh::setTempRadioTiming(",
|
||||
"void MyMesh::updateAdvertTimer()",
|
||||
"void MyMesh::updateFloodAdvertTimer()",
|
||||
"void MyMesh::queueSavedRadioApply()",
|
||||
"void MyMesh::refreshScheduledRadioState()",
|
||||
"void MyMesh::processScheduledRadioSettings()",
|
||||
"void MyMesh::applyTempRadioParams(",
|
||||
"bool MyMesh::scheduleNormalRadio()",
|
||||
"void MyMesh::clearScheduledRadioSetting(",
|
||||
"int MyMesh::findFreeScheduledRadioSlot() const",
|
||||
"int MyMesh::countScheduledRadioSettings(",
|
||||
"bool MyMesh::scheduledRadioConflicts(",
|
||||
"void MyMesh::addScheduledRadioParams(",
|
||||
]
|
||||
methods = "\n".join(method(source, signature) for signature in signatures)
|
||||
post = method(source, "void __attribute__((noinline)) MyMesh::servicePostMeshLoop()")
|
||||
# The remaining service tail handles unrelated MQTT/OTA/peripheral work.
|
||||
post = post[:post.index("#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)")] + "}\n"
|
||||
methods += "\n" + post
|
||||
self.compile_and_run(HARNESS.replace("@METHODS@", methods))
|
||||
|
||||
def test_production_cli_replies_and_duration_validation(self):
|
||||
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8")
|
||||
parsers = "\n".join(method(source, signature) for signature in [
|
||||
"static bool looksUnsignedInteger(", "static const char* skipSpacesConst(",
|
||||
"static bool parseUint32Strict(", "static bool bwMatches(",
|
||||
"static bool isValidLoRaBandwidth(",
|
||||
])
|
||||
start = source.index(' } else if (memcmp(command, "tempradio ", 10) == 0)')
|
||||
end = source.index(' } else if (memcmp(command, "password ", 9) == 0)', start)
|
||||
utils = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8")
|
||||
parts = method(utils, "int Utils::parseTextParts(")
|
||||
self.compile_and_run(CLI_HARNESS.replace("@PARSERS@", parsers)
|
||||
.replace("@BRANCH@", source[start:end])
|
||||
.replace("@PARTS@", parts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user