Merge pull request #15 from xJARiD/develop
fix: add graceful MQTT reconnect backoff during broker outages
Cette révision appartient à :
+1
-1
@@ -32,7 +32,7 @@ For flashing guidance, including when to use `.bin` vs `-merged.bin`, see:
|
|||||||
|
|
||||||
The custom firmware flasher site is:
|
The custom firmware flasher site is:
|
||||||
|
|
||||||
- <https://flasher.meshcore.io/>
|
- <https://flasher.eastmesh.au/>
|
||||||
|
|
||||||
## Install uv
|
## Install uv
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -46,15 +46,69 @@ Practical rule:
|
|||||||
|
|
||||||
## Recommended Flasher
|
## Recommended Flasher
|
||||||
|
|
||||||
For custom firmware files, use:
|
The recommended flasher is:
|
||||||
|
|
||||||
- <https://flasher.meshcore.io/>
|
- <https://flasher.eastmesh.au/>
|
||||||
|
|
||||||
|
It includes native support for:
|
||||||
|
|
||||||
|
- `companion_radio_wifi` firmware
|
||||||
|
- `repeater_mqtt` firmware
|
||||||
|
- custom firmware files
|
||||||
|
|
||||||
Recommended usage:
|
Recommended usage:
|
||||||
|
|
||||||
- use the normal `.bin` there when you are updating an existing device
|
- 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
|
- 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 <https://flasher.eastmesh.au/>.
|
||||||
|
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 <your-ssid>`
|
||||||
|
- `set wifi.pwd <your-password>`
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
|
||||||
|
- `companion_radio_wifi`
|
||||||
|
- `repeater_mqtt`
|
||||||
|
|
||||||
## Repeater MQTT Notes
|
## Repeater MQTT Notes
|
||||||
|
|
||||||
`repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel.
|
`repeater_mqtt` builds include the EastMesh MQTT additions. Depending on the board, they may also include the local web panel.
|
||||||
|
|||||||
@@ -1483,9 +1483,14 @@ void MyMesh::runWebCommand(const char* command, char* reply, size_t reply_size)
|
|||||||
matches_exact("get name") ||
|
matches_exact("get name") ||
|
||||||
matches_exact("get lat") ||
|
matches_exact("get lat") ||
|
||||||
matches_exact("get lon") ||
|
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 advert.interval") ||
|
||||||
matches_exact("get flood.advert.interval") ||
|
matches_exact("get flood.advert.interval") ||
|
||||||
matches_exact("get flood.max") ||
|
matches_exact("get flood.max") ||
|
||||||
|
matches_exact("get path.hash.mode") ||
|
||||||
matches_exact("get owner.info") ||
|
matches_exact("get owner.info") ||
|
||||||
matches_exact("get guest.password") ||
|
matches_exact("get guest.password") ||
|
||||||
matches_prefix("set wifi.ssid ") ||
|
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 name ") ||
|
||||||
matches_prefix("set lat ") ||
|
matches_prefix("set lat ") ||
|
||||||
matches_prefix("set lon ") ||
|
matches_prefix("set lon ") ||
|
||||||
|
matches_prefix("set radio ") ||
|
||||||
|
matches_prefix("password ") ||
|
||||||
matches_prefix("set guest.password ") ||
|
matches_prefix("set guest.password ") ||
|
||||||
matches_prefix("set prv.key ") ||
|
matches_prefix("set prv.key ") ||
|
||||||
matches_prefix("set advert.interval ") ||
|
matches_prefix("set advert.interval ") ||
|
||||||
matches_prefix("set flood.advert.interval ") ||
|
matches_prefix("set flood.advert.interval ") ||
|
||||||
matches_prefix("set flood.max ") ||
|
matches_prefix("set flood.max ") ||
|
||||||
|
matches_prefix("set path.hash.mode ") ||
|
||||||
|
matches_prefix("time ") ||
|
||||||
|
matches_prefix("time.force ") ||
|
||||||
matches_prefix("set owner.info ");
|
matches_prefix("set owner.info ");
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
@@ -249,6 +249,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
|||||||
uint32_t now = getRTCClock()->getCurrentTime();
|
uint32_t now = getRTCClock()->getCurrentTime();
|
||||||
DateTime dt = DateTime(now);
|
DateTime dt = DateTime(now);
|
||||||
sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
|
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)
|
} else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds)
|
||||||
uint32_t secs = _atoi(&command[5]);
|
uint32_t secs = _atoi(&command[5]);
|
||||||
uint32_t curr = getRTCClock()->getCurrentTime();
|
uint32_t curr = getRTCClock()->getCurrentTime();
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr unsigned long kWifiRetryMillis = 15000;
|
constexpr unsigned long kWifiRetryMillis = 15000;
|
||||||
constexpr unsigned long kWifiConnectTimeoutMillis = 45000;
|
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 size_t kBrokerTokenSize = 640;
|
||||||
constexpr time_t kTokenLifetimeSecs = 3600;
|
constexpr time_t kTokenLifetimeSecs = 3600;
|
||||||
constexpr time_t kTokenRefreshSlackSecs = 300;
|
constexpr time_t kTokenRefreshSlackSecs = 300;
|
||||||
@@ -79,6 +80,18 @@ const char* getWifiQualityLabel(int rssi_dbm) {
|
|||||||
return "poor";
|
return "poor";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsigned long getBrokerRetryDelayMillis(uint8_t failures) {
|
||||||
|
unsigned long delay_ms = kBrokerRetryBaseMillis;
|
||||||
|
if (failures > 0) {
|
||||||
|
uint8_t shifts = min<uint8_t>(failures - 1, 5);
|
||||||
|
delay_ms <<= shifts;
|
||||||
|
}
|
||||||
|
if (delay_ms > kBrokerRetryMaxMillis) {
|
||||||
|
delay_ms = kBrokerRetryMaxMillis;
|
||||||
|
}
|
||||||
|
return delay_ms;
|
||||||
|
}
|
||||||
|
|
||||||
char* allocScratchBuffer(size_t size) {
|
char* allocScratchBuffer(size_t size) {
|
||||||
void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
if (ptr == nullptr) {
|
if (ptr == nullptr) {
|
||||||
@@ -384,7 +397,7 @@ bool MQTTUplink::refreshToken(BrokerState& broker) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MQTTUplink::destroyBroker(BrokerState& broker) {
|
void MQTTUplink::destroyBroker(BrokerState& broker, bool reset_retry_state) {
|
||||||
if (broker.client != nullptr) {
|
if (broker.client != nullptr) {
|
||||||
MQTT_LOG("%s destroy broker client", broker.spec->label);
|
MQTT_LOG("%s destroy broker client", broker.spec->label);
|
||||||
esp_mqtt_client_stop(broker.client);
|
esp_mqtt_client_stop(broker.client);
|
||||||
@@ -396,6 +409,12 @@ void MQTTUplink::destroyBroker(BrokerState& broker) {
|
|||||||
broker.connected = false;
|
broker.connected = false;
|
||||||
broker.connect_announced = false;
|
broker.connect_announced = false;
|
||||||
broker.token_expires_at = 0;
|
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) {
|
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) {
|
switch (event_id) {
|
||||||
case MQTT_EVENT_CONNECTED:
|
case MQTT_EVENT_CONNECTED:
|
||||||
broker->connected = true;
|
broker->connected = true;
|
||||||
|
broker->reconnect_pending = false;
|
||||||
|
broker->next_connect_attempt = 0;
|
||||||
|
broker->reconnect_failures = 0;
|
||||||
MQTT_LOG("%s connected", broker->spec->label);
|
MQTT_LOG("%s connected", broker->spec->label);
|
||||||
break;
|
break;
|
||||||
case MQTT_EVENT_DISCONNECTED:
|
case MQTT_EVENT_DISCONNECTED:
|
||||||
MQTT_LOG("%s disconnected", broker->spec->label);
|
MQTT_LOG("%s disconnected", broker->spec->label);
|
||||||
case MQTT_EVENT_ERROR:
|
case MQTT_EVENT_ERROR:
|
||||||
broker->connected = false;
|
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_id == MQTT_EVENT_ERROR) {
|
||||||
if (event != nullptr && event->error_handle != nullptr) {
|
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",
|
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 error event", broker->spec->label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MQTT_LOG("%s reconnect in %lu ms (failures=%u)", broker->spec->label,
|
||||||
|
getBrokerRetryDelayMillis(broker->reconnect_failures),
|
||||||
|
static_cast<unsigned>(broker->reconnect_failures));
|
||||||
break;
|
break;
|
||||||
case MQTT_EVENT_BEFORE_CONNECT:
|
case MQTT_EVENT_BEFORE_CONNECT:
|
||||||
MQTT_LOG("%s before connect", broker->spec->label);
|
MQTT_LOG("%s before connect", broker->spec->label);
|
||||||
@@ -702,20 +732,29 @@ void MQTTUplink::ensureBroker(BrokerState& broker) {
|
|||||||
|
|
||||||
time_t now = time(nullptr);
|
time_t now = time(nullptr);
|
||||||
if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) {
|
if (broker.client != nullptr && broker.token_expires_at > 0 && now + kTokenRefreshSlackSecs >= broker.token_expires_at) {
|
||||||
destroyBroker(broker);
|
destroyBroker(broker, false);
|
||||||
}
|
|
||||||
|
|
||||||
if (broker.client != nullptr) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsigned long now_ms = millis();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
broker.last_connect_attempt = now_ms;
|
broker.last_connect_attempt = now_ms;
|
||||||
|
broker.reconnect_pending = false;
|
||||||
|
|
||||||
if (!refreshToken(broker)) {
|
if (!refreshToken(broker)) {
|
||||||
|
broker.reconnect_pending = true;
|
||||||
|
broker.next_connect_attempt = now_ms + kBrokerRetryBaseMillis;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +778,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker) {
|
|||||||
cfg.session.last_will.retain = 1;
|
cfg.session.last_will.retain = 1;
|
||||||
cfg.network.reconnect_timeout_ms = 10000;
|
cfg.network.reconnect_timeout_ms = 10000;
|
||||||
cfg.network.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.size = 768;
|
||||||
cfg.buffer.out_size = 1280;
|
cfg.buffer.out_size = 1280;
|
||||||
#else
|
#else
|
||||||
@@ -753,7 +792,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker) {
|
|||||||
cfg.out_buffer_size = 1280;
|
cfg.out_buffer_size = 1280;
|
||||||
cfg.reconnect_timeout_ms = 10000;
|
cfg.reconnect_timeout_ms = 10000;
|
||||||
cfg.network_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.transport = MQTT_TRANSPORT_OVER_WSS;
|
||||||
cfg.cert_pem = mqtt_ca_certs::kCombinedPem;
|
cfg.cert_pem = mqtt_ca_certs::kCombinedPem;
|
||||||
cfg.lwt_topic = broker.status_topic;
|
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);
|
esp_mqtt_client_register_event(broker.client, MQTT_EVENT_ANY, &MQTTUplink::handleMqttEvent, &broker);
|
||||||
if (esp_mqtt_client_start(broker.client) != ESP_OK) {
|
if (esp_mqtt_client_start(broker.client) != ESP_OK) {
|
||||||
MQTT_LOG("%s mqtt start failed", broker.spec->label);
|
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 {
|
} else {
|
||||||
MQTT_LOG("%s mqtt start requested", broker.spec->label);
|
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) {
|
if (broker->client != nullptr) {
|
||||||
return "conn";
|
return "conn";
|
||||||
}
|
}
|
||||||
|
if (broker->next_connect_attempt != 0 && broker->next_connect_attempt > millis()) {
|
||||||
|
return "backoff";
|
||||||
|
}
|
||||||
return "retry";
|
return "retry";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,11 @@ private:
|
|||||||
esp_mqtt_client_handle_t client;
|
esp_mqtt_client_handle_t client;
|
||||||
bool connected;
|
bool connected;
|
||||||
bool connect_announced;
|
bool connect_announced;
|
||||||
|
bool reconnect_pending;
|
||||||
unsigned long last_connect_attempt;
|
unsigned long last_connect_attempt;
|
||||||
|
unsigned long next_connect_attempt;
|
||||||
time_t token_expires_at;
|
time_t token_expires_at;
|
||||||
|
uint8_t reconnect_failures;
|
||||||
char username[70];
|
char username[70];
|
||||||
char* token;
|
char* token;
|
||||||
char client_id[48];
|
char client_id[48];
|
||||||
@@ -142,7 +145,7 @@ private:
|
|||||||
void refreshBrokerIdentity(BrokerState& broker);
|
void refreshBrokerIdentity(BrokerState& broker);
|
||||||
void refreshBrokerState(BrokerState& broker);
|
void refreshBrokerState(BrokerState& broker);
|
||||||
void ensureBroker(BrokerState& broker);
|
void ensureBroker(BrokerState& broker);
|
||||||
void destroyBroker(BrokerState& broker);
|
void destroyBroker(BrokerState& broker, bool reset_retry_state = true);
|
||||||
bool refreshToken(BrokerState& broker);
|
bool refreshToken(BrokerState& broker);
|
||||||
void publishStatus(bool online);
|
void publishStatus(bool online);
|
||||||
void publishOnlineStatus(BrokerState& broker);
|
void publishOnlineStatus(BrokerState& broker);
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ const char kWebPanelLoginHtml[] PROGMEM = R"HTML(
|
|||||||
<main>
|
<main>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1>Repeater Config</h1>
|
<h1>Repeater Config</h1>
|
||||||
<p>Use the repeater admin password to unlock the panel. Accept the self-signed certificate warning in your browser first.</p>
|
<p>Use the repeater admin password to unlock the panel.</p>
|
||||||
<input id="password" type="password" placeholder="Admin password" autocomplete="current-password">
|
<input id="password" type="password" placeholder="Admin password" autocomplete="current-password">
|
||||||
<button id="loginBtn">Unlock</button>
|
<button id="loginBtn">Unlock</button>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
@@ -296,6 +296,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
|
button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
|
||||||
.stack { display:grid; gap:12px; }
|
.stack { display:grid; gap:12px; }
|
||||||
.field-card { display:grid; gap:10px; }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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-warning { min-height:1.4em; font-size:13px; color:var(--status-red); }
|
||||||
.panel-note { font-size:13px; color:var(--text-muted); }
|
.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; }
|
.themebtn { padding:10px 14px; }
|
||||||
#status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
|
#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; }
|
.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(
|
|||||||
<main>
|
<main>
|
||||||
<section class="card" id="login" style="display:none">
|
<section class="card" id="login" style="display:none">
|
||||||
<h1>Repeater Config</h1>
|
<h1>Repeater Config</h1>
|
||||||
<p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
|
<p>Use the repeater admin password to unlock the command console.</p>
|
||||||
<div class="row" style="margin-top:14px">
|
<div class="row" style="margin-top:14px">
|
||||||
<input id="password" type="password" placeholder="Admin password">
|
<input id="password" type="password" placeholder="Admin password">
|
||||||
<button id="loginBtn">Unlock</button>
|
<button id="loginBtn">Unlock</button>
|
||||||
@@ -456,94 +459,183 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
<div id="reply" class="terminal"></div>
|
<div id="reply" class="terminal"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="infoPanel" style="display:none">
|
||||||
|
<h2>Info</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="row">
|
||||||
|
<div class="field-card">
|
||||||
|
<label class="label" for="roleValue">Role</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="roleValue" readonly disabled>
|
||||||
|
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<label class="label" for="clockUtc">Clock UTC</label>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<input id="clockUtc" readonly disabled>
|
||||||
|
<button class="iconbtn" data-load-cmd="clock" data-load-input="clockUtc" title="Refresh clock UTC">↻</button>
|
||||||
|
<button id="syncClockBtn" class="savebtn">Sync</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<label class="label" for="publicKey">Public Key</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="publicKey" readonly disabled>
|
||||||
|
<button id="copyPublicKeyBtn" class="iconbtn" title="Copy public key">⎘</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card" id="repeaterSettingsPanel" style="display:none">
|
<section class="card" id="repeaterSettingsPanel" style="display:none">
|
||||||
<h2>Repeater Settings</h2>
|
<h2>Repeater Settings</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="field-card">
|
<div class="section-group">
|
||||||
<label class="label" for="nodeName">Device Name</label>
|
<h3>Repeater Settings</h3>
|
||||||
<div class="inline-actions">
|
|
||||||
<input id="nodeName" placeholder="MeshCore-HOWL">
|
|
||||||
<button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">↻</button>
|
|
||||||
<button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="field-card">
|
<div class="field-card">
|
||||||
<div>
|
<label class="label" for="nodeName">Device Name</label>
|
||||||
<label class="label" for="nodeLat">Latitude</label>
|
<div class="inline-actions">
|
||||||
<div class="fieldline">
|
<input id="nodeName" placeholder="MeshCore-HOWL">
|
||||||
<input id="nodeLat" placeholder="0.0">
|
<button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">↻</button>
|
||||||
<button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">↻</button>
|
<button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="nodeLat">Latitude</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="nodeLat" placeholder="0.0">
|
||||||
|
<button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="nodeLon">Longitude</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="nodeLon" placeholder="0.0">
|
||||||
|
<button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field-card">
|
<div class="field-card">
|
||||||
<div>
|
<label class="label" for="privateKey">Private Key</label>
|
||||||
<label class="label" for="nodeLon">Longitude</label>
|
<div class="inline-actions">
|
||||||
<div class="fieldline">
|
<input id="privateKey" type="password" placeholder="64-hex-char private key">
|
||||||
<input id="nodeLon" placeholder="0.0">
|
<button id="copyPrivateKeyBtn" class="iconbtn" title="Copy private key">⎘</button>
|
||||||
<button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">↻</button>
|
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p>Changing the private key requires a reboot to apply.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<label class="label" for="ownerInfo">Owner Info</label>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
|
||||||
|
<button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">↻</button>
|
||||||
|
<button id="saveOwnerInfo" class="savebtn">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-card">
|
<div class="section-group">
|
||||||
<label class="label" for="guestPassword">Guest Password</label>
|
<h3>Access</h3>
|
||||||
<div class="inline-actions">
|
<div class="field-card">
|
||||||
<input id="guestPassword" type="password" placeholder="new guest password">
|
<label class="label" for="adminPassword">Admin Password</label>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<input id="adminPassword" type="password" placeholder="new admin password">
|
||||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||||
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
|
<button class="savebtn" data-prefix="password " data-input="adminPassword">Save</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="field-card">
|
||||||
<div class="field-card">
|
<label class="label" for="guestPassword">Guest Password</label>
|
||||||
<label class="label" for="privateKey">Private Key</label>
|
<div class="inline-actions">
|
||||||
<div class="inline-actions">
|
<input id="guestPassword" type="password" placeholder="new guest password">
|
||||||
<input id="privateKey" placeholder="64-hex-char private key">
|
|
||||||
<span class="placeholder-slot" aria-hidden="true"></span>
|
<span class="placeholder-slot" aria-hidden="true"></span>
|
||||||
<button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
|
<button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
|
||||||
</div>
|
|
||||||
<p>Changing the private key requires a reboot to apply.</p>
|
|
||||||
</div>
|
|
||||||
<div class="row3">
|
|
||||||
<div class="field-card">
|
|
||||||
<div>
|
|
||||||
<label class="label" for="advertInterval">Advert Interval (minutes)</label>
|
|
||||||
<div class="fieldline">
|
|
||||||
<input id="advertInterval" placeholder="2">
|
|
||||||
<button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">↻</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
|
|
||||||
</div>
|
|
||||||
<div class="field-card">
|
|
||||||
<div>
|
|
||||||
<label class="label" for="floodInterval">Flood Interval (hours)</label>
|
|
||||||
<div class="fieldline">
|
|
||||||
<input id="floodInterval" placeholder="12">
|
|
||||||
<button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
|
|
||||||
</div>
|
|
||||||
<div class="field-card">
|
|
||||||
<div>
|
|
||||||
<label class="label" for="floodMax">Flood Max</label>
|
|
||||||
<div class="fieldline">
|
|
||||||
<input id="floodMax" placeholder="64">
|
|
||||||
<button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-card">
|
<div class="section-group">
|
||||||
<label class="label" for="ownerInfo">Owner Info</label>
|
<h3>Radio Settings</h3>
|
||||||
<div class="inline-actions">
|
<div class="field-card">
|
||||||
<textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
|
<label class="label" for="radioPreset">Preset</label>
|
||||||
<button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">↻</button>
|
<div class="row">
|
||||||
<button id="saveOwnerInfo" class="savebtn">Save</button>
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="radioCurrent">Current Radio</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="radioCurrent" placeholder="915.800 / BW250 / SF10 / CR5" readonly disabled>
|
||||||
|
<button id="refreshRadioBtn" class="iconbtn" title="Refresh current radio">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="radioPreset">Preset</label>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<select id="radioPreset">
|
||||||
|
<option value="">Loading presets...</option>
|
||||||
|
</select>
|
||||||
|
<button id="reloadRadioPresetsBtn" class="iconbtn" title="Reload presets">↻</button>
|
||||||
|
<button id="applyRadioPresetBtn" class="savebtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="radioPresetStatus" class="panel-status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<label class="label" for="pathHashMode">Path Hash Mode</label>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<select id="pathHashMode">
|
||||||
|
<option value="0">1-byte</option>
|
||||||
|
<option value="1">2-byte</option>
|
||||||
|
<option value="2">3-byte</option>
|
||||||
|
</select>
|
||||||
|
<button class="iconbtn" data-load-cmd="get path.hash.mode" data-load-input="pathHashMode" title="Refresh path hash mode">↻</button>
|
||||||
|
<button class="savebtn" data-prefix="set path.hash.mode " data-input="pathHashMode">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-group">
|
||||||
|
<h3>Advertising</h3>
|
||||||
|
<div class="row3">
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="advertInterval">Advert Interval (minutes)</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="advertInterval" placeholder="2">
|
||||||
|
<button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="floodInterval">Flood Interval (hours)</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="floodInterval" placeholder="12">
|
||||||
|
<button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-card">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="floodMax">Flood Max</label>
|
||||||
|
<div class="fieldline">
|
||||||
|
<input id="floodMax" placeholder="64">
|
||||||
|
<button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -710,8 +802,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
const RADIO_PRESETS_URL = "https://api.meshcore.nz/api/v1/config";
|
||||||
let token = sessionStorage.getItem("repeater-token") || "";
|
let token = sessionStorage.getItem("repeater-token") || "";
|
||||||
let commandQueue = Promise.resolve();
|
let commandQueue = Promise.resolve();
|
||||||
|
let radioPresetEntries = [];
|
||||||
|
let currentRadioConfig = null;
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const replyEl = document.getElementById("reply");
|
const replyEl = document.getElementById("reply");
|
||||||
const themeToggleEl = document.getElementById("themeToggle");
|
const themeToggleEl = document.getElementById("themeToggle");
|
||||||
@@ -756,6 +851,10 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
function parseReplyValue(text) {
|
function parseReplyValue(text) {
|
||||||
return (text || "").replace(/^>\s*/, "").trim();
|
return (text || "").replace(/^>\s*/, "").trim();
|
||||||
}
|
}
|
||||||
|
function isCommandError(text) {
|
||||||
|
const value = parseReplyValue(text).toLowerCase();
|
||||||
|
return value.startsWith("err") || value.startsWith("(err") || value.startsWith("error");
|
||||||
|
}
|
||||||
function clamp(value, min, max) {
|
function clamp(value, min, max) {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
@@ -793,6 +892,86 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
|
if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
|
||||||
return Math.round(value) + " B";
|
return Math.round(value) + " B";
|
||||||
}
|
}
|
||||||
|
function formatDecimal(value, digits) {
|
||||||
|
const num = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(num)) return "";
|
||||||
|
return num.toFixed(digits);
|
||||||
|
}
|
||||||
|
function normalizeRadioConfig(config) {
|
||||||
|
if (!config) return null;
|
||||||
|
const frequency = Number.parseFloat(config.frequency);
|
||||||
|
const bandwidth = Number.parseFloat(config.bandwidth);
|
||||||
|
const spreadingFactor = Number.parseInt(config.spreadingFactor, 10);
|
||||||
|
const codingRate = Number.parseInt(config.codingRate, 10);
|
||||||
|
if (!Number.isFinite(frequency) || !Number.isFinite(bandwidth) || !Number.isFinite(spreadingFactor) || !Number.isFinite(codingRate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
frequency,
|
||||||
|
bandwidth,
|
||||||
|
spreadingFactor,
|
||||||
|
codingRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function radioSignature(config) {
|
||||||
|
const normalized = normalizeRadioConfig(config);
|
||||||
|
if (!normalized) return "";
|
||||||
|
return [
|
||||||
|
normalized.frequency.toFixed(3),
|
||||||
|
normalized.bandwidth.toFixed(3),
|
||||||
|
normalized.spreadingFactor,
|
||||||
|
normalized.codingRate
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function formatRadioConfig(config) {
|
||||||
|
const normalized = normalizeRadioConfig(config);
|
||||||
|
if (!normalized) return "";
|
||||||
|
return `${normalized.frequency.toFixed(3)} / BW${normalized.bandwidth.toFixed(3)} / SF${normalized.spreadingFactor} / CR${normalized.codingRate}`;
|
||||||
|
}
|
||||||
|
function parseRadioValue(value) {
|
||||||
|
const parts = String(value || "").split(",");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
return normalizeRadioConfig({
|
||||||
|
frequency: parts[0],
|
||||||
|
bandwidth: parts[1],
|
||||||
|
spreadingFactor: parts[2],
|
||||||
|
codingRate: parts[3]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setRadioPresetStatus(message, isError) {
|
||||||
|
const el = document.getElementById("radioPresetStatus");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = message || "";
|
||||||
|
el.style.color = isError ? "var(--status-red)" : "var(--text-muted)";
|
||||||
|
}
|
||||||
|
function syncRadioPresetUi() {
|
||||||
|
const currentEl = document.getElementById("radioCurrent");
|
||||||
|
if (currentEl) {
|
||||||
|
currentEl.value = currentRadioConfig ? formatRadioConfig(currentRadioConfig) : "";
|
||||||
|
}
|
||||||
|
const selectEl = document.getElementById("radioPreset");
|
||||||
|
const applyBtn = document.getElementById("applyRadioPresetBtn");
|
||||||
|
if (!selectEl || !applyBtn) return;
|
||||||
|
const currentSig = radioSignature(currentRadioConfig);
|
||||||
|
let matchedIndex = -1;
|
||||||
|
for (let i = 0; i < radioPresetEntries.length; i++) {
|
||||||
|
if (radioSignature(radioPresetEntries[i]) === currentSig) {
|
||||||
|
matchedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!radioPresetEntries.length) {
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchedIndex >= 0) {
|
||||||
|
selectEl.value = String(matchedIndex);
|
||||||
|
applyBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectEl.value = "";
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
}
|
||||||
function toneForPercent(percent, invert) {
|
function toneForPercent(percent, invert) {
|
||||||
if (invert) {
|
if (invert) {
|
||||||
if (percent >= 70) return "bad";
|
if (percent >= 70) return "bad";
|
||||||
@@ -995,6 +1174,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
document.getElementById("cliPanel").style.display = show ? "block" : "none";
|
document.getElementById("cliPanel").style.display = show ? "block" : "none";
|
||||||
document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
|
document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
|
||||||
document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
|
document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
|
||||||
|
document.getElementById("infoPanel").style.display = show ? "block" : "none";
|
||||||
document.getElementById("statsPanel").style.display = show ? "block" : "none";
|
document.getElementById("statsPanel").style.display = show ? "block" : "none";
|
||||||
document.getElementById("actionsPanel").style.display = show ? "block" : "none";
|
document.getElementById("actionsPanel").style.display = show ? "block" : "none";
|
||||||
document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
|
document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
|
||||||
@@ -1028,10 +1208,11 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
return { ok:false, text };
|
return { ok:false, text };
|
||||||
}
|
}
|
||||||
|
const ok = res.ok && !isCommandError(text);
|
||||||
if (recordHistory) {
|
if (recordHistory) {
|
||||||
appendHistory(cmd, text, res.ok);
|
appendHistory(cmd, text, ok);
|
||||||
}
|
}
|
||||||
return { ok:res.ok, text };
|
return { ok, text };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function runPrefixed(prefix, inputId) {
|
function runPrefixed(prefix, inputId) {
|
||||||
@@ -1044,9 +1225,49 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
let value = parseReplyValue(result.text);
|
let value = parseReplyValue(result.text);
|
||||||
if (format === "multiline") {
|
if (format === "multiline") {
|
||||||
value = value.replace(/\|/g, "\n");
|
value = value.replace(/\|/g, "\n");
|
||||||
|
} else if (format === "uppercase") {
|
||||||
|
value = value.toUpperCase();
|
||||||
}
|
}
|
||||||
document.getElementById(inputId).value = value;
|
document.getElementById(inputId).value = value;
|
||||||
}
|
}
|
||||||
|
async function copyToClipboard(value, successMessage) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
} else {
|
||||||
|
throw new Error("Clipboard unavailable");
|
||||||
|
}
|
||||||
|
statusEl.textContent = successMessage;
|
||||||
|
} catch (_) {
|
||||||
|
statusEl.textContent = "Copy failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function syncClock() {
|
||||||
|
let epoch = Math.floor(Date.now() / 1000);
|
||||||
|
let result = await runCommand("time " + String(epoch));
|
||||||
|
if (result.ok) {
|
||||||
|
await loadField("clock", "clockUtc");
|
||||||
|
statusEl.textContent = "Clock synced";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = parseReplyValue(result.text).toLowerCase();
|
||||||
|
if (!message.includes("cannot go backwards")) {
|
||||||
|
statusEl.textContent = parseReplyValue(result.text) || "Clock sync failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm("Device clock appears ahead of browser time. Force clock sync backwards?")) {
|
||||||
|
statusEl.textContent = "Clock sync cancelled";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
epoch = Math.floor(Date.now() / 1000);
|
||||||
|
result = await runCommand("time.force " + String(epoch));
|
||||||
|
if (result.ok) {
|
||||||
|
await loadField("clock", "clockUtc");
|
||||||
|
statusEl.textContent = "Clock force-synced";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = parseReplyValue(result.text) || "Clock force sync failed";
|
||||||
|
}
|
||||||
async function fetchJson(path) {
|
async function fetchJson(path) {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
@@ -1065,8 +1286,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function applyBootstrapData(data) {
|
function applyBootstrapData(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
|
if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
|
||||||
if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
|
if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
|
||||||
if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
|
if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
|
||||||
@@ -1074,11 +1295,18 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
|
if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
|
||||||
if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
|
if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
|
||||||
if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
|
if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
|
||||||
if (typeof data.mqtt_eastmesh_au === "string") setBrokerToggle("mqttEastmeshAu", data.mqtt_eastmesh_au);
|
if (typeof data.path_hash_mode === "string") document.getElementById("pathHashMode").value = data.path_hash_mode;
|
||||||
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu);
|
if (typeof data.private_key === "string") document.getElementById("privateKey").value = data.private_key.toUpperCase();
|
||||||
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us);
|
if (typeof data.radio === "string") currentRadioConfig = parseRadioValue(data.radio);
|
||||||
updateBrokerWarning();
|
if (typeof data.role === "string") document.getElementById("roleValue").value = data.role;
|
||||||
}
|
if (typeof data.clock === "string") document.getElementById("clockUtc").value = data.clock;
|
||||||
|
if (typeof data.public_key === "string") document.getElementById("publicKey").value = data.public_key.toUpperCase();
|
||||||
|
if (typeof data.mqtt_eastmesh_au === "string") setBrokerToggle("mqttEastmeshAu", data.mqtt_eastmesh_au);
|
||||||
|
if (typeof data.mqtt_letsmesh_eu === "string") setBrokerToggle("mqttLetsmeshEu", data.mqtt_letsmesh_eu);
|
||||||
|
if (typeof data.mqtt_letsmesh_us === "string") setBrokerToggle("mqttLetsmeshUs", data.mqtt_letsmesh_us);
|
||||||
|
syncRadioPresetUi();
|
||||||
|
updateBrokerWarning();
|
||||||
|
}
|
||||||
function getLetsmeshMode() {
|
function getLetsmeshMode() {
|
||||||
const eu = document.getElementById("mqttLetsmeshEu");
|
const eu = document.getElementById("mqttLetsmeshEu");
|
||||||
const us = document.getElementById("mqttLetsmeshUs");
|
const us = document.getElementById("mqttLetsmeshUs");
|
||||||
@@ -1151,12 +1379,69 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
function updateBrokerWarning() {
|
function updateBrokerWarning() {
|
||||||
document.getElementById("mqttBrokerWarning").textContent = "";
|
document.getElementById("mqttBrokerWarning").textContent = "";
|
||||||
}
|
}
|
||||||
async function loadBrokerState(cmd, inputId) {
|
async function loadBrokerState(cmd, inputId) {
|
||||||
const result = await runCommand(cmd);
|
const result = await runCommand(cmd);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
setBrokerToggle(inputId, parseReplyValue(result.text));
|
setBrokerToggle(inputId, parseReplyValue(result.text));
|
||||||
updateBrokerWarning();
|
updateBrokerWarning();
|
||||||
}
|
}
|
||||||
|
async function loadRadioConfig() {
|
||||||
|
const result = await runCommand("get radio");
|
||||||
|
if (!result.ok) {
|
||||||
|
setRadioPresetStatus("Unable to load current radio config.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseRadioValue(parseReplyValue(result.text));
|
||||||
|
if (!parsed) {
|
||||||
|
setRadioPresetStatus("Current radio config was not recognised.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentRadioConfig = parsed;
|
||||||
|
setRadioPresetStatus("");
|
||||||
|
syncRadioPresetUi();
|
||||||
|
}
|
||||||
|
async function loadRadioPresets() {
|
||||||
|
const selectEl = document.getElementById("radioPreset");
|
||||||
|
const applyBtn = document.getElementById("applyRadioPresetBtn");
|
||||||
|
if (selectEl) {
|
||||||
|
selectEl.innerHTML = '<option value="">Loading presets...</option>';
|
||||||
|
}
|
||||||
|
if (applyBtn) applyBtn.disabled = true;
|
||||||
|
setRadioPresetStatus("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(RADIO_PRESETS_URL, { cache:"no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Preset service returned " + res.status);
|
||||||
|
}
|
||||||
|
const payload = await res.json();
|
||||||
|
const config = payload && payload.config ? payload.config : {};
|
||||||
|
const suggested = config && config.suggested_radio_settings ? config.suggested_radio_settings : {};
|
||||||
|
const entries = Array.isArray(suggested.entries) ? suggested.entries : [];
|
||||||
|
radioPresetEntries = entries.map((entry) => ({
|
||||||
|
title: String(entry.title || "Unnamed preset"),
|
||||||
|
description: String(entry.description || ""),
|
||||||
|
frequency: formatDecimal(entry.frequency, 3),
|
||||||
|
bandwidth: formatDecimal(entry.bandwidth, 3),
|
||||||
|
spreadingFactor: Number.parseInt(entry.spreading_factor, 10),
|
||||||
|
codingRate: Number.parseInt(entry.coding_rate, 10)
|
||||||
|
})).filter((entry) => radioSignature(entry));
|
||||||
|
if (!selectEl) return;
|
||||||
|
const options = ['<option value="">Custom / current</option>'];
|
||||||
|
for (let i = 0; i < radioPresetEntries.length; i++) {
|
||||||
|
const preset = radioPresetEntries[i];
|
||||||
|
options.push(`<option value="${i}">${escapeHtml(preset.title)}</option>`);
|
||||||
|
}
|
||||||
|
selectEl.innerHTML = options.join("");
|
||||||
|
syncRadioPresetUi();
|
||||||
|
} catch (error) {
|
||||||
|
radioPresetEntries = [];
|
||||||
|
if (selectEl) {
|
||||||
|
selectEl.innerHTML = '<option value="">Community presets unavailable</option>';
|
||||||
|
}
|
||||||
|
if (applyBtn) applyBtn.disabled = true;
|
||||||
|
setRadioPresetStatus(error && error.message ? error.message : "Unable to load community presets.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function refreshStats() {
|
async function refreshStats() {
|
||||||
const getStatsBtn = document.getElementById("getStatsBtn");
|
const getStatsBtn = document.getElementById("getStatsBtn");
|
||||||
getStatsBtn.disabled = true;
|
getStatsBtn.disabled = true;
|
||||||
@@ -1270,6 +1555,42 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
|
const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
|
||||||
runCommand("set owner.info " + value);
|
runCommand("set owner.info " + value);
|
||||||
};
|
};
|
||||||
|
document.getElementById("refreshRadioBtn").onclick = () => loadRadioConfig();
|
||||||
|
document.getElementById("reloadRadioPresetsBtn").onclick = () => loadRadioPresets();
|
||||||
|
document.getElementById("copyPublicKeyBtn").onclick = () => copyToClipboard(document.getElementById("publicKey").value, "Public key copied");
|
||||||
|
document.getElementById("copyPrivateKeyBtn").onclick = () => copyToClipboard(document.getElementById("privateKey").value.toUpperCase(), "Private key copied");
|
||||||
|
document.getElementById("syncClockBtn").onclick = () => syncClock();
|
||||||
|
document.getElementById("radioPreset").addEventListener("change", (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "") {
|
||||||
|
syncRadioPresetUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preset = radioPresetEntries[Number.parseInt(value, 10)];
|
||||||
|
if (!preset) {
|
||||||
|
syncRadioPresetUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("applyRadioPresetBtn").disabled = false;
|
||||||
|
});
|
||||||
|
document.getElementById("applyRadioPresetBtn").onclick = async () => {
|
||||||
|
const selectEl = document.getElementById("radioPreset");
|
||||||
|
const index = Number.parseInt(selectEl.value, 10);
|
||||||
|
const preset = radioPresetEntries[index];
|
||||||
|
if (!preset) {
|
||||||
|
setRadioPresetStatus("Select a community preset first.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const command = `set radio ${preset.frequency} ${preset.bandwidth} ${preset.spreadingFactor} ${preset.codingRate}`;
|
||||||
|
const result = await runCommand(command);
|
||||||
|
if (!result.ok) {
|
||||||
|
setRadioPresetStatus(parseReplyValue(result.text) || "Unable to apply radio preset.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentRadioConfig = normalizeRadioConfig(preset);
|
||||||
|
syncRadioPresetUi();
|
||||||
|
setRadioPresetStatus("Preset saved. Reboot to apply the new radio settings.", false);
|
||||||
|
};
|
||||||
document.getElementById("rebootBtn").onclick = async () => {
|
document.getElementById("rebootBtn").onclick = async () => {
|
||||||
if (confirm("Reboot the repeater now?")) {
|
if (confirm("Reboot the repeater now?")) {
|
||||||
await runCommand("reboot");
|
await runCommand("reboot");
|
||||||
@@ -1310,9 +1631,16 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
|
|||||||
loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"),
|
loadBrokerState("get mqtt.letsmesh-us", "mqttLetsmeshUs"),
|
||||||
loadField("get advert.interval", "advertInterval"),
|
loadField("get advert.interval", "advertInterval"),
|
||||||
loadField("get flood.advert.interval", "floodInterval"),
|
loadField("get flood.advert.interval", "floodInterval"),
|
||||||
loadField("get flood.max", "floodMax")
|
loadField("get flood.max", "floodMax"),
|
||||||
|
loadField("get path.hash.mode", "pathHashMode"),
|
||||||
|
loadField("get prv.key", "privateKey", "uppercase"),
|
||||||
|
loadField("get role", "roleValue"),
|
||||||
|
loadField("clock", "clockUtc"),
|
||||||
|
loadField("get public.key", "publicKey", "uppercase"),
|
||||||
|
loadRadioConfig()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
loadRadioPresets();
|
||||||
}
|
}
|
||||||
refreshEastmeshModeUi();
|
refreshEastmeshModeUi();
|
||||||
refreshLetsmeshModeUi();
|
refreshLetsmeshModeUi();
|
||||||
@@ -1528,6 +1856,12 @@ esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
|
|||||||
{"advert_interval", "get advert.interval"},
|
{"advert_interval", "get advert.interval"},
|
||||||
{"flood_interval", "get flood.advert.interval"},
|
{"flood_interval", "get flood.advert.interval"},
|
||||||
{"flood_max", "get flood.max"},
|
{"flood_max", "get flood.max"},
|
||||||
|
{"path_hash_mode", "get path.hash.mode"},
|
||||||
|
{"private_key", "get prv.key"},
|
||||||
|
{"role", "get role"},
|
||||||
|
{"clock", "clock"},
|
||||||
|
{"public_key", "get public.key"},
|
||||||
|
{"radio", "get radio"},
|
||||||
};
|
};
|
||||||
|
|
||||||
httpd_resp_set_type(req, "application/json; charset=utf-8");
|
httpd_resp_set_type(req, "application/json; charset=utf-8");
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur