diff --git a/build.sh b/build.sh index 1a46bcfa..2129518e 100755 --- a/build.sh +++ b/build.sh @@ -146,6 +146,21 @@ disable_debug_flags() { fi } +get_firmware_build_date() { + local env_name="$1" + local header="examples/simple_repeater/MyMesh.h" + + if [[ "$env_name" == *companion_radio* ]]; then + header="examples/companion_radio/MyMesh.h" + elif [[ "$env_name" == *room_server* ]]; then + header="examples/simple_room_server/MyMesh.h" + elif [[ "$env_name" == *sensor* ]]; then + header="examples/simple_sensor/SensorMesh.h" + fi + + sed -n 's/^[[:space:]]*#define[[:space:]][[:space:]]*FIRMWARE_BUILD_DATE[[:space:]][[:space:]]*"\([^"]*\)".*/\1/p' "$header" | head -n 1 +} + # build firmware for the provided pio env in $1 build_firmware() { # get env platform for post build actions @@ -154,8 +169,12 @@ build_firmware() { # get git commit sha COMMIT_HASH=$(git rev-parse --short HEAD) - # set firmware build date - FIRMWARE_BUILD_DATE=$(date '+%d-%b-%Y') + # use the same fallback build date as direct PlatformIO builds + FIRMWARE_BUILD_DATE=$(get_firmware_build_date "$1") + if [ -z "$FIRMWARE_BUILD_DATE" ]; then + echo "FIRMWARE_BUILD_DATE could not be read for $1" + exit 1 + fi # get FIRMWARE_VERSION, which should be provided by the environment if [ -z "$FIRMWARE_VERSION" ]; then diff --git a/docs/custom-cli.md b/docs/custom-cli.md index 0da69ef0..84421883 100644 --- a/docs/custom-cli.md +++ b/docs/custom-cli.md @@ -8,6 +8,8 @@ It does not try to repeat the full upstream MeshCore CLI surface. These commands are available on `*_repeater_mqtt` firmware targets. +No-argument `get` commands must be entered exactly as shown. + ### MQTT Status And Routing - `get mqtt.status`: shows WiFi, NTP, IATA, endpoint status, status publishing state, and TX state. @@ -50,6 +52,8 @@ Notes: - new repeater MQTT installs default `mqtt.iata` to `UNSET` - `letsmesh-eu` and `letsmesh-us` remain off by default unless already configured in saved prefs - if `mqtt.iata` is `UNSET`, `eastmesh-au`, `letsmesh-eu`, and `letsmesh-us` will not connect even if they are toggled on +- turning off a connected broker publishes a retained MQTT status update with `"status":"offline"` before the client disconnects +- changing `mqtt.iata` away from a configured value also publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path Legacy dotted aliases are also accepted: diff --git a/docs/web-panel.md b/docs/web-panel.md index dea1ac2d..33185a5b 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -221,6 +221,8 @@ Notes: - while `mqtt.iata` is `UNSET`, enabled MQTT brokers do not attempt to connect - the current MQTT server states are loaded when the page opens - you can toggle each MQTT server on or off from this panel +- turning off a connected MQTT server publishes retained offline status before the client disconnects +- changing `mqtt.iata` away from a configured value publishes retained offline status to the old status topic, restarts connected broker clients, and reconnects under the new topic path - if all three servers are enabled at once, the panel shows a warning recommending two at most ## `/stats` Overview diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4b2a0714..5df5387b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2233,11 +2233,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } #endif #if defined(ESP_PLATFORM) - } else if (memcmp(command, "get wifi.status", 15) == 0) { + } else if (strcmp(command, "get wifi.status") == 0) { network.formatWifiStatusReply(reply, 160); - } else if (memcmp(command, "get wifi.ssid", 13) == 0) { + } else if (strcmp(command, "get wifi.ssid") == 0) { sprintf(reply, "> %s", network.getWifiSSID()[0] ? network.getWifiSSID() : "-"); - } else if (memcmp(command, "get wifi.powersaving", 20) == 0) { + } else if (strcmp(command, "get wifi.powersaving") == 0) { sprintf(reply, "> %s", network.getWifiPowerSave()); #endif #if defined(ESP_PLATFORM) && WITH_WEB_PANEL @@ -2308,23 +2308,23 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply mqtt.formatStatusReply(reply, 160); } else if (strcmp(command, "get mqtt.client_version") == 0) { sprintf(reply, "> %s", mqtt.getClientVersion()); - } else if (memcmp(command, "get mqtt.iata", 13) == 0) { + } else if (strcmp(command, "get mqtt.iata") == 0) { sprintf(reply, "> %s", mqtt.getIata()); - } else if (memcmp(command, "get mqtt.owner", 14) == 0) { + } else if (strcmp(command, "get mqtt.owner") == 0) { sprintf(reply, "> %s", mqtt.getOwnerPublicKey()[0] ? mqtt.getOwnerPublicKey() : "-"); - } else if (memcmp(command, "get mqtt.email", 14) == 0) { + } else if (strcmp(command, "get mqtt.email") == 0) { sprintf(reply, "> %s", mqtt.getOwnerEmail()[0] ? mqtt.getOwnerEmail() : "-"); - } else if (memcmp(command, "get mqtt.packets", 16) == 0) { + } else if (strcmp(command, "get mqtt.packets") == 0) { sprintf(reply, "> %s", mqtt.isPacketsEnabled() ? "on" : "off"); - } else if (memcmp(command, "get mqtt.raw", 12) == 0) { + } else if (strcmp(command, "get mqtt.raw") == 0) { sprintf(reply, "> %s", mqtt.isRawEnabled() ? "on" : "off"); - } else if (memcmp(command, "get mqtt.tx", 11) == 0) { + } else if (strcmp(command, "get mqtt.tx") == 0) { sprintf(reply, "> %s", mqtt.isTxEnabled() ? "on" : "off"); - } else if (memcmp(command, "get mqtt.eastmesh-au", 20) == 0 || memcmp(command, "get mqtt.eastmesh.au", 20) == 0) { + } else if (strcmp(command, "get mqtt.eastmesh-au") == 0 || strcmp(command, "get mqtt.eastmesh.au") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x01) ? "on" : "off"); - } else if (memcmp(command, "get mqtt.letsmesh-eu", 21) == 0 || memcmp(command, "get mqtt.letsmesh.eu", 21) == 0) { + } else if (strcmp(command, "get mqtt.letsmesh-eu") == 0 || strcmp(command, "get mqtt.letsmesh.eu") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x02) ? "on" : "off"); - } else if (memcmp(command, "get mqtt.letsmesh-us", 21) == 0 || memcmp(command, "get mqtt.letsmesh.us", 21) == 0) { + } else if (strcmp(command, "get mqtt.letsmesh-us") == 0 || strcmp(command, "get mqtt.letsmesh.us") == 0) { sprintf(reply, "> %s", mqtt.isEndpointEnabled(0x04) ? "on" : "off"); } else if (memcmp(command, "set mqtt.tx ", 12) == 0) { mqtt.setTxEnabled(memcmp(&command[12], "on", 2) == 0); diff --git a/release-notes.yml b/release-notes.yml index 854b0504..1e61e9fa 100644 --- a/release-notes.yml +++ b/release-notes.yml @@ -557,3 +557,27 @@ releases: area: web-panel text: "Documented no-PSRAM `/stats` history behavior, stale browser-token recovery, and the lightweight `/api/session` endpoint used by the web panel." breaking_changes: [] + + - track: repeater-mqtt + version: "1.3.15" + tag: "repeater-mqtt-eastmesh-v1.3.15" + date: "2026-04-30" + previous_version: "1.3.14" + summary: "Publishes retained offline status for clean MQTT broker disables and IATA topic changes so operator-driven disconnects do not leave stale online status behind." + changes: + - type: fixed + area: mqtt + text: "Published explicit retained offline status before a connected broker is toggled off, matching operator expectations without relying on last-will behavior that only fires on unclean disconnects." + - type: fixed + area: mqtt + text: "Published retained offline status to the old status topic before `mqtt.iata` changes, then restarted connected broker clients so they reconnect and publish online status under the new IATA path." + - type: docs + area: mqtt + text: "Updated the CLI and web-panel docs to describe retained offline status publishing for broker toggles and IATA changes." + - type: fixed + area: cli + text: "Matched no-argument MQTT and Wi-Fi `get` commands exactly so malformed commands with trailing text no longer execute as valid getters." + - type: changed + area: build + text: "Aligned release-style builds with direct PlatformIO builds by using the firmware source build date instead of stamping the current build day into `ver` output." + breaking_changes: [] diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index f7a37ae4..59e3ea4c 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -695,12 +695,12 @@ int MQTTUplink::buildRawJson(char* buffer, size_t buffer_size, const mesh::Packe return len; } -void MQTTUplink::publishOnlineStatus(BrokerState& broker) { +void MQTTUplink::publishBrokerStatus(BrokerState& broker, bool online) { char* payload = allocScratchBuffer(768); if (payload == nullptr) { return; } - int len = buildStatusJson(payload, 768, true); + int len = buildStatusJson(payload, 768, online); if (len > 0 && static_cast(len) < 768) { queuePublish(broker, broker.status_topic, payload, true); } @@ -999,7 +999,7 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) { connect_started = true; } if (broker.connected && !broker.connect_announced) { - publishOnlineStatus(broker); + publishBrokerStatus(broker, true); broker.connect_announced = true; _last_status_publish = millis(); } else if (!broker.connected) { @@ -1116,6 +1116,12 @@ bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) { return false; } } else { + for (BrokerState& broker : _brokers) { + if (broker.spec != nullptr && broker.spec->bit == bit && broker.connected && broker.client != nullptr) { + publishBrokerStatus(broker, false); + break; + } + } next_mask &= ~bit; } _prefs.enabled_mask = next_mask; @@ -1158,14 +1164,30 @@ bool MQTTUplink::setIata(const char* iata) { for (size_t i = 0; cleaned[i] != 0; ++i) { cleaned[i] = toupper(static_cast(cleaned[i])); } + bool changed = strcmp(cleaned, _prefs.iata) != 0; + if (changed && !isUnsetIataValue(_prefs.iata)) { + publishStatus(false); + } if (strcmp(cleaned, MQTT_UNSET_IATA) == 0) { StrHelper::strncpy(_prefs.iata, MQTT_UNSET_IATA, sizeof(_prefs.iata)); refreshIdentityStrings(); - return savePrefs(); + bool saved = savePrefs(); + if (changed) { + for (BrokerState& broker : _brokers) { + destroyBroker(broker); + } + } + return saved; } StrHelper::strncpy(_prefs.iata, cleaned, sizeof(_prefs.iata)); refreshIdentityStrings(); - return savePrefs(); + bool saved = savePrefs(); + if (changed) { + for (BrokerState& broker : _brokers) { + destroyBroker(broker); + } + } + return saved; } bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) { diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index 33a1ff56..915726f0 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -138,7 +138,7 @@ private: void destroyBroker(BrokerState& broker, bool reset_retry_state = true); bool refreshToken(BrokerState& broker); void publishStatus(bool online); - void publishOnlineStatus(BrokerState& broker); + void publishBrokerStatus(BrokerState& broker, bool online); void queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain); int buildStatusJson(char* buffer, size_t buffer_size, bool online) const; int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr,