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