SensESP 3.6.1-alpha
Universal Signal K sensor toolkit ESP32
Loading...
Searching...
No Matches
signalk_ws_client.cpp
Go to the documentation of this file.
1#include "sensesp.h"
2
3#include "signalk_ws_client.h"
4
5#include <ArduinoJson.h>
6#include <ESPmDNS.h>
7#include <esp_http_client.h>
8
9#include <map>
10
12
13#ifdef SENSESP_SSL_SUPPORT
14#include <mbedtls/pem.h>
15#include <mbedtls/sha256.h>
16#include <mbedtls/ssl.h>
17#include <mbedtls/x509_crt.h>
18
20#endif
21
22#include <memory>
23#include <new>
24
25#include "Arduino.h"
26#include "elapsedMillis.h"
27#include "esp_arduino_version.h"
31#include "sensesp/system/uuid.h"
32#include "sensesp_app.h"
33
34namespace sensesp {
35
36constexpr int kWsClientTaskStackSize = 8192; // Stack for the connect worker
37constexpr int kWsTransportTaskStackSize = 6144; // Stack for esp_websocket_client internal task
38constexpr TickType_t kWsSendTimeoutTicks = pdMS_TO_TICKS(5000);
39// Periodic delta telemetry must never block the caller (it will be driven from
40// the event loop). 0 = enqueue if the ws-client lock and transport are free
41// right now, otherwise fail fast. Deltas are supersedable. See SignalK/SensESP#1033.
42constexpr TickType_t kWsDeltaSendTimeoutTicks = 0;
43// After a self-stop, the IDF 4.4 client task still closes its transport (a
44// TLS close_notify on wss) and sets STOPPED_BIT in the event group destroy()
45// frees.
46constexpr TickType_t kWsSelfStoppedTaskGraceTicks = pdMS_TO_TICKS(1000);
47// A device that keeps producing a delta larger than the buffer would drop one
48// every send cycle; rate-limit the warning so it does not flood the log.
49constexpr uint32_t kOversizeDropLogIntervalMs = 10000;
50
52
53static const char* kRequestPermission = "readwrite";
54
55#ifdef SENSESP_SSL_SUPPORT
56// Convert a SHA256 hash to hex string
57static void sha256_to_hex(const uint8_t* sha256, char* hex) {
58 for (int i = 0; i < 32; i++) {
59 sprintf(hex + (i * 2), "%02x", sha256[i]);
60 }
61 hex[64] = '\0';
62}
63
64// SHA256 of a certificate's raw DER, as a 64-char hex String.
65static String cert_fingerprint(const mbedtls_x509_crt* crt) {
66 uint8_t sha256[32];
67 mbedtls_sha256_context ctx;
68 mbedtls_sha256_init(&ctx);
69 mbedtls_sha256_starts(&ctx, 0); // 0 = SHA256 (not SHA224)
70 mbedtls_sha256_update(&ctx, crt->raw.p, crt->raw.len);
71 mbedtls_sha256_finish(&ctx, sha256);
72 mbedtls_sha256_free(&ctx);
73 char hex[65];
74 sha256_to_hex(sha256, hex);
75 return String(hex);
76}
77
78// Maximum stored/displayed length of a certificate CN.
79static constexpr size_t kMaxPinCnLen = 64;
80
81// Extract the CN from a certificate subject, for display. The CN is
82// attacker-controlled, so the result is length-bounded and stripped of
83// non-printable and quote/backslash characters before it is stored or shown.
84static String cert_common_name(const mbedtls_x509_crt* crt) {
85 char dn[256];
86 int len = mbedtls_x509_dn_gets(dn, sizeof(dn), &crt->subject);
87 if (len <= 0) {
88 return String("");
89 }
90 const char* cn = strstr(dn, "CN=");
91 if (cn == nullptr) {
92 return String("");
93 }
94 cn += 3; // skip "CN="
95 String out;
96 for (size_t i = 0; i < kMaxPinCnLen && cn[i] != '\0' && cn[i] != ','; i++) {
97 char c = cn[i];
98 if (c >= 0x20 && c < 0x7f && c != '"' && c != '\\') {
99 out += c;
100 }
101 }
102 return out;
103}
104
105// PEM-encode a certificate's DER for storage. The encode buffer is allocated on
106// demand and freed on return rather than held for the device's lifetime: TOFU CA
107// capture happens only during the TLS handshake, so a permanent .bss buffer
108// would waste ~4 KB for the whole uptime. It is heap- rather than stack-
109// allocated because the verify callback runs on a small TLS task stack.
110static String cert_to_pem(const mbedtls_x509_crt* crt) {
111 // Sized for a large CA cert (RSA-4096 + SANs/extensions). The size is fixed,
112 // not derived from crt->raw.len: PEM is base64 (~4/3 of the DER) plus the
113 // header/footer and line breaks, so the encoded form is always larger than the
114 // DER. On overflow mbedtls_pem_write_buffer returns an error and this returns
115 // "" -- callers must treat an empty PEM as "no usable CA" and fail safe, never
116 // store it.
117 constexpr size_t kPemBufSize = 4096;
118 std::unique_ptr<unsigned char[]> pem_buf(
119 new (std::nothrow) unsigned char[kPemBufSize]);
120 if (!pem_buf) {
121 ESP_LOGE("SKWSClient", "TOFU: PEM buffer allocation failed");
122 return String("");
123 }
124 size_t olen = 0;
125 int r = mbedtls_pem_write_buffer(
126 "-----BEGIN CERTIFICATE-----\n", "-----END CERTIFICATE-----\n",
127 crt->raw.p, crt->raw.len, pem_buf.get(), kPemBufSize, &olen);
128 if (r != 0) {
129 ESP_LOGE("SKWSClient", "TOFU: PEM encode failed (-0x%x)", -r);
130 return String("");
131 }
132 return String(reinterpret_cast<const char*>(pem_buf.get()));
133}
134
135// Normalized (lowercase, sorted, deduplicated, comma-joined) set of the
136// certificate's dNSName SANs, for TOFU identity binding. Empty if the cert has
137// no DNS SAN. Two certs with the same set of names produce the same string
138// regardless of order, so an exact-match comparison is stable across leaf
139// rotation but changes when the identity itself changes.
140static String cert_dns_sans(const mbedtls_x509_crt* crt) {
141 std::set<String> names;
142 for (const mbedtls_x509_sequence* cur = &crt->subject_alt_names;
143 cur != nullptr && cur->buf.p != nullptr; cur = cur->next) {
144 mbedtls_x509_subject_alternative_name san;
145 memset(&san, 0, sizeof(san));
146 if (mbedtls_x509_parse_subject_alt_name(&cur->buf, &san) != 0) {
147 continue;
148 }
149 if (san.type == MBEDTLS_X509_SAN_DNS_NAME &&
150 san.san.unstructured_name.p != nullptr &&
151 san.san.unstructured_name.len > 0) {
152 size_t n = san.san.unstructured_name.len;
153 if (n > 255) {
154 n = 255;
155 }
156 String name;
157 name.reserve(n);
158 for (size_t i = 0; i < n; i++) {
159 char c = static_cast<char>(san.san.unstructured_name.p[i]);
160 if (c >= 'A' && c <= 'Z') {
161 c = static_cast<char>(c + ('a' - 'A'));
162 }
163 name += c;
164 }
165 names.insert(name);
166 }
167 mbedtls_x509_free_subject_alt_name(&san);
168 }
169 String out;
170 for (const String& s : names) {
171 if (!out.isEmpty()) {
172 out += ",";
173 }
174 out += s;
175 }
176 return out;
177}
178
179// TOFU verification callback - called during the TLS handshake, once per
180// presented certificate, highest depth (CA) first down to depth 0 (leaf).
181// Returns 0 to allow, non-zero to reject.
182static int tofu_verify_callback(void* ctx, mbedtls_x509_crt* crt, int depth,
183 uint32_t* flags) {
184 SKWSClient* client = static_cast<SKWSClient*>(ctx);
185 if (client == nullptr) {
186 ESP_LOGW("SKWSClient", "TOFU: no client context, allowing connection");
187 *flags = 0;
188 return 0;
189 }
190
191 if (!client->is_tofu_enabled()) {
192 // Verification disabled: accept any certificate (insecure opt-out).
193 *flags = 0;
194 return 0;
195 }
196
197 // CA-anchor mode: the stored CA was installed as the trust anchor and esp-tls
198 // runs VERIFY_REQUIRED, so mbedTLS has already validated this certificate and
199 // set *flags. Honor that result rather than clearing it.
200 if (client->has_tofu_ca()) {
201 if (*flags != 0) {
202 ESP_LOGE("SKWSClient", "TOFU: certificate failed CA validation (0x%lx)",
203 (unsigned long)*flags);
204 client->flag_cert_error();
205 return MBEDTLS_ERR_X509_CERT_VERIFY_FAILED;
206 }
207 // Identity binding: at the leaf, require the same SAN identity captured when
208 // the CA was pinned. This is what keeps a public CA safe — a valid leaf for
209 // a different name signed by the same CA (e.g. any Let's Encrypt cert) is
210 // rejected here even though it chains to the pinned CA.
211 if (depth == 0 && !client->get_tofu_san().isEmpty()) {
212 if (cert_dns_sans(crt) != client->get_tofu_san()) {
213 ESP_LOGE("SKWSClient", "TOFU: leaf identity (SAN) mismatch, rejecting");
214 client->flag_cert_error();
215 return MBEDTLS_ERR_X509_CERT_VERIFY_FAILED;
216 }
217 }
218 return 0;
219 }
220
221 // Capture / leaf-fingerprint mode (VERIFY_OPTIONAL). Collect a CA candidate
222 // from the higher-depth certificates (which arrive first), then decide at the
223 // leaf. The first CA:TRUE certificate seen is the highest in the presented
224 // chain (closest to the root), so it is the preferred anchor.
225 // Certificates above the leaf (depth > 0): collect a CA candidate -- the
226 // highest CA:TRUE cert, which arrives first -- and never fail on chain-trust
227 // flags during capture. A depth-0 certificate is ALWAYS treated as the leaf
228 // by the fingerprint/role decision below, even if it is self-signed with
229 // CA:TRUE: a single presented certificate is pinned as a leaf, never adopted
230 // as a CA trust anchor. (Adopting a CA:TRUE leaf here would skip the
231 // fingerprint check and let a mismatched self-signed cert be accepted.)
232 if (depth > 0) {
233 // basicConstraints CA:TRUE has no public getter in mbedTLS 3.x, so the
234 // flag is read through the MBEDTLS_PRIVATE accessor macro.
235 bool is_ca = crt->MBEDTLS_PRIVATE(ca_istrue) != 0;
236 if (is_ca && !client->has_pending_ca()) {
237 String ca_pem = cert_to_pem(crt);
238 // Skip on encode failure: leaving no pending CA fails safe to leaf-
239 // fingerprint mode rather than committing an empty (pin-disabling) anchor.
240 if (!ca_pem.isEmpty()) {
241 client->stash_pending_ca(ca_pem, cert_common_name(crt));
242 }
243 }
244 *flags = 0;
245 return 0;
246 }
247
248 // depth == 0: the leaf. Always run the fingerprint/role decision.
249 String leaf_fp = cert_fingerprint(crt);
250 String leaf_san = cert_dns_sans(crt);
251 bool leaf_matches =
252 client->has_tofu_fingerprint() && client->get_tofu_fingerprint() == leaf_fp;
254 client->has_tofu_fingerprint(), leaf_matches, client->has_pending_ca(),
255 !leaf_san.isEmpty());
256
257 switch (decision) {
259 ESP_LOGE("SKWSClient", "TOFU: leaf fingerprint mismatch, rejecting");
260 client->flag_cert_error();
261 return MBEDTLS_ERR_X509_CERT_VERIFY_FAILED;
263 ESP_LOGI("SKWSClient", "TOFU: first use, pinning leaf %s", leaf_fp.c_str());
264 client->stash_pending_leaf(leaf_fp, cert_common_name(crt));
265 break;
267 ESP_LOGI("SKWSClient", "TOFU: first use, pinning issuing CA (identity %s)",
268 leaf_san.c_str());
269 client->set_pending_san(leaf_san); // bind the leaf identity to the CA
270 break;
272 // Leaf matches the stored fingerprint: keep the leaf pin and drop any
273 // stray pending state (e.g. a CA stashed at depth > 0 this handshake --
274 // mode is fixed at first use, so a later-presented CA is not adopted).
275 client->clear_pending_tofu();
276 break;
277 }
278 *flags = 0;
279 return 0;
280}
281
282// Attach function installed for the TLS connection. In CA-anchor mode it
283// installs the pinned CA as the mbedTLS trust anchor (esp-tls runs
284// VERIFY_REQUIRED, so mbedTLS validates the chain against it); otherwise it
285// selects VERIFY_OPTIONAL so the verify callback can capture / fingerprint.
286static esp_err_t tofu_crt_bundle_attach(void* conf) {
287 mbedtls_ssl_config* ssl_conf = static_cast<mbedtls_ssl_config*>(conf);
288 SKWSClient* client = ws_client;
289
290 if (client != nullptr && client->is_tofu_enabled() && client->has_tofu_ca()) {
291 // Re-parse the stored CA into a static cert that outlives the handshake.
292 static mbedtls_x509_crt pinned_ca;
293 static bool pinned_ca_inited = false;
294 if (pinned_ca_inited) {
295 mbedtls_x509_crt_free(&pinned_ca);
296 }
297 mbedtls_x509_crt_init(&pinned_ca);
298 pinned_ca_inited = true;
299 const String& pem = client->get_tofu_ca();
300 int r = mbedtls_x509_crt_parse(
301 &pinned_ca, reinterpret_cast<const unsigned char*>(pem.c_str()),
302 pem.length() + 1);
303 if (r == 0) {
304 mbedtls_ssl_conf_ca_chain(ssl_conf, &pinned_ca, nullptr);
305 // esp-tls already set VERIFY_REQUIRED before calling us; keep it.
306 ESP_LOGD("SKWSClient", "TOFU: pinned CA installed as trust anchor");
307 } else {
308 // Stored CA won't parse (corruption/bug). Fail closed: leave
309 // VERIFY_REQUIRED with no trust anchor so the handshake is rejected and
310 // surfaces as a certificate error requiring a manual reset -- never
311 // silently downgrade to accept-any.
312 ESP_LOGE("SKWSClient",
313 "TOFU: stored CA failed to parse (-0x%x); connections will fail "
314 "until reset",
315 -r);
316 }
317 } else {
318 // Capture / leaf-fingerprint mode (or TOFU disabled): the callback decides.
319 mbedtls_ssl_conf_authmode(ssl_conf, MBEDTLS_SSL_VERIFY_OPTIONAL);
320 }
321
322 mbedtls_ssl_conf_verify(ssl_conf, tofu_verify_callback, client);
323 return ESP_OK;
324}
325#endif // SENSESP_SSL_SUPPORT
326
327static void websocket_event_handler(void* handler_args,
328 esp_event_base_t base,
329 int32_t event_id, void* event_data) {
330 // Drop events from a client that has been handed off for destruction: the
331 // handler was registered with the generation current at build time; if that no
332 // longer matches the live generation, this callback is from a reaped client.
333 if (ws_client == nullptr ||
334 static_cast<uint32_t>(reinterpret_cast<uintptr_t>(handler_args)) !=
336 return;
337 }
338 esp_websocket_event_data_t* data = (esp_websocket_event_data_t*)event_data;
339 switch (event_id) {
340 case WEBSOCKET_EVENT_CONNECTED:
342 break;
343 case WEBSOCKET_EVENT_DISCONNECTED:
344 // A close frame from the server ends the client task with CLOSED and no
345 // DISCONNECTED event, and the client does not reconnect by itself.
346 case WEBSOCKET_EVENT_CLOSED:
348 break;
349 case WEBSOCKET_EVENT_DATA:
350 // Only process text frames (opcode 0x1) and continuation frames (0x0).
351 // Control frames (ping/pong/close: 0x8-0xA) have no JSON payload.
352 if (data->op_code <= 0x2) {
353 ws_client->on_receive_delta((uint8_t*)data->data_ptr, data->data_len);
354 }
355 break;
356 case WEBSOCKET_EVENT_ERROR:
357 // The HTTP status of the failed upgrade (e.g. 401 for a rejected token)
358 // lets us distinguish a bad token from a transient transport error. The
359 // handshake status field only exists in the newer esp_websocket_client
360 // component pulled for SSL builds; the version bundled with the EOL
361 // espressif32 Arduino platform lacks it, so fall back to 0 (not
362 // applicable), matching how on_error() treats a transient failure.
363#ifdef SENSESP_SSL_SUPPORT
364 ws_client->on_error(data->error_handle.esp_ws_handshake_status_code);
365#else
367#endif
368 break;
369 }
370}
371
372SKWSClient::SKWSClient(const String& config_path,
373 std::shared_ptr<SKDeltaQueue> sk_delta_queue,
374 const String& server_address, uint16_t server_port,
375 bool use_mdns)
376 : FileSystemSaveable{config_path},
377 conf_server_address_{server_address},
378 conf_server_port_{server_port},
379 use_mdns_{use_mdns},
380 sk_delta_queue_{sk_delta_queue} {
381 // a SKWSClient object observes its own connection_state_ member
382 // and simply passes through any notification it emits. As a result,
383 // whenever the value of connection_state_ is updated, observers of the
384 // SKWSClient object get automatically notified.
386 [this]() { this->emit(this->connection_state_.get()); });
387
388 // process any received updates in the main task
389 event_loop()->onRepeat(1, [this]() { this->process_received_updates(); });
390
391 // set the singleton object pointer
392 ws_client = this;
393
394 load();
395
396 // Connect the counters
398
399 event_loop()->onDelay(0, [this]() {
400 ESP_LOGD(__FILENAME__, "Starting SKWSClient");
401 // Run the connection lifecycle on the event loop instead of a dedicated
402 // task: connect() is a non-blocking dispatcher (it spawns a worker for the
403 // blocking auth legs) and send_delta() is non-blocking, so neither stalls
404 // the loop. The ~100 ms cadence matches the former task's vTaskDelay(100ms).
405 event_loop()->onRepeat(100, [this]() {
406 // Retry a teardown whose reaper failed to spawn under OOM (no-op if none).
408 if (is_connect_due()) {
409 connect();
410 }
411 send_delta();
412 });
413 MDNS.addService("signalk-sensesp", "tcp", 80);
414 });
415}
416
426
427bool should_clear_token_on_status(int handshake_status) {
428 return handshake_status == kHttpUnauthorized;
429}
430
437void SKWSClient::on_error(int handshake_status) {
439 if (auth_token_ != NULL_AUTH_TOKEN &&
440 should_clear_token_on_status(handshake_status)) {
441 // The server rejected the token on the WebSocket upgrade (e.g. the server
442 // was reinstalled, or the device was moved to a different server). Clear it
443 // so the next reconnect requests fresh access, and reset the backoff so
444 // attempts after the already-scheduled one come at the short interval. A
445 // 401 on a tokenless upgrade cannot mean a stale token: it falls through
446 // to the retry branch, keeping the backoff growing and avoiding a config
447 // flash write per attempt. A non-401 error (transport, TLS, network)
448 // leaves the token intact and simply retries.
449 ESP_LOGW(__FILENAME__, "Token rejected (%d), requesting new access",
450 handshake_status);
451 auth_token_ = NULL_AUTH_TOKEN;
452 save();
454 } else {
455 ESP_LOGW(__FILENAME__, "Websocket client error.");
456 }
457}
458
465 // The connection is fully established and the server proved possession of its
466 // certificate's private key, so it is safe to persist any anchor stashed
467 // during this attempt's handshake (first-use capture).
468 this->commit_pending_tofu();
471 this->sk_delta_queue_->reset_meta_send();
472 ESP_LOGI(__FILENAME__, "Subscribing to Signal K listeners...");
473 this->subscribe_listeners();
474}
475
483 bool output_available = false;
484 JsonDocument subscription;
485 subscription["context"] = "vessels.self";
486
488 const std::vector<SKListener*>& listeners = SKListener::get_listeners();
489
490 if (listeners.size() > 0) {
491 output_available = true;
492 JsonArray subscribe = subscription["subscribe"].to<JsonArray>();
493
494 // Collapse listeners that share a path into one subscription entry.
495 // With sendMeta=all (a connection-level flag), a single subscription
496 // delivers both value and meta deltas, so a value listener and a
497 // metadata listener on the same path — the common gauge pattern —
498 // would otherwise emit two identical entries. Keep the smallest
499 // period so the fastest listener's cadence wins.
500 std::map<String, int> path_period;
501 for (size_t i = 0; i < listeners.size(); i++) {
502 auto* listener = listeners.at(i);
503 const String& sk_path = listener->get_sk_path();
504 int listen_delay = listener->get_listen_delay();
505 auto it = path_period.find(sk_path);
506 if (it == path_period.end() || listen_delay < it->second) {
507 path_period[sk_path] = listen_delay;
508 }
509 }
510 for (const auto& [sk_path, listen_delay] : path_period) {
511 JsonObject subscribe_path = subscribe.add<JsonObject>();
512 subscribe_path["path"] = sk_path;
513 subscribe_path["period"] = listen_delay;
514 ESP_LOGI(__FILENAME__, "Adding %s subscription with listen_delay %d\n",
515 sk_path.c_str(), listen_delay);
516 }
517 }
519
520 if (output_available &&
522 String json_message;
523
524 serializeJson(subscription, json_message);
525 ESP_LOGI(__FILENAME__, "Subscription JSON message:\n %s",
526 json_message.c_str());
527 int result = esp_websocket_client_send_text(
528 client_.load(), json_message.c_str(), json_message.length(),
530 if (result < 0) {
531 ESP_LOGE(__FILENAME__, "Subscription send failed (result=%d)", result);
532 }
533 }
534}
535
543void SKWSClient::on_receive_delta(uint8_t* payload, size_t length) {
544 // Need to work on null-terminated strings
545 constexpr size_t kMaxWsMessageSize = 4096;
546 if (length > kMaxWsMessageSize) {
547 ESP_LOGW(__FILENAME__, "WebSocket message too large (%u bytes), dropping",
548 (unsigned)length);
549 return;
550 }
551 std::unique_ptr<char[]> buf(new char[length + 1]);
552 memcpy(buf.get(), payload, length);
553 buf[length] = 0;
554
555#ifdef SIGNALK_PRINT_RCV_DELTA
556 ESP_LOGD(__FILENAME__, "Websocket payload received: %s", buf.get());
557#endif
558
559 JsonDocument message;
560 auto error = deserializeJson(message, buf.get());
561
562 if (!error) {
563 if (message["updates"].is<JsonVariant>()) {
564 on_receive_updates(message);
565 }
566
567 if (message["put"].is<JsonVariant>()) {
568 on_receive_put(message);
569 }
570
571 // Putrequest contains also requestId Key GA
572 if (message["requestId"].is<JsonVariant>() &&
573 !message["put"].is<JsonVariant>()) {
575 }
576 } else {
577 ESP_LOGE(__FILENAME__, "deserializeJson error: %s", error.c_str());
578 }
579}
580
588void SKWSClient::on_receive_updates(JsonDocument& message) {
589 // Process updates from subscriptions...
590 JsonArray updates = message["updates"];
591
592 // With sendMeta=all enabled by default, the server pushes meta deltas to
593 // every client. Skip copying them onto the queue unless something actually
594 // consumes them. Compute this before taking received_updates_semaphore_ to
595 // preserve the global lock order (SKListener before received_updates).
596 bool has_meta_listener = false;
598 for (SKListener* listener : SKListener::get_listeners()) {
599 if (listener->wants_meta()) {
600 has_meta_listener = true;
601 break;
602 }
603 }
605
607 for (size_t i = 0; i < updates.size(); i++) {
608 JsonObject update = updates[i];
609
610 JsonArray values = update["values"];
611
612 for (size_t vi = 0; vi < values.size(); vi++) {
613 // Copy each value into an owned document for processing in the main
614 // task (decoupled from `message`, which is freed when on_receive_delta
615 // returns).
617 ru.is_meta = false;
618 ru.doc.set(values[vi]);
619 enqueue_received_update(std::move(ru));
620 }
621
622 // Meta deltas only arrive when subscribed with sendMeta=all and are
623 // typically one-shot per path (at subscribe + on metadata change). Copy
624 // each meta entry into an owned document the same way; SKMetadataListener
625 // consumers receive it (path-routed) on the main task. No user code runs
626 // here, so the critical section stays free of arbitrary callbacks. Skip
627 // entirely when no listener consumes meta (see has_meta_listener above).
628 if (has_meta_listener) {
629 JsonArray meta_entries = update["meta"];
630 for (size_t mi = 0; mi < meta_entries.size(); mi++) {
631 JsonObject entry = meta_entries[mi];
632 if (entry["path"].isNull() || entry["value"].isNull()) continue;
634 ru.is_meta = true;
635 ru.doc.set(entry); // {path, value: {...meta...}}
636 enqueue_received_update(std::move(ru));
637 }
638 }
639 }
641}
642
653 // Per-kind caps, tunable via build_flags (see signalk_ws_client.h). Meta and
654 // value deltas are budgeted independently so a metadata burst cannot evict
655 // pending values, and vice versa.
656 constexpr size_t kMaxReceivedValues = SENSESP_MAX_RECEIVED_VALUE_UPDATES;
657 constexpr size_t kMaxReceivedMeta = SENSESP_MAX_RECEIVED_META_UPDATES;
658
659 const bool is_meta = update.is_meta;
660 const size_t cap = is_meta ? kMaxReceivedMeta : kMaxReceivedValues;
661
662 size_t kind_count = 0;
663 for (const auto& ru : received_updates_) {
664 if (ru.is_meta == is_meta) kind_count++;
665 }
666
667 while (kind_count >= cap) {
668 // Drop the oldest entry of the SAME kind, leaving the other kind intact.
669 for (auto it = received_updates_.begin(); it != received_updates_.end();
670 ++it) {
671 if (it->is_meta == is_meta) {
672 received_updates_.erase(it);
673 kind_count--;
674 break;
675 }
676 }
677 ESP_LOGW(__FILENAME__, "Dropping oldest received %s update (queue full)",
678 is_meta ? "meta" : "value");
679 }
680
681 received_updates_.push_back(std::move(update));
682}
683
692
693 const std::vector<SKListener*>& listeners = SKListener::get_listeners();
694 const std::vector<SKPutListener*>& put_listeners =
696
698 // Count only value/put deltas toward the rx metric; meta deltas are
699 // low-frequency one-shots and would otherwise inflate it.
700 int num_updates = 0;
701 while (!received_updates_.empty()) {
702 ReceivedUpdate& ru = received_updates_.front();
703
704 if (ru.is_meta) {
705 // Capture the path into an owned String before moving the document: the
706 // const char* would dangle once ru.doc is moved into the shared_ptr.
707 String path = ru.doc["path"].as<String>();
708 // Move the already-owned queue document into a refcounted, read-only
709 // shared_ptr (no extra deep copy) so it safely outlives the queue entry
710 // and any deferred consumer, then fan it out to matching listeners.
711 std::shared_ptr<const JsonDocument> meta_doc =
712 std::make_shared<const JsonDocument>(std::move(ru.doc));
713 for (size_t i = 0; i < listeners.size(); i++) {
714 SKListener* listener = listeners[i];
715 if (listener->wants_meta() && listener->matches(path)) {
716 listener->parse_meta(meta_doc);
717 }
718 }
719 } else {
720 num_updates++;
721 const char* path = ru.doc["path"];
722 JsonObject value = ru.doc.as<JsonObject>();
723
724 for (size_t i = 0; i < listeners.size(); i++) {
725 SKListener* listener = listeners[i];
726 if (!listener->wants_meta() && listener->matches(path)) {
727 listener->parse_value(value);
728 }
729 }
730 // to be able to parse values of Put Listeners GA
731 for (size_t i = 0; i < put_listeners.size(); i++) {
732 SKPutListener* listener = put_listeners[i];
733 if (listener->get_sk_path().equals(path)) {
734 listener->parse_value(value);
735 }
736 }
737 }
738 received_updates_.pop_front();
739 }
741 delta_rx_count_producer_.set(num_updates);
742
744}
745
753void SKWSClient::on_receive_put(JsonDocument& message) {
754 // Process PUT requests...
755 JsonArray puts = message["put"];
756 bool all_matched = true;
757 for (size_t i = 0; i < puts.size(); i++) {
758 JsonObject value = puts[i];
759 const char* path = value["path"];
760 bool matched = false;
761
763 const std::vector<SKPutListener*>& listeners =
765 for (size_t j = 0; j < listeners.size(); j++) {
766 SKPutListener* listener = listeners[j];
767 if (listener->get_sk_path().equals(path)) {
769 ru.is_meta = false;
770 ru.doc.set(value);
772 enqueue_received_update(std::move(ru));
774 matched = true;
775 }
776 }
778
779 if (!matched) {
780 all_matched = false;
781 }
782 }
783
784 // Send back a single request response if still connected
786 JsonDocument put_response;
787 put_response["requestId"] = message["requestId"];
788 if (all_matched) {
789 put_response["state"] = "COMPLETED";
790 put_response["statusCode"] = 200;
791 } else {
792 put_response["state"] = "FAILED";
793 put_response["statusCode"] = 405;
794 }
795 String response_text;
796 serializeJson(put_response, response_text);
797 int result = esp_websocket_client_send_text(
798 client_.load(), response_text.c_str(), response_text.length(),
800 if (result < 0) {
801 ESP_LOGE(__FILENAME__, "PUT response send failed (result=%d)", result);
802 }
803 }
804}
805
813void SKWSClient::sendTXT(String& payload) {
815 int result = esp_websocket_client_send_text(
816 client_.load(), payload.c_str(), payload.length(), kWsSendTimeoutTicks);
817 if (result < 0) {
818 ESP_LOGE(__FILENAME__, "sendTXT failed (result=%d)", result);
819 }
820 }
821}
822
823bool SKWSClient::get_mdns_service(String& server_address,
824 uint16_t& server_port) {
825 // get IP address using an mDNS query
826 // Try SSL service first, then fall back to non-SSL
827 int num = MDNS.queryService("signalk-wss", "tcp");
828 if (num > 0) {
829 // Found SSL-enabled server
830 ssl_enabled_ = true;
831 ESP_LOGI(__FILENAME__, "Found Signal K server via mDNS (signalk-wss)");
832 } else {
833 // Try non-SSL service
834 num = MDNS.queryService("signalk-ws", "tcp");
835 if (num == 0) {
836 // no service found
837 return false;
838 }
839 // Found non-SSL server, disable SSL
840 ssl_enabled_ = false;
841 ESP_LOGI(__FILENAME__, "Found Signal K server via mDNS (signalk-ws)");
842 }
843
844#if ESP_ARDUINO_VERSION_MAJOR < 3
845 server_address = MDNS.IP(0).toString();
846#else
847 server_address = MDNS.address(0).toString();
848#endif
849 server_port = MDNS.port(0);
850 ESP_LOGI(__FILENAME__, "Found server %s (port %d)", server_address.c_str(),
851 server_port);
852 return true;
853}
854
855// Event handler for detect_ssl() to capture the Location response header
856static esp_err_t detect_ssl_event_handler(esp_http_client_event_t* evt) {
857 if (evt->event_id == HTTP_EVENT_ON_HEADER) {
858 // Check for Location header (case-insensitive)
859 if (strcasecmp(evt->header_key, "Location") == 0) {
860 String* location = static_cast<String*>(evt->user_data);
861 *location = evt->header_value;
862 }
863 }
864 return ESP_OK;
865}
866
868 // Try to detect if the server requires SSL by checking for HTTP->HTTPS
869 // redirects
870 String url =
871 String("http://") + server_address_ + ":" + server_port_ + "/signalk";
872
873 ESP_LOGD(__FILENAME__, "Probing for SSL redirect at %s", url.c_str());
874
875 String location;
876
877 esp_http_client_config_t config = {};
878 config.url = url.c_str();
879 config.disable_auto_redirect = true;
880 config.timeout_ms = 10000;
881 config.event_handler = detect_ssl_event_handler;
882 config.user_data = &location;
883
884 esp_http_client_handle_t client = esp_http_client_init(&config);
885 if (client == nullptr) {
886 ESP_LOGE(__FILENAME__, "Failed to initialize HTTP client");
887 return false;
888 }
889
890 esp_err_t err = esp_http_client_perform(client);
891 int status_code = esp_http_client_get_status_code(client);
892 esp_http_client_cleanup(client);
893
894 if (err != ESP_OK) {
895 ESP_LOGD(__FILENAME__, "HTTP request failed: %s", esp_err_to_name(err));
896 return false;
897 }
898
899 if ((status_code == 301 || status_code == 302 ||
900 status_code == 307 || status_code == 308) &&
901 location.startsWith("https://")) {
902 ESP_LOGI(__FILENAME__, "SSL redirect detected, enabling HTTPS/WSS");
903 ssl_enabled_ = true;
904 save();
905 return true;
906 }
907
908 return false;
909}
910
911
913 // A prior certificate rejection leaves the state in kSKWSCertificateError;
914 // treat it like a disconnect for retry purposes so the device keeps trying
915 // (and re-surfaces the cert error each failed attempt).
918 return;
919 }
920
921 // A connect attempt is already running on a worker task; let it finish.
922 if (auth_job_running_.load()) {
923 return;
924 }
925
926 // Reap any client left from a previous attempt before starting a new one,
927 // off this context. While the reap is in flight, defer bring-up so at most one
928 // client ever exists; state stays Disconnected, so a later cycle retries.
929 if (client_.load() != nullptr) {
931 }
932 if (teardown_in_progress_.load()) {
933 return;
934 }
935
936 // Discard any anchor candidate stashed by a previous attempt; it is only
937 // committed after a fully successful connection (see on_connected). Clearing
938 // here also prevents committing stale data if a resumed TLS session skips the
939 // certificate callback.
941
942 // Schedule next attempt with backoff in case this one fails.
943 // Will be reset on successful connection.
945
946 // Wait for the active network provisioner (WiFi, Ethernet, …) to be
947 // up before initiating the WS connection. The provisioner abstracts
948 // away whether we're on WiFi, Ethernet, or some other transport.
949 auto provisioner = SensESPApp::get()->get_network_provisioner();
950 if (!provisioner || !provisioner->is_connected()) {
951 ESP_LOGI(__FILENAME__,
952 "Network is not yet up. SignalK client connection will be "
953 "initiated when the link comes up.");
954 return;
955 }
956
958
959 // The rest of the attempt — mDNS resolve, SSL detect, and the access-request /
960 // poll / connect_ws leg — makes blocking HTTP/mDNS calls, so run it on a
961 // one-shot worker task. The SK/event-loop context stays responsive, and
962 // auth_job_running_ keeps at most one attempt in flight.
963 auth_job_running_.store(true);
964 if (xTaskCreate(&SKWSClient::connect_worker, "SKWSConnect",
965 kWsClientTaskStackSize, this, 1, nullptr) != pdPASS) {
966 ESP_LOGE(__FILENAME__, "connect worker spawn failed");
967 auth_job_running_.store(false);
969 }
970}
971
973 auto* self = static_cast<SKWSClient*>(arg);
975 self->auth_job_running_.store(false);
976 vTaskDelete(nullptr);
977}
978
980 ESP_LOGI(__FILENAME__, "Initiating websocket connection with server...");
981
982 if (use_mdns_) {
983 if (!get_mdns_service(this->server_address_, this->server_port_)) {
984 ESP_LOGE(__FILENAME__,
985 "No Signal K server found in network when using mDNS service!");
986 } else {
987 ESP_LOGI(__FILENAME__,
988 "Signal K server has been found at address %s:%d by mDNS.",
989 this->server_address_.c_str(), this->server_port_);
990 }
991 } else {
993 this->server_port_ = this->conf_server_port_;
994 }
995
996 if (!this->server_address_.isEmpty() && this->server_port_ > 0) {
997 ESP_LOGD(__FILENAME__,
998 "Websocket is connecting to Signal K server on address %s:%d",
999 this->server_address_.c_str(), this->server_port_);
1000
1001 // Detect if server requires SSL (check for HTTP->HTTPS redirects)
1002 if (!ssl_enabled_) {
1003 detect_ssl();
1004 }
1005 } else {
1006 // host and port not defined - don't try to connect
1007 ESP_LOGD(__FILENAME__,
1008 "Websocket is not connecting to Signal K server because host and "
1009 "port are not defined.");
1011 return;
1012 }
1013
1014 if (this->polling_href_.length() > 0 && this->polling_href_.startsWith("/")) {
1015 // existing pending request
1017 this->polling_href_);
1018 return;
1019 }
1020
1021 if (this->auth_token_ == NULL_AUTH_TOKEN) {
1022 // initiate HTTP authentication
1023 ESP_LOGD(__FILENAME__, "No prior authorization token present.");
1025 return;
1026 }
1027
1028 // A token is already present. Validate it before streaming.
1029#ifdef SENSESP_SSL_SUPPORT
1030 // Connect the WebSocket directly rather than first probing the token over a
1031 // separate HTTPS request: on memory-constrained targets (e.g. ESP32-C3) the
1032 // back-to-back token-probe TLS handshake and the WebSocket TLS handshake
1033 // fragment the heap, and the second fails to allocate
1034 // (MBEDTLS_ERR_SSL_ALLOC_FAILED). The server validates the token on the
1035 // upgrade itself; a 401 there is handled in on_error() (clears the token and
1036 // re-requests access on the next reconnect).
1037 this->connect_ws(this->server_address_, this->server_port_);
1038#else
1039 // The bundled (non-SSL) esp_websocket_client reports no upgrade status, so
1040 // on_error() cannot tell a rejected token from a transient failure. Probe the
1041 // token over plain HTTP first -- there is no TLS handshake to fragment the
1042 // heap. A 401 there clears the token and re-requests access.
1043 this->test_token(this->server_address_, this->server_port_);
1044#endif
1045}
1046
1048 // No-op: esp_websocket_client handles data via event callbacks
1049}
1050
1051#ifndef SENSESP_SSL_SUPPORT
1052void SKWSClient::test_token(const String server_address,
1053 const uint16_t server_port) {
1054 String url = String("http://") + server_address + ":" + server_port +
1055 "/signalk/v1/stream";
1056 ESP_LOGD(__FILENAME__, "Testing token with url %s", url.c_str());
1057
1058 const String full_token = String("Bearer ") + auth_token_;
1059 ESP_LOGD(__FILENAME__, "Authorization: %.8s...[redacted]", full_token.c_str());
1060
1061 esp_http_client_config_t config = {};
1062 config.url = url.c_str();
1063 config.timeout_ms = 10000;
1064
1065 esp_http_client_handle_t client = esp_http_client_init(&config);
1066 if (client == nullptr) {
1067 ESP_LOGE(__FILENAME__, "Failed to initialize HTTP client");
1069 return;
1070 }
1071
1072 esp_http_client_set_header(client, "Authorization", full_token.c_str());
1073
1074 // Use streaming API for GET request
1075 esp_err_t err = esp_http_client_open(client, 0);
1076 if (err != ESP_OK) {
1077 ESP_LOGE(__FILENAME__, "Failed to open HTTP connection: %s",
1078 esp_err_to_name(err));
1079 esp_http_client_cleanup(client);
1081 return;
1082 }
1083
1084 int content_length = esp_http_client_fetch_headers(client);
1085 int http_code = esp_http_client_get_status_code(client);
1086
1087 ESP_LOGD(__FILENAME__, "Testing resulted in http status %d", http_code);
1088
1089 // Read response body
1090 String payload;
1091 if (content_length > 0 && content_length < 4096) {
1092 char* buffer = new char[content_length + 1];
1093 int read_len = esp_http_client_read(client, buffer, content_length);
1094 buffer[read_len > 0 ? read_len : 0] = '\0';
1095 payload = String(buffer);
1096 delete[] buffer;
1097 } else {
1098 // Chunked encoding or unknown/large content length - read in chunks
1099 char buffer[512];
1100 int read_len;
1101 while ((read_len = esp_http_client_read(client, buffer,
1102 sizeof(buffer) - 1)) > 0) {
1103 buffer[read_len] = '\0';
1104 payload += String(buffer);
1105 if (payload.length() > 4096) break;
1106 }
1107 }
1108
1109 esp_http_client_close(client);
1110 esp_http_client_cleanup(client);
1111
1112 if (payload.length() > 0) {
1113 ESP_LOGD(__FILENAME__, "Returned payload (%d bytes): %s", payload.length(),
1114 payload.c_str());
1115 }
1116
1117 if (http_code == 426) {
1118 // HTTP status 426 is "Upgrade Required", the expected response for a
1119 // websocket endpoint reached over plain HTTP: the token is valid.
1120 ESP_LOGD(__FILENAME__, "Attempting to connect to Signal K Websocket...");
1121 this->connect_ws(server_address, server_port);
1122 } else if (http_code == kHttpUnauthorized) {
1123 // Token is invalid/expired - clear it and request new access.
1124 // Keep client_id_ so we reuse the same device identity.
1125 ESP_LOGW(__FILENAME__, "Token rejected (401), requesting new access");
1126 this->auth_token_ = NULL_AUTH_TOKEN;
1127 this->save();
1128 this->send_access_request(server_address, server_port);
1129 } else if (http_code > 0) {
1131 } else {
1132 ESP_LOGE(__FILENAME__, "HTTP request failed with code %d", http_code);
1134 }
1135}
1136#endif // !SENSESP_SSL_SUPPORT
1137
1138void SKWSClient::send_access_request(const String server_address,
1139 const uint16_t server_port) {
1140 ESP_LOGD(__FILENAME__, "Sending access request (client_id=%s, ssl=%d)",
1141 client_id_.c_str(), ssl_enabled_);
1142 if (client_id_ == "") {
1143 // generate a client ID
1145 save();
1146 }
1147
1148 // create a new access request
1149 JsonDocument doc;
1150 doc["clientId"] = client_id_;
1151 doc["description"] =
1152 String("SensESP device: ") + SensESPBaseApp::get_hostname();
1153 doc["permissions"] = kRequestPermission;
1154 String json_req = "";
1155 serializeJson(doc, json_req);
1156
1157 ESP_LOGD(__FILENAME__, "Access request: %s", json_req.c_str());
1158
1159 String protocol = ssl_enabled_ ? "https://" : "http://";
1160 String url = protocol + server_address + ":" + server_port +
1161 "/signalk/v1/access/requests";
1162 ESP_LOGD(__FILENAME__, "Access request url: %s", url.c_str());
1163
1164 esp_http_client_config_t config = {};
1165 config.url = url.c_str();
1166 config.method = HTTP_METHOD_POST;
1167 config.timeout_ms = 10000;
1168#ifdef SENSESP_SSL_SUPPORT
1169 if (ssl_enabled_) {
1170 config.crt_bundle_attach = tofu_crt_bundle_attach;
1171 config.skip_cert_common_name_check = true;
1172 }
1173#endif
1174
1175 esp_http_client_handle_t client = esp_http_client_init(&config);
1176 if (client == nullptr) {
1177 ESP_LOGE(__FILENAME__, "Failed to initialize HTTP client");
1179 // Don't clear client_id_ - keep device identity for retry
1180 return;
1181 }
1182
1183 esp_http_client_set_header(client, "Content-Type", "application/json");
1184
1185 // Use streaming API: open -> write request -> fetch headers -> read response
1186 esp_err_t err = esp_http_client_open(client, json_req.length());
1187 if (err != ESP_OK) {
1188 ESP_LOGE(__FILENAME__, "Failed to open HTTP connection: %s", esp_err_to_name(err));
1189 esp_http_client_cleanup(client);
1191 return;
1192 }
1193
1194 int write_len = esp_http_client_write(client, json_req.c_str(), json_req.length());
1195 if (write_len < 0 || write_len != (int)json_req.length()) {
1196 ESP_LOGE(__FILENAME__, "Failed to write request body (wrote %d of %d bytes)",
1197 write_len, json_req.length());
1198 esp_http_client_close(client);
1199 esp_http_client_cleanup(client);
1201 return;
1202 }
1203
1204 int content_length = esp_http_client_fetch_headers(client);
1205 int http_code = esp_http_client_get_status_code(client);
1206
1207 ESP_LOGD(__FILENAME__, "HTTP response: code=%d, content_length=%d", http_code, content_length);
1208
1209 // Read response body
1210 String payload;
1211 char buffer[512];
1212 int read_len;
1213 while ((read_len = esp_http_client_read(client, buffer, sizeof(buffer) - 1)) > 0) {
1214 buffer[read_len] = '\0';
1215 payload += String(buffer);
1216 if (payload.length() > 4096) break;
1217 }
1218 ESP_LOGD(__FILENAME__, "Response payload (%d bytes): %s",
1219 payload.length(), payload.c_str());
1220
1221 esp_http_client_close(client);
1222 esp_http_client_cleanup(client);
1223
1224 // Parse JSON response for both 202 and 400 status codes
1225 deserializeJson(doc, payload.c_str());
1226 String state = doc["state"].is<const char*>() ? doc["state"].as<String>() : "";
1227 String href = doc["href"].is<const char*>() ? doc["href"].as<String>() : "";
1228 String message = doc["message"].is<const char*>() ? doc["message"].as<String>() : "";
1229
1230 ESP_LOGD(__FILENAME__, "Access request response: http=%d, state=%s, href=%s",
1231 http_code, state.c_str(), href.c_str());
1232 if (message.length() > 0) {
1233 ESP_LOGI(__FILENAME__, "Server message: %s", message.c_str());
1234 }
1235
1236 // HTTP 400 with href means "already requested" - save href for polling on
1237 // next connect() cycle (after backoff)
1238 if (http_code == 400 && href.length() > 0 && href.startsWith("/")) {
1239 ESP_LOGI(__FILENAME__, "Existing request found, will poll href: %s", href.c_str());
1240 polling_href_ = href;
1241 save();
1243 return;
1244 }
1245
1246 // HTTP 202 with href means new request pending - save href for polling on
1247 // next connect() cycle (after backoff)
1248 if (http_code == 202 && href.length() > 0 && href.startsWith("/")) {
1249 polling_href_ = href;
1250 save();
1252 return;
1253 }
1254
1255 // HTTP 404 means the server has no security enabled — access requests are
1256 // not available. Connect without a token.
1257 if (http_code == 404) {
1258 ESP_LOGI(__FILENAME__,
1259 "Server security disabled (404 on access request) — connecting "
1260 "without token");
1261 auth_token_ = NULL_AUTH_TOKEN;
1262 this->connect_ws(server_address, server_port);
1263 return;
1264 }
1265
1266 // Can't proceed - disconnect and retry later
1267 ESP_LOGW(__FILENAME__, "Cannot handle response: http=%d, state=%s", http_code, state.c_str());
1269}
1270
1271void SKWSClient::poll_access_request(const String server_address,
1272 const uint16_t server_port,
1273 const String href) {
1274 ESP_LOGD(__FILENAME__, "Polling SK Server for authentication token");
1275
1276 String protocol = ssl_enabled_ ? "https://" : "http://";
1277 String url = protocol + server_address + ":" + server_port + href;
1278
1279 esp_http_client_config_t config = {};
1280 config.url = url.c_str();
1281 config.timeout_ms = 10000;
1282#ifdef SENSESP_SSL_SUPPORT
1283 if (ssl_enabled_) {
1284 config.crt_bundle_attach = tofu_crt_bundle_attach;
1285 config.skip_cert_common_name_check = true;
1286 }
1287#endif
1288
1289 esp_http_client_handle_t client = esp_http_client_init(&config);
1290 if (client == nullptr) {
1291 ESP_LOGE(__FILENAME__, "Failed to initialize HTTP client");
1293 return;
1294 }
1295
1296 // Use streaming API for GET request
1297 esp_err_t err = esp_http_client_open(client, 0);
1298 if (err != ESP_OK) {
1299 ESP_LOGE(__FILENAME__, "Failed to open HTTP connection: %s", esp_err_to_name(err));
1300 esp_http_client_cleanup(client);
1302 return;
1303 }
1304
1305 int content_length = esp_http_client_fetch_headers(client);
1306 int http_code = esp_http_client_get_status_code(client);
1307
1308 // Read response body
1309 String payload;
1310 if (content_length > 0 && content_length < 4096) {
1311 char* buffer = new char[content_length + 1];
1312 int read_len = esp_http_client_read(client, buffer, content_length);
1313 buffer[read_len > 0 ? read_len : 0] = '\0';
1314 payload = String(buffer);
1315 delete[] buffer;
1316 } else {
1317 // Chunked encoding or unknown/large content length - read in chunks
1318 char buffer[512];
1319 int read_len;
1320 while ((read_len = esp_http_client_read(client, buffer, sizeof(buffer) - 1)) > 0) {
1321 buffer[read_len] = '\0';
1322 payload += String(buffer);
1323 if (payload.length() > 4096) break;
1324 }
1325 }
1326
1327 // An APPROVED poll response carries the access token in its body, so log only
1328 // the status and size, never the payload itself. The web log buffer exposes
1329 // captured log lines over HTTP, so a payload dump here would leak the token.
1330 ESP_LOGD(__FILENAME__, "Poll response: http=%d, %d bytes", http_code,
1331 static_cast<int>(payload.length()));
1332
1333 esp_http_client_close(client);
1334 esp_http_client_cleanup(client);
1335
1336 if (http_code == 200 || http_code == 202) {
1337 JsonDocument doc;
1338 auto error = deserializeJson(doc, payload.c_str());
1339 if (error) {
1340 ESP_LOGW(__FILENAME__, "WARNING: Could not deserialize http payload.");
1341 ESP_LOGW(__FILENAME__, "DeserializationError: %s", error.c_str());
1343 return;
1344 }
1345 String state = doc["state"];
1346 ESP_LOGD(__FILENAME__, "%s", state.c_str());
1347 if (state == "PENDING") {
1349 return;
1350 }
1351 if (state == "COMPLETED") {
1352 JsonObject access_req = doc["accessRequest"];
1353 String permission = access_req["permission"];
1354
1355 polling_href_ = "";
1356 save();
1357
1358 if (permission == "DENIED") {
1359 ESP_LOGW(__FILENAME__, "Permission denied");
1361 return;
1362 }
1363
1364 if (permission == "APPROVED") {
1365 ESP_LOGI(__FILENAME__, "Permission granted");
1366 String token = access_req["token"];
1367 auth_token_ = token;
1368 save();
1369 this->connect_ws(server_address, server_port);
1370 return;
1371 }
1372 }
1373 } else {
1374 if (http_code == 404 || http_code == 500) {
1375 // Server doesn't recognize this request (stale href after
1376 // server restart, different server, or security disabled).
1377 // Clear the polling href so the next connect cycle starts
1378 // a fresh access-request flow.
1379 ESP_LOGD(__FILENAME__,
1380 "Got %d polling access request — clearing stale href.",
1381 http_code);
1382 polling_href_ = "";
1383 save();
1385 return;
1386 }
1387 // any other HTTP status code
1388 ESP_LOGW(__FILENAME__,
1389 "Can't handle response %d to pending access request.\n",
1390 http_code);
1392 return;
1393 }
1394 // Catch-all: a 200/202 COMPLETED whose permission is neither APPROVED nor
1395 // DENIED, or any unexpected state, leaves no terminal state. With no live
1396 // client, no event will move us off Authorizing, so fall back to Disconnected
1397 // to retry rather than wedge.
1399}
1400
1401void SKWSClient::connect_ws(const String& host, const uint16_t port) {
1402 // connect() reaps any prior client before dispatching here, so client_ is
1403 // null and no teardown is in flight. Guard defensively against a leak.
1404 if (client_.load() != nullptr || teardown_in_progress_.load()) {
1405 ESP_LOGW(__FILENAME__, "connect_ws: prior client not reaped; deferring");
1408 return;
1409 }
1410
1411 // Discard any anchor candidate stashed by the earlier esp_http_client legs
1412 // (token check / access request). Only the WebSocket handshake -- the one
1413 // whose success reaches on_connected and proves the server holds the leaf's
1414 // private key -- may populate the anchor that gets committed.
1417
1418 String protocol = ssl_enabled_ ? "wss" : "ws";
1419 String path = "/signalk/v1/stream?subscribe=none";
1420 if (send_meta_enabled_) path += "&sendMeta=all";
1421 String url = protocol + "://" + host + ":" + String(port) + path;
1422
1423 ESP_LOGD(__FILENAME__, "Connecting WebSocket to %s", url.c_str());
1424
1425 // Build authorization header string (must persist through init call)
1426 String auth_header;
1427 if (auth_token_ != NULL_AUTH_TOKEN) {
1428 auth_header = String("Authorization: Bearer ") + auth_token_ + "\r\n";
1429 }
1430
1431 // Configure WebSocket client
1432 esp_websocket_client_config_t config = {};
1433 config.uri = url.c_str();
1434 config.task_stack = kWsTransportTaskStackSize;
1435 config.buffer_size = SENSESP_SK_WS_BUFFER_SIZE;
1436 if (auth_header.length() > 0) {
1437 config.headers = auth_header.c_str();
1438 }
1439
1440#ifdef SENSESP_SSL_SUPPORT
1441 if (ssl_enabled_) {
1442 // Use custom crt_bundle_attach to disable SSL verification
1443 // This directly configures mbedTLS to skip certificate verification
1444 config.crt_bundle_attach = tofu_crt_bundle_attach;
1445 config.skip_cert_common_name_check = true;
1446 }
1447#endif
1448
1449 esp_websocket_client_handle_t h = esp_websocket_client_init(&config);
1450 if (h == nullptr) {
1451 ESP_LOGE(__FILENAME__, "Failed to initialize WebSocket client");
1453 return;
1454 }
1455 client_.store(h);
1456
1457 // Register the event handler tagged with the current generation, so that any
1458 // late event from this client after it is later handed off for destruction is
1459 // dropped by websocket_event_handler (generation mismatch).
1460 esp_websocket_register_events(
1461 h, WEBSOCKET_EVENT_ANY, websocket_event_handler,
1462 reinterpret_cast<void*>(
1463 static_cast<uintptr_t>(client_generation_.load())));
1464
1465 // Start the client
1466 esp_err_t err = esp_websocket_client_start(h);
1467 if (err != ESP_OK) {
1468 ESP_LOGE(__FILENAME__, "Failed to start WebSocket client: %s",
1469 esp_err_to_name(err));
1470 // Null the shared handle before freeing it so a concurrent send sees null.
1471 client_.store(nullptr);
1472 esp_websocket_client_destroy(h);
1474 return;
1475 }
1476
1477 ESP_LOGD(__FILENAME__, "WebSocket client started, waiting for connection...");
1478}
1479
1483
1484namespace {
1485struct WsTeardownArg {
1486 esp_websocket_client_handle_t handle;
1487 SKWSClient* self;
1488};
1489} // namespace
1490
1492 auto* a = static_cast<WsTeardownArg*>(arg);
1493 // Blocking: esp_websocket_client_stop() waits for the client's task to exit
1494 // (up to one ~1 s poll cycle), destroy() frees its buffers. Run here, on a
1495 // throwaway task, so it never blocks the connect/event-loop context.
1496 if (esp_websocket_client_stop(a->handle) == ESP_FAIL) {
1497 // The client task stopped on its own after a server close frame. The IDF
1498 // 4.4 client reports that as soon as it dispatches CLOSED, before the task
1499 // has closed its transport; let it finish before its resources are freed.
1500 vTaskDelay(kWsSelfStoppedTaskGraceTicks);
1501 }
1502 esp_websocket_client_destroy(a->handle);
1503 a->self->teardown_in_progress_.store(false);
1504 delete a;
1505 vTaskDelete(nullptr);
1506}
1507
1508void SKWSClient::reap_async(esp_websocket_client_handle_t old) {
1509 if (old == nullptr) {
1510 return;
1511 }
1512 auto* arg = new WsTeardownArg{old, this};
1513 if (xTaskCreate(&SKWSClient::teardown_task, "SKWSTeardown", 4096, arg, 1,
1514 nullptr) == pdPASS) {
1515 // The task now owns `old` and clears teardown_in_progress_ when done.
1516 pending_teardown_.store(nullptr);
1517 return;
1518 }
1519 // Spawn failed (OOM). Do NOT reap synchronously: a ~1 s stop()+destroy() on
1520 // the event loop would stall every consumer under the exact heap pressure
1521 // this path exists for. Stash the handle and retry on the next loop tick;
1522 // teardown_in_progress_ stays set so bring-up remains deferred (at most one
1523 // un-reaped client, no leak beyond it).
1524 delete arg;
1525 pending_teardown_.store(old);
1526 ESP_LOGW(__FILENAME__, "teardown task spawn failed; will retry next cycle");
1527}
1528
1530 // Single atomic check-and-null: if two contexts race here, only one gets the
1531 // handle, so it is stopped/destroyed exactly once (no double-free).
1532 esp_websocket_client_handle_t old = client_.exchange(nullptr);
1533 if (old == nullptr) {
1534 return;
1535 }
1536 // Invalidate the old client's generation so any late event it dispatches
1537 // while being reaped is dropped by websocket_event_handler.
1538 client_generation_.fetch_add(1);
1539 teardown_in_progress_.store(true);
1540 reap_async(old);
1541}
1542
1544 // Set state first so event handler callbacks and send callsites see the
1545 // disconnected state and skip operations on the client being destroyed.
1548}
1549
1552 // A server close frame moves the client out of CONNECTED at once, before
1553 // it dispatches CLOSED, and the IDF 4.4 client dispatches no event at all
1554 // when the server's TCP close follows late. Without this check the state
1555 // could stay Connected and never reconnect.
1556 if (!esp_websocket_client_is_connected(client_.load())) {
1557 ESP_LOGW(__FILENAME__, "WebSocket client is no longer connected");
1559 return;
1560 }
1561 if (sk_delta_queue_->data_available()) {
1562 std::vector<String> deltas;
1563 sk_delta_queue_->get_deltas(deltas);
1564 bool first = true;
1565 for (const auto& delta : deltas) {
1566 if (sk_delta_exceeds_ws_buffer(delta.length(),
1568 // Drop the delta to keep the connection alive (signalk_ws_delta_size.h
1569 // explains why an oversize delta would otherwise abort it). Unlike the
1570 // transient send failure below, an oversize delta is deterministic, so
1571 // do NOT re-arm metadata here: get_deltas() already bundles metadata
1572 // into the first delta and marks it sent, and re-arming would have
1573 // get_deltas() rebuild the same oversize first delta every cycle --
1574 // dropped and re-armed forever, never delivered. Leaving it sent lets
1575 // the next, metadata-free first delta fit and flow; metadata waits for
1576 // a reconnect or a larger SENSESP_SK_WS_BUFFER_SIZE.
1577 uint32_t now = millis();
1578 if (last_oversize_log_ms_ == 0 ||
1580 ESP_LOGW(__FILENAME__,
1581 "Delta too large (%u B > %u buffer); dropped to keep the "
1582 "connection alive -- raise SENSESP_SK_WS_BUFFER_SIZE",
1583 (unsigned)delta.length(),
1584 (unsigned)SENSESP_SK_WS_BUFFER_SIZE);
1586 }
1587 first = false;
1588 continue;
1589 }
1590 int send_result = esp_websocket_client_send_text(
1591 client_.load(), delta.c_str(), delta.length(), kWsDeltaSendTimeoutTicks);
1592 if (send_result < 0) {
1593 // Non-blocking send (0 timeout) did not complete: either brief
1594 // ws-client lock contention (retry next cycle) or the transport
1595 // backpressured and esp_websocket_client aborted the connection
1596 // internally -- its disconnect/error event drives reconnect. Never
1597 // block or tear the connection down from here. Deltas are
1598 // supersedable, so drop the rest of this batch. See SignalK/SensESP#1033.
1599 if (first) {
1600 // get_deltas() builds one-shot metadata (units, zones, ...) into the
1601 // first delta and marks it sent before it leaves the device. The
1602 // first delta is the only one that can carry that metadata, so if its
1603 // send is the one that fails, re-arm metadata for the next batch --
1604 // otherwise the server runs without it until the next reconnect.
1605 sk_delta_queue_->reset_meta_send();
1606 }
1607 ESP_LOGW(__FILENAME__,
1608 "Delta send incomplete (result=%d); dropping rest of batch",
1609 send_result);
1610 break;
1611 }
1613 first = false;
1614 }
1615 }
1616 }
1617}
1618
1619bool SKWSClient::to_json(JsonObject& root) {
1620 root["sk_address"] = this->conf_server_address_;
1621 root["sk_port"] = this->conf_server_port_;
1622 root["use_mdns"] = this->use_mdns_;
1623
1624 root["token"] = this->auth_token_;
1625 root["client_id"] = this->client_id_;
1626 root["polling_href"] = this->polling_href_;
1627
1628 root["ssl_enabled"] = this->ssl_enabled_;
1629 root["tofu_enabled"] = this->tofu_enabled_;
1630 // Persisted trust anchor: leaf fingerprint (legacy / leaf mode) and/or CA PEM.
1631 root["tofu_fingerprint"] = this->tofu_fingerprint_;
1632 root["tofu_ca_pem"] = this->tofu_ca_pem_;
1633 root["tofu_san"] = this->tofu_san_;
1634 // Read-only display fields for the pinned identity.
1635 root["tofu_pin_cn"] = this->tofu_pin_cn_;
1636 root["tofu_pin_is_ca"] = this->tofu_pin_is_ca_;
1637 root["send_meta_enabled"] = this->send_meta_enabled_;
1638 return true;
1639}
1640
1641bool SKWSClient::from_json(const JsonObject& config) {
1642 if (config["sk_address"].is<String>()) {
1643 this->conf_server_address_ = config["sk_address"].as<String>();
1644 }
1645 if (config["sk_port"].is<int>()) {
1646 this->conf_server_port_ = config["sk_port"].as<int>();
1647 }
1648 if (config["use_mdns"].is<bool>()) {
1649 this->use_mdns_ = config["use_mdns"].as<bool>();
1650 }
1651 if (config["token"].is<String>()) {
1652 this->auth_token_ = config["token"].as<String>();
1653 }
1654 if (config["client_id"].is<String>()) {
1655 this->client_id_ = config["client_id"].as<String>();
1656 }
1657 if (config["polling_href"].is<String>()) {
1658 String href = config["polling_href"].as<String>();
1659 // Only accept valid hrefs (must start with /)
1660 this->polling_href_ = href.startsWith("/") ? href : "";
1661 }
1662
1663 if (config["ssl_enabled"].is<bool>()) {
1664 this->ssl_enabled_ = config["ssl_enabled"].as<bool>();
1665 }
1666 if (config["tofu_enabled"].is<bool>()) {
1667 this->tofu_enabled_ = config["tofu_enabled"].as<bool>();
1668 }
1669 // A legacy config carries only tofu_fingerprint (loads as leaf mode — the
1670 // migration entry point); newer configs may also carry a pinned CA and the
1671 // display fields. Tolerate both.
1672 if (config["tofu_fingerprint"].is<String>()) {
1673 this->tofu_fingerprint_ = config["tofu_fingerprint"].as<String>();
1674 }
1675 if (config["tofu_ca_pem"].is<String>()) {
1676 this->tofu_ca_pem_ = config["tofu_ca_pem"].as<String>();
1677 }
1678 if (config["tofu_san"].is<String>()) {
1679 this->tofu_san_ = config["tofu_san"].as<String>();
1680 }
1681 if (config["tofu_pin_cn"].is<String>()) {
1682 this->tofu_pin_cn_ = config["tofu_pin_cn"].as<String>();
1683 }
1684 if (config["tofu_pin_is_ca"].is<bool>()) {
1685 this->tofu_pin_is_ca_ = config["tofu_pin_is_ca"].as<bool>();
1686 }
1687 if (config["send_meta_enabled"].is<bool>()) {
1688 this->send_meta_enabled_ = config["send_meta_enabled"].as<bool>();
1689 }
1690
1691 return true;
1692}
1693
1700 auto state = get_connection_state();
1701 switch (state) {
1703 return "Authorizing with SignalK";
1705 return "Connected";
1707 return "Connecting";
1709 return "Disconnected";
1711 return "Certificate verification failed";
1712 }
1713
1714 return "Unknown";
1715}
1716
1717} // namespace sensesp
virtual bool load() override
Load and populate the object from a persistent storage.
Definition saveable.cpp:8
virtual bool save() override
Save the object to a persistent storage.
Definition saveable.cpp:40
virtual void set(const C &input) override final
Definition integrator.h:34
int attach(std::function< void()> observer)
Attach an observer callback.
Definition observable.h:40
An Obervable class that listens for Signal K stream deltas and notifies any observers of value change...
static bool take_semaphore(uint64_t timeout_ms=0)
static void release_semaphore()
virtual void parse_value(const JsonObject &json)
virtual bool matches(const String &path) const
virtual void parse_meta(const std::shared_ptr< const JsonDocument > &meta_doc)
static const std::vector< SKListener * > & get_listeners()
virtual bool wants_meta() const
An Obervable class that listens for Signal K PUT requests coming over the websocket connection and no...
static const std::vector< SKPutListener * > & get_listeners()
virtual void parse_value(const JsonObject &put)=0
static void handle_response(JsonDocument &response)
The websocket connection to the Signal K server.
void commit_pending_tofu()
Persist a stashed anchor after a successful (authenticated) connection. Called from on_connected().
void poll_access_request(const String host, const uint16_t port, const String href)
SKWSClient(const String &config_path, std::shared_ptr< SKDeltaQueue > sk_delta_queue, const String &server_address, uint16_t server_port, bool use_mdns=true)
void on_receive_delta(uint8_t *payload, size_t length)
Called when the websocket receives a delta.
void process_received_updates()
Loop through the received updates and process them.
std::atomic< esp_websocket_client_handle_t > pending_teardown_
std::atomic< bool > auth_job_running_
void connect_ws(const String &host, const uint16_t port)
Integrator< int, int > delta_tx_count_producer_
TaskQueueProducer< SKWSConnectionState > connection_state_
SKWSConnectionState get_connection_state()
std::atomic< bool > teardown_in_progress_
void on_receive_put(JsonDocument &message)
Called when a PUT event is received.
uint32_t client_generation() const
Generation tag of the currently-valid client. The event handler compares the generation it was regist...
void release_received_updates_semaphore()
void run_connect_attempt()
The (blocking) connect attempt — mDNS resolve, SSL detect, and the access-request / poll / connect_ws...
void send_access_request(const String host, const uint16_t port)
std::atomic< uint32_t > client_generation_
void on_error(int handshake_status)
Called when the websocket connection encounters an error.
Integrator< int, int > delta_rx_count_producer_
std::list< ReceivedUpdate > received_updates_
virtual bool to_json(JsonObject &root) override final
void detach_teardown()
Hand client_ to a detached one-shot task that stops+destroys it, so the blocking teardown never runs ...
void enqueue_received_update(ReceivedUpdate &&update)
Push a received delta entry onto the queue, enforcing a per-kind budget so a metadata burst (one meta...
void sendTXT(String &payload)
Send some processed data to the websocket.
uint32_t last_oversize_log_ms_
millis() timestamp of the last oversize-delta-drop warning, used to rate-limit it when a device keeps...
bool get_mdns_service(String &server_address, uint16_t &server_port)
std::atomic< esp_websocket_client_handle_t > client_
void on_disconnected()
Called when the websocket connection is disconnected.
void reap_async(esp_websocket_client_handle_t old)
Spawn the detached reaper for old; on spawn failure (OOM) stash it in pending_teardown_ for a later r...
void subscribe_listeners()
Subscribes the SK delta paths to the websocket.
void on_receive_updates(JsonDocument &message)
Called when a delta update is received.
void test_token(const String host, const uint16_t port)
void set_connection_state(SKWSConnectionState state)
bool take_received_updates_semaphore(unsigned long int timeout_ms=0)
virtual bool from_json(const JsonObject &config) override final
String get_connection_status()
Get a String representation of the current connection state.
static void connect_worker(void *arg)
TaskQueueProducer< int > delta_tx_tick_producer_
Emits the number of deltas sent since last report.
static void teardown_task(void *arg)
Body of the detached teardown task (stop+destroy+self-delete).
std::shared_ptr< SKDeltaQueue > sk_delta_queue_
void restart()
Drop the current connection (detached, non-blocking teardown) and let the reconnect path rebuild it.
void on_connected()
Called when the websocket connection is established.
static std::shared_ptr< SensESPApp > get()
Get the singleton instance of the SensESPApp.
Definition sensesp_app.h:58
static String get_hostname()
Get the current hostname.
virtual void set(const T &value) override
virtual const T & get() const
void emit(const SKWSConnectionState &new_value)
std::enable_if< std::is_base_of< ValueConsumer< typenameVConsumer::input_type >, VConsumer >::value &&std::is_convertible< T, typenameVConsumer::input_type >::value, std::shared_ptr< VConsumer > >::type connect_to(std::shared_ptr< VConsumer > consumer)
Connect a producer to a transform with a different input type.
std::shared_ptr< reactesp::EventLoop > event_loop()
Definition sensesp.cpp:9
String generate_uuid4()
Generate a random UUIDv4 string.
Definition uuid.cpp:5
constexpr int kWsClientTaskStackSize
bool sk_delta_exceeds_ws_buffer(size_t delta_length, size_t buffer_size)
True if a delta is too large to hand to esp_websocket_client as a single tx chunk,...
SKWSClient * ws_client
constexpr TickType_t kWsSendTimeoutTicks
constexpr TickType_t kWsSelfStoppedTaskGraceTicks
constexpr uint32_t kOversizeDropLogIntervalMs
constexpr int kWsTransportTaskStackSize
TofuCaptureDecision tofu_decide_capture(bool has_leaf_anchor, bool leaf_matches_anchor, bool ca_present, bool leaf_has_identity)
TofuCaptureDecision
Capture-mode decision for TOFU certificate pinning.
@ kCaptureLeaf
first use, no usable CA in the chain: pin the leaf
@ kAccept
leaf matches the stored fingerprint; nothing to capture
@ kCaptureCa
first use, usable CA present: pin the CA
@ kReject
leaf does not match the stored fingerprint
bool should_clear_token_on_status(int handshake_status)
Decide whether a failed WebSocket upgrade should clear the auth token.
constexpr TickType_t kWsDeltaSendTimeoutTicks
esp_websocket_client_handle_t handle
SKWSClient * self
#define SENSESP_SK_WS_BUFFER_SIZE
#define SENSESP_MAX_RECEIVED_META_UPDATES
#define SENSESP_MAX_RECEIVED_VALUE_UPDATES
A single received delta entry awaiting dispatch on the main task.