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