diff --git a/zephcore/.gitattributes b/zephcore/.gitattributes new file mode 100644 index 0000000..0b66b45 --- /dev/null +++ b/zephcore/.gitattributes @@ -0,0 +1,2 @@ +patches/zephyr/*.patch text eol=lf +patches/modules/loramac-node/*.patch text eol=crlf diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 8f9e23a..9ac87ca 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -42,18 +42,57 @@ function(zephcore_apply_patches PATCH_DIR TARGET_DIR LABEL) endif() foreach(PATCH_FILE ${PATCH_FILES}) get_filename_component(PATCH_NAME ${PATCH_FILE} NAME) + set(PATCH_FILE_TO_APPLY "${PATCH_FILE}") + # Prepare LF/CRLF patch variants for robust matching across clones. + file(READ "${PATCH_FILE}" _patch_content) + string(REPLACE "\r\n" "\n" _patch_lf "${_patch_content}") + set(_patch_crlf "${_patch_lf}") + string(REPLACE "\n" "\r\n" _patch_crlf "${_patch_crlf}") + set(_tmp_patch_dir "${CMAKE_BINARY_DIR}/zephcore_patch_tmp/${LABEL}") + file(MAKE_DIRECTORY "${_tmp_patch_dir}") + set(_tmp_patch_lf "${_tmp_patch_dir}/${PATCH_NAME}.lf") + set(_tmp_patch_crlf "${_tmp_patch_dir}/${PATCH_NAME}.crlf") + file(WRITE "${_tmp_patch_lf}" "${_patch_lf}") + file(WRITE "${_tmp_patch_crlf}" "${_patch_crlf}") # Dry-run check execute_process( - COMMAND git apply --check "${PATCH_FILE}" + COMMAND git apply --check "${PATCH_FILE_TO_APPLY}" WORKING_DIRECTORY "${TARGET_DIR}" RESULT_VARIABLE PATCH_CHECK ERROR_VARIABLE PATCH_ERR ) + # Retry check with explicit LF/CRLF variants to avoid EOL drift issues. + if(NOT PATCH_CHECK EQUAL 0) + execute_process( + COMMAND git apply --check "${_tmp_patch_lf}" + WORKING_DIRECTORY "${TARGET_DIR}" + RESULT_VARIABLE PATCH_CHECK_LF + ERROR_VARIABLE PATCH_ERR_LF + ) + if(PATCH_CHECK_LF EQUAL 0) + set(PATCH_FILE_TO_APPLY "${_tmp_patch_lf}") + set(PATCH_CHECK 0) + else() + execute_process( + COMMAND git apply --check "${_tmp_patch_crlf}" + WORKING_DIRECTORY "${TARGET_DIR}" + RESULT_VARIABLE PATCH_CHECK_CRLF + ERROR_VARIABLE PATCH_ERR_CRLF + ) + if(PATCH_CHECK_CRLF EQUAL 0) + set(PATCH_FILE_TO_APPLY "${_tmp_patch_crlf}") + set(PATCH_CHECK 0) + else() + # Keep the most relevant error from the last attempted variant. + set(PATCH_ERR "${PATCH_ERR_CRLF}") + endif() + endif() + endif() # Stale patches from previous build: reset affected files, retry if(NOT PATCH_CHECK EQUAL 0) # Extract affected file paths from numstat execute_process( - COMMAND git apply --numstat "${PATCH_FILE}" + COMMAND git apply --numstat "${PATCH_FILE_TO_APPLY}" WORKING_DIRECTORY "${TARGET_DIR}" OUTPUT_VARIABLE PATCH_NUMSTAT ERROR_QUIET @@ -74,7 +113,7 @@ function(zephcore_apply_patches PATCH_DIR TARGET_DIR LABEL) ) # Retry dry-run execute_process( - COMMAND git apply --check "${PATCH_FILE}" + COMMAND git apply --check "${PATCH_FILE_TO_APPLY}" WORKING_DIRECTORY "${TARGET_DIR}" RESULT_VARIABLE PATCH_CHECK ERROR_VARIABLE PATCH_ERR @@ -94,7 +133,7 @@ function(zephcore_apply_patches PATCH_DIR TARGET_DIR LABEL) endif() # Apply execute_process( - COMMAND git apply "${PATCH_FILE}" + COMMAND git apply "${PATCH_FILE_TO_APPLY}" WORKING_DIRECTORY "${TARGET_DIR}" RESULT_VARIABLE APPLY_RESULT ERROR_VARIABLE APPLY_ERR @@ -425,12 +464,27 @@ if(CONFIG_ZEPHCORE_ROLE_REPEATER) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/adapters/usb ) + if(CONFIG_ZEPHCORE_REPEATER_UPLINK AND CONFIG_MQTT_LIB) + target_sources(app PRIVATE + app/observer_creds.cpp + adapters/wifi/ZephyrWiFiStation.c + adapters/mqtt/ZephyrMQTTPublisher.c + ) + target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/adapters/wifi + ${CMAKE_CURRENT_SOURCE_DIR}/adapters/mqtt + ${CMAKE_CURRENT_SOURCE_DIR}/app + ) + elseif(CONFIG_ZEPHCORE_REPEATER_UPLINK) + message(WARNING "ZephCore repeater uplink requested, but MQTT is disabled (likely prod.conf). Build will exclude uplink runtime.") + endif() target_compile_definitions(app PRIVATE ZEPHCORE_REPEATER=1) elseif(CONFIG_ZEPHCORE_ROLE_OBSERVER) message(STATUS "ZephCore Role: OBSERVER") target_sources(app PRIVATE app/main_observer.cpp app/ObserverMesh.cpp + app/observer_creds.cpp app/RepeaterDataStore.cpp adapters/wifi/ZephyrWiFiStation.c adapters/mqtt/ZephyrMQTTPublisher.c diff --git a/zephcore/Kconfig b/zephcore/Kconfig index ea9e055..f955d1d 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -84,6 +84,15 @@ endchoice if ZEPHCORE_ROLE_REPEATER +config ZEPHCORE_REPEATER_UPLINK + bool "Enable repeater WiFi+MQTT uplink" + depends on SOC_FAMILY_ESPRESSIF_ESP32 + default n + help + Adds observer-style WiFi station and MQTT packet publishing to + repeater builds. Configuration is stored in LittleFS and applied + on reboot. + config ZEPHCORE_MAX_CLIENTS int "Maximum ACL clients" default 32 diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 775d985..e8a91e2 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -134,6 +134,33 @@ Regions control which flood packets the repeater forwards. The region tree is hi --- +## Repeater Uplink (ESP32 + `CONFIG_ZEPHCORE_REPEATER_UPLINK`) + +These commands configure observer-style WiFi+MQTT packet reporting from repeater role. +All `set uplink.*` changes are saved immediately and only applied after reboot. + +| Command | Description | +|---------|-------------| +| `get uplink.status` | Uplink runtime state: enabled flag, WiFi state, MQTT state, reboot-required flag | +| `get uplink.enable` | Uplink enable flag (`on`/`off`) | +| `get uplink.wifi.ssid` | Configured WiFi SSID | +| `get uplink.mqtt.host` | Configured MQTT broker host | +| `get uplink.mqtt.port` | Configured MQTT broker port | +| `get uplink.mqtt.tls` | MQTT TLS mode (`0`/`1`) | +| `get uplink.mqtt.user` | Configured MQTT username | +| `get uplink.mqtt.iata` | Configured IATA/site code used in MQTT topic | +| `set uplink.enable ` | Enable or disable repeater uplink *(reboot required)* | +| `set uplink.wifi.ssid ` | Set WiFi SSID *(reboot required)* | +| `set uplink.wifi.psk ` | Set WiFi password *(reboot required)* | +| `set uplink.mqtt.host ` | Set MQTT host *(reboot required)* | +| `set uplink.mqtt.port ` | Set MQTT port 1–65535 *(reboot required)* | +| `set uplink.mqtt.tls <0\|1>` | Set MQTT TLS mode *(reboot required)* | +| `set uplink.mqtt.user ` | Set MQTT username *(reboot required)* | +| `set uplink.mqtt.password ` | Set MQTT password *(reboot required)* | +| `set uplink.mqtt.iata ` | Set MQTT site code *(reboot required)* | + +--- + ## `get` — Read Configuration | Command | Returns | diff --git a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c index a690acd..e0a190a 100644 --- a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c +++ b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c @@ -325,7 +325,7 @@ static void run_poll_loop(void) /* ========== Publisher thread ========== */ -#define MQTT_THREAD_STACK_SIZE 8192 +#define MQTT_THREAD_STACK_SIZE 12288 #define MQTT_THREAD_PRIORITY 5 static void mqtt_thread_fn(void *p1, void *p2, void *p3); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 6e2ea68..3b265b3 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -20,6 +20,11 @@ #include #include #include +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) +#include "observer_creds.h" +#include +#include +#endif /* Helper to get radio driver for stats — uses LoRaRadioBase (works for SX126x and LR1110) */ static inline mesh::LoRaRadioBase& getRadioDriver(mesh::Radio* radio) { @@ -42,6 +47,16 @@ static void simple_sort(T* arr, int count, Comparator cmp) { LOG_MODULE_REGISTER(zephcore_repeater, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) +static RepeaterMesh *s_uplink_mesh; +static void uplink_time_sync_cb(uint32_t unix_ts) +{ + if (s_uplink_mesh) { + s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts); + } +} +#endif + /* Protocol constants */ #define FIRMWARE_VER_LEVEL 2 @@ -468,6 +483,13 @@ void RepeaterMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) #endif (void)snr; (void)rssi; +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + _uplink_last_rssi = rssi; + _uplink_last_raw_len = len <= (int)sizeof(_uplink_last_raw) ? len : (int)sizeof(_uplink_last_raw); + if (_uplink_last_raw_len > 0) { + memcpy(_uplink_last_raw, raw, _uplink_last_raw_len); + } +#endif } void RepeaterMesh::logRx(mesh::Packet* pkt, int len, float score) { @@ -476,6 +498,10 @@ void RepeaterMesh::logRx(mesh::Packet* pkt, int len, float score) { len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI()); } +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + _uplink_last_score = score; + publishUplinkPacket(pkt); +#endif } void RepeaterMesh::logTx(mesh::Packet* pkt, int len) { @@ -805,6 +831,17 @@ RepeaterMesh::RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Mil initNodePrefs(&_prefs); strcpy(_prefs.node_name, "Repeater"); _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // Repeaters always advertise prefs coordinates +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + memset(&_uplink_creds, 0, sizeof(_uplink_creds)); + observer_creds_init(&_uplink_creds); + _uplink_reboot_required = false; + memset(_uplink_pubkey_hex, 0, sizeof(_uplink_pubkey_hex)); + memset(_uplink_packets_topic, 0, sizeof(_uplink_packets_topic)); + memset(_uplink_status_topic, 0, sizeof(_uplink_status_topic)); + _uplink_last_score = 0.0f; + _uplink_last_rssi = 0.0f; + _uplink_last_raw_len = 0; +#endif } void RepeaterMesh::begin(RepeaterDataStore* store) { @@ -837,6 +874,27 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { LOG_INF("RepeaterMesh started: %s (freq=%.2f bw=%.0f sf=%d cr=%d)", _prefs.node_name, (double)_prefs.freq, (double)_prefs.bw, _prefs.sf, _prefs.cr); +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + observer_creds_load(&_uplink_creds, _store->getBasePath()); + mesh::Utils::toHex(_uplink_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE); + _uplink_pubkey_hex[PUB_KEY_SIZE * 2] = '\0'; + + const char *iata = _uplink_creds.mqtt_iata[0] ? _uplink_creds.mqtt_iata : "XXX"; + snprintf(_uplink_packets_topic, sizeof(_uplink_packets_topic), + "meshcore/%s/%s/packets", iata, _uplink_pubkey_hex); + snprintf(_uplink_status_topic, sizeof(_uplink_status_topic), + "meshcore/%s/%s/status", iata, _uplink_pubkey_hex); + + if (isUplinkEnabled() && _uplink_creds.wifi_ssid[0] && _uplink_creds.mqtt_host[0]) { + s_uplink_mesh = this; + zc_wifi_station_start(&_uplink_creds, uplink_time_sync_cb); + mqtt_publisher_start(&_uplink_creds, _prefs.node_name, + _uplink_status_topic, _uplink_packets_topic); + LOG_INF("Repeater uplink active: %s", _uplink_packets_topic); + } else { + LOG_INF("Repeater uplink inactive"); + } +#endif } double RepeaterMesh::getNodeLat() const { @@ -1037,6 +1095,12 @@ void RepeaterMesh::handleCommand(uint32_t sender_timestamp, char* command, char* command += 3; } +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + if (handleUplinkCommand(command, reply)) { + return; + } +#endif + // ACL commands - supports BOTH formats for app compatibility: // Old Arduino: setperm {pubkey-hex} {permissions} (pubkey is long, perms is short) // MeshCore App: setperm {permissions} {pubkey-hex} (perms is short 2-char hex, pubkey is long) @@ -1202,6 +1266,172 @@ void RepeaterMesh::handleCommand(uint32_t sender_timestamp, char* command, char* } } +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) +bool RepeaterMesh::saveUplinkCreds() +{ + if (!_store) return false; + return observer_creds_save(&_uplink_creds, _store->getBasePath()); +} + +bool RepeaterMesh::handleUplinkCommand(const char *command, char *reply) +{ + if (memcmp(command, "get uplink.", 11) == 0) { + const char *key = command + 11; + if (strcmp(key, "enable") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", isUplinkEnabled() ? "on" : "off"); + } else if (strcmp(key, "wifi.ssid") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _uplink_creds.wifi_ssid[0] ? _uplink_creds.wifi_ssid : "(not set)"); + } else if (strcmp(key, "mqtt.host") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _uplink_creds.mqtt_host[0] ? _uplink_creds.mqtt_host : "(not set)"); + } else if (strcmp(key, "mqtt.port") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (unsigned)_uplink_creds.mqtt_port); + } else if (strcmp(key, "mqtt.tls") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (unsigned)_uplink_creds.mqtt_tls); + } else if (strcmp(key, "mqtt.user") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _uplink_creds.mqtt_user[0] ? _uplink_creds.mqtt_user : "(not set)"); + } else if (strcmp(key, "mqtt.iata") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _uplink_creds.mqtt_iata[0] ? _uplink_creds.mqtt_iata : "(not set)"); + } else if (strcmp(key, "status") == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> enabled=%s wifi=%s mqtt=%s reboot_required=%s", + isUplinkEnabled() ? "yes" : "no", + zc_wifi_station_is_connected() ? "up" : "down", + mqtt_publisher_is_connected() ? "up" : "down", + _uplink_reboot_required ? "yes" : "no"); + } else { + snprintf(reply, CLI_REPLY_SIZE, "unknown config: uplink.%s", key); + } + return true; + } + + if (memcmp(command, "set uplink.", 11) == 0) { + const char *cfg = command + 11; + const char *val = strchr(cfg, ' '); + if (!val) { + strcpy(reply, "Error: value required"); + return true; + } + int key_len = (int)(val - cfg); + val++; + + if (key_len == 6 && memcmp(cfg, "enable", 6) == 0) { + if (strcmp(val, "on") == 0) { + setUplinkEnabled(true); + } else if (strcmp(val, "off") == 0) { + setUplinkEnabled(false); + } else { + strcpy(reply, "Error: must be on or off"); + return true; + } + } else if (key_len == 9 && memcmp(cfg, "wifi.ssid", 9) == 0) { + StrHelper::strncpy(_uplink_creds.wifi_ssid, val, sizeof(_uplink_creds.wifi_ssid)); + } else if (key_len == 8 && memcmp(cfg, "wifi.psk", 8) == 0) { + StrHelper::strncpy(_uplink_creds.wifi_psk, val, sizeof(_uplink_creds.wifi_psk)); + } else if (key_len == 9 && memcmp(cfg, "mqtt.host", 9) == 0) { + StrHelper::strncpy(_uplink_creds.mqtt_host, val, sizeof(_uplink_creds.mqtt_host)); + } else if (key_len == 9 && memcmp(cfg, "mqtt.port", 9) == 0) { + int port = atoi(val); + if (port < 1 || port > 65535) { + strcpy(reply, "Error: port range 1-65535"); + return true; + } + _uplink_creds.mqtt_port = (uint16_t)port; + } else if (key_len == 8 && memcmp(cfg, "mqtt.tls", 8) == 0) { + _uplink_creds.mqtt_tls = (atoi(val) != 0) ? 1 : 0; + } else if (key_len == 9 && memcmp(cfg, "mqtt.user", 9) == 0) { + StrHelper::strncpy(_uplink_creds.mqtt_user, val, sizeof(_uplink_creds.mqtt_user)); + } else if (key_len == 13 && memcmp(cfg, "mqtt.password", 13) == 0) { + StrHelper::strncpy(_uplink_creds.mqtt_password, val, sizeof(_uplink_creds.mqtt_password)); + } else if (key_len == 9 && memcmp(cfg, "mqtt.iata", 9) == 0) { + StrHelper::strncpy(_uplink_creds.mqtt_iata, val, sizeof(_uplink_creds.mqtt_iata)); + } else { + snprintf(reply, CLI_REPLY_SIZE, "unknown config: uplink.%.*s", key_len, cfg); + return true; + } + + if (!saveUplinkCreds()) { + strcpy(reply, "Error: save failed"); + return true; + } + markUplinkRebootRequired(); + strcpy(reply, "OK - reboot to apply"); + return true; + } + + return false; +} + +void RepeaterMesh::publishUplinkPacket(mesh::Packet *pkt) +{ + if (!isUplinkEnabled() || !mqtt_publisher_is_connected()) return; + if (_uplink_packets_topic[0] == '\0') return; + + char raw_hex[MAX_TRANS_UNIT * 2 + 1]; + mesh::Utils::toHex(raw_hex, _uplink_last_raw, _uplink_last_raw_len); + raw_hex[_uplink_last_raw_len * 2] = '\0'; + + uint8_t hash_bytes[MAX_HASH_SIZE]; + char hash_hex[MAX_HASH_SIZE * 2 + 1]; + pkt->calculatePacketHash(hash_bytes); + mesh::Utils::toHex(hash_hex, hash_bytes, MAX_HASH_SIZE); + hash_hex[MAX_HASH_SIZE * 2] = '\0'; + + uint32_t now_epoch = getRTCClock()->getCurrentTime(); + struct tm tm_now; + time_t t = (time_t)now_epoch; + gmtime_r(&t, &tm_now); + + char ts_buf[48]; + char time_buf[12], date_buf[32]; + snprintf(ts_buf, sizeof(ts_buf), "%04d-%02d-%02dT%02d:%02d:%02d.000000", + tm_now.tm_year + 1900, tm_now.tm_mon + 1, tm_now.tm_mday, + tm_now.tm_hour, tm_now.tm_min, tm_now.tm_sec); + snprintf(time_buf, sizeof(time_buf), "%02d:%02d:%02d", + tm_now.tm_hour, tm_now.tm_min, tm_now.tm_sec); + snprintf(date_buf, sizeof(date_buf), "%d/%d/%04d", + tm_now.tm_mday, tm_now.tm_mon + 1, tm_now.tm_year + 1900); + + static char json_buf[1024]; + int json_len = snprintf(json_buf, sizeof(json_buf), + "{" + "\"type\":\"PACKET\"," + "\"origin\":\"%s\"," + "\"origin_id\":\"%s\"," + "\"timestamp\":\"%s\"," + "\"direction\":\"rx\"," + "\"time\":\"%s\"," + "\"date\":\"%s\"," + "\"len\":\"%d\"," + "\"packet_type\":\"%u\"," + "\"route\":\"%s\"," + "\"payload_len\":\"%u\"," + "\"raw\":\"%s\"," + "\"SNR\":\"%d\"," + "\"RSSI\":\"%d\"," + "\"score\":\"%d\"," + "\"hash\":\"%s\"" + "}", + _prefs.node_name, + _uplink_pubkey_hex, + ts_buf, + time_buf, + date_buf, + _uplink_last_raw_len, + (unsigned)pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", + (unsigned)pkt->payload_len, + raw_hex, + (int)pkt->getSNR(), + (int)_uplink_last_rssi, + (int)(_uplink_last_score * 1000.0f), + hash_hex); + + if (json_len <= 0 || json_len >= (int)sizeof(json_buf)) { + return; + } + mqtt_publisher_enqueue(_uplink_packets_topic, json_buf, json_len); +} +#endif + void RepeaterMesh::loop() { mesh::Mesh::loop(); diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index 6e43bb4..69b4b48 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -23,6 +23,9 @@ #include #include #include "RepeaterDataStore.h" +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) +#include "observer_creds.h" +#endif #ifndef FIRMWARE_VERSION #define FIRMWARE_VERSION "v1.14.1-zephyr" @@ -105,6 +108,17 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t pending_sf; uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) + ObserverCreds _uplink_creds; + bool _uplink_reboot_required; + char _uplink_pubkey_hex[PUB_KEY_SIZE * 2 + 1]; + char _uplink_packets_topic[160]; + char _uplink_status_topic[160]; + float _uplink_last_score; + float _uplink_last_rssi; + uint8_t _uplink_last_raw[MAX_TRANS_UNIT]; + int _uplink_last_raw_len; +#endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); void sendNodeDiscoverReq(); @@ -150,6 +164,20 @@ protected: void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) + bool handleUplinkCommand(const char *command, char *reply); + void markUplinkRebootRequired() { _uplink_reboot_required = true; } + bool isUplinkEnabled() const { return (_uplink_creds._reserved[0] & 0x01) != 0; } + void setUplinkEnabled(bool en) { + if (en) { + _uplink_creds._reserved[0] |= 0x01; + } else { + _uplink_creds._reserved[0] &= (uint8_t)~0x01; + } + } + bool saveUplinkCreds(); + void publishUplinkPacket(mesh::Packet *pkt); +#endif public: RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index c7a749c..43370c1 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #define CLI_REPLY_SIZE 256 @@ -32,64 +31,6 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include "observer_creds.h" -/* ========== observer_creds_load / observer_creds_save ========== */ - -extern "C" bool observer_creds_load(struct ObserverCreds *creds, - const char *base_path) -{ - char path[96]; - snprintf(path, sizeof(path), "%s/obs_creds", base_path); - - struct fs_file_t f; - fs_file_t_init(&f); - - int rc = fs_open(&f, path, FS_O_READ); - if (rc < 0) { - LOG_DBG("obs_creds not found (%d) — using defaults", rc); - return false; - } - - ssize_t n = fs_read(&f, creds, sizeof(*creds)); - fs_close(&f); - - if (n != (ssize_t)sizeof(*creds)) { - LOG_WRN("obs_creds truncated (%d/%d) — resetting", (int)n, - (int)sizeof(*creds)); - memset(creds, 0, sizeof(*creds)); - observer_creds_init(creds); - return false; - } - - return true; -} - -extern "C" bool observer_creds_save(const struct ObserverCreds *creds, - const char *base_path) -{ - char path[96]; - snprintf(path, sizeof(path), "%s/obs_creds", base_path); - - struct fs_file_t f; - fs_file_t_init(&f); - - int rc = fs_open(&f, path, FS_O_WRITE | FS_O_CREATE | FS_O_TRUNC); - if (rc < 0) { - LOG_ERR("Cannot open %s for write: %d", path, rc); - return false; - } - - ssize_t n = fs_write(&f, creds, sizeof(*creds)); - fs_close(&f); - - if (n != (ssize_t)sizeof(*creds)) { - LOG_ERR("obs_creds write failed (%d/%d)", (int)n, - (int)sizeof(*creds)); - return false; - } - - return true; -} - /* ========== LED (optional) ========== */ #if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) diff --git a/zephcore/app/observer_creds.cpp b/zephcore/app/observer_creds.cpp new file mode 100644 index 0000000..98d1171 --- /dev/null +++ b/zephcore/app/observer_creds.cpp @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "observer_creds.h" + +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_observer_creds, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +extern "C" bool observer_creds_load(struct ObserverCreds *creds, + const char *base_path) +{ + char path[96]; + snprintf(path, sizeof(path), "%s/obs_creds", base_path); + + struct fs_file_t f; + fs_file_t_init(&f); + + int rc = fs_open(&f, path, FS_O_READ); + if (rc < 0) { + LOG_DBG("obs_creds not found (%d), using defaults", rc); + memset(creds, 0, sizeof(*creds)); + observer_creds_init(creds); + return false; + } + + ssize_t n = fs_read(&f, creds, sizeof(*creds)); + fs_close(&f); + + if (n != (ssize_t)sizeof(*creds)) { + LOG_WRN("obs_creds truncated (%d/%d), resetting", (int)n, + (int)sizeof(*creds)); + memset(creds, 0, sizeof(*creds)); + observer_creds_init(creds); + return false; + } + + return true; +} + +extern "C" bool observer_creds_save(const struct ObserverCreds *creds, + const char *base_path) +{ + char path[96]; + snprintf(path, sizeof(path), "%s/obs_creds", base_path); + + struct fs_file_t f; + fs_file_t_init(&f); + + int rc = fs_open(&f, path, FS_O_WRITE | FS_O_CREATE | FS_O_TRUNC); + if (rc < 0) { + LOG_ERR("Cannot open %s for write: %d", path, rc); + return false; + } + + ssize_t n = fs_write(&f, creds, sizeof(*creds)); + fs_close(&f); + + if (n != (ssize_t)sizeof(*creds)) { + LOG_ERR("obs_creds write failed (%d/%d)", (int)n, + (int)sizeof(*creds)); + return false; + } + + return true; +} diff --git a/zephcore/boards/common/repeater.conf b/zephcore/boards/common/repeater.conf index 9eb404b..d629f9c 100644 --- a/zephcore/boards/common/repeater.conf +++ b/zephcore/boards/common/repeater.conf @@ -22,3 +22,8 @@ CONFIG_ENTROPY_GENERATOR=y # Custom USB init with 1200 baud touch detection for UF2 bootloader (nRF52 only). # ESP32 boards use usb_serial — this setting is ignored there. CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=n + +# ========== Optional ESP32 repeater uplink ========== +# Observer-style WiFi+MQTT reporting from repeater role. +# Runtime-configured and reboot-applied. +CONFIG_ZEPHCORE_REPEATER_UPLINK=n diff --git a/zephcore/boards/common/repeater_uplink.conf b/zephcore/boards/common/repeater_uplink.conf new file mode 100644 index 0000000..7e8d80a --- /dev/null +++ b/zephcore/boards/common/repeater_uplink.conf @@ -0,0 +1,52 @@ +# Repeater uplink config (ESP32) +# +# Use with repeater + prod when WiFi+MQTT uplink is needed: +# -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/prod.conf;boards/common/repeater_uplink.conf" +# +# This restores the minimum network/MQTT/TLS settings required by +# CONFIG_ZEPHCORE_REPEATER_UPLINK in production builds. + +# IPv4 + sockets +CONFIG_NETWORKING=y +CONFIG_NET_IPV4=y +CONFIG_NET_IPV6=n +CONFIG_NET_TCP=y +CONFIG_NET_UDP=y +CONFIG_NET_SOCKETS=y +CONFIG_NET_SOCKETS_SOCKOPT_TLS=y +CONFIG_NET_MAX_CONTEXTS=8 +CONFIG_NET_MGMT=y +CONFIG_NET_MGMT_EVENT=y +CONFIG_NET_MGMT_EVENT_STACK_SIZE=4096 +CONFIG_DNS_RESOLVER=y +CONFIG_DNS_SERVER_IP_ADDRESSES=y + +# WiFi STA +CONFIG_WIFI=y +CONFIG_NET_L2_WIFI_MGMT=y +CONFIG_NET_L2_ETHERNET=y +CONFIG_NET_DHCPV4=y + +# MQTT + TLS +CONFIG_MQTT_LIB=y +CONFIG_MQTT_LIB_TLS=y +CONFIG_MQTT_KEEPALIVE=60 + +# mbedTLS +CONFIG_MBEDTLS=y +CONFIG_MBEDTLS_BUILTIN=y +CONFIG_MBEDTLS_ENABLE_HEAP=y +CONFIG_MBEDTLS_HEAP_SIZE=50000 +CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=4096 +CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y +CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y +CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y + +# SNTP (used by ZephyrWiFiStation) +CONFIG_SNTP=y + +# Stack sizes for network threads +# NOTE: Do NOT set CONFIG_MAIN_STACK_SIZE or CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE here. +# esp32_common.conf sets both to 8192 which is required for the repeater's crypto + +# routing operations. Overriding to a lower value here caused stack overflows (freeze). +CONFIG_NET_RX_STACK_SIZE=2048