feat: prepare v1.16.07 firmware release

This commit is contained in:
mikecarper committed 2026-07-18 14:53:56 -07:00
1 parent 129e687c02
commit ccce0c8d75
95 files changed
+5550 -278

No files matched your search

+63 -5
View File
@@ -4,7 +4,64 @@ This document describes the MQTT bridge implementation that allows MeshCore repe
## Quick Start Guide
### Essential Commands to Get MQTT Observer Running
### Browser setup (recommended)
Normal ESP32 MQTT repeater-, room-server-, and WiFi-companion builds include an
all-in-one WebConfig portal. The same page is used by non-MQTT ESP32 builds,
where the MQTT tab and wizard step are removed at runtime.
1. Flash an observer build such as `heltec_v4_repeater_observer_mqtt`.
2. On a fresh node with no saved WiFi SSID, join the open
`MeshCore-Setup-XXXX` access point. The captive page should open
automatically; otherwise browse to <http://192.168.4.1/>.
3. Complete the wizard, review the settings, and choose **Save & Reboot**. The
MQTT bridge remains stopped while the setup AP owns WiFi and starts normally
after the reboot.
4. Verify the connections on the portal's status page or with
`get mqtt.status` from the CLI.
The setup AP stops after 10 minutes with no connected client. WiFi companion
builds enable the WebUI by default. Repeater and room-server builds default it
off; use `set webui on` for a persistent start or `start webconfig` for only the
current boot. `get webui` reports the active URL.
To change an already-configured repeater/room-server observer temporarily, log
in through its CLI and run:
```text
start webconfig
```
Open the reported LAN URL and sign in with the node's admin password. The LAN
portal runs until `stop webconfig` or a reboot. To force the captive setup AP,
first stop the MQTT bridge, then start the portal in AP mode:
```text
set bridge off
start webconfig ap
```
`start webconfig ap` intentionally refuses to take WiFi away from a running
bridge. The forced AP is open, so stop it when finished and re-enable the bridge
if you did not reboot:
```text
stop webconfig
set bridge on
```
The classic 4 MB ESP32
`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` and
`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` targets omit the browser
portal because the async web-server code does not fit while retaining two app
slots for LoRa OTA. Configure those two builds with the CLI below.
MQTT WiFi companions use this same wizard instead of the former two-page setup.
The portal remains on the companion's station IP, alongside the companion
protocol on TCP port 5000. Because companions have no admin CLI password, their
LAN page is intentionally unauthenticated; use a trusted WiFi network.
### CLI setup and fallback
**1. Flash the observer firmware to your device**
@@ -153,7 +210,7 @@ pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt
pio run -e LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt
```
**TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6).
**TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so they retain dual-app OTA on the 4 MB flash; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The repeater observer also keeps 256 recent-repeater entries instead of the normal ESP32 default of 2,048. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6).
**T-LoRa V2.1–1.6 MQTT observer — one WSS broker:** This hardware is **classic ESP32 without PSRAM**. Each WSS preset uses a full TLS stack and large contiguous heap allocations; **two active broker presets at once** typically fails the second connection (`mbedtls_ssl_setup` / `esp-tls` `0x8017`, low `IntMax` in `memory`). **Treat these observer builds as supporting one active cloud preset:** configure the broker you need in `mqtt1` or `mqtt2`, and set the other slot to `none` (e.g. `set mqtt2.preset none`). Use PSRAM-capable boards if you need multiple simultaneous MQTT uplinks.
@@ -165,8 +222,8 @@ Some MQTT observer builds use a non-default partition table to accommodate the l
|-------------|----------------|------------|---------------|-------|
| `LilyGo_T3S3_sx1262_repeater_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) |
| `LilyGo_T3S3_sx1262_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) |
| `LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`. **One active WSS broker** recommended (no PSRAM; dual TLS usually fails on the second slot). |
| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | same |
| `LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` | `dual_ota_1984k.csv` | 4 MB | 1.9375 MB | 64 KB SPIFFS; no coredump partition. **One active WSS broker** recommended (no PSRAM; dual TLS usually fails on the second slot). |
| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`; one active WSS broker recommended. |
| `Station_G2_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `LilyGo_TBeam_1W_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | Set in `boards/t_beam_1w.json`; required vs implicit `default.csv` |
@@ -176,7 +233,8 @@ Some MQTT observer builds use a non-default partition table to accommodate the l
Flashing a **full merged image** (`*-merged.bin` at offset `0x0`) writes a new bootloader **and** partition table. If that layout **differs** from what is already on the device, **NVS is typically wiped or invalidated** — expect to lose stored configuration (admin preferences, WiFi, MQTT slots, name, etc.) and reconfigure from scratch.
- **`LilyGo_TLora_V2_1_1_6_*_observer_mqtt`:** These use the **same** `min_spiffs.csv` layout as other MeshCore TLora builds, so moving between repeater / room server / MQTT observer does **not** require a different partition table for normal upgrades. **If you previously installed an older TLora MQTT observer that used `huge_app.csv`,** flashing this firmware switches back to `min_spiffs` — treat that as a **partition layout change** (merged flash; NVS may be reset). **If you install MeshCore on a device that used a non-MeshCore partition map,** the first merged flash can still **wipe** settings.
- **`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt`:** This uses the custom `dual_ota_1984k.csv` layout. Install its merged image when coming from a standard TLora build, the room-server observer, or any older `huge_app.csv` build; expect to reconfigure after that partition change.
- **`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt`:** This retains the normal `min_spiffs.csv` layout. Moving from another `min_spiffs` TLora build does not itself require a partition change, but coming from the repeater observer's custom layout, `huge_app.csv`, or a non-MeshCore layout does.
- **`Station_G2_*_observer_mqtt`** and **`LilyGo_TBeam_1W_*_observer_mqtt`**: These use `default_16MB.csv` to accomodate the larger size of the MQTT observer firmware. Installing MQTT observer firmware on these devices requires a **merged** flash the first time. The same applies if you move **from** firmware that was built with a **different** partition table—the first merged flash that installs this layout will **wipe** stored settings.
**How to flash the merged firmware:**
+8 -8
View File
@@ -92,8 +92,8 @@ Heltec_t096_companion_radio_ble_femoff
# ESP32 - 20 builds
./build.sh build-firmware \
Heltec_v3_companion_radio_ble_ps \
heltec_v4_companion_radio_ble_ps_femon \
heltec_v4_3_companion_radio_ble_ps_femoff \
heltec_v4_companion_radio_ble_femon \
heltec_v4_3_companion_radio_ble_femoff \
Xiao_C3_companion_radio_ble_ps \
Xiao_S3_companion_radio_ble_ps \
Xiao_S3_WIO_companion_radio_ble_ps \
@@ -101,15 +101,15 @@ Heltec_v2_companion_radio_ble_ps \
LilyGo_TBeam_1W_companion_radio_ble_ps \
Heltec_WSL3_companion_radio_ble_ps \
Heltec_Wireless_Tracker_companion_radio_ble_ps \
heltec_tracker_v2_companion_radio_ble_ps_femon \
heltec_tracker_v2_companion_radio_ble_ps_femoff \
heltec_tracker_v2_companion_radio_ble_femon \
heltec_tracker_v2_companion_radio_ble_femoff \
Heltec_Wireless_Paper_companion_radio_ble_ps \
LilyGo_TLora_V2_1_1_6_companion_radio_ble_ps \
Heltec_ct62_companion_radio_ble_ps \
T_Beam_S3_Supreme_SX1262_companion_radio_ble_ps \
Tbeam_SX1262_companion_radio_ble_ps \
heltec_v4_expansionkit_tft_companion_radio_ble_ps_femon \
heltec_v4_3_expansionkit_tft_companion_radio_ble_ps_femoff \
heltec_v4_expansionkit_tft_companion_radio_ble_femon \
heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff \
LilyGo_T3S3_sx1262_companion_radio_ble_ps
# Not working
@@ -130,8 +130,8 @@ Xiao_C6_repeater_ \
RAK_4631_repeater \
Heltec_t096_repeater \
Heltec_v3_companion_radio_ble_ps \
heltec_v4_companion_radio_ble_ps_femon \
heltec_v4_3_companion_radio_ble_ps_femoff \
heltec_v4_companion_radio_ble_femon \
heltec_v4_3_companion_radio_ble_femoff \
Xiao_C3_companion_radio_ble_ps \
Xiao_C6_companion_radio_ble_ \
RAK_4631_companion_radio_ble \
+3
View File
@@ -1602,6 +1602,9 @@ collect_esp32_artifacts() {
local env_name=$1
local firmware_filename=$2
python3 scripts/check_esp32_app_size.py \
".pio/build/${env_name}/firmware.bin" \
".pio/build/${env_name}/partitions.bin" || return $?
pio run -t mergebin -e "$env_name" || return $?
copy_build_output ".pio/build/${env_name}/firmware.bin" "${OUTPUT_DIR}/${firmware_filename}.bin" || return $?
copy_build_output ".pio/build/${env_name}/firmware-merged.bin" "${OUTPUT_DIR}/${firmware_filename}-merged.bin" || return $?
+70 -6
View File
@@ -2,6 +2,10 @@
This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors.
The first word of a command is case-insensitive, so `set`, `Set`, and `SET`
are equivalent, as are `get`, `Get`, and the other command verbs. The case of
arguments such as node names, passwords, and keys is left unchanged.
## Navigation
- [Operational](#operational)
@@ -100,6 +104,48 @@ This document provides an overview of CLI commands that can be sent to MeshCore
- `start ota`
- `stop ota`
On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP
port 80 and cannot run together. Stop WebConfig before `start ota`, or stop OTA
before `start webconfig`.
---
### Browser configuration portal (ESP32 repeater and room server)
**Usage:**
- `start webconfig`
- `start webconfig ap`
- `stop webconfig`
- `set webui on`
- `set webui off`
- `get webui`
`set webui on` is the persistent master switch. It starts the portal now and
again after future reboots; `set webui off` closes it and disables that boot
start. `get webui` reports the saved on/off state plus whether the portal is
inactive, joining WiFi, serving a setup AP, or serving a LAN URL. The default is
`off` on repeater and room-server builds. On display-equipped repeaters, an
otherwise-unused triple click toggles the same saved setting.
`start webconfig` is a temporary start that does not change the saved switch.
It serves the shared WiFi, radio, flood, loop, and status page and reports its
URL. MQTT builds also show the MQTT tab and wizard step; non-MQTT builds remove
them entirely. Sign in with the node's admin password. If the node has no saved
WiFi SSID, the command starts the open `MeshCore-Setup-XXXX` captive AP at
<http://192.168.4.1/> instead.
`start webconfig ap` forces captive-AP mode. It will not interrupt an active
MQTT bridge, so run `set bridge off` first. Use `stop webconfig` to close either
mode for the current boot. (`stop webconfig` does not change a saved `webui on`.)
LAN mode otherwise remains active until reboot; setup mode stops after 10 minutes
with no connected client.
The browser portal is not compiled into the two 4 MB
`LilyGo_TLora_V2_1_1_6_*_observer_mqtt` targets because it does not fit while
retaining the two app slots required for LoRa OTA. Their normal CLI settings
remain available.
---
### Erase/Factory Reset
@@ -1221,12 +1267,25 @@ The payload names follow the [MeshCore packet-format allocation](https://docs.me
are never affected.
**Behavior:** A match prevents retransmission by this repeater. The packet is
still received and can still be logged. Rules are persistent and the table is
empty by default. While the temporary radio is active, only rows explicitly
marked `suspend=tempradio` are skipped. `tempradio` is a radio state, not an OTA
mode; normal payload types can also use the temporary channel. Other rows
remain in force. A malformed persisted table fails open (no general rules are
applied).
still received and can still be logged. Rules are persistent. While the
temporary radio is active, only rows explicitly marked `suspend=tempradio` are
skipped. `tempradio` is a radio state, not an OTA mode; normal payload types can
also use the temporary channel. Other rows remain in force. A malformed
persisted table fails open (no general rules are applied).
**Default row:** Repeater firmware seeds a new flood-filter table with
`ota all suspend=tempradio` in slot 1. This blocks repeated LoRa OTA (`0x0C`)
floods at every received hop unless temporary radio is actually active. The OTA
core independently refuses OTA receive, relay, and transmit outside temporary
radio. The row is editable and deletable; once the table is saved, deletion is
persistent. Restore the exact seeded row with:
```text
set flood.filter.1 0x0C all suspend=tempradio
```
Omitting `all` is equivalent. Omit `.1` as well to reuse an identical rule or
the first empty slot instead of replacing slot 1.
**Remote-admin protection:** `flood.filter` cannot block `anon_req`, `path`, or
`response` at received hop counts `0-6`; those login-capable types become
@@ -1253,6 +1312,7 @@ match it.
set flood.filter grp_data 4+
set flood.filter.2 PAYLOAD_TYPE_ADVERT 6+
set flood.filter ota 2-4
set flood.filter.1 0x0C all suspend=tempradio
set flood.filter grp_data all suspend=tempradio
set flood.filter any 12+
get flood.filter
@@ -2120,6 +2180,10 @@ clear recent.repeater
**Usage:**
- `gps sync`
The GPS must be enabled. When GPS power saving has put an enabled receiver to
sleep, this command schedules a sync and wakes it; after `gps off`, it reports
`gps is off` without scheduling work.
---
#### Set this node's location based on the GPS coordinates
+24 -3
View File
@@ -765,6 +765,9 @@ After this bootloader is flashed onto the device, you can trigger an over-the-ai
6. From your phone or computer, connect to the `MeshCore-OTA` hotspot when one was started.
7. Open the URL reported by `start ota` and upload the non-merged bin from the flasher. When the device started `MeshCore-OTA`, the URL is <http://192.168.4.1/update>.
On an MQTT observer, stop WebConfig before running `start ota`; both servers use
HTTP port 80. Likewise, stop the OTA uploader before running `start webconfig`.
### 7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?
**A:** Yes, developer `che aporeps` has an enhanced OTA DFU bootloader for nRF52 based devices. With this bootloader, if it detects that the application firmware is invalid, it falls back to OTA DFU mode so you can attempt to flash again to recover. This bootloader has other changes to make the OTA DFU process more fault tolerant.
@@ -801,13 +804,31 @@ Where `&type` is:
### 7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?
**A:**
Flash a companion Wi-Fi build. If it has no saved Wi-Fi configuration, it starts an open `MeshCore-Setup` access point. Join it, browse to <http://192.168.4.1/>, and submit the network SSID and password. The device saves the credentials only after a successful connection. The page and device screen then show the station IP assigned by your router.
Flash an ESP32 companion Wi-Fi build. Its shared WebUI is enabled by default. If
it has no saved Wi-Fi configuration, it starts an open `MeshCore-Setup-XXXX`
access point. Join it and browse to <http://192.168.4.1/>. The wizard configures
WiFi, node identity, and radio settings; MQTT companion builds add an MQTT step
and MQTT tab, while non-MQTT companions remove those controls. After saving,
the page and device screen show the station IP assigned by your router.
If a saved network remains unreachable for two minutes, `MeshCore-Setup` starts as a fallback. The node retries the saved SSID and password every two minutes while the setup AP is open. If the original network returns, the node reconnects and closes `MeshCore-Setup` automatically.
If a saved network remains unreachable for two minutes, the setup AP starts as
a fallback. If the original network returns, the companion closes that recovery
portal and resumes normal LAN operation.
The setup AP always uses `192.168.4.1`; the companion's station IP is assigned by DHCP and can be different. A real `WIFI_SSID` and `WIFI_PWD` can still be supplied at compile time as initial defaults, but they are no longer required.
The `Heltec_v3_companion_radio_wifi_mqtt` and `heltec_v4_companion_radio_wifi_mqtt` builds use a two-stage first-run setup. The open `MeshCore-Setup` AP serves only the SSID/password form at <http://192.168.4.1/>. After the companion joins that network, reconnect your phone or computer to the same network and open the station IP shown on the setup page or device screen. That second, station-only page asks for the MQTT preset or broker, routing identity, credentials, and publish options. It remains available at the node's station IP so MQTT settings can be changed later; the companion interface remains available on TCP port `5000`.
On the LAN, the WebUI remains available at the companion's station IP and the
companion protocol remains available on TCP port `5000`. Companion builds do not
have an admin CLI password, so this configuration page trusts the local LAN; do
not put the companion on an untrusted WiFi network.
ESP32 repeater and room-server builds use the same page, but keep it off by
default. Run `set webui on` from the admin CLI, open the reported LAN URL, and
sign in with the admin password; `get webui` reports its current address. Their
MQTT observer variants expose the MQTT controls, while ordinary builds do not.
The two 4 MB
`LilyGo_TLora_V2_1_1_6_*_observer_mqtt` builds retain CLI-only configuration so
their flash can keep two app slots for LoRa OTA.
### 7.7. Q: I have a Station G2, or a Heltec V4, or an Ikoka Stick, or a radio with an EByte E22-900M30S or an EByte E22-900M33S module, what should their transmit power be set to?
**A:**
+30 -15
View File
@@ -32,11 +32,12 @@ get flood.filter
get flood.moderation
```
The `flood.filter` and `flood.moderation` tables each have 16 persistent slots
and start empty. A row can opt into `suspend=tempradio`; temporary radio is not
synonymous with OTA and can carry normal packet types too. A corrupt or
truncated table fails open, so corrupt storage does not silently enable
blocking.
The `flood.filter` and `flood.moderation` tables each have 16 persistent slots.
A new `flood.filter` table starts with `ota all suspend=tempradio` in slot 1;
`flood.moderation` starts empty. A row can opt into `suspend=tempradio`;
temporary radio is not synonymous with OTA and can carry normal packet types
too. A corrupt or truncated table fails open, so corrupt storage does not
silently enable blocking.
## Force unscoped floods into a transport scope
@@ -90,8 +91,12 @@ type forwardable.
LoRa OTA (`0x0C`) falls under `other:*`. A matching row adds the selected
transport code, but OTA still operates normally during the temporary-radio
window because the OTA handler accepts both unscoped and transport-scoped flood
routes. The target region must allow flooding. The OTA core is already dormant
outside the temporary-radio window, so no default OTA filter row is installed.
routes. The target region must allow flooding. A new repeater also seeds
`ota all suspend=tempradio` in flood-filter slot 1. That visible rule blocks OTA
forwarding at every received hop outside temporary-radio operation and is
skipped while temporary radio is active. Independently, the OTA core refuses
OTA receive, relay, and transmit outside an actually active temporary-radio
window, even if the seeded row is deleted or replaced.
Capacity is selected at build time:
@@ -149,9 +154,18 @@ Without a slot number, `set` reuses an identical rule, including its suspension
setting, or selects the first empty slot. With a slot number, it replaces that
slot. Omitting the hop expression means `all` (`0-63`).
No flood-filter row is created automatically. Operators may add
`suspend=tempradio` to any row that should be skipped while the radio is on a
temporary channel.
On first initialization, flood-filter slot 1 is seeded with:
```text
set flood.filter.1 0x0C all suspend=tempradio
```
This is a normal editable row. After the table has been saved, deleting it
remains persistent across reboot; the firmware does not recreate it. Run the
same command to restore the exact seeded row, or omit `.1` to preserve existing
slot assignments and use the first empty slot. Operators may add
`suspend=tempradio` to any other row that should be skipped while the radio is
on a temporary channel.
Suspension does not approve a packet or bypass the rest of the filter table. It
skips that row, then evaluation continues with the next row and the remaining
@@ -358,17 +372,18 @@ Use username and path rules as traffic moderation, not as an authorization
boundary. For a strict network boundary, combine these tools with region ACLs,
private transport/channel keys, and controlled device access.
## Restore the factory-seeded channel block
## Restore the factory-seeded rows
The repeater's factory-seeded forwarding row can be restored through the CLI:
The repeater's factory-seeded forwarding rows can be restored through the CLI:
```text
set flood.channel.block.1 #wardriving h=4
set flood.filter.1 0x0C all suspend=tempradio
```
This explicitly replaces slot 1. Inspect the slot first if it may now contain
another rule. To preserve existing slot assignments, omit `.1`; the command
then updates an identical channel row or uses the first empty slot.
Each command explicitly replaces slot 1 in its own table. Inspect the slot first
if it may now contain another rule. To preserve existing slot assignments, omit
`.1`; the command then reuses an identical row or uses the first empty slot.
## Remove the custom rules
+14 -2
View File
@@ -84,7 +84,7 @@ set flood.retry.ignore none
| `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=<all|1-7|default>` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] <key|#channel> [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` |
| `flood.channel.block.hops` | Limits keyed channel-block matches to short flood paths. `all` blocks matching packets at any hop count; `1`-`7` repeats packets at that hop count or lower and blocks longer matches. This does not restrict unkeyed `GRP_DATA`; use `flood.channel.data.hops` for that. | `get flood.channel.block.hops`, `set flood.channel.block.hops <all|1-7>` | `set flood.channel.block.hops 3` |
| `flood.channel.scope` | Assigns a transport-region scope to received unscoped floods. Exact channel keys beat `txt:*`; `login:*` covers the remote-login family, and `other:*` covers every remaining flood type except TRACE, including OTA. TRACE remains unchanged across scope boundaries. ACL permission `4` can manage the table. | `get flood.channel.scope[.n]`, `set flood.channel.scope[.n] <channel|txt:*|login:*|other:*> <region>`, `del flood.channel.scope.<n>|all` | `set flood.channel.scope login:* west` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). The table starts empty; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter grp_data 4+` |
| `flood.filter` | Persistent repeater-only forwarding rules for flood routes `0x00`/`0x01`, selected by payload type and optional received hop count/range (omitted means `all`). New tables seed slot 1 with `ota all suspend=tempradio`; only rows marked `suspend=tempradio` are skipped during temporary-radio operation. Login-capable `anon_req`/`path`/`response` types become filterable at hop `7`; flood `txt_msg` becomes filterable at hop `5`. Standard direct traceroute, other direct routing, and local receive/logging are unchanged. | `get flood.filter[.n]`, `set flood.filter[.n] <type> [N|N+|N-M|all] [suspend=tempradio]`, `del flood.filter.<n>|all` | `set flood.filter.1 0x0C all suspend=tempradio` |
| `flood.moderation` | Decrypts keyed `GRP_TXT` channels and applies drop, per-username messages/minute, and maximum-hop controls, optionally matched against the first 1-3 path hashes. Supports `public`, `#channel`, and 128/256-bit channel keys. Sender names and truncated path hashes are moderation hints, not authenticated identities. | `get flood.moderation[.n]`, `set flood.moderation[.n] <channel> <sender> <drop|rate=X/min|hops=N> [path=...]`, `del flood.moderation.<n>|all` | `set flood.moderation public "Noisy User" rate=5/min hops=4` |
| `clock.sync.mesh` | After 30 minutes of uptime, estimates UTC from a configurable consensus of fresh signed-advert or valid Public-channel sources. Only timestamps from firmware build time through build time plus ten years are recorded. Successful CLI, GPS, or WiFi/NTP clock updates suppress LoRa time collection until reboot; after reboot LoRa is the fallback if NTP cannot sync. | `get clock.sync.mesh`, `set clock.sync.mesh <on|off>`, `get clock.sync.status` | `set clock.sync.mesh on` |
| `clock.sync.internet` | Adds a read-only internet/NTP estimate at the same delayed check on WiFi MQTT repeater-observer builds. Other builds retain the setting but report internet unavailable. | `get clock.sync.internet`, `set clock.sync.internet <on|off>` | `set clock.sync.internet on` |
@@ -183,7 +183,9 @@ Serial CLI pages contain up to `128` rows. Remote LoRa CLI pages contain up to
remote client context, so they are not useful from the local serial CLI.
Set paths with comma-separated hop hashes. Each hop must be `2`, `4`, or `6`
hex characters, and all hops in one path must use the same width.
hex characters, and all hops in one path must use the same width. Hex input is
case-insensitive. Replies use uppercase hex and retain the commas, so the value
can be copied directly into another `set outpath` or `set altpath` command.
```text
get outpath
@@ -196,6 +198,16 @@ set altpath 71CE82,BA09F0
set altpath clear
```
For example, both `set altpath 600000,0d2784,f8dada` and
`Set altpath 600000,0d2784,f8dada` store the same path and reply with:
```text
> 600000,0D2784,F8DADA
```
The first CLI word is case-insensitive (`set`, `Set`, and `SET` are the same,
as are `get`, `Get`, and the other command verbs). Argument case is preserved.
`set outpath direct` sets a zero-hop direct route for a client reachable without
repeaters. `set outpath clear` forgets the override and lets normal path
discovery fill it again. `set outpath flood` forces replies to use flood packets
+449 -24
View File
@@ -7,6 +7,12 @@
#include <helpers/MQTTDefaults.h>
#endif
#ifdef WITH_WEBCONFIG
#include <helpers/WiFiSetupPortal.h>
#include <WiFi.h>
#include <esp_wifi.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
if (failure_count < 6) failure_count++;
@@ -1180,6 +1186,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_mqtt_configured = false;
_mqtt_started = false;
#endif
#ifdef WITH_WEBCONFIG
_webconfig = nullptr;
_wc_mqtt_dirty = false;
#endif
}
void MyMesh::begin(bool has_display) {
@@ -1297,6 +1307,16 @@ void MyMesh::begin(bool has_display) {
_mqtt_bridge->setStatsSources(this, _radio, &board, _ms);
}
#endif
#ifdef WITH_WEBCONFIG
void* web_mqtt_prefs = nullptr;
#ifdef WITH_MQTT_BRIDGE
web_mqtt_prefs = &_mqtt_prefs;
#endif
_webconfig = new WebConfigServer(this, web_mqtt_prefs, false,
self_id.pub_key, FIRMWARE_VERSION,
"companion", board.getManufacturerName());
#endif
}
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
@@ -1389,9 +1409,8 @@ static void copyMqttString(char* dest, size_t dest_size, const char* src) {
dest[dest_size - 1] = 0;
}
void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password,
bool allow_setup_page) {
if (!_mqtt_started && !_mqtt_setup.isActive()) {
void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) {
if (!_mqtt_started) {
if (strcmp(_mqtt_prefs.wifi_ssid, wifi_ssid ? wifi_ssid : "") != 0) {
copyMqttString(_mqtt_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid), wifi_ssid);
}
@@ -1400,25 +1419,11 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password,
}
}
if (WiFi.status() != WL_CONNECTED || !allow_setup_page) {
_mqtt_setup.stop();
return;
}
if (_mqtt_setup.loop()) {
if (_mqtt_started && _mqtt_bridge) {
_mqtt_bridge->end();
}
_mqtt_started = false;
_mqtt_configured = true;
Serial.println("MQTT companion: applying settings saved from the web page");
}
if (!_mqtt_setup.isActive()) {
_mqtt_setup.begin(&_mqtt_prefs);
}
if (_mqtt_configured && !_mqtt_started && _mqtt_bridge) {
if (WiFi.status() == WL_CONNECTED && _mqtt_configured && !_mqtt_started && _mqtt_bridge
#ifdef WITH_WEBCONFIG
&& !isWebConfigSetupActive()
#endif
) {
_mqtt_started = true; // begin is one-shot; avoid retrying partial allocations
_mqtt_bridge->begin();
if (_mqtt_bridge->isRunning()) {
@@ -1428,9 +1433,429 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password,
}
}
}
#endif
void MyMesh::stopMQTTSetupPage() {
_mqtt_setup.stop();
#ifdef WITH_WEBCONFIG
static bool wcParseBool(const char* value, bool& out) {
if (strcmp(value, "on") == 0) { out = true; return true; }
if (strcmp(value, "off") == 0) { out = false; return true; }
return false;
}
static bool wcParseLong(const char* value, long min_value, long max_value, long& out) {
if (!value || !value[0]) return false;
char* end = nullptr;
long parsed = strtol(value, &end, 10);
if (!end || *end != 0 || parsed < min_value || parsed > max_value) return false;
out = parsed;
return true;
}
static bool wcParseDouble(const char* value, double min_value, double max_value, double& out) {
if (!value || !value[0]) return false;
char* end = nullptr;
double parsed = strtod(value, &end);
if (!end || *end != 0 || !isfinite(parsed) || parsed < min_value || parsed > max_value) return false;
out = parsed;
return true;
}
static bool wcValidNtpHost(const char* value) {
if (!value || !value[0] || strlen(value) > 63 || value[0] == '.') return false;
size_t len = strlen(value);
if (value[len - 1] == '.') return false;
for (size_t i = 0; i < len; i++) {
char c = value[i];
if (!isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '-') return false;
}
return true;
}
static bool wcValidHexKey(const char* value) {
if (!value || strlen(value) != 64) return false;
for (int i = 0; i < 64; i++) {
if (!isxdigit(static_cast<unsigned char>(value[i]))) return false;
}
return true;
}
static bool wcCopyValue(char* dest, size_t dest_size, const char* value) {
if (!dest || dest_size == 0 || !value || strlen(value) >= dest_size) return false;
strncpy(dest, value, dest_size - 1);
dest[dest_size - 1] = 0;
return true;
}
void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
memset(&s, 0, sizeof(s));
wcCopyValue(s.name, sizeof(s.name), _prefs.node_name);
s.lat = sensors.node_lat;
s.lon = sensors.node_lon;
s.freq = _prefs.freq;
s.bw = _prefs.bw;
s.sf = _prefs.sf;
s.cr = _prefs.cr;
s.tx_power = _prefs.tx_power_dbm;
s.airtime_factor = _prefs.airtime_factor;
s.rx_delay = _prefs.rx_delay_base;
s.rx_gain = _prefs.rx_boosted_gain;
s.repeat = _prefs.client_repeat != 0;
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_RX_GAIN
| WebConfigServer::CAP_REPEAT;
}
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
if (!_webconfig) {
strcpy(reply, "Err: WebUI unavailable (not enough memory)");
return false;
}
if (_webconfig->isRunning() || _webconfig->isStopping()) {
if (force_ap && !_webconfig->isStopping()
&& _webconfig->mode() == WebConfigServer::MODE_LAN) {
#ifdef WITH_MQTT_BRIDGE
if (_mqtt_started && _mqtt_bridge) {
_mqtt_bridge->end();
_mqtt_started = false;
}
#endif
return _webconfig->startSetupMode(reply);
}
strcpy(reply, _webconfig->isStopping() ? "Err: WebUI still stopping"
: "Err: WebUI already running");
return false;
}
if (force_ap) {
#ifdef WITH_MQTT_BRIDGE
if (_mqtt_started && _mqtt_bridge) {
_mqtt_bridge->end();
_mqtt_started = false;
}
#endif
return _webconfig->startSetupMode(reply);
}
return _webconfig->startAutoMode(reply);
}
void MyMesh::stopWebConfig() {
if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop();
}
void MyMesh::serviceWebConfig() {
if (_webconfig) _webconfig->tick(millis());
}
bool MyMesh::isWebConfigSetupActive() const {
return _webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP
&& !_webconfig->isStopping();
}
void MyMesh::rebootNow() {
board.reboot();
}
void MyMesh::onConfigBatchStart() {
_wc_mqtt_dirty = false;
}
void MyMesh::onConfigBatchEnd() {
#ifdef WITH_MQTT_BRIDGE
if (_wc_mqtt_dirty) {
CompanionMqttSetupPortal::saveStoredConfig(_mqtt_prefs);
if (_mqtt_started && _mqtt_bridge) _mqtt_bridge->end();
_mqtt_started = false;
MQTTPrefs verified;
_mqtt_configured = CompanionMqttSetupPortal::loadStoredConfig(verified);
if (_mqtt_configured) _mqtt_prefs = verified;
}
#endif
_wc_mqtt_dirty = false;
}
void MyMesh::execCommand(char* cmd, char* reply) {
reply[0] = 0;
if (!cmd || strncmp(cmd, "set ", 4) != 0) {
strcpy(reply, "Error: unsupported command");
return;
}
char* key = cmd + 4;
char* split = strchr(key, ' ');
if (!split) {
strcpy(reply, "Error: missing value");
return;
}
*split = 0;
const char* value = split + 1;
if (strcmp(key, "name") == 0) {
if (!value[0] || !wcCopyValue(_prefs.node_name, sizeof(_prefs.node_name), value)) {
strcpy(reply, "Error: name must be 1-31 characters");
} else {
savePrefs();
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "lat") == 0 || strcmp(key, "lon") == 0) {
double parsed;
const bool latitude = key[1] == 'a';
if (!wcParseDouble(value, latitude ? -90.0 : -180.0,
latitude ? 90.0 : 180.0, parsed)) {
strcpy(reply, latitude ? "Error: latitude must be -90 to 90"
: "Error: longitude must be -180 to 180");
} else {
if (latitude) sensors.node_lat = parsed; else sensors.node_lon = parsed;
savePrefs();
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "radio") == 0) {
float freq, bw;
int sf, cr;
char extra;
if (sscanf(value, "%f,%f,%d,%d%c", &freq, &bw, &sf, &cr, &extra) != 4
|| !isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f
|| bw < 7.0f || bw > 500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8) {
strcpy(reply, "Error: radio must be freq,bw,sf,cr");
} else {
_prefs.freq = freq;
_prefs.bw = bw;
_prefs.sf = static_cast<uint8_t>(sf);
_prefs.cr = static_cast<uint8_t>(cr);
savePrefs();
strcpy(reply, "OK - reboot required");
}
return;
}
if (strcmp(key, "tx") == 0) {
long parsed;
if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) {
snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER);
} else {
_prefs.tx_power_dbm = static_cast<int8_t>(parsed);
radio_driver.setTxPower(_prefs.tx_power_dbm);
savePrefs();
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "af") == 0 || strcmp(key, "rxdelay") == 0) {
double parsed;
const bool is_af = strcmp(key, "af") == 0;
if (!wcParseDouble(value, 0.0, is_af ? 9.0 : 20.0, parsed)) {
strcpy(reply, is_af ? "Error: airtime factor must be 0-9"
: "Error: RX delay must be 0-20");
} else {
if (is_af) _prefs.airtime_factor = parsed; else _prefs.rx_delay_base = parsed;
savePrefs();
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "radio.rxgain") == 0 || strcmp(key, "repeat") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
strcpy(reply, "Error: must be on or off");
} else {
if (strcmp(key, "radio.rxgain") == 0) {
_prefs.rx_boosted_gain = enabled;
radio_driver.setRxBoostedGainMode(enabled);
} else {
_prefs.client_repeat = enabled;
}
savePrefs();
strcpy(reply, "OK");
}
return;
}
#ifdef WITH_MQTT_BRIDGE
bool changed = false;
if (strcmp(key, "mqtt.origin") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin), value);
} else if (strcmp(key, "mqtt.iata") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata), value);
if (changed) {
for (char* p = _mqtt_prefs.mqtt_iata; *p; p++) *p = toupper(static_cast<unsigned char>(*p));
}
} else if (strcmp(key, "mqtt.status") == 0 || strcmp(key, "mqtt.packets") == 0
|| strcmp(key, "mqtt.raw") == 0 || strcmp(key, "mqtt.rx") == 0
|| strcmp(key, "snmp") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
strcpy(reply, "Error: must be on or off");
return;
}
if (strcmp(key, "mqtt.status") == 0) _mqtt_prefs.mqtt_status_enabled = enabled;
else if (strcmp(key, "mqtt.packets") == 0) _mqtt_prefs.mqtt_packets_enabled = enabled;
else if (strcmp(key, "mqtt.raw") == 0) _mqtt_prefs.mqtt_raw_enabled = enabled;
else if (strcmp(key, "mqtt.rx") == 0) _mqtt_prefs.mqtt_rx_enabled = enabled;
else _mqtt_prefs.snmp_enabled = enabled;
changed = true;
} else if (strcmp(key, "mqtt.tx") == 0) {
if (strcmp(value, "off") == 0) _mqtt_prefs.mqtt_tx_enabled = 0;
else if (strcmp(value, "on") == 0) _mqtt_prefs.mqtt_tx_enabled = 1;
else if (strcmp(value, "advert") == 0) _mqtt_prefs.mqtt_tx_enabled = 2;
else {
strcpy(reply, "Error: MQTT TX must be off, on, or advert");
return;
}
changed = true;
} else if (strcmp(key, "mqtt.interval") == 0) {
long minutes;
if (!wcParseLong(value, 1, 60, minutes)) {
strcpy(reply, "Error: interval must be 1-60 minutes");
return;
}
_mqtt_prefs.mqtt_status_interval = static_cast<uint32_t>(minutes) * 60000UL;
changed = true;
} else if (strcmp(key, "mqtt.ntp") == 0) {
if (strcmp(value, "none") == 0) {
_mqtt_prefs.mqtt_ntp_server[0] = 0;
changed = true;
} else if (wcValidNtpHost(value)) {
changed = wcCopyValue(_mqtt_prefs.mqtt_ntp_server,
sizeof(_mqtt_prefs.mqtt_ntp_server), value);
}
} else if (strcmp(key, "mqtt.owner") == 0) {
if (!value[0]) {
_mqtt_prefs.mqtt_owner_public_key[0] = 0;
changed = true;
} else if (wcValidHexKey(value)) {
changed = wcCopyValue(_mqtt_prefs.mqtt_owner_public_key,
sizeof(_mqtt_prefs.mqtt_owner_public_key), value);
}
} else if (strcmp(key, "mqtt.email") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_email, sizeof(_mqtt_prefs.mqtt_email), value);
} else if (strcmp(key, "timezone") == 0) {
changed = wcCopyValue(_mqtt_prefs.timezone_string, sizeof(_mqtt_prefs.timezone_string), value);
} else if (strcmp(key, "timezone.offset") == 0) {
long offset;
if (!wcParseLong(value, -12, 14, offset)) {
strcpy(reply, "Error: timezone offset must be -12 to 14");
return;
}
_mqtt_prefs.timezone_offset = static_cast<int8_t>(offset);
changed = true;
} else if (strcmp(key, "snmp.community") == 0) {
changed = wcCopyValue(_mqtt_prefs.snmp_community,
sizeof(_mqtt_prefs.snmp_community), value);
} else if (strncmp(key, "mqtt", 4) == 0 && key[4] >= '1'
&& key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.') {
int slot = key[4] - '1';
const char* field = key + 6;
if (strcmp(field, "preset") == 0) {
const bool valid = findMQTTPreset(value) != nullptr
|| strcmp(value, MQTT_PRESET_CUSTOM) == 0
|| strcmp(value, MQTT_PRESET_NONE) == 0;
if (!valid) {
strcpy(reply, "Error: unknown MQTT preset");
return;
}
if (findMQTTPreset(value)) {
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
if (i != slot && strcmp(_mqtt_prefs.mqtt_slot_preset[i], value) == 0) {
snprintf(reply, 160, "Error: preset already assigned to slot %d", i + 1);
return;
}
}
}
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_preset[slot],
sizeof(_mqtt_prefs.mqtt_slot_preset[slot]), value);
} else if (strcmp(field, "server") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_host[slot],
sizeof(_mqtt_prefs.mqtt_slot_host[slot]), value);
} else if (strcmp(field, "port") == 0) {
long port;
if (!wcParseLong(value, 1, 65535, port)) {
strcpy(reply, "Error: port must be 1-65535");
return;
}
_mqtt_prefs.mqtt_slot_port[slot] = static_cast<uint16_t>(port);
changed = true;
} else if (strcmp(field, "username") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_username[slot],
sizeof(_mqtt_prefs.mqtt_slot_username[slot]), value);
} else if (strcmp(field, "password") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_password[slot],
sizeof(_mqtt_prefs.mqtt_slot_password[slot]), value);
} else if (strcmp(field, "token") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_token[slot],
sizeof(_mqtt_prefs.mqtt_slot_token[slot]), value);
} else if (strcmp(field, "topic") == 0) {
if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], MQTT_PRESET_CUSTOM) != 0) {
strcpy(reply, "Error: topic only applies to custom slots");
return;
}
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_topic[slot],
sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), value);
} else if (strcmp(field, "audience") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_audience[slot],
sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), value);
}
}
if (changed) {
_wc_mqtt_dirty = true;
strcpy(reply, "OK");
} else if (reply[0] == 0) {
strcpy(reply, "Error: invalid or unsupported value");
}
#else
strcpy(reply, "Error: unsupported setting for this companion");
#endif
}
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
"\"noise\":%d,\"rssi\":%d,\"snr\":%.1f,"
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(millis() / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
(int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(),
radio_driver.getLastSNR(),
(unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000),
(unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(),
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
#ifdef WITH_MQTT_BRIDGE
_mqtt_bridge ? _mqtt_bridge->getQueueSize() : 0);
#else
0);
#endif
if (pos < 0 || pos >= static_cast<int>(buf_size) - 3) return;
#ifdef WITH_MQTT_BRIDGE
bool first = true;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
MQTTBridge::SlotStatusSnapshot status;
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
int written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
first ? "" : ",", i + 1, status.name, status.state);
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
pos += written;
first = false;
}
#endif
snprintf(buf + pos, buf_size - pos, "]}");
}
#endif
+28 -5
View File
@@ -26,6 +26,10 @@
#include "DataStore.h"
#include "NodePrefs.h"
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && !defined(WEBCONFIG_DISABLED)
#include <helpers/esp32/WebConfigServer.h>
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/CompanionMqttSetupPortal.h>
#include <helpers/MQTTPrefs.h>
@@ -90,7 +94,11 @@ struct AdvertPath {
uint8_t path[MAX_PATH_SIZE];
};
class MyMesh : public BaseChatMesh, public DataStoreHost {
class MyMesh : public BaseChatMesh, public DataStoreHost
#ifdef WITH_WEBCONFIG
, public WebConfigServer::Callbacks
#endif
{
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
@@ -102,12 +110,24 @@ public:
uint32_t getBLEPin();
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void serviceMQTT(const char* wifi_ssid, const char* wifi_password,
bool allow_setup_page);
void stopMQTTSetupPage();
void serviceMQTT(const char* wifi_ssid, const char* wifi_password);
bool isMQTTConfigured() const { return _mqtt_configured; }
#endif
#ifdef WITH_WEBCONFIG
bool startWebConfig(bool force_ap, char* reply);
void stopWebConfig();
void serviceWebConfig();
bool isWebConfigSetupActive() const;
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override;
void rebootNow() override;
void onConfigBatchStart() override;
void onConfigBatchEnd() override;
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
void loop();
void handleCmdFrame(size_t len);
bool advert();
@@ -237,9 +257,12 @@ private:
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
MQTTPrefs _mqtt_prefs;
MQTTBridge* _mqtt_bridge;
CompanionMqttSetupPortal _mqtt_setup;
bool _mqtt_configured;
bool _mqtt_started;
#endif
#ifdef WITH_WEBCONFIG
WebConfigServer* _webconfig;
bool _wc_mqtt_dirty;
#endif
uint32_t pending_login;
uint32_t pending_status;
+85 -32
View File
@@ -128,7 +128,7 @@ void halt() {
static char configured_wifi_ssid[32];
static char configured_wifi_password[64];
#if defined(WITH_MQTT_BRIDGE) && defined(DISPLAY_CLASS)
#if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS)
static DisplayDriver* companion_setup_display = nullptr;
static unsigned long companion_setup_display_refresh = 0;
@@ -142,9 +142,23 @@ void halt() {
companion_setup_display->setTextSize(1);
companion_setup_display->setColor(DisplayDriver::LIGHT);
if (WiFi.status() == WL_CONNECTED) {
char setup_ssid[33] = {0};
char setup_ip[16] = {0};
if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid),
setup_ip, sizeof(setup_ip))) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "MQTT setup");
companion_setup_display->width() / 2, 0, "WebUI setup");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join open WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), setup_ssid);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, setup_ip);
} else if (WiFi.status() == WL_CONNECTED) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "WebUI");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join WiFi:");
companion_setup_display->drawTextEllipsized(
@@ -154,18 +168,6 @@ void halt() {
const String ip = WiFi.localIP().toString();
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, ip.c_str());
} else if (wifiSetupPortal().isActive()) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "WiFi setup");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join open WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), COMPANION_WIFI_SETUP_AP);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
const String ip = wifiSetupPortal().apIP().toString();
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, ip.c_str());
} else {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 8, "WiFi connecting");
@@ -286,7 +288,7 @@ void setup() {
DisplayDriver* disp = NULL;
if (display.begin()) {
disp = &display;
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
companion_setup_display = disp;
#endif
disp->startFrame();
@@ -415,12 +417,25 @@ void setup() {
WiFi.mode(WIFI_STA);
wifi_disconnected_since = millis() ? millis() : 1;
WiFi.begin(configured_wifi_ssid, configured_wifi_password);
} else if (!wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
}
#ifndef WITH_WEBCONFIG
else if (!wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
WIFI_DEBUG_PRINTLN("WiFi setup: could not start setup portal");
}
// Disable WiFi modem power-save: its periodic modem/light-sleep stalls the SX1262 SPI+DIO servicing,
// which makes the LoRa radio go deaf (no TX/RX) while WiFi is associated. Required for LoRa+WiFi to
// coexist on this ESP32 — the small extra idle current is well worth a working radio.
#endif
#ifdef WITH_WEBCONFIG
// WiFi companions expose the shared WebUI by default. With no stored
// credentials it opens the captive setup AP; otherwise it waits for the
// station connection and serves the same page on the LAN.
if (WebConfigServer::loadEnabled(true)) {
char web_reply[160];
the_mesh.startWebConfig(!have_wifi, web_reply);
WIFI_DEBUG_PRINTLN("%s", web_reply);
}
#endif
// Disable WiFi modem power-save after starting WebConfig: that startup may
// restore a saved ESP-IDF WiFi power policy. Companion radios must keep
// modem sleep disabled because its pauses can stall SX1262 SPI/DIO service.
WiFi.setSleep(false);
serial_interface.begin(TCP_PORT);
#ifdef ENABLE_OTA
@@ -491,11 +506,15 @@ void loop() {
the_mesh.loop();
sensors.loop();
#ifdef DISPLAY_CLASS
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_MQTT_BRIDGE)
if (the_mesh.isMQTTConfigured()) {
ui_task.loop();
} else {
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
if (the_mesh.isWebConfigSetupActive()
#ifdef WITH_MQTT_BRIDGE
|| !the_mesh.isMQTTConfigured()
#endif
) {
renderCompanionSetupDisplay();
} else {
ui_task.loop();
}
#else
ui_task.loop();
@@ -521,23 +540,51 @@ void loop() {
}
#if defined(ESP32) && defined(WIFI_SSID)
#ifdef WITH_WEBCONFIG
the_mesh.serviceWebConfig();
#endif
#ifdef ENABLE_OTA
ota_seeder_loop(); // accept/drop a motatool `serve --tcp` connection on the dedicated seeder port
ota_console_loop(); // service the OTA text console (port 5002)
#endif
if (WiFi.status() == WL_CONNECTED) {
wifi_disconnected_since = 0;
if (wifi_setup_recovery_mode && !wifiSetupPortal().isActive()) {
#ifdef WITH_WEBCONFIG
if (wifi_setup_recovery_mode && the_mesh.isWebConfigSetupActive()) {
the_mesh.stopWebConfig();
wifi_setup_recovery_mode = false;
}
#endif
if (wifi_setup_recovery_mode
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
&& !wifiSetupPortal().isActive()
#endif
) {
wifi_setup_recovery_mode = false;
}
} else if (configured_wifi_ssid[0]) {
unsigned long now = millis();
if (wifi_disconnected_since == 0) wifi_disconnected_since = now ? now : 1;
if (!wifi_setup_recovery_mode && !wifiSetupPortal().isActive()
&& now - wifi_disconnected_since >= WIFI_SETUP_FALLBACK_MS) {
#ifdef WITH_MQTT_BRIDGE
the_mesh.stopMQTTSetupPage();
if (!wifi_setup_recovery_mode
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
&& !wifiSetupPortal().isActive()
#endif
&& now - wifi_disconnected_since >= WIFI_SETUP_FALLBACK_MS) {
#ifdef WITH_WEBCONFIG
if (WebConfigServer::loadEnabled(true)) {
char web_reply[160];
if (the_mesh.startWebConfig(true, web_reply)) {
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; %s", web_reply);
} else {
wifi_disconnected_since = now;
}
}
#else
if (wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
wifiSetupPortal().configureRecovery(
configured_wifi_ssid, configured_wifi_password, WIFI_SETUP_FALLBACK_MS);
@@ -547,10 +594,17 @@ void loop() {
// Avoid retrying portal creation on every pass through loop().
wifi_disconnected_since = now;
}
#endif
}
}
// Safely attempt to reconnect every 10 seconds if flagged
if (!wifiSetupPortal().isActive() && wifi_needs_reconnect
if (
#ifdef WITH_WEBCONFIG
!the_mesh.isWebConfigSetupActive()
#else
!wifiSetupPortal().isActive()
#endif
&& wifi_needs_reconnect
&& (millis() - last_wifi_reconnect_attempt > 10000)) {
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
WiFi.disconnect();
@@ -558,8 +612,7 @@ void loop() {
last_wifi_reconnect_attempt = millis();
}
#ifdef WITH_MQTT_BRIDGE
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password,
!wifiSetupPortal().isActive());
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password);
#endif
#endif
}
+282 -5
View File
@@ -1,8 +1,12 @@
#include "MyMesh.h"
#include <algorithm>
#include <stdlib.h> // for qsort()
#include <helpers/CLICommandUtils.h>
#include <helpers/ClockSyncUtils.h>
#include <helpers/RxReservePacketManager.h>
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
#endif
/* ------------------------------ Config -------------------------------- */
@@ -2644,6 +2648,23 @@ void MyMesh::begin(FILESYSTEM *fs) {
_alerter.setBridge(mqtt_bridge);
#endif
#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP)
bool start_webui = WebConfigServer::loadEnabled(false);
#ifdef WITH_MQTT_BRIDGE
// Preserve the MQTT observer's first-boot setup experience even though the
// persistent WebUI master switch defaults off on infrastructure roles.
start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0;
if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) {
if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end();
}
#endif
if (start_webui) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
}
#endif
saved_radio_apply_pending = !applySavedRadioParams();
if (!saved_radio_apply_pending) {
radio_driver.setTxPower(_prefs.tx_power_dbm);
@@ -3990,9 +4011,27 @@ static uint8_t floodPacketFilterProtectedHops(const mesh::Packet* packet) {
}
}
void MyMesh::seedDefaultFloodPacketFilters() {
auto& entry = flood_packet_filters[0];
memset(&entry, 0, sizeof(entry));
entry.active = true;
entry.payload_type = PAYLOAD_TYPE_OTA;
entry.min_hops = 0;
entry.max_hops = FLOOD_PACKET_FILTER_MAX_HOPS;
entry.suspend_on_temp_radio = true;
}
void MyMesh::loadFloodPacketFilters() {
memset(flood_packet_filters, 0, sizeof(flood_packet_filters));
if (_fs == NULL || !_fs->exists(FLOOD_PACKET_FILTER_FILE)) return;
if (_fs == NULL) {
seedDefaultFloodPacketFilters();
return;
}
if (!_fs->exists(FLOOD_PACKET_FILTER_FILE)) {
seedDefaultFloodPacketFilters();
saveFloodPacketFilters();
return;
}
File file = openFloodChannelBlockRead(_fs, FLOOD_PACKET_FILTER_FILE);
if (!file) return;
@@ -5995,6 +6034,210 @@ void MyMesh::clearStats() {
((SimpleMeshTables *)getTables())->resetStats();
}
#ifdef WITH_WEBCONFIG
void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
memset(&s, 0, sizeof(s));
StrHelper::strncpy(s.name, _prefs.node_name, sizeof(s.name));
StrHelper::strncpy(s.admin_password, _prefs.password, sizeof(s.admin_password));
s.lat = _prefs.node_lat;
s.lon = _prefs.node_lon;
s.freq = _prefs.freq;
s.bw = _prefs.bw;
s.sf = _prefs.sf;
s.cr = _prefs.cr;
s.tx_power = _prefs.tx_power_dbm;
s.airtime_factor = _prefs.airtime_factor;
s.rx_delay = _prefs.rx_delay_base;
s.tx_delay = _prefs.tx_delay_factor;
s.cad = _prefs.cad_enabled;
s.rx_gain = _prefs.rx_boosted_gain;
s.repeat = !_prefs.disable_fwd;
s.advert_interval = _prefs.advert_interval * 2;
s.flood_advert_interval = _prefs.flood_advert_interval;
s.flood_max = _prefs.flood_max;
s.flood_max_advert = _prefs.flood_max_advert;
s.flood_max_unscoped = _prefs.flood_max_unscoped;
s.loop_detect = _prefs.loop_detect;
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
| WebConfigServer::CAP_DELAYS | WebConfigServer::CAP_CAD
| WebConfigServer::CAP_RX_GAIN | WebConfigServer::CAP_REPEAT
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE;
}
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
if (_cli.getBoard()->isOTAUpdateRunning()) {
strcpy(reply, "Err: OTA server is running - 'stop ota' first");
return true;
}
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
return true;
}
if (!_webconfig) {
void* mqtt_prefs = nullptr;
bool owns_wifi = true;
#ifdef WITH_MQTT_BRIDGE
mqtt_prefs = _cli.getObserverPrefs();
owns_wifi = false;
#endif
_webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi,
self_id.pub_key, getFirmwareVer(), getRole(),
_cli.getBoard()->getManufacturerName());
if (!_webconfig) {
strcpy(reply, "Err: not enough memory for webconfig");
return true;
}
}
if (force_ap) {
#ifdef WITH_MQTT_BRIDGE
if (mqtt_bridge && mqtt_bridge->isRunning()) {
strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first");
return true;
}
#endif
_webconfig->startSetupMode(reply);
} else {
_webconfig->startAutoMode(reply);
}
return true;
}
bool MyMesh::stopWebConfig(char* reply) {
if (!_webconfig || !_webconfig->isRunning()) {
strcpy(reply, "Err: webconfig not running");
return true;
}
_webconfig->requestStop();
strcpy(reply, "OK - webconfig stopping");
return true;
}
bool MyMesh::setWebUIEnabled(bool enabled, char* reply) {
if (!WebConfigServer::saveEnabled(enabled)) {
strcpy(reply, "Error: failed to save webui setting");
return true;
}
if (enabled) {
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, "OK - webui on (already active)");
} else {
startWebConfig(false, reply);
if (strncmp(reply, "WebConfig", 9) == 0) {
char tmp[160];
StrHelper::strncpy(tmp, reply, sizeof(tmp));
snprintf(reply, 160, "OK - webui on; %s", tmp);
}
}
} else {
if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop();
strcpy(reply, "OK - webui off");
}
return true;
}
bool MyMesh::getWebUIStatus(char* reply) const {
const bool enabled = WebConfigServer::loadEnabled(false);
if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping())) {
snprintf(reply, 160, "> %s, inactive", enabled ? "on" : "off");
} else if (_webconfig->mode() == WebConfigServer::MODE_SETUP) {
char ssid[33], ip[16];
WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip));
snprintf(reply, 160, "> %s, setup AP %s http://%s/", enabled ? "on" : "off", ssid, ip);
} else if (_webconfig->mode() == WebConfigServer::MODE_CONNECTING) {
snprintf(reply, 160, "> %s, connecting to WiFi", enabled ? "on" : "off");
} else {
snprintf(reply, 160, "> %s, http://%s/", enabled ? "on" : "off",
WiFi.localIP().toString().c_str());
}
return true;
}
void MyMesh::onConfigBatchEnd() {
_wc_batch_active = false;
#ifdef WITH_MQTT_BRIDGE
if (_wc_restart_pending) {
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
restartBridge();
return;
}
const uint8_t mask = _wc_slot_restart_mask;
_wc_slot_restart_mask = 0;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (mask & (1U << i)) restartBridgeSlot(i);
}
#else
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
#endif
}
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
"\"noise\":%d,\"rssi\":%d,\"snr\":%.1f,"
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
(int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(),
radio_driver.getLastSNR(),
(unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000),
(unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(),
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
#ifdef WITH_MQTT_BRIDGE
mqtt_bridge ? mqtt_bridge->getQueueSize() : 0);
#else
0);
#endif
if (pos < 0 || pos >= static_cast<int>(buf_size) - 3) return;
bool first = true;
#ifdef WITH_MQTT_BRIDGE
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
MQTTBridge::SlotStatusSnapshot status;
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
int written;
if (status.has_publish_counts) {
written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu}",
first ? "" : ",", i + 1, status.name, status.state,
status.publish_ok, status.publish_err);
} else {
written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
first ? "" : ",", i + 1, status.name, status.state);
}
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
pos += written;
first = false;
}
#else
(void)first;
#endif
snprintf(buf + pos, buf_size - pos, "]}");
}
#endif
static char* trimSpaces(char* s) {
while (*s == ' ') s++;
char* end = s + strlen(s);
@@ -6090,10 +6333,27 @@ static void formatPathReply(const uint8_t* path, uint8_t path_len, char* out, si
uint8_t hash_size = (path_len >> 6) + 1;
uint8_t hop_count = path_len & 63;
uint8_t byte_len = hop_count * hash_size;
char hex[(MAX_PATH_SIZE * 2) + 1];
mesh::Utils::toHex(hex, path, byte_len);
snprintf(out, out_len, "> hs=%u hops=%u hex=%s", (uint32_t)hash_size, (uint32_t)hop_count, hex);
size_t path_text_len = (size_t)hop_count * hash_size * 2 + (hop_count - 1);
// Every path that fits in the 160-byte CLI command buffer also fits this
// shorter reply form. Retain a compact, complete fallback for a path learned
// from an unusually long over-the-air route rather than truncating it.
if (path_text_len + 3 > out_len) {
out[0] = '>';
out[1] = ' ';
mesh::Utils::toHex(out + 2, path, (size_t)hop_count * hash_size);
return;
}
size_t pos = 0;
out[pos++] = '>';
out[pos++] = ' ';
for (uint8_t hop = 0; hop < hop_count; hop++) {
mesh::Utils::toHex(out + pos, path + ((size_t)hop * hash_size), hash_size);
pos += hash_size * 2;
if (hop + 1 < hop_count) out[pos++] = ',';
}
out[pos] = 0;
}
static bool commandFamilyMatches(const char* command, const char* family) {
@@ -6227,6 +6487,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
command += 3;
}
mesh::cli::normalizeCommandVerb(command);
if (sender && !sender->isAdmin()) {
bool allowed = (sender->isRegionMgr() && isRegionMgrAllowed(command))
|| (sender->isFilterMgr() && isFilterMgrAllowed(command));
@@ -6600,6 +6862,21 @@ void MyMesh::loop() {
}
#endif
#ifdef WITH_WEBCONFIG
if (WebConfigServer::takeButtonToggleRequest()) {
char wc_reply[160];
setWebUIEnabled(!WebConfigServer::loadEnabled(false), wc_reply);
Serial.println(wc_reply);
}
if (_webconfig) {
_webconfig->tick(millis());
if (!_webconfig->isRunning() && !_webconfig->isStopping()) {
delete _webconfig;
_webconfig = nullptr;
}
}
#endif
// is pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
acl.save(_fs);
+48 -1
View File
@@ -48,6 +48,10 @@
#define WITH_BRIDGE
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
#include "helpers/esp32/WebConfigServer.h"
#endif
#ifdef WITH_SNMP
#include "helpers/SNMPAgent.h"
#endif
@@ -168,7 +172,11 @@ struct NeighbourInfo {
#define RADIO_APPLY_RETRY_INTERVAL_MILLIS 1000UL
#define SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS 60UL
class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#ifdef WITH_WEBCONFIG
, public WebConfigServer::Callbacks
#endif
{
struct ScheduledRadioSetting {
bool active;
bool temporary;
@@ -341,6 +349,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
#ifdef WITH_MQTT_BRIDGE
AlertReporter _alerter;
#endif
#ifdef WITH_WEBCONFIG
WebConfigServer* _webconfig = nullptr;
bool _wc_batch_active = false;
bool _wc_restart_pending = false;
uint8_t _wc_slot_restart_mask = 0;
#endif
bool extractDirectRetryPrefix(const mesh::Packet* packet, uint8_t* prefix, uint8_t& prefix_len) const;
int8_t getDirectRetryMinSNRX4() const;
@@ -406,6 +420,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
bool shouldBlockFloodChannelForward(const mesh::Packet* packet) const;
void loadFloodPacketFilters();
bool saveFloodPacketFilters();
void seedDefaultFloodPacketFilters();
bool shouldBlockFloodPacketForward(const mesh::Packet* packet) const;
void formatFloodPacketFilters(const char* args, char* reply) const;
void formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const;
@@ -669,6 +684,12 @@ public:
void restartBridge() override {
AbstractBridge* active_bridge = activeBridge();
if (!active_bridge || !active_bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) {
_wc_restart_pending = true;
return;
}
#endif
active_bridge->end();
#ifdef WITH_MQTT_BRIDGE
// Set device metadata before restarting bridge (same as in begin())
@@ -687,6 +708,12 @@ public:
void restartBridgeSlot(int slot) override {
#ifdef WITH_MQTT_BRIDGE
if (!mqtt_bridge || !mqtt_bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active && slot >= 0 && slot < MAX_MQTT_SLOTS) {
_wc_slot_restart_mask |= static_cast<uint8_t>(1U << slot);
return;
}
#endif
mqtt_bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]);
#else
(void)slot;
@@ -741,6 +768,26 @@ public:
}
#endif
#ifdef WITH_WEBCONFIG
bool startWebConfig(bool force_ap, char* reply) override;
bool stopWebConfig(char* reply) override;
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
_wc_batch_active = true;
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
// To check if there is pending work
bool hasPendingWork() const;
+49 -1
View File
@@ -7,8 +7,9 @@
#define USER_BTN_PRESSED LOW
#endif
#ifdef WITH_MQTT_BRIDGE
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h>
#endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds
@@ -97,6 +98,38 @@ void UITask::renderCurrScreen() {
_display->setCursor((_display->width() - poffWidth) / 2, 48);
_display->drawTextCentered(_display->width()/2, 48, poweroff_string);
} else {
#ifdef WITH_WEBCONFIG
if (WebConfigServer::isRebootPending()) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setCursor(0, 14);
_display->print("Config saved!");
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 30);
_display->print("Rebooting...");
return;
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setCursor(0, 0);
_display->print("WebUI WiFi Setup");
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->setColor(DisplayDriver::YELLOW);
_display->setCursor(6, 24);
_display->print(wc_ssid);
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 40);
_display->print("Then browse to:");
_display->setColor(DisplayDriver::YELLOW);
_display->setCursor(6, 50);
_display->print(wc_ip);
return;
}
#endif
_display->setCursor(0, 0);
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
@@ -140,6 +173,21 @@ void UITask::loop() {
_display->turnOn();
Serial.println("Powering Off");
_powering_off_at = millis() + POWEROFF_DELAY;
#ifdef WITH_WEBCONFIG
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
// Single click wakes the display and long press powers off. Triple-click
// was unused in the repeater UI, so use it for the persistent WebUI toggle.
WebConfigServer::requestToggleFromButton();
_display->turnOn();
_auto_off = millis() + AUTO_OFF_MILLIS;
#endif
}
#endif
#ifdef WITH_WEBCONFIG
if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) {
if (!_display->isOn()) _display->turnOn();
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
+15
View File
@@ -2,6 +2,9 @@
#include <Mesh.h>
#include "MyMesh.h"
#if defined(ESP32) && MAX_RECENT_REPEATERS > 0
#include <new>
#endif
#ifdef DISPLAY_CLASS
#include "UITask.h"
@@ -15,8 +18,20 @@
StdRNG fast_rng;
#if MAX_RECENT_REPEATERS > 0
#if defined(ESP32)
// Classic ESP32 has a much smaller link-time DRAM window than its runtime heap
// map. Allocate this large, fixed-capacity history at startup so enabling WiFi
// features (including WebConfig) does not overflow that static DRAM window.
// Global constructors run before setup(), while the heap is still
// unfragmented; a failed allocation safely falls back to no history table.
SimpleMeshTables::RecentRepeaterInfo* recent_repeater_storage =
new (std::nothrow) SimpleMeshTables::RecentRepeaterInfo[MAX_RECENT_REPEATERS];
SimpleMeshTables tables(recent_repeater_storage,
recent_repeater_storage ? MAX_RECENT_REPEATERS : 0);
#else
SimpleMeshTables::RecentRepeaterInfo recent_repeater_storage[MAX_RECENT_REPEATERS];
SimpleMeshTables tables(recent_repeater_storage, MAX_RECENT_REPEATERS);
#endif
#else
SimpleMeshTables tables;
#endif
+234
View File
@@ -1,5 +1,9 @@
#include "MyMesh.h"
#include <helpers/CLICommandUtils.h>
#include <helpers/RxReservePacketManager.h>
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
@@ -883,6 +887,21 @@ void MyMesh::begin(FILESYSTEM *fs) {
_alerter.begin(&_prefs, _cli.getObserverPrefs(), this, this);
_alerter.setBridge(bridge);
#endif
#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP)
bool start_webui = WebConfigServer::loadEnabled(false);
#ifdef WITH_MQTT_BRIDGE
start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0;
if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) {
if (bridge && bridge->isRunning()) bridge->end();
}
#endif
if (start_webui) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
}
#endif
}
bool MyMesh::applySavedRadioParams() {
@@ -1059,6 +1078,209 @@ void MyMesh::clearStats() {
((SimpleMeshTables *)getTables())->resetStats();
}
#ifdef WITH_WEBCONFIG
void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
memset(&s, 0, sizeof(s));
StrHelper::strncpy(s.name, _prefs.node_name, sizeof(s.name));
StrHelper::strncpy(s.admin_password, _prefs.password, sizeof(s.admin_password));
s.lat = _prefs.node_lat;
s.lon = _prefs.node_lon;
s.freq = _prefs.freq;
s.bw = _prefs.bw;
s.sf = _prefs.sf;
s.cr = _prefs.cr;
s.tx_power = _prefs.tx_power_dbm;
s.airtime_factor = _prefs.airtime_factor;
s.rx_delay = _prefs.rx_delay_base;
s.tx_delay = _prefs.tx_delay_factor;
s.cad = _prefs.cad_enabled;
s.rx_gain = _prefs.rx_boosted_gain;
s.repeat = !_prefs.disable_fwd;
s.advert_interval = _prefs.advert_interval * 2;
s.flood_advert_interval = _prefs.flood_advert_interval;
s.flood_max = _prefs.flood_max;
s.flood_max_advert = _prefs.flood_max_advert;
s.flood_max_unscoped = _prefs.flood_max_unscoped;
s.loop_detect = _prefs.loop_detect;
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
| WebConfigServer::CAP_DELAYS | WebConfigServer::CAP_CAD
| WebConfigServer::CAP_RX_GAIN | WebConfigServer::CAP_REPEAT
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE;
}
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
if (_cli.getBoard()->isOTAUpdateRunning()) {
strcpy(reply, "Err: OTA server is running - 'stop ota' first");
return true;
}
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
return true;
}
if (!_webconfig) {
void* mqtt_prefs = nullptr;
bool owns_wifi = true;
#ifdef WITH_MQTT_BRIDGE
mqtt_prefs = _cli.getObserverPrefs();
owns_wifi = false;
#endif
_webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi,
self_id.pub_key, getFirmwareVer(), getRole(),
_cli.getBoard()->getManufacturerName());
if (!_webconfig) {
strcpy(reply, "Err: not enough memory for webconfig");
return true;
}
}
if (force_ap) {
#ifdef WITH_MQTT_BRIDGE
if (bridge && bridge->isRunning()) {
strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first");
return true;
}
#endif
_webconfig->startSetupMode(reply);
} else {
_webconfig->startAutoMode(reply);
}
return true;
}
bool MyMesh::setWebUIEnabled(bool enabled, char* reply) {
if (!WebConfigServer::saveEnabled(enabled)) {
strcpy(reply, "Error: failed to save webui setting");
return true;
}
if (enabled) {
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, "OK - webui on (already active)");
} else {
startWebConfig(false, reply);
if (strncmp(reply, "WebConfig", 9) == 0) {
char tmp[160];
StrHelper::strncpy(tmp, reply, sizeof(tmp));
snprintf(reply, 160, "OK - webui on; %s", tmp);
}
}
} else {
if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop();
strcpy(reply, "OK - webui off");
}
return true;
}
bool MyMesh::getWebUIStatus(char* reply) const {
const bool enabled = WebConfigServer::loadEnabled(false);
if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping())) {
snprintf(reply, 160, "> %s, inactive", enabled ? "on" : "off");
} else if (_webconfig->mode() == WebConfigServer::MODE_SETUP) {
char ssid[33], ip[16];
WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip));
snprintf(reply, 160, "> %s, setup AP %s http://%s/", enabled ? "on" : "off", ssid, ip);
} else if (_webconfig->mode() == WebConfigServer::MODE_CONNECTING) {
snprintf(reply, 160, "> %s, connecting to WiFi", enabled ? "on" : "off");
} else {
snprintf(reply, 160, "> %s, http://%s/", enabled ? "on" : "off",
WiFi.localIP().toString().c_str());
}
return true;
}
bool MyMesh::stopWebConfig(char* reply) {
if (!_webconfig || !_webconfig->isRunning()) {
strcpy(reply, "Err: webconfig not running");
return true;
}
_webconfig->requestStop();
strcpy(reply, "OK - webconfig stopping");
return true;
}
void MyMesh::onConfigBatchEnd() {
_wc_batch_active = false;
#ifdef WITH_MQTT_BRIDGE
if (_wc_restart_pending) {
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
restartBridge();
return;
}
const uint8_t mask = _wc_slot_restart_mask;
_wc_slot_restart_mask = 0;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (mask & (1U << i)) restartBridgeSlot(i);
}
#else
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
#endif
}
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
"\"noise\":%d,\"rssi\":%d,\"snr\":%.1f,"
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
(int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(),
radio_driver.getLastSNR(),
(unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000),
(unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(),
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
#ifdef WITH_MQTT_BRIDGE
bridge ? bridge->getQueueSize() : 0);
#else
0);
#endif
if (pos < 0 || pos >= static_cast<int>(buf_size) - 3) return;
bool first = true;
#ifdef WITH_MQTT_BRIDGE
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
MQTTBridge::SlotStatusSnapshot status;
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
int written;
if (status.has_publish_counts) {
written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\",\"ok\":%lu,\"err\":%lu}",
first ? "" : ",", i + 1, status.name, status.state,
status.publish_ok, status.publish_err);
} else {
written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
first ? "" : ",", i + 1, status.name, status.state);
}
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
pos += written;
first = false;
}
#else
(void)first;
#endif
snprintf(buf + pos, buf_size - pos, "]}");
}
#endif
void MyMesh::formatStatsReply(char *reply) {
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
}
@@ -1125,6 +1347,8 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
command += 3;
}
mesh::cli::normalizeCommandVerb(command);
// handle ACL related commands
if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8}
char* hex = &command[8];
@@ -1303,6 +1527,16 @@ void MyMesh::loop() {
radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures));
}
#ifdef WITH_WEBCONFIG
if (_webconfig) {
_webconfig->tick(millis());
if (!_webconfig->isRunning() && !_webconfig->isStopping()) {
delete _webconfig;
_webconfig = nullptr;
}
}
#endif
// is pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
acl.save(_fs, MyMesh::saveFilter);
+48 -1
View File
@@ -30,6 +30,11 @@
#define WITH_BRIDGE
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
#include "helpers/esp32/WebConfigServer.h"
#endif
/* ------------------------------ Config -------------------------------- */
#ifndef FIRMWARE_BUILD_DATE
@@ -94,7 +99,11 @@ struct PostInfo {
char text[MAX_POST_TEXT_LEN+1];
};
class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#ifdef WITH_WEBCONFIG
, public WebConfigServer::Callbacks
#endif
{
FILESYSTEM* _fs;
uint32_t last_millis;
uint64_t uptime_millis;
@@ -134,6 +143,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
#ifdef WITH_MQTT_BRIDGE
AlertReporter _alerter;
#endif
#ifdef WITH_WEBCONFIG
WebConfigServer* _webconfig = nullptr;
bool _wc_batch_active = false;
bool _wc_restart_pending = false;
uint8_t _wc_slot_restart_mask = 0;
#endif
void addPost(ClientInfo* client, const char* postData);
bool applySavedRadioParams();
@@ -317,6 +332,12 @@ public:
void restartBridge() override {
if (!bridge || !bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) {
_wc_restart_pending = true;
return;
}
#endif
bridge->end();
char device_id[65];
mesh::LocalIdentity self_id = getSelfId();
@@ -334,6 +355,12 @@ public:
void restartBridgeSlot(int slot) override {
#ifdef WITH_MQTT_BRIDGE
if (!bridge || !bridge->isRunning()) return;
#ifdef WITH_WEBCONFIG
if (_wc_batch_active && slot >= 0 && slot < MAX_MQTT_SLOTS) {
_wc_slot_restart_mask |= static_cast<uint8_t>(1U << slot);
return;
}
#endif
bridge->setSlotPreset(slot, _cli.getObserverPrefs()->mqtt_slot_preset[slot]);
#else
(void)slot;
@@ -359,6 +386,26 @@ public:
return bridge->ntpDiag(reply, reply_size, verbose);
}
#endif
#ifdef WITH_WEBCONFIG
bool startWebConfig(bool force_ap, char* reply) override;
bool stopWebConfig(char* reply) override;
bool setWebUIEnabled(bool enabled, char* reply) override;
bool getWebUIStatus(char* reply) const override;
bool isWebConfigActive() const override {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping());
}
void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override;
void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); }
void rebootNow() override { _cli.getBoard()->reboot(); }
void onConfigBatchStart() override {
_wc_batch_active = true;
_wc_restart_pending = false;
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
// To check if there is pending work
+41 -1
View File
@@ -6,8 +6,9 @@
#define USER_BTN_PRESSED LOW
#endif
#ifdef WITH_MQTT_BRIDGE
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h>
#endif
#define AUTO_OFF_MILLIS 20000 // 20 seconds
@@ -78,6 +79,38 @@ void UITask::renderCurrScreen() {
_display->setCursor((_display->width() - typeWidth) / 2, 48);
_display->print(node_type);
} else { // home screen
#ifdef WITH_WEBCONFIG
if (WebConfigServer::isRebootPending()) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setCursor(0, 14);
_display->print("Config saved!");
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 30);
_display->print("Rebooting...");
return;
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
_display->setTextSize(1);
_display->setColor(DisplayDriver::GREEN);
_display->setCursor(0, 0);
_display->print("WebUI WiFi Setup");
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->setColor(DisplayDriver::YELLOW);
_display->setCursor(6, 24);
_display->print(wc_ssid);
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 40);
_display->print("Then browse to:");
_display->setColor(DisplayDriver::YELLOW);
_display->setCursor(6, 50);
_display->print(wc_ip);
return;
}
#endif
// node name
_display->setCursor(0, 0);
_display->setTextSize(1);
@@ -127,6 +160,13 @@ void UITask::loop() {
}
#endif
#ifdef WITH_WEBCONFIG
if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) {
if (!_display->isOn()) _display->turnOn();
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
if (_display->isOn()) {
if (millis() >= _next_refresh) {
_display->startFrame();
+3
View File
@@ -1,4 +1,5 @@
#include "SensorMesh.h"
#include <helpers/CLICommandUtils.h>
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
@@ -503,6 +504,8 @@ void SensorMesh::handleCommand(uint32_t sender_timestamp, char* command, char* r
command += 3;
}
mesh::cli::normalizeCommandVerb(command);
// first, see if this is a custom-handled CLI command (ie. in main.cpp)
if (handleCustomCommand(sender_timestamp, command, reply)) {
return; // command has been handled
+7 -1
View File
@@ -66,7 +66,9 @@ monitor_filters = esp32_exception_decoder
; (board_build.partitions) with two app slots + otadata (boards without one fetch-refuse cleanly).
; pio_endf appends the EndF self-identity trailer. (nRF52 single-slot OTA is enabled per-board, not here —
; it needs a custom bootloader; RP2040/STM32 have no A/B path yet.)
extra_scripts = merge-bin.py
extra_scripts =
pre:scripts/generate_webconfig_html.py
merge-bin.py
post:tools/mota/pio_endf.py
build_flags = ${arduino_base.build_flags}
-D ESP32_PLATFORM
@@ -74,7 +76,11 @@ build_flags = ${arduino_base.build_flags}
-D OTA_FLASH_STORE=1
-D OTA_FOLDER_SERIAL ; `ota folder on` relays a host folder of .mota over the USB console (no extra HW)
; -D ESP32_CPU_FREQ=80 ; change it to your need
lib_deps = ${arduino_base.lib_deps}
ESP32Async/ESPAsyncWebServer @ 3.10.3
bblanchon/ArduinoJson
build_src_filter = ${arduino_base.build_src_filter}
+<helpers/esp32/WebConfigServer.cpp>
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Fail when an ESP32 app image (including EndF) exceeds its app partition."""
from __future__ import annotations
import struct
import sys
from pathlib import Path
ENTRY_SIZE = 32
PARTITION_MAGIC = 0x50AA
TYPE_APP = 0x00
SUBTYPE_OTA_0 = 0x10
def app_partition_size(table: bytes) -> tuple[str, int]:
first_app: tuple[str, int] | None = None
for offset in range(0, len(table) - ENTRY_SIZE + 1, ENTRY_SIZE):
entry = table[offset : offset + ENTRY_SIZE]
magic, part_type, subtype, _address, size = struct.unpack_from("<HBBII", entry)
if magic != PARTITION_MAGIC:
continue
if part_type != TYPE_APP:
continue
label = entry[12:28].split(b"\0", 1)[0].decode("ascii", "replace") or "app"
if first_app is None:
first_app = (label, size)
if subtype == SUBTYPE_OTA_0:
return label, size
if first_app is not None:
return first_app
raise ValueError("partition table has no app partition")
def main() -> int:
if len(sys.argv) != 3:
print(f"usage: {Path(sys.argv[0]).name} FIRMWARE.BIN PARTITIONS.BIN", file=sys.stderr)
return 2
firmware_path = Path(sys.argv[1])
partition_path = Path(sys.argv[2])
try:
image_size = firmware_path.stat().st_size
label, limit = app_partition_size(partition_path.read_bytes())
except (OSError, ValueError) as error:
print(f"ESP32 app-size check failed: {error}", file=sys.stderr)
return 2
if image_size > limit:
print(
f"ESP32 app image is {image_size} bytes, exceeding {label} "
f"({limit} bytes) by {image_size - limit} bytes",
file=sys.stderr,
)
return 1
print(
f"ESP32 app image fits {label}: {image_size}/{limit} bytes "
f"({limit - image_size} bytes free)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python
#
# Pre-build script: gzip webui/index.html into a PROGMEM C header so the
# webconfig portal can serve the page straight from flash with
# Content-Encoding: gzip. The generated header is tracked so ESP32 targets
# that replace extra_scripts still compile from a clean checkout; this script
# refreshes it whenever the source page (or this script) is newer.
#
# Output: src/helpers/esp32/WebConfigHtml.h
# WEBCONFIG_HTML_GZ[] - gzipped page (PROGMEM)
# WEBCONFIG_HTML_GZ_LEN - byte length
# WEBCONFIG_HTML_ETAG - quoted strong ETag (sha256 prefix of the gz body)
import gzip
import hashlib
import os
import sys
# PlatformIO/SCons injects Import(). Keep the generator directly runnable as
# well so maintainers can refresh and verify the tracked header without first
# starting a firmware build.
try:
Import("env") # noqa: F821
except NameError:
pass
SOURCE = os.path.join("webui", "index.html")
OUTPUT = os.path.join("src", "helpers", "esp32", "WebConfigHtml.h")
# __file__ is not defined inside PIO/SCons-executed extra_scripts
SCRIPT = os.path.join("scripts", "generate_webconfig_html.py")
def status(msg):
sys.stderr.write("WebConfig HTML: %s\n" % msg)
def needs_rebuild():
if not os.path.isfile(OUTPUT):
return True
out_mtime = os.path.getmtime(OUTPUT)
if os.path.getmtime(SOURCE) > out_mtime:
return True
if os.path.isfile(SCRIPT) and os.path.getmtime(SCRIPT) > out_mtime:
return True
return False
def main():
if not os.path.isfile(SOURCE):
status("ERROR: %s not found" % SOURCE)
sys.exit(2)
if not needs_rebuild():
return
with open(SOURCE, "rb") as f:
raw = f.read()
# mtime=0 keeps the gzip output (and therefore the ETag) deterministic
gz = gzip.compress(raw, compresslevel=9, mtime=0)
etag = hashlib.sha256(gz).hexdigest()[:16]
lines = []
lines.append("// Auto-generated by scripts/generate_webconfig_html.py from %s" % SOURCE.replace(os.sep, "/"))
lines.append("// DO NOT EDIT - edit webui/index.html instead.")
lines.append("#pragma once")
lines.append("#include <stdint.h>")
lines.append("#include <pgmspace.h>")
lines.append("")
lines.append("const uint32_t WEBCONFIG_HTML_GZ_LEN = %d;" % len(gz))
lines.append('const char WEBCONFIG_HTML_ETAG[] = "\\"%s\\"";' % etag)
lines.append("const uint8_t WEBCONFIG_HTML_GZ[] PROGMEM = {")
for i in range(0, len(gz), 16):
chunk = gz[i:i + 16]
lines.append(" " + "".join("0x%02x," % b for b in chunk))
lines.append("};")
lines.append("")
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
with open(OUTPUT, "w") as f:
f.write("\n".join(lines))
status("%s -> %s (%d bytes raw, %d bytes gzipped)" % (SOURCE, OUTPUT, len(raw), len(gz)))
# This script is attached only to esp32_base. Generate unconditionally here:
# CPPDEFINES is not fully resolved when PlatformIO loads pre-scripts, so trying
# to detect the final role at this point can leave a stale embedded page.
# Non-WebConfig ESP32 builds do not compile the generated data into firmware.
main()
+1
View File
@@ -65,6 +65,7 @@ public:
virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; }
virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported
virtual bool stopOTAUpdate(char reply[]) { return false; } // not supported
virtual bool isOTAUpdateRunning() const { return false; }
// Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it.
// current_ver is the running firmware version string (used to skip if already up to date); when
// dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only.
+22
View File
@@ -0,0 +1,22 @@
#pragma once
namespace mesh {
namespace cli {
// Mobile keyboards commonly capitalize the first word entered into a CLI
// field. Command verbs are identifiers, so normalize only that first token and
// leave every argument (names, passwords, keys, and other values) untouched.
inline void normalizeCommandVerb(char* command) {
if (command == nullptr) return;
while (*command == ' ' || *command == '\t') command++;
while (*command != 0 && *command != ' ' && *command != '\t') {
if (*command >= 'A' && *command <= 'Z') {
*command = static_cast<char>(*command - 'A' + 'a');
}
command++;
}
}
} // namespace cli
} // namespace mesh
+49 -1
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "CLICommandUtils.h"
#include "TxtDataHelpers.h"
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
@@ -1751,6 +1752,8 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
}
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
mesh::cli::normalizeCommandVerb(command);
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
// live in CommonCLI_Observer.cpp.
if (handleObserverCommand(sender_timestamp, command, reply)) return;
@@ -1787,6 +1790,20 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
} else {
strcpy(reply, "ERR: clock cannot go backwards");
}
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
} else if (memcmp(command, "start webconfig", 15) == 0
&& (command[15] == 0 || command[15] == ' ')) {
const bool force_ap = command[15] == ' ' && strcmp(&command[16], "ap") == 0;
if (command[15] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start webconfig [ap]");
} else if (!_callbacks->startWebConfig(force_ap, reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
} else if (strcmp(command, "stop webconfig") == 0) {
if (!_callbacks->stopWebConfig(reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
#endif
#ifdef ESP_PLATFORM
} else if (memcmp(command, "memory", 6) == 0) {
sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d",
@@ -1799,6 +1816,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
#endif
} else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) {
// Manual OTA: bring up the board's browser server for a hand-uploaded binary.
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (_callbacks->isWebConfigActive()) {
strcpy(reply, "ERR: stop webconfig first");
} else
#endif
if (!_board->startOTAUpdate(_prefs->node_name, reply)) {
strcpy(reply, "Error");
}
@@ -1977,7 +1999,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
} else if (memcmp(command, "gps sync", 8) == 0) {
LocationProvider * l = _sensors->getLocationProvider();
if (l != NULL) {
if (!_prefs->gps_enabled) {
strcpy(reply, "gps is off");
} else if (l != NULL) {
l->syncTime();
strcpy(reply, "scheduled");
} else {
@@ -2157,6 +2181,22 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (memcmp(config, "webui ", 6) == 0) {
const char* value = &config[6];
bool enabled;
if (strcmp(value, "on") == 0) enabled = true;
else if (strcmp(value, "off") == 0) enabled = false;
else {
strcpy(reply, "Error: usage set webui on|off");
return;
}
if (!_callbacks->setWebUIEnabled(enabled, reply)) {
strcpy(reply, "Error: webui not supported on this build");
}
return;
}
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverSetCmd(sender_timestamp, config, reply)) return;
if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) {
@@ -2967,6 +3007,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (strcmp(config, "webui") == 0) {
if (!_callbacks->getWebUIStatus(reply)) {
strcpy(reply, "Error: webui not supported on this build");
}
return;
}
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverGetCmd(sender_timestamp, config, reply)) return;
if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) {
+26
View File
@@ -466,6 +466,32 @@ public:
return false;
};
// Browser-based configuration portal. ESP32 infrastructure roles override
// these; force_ap=true asks for the captive SoftAP even when WiFi is set.
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
virtual bool startWebConfig(bool force_ap, char* reply) {
(void)force_ap;
(void)reply;
return false;
};
virtual bool stopWebConfig(char* reply) {
(void)reply;
return false;
};
virtual bool isWebConfigActive() const {
return false;
};
virtual bool setWebUIEnabled(bool enabled, char* reply) {
(void)enabled;
(void)reply;
return false;
};
virtual bool getWebUIStatus(char* reply) const {
(void)reply;
return false;
};
#endif
virtual int getQueueSize() {
return 0; // no op by default
};
+6
View File
@@ -385,12 +385,14 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
} else if (memcmp(subcmd, "server ", 7) == 0) {
StrHelper::strncpy(_mqtt_prefs.mqtt_slot_host[slot], &subcmd[7], sizeof(_mqtt_prefs.mqtt_slot_host[slot]));
savePrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (memcmp(subcmd, "port ", 5) == 0) {
int port = atoi(&subcmd[5]);
if (port > 0 && port <= 65535) {
_mqtt_prefs.mqtt_slot_port[slot] = port;
savePrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: port must be between 1 and 65535");
@@ -457,6 +459,10 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
savePrefs();
_callbacks->restartBridgeSlot(slot);
strcpy(reply, "OK");
} else if (strcmp(config, "mqtt.owner") == 0 || strcmp(config, "mqtt.owner ") == 0) {
_mqtt_prefs.mqtt_owner_public_key[0] = '\0';
savePrefs();
strcpy(reply, "OK - owner cleared");
} else if (memcmp(config, "mqtt.owner ", 11) == 0) {
const char* owner_key = &config[11];
int key_len = strlen(owner_key);
+1
View File
@@ -177,6 +177,7 @@ public:
bool startOTAUpdate(const char* id, char reply[]) override;
bool stopOTAUpdate(char reply[]) override;
bool isOTAUpdateRunning() const override { return ota_server != nullptr; }
bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override;
// Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task
// spawned by otaFromManifest() — public only so that task entry point can call
+31 -1
View File
@@ -260,6 +260,36 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPref
snprintf(buf + pos, bufsize - pos, ", q:%d", q);
}
bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out) {
if (!out || slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return false;
if (!s_mqtt_bridge_instance || !s_mqtt_bridge_instance->_initialized) return false;
MQTTBridge* bridge = s_mqtt_bridge_instance;
const MQTTSlot& slot = bridge->_slots[slot_index];
if (!slot.enabled && slot.preset) {
out->name = slot.preset->name;
out->state = "inactive";
} else if (!slot.enabled) {
return false;
} else {
out->name = slot.preset ? slot.preset->name : MQTT_PRESET_CUSTOM;
if (!bridge->isSlotReady(slot_index)) {
out->state = "wait";
} else if (slot.connected) {
out->state = "ok";
} else if (slot.circuit_breaker_tripped) {
out->state = "fail";
} else {
out->state = "disc";
}
}
out->has_publish_counts = false;
out->publish_ok = 0;
out->publish_err = 0;
return true;
}
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
@@ -2097,7 +2127,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
} else if (ps_pref == 2) {
ps_mode = WIFI_PS_MAX_MODEM;
} else {
ps_mode = WIFI_PS_NONE; // default: no power save; eliminates DTIM wake latency on mains-powered bridges
ps_mode = WIFI_PS_MIN_MODEM;
}
esp_wifi_set_ps(ps_mode);
#ifdef MQTT_WIFI_TX_POWER
+11
View File
@@ -502,6 +502,17 @@ public:
/** True after this bridge has successfully set the RTC from NTP this boot. */
bool hasNtpTime() const { return _ntp_synced; }
static void formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs);
// Structured status used by the browser configuration page. Publish
// counters are optional because not every supported PsychicMqttClient
// version exposes them.
struct SlotStatusSnapshot {
const char* name;
const char* state;
bool has_publish_counts;
unsigned long publish_ok;
unsigned long publish_err;
};
static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out);
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
static bool isConfigValid(const MQTTPrefs* obs);
static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
#pragma once
// Shared browser configuration portal for ESP32 infrastructure and WiFi
// companion builds. The UI removes MQTT controls when no bridge is present.
//
// Two modes:
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
// - LAN: bound to an existing or portal-owned STA connection. Infrastructure
// roles require the node admin password; companions use their trusted LAN.
//
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
// must never touch the CLI, prefs persistence, or the radio. Config writes
// are marshaled into a single-slot command batch that tick() - called from
// MyMesh::loop() on the Arduino loop task - drains through the existing CLI
// `set` handlers. Prefs-struct reads and batch state are guarded by _mux.
//
// The master switch and standalone WiFi credentials use independent NVS
// namespaces, so role-specific prefs-file layouts remain untouched.
#if defined(ESP_PLATFORM) && !defined(WEBCONFIG_DISABLED)
#define WITH_WEBCONFIG 1
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
class AsyncWebServer;
class AsyncWebServerRequest;
class DNSServer;
#ifndef WEBCONFIG_AP_IDLE_TIMEOUT_MS
#define WEBCONFIG_AP_IDLE_TIMEOUT_MS (10UL * 60UL * 1000UL)
#endif
#ifndef WEBCONFIG_SESSION_TTL_MS
#define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL)
#endif
class WebConfigServer {
public:
enum Mode : uint8_t { MODE_OFF = 0, MODE_SETUP, MODE_CONNECTING, MODE_LAN };
enum Capability : uint32_t {
CAP_LOCATION = 1UL << 0,
CAP_AIRTIME = 1UL << 1,
CAP_RX_DELAY = 1UL << 2,
CAP_CAD = 1UL << 3,
CAP_RX_GAIN = 1UL << 4,
CAP_REPEAT = 1UL << 5,
CAP_ADVERT = 1UL << 6,
CAP_FLOOD = 1UL << 7,
CAP_LOOP = 1UL << 8,
CAP_TX_DELAY = 1UL << 9,
CAP_WIFI_POWER_SAVE = 1UL << 10,
CAP_DELAYS = CAP_RX_DELAY | CAP_TX_DELAY,
};
// Role-neutral snapshot. Infrastructure and companion builds have different
// persisted NodePrefs layouts, so the portal never casts either one or
// assumes their member offsets match.
struct NodeSnapshot {
char name[32];
char admin_password[32]; // empty = trusted LAN/no login prompt
double lat;
double lon;
float freq;
float bw;
float airtime_factor;
float rx_delay;
float tx_delay;
int8_t tx_power;
uint8_t sf;
uint8_t cr;
uint8_t cad;
uint8_t rx_gain;
uint8_t repeat;
uint16_t advert_interval;
uint8_t flood_advert_interval;
uint8_t flood_max;
uint8_t flood_max_advert;
uint8_t flood_max_unscoped;
uint8_t loop_detect;
uint32_t capabilities;
};
class Callbacks {
public:
virtual void getNodeSnapshot(NodeSnapshot& snapshot) = 0;
// Run one CLI command. Called from tick() only (loop task); reply is 160 bytes.
virtual void execCommand(char* cmd, char* reply) = 0;
virtual void rebootNow() = 0;
// Bracket a config batch so bridge restarts triggered by individual
// `set` handlers can be coalesced into one.
virtual void onConfigBatchStart() {}
virtual void onConfigBatchEnd() {}
// Fill buf with the stats JSON snapshot. Called from tick() (loop task).
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (server + DNS freed, WiFi mode restored).
virtual void onWebConfigStopped() {}
};
// mqtt_prefs is an MQTTPrefs* on WITH_MQTT_BRIDGE builds and NULL otherwise.
// owns_wifi is true when the portal itself brought up STA/AP (normal
// repeater/room-server builds), false when the companion/MQTT runtime owns it.
WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi,
const uint8_t* pub_key, const char* fw_ver,
const char* role, const char* board_name);
~WebConfigServer();
// For the device display: true while a setup-mode portal is active.
// Fills the AP SSID and portal IP; either buffer may be NULL to just poll.
// Call from the loop task only (same task that changes the mode).
static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len);
// For the device display: true once a config save completed and the node is
// about to reboot — ground truth for the user even if the browser lost its
// connection before the confirmation arrived. Loop task only.
static bool isRebootPending();
// Persistent master switch and standalone WiFi credentials use separate NVS
// namespaces. This avoids changing NodePrefs or MQTTPrefs file layouts.
static bool loadEnabled(bool default_value = false);
static bool saveEnabled(bool enabled);
static bool loadStandaloneWiFi(char* ssid, size_t ssid_len,
char* password, size_t password_len,
uint8_t* power_save = NULL);
static bool saveStandaloneWiFi(const char* ssid, const char* password,
uint8_t power_save);
// UI tasks use an otherwise-unused multi-click gesture without reaching into
// MyMesh directly. The mesh loop consumes the request and performs the NVS /
// WiFi transition on its own task.
static void requestToggleFromButton();
static bool takeButtonToggleRequest();
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
bool startLanMode(char reply[]); // bind to existing STA connection
bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent
void requestStop(); // stop listening now, free after grace period
void tick(uint32_t now); // call every loop iteration
Mode mode() const { return _mode; }
bool isRunning() const { return _mode != MODE_OFF; }
bool isStopping() const { return _stopping; }
private:
static const int MAX_BATCH = 24;
static const size_t MAX_BODY = 4096;
enum BatchState : uint8_t { BATCH_IDLE = 0, BATCH_PENDING, BATCH_DONE };
struct BatchEntry {
char key[24]; // allowlisted `set` key (echoed back to the UI)
char cmd[160]; // full CLI command (may contain secrets - never echoed)
char reply[160];
};
Callbacks* _cb;
void* _mqtt_prefs;
bool _owns_wifi;
const uint8_t* _pub_key;
const char* _fw_ver;
const char* _role;
const char* _board_name;
AsyncWebServer* _server = NULL;
DNSServer* _dns = NULL;
SemaphoreHandle_t _mux;
Mode _mode = MODE_OFF;
bool _stopping = false;
bool _was_setup_ap = false;
uint32_t _connect_deadline = 0;
char _wifi_ssid[32] = {0};
char _wifi_password[64] = {0};
uint8_t _wifi_power_save = 1;
char _ap_ssid[33] = {0};
// Most-recently-created instance, for the display's getSetupInfo() poll.
static WebConfigServer* _active;
// Command batch: filled by async_tcp under _mux, drained by tick().
volatile BatchState _batch_state = BATCH_IDLE;
uint8_t _batch_count = 0;
uint8_t _batch_next = 0; // drain progress (one command per tick)
uint32_t _batch_last_cmd = 0;
bool _batch_reboot = false;
bool _batch_reboot_armed = false;
bool _standalone_wifi_dirty = false;
BatchEntry _batch[MAX_BATCH];
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
uint8_t _login_fails = 0;
uint32_t _login_lock_until = 0;
// Once set (via /api/portal/exit), OS captive probes get native "success"
// replies so the phone's sign-in sheet can be dismissed without dropping the
// WiFi, letting the user continue in their real browser. async_tcp-task only.
volatile bool _captive_release = false;
// Save-path diagnostics: 1 Hz serial trace for 60 s after each config POST
// (AP station count, heap, batch state) to pinpoint client drops on hardware.
volatile uint32_t _diag_until = 0;
uint32_t _diag_last = 0;
volatile uint32_t _last_activity = 0;
uint32_t _reboot_at = 0; // 0 = none scheduled
uint32_t _delete_at = 0; // deferred teardown deadline
volatile uint32_t _stats_wanted_until = 0;
uint32_t _stats_built_at = 0;
char _stats_json[1024] = {0};
void createServer();
void registerRoutes();
void drainBatch(uint32_t now);
void finalizeTeardown();
bool checkAuth(AsyncWebServerRequest* req);
static void collectBody(AsyncWebServerRequest* req, uint8_t* data, size_t len,
size_t index, size_t total);
void handleRoot(AsyncWebServerRequest* req);
void handleStatus(AsyncWebServerRequest* req);
void handleLogin(AsyncWebServerRequest* req);
void handleLogout(AsyncWebServerRequest* req);
void handleConfigGet(AsyncWebServerRequest* req);
void handleConfigPost(AsyncWebServerRequest* req);
void handleConfigResult(AsyncWebServerRequest* req);
void handleStats(AsyncWebServerRequest* req);
void handleScan(AsyncWebServerRequest* req);
void handlePresets(AsyncWebServerRequest* req);
void handleReboot(AsyncWebServerRequest* req);
void handlePortalExit(AsyncWebServerRequest* req);
void handleNotFound(AsyncWebServerRequest* req);
};
#endif // ESP_PLATFORM && !WEBCONFIG_DISABLED
+11 -5
View File
@@ -94,6 +94,9 @@ void SerialEthernetInterface::enable() {
void SerialEthernetInterface::disable() {
_isEnabled = false;
deviceConnected = false;
if (client) client.stop();
clearBuffers();
}
size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) {
@@ -153,9 +156,8 @@ size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) {
ETHERNET_DEBUG_PRINTLN("Closing previous client");
client.stop();
}
_state = RECV_STATE_IDLE;
_frame_len = 0;
_rx_len = 0;
// Queued replies and partial input belong to the previous TCP session.
clearBuffers();
client = newClient;
ETHERNET_DEBUG_PRINTLN("Switched to new client");
}
@@ -172,9 +174,13 @@ size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) {
deviceConnected = true;
}
} else {
if (deviceConnected) {
bool wasConnected = deviceConnected;
if (wasConnected || send_queue_len > 0 || _state != RECV_STATE_IDLE) {
deviceConnected = false;
ETHERNET_DEBUG_PRINTLN("Disconnected");
clearBuffers();
if (wasConnected) {
ETHERNET_DEBUG_PRINTLN("Disconnected");
}
}
}
+38 -7
View File
@@ -2,7 +2,7 @@
#include <stdint.h>
// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand.
// 514 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// 545 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
@@ -59,6 +59,22 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x9e40157c, "Heltec_E290_repeater_bridge_espnow" },
{ 0xb12e6e01, "Heltec_E290_room_server" },
{ 0xf6f835ec, "Heltec_mesh_solar_repeater" },
{ 0x7fab017f, "heltec_rc32_companion_radio_ble" },
{ 0xf310a4d1, "heltec_rc32_companion_radio_usb" },
{ 0xdd5953ec, "heltec_rc32_companion_radio_wifi" },
{ 0x97574bc2, "heltec_rc32_kiss_modem" },
{ 0x2d918a3c, "heltec_rc32_repeater" },
{ 0xd1755faa, "heltec_rc32_repeater_bridge_espnow" },
{ 0x8a6ceaf0, "heltec_rc32_room_server" },
{ 0x57f69746, "heltec_rc32_sensor" },
{ 0xbc05d6a7, "heltec_rc32_terminal_chat" },
{ 0x5bad80f1, "heltec_rc32_without_display_companion_radio_ble" },
{ 0x112d34c4, "heltec_rc32_without_display_companion_radio_usb" },
{ 0xf10a26df, "heltec_rc32_without_display_companion_radio_wifi" },
{ 0xc5409ced, "heltec_rc32_without_display_repeater" },
{ 0xf9022545, "heltec_rc32_without_display_repeater_bridge_espnow" },
{ 0x2023036d, "heltec_rc32_without_display_room_server" },
{ 0x4abd24af, "heltec_rc32_without_display_sensor" },
{ 0x35d42b35, "Heltec_t096_companion_radio_ble_femoff" },
{ 0x9f736ca7, "Heltec_t096_companion_radio_ble_femon" },
{ 0x3fbb1498, "Heltec_t096_companion_radio_usb_femoff" },
@@ -91,8 +107,6 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x85a8c944, "Heltec_tower_v2_repeater" },
{ 0xb16009a0, "heltec_tracker_v2_companion_radio_ble_femoff" },
{ 0xdc780e80, "heltec_tracker_v2_companion_radio_ble_femon" },
{ 0x9b1ab06d, "heltec_tracker_v2_companion_radio_ble_ps_femoff" },
{ 0x9cedf289, "heltec_tracker_v2_companion_radio_ble_ps_femon" },
{ 0x4aa63180, "heltec_tracker_v2_companion_radio_usb_femoff" },
{ 0x6fddcbf1, "heltec_tracker_v2_companion_radio_usb_femon" },
{ 0xf4fb5484, "heltec_tracker_v2_companion_radio_wifi_femoff" },
@@ -126,24 +140,38 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x21519537, "Heltec_v3_sensor" },
{ 0x382bb181, "Heltec_v3_terminal_chat" },
{ 0x5a2980e6, "heltec_v4_3_companion_radio_ble_femoff" },
{ 0x90581c83, "heltec_v4_3_companion_radio_ble_ps_femoff" },
{ 0x356df17c, "heltec_v4_3_companion_radio_usb_femoff" },
{ 0x99a084fc, "heltec_v4_3_companion_radio_wifi_femoff" },
{ 0x64c9dfc9, "heltec_v4_3_companion_radio_wifi_mqtt_femoff" },
{ 0xe65a7dfd, "heltec_v4_3_expansionkit_tft_companion_radio_ble_ps_femoff" },
{ 0x2a2ffc92, "heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff" },
{ 0x2c13b8e9, "heltec_v4_3_tft_companion_radio_ble_femoff" },
{ 0xb016979a, "heltec_v4_3_tft_companion_radio_usb_femoff" },
{ 0xb49b6b55, "heltec_v4_3_tft_companion_radio_wifi_femoff" },
{ 0x86fb85f0, "heltec_v4_companion_radio_ble_femon" },
{ 0xd33b0986, "heltec_v4_companion_radio_ble_ps_femon" },
{ 0xa8f5940f, "heltec_v4_companion_radio_usb_femon" },
{ 0x86ac3b1b, "heltec_v4_companion_radio_wifi_femon" },
{ 0x7af848da, "heltec_v4_companion_radio_wifi_mqtt_femon" },
{ 0xd522a14f, "heltec_v4_expansionkit_repeater" },
{ 0x76657d76, "heltec_v4_expansionkit_repeater_observer_mqtt" },
{ 0x21ee03bd, "heltec_v4_expansionkit_room_server_observer_mqtt" },
{ 0x2224a925, "heltec_v4_expansionkit_tft_companion_radio_ble_ps_femon" },
{ 0x5977d941, "heltec_v4_expansionkit_tft_companion_radio_ble_femon" },
{ 0xf75feb3e, "heltec_v4_kiss_modem" },
{ 0xbc9cdc70, "heltec_v4_r8_companion_radio_ble" },
{ 0x36c78d86, "heltec_v4_r8_companion_radio_usb" },
{ 0xdf722326, "heltec_v4_r8_companion_radio_wifi" },
{ 0xfa901c87, "heltec_v4_r8_kiss_modem" },
{ 0xeebe3469, "heltec_v4_r8_repeater" },
{ 0x5729ad07, "heltec_v4_r8_room_server" },
{ 0xb4cb035a, "heltec_v4_r8_sensor" },
{ 0xbb429dde, "heltec_v4_r8_terminal_chat" },
{ 0x883e3edc, "heltec_v4_r8_tft_companion_radio_ble" },
{ 0xa0965a6b, "heltec_v4_r8_tft_companion_radio_usb" },
{ 0x59ea25fa, "heltec_v4_r8_tft_companion_radio_wifi" },
{ 0x1758bdb9, "heltec_v4_r8_tft_kiss_modem" },
{ 0xa2fb4192, "heltec_v4_r8_tft_repeater" },
{ 0x7964461c, "heltec_v4_r8_tft_room_server" },
{ 0x5fc52bcb, "heltec_v4_r8_tft_sensor" },
{ 0x0dacb683, "heltec_v4_r8_tft_terminal_chat" },
{ 0xe792a051, "heltec_v4_repeater" },
{ 0x2d5ea842, "heltec_v4_repeater_bridge_espnow" },
{ 0xab74db9e, "heltec_v4_repeater_observer_mqtt" },
@@ -353,12 +381,15 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x6925aaf0, "RAK_3112_terminal_chat" },
{ 0x94c5555a, "RAK_3401_repeater" },
{ 0x9f4d58ac, "RAK_4631_companion_radio_ble" },
{ 0x7a55dec3, "RAK_4631_companion_radio_ethernet" },
{ 0x48d3b5d4, "RAK_4631_companion_radio_usb" },
{ 0x14e6965d, "RAK_4631_kiss_modem" },
{ 0x22ea8497, "RAK_4631_repeater_bridge_rs232_serial1" },
{ 0x21bae522, "RAK_4631_repeater_bridge_rs232_serial2" },
{ 0x25c20350, "RAK_4631_repeater_ethernet" },
{ 0xb876506d, "RAK_4631_repeater_no_external_sensors_lora_ota" },
{ 0x626d80ed, "RAK_4631_room_server" },
{ 0x6f9c3984, "RAK_4631_room_server_ethernet" },
{ 0x8b1d6850, "RAK_4631_sensor" },
{ 0xf1d3c5a8, "RAK_4631_terminal_chat" },
{ 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" },
@@ -191,7 +191,18 @@ class RAK12500LocationProvider : public LocationProvider {
int _sats = 0;
long _epoch = 0;
bool _fix = false;
mesh::RTCClock* _clock = NULL;
unsigned long _next_time_check = 0;
unsigned long _last_time_sync = 0;
uint8_t _valid_time_samples = 0;
static const unsigned long TIME_SYNC_INTERVAL = 1800000;
public:
void setRTCClock(mesh::RTCClock* clock) { _clock = clock; }
mesh::RTCClock* getRTCClock() override { return _clock; }
void syncTime() override {
_valid_time_samples = 0;
LocationProvider::syncTime();
}
long getLatitude() override { return _lat; }
long getLongitude() override { return _lng; }
long getAltitude() override { return _alt; }
@@ -212,7 +223,32 @@ public:
} else {
_fix = false;
}
bool date_valid = ublox_GNSS.getDateValid(2);
bool time_valid = ublox_GNSS.getTimeValid(2);
_epoch = ublox_GNSS.getUnixEpoch(2);
unsigned long now = millis();
if ((long)(now - _next_time_check) >= 0) {
_next_time_check = now + 1000;
if (_fix && _sats >= 5 && date_valid && time_valid && _epoch > 0) {
if (_valid_time_samples < 0xFF) _valid_time_samples++;
} else {
_valid_time_samples = 0;
}
if (!_time_sync_needed && _clock != NULL
&& (unsigned long)(now - _last_time_sync) > TIME_SYNC_INTERVAL) {
_time_sync_needed = true;
}
if (_time_sync_needed && _clock != NULL && _valid_time_samples > 2) {
_clock->setCurrentTime((uint32_t)_epoch);
markTimeSyncApplied();
_time_sync_needed = false;
_last_time_sync = now;
_last_valid_time_sync = _clock->getCurrentTime();
}
}
}
bool isEnabled() override { return true; }
};
@@ -899,6 +935,7 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){
gps_active = true;
gps_detected = true;
RAK12500_provider.setRTCClock(_location->getRTCClock());
_location = &RAK12500_provider;
return true;
} else if (Serial1.available()) {
+1
View File
@@ -32,6 +32,7 @@ public:
virtual void setNextGPSOn(unsigned long _millis) { _next_gps_on = _millis; }
virtual unsigned long getNextGPSOn() { return _next_gps_on; }
virtual unsigned long getLastValidTimeSync() { return _last_valid_time_sync; }
virtual mesh::RTCClock* getRTCClock() { return NULL; }
virtual long getLatitude() = 0;
virtual long getLongitude() = 0;
virtual long getAltitude() = 0;
@@ -110,6 +110,7 @@ public :
}
void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }
mesh::RTCClock* getRTCClock() override { return _clock; }
long getLatitude() override { return nmea.getLatitude(); }
long getLongitude() override { return nmea.getLongitude(); }
long getAltitude() override {
+30 -10
View File
@@ -405,6 +405,10 @@ static void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
static TFT_eSprite *sprite = NULL;
static bool spriteReady() {
return sprite != NULL && sprite->getPointer() != NULL;
}
bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) {
return true;
/*
@@ -422,18 +426,22 @@ bool ST7735Display::begin() {
if (!sprite) {
// alloc offscreen canvas
sprite = new TFT_eSprite(&lcd);
if (sprite) {
if (sprite->createSprite(160, 80)) {
sprite->fillScreen(ST77XX_BLACK);
sprite->setTextColor(curr_color = ST77XX_WHITE);
} else {
Serial.printf("ST7735Display: failed to alloc canvas pixels");
}
} else {
Serial.printf("ST7735Display: failed to alloc canvas");
if (!sprite) {
Serial.println("ST7735Display: failed to alloc canvas");
return false;
}
if (!sprite->createSprite(160, 80)) {
Serial.println("ST7735Display: failed to alloc canvas pixels");
delete sprite;
sprite = NULL;
return false;
}
sprite->fillScreen(ST77XX_BLACK);
sprite->setTextColor(curr_color = ST77XX_WHITE);
}
if (!spriteReady()) return false;
if (!_isOn) {
if (_peripher_power) _peripher_power->claim();
@@ -470,6 +478,8 @@ bool ST7735Display::begin() {
}
void ST7735Display::_resetAndInit() {
if (!spriteReady()) return;
// Pulse Reset low for 10ms
digitalWrite(PIN_TFT_RST, HIGH);
delay(2);
@@ -528,10 +538,12 @@ void ST7735Display::turnOff() {
}
void ST7735Display::clear() {
if (!spriteReady()) return;
sprite->fillScreen(ST77XX_BLACK);
}
void ST7735Display::startFrame(Color bkg) {
if (!spriteReady()) return;
sprite->fillScreen(ST77XX_BLACK);
sprite->setTextColor(curr_color = ST77XX_WHITE);
sprite->setFreeFont();
@@ -540,6 +552,7 @@ void ST7735Display::startFrame(Color bkg) {
}
void ST7735Display::setTextSize(int sz) {
if (!spriteReady()) return;
sprite->setTextSize(sz);
}
@@ -570,34 +583,41 @@ void ST7735Display::setColor(Color c) {
curr_color = ST77XX_WHITE;
break;
}
sprite->setTextColor(curr_color);
if (spriteReady()) sprite->setTextColor(curr_color);
}
void ST7735Display::setCursor(int x, int y) {
if (!spriteReady()) return;
sprite->setCursor(x*SCALE_X, y*SCALE_Y);
}
void ST7735Display::print(const char* str) {
if (!spriteReady()) return;
sprite->print(str);
}
void ST7735Display::fillRect(int x, int y, int w, int h) {
if (!spriteReady()) return;
sprite->fillRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color);
}
void ST7735Display::drawRect(int x, int y, int w, int h) {
if (!spriteReady()) return;
sprite->drawRect(x*SCALE_X, y*SCALE_Y, w*SCALE_X, h*SCALE_Y, curr_color);
}
void ST7735Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
if (!spriteReady()) return;
sprite->drawBitmap(x*SCALE_X, y*SCALE_Y, bits, w, h, curr_color);
}
uint16_t ST7735Display::getTextWidth(const char* str) {
if (!spriteReady()) return 0;
return sprite->textWidth(str) / SCALE_X;
}
void ST7735Display::endFrame() {
if (!spriteReady()) return;
// blit the canvas buffer to LCD
set_CS(LOW);
_spi->beginTransaction(_spiSettings);
@@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#include <helpers/CLICommandUtils.h>
TEST(CLICommandUtils, NormalizesCapitalizedSetVerb) {
char command[] = "Set altpath 600000,0d2784,F8DADA";
mesh::cli::normalizeCommandVerb(command);
EXPECT_STREQ("set altpath 600000,0d2784,F8DADA", command);
}
TEST(CLICommandUtils, NormalizesAnyCommandVerbCase) {
char get_command[] = "GET outpath";
char clear_command[] = "cLeAr recent.repeater";
mesh::cli::normalizeCommandVerb(get_command);
mesh::cli::normalizeCommandVerb(clear_command);
EXPECT_STREQ("get outpath", get_command);
EXPECT_STREQ("clear recent.repeater", clear_command);
}
TEST(CLICommandUtils, PreservesCaseSensitiveArguments) {
char command[] = "SET name RidgeNode-A";
mesh::cli::normalizeCommandVerb(command);
EXPECT_STREQ("set name RidgeNode-A", command);
}
TEST(CLICommandUtils, HandlesLeadingWhitespaceAndSingleWordCommands) {
char spaced_command[] = " Get outpath";
char single_command[] = "PowerSaving";
mesh::cli::normalizeCommandVerb(spaced_command);
mesh::cli::normalizeCommandVerb(single_command);
EXPECT_STREQ(" get outpath", spaced_command);
EXPECT_STREQ("powersaving", single_command);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+9
View File
@@ -0,0 +1,9 @@
# Two 1.5 MiB OTA slots with 896 KiB SPIFFS on a 4 MiB ESP32.
# Use for targets whose aligned app image no longer fits Arduino's 1.25 MiB default slots.
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x180000,
app1, app, ota_1, 0x190000,0x180000,
spiffs, data, spiffs, 0x310000,0xE0000,
coredump, data, coredump,0x3F0000,0x10000,
1 # Two 1.5 MiB OTA slots with 896 KiB SPIFFS on a 4 MiB ESP32.
2 # Use for targets whose aligned app image no longer fits Arduino's 1.25 MiB default slots.
3 # Name, Type, SubType, Offset, Size, Flags
4 nvs, data, nvs, 0x9000, 0x5000,
5 otadata, data, ota, 0xe000, 0x2000,
6 app0, app, ota_0, 0x10000, 0x180000,
7 app1, app, ota_1, 0x190000,0x180000,
8 spiffs, data, spiffs, 0x310000,0xE0000,
9 coredump, data, coredump,0x3F0000,0x10000,
+3
View File
@@ -1,6 +1,9 @@
[Ebyte_EoRa-S3]
extends = esp32_base
board = ebyte_eora-s3
; Keep dual OTA slots and SPIFFS while avoiding the board definition's
; undersized 1.25 MiB default app partition.
board_build.partitions = min_spiffs.csv
build_flags =
${esp32_base.build_flags}
-I variants/ebyte_eora_s3
@@ -36,7 +36,14 @@ lib_deps =
extends = GAT562_30S_Mesh_Kit
build_flags =
${GAT562_30S_Mesh_Kit.build_flags}
; Keep the repeater image inside the nRF52 in-place LoRa OTA workspace. The
; room-server and companion targets retain the complete optional sensor set.
${nrf52_ota_sensor_trim.build_flags}
-UENV_INCLUDE_MLX90614
-UENV_INCLUDE_BMP085
-UENV_INCLUDE_INA219
-UENV_INCLUDE_INA226
-UENV_INCLUDE_INA260
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"GAT562 Repeater"'
-D ADVERT_LAT=0.0
+4
View File
@@ -29,6 +29,7 @@ lib_deps =
[env:Generic_E22_sx1262_repeater]
extends = Generic_E22
board_build.partitions = min_spiffs.csv
build_src_filter = ${Generic_E22.build_src_filter}
+<../examples/simple_repeater/*.cpp>
build_flags =
@@ -75,6 +76,7 @@ lib_deps =
[env:Generic_E22_sx1262_repeater_bridge_espnow]
extends = Generic_E22
board_build.partitions = min_spiffs.csv
build_src_filter = ${Generic_E22.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater/*.cpp>
@@ -109,6 +111,7 @@ build_flags =
[env:Generic_E22_sx1268_repeater]
extends = Generic_E22
board_build.partitions = min_spiffs.csv
build_src_filter = ${Generic_E22.build_src_filter}
+<../examples/simple_repeater/*.cpp>
build_flags =
@@ -155,6 +158,7 @@ lib_deps =
[env:Generic_E22_sx1268_repeater_bridge_espnow]
extends = Generic_E22
board_build.partitions = min_spiffs.csv
build_src_filter = ${Generic_E22.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater/*.cpp>
+4
View File
@@ -36,6 +36,9 @@ lib_deps =
[env:Generic_ESPNOW_repeatr]
extends = Generic_ESPNOW
; WebUI plus the EndF trailer no longer fits the board's 1.25 MiB app slots.
; Keep dual OTA and SPIFFS using the larger established layout.
board_build.partitions = min_spiffs.csv
build_flags =
${Generic_ESPNOW.build_flags}
-D ADVERT_NAME='"ESPNOW Repeater"'
@@ -67,6 +70,7 @@ lib_deps =
[env:Generic_ESPNOW_room_svr]
extends = Generic_ESPNOW
board_build.partitions = min_spiffs.csv
build_flags =
${Generic_ESPNOW.build_flags}
-D ADVERT_NAME='"Generic ESPNow Room"'
+3
View File
@@ -1,6 +1,7 @@
[Heltec_ct62]
extends = esp32_base
board = esp32-c3-devkitm-1
board_build.partitions = variants/dual_ota_1536k.csv
build_flags =
${esp32_base.build_flags}
-I variants/heltec_ct62
@@ -42,6 +43,7 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and dual-slot LoRa OTA
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Heltec_ct62.build_src_filter}
@@ -82,6 +84,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and dual-slot LoRa OTA
; -D BRIDGE_DEBUG=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
+1 -1
View File
@@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
SolarSensorManager sensors = SolarSensorManager(nmea);
SolarExternalWatchdog external_watchdog;
+19
View File
@@ -67,6 +67,16 @@ build_src_filter = ${Heltec_t096.build_src_filter}
build_flags =
${Heltec_t096.build_flags}
; Keep the repeater image inside the nRF52 in-place LoRa OTA workspace. The
; sensor and companion targets retain the complete optional sensor set.
${nrf52_ota_sensor_trim.build_flags}
-UENV_INCLUDE_BMP085
-UENV_INCLUDE_MLX90614
-UENV_INCLUDE_INA226
-UENV_INCLUDE_INA260
-UENV_INCLUDE_INA219
-UENV_INCLUDE_LPS22HB
-UENV_INCLUDE_BMP280
-D ADVERT_NAME='"Heltec_t096 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -79,6 +89,15 @@ build_flags =
extends = Heltec_t096
build_flags =
${Heltec_t096.build_flags}
; The RS232 bridge shares the repeater's strict in-place LoRa OTA budget.
${nrf52_ota_sensor_trim.build_flags}
-UENV_INCLUDE_BMP085
-UENV_INCLUDE_MLX90614
-UENV_INCLUDE_INA226
-UENV_INCLUDE_INA260
-UENV_INCLUDE_INA219
-UENV_INCLUDE_LPS22HB
-UENV_INCLUDE_BMP280
-D ADVERT_NAME='"RS232 Bridge"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
+11 -1
View File
@@ -23,7 +23,7 @@ build_src_filter = ${nrf52_base.build_src_filter}
lib_deps =
${nrf52_base.lib_deps}
${sensor_base.lib_deps}
adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0
bodmer/TFT_eSPI @ ^2.4.31
debug_tool = jlink
upload_protocol = nrfutil
@@ -36,6 +36,16 @@ build_src_filter = ${Heltec_t1.build_src_filter}
build_flags =
${Heltec_t1.build_flags}
; Keep the repeater image inside the nRF52 in-place LoRa OTA workspace. The
; room-server and companion targets retain the complete optional sensor set.
${nrf52_ota_sensor_trim.build_flags}
-UENV_INCLUDE_BMP085
-UENV_INCLUDE_MLX90614
-UENV_INCLUDE_INA226
-UENV_INCLUDE_INA260
-UENV_INCLUDE_INA219
-UENV_INCLUDE_LPS22HB
-UENV_INCLUDE_BMP280
-D ADVERT_NAME='"Heltec_t1 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -89,6 +89,10 @@ const char* HeltecTowerV2Board::getManufacturerName() const {
}
void HeltecTowerV2Board::powerOff() {
shutdownPeripherals();
#ifdef NRF52_POWER_MANAGEMENT
initiateShutdown(SHUTDOWN_REASON_USER);
#else
pinMode(PIN_GPS_EN, OUTPUT);
digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE);
pinMode(PIN_GPS_STANDBY, OUTPUT);
@@ -100,4 +104,5 @@ void HeltecTowerV2Board::powerOff() {
digitalWrite(PIN_BAT_CTL, LOW);
variant_shutdown();
sd_power_system_off();
#endif
}
-11
View File
@@ -189,17 +189,6 @@ build_flags =
${env:heltec_tracker_v2_companion_radio_ble_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_tracker_v2_companion_radio_ble_ps_femon]
extends = env:heltec_tracker_v2_companion_radio_ble_femon
build_flags =
${env:heltec_tracker_v2_companion_radio_ble_femon.build_flags}
[env:heltec_tracker_v2_companion_radio_ble_ps_femoff]
extends = env:heltec_tracker_v2_companion_radio_ble_ps_femon
build_flags =
${env:heltec_tracker_v2_companion_radio_ble_ps_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_tracker_v2_companion_radio_wifi_femon]
extends = Heltec_tracker_v2
build_flags =
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, NULL, GPS_RESET, GPS_EN, &board.periph_power);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, GPS_RESET, GPS_EN, &board.periph_power);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -43,4 +43,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
-31
View File
@@ -50,37 +50,6 @@ public:
}
}
void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1) {
// Clear stale wakeup sources to avoid ghost wakeup
// This is required when Power Management and automatic lightsleep are enabled
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
// Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet
} else {
esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000);
}
// Finally set ESP32 into sleep
esp_deep_sleep_start(); // CPU halts here and never returns!
}
void powerOff() override {
enterDeepSleep(0);
}
uint16_t getBattMilliVolts() override {
analogReadResolution(10);
digitalWrite(PIN_ADC_CTRL, adc_active_state);
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -43,4 +43,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+4 -15
View File
@@ -472,17 +472,6 @@ build_flags =
${env:heltec_v4_companion_radio_ble_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_v4_companion_radio_ble_ps_femon]
extends = env:heltec_v4_companion_radio_ble_femon
build_flags =
${env:heltec_v4_companion_radio_ble_femon.build_flags}
[env:heltec_v4_3_companion_radio_ble_ps_femoff]
extends = env:heltec_v4_companion_radio_ble_ps_femon
build_flags =
${env:heltec_v4_companion_radio_ble_ps_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_v4_companion_radio_wifi_femon]
extends = heltec_v4_oled
build_flags =
@@ -697,17 +686,17 @@ build_flags =
${env:heltec_v4_tft_companion_radio_ble_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_v4_expansionkit_tft_companion_radio_ble_ps_femon]
[env:heltec_v4_expansionkit_tft_companion_radio_ble_femon]
extends = env:heltec_v4_tft_companion_radio_ble_femon
build_flags =
${env:heltec_v4_tft_companion_radio_ble_femon.build_flags}
-D ENV_PIN_SDA=4
-D ENV_PIN_SCL=3
[env:heltec_v4_3_expansionkit_tft_companion_radio_ble_ps_femoff]
extends = env:heltec_v4_expansionkit_tft_companion_radio_ble_ps_femon
[env:heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff]
extends = env:heltec_v4_expansionkit_tft_companion_radio_ble_femon
build_flags =
${env:heltec_v4_expansionkit_tft_companion_radio_ble_ps_femon.build_flags}
${env:heltec_v4_expansionkit_tft_companion_radio_ble_femon.build_flags}
-D RADIO_FEM_RXGAIN=0 ; undefined (default on), 1=on, 0=off
[env:heltec_v4_tft_companion_radio_wifi_femon]
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -43,4 +43,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+5 -23
View File
@@ -39,30 +39,12 @@ void HeltecV4R8Board::onAfterTransmit(void) {
loRaFEMControl.setRxModeEnable();
}
void HeltecV4R8Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY);
rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1);
rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS);
loRaFEMControl.setRxModeEnableWhenMCUSleep();
if (pin_wake_btn < 0) {
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH);
} else {
esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH);
}
if (secs > 0) {
esp_sleep_enable_timer_wakeup(secs * 1000000);
}
esp_deep_sleep_start();
}
void HeltecV4R8Board::powerOff() {
enterDeepSleep(0);
loRaFEMControl.setSleepModeEnable();
digitalWrite(P_LORA_PA_POWER, LOW);
rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER);
periph_power.release(); // Drop the permanent antenna-boost/VEXT claim from begin().
ESP32Board::powerOff();
}
uint16_t HeltecV4R8Board::getBattMilliVolts() {
-1
View File
@@ -23,7 +23,6 @@ public:
void begin();
void onBeforeTransmit(void) override;
void onAfterTransmit(void) override;
void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1);
void powerOff() override;
uint16_t getBattMilliVolts() override;
bool setAdcMultiplier(float multiplier) override {
+1 -2
View File
@@ -12,7 +12,7 @@ VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -34,4 +34,3 @@ mesh::LocalIdentity radio_new_identity() {
return mesh::LocalIdentity(&rng); // create new random identity
}
+2
View File
@@ -38,6 +38,7 @@ lib_deps =
extends = LilyGo_T3S3_sx1262
build_flags =
${LilyGo_T3S3_sx1262.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI and the original dual-OTA partition map
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"T3S3-1262 Repeater"'
-D ADVERT_LAT=0.0
@@ -81,6 +82,7 @@ lib_deps =
extends = LilyGo_T3S3_sx1262
build_flags =
${LilyGo_T3S3_sx1262.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI and the original dual-OTA partition map
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"ESPNow Bridge"'
-D ADVERT_LAT=0.0
@@ -35,6 +35,7 @@ lib_deps =
extends = LilyGo_T3S3_sx1276
build_flags =
${LilyGo_T3S3_sx1276.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI and the original dual-OTA partition map
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"T3S3-1276 Repeater"'
-D ADVERT_LAT=0.0
@@ -78,6 +79,7 @@ lib_deps =
extends = LilyGo_T3S3_sx1276
build_flags =
${LilyGo_T3S3_sx1276.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI and the original dual-OTA partition map
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"ESPNow Bridge"'
-D ADVERT_LAT=0.0
+1 -2
View File
@@ -19,7 +19,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -47,4 +47,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -38,4 +38,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+2 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -42,4 +42,4 @@ bool radio_init() {
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
}
@@ -19,7 +19,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -35,4 +35,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+3
View File
@@ -67,6 +67,9 @@ lib_deps =
[env:LilyGo_Tlora_C6_companion_radio_ble_]
extends = tlora_c6
; BLE plus the shared WebUI exceeds a dual-OTA slot. Companion releases
; explicitly disable LoRa OTA, so keep SPIFFS and use the 3 MB app layout.
board_build.partitions = huge_app.csv
build_flags = ${tlora_c6.build_flags}
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
+15
View File
@@ -52,6 +52,10 @@ lib_deps =
; === LILYGO T-LoRa V2.1-1.6 with SX1276 environments ===
[env:LilyGo_TLora_V2_1_1_6_repeater]
extends = LilyGo_TLora_V2_1_1_6
; The shared WebUI needs the largest dual-OTA layout that still leaves a 64 KB
; SPIFFS identity store. Keep both OTA slots and use the smaller built-in
; upload handler instead of ElegantOTA on this ultra-tight 4 MB target.
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<../examples/simple_repeater>
build_flags =
@@ -62,6 +66,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; ultra-tight classic ESP32: three wildcards plus one exact channel
-D LIGHTWEIGHT_WIFI_OTA=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; -D CORE_DEBUG_LEVEL=3
@@ -102,6 +107,8 @@ lib_deps =
[env:LilyGo_TLora_V2_1_1_6_companion_radio_ble]
extends = LilyGo_TLora_V2_1_1_6
; Keep both OTA slots while giving the BLE stack enough room for the shared companion code.
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_flags =
${LilyGo_TLora_V2_1_1_6.build_flags}
-I examples/companion_radio/ui-new
@@ -126,6 +133,8 @@ extends = env:LilyGo_TLora_V2_1_1_6_companion_radio_ble
[env:LilyGo_TLora_V2_1_1_6_room_server]
extends = LilyGo_TLora_V2_1_1_6
; Preserve full WebUI/ElegantOTA and LoRa OTA with the target's larger dual-slot layout.
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_room_server>
@@ -162,7 +171,9 @@ build_flags =
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D MAX_RECENT_REPEATERS=256 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS
-D WITH_MQTT_BRIDGE=1
-D WEBCONFIG_DISABLED=1 ; async portal exceeds this 4 MB target's dual-OTA app slot; use CLI setup
-D FLOOD_CHANNEL_SCOPE_SLOTS=4 ; no-PSRAM observer must retain internal DRAM for MQTT/TLS
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
@@ -210,6 +221,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
-D WITH_MQTT_BRIDGE=1
-D WEBCONFIG_DISABLED=1 ; async portal exceeds this 4 MB target's dual-OTA app slot; use CLI setup
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MAX_MQTT_BROKERS=3
-D MQTT_MAX_PACKET_SIZE=1024
@@ -237,6 +249,7 @@ lib_deps =
[env:LilyGo_TLora_V2_1_1_6_companion_radio_wifi]
extends = LilyGo_TLora_V2_1_1_6
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_flags =
${LilyGo_TLora_V2_1_1_6.build_flags}
-I examples/companion_radio/ui-new
@@ -260,6 +273,7 @@ lib_deps =
;
[env:LilyGo_TLora_V2_1_1_6_repeater_bridge_rs232]
extends = LilyGo_TLora_V2_1_1_6
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<helpers/bridges/RS232Bridge.cpp>
+<../examples/simple_repeater>
@@ -284,6 +298,7 @@ lib_deps =
[env:LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow]
extends = LilyGo_TLora_V2_1_1_6
board_build.partitions = variants/lilygo_tlora_v2_1/dual_ota_1984k.csv
build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<../examples/simple_repeater>
+5 -1
View File
@@ -72,11 +72,15 @@ lib_deps =
[env:M5Stack_Unit_C6L_companion_radio_ble]
extends = M5Stack_Unit_C6L
; BLE and the shared WebUI do not fit in the base target's 1.875 MB OTA slot.
; Companion profiles are not LoRa-OTA builds, so use the board's existing
; single-app 3 MB layout and retain a 896 KB SPIFFS data store.
board_build.partitions = huge_app.csv
build_flags = ${M5Stack_Unit_C6L.build_flags}
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D BLE_DEBUG_LOGGING=1
; -D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=256
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
+1 -1
View File
@@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
MASensorManager sensors = MASensorManager(nmea);
#ifdef DISPLAY_CLASS
+5 -4
View File
@@ -17,9 +17,10 @@ extern EnvironmentSensorManager sensors;
EnvironmentSensorManager sensors;
#endif
#ifdef DISPLAY_CLASS
NullDisplayDriver display;
#endif
#ifdef DISPLAY_CLASS
NullDisplayDriver display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
#ifndef LORA_CR
#define LORA_CR 5
@@ -80,4 +81,4 @@ bool radio_init() {
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
}
+9 -7
View File
@@ -9,13 +9,15 @@
#include <helpers/SensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#ifdef DISPLAY_CLASS
#include "NullDisplayDriver.h"
#endif
#ifdef DISPLAY_CLASS
extern NullDisplayDriver display;
#endif
#ifdef DISPLAY_CLASS
#include "NullDisplayDriver.h"
#include <helpers/ui/MomentaryButton.h>
#endif
#ifdef DISPLAY_CLASS
extern NullDisplayDriver display;
extern MomentaryButton user_btn;
#endif
extern MinewsemiME25LS01Board board;
extern WRAPPER_CLASS radio_driver;
+1
View File
@@ -20,6 +20,7 @@ public:
#ifdef NRF52_POWER_MANAGEMENT
void powerOff() override {
shutdownPeripherals();
initiateShutdown(SHUTDOWN_REASON_USER);
}
#endif
+1
View File
@@ -5,6 +5,7 @@
R1NeoBoard board;
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000);
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
+1 -1
View File
@@ -12,7 +12,7 @@ WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
NanoG2UltraSensorManager sensors = NanoG2UltraSensorManager(nmea);
#ifdef DISPLAY_CLASS
@@ -59,10 +59,11 @@ build_flags =
-D ADVERT_NAME='"ESPNow Bridge"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
build_src_filter = ${nibble_screen_connect_base.build_src_filter}
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and the original dual-OTA layout
build_src_filter = ${nibble_screen_connect_base.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater>
+1 -1
View File
@@ -59,6 +59,7 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_ESPNOW_BRIDGE=1
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and the original dual-OTA layout
build_src_filter = ${nibble_zero_connect_base.build_src_filter}
+<helpers/bridges/ESPNowBridge.cpp>
+<helpers/ui/SSD1306Display.cpp>
@@ -155,4 +156,3 @@ lib_deps =
${nibble_zero_connect_base.lib_deps}
densaugeo/base64 @ ~1.4.0
+1 -2
View File
@@ -12,7 +12,7 @@ VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -34,4 +34,3 @@ mesh::LocalIdentity radio_new_identity() {
return mesh::LocalIdentity(&rng); // create new random identity
}
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -43,4 +43,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+1 -2
View File
@@ -26,7 +26,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -41,4 +41,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+4
View File
@@ -109,6 +109,8 @@ lib_deps =
extends = rak4631
build_flags =
${rak4631.build_flags}
-UENABLE_OTA
-D DISABLE_LORA_OTA=1 ; Ethernet image exceeds the safe nRF52 in-place OTA ceiling
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"RAK4631 Repeater"'
-D ADVERT_LAT=0.0
@@ -192,6 +194,8 @@ build_src_filter = ${rak4631.build_src_filter}
extends = rak4631
build_flags =
${rak4631.build_flags}
-UENABLE_OTA
-D DISABLE_LORA_OTA=1 ; Ethernet image exceeds the safe nRF52 in-place OTA ceiling
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"RAK4631 Room Server"'
-D ADVERT_LAT=0.0
+1 -1
View File
@@ -26,7 +26,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
+1 -2
View File
@@ -22,7 +22,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -37,4 +37,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
@@ -52,6 +52,7 @@ public:
#endif
#ifdef NRF52_POWER_MANAGEMENT
shutdownPeripherals();
initiateShutdown(SHUTDOWN_REASON_USER);
#else
NRF52Board::powerOff();
+1
View File
@@ -1,6 +1,7 @@
[Tenstar_esp32_C3]
extends = esp32_base
board = esp32-c3-devkitm-1
board_build.partitions = min_spiffs.csv
build_flags =
${esp32_base.build_flags}
-I variants/tenstar_c3
+2
View File
@@ -45,6 +45,7 @@ build_src_filter = ${ThinkNode_M2.build_src_filter}
+<../examples/simple_repeater/*.cpp>
build_flags =
${ThinkNode_M2.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and the original dual-OTA layout
-D ADVERT_NAME='"Thinknode M2 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -84,6 +85,7 @@ build_src_filter = ${ThinkNode_M2.build_src_filter}
+<../examples/simple_repeater/*.cpp>
build_flags =
${ThinkNode_M2.build_flags}
-D LIGHTWEIGHT_WIFI_OTA=1 ; retain WebUI, browser OTA, and the original dual-OTA layout
-D ADVERT_NAME='"ESPNow Bridge"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
+2 -2
View File
@@ -11,7 +11,7 @@ WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#ifdef ENV_INCLUDE_GPS
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors = EnvironmentSensorManager();
@@ -81,4 +81,4 @@ bool radio_init() {
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
}
+1
View File
@@ -1,6 +1,7 @@
[ThinkNode_M5]
extends = esp32_base
board = ESP32-S3-WROOM-1-N4
board_build.partitions = min_spiffs.csv
build_flags = ${esp32_base.build_flags}
-I variants/thinknode_m5
-I src/helpers/sensors
@@ -4,14 +4,6 @@ void ThinkNodeM7Board::begin() {
ESP32Board::begin();
}
void ThinkNodeM7Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) {
esp_deep_sleep_start();
}
void ThinkNodeM7Board::powerOff() {
enterDeepSleep(0);
}
const char* ThinkNodeM7Board::getManufacturerName() const {
return "Elecrow ThinkNode M7";
}
-2
View File
@@ -11,8 +11,6 @@ class ThinkNodeM7Board : public ESP32Board {
public:
void begin();
void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1);
void powerOff() override;
const char* getManufacturerName() const override;
void onBeforeTransmit() override {
digitalWrite(P_LORA_TX_LED, LOW);
+3
View File
@@ -1,6 +1,9 @@
[Xiao_esp32_C3]
extends = esp32_base
board = seeed_xiao_esp32c3
; Preserve two OTA app slots while allowing the WebUI-enabled firmware to
; grow beyond the board definition's 1.25 MiB default app partition.
board_build.partitions = min_spiffs.csv
build_flags =
${esp32_base.build_flags}
-I variants/xiao_c3
+1 -2
View File
@@ -17,7 +17,7 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1);
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors;
@@ -39,4 +39,3 @@ mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
+4 -1
View File
@@ -51,6 +51,8 @@ lib_deps =
[env:Xiao_C6_companion_radio_ble_]
extends = Xiao_C6
; Companions have no OTA transport; keep a large app and SPIFFS-backed identity/contact storage.
board_build.partitions = huge_app.csv
build_flags = ${Xiao_C6.build_flags}
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
@@ -72,7 +74,6 @@ lib_deps =
; Meshimi variant
[Meshimi]
extends = Xiao_C6
board_build.partitions = max_app_4MB.csv
build_flags =
${Xiao_C6.build_flags}
-D P_LORA_TX_LED=15
@@ -108,6 +109,8 @@ lib_deps =
[env:Meshimi_companion_radio_ble_]
extends = Meshimi
; The repeater inherits dual OTA; only the no-OTA companion needs the single large app layout.
board_build.partitions = huge_app.csv
build_flags = ${Meshimi.build_flags}
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
+1
View File
@@ -34,6 +34,7 @@ build_src_filter = ${rak4631_hw.build_src_filter}
+<helpers/*.cpp>
+<helpers/sensors>
+<../variants/xiao_nrf52>
+<helpers/ui/MomentaryButton.cpp>
+<helpers/ui/NullDisplayDriver.cpp>
debug_tool = jlink
upload_protocol = nrfutil
+2 -1
View File
@@ -4,6 +4,7 @@
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
#endif
XiaoNrf52Board board;
@@ -32,4 +33,4 @@ bool radio_init() {
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
}
+2 -1
View File
@@ -14,7 +14,9 @@
#ifdef DISPLAY_CLASS
#include <helpers/ui/NullDisplayDriver.h>
#include <helpers/ui/MomentaryButton.h>
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
extern XiaoNrf52Board board;
@@ -28,4 +30,3 @@ extern EnvironmentSensorManager sensors;
bool radio_init();
mesh::LocalIdentity radio_new_identity();
+1029
View File
File diff suppressed because it is too large. Load diff