diff --git a/README.md b/README.md index ee3b8573..16dc1889 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ For flashing guidance, including when to use `.bin` vs `-merged.bin`, see: The custom firmware flasher site is: -- +- ## Install uv diff --git a/docs/releases.md b/docs/releases.md index 331dc265..e041d537 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -46,15 +46,69 @@ Practical rule: ## Recommended Flasher -For custom firmware files, use: +The recommended flasher is: -- +- + +It includes native support for: + +- `companion_radio_wifi` firmware +- `repeater_mqtt` firmware +- custom firmware files Recommended usage: - use the normal `.bin` there when you are updating an existing device - use the `-merged.bin` there after an erase when you want a clean flash +## Beginner Setup + +If this is your first time flashing EastMesh firmware, the easiest path is: + +1. Open . +2. Select the firmware type you want: + - `Companion WiFi` + - `Repeater MQTT` + - or `Custom` +3. Flash the correct firmware for your board. +4. Use the built-in setup tools in the flasher site to finish first-time configuration. + +The flasher site includes two especially useful actions after flashing: + +- `Repeater Setup` +- `Console` + +### Repeater Setup + +`Repeater Setup` is the guided first-time repeater flow. + +It is the traditional way to configure a repeater after flashing, including: + +- device name +- latitude and longitude +- admin and guest passwords +- radio settings, including preset selection +- advert interval +- flood advert interval +- flood max +- some advanced repeater settings + +As of `v1.2.1`, the local repeater web panel also includes the same common repeater settings, so many users can continue day-to-day configuration there after initial setup. + +### Console + +`Console` is the raw CLI interface. + +It is especially useful, and often required, for the initial Wi-Fi setup on both firmware tracks: + +- `set wifi.ssid ` +- `set wifi.pwd ` + +This applies to: + +- `companion_radio_wifi` +- `repeater_mqtt` + ## Repeater MQTT Notes `repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index da232be3..4e4ef4e7 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1483,9 +1483,14 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_exact("get name") || matches_exact("get lat") || matches_exact("get lon") || + matches_exact("get radio") || + matches_exact("get prv.key") || + matches_exact("get role") || + matches_exact("get public.key") || matches_exact("get advert.interval") || matches_exact("get flood.advert.interval") || matches_exact("get flood.max") || + matches_exact("get path.hash.mode") || matches_exact("get owner.info") || matches_exact("get guest.password") || matches_prefix("set wifi.ssid ") || @@ -1509,11 +1514,16 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size) matches_prefix("set name ") || matches_prefix("set lat ") || matches_prefix("set lon ") || + matches_prefix("set radio ") || + matches_prefix("password ") || matches_prefix("set guest.password ") || matches_prefix("set prv.key ") || matches_prefix("set advert.interval ") || matches_prefix("set flood.advert.interval ") || matches_prefix("set flood.max ") || + matches_prefix("set path.hash.mode ") || + matches_prefix("time ") || + matches_prefix("time.force ") || matches_prefix("set owner.info "); if (!allowed) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 37204b84..8010b823 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -249,6 +249,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch uint32_t now = getRTCClock()->getCurrentTime(); DateTime dt = DateTime(now); sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else if (memcmp(command, "time.force ", 11) == 0) { // force set time (to epoch seconds) + uint32_t secs = _atoi(&command[11]); + getRTCClock()->setCurrentTime(secs); + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock force-set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); } else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds) uint32_t secs = _atoi(&command[5]); uint32_t curr = getRTCClock()->getCurrentTime(); diff --git a/src/helpers/mqtt/MQTTUplink.cpp b/src/helpers/mqtt/MQTTUplink.cpp index fe745afd..49c32fad 100644 --- a/src/helpers/mqtt/MQTTUplink.cpp +++ b/src/helpers/mqtt/MQTTUplink.cpp @@ -50,7 +50,8 @@ namespace { constexpr unsigned long kWifiRetryMillis = 15000; constexpr unsigned long kWifiConnectTimeoutMillis = 45000; -constexpr unsigned long kBrokerRetryMillis = 10000; +constexpr unsigned long kBrokerRetryBaseMillis = 10000; +constexpr unsigned long kBrokerRetryMaxMillis = 300000; constexpr size_t kBrokerTokenSize = 640; constexpr time_t kTokenLifetimeSecs = 3600; constexpr time_t kTokenRefreshSlackSecs = 300; @@ -79,6 +80,18 @@ const char* getWifiQualityLabel(int rssi_dbm) { return "poor"; } +unsigned long getBrokerRetryDelayMillis(uint8_t failures) { + unsigned long delay_ms = kBrokerRetryBaseMillis; + if (failures > 0) { + uint8_t shifts = min(failures - 1, 5); + delay_ms <<= shifts; + } + if (delay_ms > kBrokerRetryMaxMillis) { + delay_ms = kBrokerRetryMaxMillis; + } + return delay_ms; +} + char* allocScratchBuffer(size_t size) { void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (ptr == nullptr) { @@ -384,7 +397,7 @@ bool MQTTUplink::refreshToken(BrokerState& broker) { return true; } -void MQTTUplink::destroyBroker(BrokerState& broker) { +void MQTTUplink::destroyBroker(BrokerState& broker, bool reset_retry_state) { if (broker.client != nullptr) { MQTT_LOG("%s destroy broker client", broker.spec->label); esp_mqtt_client_stop(broker.client); @@ -396,6 +409,12 @@ void MQTTUplink::destroyBroker(BrokerState& broker) { broker.connected = false; broker.connect_announced = false; broker.token_expires_at = 0; + if (reset_retry_state) { + broker.reconnect_pending = false; + broker.next_connect_attempt = 0; + broker.reconnect_failures = 0; + broker.last_connect_attempt = 0; + } } void MQTTUplink::queuePublish(BrokerState& broker, const char* topic, const char* payload, bool retain) { @@ -573,12 +592,20 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e switch (event_id) { case MQTT_EVENT_CONNECTED: broker->connected = true; + broker->reconnect_pending = false; + broker->next_connect_attempt = 0; + broker->reconnect_failures = 0; MQTT_LOG("%s connected", broker->spec->label); break; case MQTT_EVENT_DISCONNECTED: MQTT_LOG("%s disconnected", broker->spec->label); case MQTT_EVENT_ERROR: broker->connected = false; + if (broker->reconnect_failures < 10) { + broker->reconnect_failures++; + } + broker->reconnect_pending = true; + broker->next_connect_attempt = millis() + getBrokerRetryDelayMillis(broker->reconnect_failures); if (event_id == MQTT_EVENT_ERROR) { if (event != nullptr && event->error_handle != nullptr) { MQTT_LOG("%s error type=%d tls_esp=0x%x tls_stack=0x%x cert_flags=0x%x sock_errno=%d conn_refused=%d", @@ -589,6 +616,9 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e MQTT_LOG("%s error event", broker->spec->label); } } + MQTT_LOG("%s reconnect in %lu ms (failures=%u)", broker->spec->label, + getBrokerRetryDelayMillis(broker->reconnect_failures), + static_cast(broker->reconnect_failures)); break; case MQTT_EVENT_BEFORE_CONNECT: MQTT_LOG("%s before connect", broker->spec->label); @@ -702,20 +732,29 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { time_t now = time(nullptr); if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) { - destroyBroker(broker); - } - - if (broker.client != nullptr) { - return; + destroyBroker(broker, false); } unsigned long now_ms = millis(); - if (now_ms - broker.last_connect_attempt < kBrokerRetryMillis) { + if (broker.client != nullptr) { + if (broker.connected) { + return; + } + if (!broker.reconnect_pending || now_ms < broker.next_connect_attempt) { + return; + } + destroyBroker(broker, false); + } + + if (broker.next_connect_attempt != 0 && now_ms < broker.next_connect_attempt) { return; } broker.last_connect_attempt = now_ms; + broker.reconnect_pending = false; if (!refreshToken(broker)) { + broker.reconnect_pending = true; + broker.next_connect_attempt = now_ms + kBrokerRetryBaseMillis; return; } @@ -739,7 +778,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { cfg.session.last_will.retain = 1; cfg.network.reconnect_timeout_ms = 10000; cfg.network.timeout_ms = 10000; - cfg.network.disable_auto_reconnect = false; + cfg.network.disable_auto_reconnect = true; cfg.buffer.size = 768; cfg.buffer.out_size = 1280; #else @@ -753,7 +792,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { cfg.out_buffer_size = 1280; cfg.reconnect_timeout_ms = 10000; cfg.network_timeout_ms = 10000; - cfg.disable_auto_reconnect = false; + cfg.disable_auto_reconnect = true; cfg.transport = MQTT_TRANSPORT_OVER_WSS; cfg.cert_pem = mqtt_ca_certs::kCombinedPem; cfg.lwt_topic = broker.status_topic; @@ -772,7 +811,9 @@ void MQTTUplink::ensureBroker(BrokerState& broker) { esp_mqtt_client_register_event(broker.client, MQTT_EVENT_ANY, &MQTTUplink::handleMqttEvent, &broker); if (esp_mqtt_client_start(broker.client) != ESP_OK) { MQTT_LOG("%s mqtt start failed", broker.spec->label); - destroyBroker(broker); + broker.reconnect_pending = true; + broker.next_connect_attempt = now_ms + kBrokerRetryBaseMillis; + destroyBroker(broker, false); } else { MQTT_LOG("%s mqtt start requested", broker.spec->label); } @@ -918,6 +959,9 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { if (broker->client != nullptr) { return "conn"; } + if (broker->next_connect_attempt != 0 && broker->next_connect_attempt > millis()) { + return "backoff"; + } return "retry"; }; diff --git a/src/helpers/mqtt/MQTTUplink.h b/src/helpers/mqtt/MQTTUplink.h index fea5bbf0..6b89c998 100644 --- a/src/helpers/mqtt/MQTTUplink.h +++ b/src/helpers/mqtt/MQTTUplink.h @@ -95,8 +95,11 @@ private: esp_mqtt_client_handle_t client; bool connected; bool connect_announced; + bool reconnect_pending; unsigned long last_connect_attempt; + unsigned long next_connect_attempt; time_t token_expires_at; + uint8_t reconnect_failures; char username[70]; char* token; char client_id[48]; @@ -142,7 +145,7 @@ private: void refreshBrokerIdentity(BrokerState& broker); void refreshBrokerState(BrokerState& broker); void ensureBroker(BrokerState& broker); - void destroyBroker(BrokerState& broker); + void destroyBroker(BrokerState& broker, bool reset_retry_state = true); bool refreshToken(BrokerState& broker); void publishStatus(bool online); void publishOnlineStatus(BrokerState& broker); diff --git a/src/helpers/web/WebPanelServer.cpp b/src/helpers/web/WebPanelServer.cpp index 90dfc68e..1ccfef35 100644 --- a/src/helpers/web/WebPanelServer.cpp +++ b/src/helpers/web/WebPanelServer.cpp @@ -194,7 +194,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(

Repeater Config

-

Use the repeater admin password to unlock the panel. Accept the self-signed certificate warning in your browser first.

+

Use the repeater admin password to unlock the panel.

@@ -296,6 +296,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); } .stack { display:grid; gap:12px; } .field-card { display:grid; gap:10px; } + .section-group { background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:14px; display:grid; gap:12px; } + .section-group h3 { margin:0; font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; } .inline-actions { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; align-items:center; } .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; } .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; } @@ -332,6 +334,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML( .visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .panel-warning { min-height:1.4em; font-size:13px; color:var(--status-red); } .panel-note { font-size:13px; color:var(--text-muted); } + .panel-status { min-height:1.4em; font-size:13px; color:var(--text-muted); } .themebtn { padding:10px 14px; } #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; } .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font-family:inherit; font-size:14px; line-height:1.45; } @@ -399,7 +402,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
+ +