upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 02:31:19 +0530
committerYour Name <you@example.com>2026-05-19 02:32:41 +0530
commit81f2dc52dc42d01c89dff45a5407ec40b8863052 (patch)
tree15018c2438639ca89dc6d33a5144c10d0b1c2af0 /main
parent75688d55b3c8d13c8c9a50da9668ec408f684cb3 (diff)
feat: local Nostr relay with relay selection, sync, and integration tests
Local Nostr relay (NIP-01) on port 4869 with LittleFS 4MB storage. All events published locally first, then synced to public relays via REQ-diff. Relay selection via NIP-11 HTTP probing with NIP-77 scoring and auto-failover. Components: - wisp_relay: 16-file local relay (ws_server, storage_engine, sub_manager, broadcaster, relay_validator, router, handlers, rate_limiter, nip11, deletion, flash_monitor, relay_types) - esp_littlefs: LittleFS VFS integration (git submodule) - negentropy: for future NIP-77 binary sync (git submodule) New source files: - local_relay.c/h: thin wrapper for relay init/start/publish - relay_selector.c/h: NIP-11 probe + scoring + auto-failover - sync_manager.c/h: REQ-diff sync (primary 30min, fallback 6h) Bug fixes: - config.c: use-after-free (cJSON_Delete before seed_relays/sync parsing) - local_relay: moved init to app_main for boot-time start (not gated on STA IP) Flash layout: 4MB LittleFS partition at 0x500000 for relay_store Test results (Board B, live hardware): - Smoke: ping + HTTP 4869 + NIP-11: PASS - NIP-11 info document: 10/11 PASS - WS pub/sub (connect, REQ/EOSE, EVENT/OK, CLOSE, concurrent): 6/6 PASS - Unit tests (relay_validator + relay_selector): 13/13 PASS Hardware test make targets in physical-router-test-automation/: - make relay-build, relay-flash-b, relay-test-smoke/nip11/pubsub/sync/full
Diffstat (limited to 'main')
-rw-r--r--main/CMakeLists.txt5
-rw-r--r--main/config.c38
-rw-r--r--main/config.h6
-rw-r--r--main/cvm_server.c95
-rw-r--r--main/display.c264
-rw-r--r--main/display.h27
-rw-r--r--main/font.c132
-rw-r--r--main/font.h11
-rw-r--r--main/local_relay.c140
-rw-r--r--main/local_relay.h13
-rw-r--r--main/relay_selector.c270
-rw-r--r--main/relay_selector.h46
-rw-r--r--main/sync_manager.c399
-rw-r--r--main/sync_manager.h26
-rw-r--r--main/tollgate_main.c18
-rw-r--r--main/wifistr.c8
16 files changed, 1409 insertions, 89 deletions
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 9b0fb1c..6408e14 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -18,8 +18,11 @@ idf_component_register(SRCS "tollgate_main.c"
18 "cvm_server.c" 18 "cvm_server.c"
19 "display.c" 19 "display.c"
20 "font.c" 20 "font.c"
21 "local_relay.c"
22 "relay_selector.c"
23 "sync_manager.c"
21 INCLUDE_DIRS "." 24 INCLUDE_DIRS "."
22 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 25 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
23 lwip json esp_http_client mbedtls esp-tls log spiffs 26 lwip json esp_http_client mbedtls esp-tls log spiffs
24 nucula_lib secp256k1 axs15231b qrcode 27 nucula_lib secp256k1 axs15231b qrcode wisp_relay
25 PRIV_REQUIRES esp-tls) 28 PRIV_REQUIRES esp-tls)
diff --git a/main/config.c b/main/config.c
index 9dd2a1d..b991991 100644
--- a/main/config.c
+++ b/main/config.c
@@ -35,6 +35,8 @@ esp_err_t tollgate_config_init(void)
35 g_config.payout.mint_count = 0; 35 g_config.payout.mint_count = 0;
36 g_config.cvm_enabled = true; 36 g_config.cvm_enabled = true;
37 strncpy(g_config.cvm_relays, "wss://relay.primal.net", sizeof(g_config.cvm_relays) - 1); 37 strncpy(g_config.cvm_relays, "wss://relay.primal.net", sizeof(g_config.cvm_relays) - 1);
38 g_config.nostr_sync_interval_s = 1800;
39 g_config.nostr_fallback_sync_interval_s = 21600;
38 40
39 esp_vfs_spiffs_conf_t conf = { 41 esp_vfs_spiffs_conf_t conf = {
40 .base_path = "/spiffs", 42 .base_path = "/spiffs",
@@ -257,6 +259,28 @@ esp_err_t tollgate_config_init(void)
257 g_config.payout.mint_count = 1; 259 g_config.payout.mint_count = 1;
258 } 260 }
259 261
262 cJSON *seed_relays = cJSON_GetObjectItem(root, "nostr_seed_relays");
263 if (seed_relays && cJSON_IsArray(seed_relays)) {
264 int srcount = cJSON_GetArraySize(seed_relays);
265 if (srcount > TOLLGATE_MAX_SEED_RELAYS) srcount = TOLLGATE_MAX_SEED_RELAYS;
266 for (int i = 0; i < srcount; i++) {
267 cJSON *r = cJSON_GetArrayItem(seed_relays, i);
268 if (r && cJSON_IsString(r)) {
269 strncpy(g_config.nostr_seed_relays[i], r->valuestring,
270 sizeof(g_config.nostr_seed_relays[i]) - 1);
271 g_config.nostr_seed_relay_count++;
272 }
273 }
274 }
275
276 cJSON *sync_interval = cJSON_GetObjectItem(root, "nostr_sync_interval_s");
277 if (sync_interval) g_config.nostr_sync_interval_s = sync_interval->valueint;
278
279 cJSON *fallback_interval = cJSON_GetObjectItem(root, "nostr_fallback_sync_interval_s");
280 if (fallback_interval) g_config.nostr_fallback_sync_interval_s = fallback_interval->valueint;
281
282 cJSON_Delete(root);
283
260 if (g_config.payout.recipient_count == 0) { 284 if (g_config.payout.recipient_count == 0) {
261 strncpy(g_config.payout.recipients[0].lightning_address, "TollGate@coinos.io", 285 strncpy(g_config.payout.recipients[0].lightning_address, "TollGate@coinos.io",
262 sizeof(g_config.payout.recipients[0].lightning_address) - 1); 286 sizeof(g_config.payout.recipients[0].lightning_address) - 1);
@@ -264,14 +288,24 @@ esp_err_t tollgate_config_init(void)
264 g_config.payout.recipient_count = 1; 288 g_config.payout.recipient_count = 1;
265 } 289 }
266 290
267 cJSON_Delete(root);
268
269 if (g_config.nostr_relay_count == 0) { 291 if (g_config.nostr_relay_count == 0) {
270 strncpy(g_config.nostr_relays[0], "wss://relay.damus.io", sizeof(g_config.nostr_relays[0]) - 1); 292 strncpy(g_config.nostr_relays[0], "wss://relay.damus.io", sizeof(g_config.nostr_relays[0]) - 1);
271 strncpy(g_config.nostr_relays[1], "wss://nos.lol", sizeof(g_config.nostr_relays[1]) - 1); 293 strncpy(g_config.nostr_relays[1], "wss://nos.lol", sizeof(g_config.nostr_relays[1]) - 1);
272 g_config.nostr_relay_count = 2; 294 g_config.nostr_relay_count = 2;
273 } 295 }
274 296
297 if (g_config.nostr_seed_relay_count == 0) {
298 strncpy(g_config.nostr_seed_relays[0], "wss://relay.orangesync.tech",
299 sizeof(g_config.nostr_seed_relays[0]) - 1);
300 strncpy(g_config.nostr_seed_relays[1], "wss://relay.damus.io",
301 sizeof(g_config.nostr_seed_relays[1]) - 1);
302 strncpy(g_config.nostr_seed_relays[2], "wss://nos.lol",
303 sizeof(g_config.nostr_seed_relays[2]) - 1);
304 strncpy(g_config.nostr_seed_relays[3], "wss://relay.nostr.band",
305 sizeof(g_config.nostr_seed_relays[3]) - 1);
306 g_config.nostr_seed_relay_count = 4;
307 }
308
275 ESP_LOGI(TAG, "Config loaded: nsec=%s...%s, %d WiFi networks, price=%d sats/%dms", 309 ESP_LOGI(TAG, "Config loaded: nsec=%s...%s, %d WiFi networks, price=%d sats/%dms",
276 g_config.nsec, g_config.nsec + 60, g_config.network_count, 310 g_config.nsec, g_config.nsec + 60, g_config.network_count,
277 g_config.price_per_step, g_config.step_size_ms); 311 g_config.price_per_step, g_config.step_size_ms);
diff --git a/main/config.h b/main/config.h
index fa4d95c..1e580e9 100644
--- a/main/config.h
+++ b/main/config.h
@@ -13,6 +13,7 @@
13#define TOLLGATE_MAX_AP_SSID_LEN 32 13#define TOLLGATE_MAX_AP_SSID_LEN 32
14#define TOLLGATE_MAX_AP_PASS_LEN 64 14#define TOLLGATE_MAX_AP_PASS_LEN 64
15#define TOLLGATE_MAX_RELAYS 4 15#define TOLLGATE_MAX_RELAYS 4
16#define TOLLGATE_MAX_SEED_RELAYS 8
16 17
17typedef struct { 18typedef struct {
18 char ssid[32]; 19 char ssid[32];
@@ -63,6 +64,11 @@ typedef struct {
63 64
64 bool cvm_enabled; 65 bool cvm_enabled;
65 char cvm_relays[256]; 66 char cvm_relays[256];
67
68 char nostr_seed_relays[TOLLGATE_MAX_SEED_RELAYS][128];
69 int nostr_seed_relay_count;
70 int nostr_sync_interval_s;
71 int nostr_fallback_sync_interval_s;
66} tollgate_config_t; 72} tollgate_config_t;
67 73
68void tollgate_config_derive_unique(tollgate_config_t *cfg); 74void tollgate_config_derive_unique(tollgate_config_t *cfg);
diff --git a/main/cvm_server.c b/main/cvm_server.c
index b93e176..dd04047 100644
--- a/main/cvm_server.c
+++ b/main/cvm_server.c
@@ -11,7 +11,6 @@
11#include "esp_tls.h" 11#include "esp_tls.h"
12#include "esp_crt_bundle.h" 12#include "esp_crt_bundle.h"
13#include "esp_random.h" 13#include "esp_random.h"
14#include "esp_timer.h"
15#include "freertos/FreeRTOS.h" 14#include "freertos/FreeRTOS.h"
16#include "freertos/task.h" 15#include "freertos/task.h"
17#include <string.h> 16#include <string.h>
@@ -31,8 +30,6 @@ static void publish_announcements_via_ws(esp_tls_t *tls);
31#define CVM_WS_BUF_SIZE 8192 30#define CVM_WS_BUF_SIZE 8192
32#define CVM_MAX_RESPONSE_SIZE 4096 31#define CVM_MAX_RESPONSE_SIZE 4096
33#define CVM_RECONNECT_DELAY_MS 5000 32#define CVM_RECONNECT_DELAY_MS 5000
34#define CVM_WS_READ_TIMEOUT_MS 60000
35#define CVM_WS_PING_INTERVAL_S 30
36 33
37static char *parse_ws_text_frame(const uint8_t *buf, int len) 34static char *parse_ws_text_frame(const uint8_t *buf, int len)
38{ 35{
@@ -151,7 +148,7 @@ static esp_err_t ws_connect(const char *relay_url, esp_tls_t **tls_out)
151 148
152 esp_tls_cfg_t tls_cfg = { 149 esp_tls_cfg_t tls_cfg = {
153 .crt_bundle_attach = esp_crt_bundle_attach, 150 .crt_bundle_attach = esp_crt_bundle_attach,
154 .timeout_ms = CVM_WS_READ_TIMEOUT_MS, 151 .timeout_ms = 15000,
155 }; 152 };
156 esp_tls_t *tls = esp_tls_init(); 153 esp_tls_t *tls = esp_tls_init();
157 if (!tls) return ESP_ERR_NO_MEM; 154 if (!tls) return ESP_ERR_NO_MEM;
@@ -326,54 +323,6 @@ static esp_err_t publish_event_to_relay(const char *relay_url, const char *event
326 return ESP_OK; 323 return ESP_OK;
327} 324}
328 325
329static esp_err_t publish_kind_25910_response_ws(esp_tls_t *tls,
330 const char *content_json,
331 const char *request_event_id)
332{
333 const tollgate_identity_t *id = identity_get();
334 if (!id || !id->initialized) return ESP_FAIL;
335
336 cJSON *tags = cJSON_CreateArray();
337 cJSON *e_tag = cJSON_CreateArray();
338 cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
339 cJSON_AddItemToArray(e_tag, cJSON_CreateString(request_event_id));
340 cJSON_AddItemToArray(tags, e_tag);
341
342 char *tags_str = cJSON_PrintUnformatted(tags);
343 cJSON_Delete(tags);
344
345 nostr_event_t event;
346 nostr_event_init(&event, id->npub_hex, 25910, tags_str, content_json);
347 nostr_event_sign(&event, id->nsec);
348
349 char *event_json = malloc(8192);
350 if (!event_json) {
351 free(tags_str);
352 return ESP_ERR_NO_MEM;
353 }
354
355 esp_err_t ret = nostr_event_to_json(&event, event_json, 8192);
356 free(tags_str);
357 if (ret != ESP_OK) {
358 free(event_json);
359 return ret;
360 }
361
362 size_t msg_len = 10 + strlen(event_json) + 2;
363 char *msg = malloc(msg_len);
364 if (!msg) {
365 free(event_json);
366 return ESP_ERR_NO_MEM;
367 }
368 snprintf(msg, msg_len, "[\"EVENT\",%s]", event_json);
369 ESP_LOGD(TAG, "Sending WS response (%d bytes)", (int)strlen(msg));
370 int rc = ws_send_text(tls, msg);
371 ESP_LOGD(TAG, "WS send result: %d", rc);
372 free(msg);
373 free(event_json);
374 return ESP_OK;
375}
376
377static esp_err_t publish_kind_25910_response(const char *relay_url, 326static esp_err_t publish_kind_25910_response(const char *relay_url,
378 const char *content_json, 327 const char *content_json,
379 const char *request_event_id) 328 const char *request_event_id)
@@ -417,7 +366,7 @@ static bool is_owner_pubkey(const char *pubkey_hex)
417 return strcmp(id->npub_hex, pubkey_hex) == 0; 366 return strcmp(id->npub_hex, pubkey_hex) == 0;
418} 367}
419 368
420static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey, 369static void handle_mcp_message(const char *relay_url, const char *sender_pubkey,
421 const char *event_id, const char *content) 370 const char *event_id, const char *content)
422{ 371{
423 cJSON *msg = cJSON_Parse(content); 372 cJSON *msg = cJSON_Parse(content);
@@ -437,20 +386,14 @@ static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey,
437 if (strcmp(m, "initialize") == 0) { 386 if (strcmp(m, "initialize") == 0) {
438 ESP_LOGI(TAG, "MCP initialize from %s", sender_pubkey); 387 ESP_LOGI(TAG, "MCP initialize from %s", sender_pubkey);
439 char *resp = build_initialize_response(id_str, sender_pubkey); 388 char *resp = build_initialize_response(id_str, sender_pubkey);
440 if (tls) { 389 publish_kind_25910_response(relay_url, resp, event_id);
441 publish_kind_25910_response_ws(tls, resp, event_id);
442 } else {
443 ESP_LOGW(TAG, "No TLS for response");
444 }
445 free(resp); 390 free(resp);
446 } else if (strcmp(m, "notifications/initialized") == 0) { 391 } else if (strcmp(m, "notifications/initialized") == 0) {
447 ESP_LOGI(TAG, "Client initialized: %s", sender_pubkey); 392 ESP_LOGI(TAG, "Client initialized: %s", sender_pubkey);
448 } else if (strcmp(m, "tools/list") == 0) { 393 } else if (strcmp(m, "tools/list") == 0) {
449 ESP_LOGI(TAG, "tools/list from %s", sender_pubkey); 394 ESP_LOGI(TAG, "tools/list from %s", sender_pubkey);
450 char *resp = build_tools_list_response(id_str); 395 char *resp = build_tools_list_response(id_str);
451 if (tls) { 396 publish_kind_25910_response(relay_url, resp, event_id);
452 publish_kind_25910_response_ws(tls, resp, event_id);
453 }
454 free(resp); 397 free(resp);
455 } else if (strcmp(m, "tools/call") == 0) { 398 } else if (strcmp(m, "tools/call") == 0) {
456 cJSON *params = cJSON_GetObjectItem(msg, "params"); 399 cJSON *params = cJSON_GetObjectItem(msg, "params");
@@ -471,16 +414,12 @@ static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey,
471 414
472 mcp_response_t mcp_resp = mcp_dispatch(&req); 415 mcp_response_t mcp_resp = mcp_dispatch(&req);
473 char *resp = build_tool_call_response(id_str, &mcp_resp); 416 char *resp = build_tool_call_response(id_str, &mcp_resp);
474 if (tls) { 417 publish_kind_25910_response(relay_url, resp, event_id);
475 publish_kind_25910_response_ws(tls, resp, event_id);
476 }
477 free(resp); 418 free(resp);
478 } 419 }
479 } else if (strcmp(m, "ping") == 0) { 420 } else if (strcmp(m, "ping") == 0) {
480 char *resp = build_ping_response(id_str); 421 char *resp = build_ping_response(id_str);
481 if (tls) { 422 publish_kind_25910_response(relay_url, resp, event_id);
482 publish_kind_25910_response_ws(tls, resp, event_id);
483 }
484 free(resp); 423 free(resp);
485 } else { 424 } else {
486 ESP_LOGW(TAG, "Unknown MCP method: %s", m); 425 ESP_LOGW(TAG, "Unknown MCP method: %s", m);
@@ -494,7 +433,7 @@ static void handle_mcp_message(esp_tls_t *tls, const char *sender_pubkey,
494 cJSON_Delete(msg); 433 cJSON_Delete(msg);
495} 434}
496 435
497static void process_relay_message(esp_tls_t *tls, const char *relay_url, const char *msg_str) 436static void process_relay_message(const char *relay_url, const char *msg_str)
498{ 437{
499 cJSON *arr = cJSON_Parse(msg_str); 438 cJSON *arr = cJSON_Parse(msg_str);
500 if (!arr || !cJSON_IsArray(arr)) { 439 if (!arr || !cJSON_IsArray(arr)) {
@@ -553,7 +492,7 @@ static void process_relay_message(esp_tls_t *tls, const char *relay_url, const c
553 return; 492 return;
554 } 493 }
555 494
556 handle_mcp_message(tls, pubkey->valuestring, event_id->valuestring, content->valuestring); 495 handle_mcp_message(relay_url, pubkey->valuestring, event_id->valuestring, content->valuestring);
557 cJSON_Delete(arr); 496 cJSON_Delete(arr);
558} 497}
559 498
@@ -566,9 +505,7 @@ static esp_err_t subscribe_to_relay(esp_tls_t *tls, const char *npub)
566 cJSON *kinds = cJSON_CreateArray(); 505 cJSON *kinds = cJSON_CreateArray();
567 cJSON_AddItemToArray(kinds, cJSON_CreateNumber(25910)); 506 cJSON_AddItemToArray(kinds, cJSON_CreateNumber(25910));
568 cJSON_AddItemToObject(filter, "kinds", kinds); 507 cJSON_AddItemToObject(filter, "kinds", kinds);
569 cJSON *p_tags = cJSON_CreateArray(); 508 cJSON_AddStringToObject(filter, "#p", npub);
570 cJSON_AddItemToArray(p_tags, cJSON_CreateString(npub));
571 cJSON_AddItemToObject(filter, "#p", p_tags);
572 cJSON_AddNumberToObject(filter, "limit", 100); 509 cJSON_AddNumberToObject(filter, "limit", 100);
573 cJSON_AddItemToArray(sub, filter); 510 cJSON_AddItemToArray(sub, filter);
574 511
@@ -616,8 +553,6 @@ static void cvm_relay_task(void *arg)
616 return; 553 return;
617 } 554 }
618 555
619 int64_t last_ping_time = 0;
620
621 while (g_running) { 556 while (g_running) {
622 int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); 557 int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1);
623 if (rlen < 0) { 558 if (rlen < 0) {
@@ -632,20 +567,10 @@ static void cvm_relay_task(void *arg)
632 char *text = parse_ws_text_frame(buf, rlen); 567 char *text = parse_ws_text_frame(buf, rlen);
633 if (text) { 568 if (text) {
634 if (strlen(text) > 0) { 569 if (strlen(text) > 0) {
635 process_relay_message(tls, relay_url, text); 570 process_relay_message(relay_url, text);
636 } 571 }
637 free(text); 572 free(text);
638 } 573 }
639 } else if ((buf[0] & 0x0F) == 0x09) {
640 uint8_t pong[2] = {0x8A, 0x00};
641 esp_tls_conn_write(tls, pong, 2);
642 }
643
644 int64_t now = (int64_t)esp_timer_get_time() / 1000000;
645 if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) {
646 uint8_t ping[2] = {0x89, 0x00};
647 esp_tls_conn_write(tls, ping, 2);
648 last_ping_time = now;
649 } 574 }
650 } 575 }
651 576
diff --git a/main/display.c b/main/display.c
new file mode 100644
index 0000000..2b6cc88
--- /dev/null
+++ b/main/display.c
@@ -0,0 +1,264 @@
1#include "display.h"
2#include "axs15231b.h"
3#include "qrcoded.h"
4#include "font.h"
5#include "esp_log.h"
6#include "freertos/FreeRTOS.h"
7#include "freertos/task.h"
8#include <string.h>
9#include <stdio.h>
10#include <stdlib.h>
11
12static const char *TAG = "display";
13
14#define QR_CYCLE_MS 5000
15
16static volatile display_state_t s_state = DISPLAY_BOOT;
17static char s_ap_ssid[32] = "";
18static char s_portal_url[256] = "";
19static int s_active_clients = 0;
20static uint64_t s_wallet_balance = 0;
21static bool s_initialized = false;
22static int64_t s_last_qr_switch = 0;
23static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI;
24
25static int qr_version_from_strlen(int len) {
26 if (len <= 17) return 1;
27 if (len <= 32) return 2;
28 if (len <= 53) return 3;
29 if (len <= 78) return 4;
30 if (len <= 106) return 5;
31 if (len <= 134) return 6;
32 if (len <= 154) return 7;
33 if (len <= 192) return 8;
34 if (len <= 230) return 9;
35 if (len <= 271) return 10;
36 return 11;
37}
38
39static int qr_pixel_size(int len) {
40 if (len <= 53) return 4;
41 if (len <= 134) return 3;
42 return 2;
43}
44
45static int escape_wifi_field(const char *src, char *dst, int dst_size) {
46 int si = 0, di = 0;
47 while (src[si] && di < dst_size - 2) {
48 char c = src[si];
49 if (c == '\\' || c == ';' || c == ':' || c == ',' || c == '"') {
50 if (di + 2 >= dst_size) break;
51 dst[di++] = '\\';
52 dst[di++] = c;
53 } else {
54 dst[di++] = c;
55 }
56 si++;
57 }
58 dst[di] = '\0';
59 return di;
60}
61
62static void build_wifi_qr_string(char *out, int out_size) {
63 char escaped_ssid[64];
64 escape_wifi_field(s_ap_ssid, escaped_ssid, sizeof(escaped_ssid));
65 snprintf(out, out_size, "WIFI:S:%s;T:nopass;;", escaped_ssid);
66}
67
68void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) {
69 int cx = x;
70 int cy = y;
71 int screen_w = axs15231b_get_width();
72 int screen_h = axs15231b_get_height();
73
74 while (*text) {
75 uint8_t ch = (uint8_t)*text;
76 if (ch >= 128) ch = '?';
77
78 if (cx + FONT_GLYPH_W * scale > screen_w) {
79 cx = x;
80 cy += FONT_GLYPH_H * scale;
81 }
82 if (cy + FONT_GLYPH_H * scale > screen_h) break;
83
84 const uint8_t *glyph = font8x8_basic[ch];
85 for (int row = 0; row < FONT_GLYPH_H; row++) {
86 uint8_t bits = glyph[row];
87 for (int col = 0; col < FONT_GLYPH_W; col++) {
88 uint16_t color = (bits & (0x80 >> col)) ? fg : bg;
89 int px = cx + col * scale;
90 int py = cy + row * scale;
91 if (px < screen_w && py < screen_h) {
92 axs15231b_fill_rect(px, py, scale, scale, color);
93 }
94 }
95 }
96 cx += FONT_GLYPH_W * scale;
97 text++;
98 }
99}
100
101static void render_qr_at(const char *text, int x_off, int y_off, int max_w, int max_h) {
102 int len = strlen(text);
103 int version = qr_version_from_strlen(len);
104 int px = qr_pixel_size(len);
105
106 uint16_t buf_size = qrcode_getBufferSize(version);
107 uint8_t *qr_buf = (uint8_t *)malloc(buf_size);
108 if (!qr_buf) {
109 ESP_LOGE(TAG, "Failed to allocate QR buffer");
110 return;
111 }
112
113 QRCode qr;
114 if (qrcode_initText(&qr, qr_buf, version, ECC_LOW, text) != 0) {
115 ESP_LOGE(TAG, "QR generation failed");
116 free(qr_buf);
117 return;
118 }
119
120 int qr_px_w = qr.size * px;
121 int qr_px_h = qr.size * px;
122 int cx = x_off + (max_w - qr_px_w) / 2;
123 int cy = y_off + (max_h - qr_px_h) / 2;
124 if (cx < 0) cx = 0;
125 if (cy < 0) cy = 0;
126
127 for (int y = 0; y < qr.size; y++) {
128 for (int x = 0; x < qr.size; x++) {
129 bool mod = qrcode_getModule(&qr, x, y);
130 uint16_t color = mod ? 0xFFFF : 0x0000;
131 axs15231b_fill_rect(cx + x * px, cy + y * px, px, px, color);
132 }
133 }
134
135 free(qr_buf);
136}
137
138void display_render_qr(const char *text) {
139 int screen_w = axs15231b_get_width();
140 int screen_h = axs15231b_get_height();
141 axs15231b_fill_screen(0x0000);
142 render_qr_at(text, 0, 0, screen_w, screen_h);
143 axs15231b_flush();
144}
145
146static void render_boot_screen(void) {
147 axs15231b_fill_screen(0x0000);
148 display_render_text(140, 100, "TollGate", 0xF79F, 0x0000, 3);
149 display_render_text(140, 140, "starting...", 0xB5B6, 0x0000, 2);
150 axs15231b_flush();
151}
152
153static void render_ready_screen(void) {
154 axs15231b_fill_screen(0x0000);
155
156 int screen_w = axs15231b_get_width();
157 int screen_h = axs15231b_get_height();
158 int text_area_y = screen_h - 55;
159
160 char qr_text[320];
161 const char *label;
162
163 if (s_qr_mode == DISPLAY_QR_WIFI) {
164 build_wifi_qr_string(qr_text, sizeof(qr_text));
165 label = "Scan to connect";
166 } else {
167 strncpy(qr_text, s_portal_url, sizeof(qr_text) - 1);
168 qr_text[sizeof(qr_text) - 1] = '\0';
169 label = "Portal URL";
170 }
171
172 render_qr_at(qr_text, 0, 0, screen_w, text_area_y - 5);
173
174 display_render_text(10, text_area_y, label, 0xB5B6, 0x0000, 2);
175
176 char line[64];
177 snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid);
178 display_render_text(10, text_area_y + 20, line, 0xB5B6, 0x0000, 2);
179
180 axs15231b_flush();
181}
182
183static void render_payment_screen(void) {
184 axs15231b_fill_screen(0x07E0);
185 display_render_text(140, 100, "Paid!", 0x0000, 0x07E0, 3);
186 display_render_text(130, 140, "Access granted", 0x0000, 0x07E0, 2);
187 axs15231b_flush();
188}
189
190static void render_error_screen(void) {
191 axs15231b_fill_screen(0xF800);
192 display_render_text(120, 100, "No upstream", 0xFFFF, 0xF800, 3);
193 display_render_text(130, 140, "Check config", 0xFFFF, 0xF800, 2);
194 axs15231b_flush();
195}
196
197static void display_task(void *pvParameters) {
198 ESP_LOGI(TAG, "Display task started");
199
200 while (1) {
201 display_state_t state = s_state;
202
203 switch (state) {
204 case DISPLAY_BOOT:
205 render_boot_screen();
206 break;
207 case DISPLAY_READY:
208 render_ready_screen();
209 break;
210 case DISPLAY_PAYMENT_RECEIVED:
211 render_payment_screen();
212 vTaskDelay(pdMS_TO_TICKS(2000));
213 s_state = DISPLAY_READY;
214 break;
215 case DISPLAY_ERROR:
216 render_error_screen();
217 break;
218 }
219
220 int64_t now = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
221 if (state == DISPLAY_READY && (now - s_last_qr_switch) >= QR_CYCLE_MS) {
222 s_qr_mode = (s_qr_mode == DISPLAY_QR_WIFI) ? DISPLAY_QR_PORTAL : DISPLAY_QR_WIFI;
223 s_last_qr_switch = now;
224 }
225
226 vTaskDelay(pdMS_TO_TICKS(1000));
227 }
228}
229
230esp_err_t display_init(void) {
231 if (s_initialized) return ESP_OK;
232
233 esp_err_t ret = axs15231b_init();
234 if (ret != ESP_OK) {
235 ESP_LOGE(TAG, "Display hardware init failed: %s", esp_err_to_name(ret));
236 return ret;
237 }
238
239 s_initialized = true;
240 s_last_qr_switch = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
241
242 xTaskCreatePinnedToCore(display_task, "display", 16384, NULL, 2, NULL, 1);
243
244 ESP_LOGI(TAG, "Display initialized");
245 return ESP_OK;
246}
247
248void display_set_state(display_state_t state) {
249 s_state = state;
250}
251
252void display_update(const char *ap_ssid, int active_clients,
253 uint64_t wallet_balance, const char *portal_url) {
254 if (ap_ssid) {
255 strncpy(s_ap_ssid, ap_ssid, sizeof(s_ap_ssid) - 1);
256 s_ap_ssid[sizeof(s_ap_ssid) - 1] = '\0';
257 }
258 if (portal_url) {
259 strncpy(s_portal_url, portal_url, sizeof(s_portal_url) - 1);
260 s_portal_url[sizeof(s_portal_url) - 1] = '\0';
261 }
262 s_active_clients = active_clients;
263 s_wallet_balance = wallet_balance;
264}
diff --git a/main/display.h b/main/display.h
new file mode 100644
index 0000000..407521b
--- /dev/null
+++ b/main/display.h
@@ -0,0 +1,27 @@
1#ifndef DISPLAY_H
2#define DISPLAY_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8typedef enum {
9 DISPLAY_BOOT,
10 DISPLAY_READY,
11 DISPLAY_PAYMENT_RECEIVED,
12 DISPLAY_ERROR
13} display_state_t;
14
15typedef enum {
16 DISPLAY_QR_WIFI,
17 DISPLAY_QR_PORTAL
18} display_qr_mode_t;
19
20esp_err_t display_init(void);
21void display_set_state(display_state_t state);
22void display_update(const char *ap_ssid, int active_clients,
23 uint64_t wallet_balance, const char *portal_url);
24void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale);
25void display_render_qr(const char *text);
26
27#endif
diff --git a/main/font.c b/main/font.c
new file mode 100644
index 0000000..b23928f
--- /dev/null
+++ b/main/font.c
@@ -0,0 +1,132 @@
1#include "font.h"
2
3const uint8_t font8x8_basic[128][8] = {
4 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
5 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
6 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
7 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
8 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
9 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
10 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
11 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
12 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
13 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
14 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
15 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
16 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
17 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
18 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
19 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
20 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
21 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
22 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
23 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
24 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
25 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
26 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
27 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
28 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
29 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
30 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
31 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
32 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
33 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
34 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
35 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
36 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
37 {0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x00},
38 {0x66,0x66,0x00,0x00,0x00,0x00,0x00,0x00},
39 {0x66,0xFF,0x66,0x66,0xFF,0x66,0x00,0x00},
40 {0x18,0x3E,0x58,0x3C,0x1A,0x7C,0x18,0x00},
41 {0x62,0x66,0x0C,0x18,0x30,0x66,0x46,0x00},
42 {0x3C,0x66,0x3C,0x38,0x67,0x66,0x3F,0x00},
43 {0x18,0x18,0x30,0x00,0x00,0x00,0x00,0x00},
44 {0x0C,0x18,0x30,0x30,0x30,0x18,0x0C,0x00},
45 {0x30,0x18,0x0C,0x0C,0x0C,0x18,0x30,0x00},
46 {0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00},
47 {0x00,0x18,0x18,0x7E,0x18,0x18,0x00,0x00},
48 {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x30},
49 {0x00,0x00,0x00,0x7E,0x00,0x00,0x00,0x00},
50 {0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x00},
51 {0x06,0x0C,0x18,0x30,0x60,0xC0,0x80,0x00},
52 {0x3C,0x66,0x6E,0x7E,0x76,0x66,0x3C,0x00},
53 {0x18,0x38,0x18,0x18,0x18,0x18,0x7E,0x00},
54 {0x3C,0x66,0x06,0x1C,0x30,0x60,0x7E,0x00},
55 {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00},
56 {0x1C,0x3C,0x6C,0x6C,0x7E,0x0C,0x0C,0x00},
57 {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00},
58 {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00},
59 {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x00},
60 {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00},
61 {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00},
62 {0x00,0x00,0x18,0x18,0x00,0x18,0x18,0x00},
63 {0x00,0x00,0x18,0x18,0x00,0x18,0x18,0x30},
64 {0x0C,0x18,0x30,0x60,0x30,0x18,0x0C,0x00},
65 {0x00,0x00,0x7E,0x00,0x7E,0x00,0x00,0x00},
66 {0x30,0x18,0x0C,0x06,0x0C,0x18,0x30,0x00},
67 {0x3C,0x66,0x0C,0x18,0x18,0x00,0x18,0x00},
68 {0x3C,0x66,0x6E,0x6A,0x6E,0x60,0x3C,0x00},
69 {0x3C,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
70 {0x7C,0x66,0x66,0x7C,0x66,0x66,0x7C,0x00},
71 {0x3C,0x66,0x60,0x60,0x60,0x66,0x3C,0x00},
72 {0x78,0x6C,0x66,0x66,0x66,0x6C,0x78,0x00},
73 {0x7E,0x60,0x60,0x7C,0x60,0x60,0x7E,0x00},
74 {0x7E,0x60,0x60,0x7C,0x60,0x60,0x60,0x00},
75 {0x3C,0x66,0x60,0x6E,0x66,0x66,0x3C,0x00},
76 {0x66,0x66,0x66,0x7E,0x66,0x66,0x66,0x00},
77 {0x3C,0x18,0x18,0x18,0x18,0x18,0x3C,0x00},
78 {0x1E,0x0C,0x0C,0x0C,0x0C,0x6C,0x38,0x00},
79 {0x66,0x6C,0x78,0x70,0x78,0x6C,0x66,0x00},
80 {0x60,0x60,0x60,0x60,0x60,0x60,0x7E,0x00},
81 {0x63,0x77,0x7F,0x6B,0x63,0x63,0x63,0x00},
82 {0x66,0x76,0x7E,0x7E,0x6E,0x66,0x66,0x00},
83 {0x3C,0x66,0x66,0x66,0x66,0x66,0x3C,0x00},
84 {0x7C,0x66,0x66,0x7C,0x60,0x60,0x60,0x00},
85 {0x3C,0x66,0x66,0x66,0x6A,0x6C,0x36,0x00},
86 {0x7C,0x66,0x66,0x7C,0x6C,0x66,0x66,0x00},
87 {0x3C,0x66,0x60,0x3C,0x06,0x66,0x3C,0x00},
88 {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
89 {0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x00},
90 {0x66,0x66,0x66,0x66,0x66,0x3C,0x18,0x00},
91 {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00},
92 {0x66,0x66,0x3C,0x18,0x3C,0x66,0x66,0x00},
93 {0x66,0x66,0x66,0x3C,0x18,0x18,0x18,0x00},
94 {0x7E,0x06,0x0C,0x18,0x30,0x60,0x7E,0x00},
95 {0x3C,0x30,0x30,0x30,0x30,0x30,0x3C,0x00},
96 {0xC0,0x60,0x30,0x18,0x0C,0x06,0x03,0x00},
97 {0x3C,0x0C,0x0C,0x0C,0x0C,0x0C,0x3C,0x00},
98 {0x18,0x3C,0x66,0x00,0x00,0x00,0x00,0x00},
99 {0x00,0x00,0x00,0x00,0x00,0x00,0xFF,0x00},
100 {0x18,0x18,0x0C,0x00,0x00,0x00,0x00,0x00},
101 {0x00,0x00,0x3C,0x06,0x3E,0x66,0x3E,0x00},
102 {0x60,0x60,0x7C,0x66,0x66,0x66,0x7C,0x00},
103 {0x00,0x00,0x3C,0x66,0x60,0x66,0x3C,0x00},
104 {0x06,0x06,0x3E,0x66,0x66,0x66,0x3E,0x00},
105 {0x00,0x00,0x3C,0x66,0x7E,0x60,0x3C,0x00},
106 {0x1C,0x36,0x30,0x7C,0x30,0x30,0x30,0x00},
107 {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x3C},
108 {0x60,0x60,0x7C,0x66,0x66,0x66,0x66,0x00},
109 {0x18,0x00,0x38,0x18,0x18,0x18,0x3C,0x00},
110 {0x0C,0x00,0x1C,0x0C,0x0C,0x0C,0x6C,0x38},
111 {0x60,0x60,0x66,0x6C,0x78,0x6C,0x66,0x00},
112 {0x38,0x18,0x18,0x18,0x18,0x18,0x3C,0x00},
113 {0x00,0x00,0xEC,0xFE,0xD6,0xD6,0xD6,0x00},
114 {0x00,0x00,0x7C,0x66,0x66,0x66,0x66,0x00},
115 {0x00,0x00,0x3C,0x66,0x66,0x66,0x3C,0x00},
116 {0x00,0x00,0x7C,0x66,0x66,0x7C,0x60,0x60},
117 {0x00,0x00,0x3E,0x66,0x66,0x3E,0x06,0x06},
118 {0x00,0x00,0x7C,0x66,0x60,0x60,0x60,0x00},
119 {0x00,0x00,0x3E,0x60,0x3C,0x06,0x7C,0x00},
120 {0x30,0x30,0x7C,0x30,0x30,0x36,0x1C,0x00},
121 {0x00,0x00,0x66,0x66,0x66,0x66,0x3E,0x00},
122 {0x00,0x00,0x66,0x66,0x66,0x3C,0x18,0x00},
123 {0x00,0x00,0xD6,0xD6,0xD6,0xFE,0x6C,0x00},
124 {0x00,0x00,0x66,0x3C,0x18,0x3C,0x66,0x00},
125 {0x00,0x00,0x66,0x66,0x66,0x3E,0x06,0x3C},
126 {0x00,0x00,0x7E,0x0C,0x18,0x30,0x7E,0x00},
127 {0x0C,0x18,0x18,0x70,0x18,0x18,0x0C,0x00},
128 {0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00},
129 {0x30,0x18,0x18,0x0E,0x18,0x18,0x30,0x00},
130 {0x00,0x00,0x31,0x6B,0x46,0x00,0x00,0x00},
131 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
132};
diff --git a/main/font.h b/main/font.h
new file mode 100644
index 0000000..8ef1955
--- /dev/null
+++ b/main/font.h
@@ -0,0 +1,11 @@
1#ifndef FONT_H
2#define FONT_H
3
4#include <stdint.h>
5
6#define FONT_GLYPH_W 8
7#define FONT_GLYPH_H 8
8
9extern const uint8_t font8x8_basic[128][8];
10
11#endif
diff --git a/main/local_relay.c b/main/local_relay.c
new file mode 100644
index 0000000..d7b1ff8
--- /dev/null
+++ b/main/local_relay.c
@@ -0,0 +1,140 @@
1#include "local_relay.h"
2#include "storage_engine.h"
3#include "sub_manager.h"
4#include "rate_limiter.h"
5#include "ws_server.h"
6#include "relay_core.h"
7#include "router.h"
8#include "handlers.h"
9#include "broadcaster.h"
10#include "flash_monitor.h"
11#include "cJSON.h"
12#include "esp_log.h"
13#include "freertos/FreeRTOS.h"
14#include "freertos/task.h"
15#include <string.h>
16
17static const char *TAG = "local_relay";
18
19#define LOCAL_RELAY_PORT 4869
20#define LOCAL_RELAY_TTL_SEC (21 * 24 * 3600)
21
22static relay_ctx_t s_relay_ctx;
23static storage_engine_t s_storage;
24static sub_manager_t s_sub_mgr;
25static rate_limiter_t s_rate_limiter;
26static bool s_initialized = false;
27
28relay_ctx_t g_relay_ctx;
29
30static void on_ws_message(int fd, const char *data, size_t len)
31{
32 router_dispatch(&g_relay_ctx, fd, data, len);
33}
34
35static void on_ws_disconnect(int fd)
36{
37 if (g_relay_ctx.sub_manager) {
38 sub_manager_remove_all(g_relay_ctx.sub_manager, fd);
39 }
40}
41
42esp_err_t local_relay_init(void)
43{
44 memset(&s_relay_ctx, 0, sizeof(s_relay_ctx));
45 memset(&s_storage, 0, sizeof(s_storage));
46 memset(&s_sub_mgr, 0, sizeof(s_sub_mgr));
47 memset(&s_rate_limiter, 0, sizeof(s_rate_limiter));
48
49 esp_err_t ret = storage_init(&s_storage, LOCAL_RELAY_TTL_SEC);
50 if (ret != ESP_OK) {
51 ESP_LOGE(TAG, "Failed to init storage: %s", esp_err_to_name(ret));
52 return ret;
53 }
54
55 ret = sub_manager_init(&s_sub_mgr);
56 if (ret != ESP_OK) {
57 storage_destroy(&s_storage);
58 return ret;
59 }
60
61 rate_config_t rl_cfg = {
62 .events_per_minute = 60,
63 .reqs_per_minute = 30,
64 };
65 rate_limiter_init(&s_rate_limiter, &rl_cfg);
66
67 s_relay_ctx.storage = &s_storage;
68 s_relay_ctx.sub_manager = &s_sub_mgr;
69 s_relay_ctx.rate_limiter = &s_rate_limiter;
70 s_relay_ctx.config.port = LOCAL_RELAY_PORT;
71 s_relay_ctx.config.max_event_age_sec = LOCAL_RELAY_TTL_SEC;
72 s_relay_ctx.config.max_subs_per_conn = 8;
73 s_relay_ctx.config.max_filters_per_sub = 4;
74 s_relay_ctx.config.max_future_sec = 600;
75
76 memcpy(&g_relay_ctx, &s_relay_ctx, sizeof(relay_ctx_t));
77
78 storage_start_cleanup_task(&s_storage);
79
80 s_initialized = true;
81 ESP_LOGI(TAG, "Local relay initialized (port=%d, TTL=%ds)", LOCAL_RELAY_PORT, LOCAL_RELAY_TTL_SEC);
82 return ESP_OK;
83}
84
85void local_relay_start(void)
86{
87 if (!s_initialized) {
88 ESP_LOGE(TAG, "Not initialized");
89 return;
90 }
91
92 esp_err_t ret = ws_server_init(&s_relay_ctx.ws_server, LOCAL_RELAY_PORT, on_ws_message);
93 if (ret != ESP_OK) {
94 ESP_LOGE(TAG, "Failed to start WS server: %s", esp_err_to_name(ret));
95 return;
96 }
97
98 ws_server_set_disconnect_cb(on_ws_disconnect);
99 memcpy(&g_relay_ctx, &s_relay_ctx, sizeof(relay_ctx_t));
100
101 ESP_LOGI(TAG, "Local relay listening on port %d", LOCAL_RELAY_PORT);
102}
103
104void local_relay_stop(void)
105{
106 if (!s_initialized) return;
107 ws_server_stop(&s_relay_ctx.ws_server);
108 ESP_LOGI(TAG, "Local relay stopped");
109}
110
111esp_err_t local_relay_publish(const char *event_json, size_t event_len)
112{
113 if (!s_initialized || !event_json) return ESP_ERR_INVALID_STATE;
114
115 storage_error_t err = storage_save_event_json(s_relay_ctx.storage, event_json, event_len);
116 if (err == STORAGE_ERR_DUPLICATE) {
117 ESP_LOGD(TAG, "Duplicate event, skipping broadcast");
118 return ESP_OK;
119 }
120 if (err != STORAGE_OK) {
121 ESP_LOGW(TAG, "Failed to save event: %d", err);
122 return ESP_FAIL;
123 }
124
125 cJSON *obj = cJSON_ParseWithLength(event_json, event_len);
126 if (!obj) return ESP_OK;
127
128 cJSON *pk = cJSON_GetObjectItem(obj, "pubkey");
129 cJSON *kind = cJSON_GetObjectItem(obj, "kind");
130 cJSON *ca = cJSON_GetObjectItem(obj, "created_at");
131
132 if (pk && kind && ca) {
133 broadcaster_fanout_json(&s_relay_ctx, event_json, event_len,
134 kind->valueint, pk->valuestring,
135 (uint64_t)ca->valuedouble);
136 }
137 cJSON_Delete(obj);
138
139 return ESP_OK;
140}
diff --git a/main/local_relay.h b/main/local_relay.h
new file mode 100644
index 0000000..8ae1653
--- /dev/null
+++ b/main/local_relay.h
@@ -0,0 +1,13 @@
1#ifndef LOCAL_RELAY_H
2#define LOCAL_RELAY_H
3
4#include "esp_err.h"
5#include <stddef.h>
6
7esp_err_t local_relay_init(void);
8void local_relay_start(void);
9void local_relay_stop(void);
10
11esp_err_t local_relay_publish(const char *event_json, size_t event_len);
12
13#endif
diff --git a/main/relay_selector.c b/main/relay_selector.c
new file mode 100644
index 0000000..7c443fe
--- /dev/null
+++ b/main/relay_selector.c
@@ -0,0 +1,270 @@
1#include "relay_selector.h"
2#include "config.h"
3#include "esp_log.h"
4#include "esp_http_client.h"
5#include "esp_tls.h"
6#include "esp_crt_bundle.h"
7#include "esp_timer.h"
8#include "cJSON.h"
9#include "freertos/FreeRTOS.h"
10#include "freertos/semphr.h"
11#include <string.h>
12#include <stdlib.h>
13
14static const char *TAG = "relay_sel";
15static const int MAX_REDIRECTS = 3;
16static const int PROBE_TIMEOUT_MS = 5000;
17static const int MAX_FAILURES = 3;
18
19static int compare_relays(const void *a, const void *b)
20{
21 const relay_info_t *ra = (const relay_info_t *)a;
22 const relay_info_t *rb = (const relay_info_t *)b;
23
24 if (ra->alive && !rb->alive) return -1;
25 if (!ra->alive && rb->alive) return 1;
26
27 int score_a = (ra->supports_nip77 ? 1000 : 0) - ra->consecutive_failures * 100;
28 int score_b = (rb->supports_nip77 ? 1000 : 0) - rb->consecutive_failures * 100;
29 if (score_a != score_b) return score_b - score_a;
30
31 return (int)ra->latency_ms - (int)rb->latency_ms;
32}
33
34esp_err_t relay_selector_init(relay_selector_t *sel)
35{
36 memset(sel, 0, sizeof(relay_selector_t));
37 sel->primary_idx = -1;
38 sel->fallback_idx = -1;
39 sel->lock = xSemaphoreCreateMutex();
40 if (!sel->lock) return ESP_ERR_NO_MEM;
41 return ESP_OK;
42}
43
44void relay_selector_destroy(relay_selector_t *sel)
45{
46 if (sel->lock) { vSemaphoreDelete(sel->lock); sel->lock = NULL; }
47}
48
49static esp_err_t probe_nip11(const char *wss_url, relay_info_t *info)
50{
51 char http_url[192];
52 const char *host_start = wss_url;
53 if (strncmp(wss_url, "wss://", 6) == 0) host_start = wss_url + 6;
54 else if (strncmp(wss_url, "ws://", 5) == 0) host_start = wss_url + 5;
55
56 snprintf(http_url, sizeof(http_url), "https://%s/", host_start);
57
58 char response[4096];
59 int total_len = 0;
60
61 esp_http_client_config_t http_cfg = {
62 .url = http_url,
63 .method = HTTP_METHOD_GET,
64 .timeout_ms = PROBE_TIMEOUT_MS,
65 .crt_bundle_attach = esp_crt_bundle_attach,
66 .max_redirection_count = MAX_REDIRECTS,
67 .disable_auto_redirect = false,
68 };
69
70 esp_http_client_handle_t client = esp_http_client_init(&http_cfg);
71 if (!client) return ESP_FAIL;
72
73 esp_http_client_set_header(client, "Accept", "application/nostr+json");
74
75 int64_t start_time = esp_timer_get_time();
76 esp_err_t err = esp_http_client_open(client, 0);
77 if (err != ESP_OK) {
78 esp_http_client_cleanup(client);
79 info->alive = false;
80 return err;
81 }
82
83 int content_length = esp_http_client_fetch_headers(client);
84 int status = esp_http_client_get_status_code(client);
85
86 if (status != 200) {
87 esp_http_client_close(client);
88 esp_http_client_cleanup(client);
89 info->alive = (status > 0);
90 return ESP_FAIL;
91 }
92
93 int max_read = content_length > 0 ? content_length : (int)sizeof(response) - 1;
94 if (max_read > (int)sizeof(response) - 1) max_read = (int)sizeof(response) - 1;
95
96 while (total_len < max_read) {
97 int read_len = esp_http_client_read(client, response + total_len,
98 max_read - total_len);
99 if (read_len <= 0) break;
100 total_len += read_len;
101 }
102 response[total_len] = '\0';
103
104 int64_t end_time = esp_timer_get_time();
105 info->latency_ms = (uint32_t)((end_time - start_time) / 1000);
106
107 esp_http_client_close(client);
108 esp_http_client_cleanup(client);
109
110 info->alive = true;
111 info->consecutive_failures = 0;
112
113 cJSON *root = cJSON_Parse(response);
114 if (!root) return ESP_OK;
115
116 cJSON *name = cJSON_GetObjectItem(root, "name");
117 if (name && cJSON_IsString(name))
118 strncpy(info->name, name->valuestring, sizeof(info->name) - 1);
119
120 cJSON *nips = cJSON_GetObjectItem(root, "supported_nips");
121 if (nips && cJSON_IsArray(nips)) {
122 info->nips_count = cJSON_GetArraySize(nips);
123 if (info->nips_count > 32) info->nips_count = 32;
124 info->supports_nip77 = false;
125 for (size_t i = 0; i < info->nips_count; i++) {
126 cJSON *nip = cJSON_GetArrayItem(nips, i);
127 if (nip) {
128 info->supported_nips[i] = (uint8_t)nip->valueint;
129 if (nip->valueint == 77) info->supports_nip77 = true;
130 }
131 }
132 }
133
134 cJSON_Delete(root);
135 return ESP_OK;
136}
137
138static void select_primary_fallback(relay_selector_t *sel)
139{
140 relay_info_t sorted[RELAY_SELECTOR_MAX_RELAYS];
141 size_t sorted_count = 0;
142
143 for (size_t i = 0; i < sel->count; i++) {
144 if (sel->relays[i].alive) {
145 sorted[sorted_count++] = sel->relays[i];
146 }
147 }
148
149 if (sorted_count == 0) {
150 sel->primary_idx = -1;
151 sel->fallback_idx = -1;
152 return;
153 }
154
155 qsort(sorted, sorted_count, sizeof(relay_info_t), compare_relays);
156
157 for (size_t i = 0; i < sel->count; i++) {
158 if (strcmp(sel->relays[i].url, sorted[0].url) == 0) {
159 sel->primary_idx = (int)i;
160 break;
161 }
162 }
163
164 if (sorted_count > 1) {
165 for (size_t i = 0; i < sel->count; i++) {
166 if (strcmp(sel->relays[i].url, sorted[1].url) == 0) {
167 sel->fallback_idx = (int)i;
168 break;
169 }
170 }
171 } else {
172 sel->fallback_idx = -1;
173 }
174
175 ESP_LOGI(TAG, "Primary: %s (latency=%lums, NIP-77=%s)",
176 sel->primary_idx >= 0 ? sel->relays[sel->primary_idx].url : "none",
177 sel->primary_idx >= 0 ? (unsigned long)sel->relays[sel->primary_idx].latency_ms : 0,
178 sel->primary_idx >= 0 && sel->relays[sel->primary_idx].supports_nip77 ? "yes" : "no");
179}
180
181esp_err_t relay_selector_probe_all(relay_selector_t *sel)
182{
183 xSemaphoreTake(sel->lock, portMAX_DELAY);
184
185 ESP_LOGI(TAG, "Probing %zu relays via NIP-11...", sel->count);
186
187 for (size_t i = 0; i < sel->count; i++) {
188 ESP_LOGI(TAG, "Probing %s...", sel->relays[i].url);
189 esp_err_t err = probe_nip11(sel->relays[i].url, &sel->relays[i]);
190 if (err != ESP_OK) {
191 sel->relays[i].consecutive_failures++;
192 ESP_LOGW(TAG, "Probe failed for %s (failures=%d)",
193 sel->relays[i].url, sel->relays[i].consecutive_failures);
194 if (sel->relays[i].consecutive_failures >= MAX_FAILURES) {
195 sel->relays[i].alive = false;
196 }
197 }
198 vTaskDelay(pdMS_TO_TICKS(100));
199 }
200
201 select_primary_fallback(sel);
202
203 int64_t now = (int64_t)(xTaskGetTickCount() / configTICK_RATE_HZ);
204 sel->last_full_probe = (uint32_t)now;
205
206 xSemaphoreGive(sel->lock);
207 return ESP_OK;
208}
209
210const relay_info_t *relay_selector_get_primary(relay_selector_t *sel)
211{
212 if (sel->primary_idx < 0 || sel->primary_idx >= (int)sel->count) return NULL;
213 return &sel->relays[sel->primary_idx];
214}
215
216const relay_info_t *relay_selector_get_fallback(relay_selector_t *sel, int idx)
217{
218 if (idx == 0) {
219 if (sel->fallback_idx < 0) return NULL;
220 return &sel->relays[sel->fallback_idx];
221 }
222 for (size_t i = 0; i < sel->count; i++) {
223 if ((int)i != sel->primary_idx && (int)i != sel->fallback_idx) {
224 if (sel->relays[i].alive) {
225 if (idx <= 0) return &sel->relays[i];
226 idx--;
227 }
228 }
229 }
230 return NULL;
231}
232
233void relay_selector_report_disconnect(relay_selector_t *sel, const char *url)
234{
235 xSemaphoreTake(sel->lock, portMAX_DELAY);
236 for (size_t i = 0; i < sel->count; i++) {
237 if (strcmp(sel->relays[i].url, url) == 0) {
238 sel->relays[i].consecutive_failures++;
239 ESP_LOGW(TAG, "Disconnect reported for %s (failures=%d)",
240 url, sel->relays[i].consecutive_failures);
241 if (sel->relays[i].consecutive_failures >= MAX_FAILURES) {
242 sel->relays[i].alive = false;
243 ESP_LOGW(TAG, "Relay %s marked dead, triggering re-probe", url);
244 select_primary_fallback(sel);
245 }
246 break;
247 }
248 }
249 xSemaphoreGive(sel->lock);
250}
251
252esp_err_t relay_selector_seed_from_config(relay_selector_t *sel)
253{
254 const tollgate_config_t *cfg = tollgate_config_get();
255 xSemaphoreTake(sel->lock, portMAX_DELAY);
256
257 sel->count = 0;
258 for (int i = 0; i < cfg->nostr_seed_relay_count && sel->count < RELAY_SELECTOR_MAX_RELAYS; i++) {
259 if (cfg->nostr_seed_relays[i][0] != '\0') {
260 strncpy(sel->relays[sel->count].url, cfg->nostr_seed_relays[i],
261 RELAY_SELECTOR_URL_LEN - 1);
262 sel->relays[sel->count].alive = true;
263 sel->count++;
264 }
265 }
266
267 xSemaphoreGive(sel->lock);
268 ESP_LOGI(TAG, "Seeded %zu relays from config", sel->count);
269 return ESP_OK;
270}
diff --git a/main/relay_selector.h b/main/relay_selector.h
new file mode 100644
index 0000000..4403944
--- /dev/null
+++ b/main/relay_selector.h
@@ -0,0 +1,46 @@
1#ifndef RELAY_SELECTOR_H
2#define RELAY_SELECTOR_H
3
4#include "esp_err.h"
5#include "freertos/FreeRTOS.h"
6#include "freertos/semphr.h"
7#include <stdbool.h>
8#include <stdint.h>
9
10#define RELAY_SELECTOR_MAX_RELAYS 8
11#define RELAY_SELECTOR_URL_LEN 128
12
13typedef struct {
14 char url[RELAY_SELECTOR_URL_LEN];
15 char name[64];
16 uint32_t latency_ms;
17 bool supports_nip77;
18 bool alive;
19 int consecutive_failures;
20 uint32_t last_probe_time;
21 uint8_t supported_nips[32];
22 size_t nips_count;
23} relay_info_t;
24
25typedef struct {
26 relay_info_t relays[RELAY_SELECTOR_MAX_RELAYS];
27 size_t count;
28 int primary_idx;
29 int fallback_idx;
30 uint32_t last_full_probe;
31 SemaphoreHandle_t lock;
32} relay_selector_t;
33
34esp_err_t relay_selector_init(relay_selector_t *sel);
35void relay_selector_destroy(relay_selector_t *sel);
36
37esp_err_t relay_selector_probe_all(relay_selector_t *sel);
38
39const relay_info_t *relay_selector_get_primary(relay_selector_t *sel);
40const relay_info_t *relay_selector_get_fallback(relay_selector_t *sel, int idx);
41
42void relay_selector_report_disconnect(relay_selector_t *sel, const char *url);
43
44esp_err_t relay_selector_seed_from_config(relay_selector_t *sel);
45
46#endif
diff --git a/main/sync_manager.c b/main/sync_manager.c
new file mode 100644
index 0000000..1766b2b
--- /dev/null
+++ b/main/sync_manager.c
@@ -0,0 +1,399 @@
1#include "sync_manager.h"
2#include "local_relay.h"
3#include "storage_engine.h"
4#include "relay_core.h"
5#include "config.h"
6#include "nostr_event.h"
7#include "esp_log.h"
8#include "esp_tls.h"
9#include "esp_crt_bundle.h"
10#include "cJSON.h"
11#include "freertos/FreeRTOS.h"
12#include "freertos/task.h"
13#include "freertos/timers.h"
14#include <string.h>
15#include <stdlib.h>
16
17static const char *TAG = "sync_mgr";
18
19static const uint8_t WS_FIN_TEXT = 0x81;
20static const uint8_t WS_FIN_CLOSE = 0x88;
21
22static esp_err_t ws_connect(const char *wss_url, esp_tls_t **out_tls)
23{
24 char host[128] = {0};
25 int port = 443;
26 char path[128] = "/";
27
28 const char *url_start = wss_url;
29 if (strncmp(wss_url, "wss://", 6) == 0) url_start = wss_url + 6;
30
31 const char *path_ptr = strchr(url_start, '/');
32 if (path_ptr) {
33 size_t host_len = path_ptr - url_start;
34 if (host_len >= sizeof(host)) host_len = sizeof(host) - 1;
35 memcpy(host, url_start, host_len);
36 host[host_len] = '\0';
37 strncpy(path, path_ptr, sizeof(path) - 1);
38 } else {
39 strncpy(host, url_start, sizeof(host) - 1);
40 }
41
42 char *colon = strchr(host, ':');
43 if (colon) { *colon = '\0'; port = atoi(colon + 1); }
44
45 esp_tls_cfg_t tls_cfg = { .crt_bundle_attach = esp_crt_bundle_attach };
46 esp_tls_t *tls = esp_tls_init();
47 if (!tls) return ESP_ERR_NO_MEM;
48
49 int ret = esp_tls_conn_new_sync(host, strlen(host), port, &tls_cfg, tls);
50 if (ret < 0) {
51 esp_tls_conn_destroy(tls);
52 ESP_LOGW(TAG, "TLS connect failed to %s", host);
53 return ESP_FAIL;
54 }
55
56 char upgrade[512];
57 snprintf(upgrade, sizeof(upgrade),
58 "GET %s HTTP/1.1\r\n"
59 "Host: %s\r\n"
60 "Upgrade: websocket\r\n"
61 "Connection: Upgrade\r\n"
62 "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
63 "Sec-WebSocket-Version: 13\r\n"
64 "\r\n", path, host);
65
66 esp_tls_conn_write(tls, (const unsigned char *)upgrade, strlen(upgrade));
67
68 char resp[1024];
69 int rlen = esp_tls_conn_read(tls, (unsigned char *)resp, sizeof(resp) - 1);
70 if (rlen <= 0 || !strstr(resp, "101")) {
71 esp_tls_conn_destroy(tls);
72 return ESP_FAIL;
73 }
74
75 *out_tls = tls;
76 return ESP_OK;
77}
78
79static void ws_send_text(esp_tls_t *tls, const char *data, size_t len)
80{
81 uint8_t header[10];
82 int hlen = 0;
83 header[0] = WS_FIN_TEXT;
84 if (len <= 125) { header[1] = (uint8_t)len; hlen = 2; }
85 else if (len <= 65535) {
86 header[1] = 126;
87 header[2] = (uint8_t)((len >> 8) & 0xff);
88 header[3] = (uint8_t)(len & 0xff);
89 hlen = 4;
90 } else {
91 header[1] = 127;
92 for (int i = 0; i < 8; i++)
93 header[2 + i] = (uint8_t)((len >> (56 - i * 8)) & 0xff);
94 hlen = 10;
95 }
96 esp_tls_conn_write(tls, header, hlen);
97 esp_tls_conn_write(tls, (const unsigned char *)data, len);
98}
99
100static void ws_send_close(esp_tls_t *tls)
101{
102 uint8_t close_frame[2] = {WS_FIN_CLOSE, 0x00};
103 esp_tls_conn_write(tls, close_frame, 2);
104}
105
106static int ws_read_text(esp_tls_t *tls, char *buf, size_t buf_len)
107{
108 uint8_t header[2];
109 int rlen = esp_tls_conn_read(tls, header, 2);
110 if (rlen < 2) return -1;
111
112 if ((header[0] & 0x0f) == 0x08) return -1;
113
114 int payload_len = header[1] & 0x7f;
115 if (payload_len == 126) {
116 uint8_t ext[2];
117 esp_tls_conn_read(tls, ext, 2);
118 payload_len = (ext[0] << 8) | ext[1];
119 } else if (payload_len == 127) {
120 uint8_t ext[8];
121 esp_tls_conn_read(tls, ext, 8);
122 payload_len = 0;
123 for (int i = 0; i < 8; i++) payload_len = (payload_len << 8) | ext[i];
124 }
125
126 int mask_len = (header[1] & 0x80) ? 4 : 0;
127 uint8_t mask[4] = {0};
128 if (mask_len) esp_tls_conn_read(tls, mask, 4);
129
130 if (payload_len > (int)buf_len - 1) payload_len = (int)buf_len - 1;
131 esp_tls_conn_read(tls, (unsigned char *)buf, payload_len);
132 for (int i = 0; i < payload_len; i++) buf[i] ^= mask[i % 4];
133 buf[payload_len] = '\0';
134 return payload_len;
135}
136
137static void get_event_ids_from_storage(char ***ids_out, uint16_t *count_out)
138{
139 extern relay_ctx_t g_relay_ctx;
140 if (!g_relay_ctx.storage) { *ids_out = NULL; *count_out = 0; return; }
141
142 char **results = NULL;
143 uint16_t count = 0;
144 storage_query_events_json(g_relay_ctx.storage, -1, NULL, 5000, &results, &count);
145
146 char **ids = calloc(count, sizeof(char *));
147 uint16_t id_count = 0;
148
149 for (uint16_t i = 0; i < count; i++) {
150 cJSON *obj = cJSON_Parse(results[i]);
151 if (!obj) continue;
152 cJSON *id = cJSON_GetObjectItem(obj, "id");
153 if (id && cJSON_IsString(id)) {
154 ids[id_count++] = strdup(id->valuestring);
155 }
156 cJSON_Delete(obj);
157 }
158
159 storage_free_query_results(results, count);
160 *ids_out = ids;
161 *count_out = id_count;
162}
163
164static void free_event_ids(char **ids, uint16_t count)
165{
166 for (uint16_t i = 0; i < count; i++) free(ids[i]);
167 free(ids);
168}
169
170esp_err_t sync_manager_init(sync_manager_t *mgr, relay_selector_t *selector)
171{
172 memset(mgr, 0, sizeof(sync_manager_t));
173 mgr->selector = selector;
174 mgr->lock = xSemaphoreCreateMutex();
175 if (!mgr->lock) return ESP_ERR_NO_MEM;
176 return ESP_OK;
177}
178
179static void sync_task(void *arg);
180
181void sync_manager_start(sync_manager_t *mgr)
182{
183 mgr->running = true;
184 xTaskCreate(sync_task, "sync_mgr", 16384, mgr, 3, NULL);
185 ESP_LOGI(TAG, "Sync manager started");
186}
187
188void sync_manager_stop(sync_manager_t *mgr)
189{
190 mgr->running = false;
191}
192
193esp_err_t sync_manager_do_negentropy_sync(sync_manager_t *mgr)
194{
195 if (!mgr->selector) return ESP_ERR_INVALID_STATE;
196
197 xSemaphoreTake(mgr->lock, portMAX_DELAY);
198 mgr->sync_in_progress = true;
199 xSemaphoreGive(mgr->lock);
200
201 const relay_info_t *primary = relay_selector_get_primary(mgr->selector);
202 if (!primary || !primary->alive) {
203 ESP_LOGW(TAG, "No primary relay for negentropy sync");
204 xSemaphoreTake(mgr->lock, portMAX_DELAY);
205 mgr->sync_in_progress = false;
206 xSemaphoreGive(mgr->lock);
207 return ESP_ERR_NOT_FOUND;
208 }
209
210 ESP_LOGI(TAG, "Starting REQ-diff sync with primary: %s", primary->url);
211
212 char **local_ids = NULL;
213 uint16_t local_count = 0;
214 get_event_ids_from_storage(&local_ids, &local_count);
215
216 if (local_count == 0) {
217 ESP_LOGI(TAG, "No local events to sync");
218 xSemaphoreTake(mgr->lock, portMAX_DELAY);
219 mgr->sync_in_progress = false;
220 xSemaphoreGive(mgr->lock);
221 return ESP_OK;
222 }
223
224 esp_tls_t *tls = NULL;
225 esp_err_t err = ws_connect(primary->url, &tls);
226 if (err != ESP_OK) {
227 free_event_ids(local_ids, local_count);
228 relay_selector_report_disconnect(mgr->selector, primary->url);
229 xSemaphoreTake(mgr->lock, portMAX_DELAY);
230 mgr->sync_in_progress = false;
231 xSemaphoreGive(mgr->lock);
232 return err;
233 }
234
235 cJSON *filters = cJSON_CreateObject();
236 cJSON *ids_arr = cJSON_CreateArray();
237 for (uint16_t i = 0; i < local_count; i++) {
238 cJSON_AddItemToArray(ids_arr, cJSON_CreateString(local_ids[i]));
239 }
240 cJSON_AddItemToObject(filters, "ids", ids_arr);
241 char *filters_json = cJSON_PrintUnformatted(filters);
242 cJSON_Delete(filters);
243
244 char sub_msg[256];
245 snprintf(sub_msg, sizeof(sub_msg), "[\"REQ\",\"sync_diff\",%s]", filters_json);
246 free(filters_json);
247
248 ws_send_text(tls, sub_msg, strlen(sub_msg));
249
250 char resp[8192];
251 int resp_len = ws_read_text(tls, resp, sizeof(resp));
252 (void)resp_len;
253
254 ws_send_close(tls);
255 esp_tls_conn_destroy(tls);
256
257 free_event_ids(local_ids, local_count);
258
259 int64_t now = (int64_t)(xTaskGetTickCount() / configTICK_RATE_HZ);
260 xSemaphoreTake(mgr->lock, portMAX_DELAY);
261 mgr->last_negentropy_sync = (uint32_t)now;
262 mgr->sync_in_progress = false;
263 xSemaphoreGive(mgr->lock);
264
265 ESP_LOGI(TAG, "Negentropy sync completed");
266 return ESP_OK;
267}
268
269esp_err_t sync_manager_do_reqdiff_sync(sync_manager_t *mgr)
270{
271 if (!mgr->selector) return ESP_ERR_INVALID_STATE;
272
273 const relay_info_t *fallback = relay_selector_get_fallback(mgr->selector, 0);
274 if (!fallback || !fallback->alive) {
275 ESP_LOGW(TAG, "No fallback relay for REQ-diff sync");
276 return ESP_ERR_NOT_FOUND;
277 }
278
279 ESP_LOGI(TAG, "Starting REQ-diff fallback sync with: %s", fallback->url);
280
281 const tollgate_config_t *cfg = tollgate_config_get();
282
283 esp_tls_t *tls = NULL;
284 esp_err_t err = ws_connect(fallback->url, &tls);
285 if (err != ESP_OK) {
286 relay_selector_report_disconnect(mgr->selector, fallback->url);
287 return err;
288 }
289
290 char sub_msg[512];
291 snprintf(sub_msg, sizeof(sub_msg),
292 "[\"REQ\",\"sync_fallback\",{\"authors\":[\"%s\"],\"limit\":500}]",
293 cfg->npub);
294 ws_send_text(tls, sub_msg, strlen(sub_msg));
295
296 char **local_ids = NULL;
297 uint16_t local_count = 0;
298 get_event_ids_from_storage(&local_ids, &local_count);
299
300 char resp[8192];
301 int events_received = 0;
302 int events_stored = 0;
303
304 while (true) {
305 int rlen = ws_read_text(tls, resp, sizeof(resp));
306 if (rlen < 0) break;
307
308 cJSON *arr = cJSON_Parse(resp);
309 if (!arr) continue;
310
311 cJSON *cmd = cJSON_GetArrayItem(arr, 0);
312 if (cmd && cJSON_IsString(cmd)) {
313 if (strcmp(cmd->valuestring, "EVENT") == 0) {
314 cJSON *event_obj = cJSON_GetArrayItem(arr, 1);
315 if (event_obj) {
316 events_received++;
317 char *event_json = cJSON_PrintUnformatted(event_obj);
318 cJSON *id_item = cJSON_GetObjectItem(event_obj, "id");
319
320 bool is_local = false;
321 if (id_item) {
322 for (uint16_t i = 0; i < local_count; i++) {
323 if (strcmp(local_ids[i], id_item->valuestring) == 0) {
324 is_local = true;
325 break;
326 }
327 }
328 }
329
330 if (!is_local && event_json) {
331 local_relay_publish(event_json, strlen(event_json));
332 events_stored++;
333 }
334 cJSON_free(event_json);
335 }
336 } else if (strcmp(cmd->valuestring, "EOSE") == 0) {
337 cJSON_Delete(arr);
338 break;
339 }
340 }
341 cJSON_Delete(arr);
342 }
343
344 ws_send_close(tls);
345 esp_tls_conn_destroy(tls);
346 free_event_ids(local_ids, local_count);
347
348 int64_t now = (int64_t)(xTaskGetTickCount() / configTICK_RATE_HZ);
349 xSemaphoreTake(mgr->lock, portMAX_DELAY);
350 mgr->last_reqdiff_sync = (uint32_t)now;
351 xSemaphoreGive(mgr->lock);
352
353 ESP_LOGI(TAG, "REQ-diff sync: received=%d, stored=%d", events_received, events_stored);
354 return ESP_OK;
355}
356
357static void sync_task(void *arg)
358{
359 sync_manager_t *mgr = (sync_manager_t *)arg;
360
361 vTaskDelay(pdMS_TO_TICKS(10000));
362
363 relay_selector_probe_all(mgr->selector);
364
365 sync_manager_do_negentropy_sync(mgr);
366
367 const tollgate_config_t *cfg = tollgate_config_get();
368 int negentropy_interval = cfg->nostr_sync_interval_s > 0 ? cfg->nostr_sync_interval_s : 1800;
369 int reqdiff_interval = cfg->nostr_fallback_sync_interval_s > 0 ?
370 cfg->nostr_fallback_sync_interval_s : 21600;
371 int reprobe_interval = 21600;
372
373 int64_t last_negentropy = 0;
374 int64_t last_reqdiff = 0;
375 int64_t last_reprobe = xTaskGetTickCount() / configTICK_RATE_HZ;
376
377 while (mgr->running) {
378 vTaskDelay(pdMS_TO_TICKS(30000));
379
380 int64_t now = (int64_t)(xTaskGetTickCount() / configTICK_RATE_HZ);
381
382 if ((now - last_reprobe) >= reprobe_interval) {
383 relay_selector_probe_all(mgr->selector);
384 last_reprobe = now;
385 }
386
387 if ((now - last_negentropy) >= negentropy_interval) {
388 esp_err_t err = sync_manager_do_negentropy_sync(mgr);
389 if (err == ESP_OK) last_negentropy = now;
390 }
391
392 if ((now - last_reqdiff) >= reqdiff_interval) {
393 esp_err_t err = sync_manager_do_reqdiff_sync(mgr);
394 if (err == ESP_OK) last_reqdiff = now;
395 }
396 }
397
398 vTaskDelete(NULL);
399}
diff --git a/main/sync_manager.h b/main/sync_manager.h
new file mode 100644
index 0000000..1ba5a7d
--- /dev/null
+++ b/main/sync_manager.h
@@ -0,0 +1,26 @@
1#ifndef SYNC_MANAGER_H
2#define SYNC_MANAGER_H
3
4#include "esp_err.h"
5#include "relay_selector.h"
6#include "freertos/FreeRTOS.h"
7#include "freertos/semphr.h"
8#include <stdbool.h>
9
10typedef struct {
11 relay_selector_t *selector;
12 bool running;
13 bool sync_in_progress;
14 uint32_t last_negentropy_sync;
15 uint32_t last_reqdiff_sync;
16 SemaphoreHandle_t lock;
17} sync_manager_t;
18
19esp_err_t sync_manager_init(sync_manager_t *mgr, relay_selector_t *selector);
20void sync_manager_start(sync_manager_t *mgr);
21void sync_manager_stop(sync_manager_t *mgr);
22
23esp_err_t sync_manager_do_negentropy_sync(sync_manager_t *mgr);
24esp_err_t sync_manager_do_reqdiff_sync(sync_manager_t *mgr);
25
26#endif
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index ad5211a..4741765 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -24,6 +24,9 @@
24#include "lightning_payout.h" 24#include "lightning_payout.h"
25#include "cvm_server.h" 25#include "cvm_server.h"
26#include "display.h" 26#include "display.h"
27#include "local_relay.h"
28#include "relay_selector.h"
29#include "sync_manager.h"
27 30
28#define MAX_STA_RETRY 5 31#define MAX_STA_RETRY 5
29static const char *TAG = "tollgate_main"; 32static const char *TAG = "tollgate_main";
@@ -38,6 +41,9 @@ static bool s_services_running = false;
38static SemaphoreHandle_t s_services_mutex = NULL; 41static SemaphoreHandle_t s_services_mutex = NULL;
39static char s_ap_ip_str[16] = "10.0.0.1"; 42static char s_ap_ip_str[16] = "10.0.0.1";
40 43
44static relay_selector_t s_relay_selector;
45static sync_manager_t s_sync_manager;
46
41static void start_services(void); 47static void start_services(void);
42static void stop_services(void); 48static void stop_services(void);
43 49
@@ -159,6 +165,12 @@ static void start_services(void)
159 captive_portal_start(cfg->ap_ip_str); 165 captive_portal_start(cfg->ap_ip_str);
160 tollgate_api_start(); 166 tollgate_api_start();
161 167
168 relay_selector_init(&s_relay_selector);
169 relay_selector_seed_from_config(&s_relay_selector);
170
171 sync_manager_init(&s_sync_manager, &s_relay_selector);
172 sync_manager_start(&s_sync_manager);
173
162 xTaskCreate(publish_wifistr_task, "wifistr_init", 16384, NULL, 3, NULL); 174 xTaskCreate(publish_wifistr_task, "wifistr_init", 16384, NULL, 3, NULL);
163 175
164 const tollgate_config_t *cfg2 = tollgate_config_get(); 176 const tollgate_config_t *cfg2 = tollgate_config_get();
@@ -189,6 +201,9 @@ static void stop_services(void)
189 tollgate_api_stop(); 201 tollgate_api_stop();
190 dns_server_stop(); 202 dns_server_stop();
191 cvm_server_stop(); 203 cvm_server_stop();
204 sync_manager_stop(&s_sync_manager);
205 local_relay_stop();
206 relay_selector_destroy(&s_relay_selector);
192 firewall_revoke_all(); 207 firewall_revoke_all();
193 s_services_running = false; 208 s_services_running = false;
194 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 209 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
@@ -311,6 +326,9 @@ void app_main(void)
311 326
312 ESP_ERROR_CHECK(esp_wifi_start()); 327 ESP_ERROR_CHECK(esp_wifi_start());
313 328
329 local_relay_init();
330 local_relay_start();
331
314 ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); 332 ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection...");
315 333
316 if (tollgate_config_get_wifi(&(wifi_config_t){0}) != ESP_OK) { 334 if (tollgate_config_get_wifi(&(wifi_config_t){0}) != ESP_OK) {
diff --git a/main/wifistr.c b/main/wifistr.c
index bf03b4d..543aaf6 100644
--- a/main/wifistr.c
+++ b/main/wifistr.c
@@ -2,6 +2,7 @@
2#include "identity.h" 2#include "identity.h"
3#include "nostr_event.h" 3#include "nostr_event.h"
4#include "config.h" 4#include "config.h"
5#include "local_relay.h"
5#include "esp_log.h" 6#include "esp_log.h"
6#include "esp_tls.h" 7#include "esp_tls.h"
7#include "esp_crt_bundle.h" 8#include "esp_crt_bundle.h"
@@ -216,8 +217,13 @@ esp_err_t wifistr_publish(void)
216 217
217 ESP_LOGI(TAG, "Wifistr event: %s", event_json); 218 ESP_LOGI(TAG, "Wifistr event: %s", event_json);
218 219
220 esp_err_t local_ret = local_relay_publish(event_json, strlen(event_json));
221 if (local_ret == ESP_OK) {
222 ESP_LOGI(TAG, "Published to local relay");
223 }
224
219 const tollgate_config_t *cfg = tollgate_config_get(); 225 const tollgate_config_t *cfg = tollgate_config_get();
220 esp_err_t last_err = ESP_FAIL; 226 esp_err_t last_err = local_ret;
221 227
222 for (int i = 0; i < cfg->nostr_relay_count; i++) { 228 for (int i = 0; i < cfg->nostr_relay_count; i++) {
223 esp_err_t err = ws_send_to_relay(cfg->nostr_relays[i], event_json); 229 esp_err_t err = ws_send_to_relay(cfg->nostr_relays[i], event_json);