Skip to content

Commit b699bf6

Browse files
authored
Merge branch 'mqtt-bridge-implementation-flex' into add-rflab-mqtt-preset
2 parents 32b0eb9 + c39ab63 commit b699bf6

31 files changed

Lines changed: 1122 additions & 132 deletions

File tree

.github/workflows/build-observer-firmwares.yml

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
runs-on: ubuntu-latest
4848
outputs:
4949
matrix: ${{ steps.split.outputs.matrix }}
50+
build_number: ${{ steps.buildnum.outputs.n }}
5051
steps:
5152
- name: Clone Repo
5253
uses: actions/checkout@v4
@@ -66,6 +67,27 @@ jobs:
6667
| jq -cs .)
6768
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
6869
70+
- name: Compute observer build number
71+
id: buildnum
72+
shell: bash
73+
run: |
74+
# Per-base published-build counter. N increments once per release and
75+
# resets to 1 when FIRMWARE_VERSION (the MeshCore base version) changes.
76+
# Read-only here (off the published counter); the release job is the sole
77+
# writer and only writes on a successful build, so a failed build doesn't
78+
# burn a number. First run / 404 -> empty -> N=1.
79+
COUNTER_URL="https://observer.gessaman.com/observer-build-counter.json"
80+
CUR=$(curl -fsSL "$COUNTER_URL" 2>/dev/null || echo '{}')
81+
PREV_BASE=$(echo "$CUR" | jq -r '.baseVersion // ""')
82+
PREV_BUILD=$(echo "$CUR" | jq -r '.build // 0')
83+
if [ "$PREV_BASE" = "$FIRMWARE_VERSION" ]; then
84+
N=$((PREV_BUILD + 1))
85+
else
86+
N=1
87+
fi
88+
echo "Base $FIRMWARE_VERSION; previous build $PREV_BUILD (base $PREV_BASE) -> N=$N"
89+
echo "n=$N" >> "$GITHUB_OUTPUT"
90+
6991
# Build one shard (several envs) per runner. build.sh emits both the app
7092
# .bin and the ESP32 -merged.bin into out/ for each env.
7193
build:
@@ -100,6 +122,9 @@ jobs:
100122
uses: ./.github/actions/setup-build-environment
101123

102124
- name: Build Shard ${{ matrix.shard.idx }}
125+
env:
126+
# Stamp the per-base build number into the embedded version (v1.16.0.N).
127+
FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
103128
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }}
104129

105130
- name: Upload Shard Artifact
@@ -112,7 +137,7 @@ jobs:
112137
# Collect all shard outputs, publish the rolling release, then point the
113138
# flasher at the new build by bumping the hash + notes in its config.json.
114139
release:
115-
needs: build
140+
needs: [enumerate, build]
116141
runs-on: ubuntu-latest
117142
steps:
118143
- name: Clone Repo
@@ -134,6 +159,8 @@ jobs:
134159
run: |
135160
mkdir -p out
136161
find artifacts -type f -name '*.bin' -exec cp -f {} out/ \;
162+
# Per-env partition-table signatures (for the slim manifest's OTA gate).
163+
find artifacts -type f -name '*.partsig' -exec cp -f {} out/ \;
137164
echo "Collected binaries:"; ls -1 out
138165
139166
- name: Compute Short SHA
@@ -153,8 +180,10 @@ jobs:
153180
--notes "Rolling build of all *_observer_mqtt firmwares. The git short hash is embedded in each asset filename."
154181
fi
155182
156-
# Upload this build, overwriting same-named assets.
157-
gh release upload "$RELEASE_TAG" out/* --clobber
183+
# Upload this build, overwriting same-named assets. Exclude the .partsig
184+
# files — those are consumed locally by the slim-manifest generator below,
185+
# not published as release assets (and they'd break the hash-based prune).
186+
gh release upload "$RELEASE_TAG" $(find out -maxdepth 1 -type f ! -name '*.partsig') --clobber
158187
159188
# Prune old assets, but RETAIN the most recent KEEP_BUILDS build hashes
160189
# (not just the current one). The flasher SPA reads config.json once at
@@ -176,7 +205,10 @@ jobs:
176205
echo "Retaining build hashes:"; echo "$keep_hashes"
177206
gh release view "$RELEASE_TAG" --json assets -q '.assets[].name' \
178207
| while read -r asset; do
179-
ah=$(printf '%s' "$asset" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}')
208+
# `|| true`: a non-firmware asset (no embedded hash) makes grep exit
209+
# non-zero, which under `bash -e` would abort the step. Empty ah then
210+
# falls through to delete-asset, cleaning up any stray non-.bin asset.
211+
ah=$(printf '%s' "$asset" | grep -oiE '[0-9a-f]{7,40}(-merged)?\.bin$' | grep -oiE '^[0-9a-f]{7,40}' || true)
180212
if [ -n "$ah" ] && grep -qxF "$ah" <<<"$keep_hashes"; then
181213
continue
182214
fi
@@ -196,6 +228,24 @@ jobs:
196228
"${{ steps.sha.outputs.short }}" \
197229
"$GITHUB_WORKSPACE/firmware-notes.html"
198230
231+
- name: Generate Slim Per-Variant Manifests + Persist Build Counter
232+
env:
233+
BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
234+
run: |
235+
# Derive the slim per-variant manifests (flasher/v/<env>.json) that the
236+
# firmware fetches for `ota check`/`ota update`, from the just-updated
237+
# config.json (single source of truth), stamping this build's number.
238+
# Then persist the counter so the next run increments from here.
239+
python3 flasher/scripts/gen-slim-manifests.py \
240+
--config flasher/config.json \
241+
--out-dir flasher/v \
242+
--base-version "$FIRMWARE_VERSION" \
243+
--build "$BUILD_NUMBER" \
244+
--partsig-dir out
245+
printf '{\n "baseVersion": "%s",\n "build": %s\n}\n' \
246+
"$FIRMWARE_VERSION" "$BUILD_NUMBER" > flasher/observer-build-counter.json
247+
echo "Build $FIRMWARE_VERSION.$BUILD_NUMBER"
248+
199249
- name: Unshallow for Changelog
200250
run: |
201251
# gen_changelog.py needs the full branch history; deepen only now, AFTER
@@ -233,11 +283,15 @@ jobs:
233283
- name: Commit & Push Flasher Config
234284
working-directory: flasher
235285
run: |
236-
if git diff --quiet; then
286+
# Stage everything: update-firmware.py edits config.json in place, but the
287+
# slim manifests (v/*.json) and the build counter can be NEW files, which
288+
# `commit -am` would miss — so add -A and check the staged diff.
289+
git add -A
290+
if git diff --cached --quiet; then
237291
echo "No flasher changes to commit."
238292
exit 0
239293
fi
240294
git config user.name "meshcore-bot"
241295
git config user.email "noreply@gessaman.com"
242-
git commit -am "Update observer firmware to ${{ steps.sha.outputs.short }}"
296+
git commit -m "Update observer firmware to ${{ steps.sha.outputs.short }} (build ${FIRMWARE_VERSION}.${{ needs.enumerate.outputs.build_number }})"
243297
git push

MQTT_IMPLEMENTATION.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ The MQTT bridge uses a slot-based architecture with up to 6 concurrent connectio
116116
| `meshcore-ca-1` | mqtt1.meshcore.ca:443 | JWT (Ed25519) | WSS |
117117
| `meshcore-ca-2` | mqtt2.meshcore.ca:443 | JWT (Ed25519) | WSS |
118118
| `bostonmesh` | mqttmc01.bostonme.sh:443 | JWT (Ed25519) | WSS |
119+
| `ipnt.uk` | mqtt.ipnt.uk:443 | JWT (Ed25519) | WSS |
120+
| `flmesh` | mcmqtt.jntconnections.com:443 | JWT (Ed25519) | WSS |
119121
| `inwmesh` | scope.inwmesh.org:8883 | Username/password (per slot via `mqttN.username` / `mqttN.password`) | MQTT over TLS |
120122
| `rflab` | mqtt.rflab.io:443 | JWT (Ed25519) | WSS |
121123
| `custom` | User-configured | Username/Password | MQTT or WSS |
@@ -378,6 +380,8 @@ These settings apply across all MQTT slots:
378380
- `get mqtt.rx` - Get RX packet uplinking setting (on/off)
379381
- `get mqtt.tx` - Get TX packet uplinking setting (on/off/advert)
380382
- `get mqtt.interval` - Get status publish interval
383+
- `get mqtt.ntp` - Get effective NTP server hostname
384+
- `get mqtt.ntp.diag` - Probe every configured NTP server for connectivity (does not change the clock; serial console shows each server's reported time, LoRa shows a compact `<server> ok|fail` list)
381385
- `get mqtt.owner` - Get owner public key (serial console only)
382386
- `get mqtt.email` - Get owner email address (serial console only)
383387

@@ -393,6 +397,7 @@ These settings apply across all MQTT slots:
393397
- `advert` - Uplink only this node's own advert packets (self-originated)
394398
- `off` - Disable TX packet uplinking
395399
- `set mqtt.interval <minutes>` - Set status publish interval (1-60 minutes)
400+
- `set mqtt.ntp <hostname>` - Set custom NTP server (validated with immediate sync); `none` reverts to default
396401
- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key
397402
- `set mqtt.email <email>` - Set owner email address
398403

@@ -607,8 +612,13 @@ Minimal raw packet data for map integration.
607612
- Automatic reconnection with exponential backoff
608613

609614
### NTP Time Synchronization
610-
- Automatic time synchronization with NTP servers
611-
- Periodic time updates (every hour)
615+
- Automatic time synchronization with NTP servers (required for JWT authentication)
616+
- Default primary: `pool.ntp.org`; built-in fallbacks (tried sequentially on failure): `time.google.com`, `time.cloudflare.com`, `time.aws.com`, `time.nist.gov`
617+
- Custom primary via `set mqtt.ntp <hostname>`; `set mqtt.ntp none` reverts to default
618+
- `set mqtt.ntp` runs an immediate sync (primary only, so a typo fails fast) when WiFi is connected and the bridge is running
619+
- `get mqtt.ntp` returns the effective primary hostname
620+
- `get mqtt.ntp.diag` probes every configured server (primary + fallbacks) for connectivity and reports each server's time without changing the system clock — a pure diagnostic
621+
- Periodic time updates (every hour) on the effective primary only
612622
- Proper UTC system time handling
613623

614624
### Authentication

build.sh

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,23 @@ build_firmware() {
149149
case "$1" in
150150
*observer*) VARIANT_TAG="-observer" ;;
151151
esac
152-
EMBEDDED_VERSION_STRING="${FIRMWARE_VERSION}${VARIANT_TAG}-${COMMIT_HASH}"
153152

154-
# add firmware version info to end of existing platformio build flags in environment vars
155-
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"'"
153+
# Observer build number: when CI provides FIRMWARE_BUILD_NUMBER (the per-base
154+
# published-build counter), append it as a 4th version component so the node
155+
# reports e.g. v1.16.0.5-observer-abcdef and `ota check` can show how many
156+
# builds behind it is. Local dev builds leave it unset → no 4th component.
157+
# The *filename* (FIRMWARE_VERSION_STRING above) is deliberately left without
158+
# the build number so assets stay <env>-v<base>-<hash>.bin.
159+
BUILD_NUMBER_SUFFIX=""
160+
if [ -n "$FIRMWARE_BUILD_NUMBER" ]; then
161+
BUILD_NUMBER_SUFFIX=".${FIRMWARE_BUILD_NUMBER}"
162+
fi
163+
EMBEDDED_VERSION_STRING="${FIRMWARE_VERSION}${BUILD_NUMBER_SUFFIX}${VARIANT_TAG}-${COMMIT_HASH}"
164+
165+
# add firmware version info to end of existing platformio build flags in environment vars.
166+
# OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest
167+
# (<OTA_MANIFEST_BASE>/<OTA_VARIANT>.json) that the observer pull-OTA fetches.
168+
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"'"
156169

157170
# disable debug flags if requested
158171
disable_debug_flags
@@ -175,6 +188,13 @@ build_firmware() {
175188
cp .pio/build/$1/firmware.uf2 out/${FIRMWARE_FILENAME}.uf2 2>/dev/null || true
176189
cp .pio/build/$1/firmware.zip out/${FIRMWARE_FILENAME}.zip 2>/dev/null || true
177190

191+
# Emit the partition-table signature (ESP32) for OTA partition-compatibility
192+
# checks. Keyed by env name so the slim-manifest generator can find it; the
193+
# firmware computes the same signature at runtime from its flashed table.
194+
if [ -f ".pio/build/$1/partitions.bin" ]; then
195+
python3 scripts/partition_signature.py ".pio/build/$1/partitions.bin" > "out/$1.partsig" 2>/dev/null || true
196+
fi
197+
178198
}
179199

180200
# firmwares containing $1 will be built

docs/cli_commands.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,33 @@ region save
10761076
10771077
---
10781078

1079+
#### View or change the NTP server (MQTT observer only)
1080+
**Usage:**
1081+
- `get mqtt.ntp`
1082+
- `set mqtt.ntp <hostname>`
1083+
- `set mqtt.ntp none`
1084+
1085+
**Description:** Sets the primary NTP server used for clock sync (required for JWT MQTT auth). On `set`, the device attempts an immediate sync of the just-configured server (primary only, so a typo fails fast) when WiFi is connected and the MQTT bridge is running.
1086+
1087+
**Fallbacks:** If the primary fails, the firmware tries `pool.ntp.org`, `time.google.com`, `time.cloudflare.com`, `time.aws.com`, and `time.nist.gov` in order (skipping duplicates).
1088+
1089+
**Default:** `pool.ntp.org` (when unset or `none`)
1090+
1091+
---
1092+
1093+
#### Diagnose NTP server connectivity (MQTT observer only)
1094+
**Usage:**
1095+
- `get mqtt.ntp.diag`
1096+
1097+
**Description:** Probes every configured NTP server (the custom primary, if set, plus the built-in fallbacks) and reports whether each responds. This is a pure connectivity diagnostic — it does **not** change the system clock.
1098+
1099+
- **Serial console:** prints a detailed table with each server's reported UTC time (or `FAIL`).
1100+
- **Over LoRa:** returns a compact `<server> ok|fail` list, one per line.
1101+
1102+
Requires WiFi connected and the MQTT bridge running.
1103+
1104+
---
1105+
10791106
#### View or change the speed of the bridge (RS-232 only)
10801107
**Usage:**
10811108
- `get bridge.baud`

examples/simple_repeater/MyMesh.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,23 @@ void MyMesh::loop() {
14451445
MESH_DEBUG_PRINTLN("Radio params restored");
14461446
}
14471447

1448+
#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)
1449+
if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update`
1450+
_ota_update_at = 0; // clear timer
1451+
// The "Beginning update..." reply has now gone out. Free the bridge for heap
1452+
// headroom, then flash: otaFromManifest reboots into the new image on success
1453+
// (so this never returns); on any abort (already up to date, partition change,
1454+
// download error) it returns and we resume the bridge.
1455+
Serial.println("OTA: starting update");
1456+
setBridgeState(false);
1457+
char ota_reply[160];
1458+
if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
1459+
Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply);
1460+
setBridgeState(true);
1461+
}
1462+
}
1463+
#endif
1464+
14481465
// is pending dirty contacts write needed?
14491466
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
14501467
acl.save(_fs);

examples/simple_repeater/MyMesh.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
116116
#endif
117117
CayenneLPP telemetry;
118118
unsigned long set_radio_at, revert_radio_at;
119+
unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled)
119120
float pending_freq;
120121
float pending_bw;
121122
uint8_t pending_sf;
@@ -306,9 +307,33 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
306307
bridge->setSlotPreset(slot, _prefs.mqtt_slot_preset[slot]);
307308
}
308309

310+
// Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the
311+
// "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit
312+
// before the flash blocks the loop and reboots.
313+
bool beginDeferredOtaUpdate() override {
314+
_ota_update_at = millis() + 2500;
315+
if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none"
316+
return true;
317+
}
318+
309319
int getQueueSize() override {
310320
return bridge ? bridge->getQueueSize() : 0;
311321
}
322+
323+
bool isMqttBridgeRunning() override {
324+
return bridge && bridge->isRunning();
325+
}
326+
327+
bool syncMqttNtp() override {
328+
if (!bridge || !bridge->isRunning()) return false;
329+
// Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1).
330+
return bridge->requestForcedNtpSync();
331+
}
332+
333+
bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) override {
334+
if (!bridge || !bridge->isRunning()) return false;
335+
return bridge->ntpDiag(reply, reply_size, verbose);
336+
}
312337
#endif
313338

314339
// To check if there is pending work

examples/simple_repeater/main.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ void setup() {
8282
store.save("_main", the_mesh.self_id);
8383
}
8484

85+
// Print the running firmware version at boot so it's visible after an OTA
86+
// reboot without having to issue `ver` manually.
87+
Serial.print("Firmware: "); Serial.print(FIRMWARE_VERSION);
88+
Serial.print(" (built "); Serial.print(FIRMWARE_BUILD_DATE); Serial.println(")");
89+
8590
Serial.print("Repeater ID: ");
8691
mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println();
8792

examples/simple_room_server/MyMesh.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,5 +296,20 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
296296
int getQueueSize() override {
297297
return bridge ? bridge->getQueueSize() : 0;
298298
}
299+
300+
bool isMqttBridgeRunning() override {
301+
return bridge && bridge->isRunning();
302+
}
303+
304+
bool syncMqttNtp() override {
305+
if (!bridge || !bridge->isRunning()) return false;
306+
// Marshal onto the MQTT task (Core 0); this runs on the CLI thread (Core 1).
307+
return bridge->requestForcedNtpSync();
308+
}
309+
310+
bool runMqttNtpDiag(char* reply, size_t reply_size, bool verbose) override {
311+
if (!bridge || !bridge->isRunning()) return false;
312+
return bridge->ntpDiag(reply, reply_size, verbose);
313+
}
299314
#endif
300315
};

scripts/partition_signature.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
"""Canonical signature of an ESP32 partition table (from a partitions.bin).
3+
4+
Used to decide OTA partition compatibility: the observer firmware computes the
5+
same signature at runtime from its *flashed* partition table (via esp_partition),
6+
and `ota update` refuses only when the target build's signature differs from the
7+
running device's — i.e. a real partition-table change, not a blanket flag.
8+
9+
The signature MUST be computed identically here and in firmware
10+
(src/helpers/ESP32Board.cpp). Definition:
11+
12+
for each partition-table entry: (type, subtype, offset, size)
13+
sort by offset ascending
14+
format each as "%x:%x:%x:%x" (lowercase hex, no 0x, no padding)
15+
join with ","
16+
17+
partitions.bin layout: 32-byte records, each starting with magic 0xAA 0x50; the
18+
trailing MD5 record (magic 0xEB 0xEB) and 0xFF padding are ignored.
19+
20+
Usage: partition_signature.py <partitions.bin> # prints the signature
21+
"""
22+
import struct
23+
import sys
24+
25+
26+
def signature(bin_path: str) -> str:
27+
data = open(bin_path, "rb").read()
28+
entries = []
29+
for i in range(0, len(data) - 31, 32):
30+
rec = data[i:i + 32]
31+
if rec[0:2] != b"\xaa\x50": # not a partition entry (MD5 record / padding)
32+
continue
33+
ptype, subtype = rec[2], rec[3]
34+
offset, size = struct.unpack("<II", rec[4:12])
35+
entries.append((ptype, subtype, offset, size))
36+
entries.sort(key=lambda e: e[2])
37+
return ",".join("%x:%x:%x:%x" % e for e in entries)
38+
39+
40+
if __name__ == "__main__":
41+
if len(sys.argv) != 2:
42+
print("usage: partition_signature.py <partitions.bin>", file=sys.stderr)
43+
sys.exit(2)
44+
print(signature(sys.argv[1]))

0 commit comments

Comments
 (0)