From 8b948e2df2ac2e3aed33784afa58787ff6342472 Mon Sep 17 00:00:00 2001 From: Gints Murans Date: Fri, 14 Nov 2025 02:49:44 +0200 Subject: [PATCH 1/2] [WIP] Added new example and firmware build step that is repeater and have wifi connection and also does mqtt packet forwarding --- examples/simple_repeater_mqtt/MyMesh.cpp | 1350 +++++++++++++++++++ examples/simple_repeater_mqtt/MyMesh.h | 261 ++++ examples/simple_repeater_mqtt/RateLimiter.h | 23 + examples/simple_repeater_mqtt/UITask.cpp | 114 ++ examples/simple_repeater_mqtt/UITask.h | 19 + examples/simple_repeater_mqtt/main.cpp | 142 ++ variants/heltec_v4/platformio.ini | 45 + 7 files changed, 1954 insertions(+) create mode 100644 examples/simple_repeater_mqtt/MyMesh.cpp create mode 100644 examples/simple_repeater_mqtt/MyMesh.h create mode 100644 examples/simple_repeater_mqtt/RateLimiter.h create mode 100644 examples/simple_repeater_mqtt/UITask.cpp create mode 100644 examples/simple_repeater_mqtt/UITask.h create mode 100644 examples/simple_repeater_mqtt/main.cpp diff --git a/examples/simple_repeater_mqtt/MyMesh.cpp b/examples/simple_repeater_mqtt/MyMesh.cpp new file mode 100644 index 0000000000..d71da82617 --- /dev/null +++ b/examples/simple_repeater_mqtt/MyMesh.cpp @@ -0,0 +1,1350 @@ +#include "MyMesh.h" +#include + +/* ------------------------------ Config -------------------------------- */ + +#ifndef LORA_FREQ + #define LORA_FREQ 915.0 +#endif +#ifndef LORA_BW + #define LORA_BW 250 +#endif +#ifndef LORA_SF + #define LORA_SF 10 +#endif +#ifndef LORA_CR + #define LORA_CR 5 +#endif +#ifndef LORA_TX_POWER + #define LORA_TX_POWER 20 +#endif + +#ifndef ADVERT_NAME + #define ADVERT_NAME "repeater" +#endif +#ifndef ADVERT_LAT + #define ADVERT_LAT 0.0 +#endif +#ifndef ADVERT_LON + #define ADVERT_LON 0.0 +#endif + +#ifndef ADMIN_PASSWORD + #define ADMIN_PASSWORD "password" +#endif + +#ifndef SERVER_RESPONSE_DELAY + #define SERVER_RESPONSE_DELAY 300 +#endif + +#ifndef TXT_ACK_DELAY + #define TXT_ACK_DELAY 200 +#endif + +#define FIRMWARE_VER_LEVEL 1 + +#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS +#define REQ_TYPE_KEEP_ALIVE 0x02 +#define REQ_TYPE_GET_TELEMETRY_DATA 0x03 +#define REQ_TYPE_GET_ACCESS_LIST 0x05 +#define REQ_TYPE_GET_NEIGHBOURS 0x06 + +#define RESP_SERVER_LOGIN_OK 0 // response to ANON_REQ + +#define CLI_REPLY_DELAY_MILLIS 600 + +#define LAZY_CONTACTS_WRITE_DELAY 5000 + +// MQTT defaults +#ifdef WITH_MQTT_BRIDGE + #include + + // Fallbacks if you forget to define them in build_flags + #ifndef MQTT_DEFAULT_HOST + #define MQTT_DEFAULT_HOST "192.168.88.10" + #endif + #ifndef MQTT_DEFAULT_PORT + #define MQTT_DEFAULT_PORT 1883 + #endif + #ifndef MQTT_DEFAULT_USERNAME + #define MQTT_DEFAULT_USERNAME "mqttuser" + #endif + #ifndef MQTT_DEFAULT_PASSWORD + #define MQTT_DEFAULT_PASSWORD "mqttpassword" + #endif + #ifndef MQTT_DEFAULT_CLIENT_ID + #define MQTT_DEFAULT_CLIENT_ID "heltec-v4-mqtt-repeater" + #endif + + #ifndef MQTT_CLI_TOPIC_DOWN_BASE + #define MQTT_CLI_TOPIC_DOWN_BASE "mesh/cli/down" + #endif + #ifndef MQTT_CLI_TOPIC_UP_BASE + #define MQTT_CLI_TOPIC_UP_BASE "mesh/cli/up" + #endif + + #ifndef MQTT_BACKBONE_TOPIC + #define MQTT_BACKBONE_TOPIC "mesh/backbone/raw" + #endif + + bool _backbone_ingress = false; + + // Trampoline to convert C callback into MyMesh::onMqttMessage + static MyMesh* g_mqtt_owner = nullptr; + static void mqttCallbackTrampoline(char* topic, uint8_t* payload, unsigned int length) { + if (g_mqtt_owner) { + g_mqtt_owner->onMqttMessage(topic, payload, length); + } + } +#endif + +void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { +#if MAX_NEIGHBOURS // check if neighbours enabled + // find existing neighbour, else use least recently updated + uint32_t oldest_timestamp = 0xFFFFFFFF; + NeighbourInfo *neighbour = &neighbours[0]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + // if neighbour already known, we should update it + if (id.matches(neighbours[i].id)) { + neighbour = &neighbours[i]; + break; + } + + // otherwise we should update the least recently updated neighbour + if (neighbours[i].heard_timestamp < oldest_timestamp) { + neighbour = &neighbours[i]; + oldest_timestamp = neighbour->heard_timestamp; + } + } + + // update neighbour info + neighbour->id = id; + neighbour->advert_timestamp = timestamp; + neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); + neighbour->snr = (int8_t)(snr * 4); +#endif +} + +uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data) { + ClientInfo* client = NULL; + if (data[0] == 0) { // blank password, just check if sender is in ACL + client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + if (client == NULL) { + #if MESH_DEBUG + MESH_DEBUG_PRINTLN("Login, sender not in ACL"); + #endif + } + } + if (client == NULL) { + uint8_t perms; + if (strcmp((char *)data, _prefs.password) == 0) { // check for valid admin password + perms = PERM_ACL_ADMIN; + } else if (strcmp((char *)data, _prefs.guest_password) == 0) { // check guest password + perms = PERM_ACL_GUEST; + } else { +#if MESH_DEBUG + MESH_DEBUG_PRINTLN("Invalid password: %s", data); +#endif + return 0; + } + + client = acl.putClient(sender, 0); // add to contacts (if not already known) + if (sender_timestamp <= client->last_timestamp) { + MESH_DEBUG_PRINTLN("Possible login replay attack!"); + return 0; // FATAL: client table is full -OR- replay attack + } + + MESH_DEBUG_PRINTLN("Login success!"); + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + client->permissions &= ~0x03; + client->permissions |= perms; + memcpy(client->shared_secret, secret, PUB_KEY_SIZE); + + if (perms != PERM_ACL_GUEST) { // keep number of FS writes to a minimum + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } + } + + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp + reply_data[4] = RESP_SERVER_LOGIN_OK; + reply_data[5] = 0; // Legacy: was recommended keep-alive interval (secs / 16) + reply_data[6] = client->isAdmin() ? 1 : 0; + reply_data[7] = client->permissions; + getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness + reply_data[12] = FIRMWARE_VER_LEVEL; // New field + + return 13; // reply length +} + +int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t *payload, size_t payload_len) { + // uint32_t now = getRTCClock()->getCurrentTimeUnique(); + // memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp + memcpy(reply_data, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag') + + if (payload[0] == REQ_TYPE_GET_STATUS) { // guests can also access this now + RepeaterStats stats; + stats.batt_milli_volts = board.getBattMilliVolts(); + stats.curr_tx_queue_len = _mgr->getOutboundCount(0xFFFFFFFF); + stats.noise_floor = (int16_t)_radio->getNoiseFloor(); + stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); + stats.n_packets_recv = radio_driver.getPacketsRecv(); + stats.n_packets_sent = radio_driver.getPacketsSent(); + stats.total_air_time_secs = getTotalAirTime() / 1000; + stats.total_up_time_secs = uptime_millis / 1000; + stats.n_sent_flood = getNumSentFlood(); + stats.n_sent_direct = getNumSentDirect(); + stats.n_recv_flood = getNumRecvFlood(); + stats.n_recv_direct = getNumRecvDirect(); + stats.err_events = _err_flags; + stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4); + stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups(); + stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups(); + stats.total_rx_air_time_secs = getReceiveAirTime() / 1000; + + memcpy(&reply_data[4], &stats, sizeof(stats)); + + return 4 + sizeof(stats); // reply_len + } + if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA) { + uint8_t perm_mask = ~(payload[1]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions + + telemetry.reset(); + telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + // query other sensors -- target specific + if ((sender->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { + perm_mask = 0x00; // just base telemetry allowed + } + sensors.querySensors(perm_mask, telemetry); + + uint8_t tlen = telemetry.getSize(); + memcpy(&reply_data[4], telemetry.getBuffer(), tlen); + return 4 + tlen; // reply_len + } + if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { + uint8_t res1 = payload[1]; // reserved for future (extra query params) + uint8_t res2 = payload[2]; + if (res1 == 0 && res2 == 0) { + uint8_t ofs = 4; + for (int i = 0; i < acl.getNumClients() && ofs + 7 <= sizeof(reply_data) - 4; i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; // skip deleted entries + memcpy(&reply_data[ofs], c->id.pub_key, 6); ofs += 6; // just 6-byte pub_key prefix + reply_data[ofs++] = c->permissions; + } + return ofs; + } + } + if (payload[0] == REQ_TYPE_GET_NEIGHBOURS) { + uint8_t request_version = payload[1]; + if (request_version == 0) { + + // reply data offset (after response sender_timestamp/tag) + int reply_offset = 4; + + // get request params + uint8_t count = payload[2]; // how many neighbours to fetch (0-255) + uint16_t offset; + memcpy(&offset, &payload[3], 2); // offset from start of neighbours list (0-65535) + uint8_t order_by = payload[5]; // how to order neighbours. 0=newest_to_oldest, 1=oldest_to_newest, 2=strongest_to_weakest, 3=weakest_to_strongest + uint8_t pubkey_prefix_length = payload[6]; // how many bytes of neighbour pub key we want + // we also send a 4 byte random blob in payload[7...10] to help packet uniqueness + + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS count=%d, offset=%d, order_by=%d, pubkey_prefix_length=%d", count, offset, order_by, pubkey_prefix_length); + + // clamp pub key prefix length to max pub key length + if(pubkey_prefix_length > PUB_KEY_SIZE){ + pubkey_prefix_length = PUB_KEY_SIZE; + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS invalid pubkey_prefix_length=%d clamping to %d", pubkey_prefix_length, PUB_KEY_SIZE); + } + + // create copy of neighbours list, skipping empty entries so we can sort it separately from main list + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + auto neighbour = &neighbours[i]; + if (neighbour->heard_timestamp > 0) { + sorted_neighbours[neighbours_count] = neighbour; + neighbours_count++; + } + } + + // sort neighbours based on order + if (order_by == 0) { + // sort by newest to oldest + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting newest to oldest"); + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; // desc + }); + } else if (order_by == 1) { + // sort by oldest to newest + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting oldest to newest"); + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp < b->heard_timestamp; // asc + }); + } else if (order_by == 2) { + // sort by strongest to weakest + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting strongest to weakest"); + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->snr > b->snr; // desc + }); + } else if (order_by == 3) { + // sort by weakest to strongest + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS sorting weakest to strongest"); + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->snr < b->snr; // asc + }); + } + + // build results buffer + int results_count = 0; + int results_offset = 0; + uint8_t results_buffer[130]; + for(int index = 0; index < count && index + offset < neighbours_count; index++){ + + // stop if we can't fit another entry in results + int entry_size = pubkey_prefix_length + 4 + 1; + if(results_offset + entry_size > sizeof(results_buffer)){ + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS no more entries can fit in results buffer"); + break; + } + + // add next neighbour to results + auto neighbour = sorted_neighbours[index + offset]; + uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + memcpy(&results_buffer[results_offset], neighbour->id.pub_key, pubkey_prefix_length); results_offset += pubkey_prefix_length; + memcpy(&results_buffer[results_offset], &heard_seconds_ago, 4); results_offset += 4; + memcpy(&results_buffer[results_offset], &neighbour->snr, 1); results_offset += 1; + results_count++; + + } + + // build reply + MESH_DEBUG_PRINTLN("REQ_TYPE_GET_NEIGHBOURS neighbours_count=%d results_count=%d", neighbours_count, results_count); + memcpy(&reply_data[reply_offset], &neighbours_count, 2); reply_offset += 2; + memcpy(&reply_data[reply_offset], &results_count, 2); reply_offset += 2; + memcpy(&reply_data[reply_offset], &results_buffer, results_offset); reply_offset += results_offset; + + return reply_offset; + } + } + return 0; // unknown command +} + +mesh::Packet *MyMesh::createSelfAdvert() { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_REPEATER, app_data); + + return createAdvert(self_id, app_data, app_data_len); +} + +File MyMesh::openAppend(const char *fname) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return _fs->open(fname, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + return _fs->open(fname, "a"); +#else + return _fs->open(fname, "a", true); +#endif +} + +bool MyMesh::allowPacketForward(const mesh::Packet *packet) { + if (_prefs.disable_fwd) return false; + if (packet->isRouteFlood() && packet->path_len >= _prefs.flood_max) return false; + if (packet->isRouteFlood() && recv_pkt_region == NULL) { + MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); + return false; + } + return true; +} + +const char *MyMesh::getLogDateTime() { + static char tmp[32]; + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(tmp, "%02d:%02d:%02d - %d/%d/%d U", dt.hour(), dt.minute(), dt.second(), dt.day(), dt.month(), + dt.year()); + return tmp; +} + +void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { +#ifdef WITH_MQTT_BRIDGE + // Don’t mirror packets that came from MQTT backbone + if (_backbone_ingress) { + return; + } +#endif + +#if MESH_PACKET_LOGGING + Serial.print(getLogDateTime()); + Serial.print(" RAW: "); + mesh::Utils::printHex(Serial, raw, len); + Serial.println(); +#endif +} + +void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef WITH_BRIDGE + if (_prefs.bridge_pkt_src == 1) { + bridge.sendPacket(pkt); + } +#endif + +#ifdef WITH_MQTT_BRIDGE + publishMeshPacket(pkt); +#endif + + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d", len, + pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)_radio->getLastSNR(), (int)_radio->getLastRSSI(), (int)(score * 1000)); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + f.printf(" [%02X -> %02X]\n", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } else { + f.printf("\n"); + } + f.close(); + } + } +} + +void MyMesh::logTx(mesh::Packet *pkt, int len) { +#ifdef WITH_BRIDGE + if (_prefs.bridge_pkt_src == 0) { + bridge.sendPacket(pkt); + } +#endif + + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": TX, len=%d (type=%d, route=%s, payload_len=%d)", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + + if (pkt->getPayloadType() == PAYLOAD_TYPE_PATH || pkt->getPayloadType() == PAYLOAD_TYPE_REQ || + pkt->getPayloadType() == PAYLOAD_TYPE_RESPONSE || pkt->getPayloadType() == PAYLOAD_TYPE_TXT_MSG) { + f.printf(" [%02X -> %02X]\n", (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } else { + f.printf("\n"); + } + f.close(); + } + } +} + +void MyMesh::logTxFail(mesh::Packet *pkt, int len) { + if (_logging) { + File f = openAppend(PACKET_LOG_FILE); + if (f) { + f.print(getLogDateTime()); + f.printf(": TX FAIL!, len=%d (type=%d, route=%s, payload_len=%d)\n", len, pkt->getPayloadType(), + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len); + f.close(); + } + } +} + +int MyMesh::calcRxDelay(float score, uint32_t air_time) const { + if (_prefs.rx_delay_base <= 0.0f) return 0; + return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +} + +uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { + uint32_t t = (_radio->getEstAirtimeFor(packet->path_len + packet->payload_len + 2) * _prefs.tx_delay_factor); + return getRNG()->nextInt(0, 5*t + 1); +} +uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { + uint32_t t = (_radio->getEstAirtimeFor(packet->path_len + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); + return getRNG()->nextInt(0, 5*t + 1); +} + +bool MyMesh::filterRecvFloodPacket(mesh::Packet* pkt) { + // just try to determine region for packet (apply later in allowPacketForward()) + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = NULL; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } + } else { + recv_pkt_region = NULL; + } + // do normal processing + return false; +} + +void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, + uint8_t *data, size_t len) { + if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin + // client (unknown at this stage) + uint32_t timestamp; + memcpy(×tamp, data, 4); + + data[len] = 0; // ensure null terminator + uint8_t reply_len; + if (data[4] == 0 || data[4] >= ' ') { // is password, ie. a login request + reply_len = handleLoginReq(sender, secret, timestamp, &data[4]); + //} else if (data[4] == ANON_REQ_TYPE_*) { // future type codes + // TODO + } else { + reply_len = 0; // unknown request type + } + + if (reply_len == 0) return; // invalid request + + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response + mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + if (path) sendFlood(path, SERVER_RESPONSE_DELAY); + } else { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply) sendFlood(reply, SERVER_RESPONSE_DELAY); + } + } +} + +int MyMesh::searchPeersByHash(const uint8_t *hash) { + int n = 0; + for (int i = 0; i < acl.getNumClients(); i++) { + if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; +} + +void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } +} + +static bool isShare(const mesh::Packet *packet) { + if (packet->hasTransportCodes()) { + return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' + } + return false; +} + +void MyMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, + const uint8_t *app_data, size_t app_data_len) { + mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); // chain to super impl + + // if this a zero hop advert (and not via 'Share'), add it to neighbours + if (packet->path_len == 0 && !isShare(packet)) { + AdvertDataParser parser(app_data, app_data_len); + if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { // just keep neigbouring Repeaters + putNeighbour(id, timestamp, packet->getSNR()); + } + } +} + +void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, + uint8_t *data, size_t len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) + MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i); + return; + } + ClientInfo* client = acl.getClientByIdx(i); + + if (type == PAYLOAD_TYPE_REQ) { // request (from a Known admin client!) + uint32_t timestamp; + memcpy(×tamp, data, 4); + + if (timestamp > client->last_timestamp) { // prevent replay attacks + int reply_len = handleRequest(client, timestamp, &data[4], len - 4); + if (reply_len == 0) return; // invalid command + + client->last_timestamp = timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response + mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + if (path) sendFlood(path, SERVER_RESPONSE_DELAY); + } else { + mesh::Packet *reply = + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + if (reply) { + if (client->out_path_len >= 0) { // we have an out_path, so send DIRECT + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFlood(reply, SERVER_RESPONSE_DELAY); + } + } + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + } + } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && client->isAdmin()) { // a CLI command + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) + uint flags = (data[4] >> 2); // message attempt number, and other flags + + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA)) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported text type received: flags=%02x", (uint32_t)flags); + } else if (sender_timestamp >= client->last_timestamp) { // prevent replay attacks + bool is_retry = (sender_timestamp == client->last_timestamp); + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator + + if (flags == TXT_TYPE_PLAIN) { // for legacy CLI, send Acks + uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove + // to sender that we got it + mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + strlen((char *)&data[5]), client->id.pub_key, + PUB_KEY_SIZE); + + mesh::Packet *ack = createAck(ack_hash); + if (ack) { + if (client->out_path_len < 0) { + sendFlood(ack, TXT_ACK_DELAY); + } else { + sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY); + } + } + } + + uint8_t temp[166]; + char *command = (char *)&data[5]; + char *reply = (char *)&temp[5]; + if (is_retry) { + *reply = 0; + } else { + handleCommand(sender_timestamp, command, reply); + } + int text_len = strlen(reply); + if (text_len > 0) { + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == sender_timestamp) { + // WORKAROUND: the two timestamps need to be different, in the CLI view + timestamp++; + } + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN + + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + if (reply) { + if (client->out_path_len < 0) { + sendFlood(reply, CLI_REPLY_DELAY_MILLIS); + } else { + sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); + } + } + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + } + } +} + +bool MyMesh::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) { + // TODO: prevent replay attacks + int i = matching_peer_indexes[sender_idx]; + + if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) + MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); + auto client = acl.getClientByIdx(i); + + memcpy(client->out_path, path, client->out_path_len = path_len); // store a copy of path, for sendDirect() + client->last_activity = getRTCClock()->getCurrentTime(); + } else { + MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); + } + + // NOTE: no reciprocal path send!! + return false; +} + +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + +void MyMesh::onControlDataRecv(mesh::Packet* packet) { + uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits + if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && discover_limiter.allow(rtc_clock.getCurrentTime())) { + int i = 1; + uint8_t filter = packet->payload[i++]; + uint32_t tag; + memcpy(&tag, &packet->payload[i], 4); i += 4; + uint32_t since; + if (packet->payload_len >= i+4) { // optional since field + memcpy(&since, &packet->payload[i], 4); i += 4; + } else { + since = 0; + } + + if ((filter & (1 << ADV_TYPE_REPEATER)) != 0 && _prefs.discovery_mod_timestamp >= since) { + bool prefix_only = packet->payload[0] & 1; + uint8_t data[6 + PUB_KEY_SIZE]; + data[0] = CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER; // low 4-bits for node type + data[1] = packet->_snr; // let sender know the inbound SNR ( x 4) + memcpy(&data[2], &tag, 4); // include tag from request, for client to match to + memcpy(&data[6], self_id.pub_key, PUB_KEY_SIZE); + auto resp = createControlData(data, prefix_only ? 6 + 8 : 6 + PUB_KEY_SIZE); + if (resp) { + sendZeroHop(resp, getRetransmitDelay(resp)*4); // apply random delay (widened x4), as multiple nodes can respond to this + } + } + } +} + +MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, + mesh::RTCClock &rtc, mesh::MeshTables &tables) + : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), + _cli(board, rtc, sensors, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), region_map(key_store), temp_map(key_store), + discover_limiter(4, 120) // max 4 every 2 minutes +#if defined(WITH_RS232_BRIDGE) + , bridge(&_prefs, WITH_RS232_BRIDGE, _mgr, &rtc) +#endif +#if defined(WITH_ESPNOW_BRIDGE) + , bridge(&_prefs, _mgr, &rtc) +#endif +{ + last_millis = 0; + uptime_millis = 0; + next_local_advert = next_flood_advert = 0; + dirty_contacts_expiry = 0; + set_radio_at = revert_radio_at = 0; + _logging = false; + region_load_active = false; + +#if MAX_NEIGHBOURS + memset(neighbours, 0, sizeof(neighbours)); +#endif + + // defaults + memset(&_prefs, 0, sizeof(_prefs)); + _prefs.airtime_factor = 1.0; // one half + _prefs.rx_delay_base = 0.0f; // turn off by default, was 10.0; + _prefs.tx_delay_factor = 0.5f; // was 0.25f + _prefs.direct_tx_delay_factor = 0.2f; // was zero + StrHelper::strncpy(_prefs.node_name, ADVERT_NAME, sizeof(_prefs.node_name)); + _prefs.node_lat = ADVERT_LAT; + _prefs.node_lon = ADVERT_LON; + StrHelper::strncpy(_prefs.password, ADMIN_PASSWORD, sizeof(_prefs.password)); + _prefs.freq = LORA_FREQ; + _prefs.sf = LORA_SF; + _prefs.bw = LORA_BW; + _prefs.cr = LORA_CR; + _prefs.tx_power_dbm = LORA_TX_POWER; + _prefs.advert_interval = 1; // default to 2 minutes for NEW installs + _prefs.flood_advert_interval = 12; // 12 hours + _prefs.flood_max = 64; + _prefs.interference_threshold = 0; // disabled + + // bridge defaults + _prefs.bridge_enabled = 1; // enabled + _prefs.bridge_delay = 500; // milliseconds + _prefs.bridge_pkt_src = 0; // logTx + _prefs.bridge_baud = 115200; // baud rate + _prefs.bridge_channel = 1; // channel 1 + + StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); + + // GPS defaults + _prefs.gps_enabled = 0; + _prefs.gps_interval = 0; + _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + + // MQTT defaults +#ifdef WITH_MQTT_BRIDGE + _mqtt_cfg.host = MQTT_DEFAULT_HOST; + _mqtt_cfg.port = MQTT_DEFAULT_PORT; + _mqtt_cfg.username = MQTT_DEFAULT_USERNAME; + _mqtt_cfg.password = MQTT_DEFAULT_PASSWORD; + _mqtt_cfg.client_id = MQTT_DEFAULT_CLIENT_ID; + _mqtt_last_connect_attempt = 0; + + g_mqtt_owner = this; +#endif +} + +void MyMesh::begin(FILESYSTEM *fs) { + mesh::Mesh::begin(); + _fs = fs; + // load persisted prefs + _cli.loadPrefs(_fs); + acl.load(_fs); + // TODO: key_store.begin(); + region_map.load(_fs); + +#if defined(WITH_BRIDGE) + if (_prefs.bridge_enabled) { + bridge.begin(); + } +#endif + +// MQTT +#ifdef WITH_MQTT_BRIDGE + // Build per-node MQTT CLI topics using first 4 bytes of pubkey as hex + char hex_id[9]; + mesh::Utils::toHex(hex_id, self_id.pub_key, 4); // 8 chars + hex_id[8] = 0; + + _mqtt_topic_cli_down = String(MQTT_CLI_TOPIC_DOWN_BASE) + "/" + hex_id; + _mqtt_topic_cli_up = String(MQTT_CLI_TOPIC_UP_BASE) + "/" + hex_id; + _mqtt_topic_backbone = MQTT_BACKBONE_TOPIC; +#endif + + radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + radio_set_tx_power(_prefs.tx_power_dbm); + + updateAdvertTimer(); + updateFloodAdvertTimer(); + +#if ENV_INCLUDE_GPS == 1 + applyGpsPrefs(); +#endif +} + +void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params + pending_freq = freq; + pending_bw = bw; + pending_sf = sf; + pending_cr = cr; + + revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params +} + +bool MyMesh::formatFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.format(); +#elif defined(RP2040_PLATFORM) + return LittleFS.format(); +#elif defined(ESP32) + return SPIFFS.format(); +#else +#error "need to implement file system erase" + return false; +#endif +} + +void MyMesh::sendSelfAdvertisement(int delay_millis) { + mesh::Packet *pkt = createSelfAdvert(); + if (pkt) { + sendFlood(pkt, delay_millis); + } else { + MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); + } +} + +void MyMesh::updateAdvertTimer() { + if (_prefs.advert_interval > 0) { // schedule local advert timer + next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + } else { + next_local_advert = 0; // stop the timer + } +} + +void MyMesh::updateFloodAdvertTimer() { + if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer + next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); + } else { + next_flood_advert = 0; // stop the timer + } +} + +void MyMesh::dumpLogFile() { +#if defined(RP2040_PLATFORM) + File f = _fs->open(PACKET_LOG_FILE, "r"); +#else + File f = _fs->open(PACKET_LOG_FILE); +#endif + if (f) { + while (f.available()) { + int c = f.read(); + if (c < 0) break; + Serial.print((char)c); + } + f.close(); + } +} + +void MyMesh::setTxPower(uint8_t power_dbm) { + radio_set_tx_power(power_dbm); +} + +void MyMesh::formatNeighborsReply(char *reply) { + char *dp = reply; + +#if MAX_NEIGHBOURS + // create copy of neighbours list, skipping empty entries so we can sort it separately from main list + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + auto neighbour = &neighbours[i]; + if (neighbour->heard_timestamp > 0) { + sorted_neighbours[neighbours_count] = neighbour; + neighbours_count++; + } + } + + // sort neighbours newest to oldest + std::sort(sorted_neighbours, sorted_neighbours + neighbours_count, [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; // desc + }); + + for (int i = 0; i < neighbours_count && dp - reply < 134; i++) { + NeighbourInfo *neighbour = sorted_neighbours[i]; + + // add new line if not first item + if (i > 0) *dp++ = '\n'; + + char hex[10]; + // get 4 bytes of neighbour id as hex + mesh::Utils::toHex(hex, neighbour->id.pub_key, 4); + + // add next neighbour + uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + sprintf(dp, "%s:%d:%d", hex, secs_ago, neighbour->snr); + while (*dp) + dp++; // find end of string + } +#endif + if (dp == reply) { // no neighbours, need empty response + strcpy(dp, "-none-"); + dp += 6; + } + *dp = 0; // null terminator +} + +void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + NeighbourInfo *neighbour = &neighbours[i]; + if (memcmp(neighbour->id.pub_key, pubkey, key_len) == 0) { + neighbours[i] = NeighbourInfo(); // clear neighbour entry + } + } +#endif +} + +void MyMesh::formatStatsReply(char *reply) { + StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +} + +void MyMesh::formatRadioStatsReply(char *reply) { + StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +} + +void MyMesh::formatPacketStatsReply(char *reply) { + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect()); +} + +void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { + self_id = new_id; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + IdentityStore store(*_fs, ""); +#elif defined(ESP32) + IdentityStore store(*_fs, "/identity"); +#elif defined(RP2040_PLATFORM) + IdentityStore store(*_fs, "/identity"); +#else +#error "need to define saveIdentity()" +#endif + store.save("_main", self_id); +} + +void MyMesh::clearStats() { + radio_driver.resetStats(); + resetStats(); + ((SimpleMeshTables *)getTables())->resetStats(); +} + +void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { + if (region_load_active) { + if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation + region_map = temp_map; // copy over the temp instance as new current map + region_load_active = false; + + sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + } else { + char *np = command; + while (*np == ' ') np++; // skip indent + int indent = np - command; + + char *ep = np; + while (RegionMap::is_name_char(*ep)) ep++; + if (*ep) { *ep++ = 0; } // set null terminator for end of name + + while (*ep && *ep != 'F') ep++; // look for (optional) flags + + if (indent > 0 && indent < 8 && strlen(np) > 0) { + auto parent = load_stack[indent - 1]; + if (parent) { + auto old = region_map.findByName(np); + auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0); // carry-over the current ID (if name already exists) + if (nw) { + nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD); // carry-over flags from curr + + load_stack[indent] = nw; // keep pointers to parent regions, to resolve parent_id's + } + } + } + reply[0] = 0; + } + return; + } + + while (*command == ' ') command++; // skip leading spaces + + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) + memcpy(reply, command, 3); // reflect the prefix back + reply += 3; + command += 3; + } + + // handle ACL related commands + if (memcmp(command, "setperm ", 8) == 0) { // format: setperm {pubkey-hex} {permissions-int8} + char* hex = &command[8]; + char* sp = strchr(hex, ' '); // look for separator char + if (sp == NULL) { + strcpy(reply, "Err - bad params"); + } else { + *sp++ = 0; // replace space with null terminator + + uint8_t pubkey[PUB_KEY_SIZE]; + int hex_len = min(sp - hex, PUB_KEY_SIZE*2); + if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + uint8_t perms = atoi(sp); + if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // trigger acl.save() + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - invalid params"); + } + } else { + strcpy(reply, "Err - bad pubkey"); + } + } + } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { + Serial.println("ACL:"); + for (int i = 0; i < acl.getNumClients(); i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; // skip deleted (or guest) entries + + Serial.printf("%02X ", c->permissions); + mesh::Utils::printHex(Serial, c->id.pub_key, PUB_KEY_SIZE); + Serial.printf("\n"); + } + reply[0] = 0; + } else if (memcmp(command, "region", 6) == 0) { + reply[0] = 0; + + const char* parts[4]; + int n = mesh::Utils::parseTextParts(command, parts, 4, ' '); + if (n == 1 && sender_timestamp == 0) { + region_map.exportTo(Serial); + } else if (n >= 2 && strcmp(parts[1], "load") == 0) { + temp_map.resetFrom(region_map); // rebuild regions in a temp instance + memset(load_stack, 0, sizeof(load_stack)); + load_stack[0] = &temp_map.getWildcard(); + region_load_active = true; + } else if (n >= 2 && strcmp(parts[1], "save") == 0) { + _prefs.discovery_mod_timestamp = rtc_clock.getCurrentTime(); // this node is now 'modified' (for discovery info) + savePrefs(); + bool success = region_map.save(_fs); + strcpy(reply, success ? "OK" : "Err - save failed"); + } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) { + auto region = region_map.findByNamePrefix(parts[2]); + if (region) { + region->flags &= ~REGION_DENY_FLOOD; + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) { + auto region = region_map.findByNamePrefix(parts[2]); + if (region) { + region->flags |= REGION_DENY_FLOOD; + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "get") == 0) { + auto region = region_map.findByNamePrefix(parts[2]); + if (region) { + auto parent = region_map.findById(region->parent); + if (parent && parent->id != 0) { + sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); + } else { + sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); + } + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "home") == 0) { + auto home = region_map.findByNamePrefix(parts[2]); + if (home) { + region_map.setHomeRegion(home); + sprintf(reply, " home is now %s", home->name); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n == 2 && strcmp(parts[1], "home") == 0) { + auto home = region_map.getHomeRegion(); + sprintf(reply, " home is %s", home ? home->name : "*"); + } else if (n >= 3 && strcmp(parts[1], "put") == 0) { + auto parent = n >= 4 ? region_map.findByNamePrefix(parts[3]) : ®ion_map.getWildcard(); + if (parent == NULL) { + strcpy(reply, "Err - unknown parent"); + } else { + auto region = region_map.putRegion(parts[2], parent->id); + if (region == NULL) { + strcpy(reply, "Err - unable to put"); + } else { + strcpy(reply, "OK"); + } + } + } else if (n >= 3 && strcmp(parts[1], "remove") == 0) { + auto region = region_map.findByName(parts[2]); + if (region) { + if (region_map.removeRegion(*region)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - not empty"); + } + } else { + strcpy(reply, "Err - not found"); + } + } else { + strcpy(reply, "Err - ??"); + } + } else{ + _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands + } +} + +void MyMesh::loop() { +#ifdef WITH_BRIDGE + bridge.loop(); +#endif + + mesh::Mesh::loop(); + + if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { + mesh::Packet *pkt = createSelfAdvert(); + if (pkt) sendFlood(pkt); + + updateFloodAdvertTimer(); // schedule next flood advert + updateAdvertTimer(); // also schedule local advert (so they don't overlap) + } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { + mesh::Packet *pkt = createSelfAdvert(); + if (pkt) sendZeroHop(pkt); + + updateAdvertTimer(); // schedule next local advert + } + + if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params + set_radio_at = 0; // clear timer + radio_set_params(pending_freq, pending_bw, pending_sf, pending_cr); + MESH_DEBUG_PRINTLN("Temp radio params"); + } + + if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig + revert_radio_at = 0; // clear timer + radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + MESH_DEBUG_PRINTLN("Radio params restored"); + } + + // is pending dirty contacts write needed? + if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { + acl.save(_fs); + dirty_contacts_expiry = 0; + } + + // MQTT +#ifdef WITH_MQTT_BRIDGE + if (_mqtt) { + if (!_mqtt->connected()) { + unsigned long now_ms = millis(); + // simple throttling: one connect attempt every 5 seconds + if (now_ms - _mqtt_last_connect_attempt > 5000) { + _mqtt_last_connect_attempt = now_ms; + + // make sure server config is up-to-date (in case host/port changed) + _mqtt->setServer(_mqtt_cfg.host.c_str(), _mqtt_cfg.port); + + // basic blocking connect; you can refine later + bool ok = _mqtt->connect( + _mqtt_cfg.client_id.c_str(), + _mqtt_cfg.username.c_str(), + _mqtt_cfg.password.c_str() + ); + +#if MESH_DEBUG + if (!ok) { + Serial.print("[MQTT] connect failed, rc="); + Serial.println(_mqtt->state()); + } else { + Serial.println("[MQTT] connected"); + } +#endif + + if (ok && _mqtt_topic_cli_down.length() > 0) { + _mqtt->subscribe(_mqtt_topic_cli_down.c_str()); +#if MESH_DEBUG + Serial.print("[MQTT] subscribed to "); + Serial.println(_mqtt_topic_cli_down); +#endif + } + } + } else { + // Process incoming/outgoing MQTT traffic + _mqtt->loop(); + } + } +#endif + + // update uptime + uint32_t now = millis(); + uptime_millis += now - last_millis; + last_millis = now; +} + +// MQTT +#ifdef WITH_MQTT_BRIDGE +void MyMesh::attachMqttClient(PubSubClient* client) { + _mqtt = client; + _mqtt_last_connect_attempt = 0; + + if (_mqtt) { + _mqtt->setServer(_mqtt_cfg.host.c_str(), _mqtt_cfg.port); + _mqtt->setCallback(mqttCallbackTrampoline); + } +} + +void MyMesh::onMqttMessage(const char* topic, const uint8_t* payload, unsigned int length) { + if (!_mqtt) return; + + // 1) Backbone raw packet bridge + if (_mqtt_topic_backbone.length() > 0 && + strcmp(topic, _mqtt_topic_backbone.c_str()) == 0) { + + if (length < 1 + 8) return; // too short + + uint8_t ver = payload[0]; + if (ver != 1) { +#if MESH_DEBUG + Serial.println("[MQTT] backbone: unsupported version"); +#endif + return; + } + + const uint8_t* src_gateway = &payload[1]; + const uint8_t* pkt_bytes = &payload[1 + 8]; + unsigned int pkt_len = length - (1 + 8); + + // Optional: ignore packets we originally sent + if (memcmp(src_gateway, self_id.pub_key, 8) == 0) { + return; + } + + // Rebuild a mesh::Packet from bytes + mesh::Packet* pkt = obtainNewPacket(); // MeshCore API + if (!pkt) return; + + int consumed = pkt->readFrom(pkt_bytes, pkt_len); // MeshCore API + if (consumed <= 0) { + releasePacket(pkt); + return; + } + + // Inject into local mesh, but do it via onRecvPacket() so all normal processing occurs + _backbone_ingress = true; + uint32_t act = onRecvPacket(pkt); + _backbone_ingress = false; + + if (act == ACTION_RELEASE) { + _mgr->free(pkt); + } else if (act == ACTION_MANUAL_HOLD) { + // sub-class is wanting to manually hold Packet instance, and call releasePacket() at appropriate time + } else { // ACTION_RETRANSMIT* + uint8_t priority = (act >> 24) - 1; + uint32_t _delay = act & 0xFFFFFF; + _mgr->queueOutbound(pkt, priority, futureMillis(_delay)); + } + return; + } + + // 2) CLI over MQTT + if (_mqtt_topic_cli_down.length() > 0 && + strcmp(topic, _mqtt_topic_cli_down.c_str()) == 0) { + + char cmd[160]; + unsigned int len = min(length, (unsigned int)(sizeof(cmd) - 1)); + memcpy(cmd, payload, len); + cmd[len] = 0; + +#if MESH_DEBUG + Serial.print("[MQTT] CLI cmd: "); + Serial.println(cmd); +#endif + + char reply[160]; + handleCommand(0, cmd, reply); + + if (reply[0] && _mqtt_topic_cli_up.length() > 0) { + _mqtt->publish(_mqtt_topic_cli_up.c_str(), reply); +#if MESH_DEBUG + Serial.print("[MQTT] CLI reply -> "); + Serial.print(_mqtt_topic_cli_up); + Serial.print(": "); + Serial.println(reply); +#endif + } + + return; + } +} + +void MyMesh::publishMeshPacket(mesh::Packet* pkt) { + if (!_mqtt || !_mqtt->connected()) return; + if (_mqtt_topic_backbone.length() == 0) return; + + // Serialize mesh::Packet to a temporary buffer + uint8_t buf[256]; + int packet_len = pkt->writeTo(buf); + + if (packet_len <= 0 || packet_len > (int)sizeof(buf)) { +#if MESH_DEBUG + Serial.println("[MQTT] publishMeshPacket: invalid packet_len"); +#endif + return; + } + + // Build MQTT frame: [version][gateway_id][packet_bytes] + uint8_t frame[1 + 8 + sizeof(buf)]; + uint8_t ver = 1; + frame[0] = ver; + + // gateway_id: first 8 bytes of our pubkey + memcpy(&frame[1], self_id.pub_key, 8); + + memcpy(&frame[1 + 8], buf, packet_len); + int frame_len = 1 + 8 + packet_len; + + _mqtt->publish(_mqtt_topic_backbone.c_str(), frame, frame_len); +} +#endif diff --git a/examples/simple_repeater_mqtt/MyMesh.h b/examples/simple_repeater_mqtt/MyMesh.h new file mode 100644 index 0000000000..f8c9cd9f24 --- /dev/null +++ b/examples/simple_repeater_mqtt/MyMesh.h @@ -0,0 +1,261 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #include +#elif defined(RP2040_PLATFORM) + #include +#elif defined(ESP32) + #include +#endif + +#ifdef WITH_RS232_BRIDGE +#include "helpers/bridges/RS232Bridge.h" +#define WITH_BRIDGE +#endif + +#ifdef WITH_ESPNOW_BRIDGE +#include "helpers/bridges/ESPNowBridge.h" +#define WITH_BRIDGE +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "RateLimiter.h" + +#ifdef WITH_MQTT_BRIDGE +class PubSubClient; + +struct MqttConfig { + String host; + uint16_t port; + String username; + String password; + String client_id; +}; +#endif + +#ifdef WITH_BRIDGE +extern AbstractBridge* bridge; +#endif + +struct RepeaterStats { + uint16_t batt_milli_volts; + uint16_t curr_tx_queue_len; + int16_t noise_floor; + int16_t last_rssi; + uint32_t n_packets_recv; + uint32_t n_packets_sent; + uint32_t total_air_time_secs; + uint32_t total_up_time_secs; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + uint16_t err_events; // was 'n_full_events' + int16_t last_snr; // x 4 + uint16_t n_direct_dups, n_flood_dups; + uint32_t total_rx_air_time_secs; +}; + +#ifndef MAX_CLIENTS + #define MAX_CLIENTS 32 +#endif + +struct NeighbourInfo { + mesh::Identity id; + uint32_t advert_timestamp; + uint32_t heard_timestamp; + int8_t snr; // multiplied by 4, user should divide to get float value +}; + +#ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE "13 Nov 2025" +#endif + +#ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v1.10.0" +#endif + +#define FIRMWARE_ROLE "repeater" + +#define PACKET_LOG_FILE "/packet_log" + +class MyMesh : public mesh::Mesh, public CommonCLICallbacks { + FILESYSTEM* _fs; + uint32_t last_millis; + uint64_t uptime_millis; + unsigned long next_local_advert, next_flood_advert; + bool _logging; + NodePrefs _prefs; + CommonCLI _cli; + uint8_t reply_data[MAX_PACKET_PAYLOAD]; + ClientACL acl; + TransportKeyStore key_store; + RegionMap region_map, temp_map; + RegionEntry* load_stack[8]; + RegionEntry* recv_pkt_region; + RateLimiter discover_limiter; + bool region_load_active; + unsigned long dirty_contacts_expiry; +#if MAX_NEIGHBOURS + NeighbourInfo neighbours[MAX_NEIGHBOURS]; +#endif + CayenneLPP telemetry; + unsigned long set_radio_at, revert_radio_at; + float pending_freq; + float pending_bw; + uint8_t pending_sf; + uint8_t pending_cr; + int matching_peer_indexes[MAX_CLIENTS]; +#if defined(WITH_RS232_BRIDGE) + RS232Bridge bridge; +#elif defined(WITH_ESPNOW_BRIDGE) + ESPNowBridge bridge; +#endif + + // MQTT +#ifdef WITH_MQTT_BRIDGE + PubSubClient* _mqtt = nullptr; + MqttConfig _mqtt_cfg; + unsigned long _mqtt_last_connect_attempt = 0; + + String _mqtt_topic_cli_down; + String _mqtt_topic_cli_up; + String _mqtt_topic_backbone; + + void publishMeshPacket(mesh::Packet* pkt); +#endif + + void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data); + int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); + mesh::Packet* createSelfAdvert(); + + File openAppend(const char* fname); + +protected: + float getAirtimeBudgetFactor() const override { + return _prefs.airtime_factor; + } + + bool allowPacketForward(const mesh::Packet* packet) override; + const char* getLogDateTime() override; + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + + void logRx(mesh::Packet* pkt, int len, float score) override; + void logTx(mesh::Packet* pkt, int len) override; + void logTxFail(mesh::Packet* pkt, int len) override; + int calcRxDelay(float score, uint32_t air_time) const override; + + uint32_t getRetransmitDelay(const mesh::Packet* packet) override; + uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; + + int getInterferenceThreshold() const override { + return _prefs.interference_threshold; + } + int getAGCResetInterval() const override { + return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds + } + uint8_t getExtraAckTransmitCount() const override { + return _prefs.multi_acks; + } + +#if ENV_INCLUDE_GPS == 1 + void applyGpsPrefs() { + sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + } +#endif + + bool filterRecvFloodPacket(mesh::Packet* pkt) override; + + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); + 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; + +public: + MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); + + void begin(FILESYSTEM* fs); + + const char* getFirmwareVer() override { return FIRMWARE_VERSION; } + const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } + const char* getRole() override { return FIRMWARE_ROLE; } + const char* getNodeName() { return _prefs.node_name; } + NodePrefs* getNodePrefs() { + return &_prefs; + } + + void savePrefs() override { + _cli.savePrefs(_fs); + } + + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + bool formatFileSystem() override; + void sendSelfAdvertisement(int delay_millis) override; + void updateAdvertTimer() override; + void updateFloodAdvertTimer() override; + + void setLoggingOn(bool enable) override { _logging = enable; } + + void eraseLogFile() override { + _fs->remove(PACKET_LOG_FILE); + } + + void dumpLogFile() override; + void setTxPower(uint8_t power_dbm) override; + void formatNeighborsReply(char *reply) override; + void removeNeighbor(const uint8_t* pubkey, int key_len) override; + void formatStatsReply(char *reply) override; + void formatRadioStatsReply(char *reply) override; + void formatPacketStatsReply(char *reply) override; + + mesh::LocalIdentity& getSelfId() override { return self_id; } + + void saveIdentity(const mesh::LocalIdentity& new_id) override; + void clearStats() override; + void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void loop(); + +#if defined(WITH_BRIDGE) + void setBridgeState(bool enable) override { + if (enable == bridge.isRunning()) return; + if (enable) + { + bridge.begin(); + } + else + { + bridge.end(); + } + } + + void restartBridge() override { + if (!bridge.isRunning()) return; + bridge.end(); + bridge.begin(); + } +#endif + + // MQTT +#ifdef WITH_MQTT_BRIDGE + void attachMqttClient(PubSubClient* client); + void onMqttMessage(const char* topic, const uint8_t* payload, unsigned int length); +#endif +}; diff --git a/examples/simple_repeater_mqtt/RateLimiter.h b/examples/simple_repeater_mqtt/RateLimiter.h new file mode 100644 index 0000000000..a6633c0a26 --- /dev/null +++ b/examples/simple_repeater_mqtt/RateLimiter.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +class RateLimiter { + uint32_t _start_timestamp; + uint32_t _secs; + uint16_t _maximum, _count; + +public: + RateLimiter(uint16_t maximum, uint32_t secs): _maximum(maximum), _secs(secs), _start_timestamp(0), _count(0) { } + + bool allow(uint32_t now) { + if (now < _start_timestamp + _secs) { + _count++; + if (_count > _maximum) return false; // deny + } else { // time window now expired + _start_timestamp = now; + _count = 1; + } + return true; + } +}; \ No newline at end of file diff --git a/examples/simple_repeater_mqtt/UITask.cpp b/examples/simple_repeater_mqtt/UITask.cpp new file mode 100644 index 0000000000..d096d14b22 --- /dev/null +++ b/examples/simple_repeater_mqtt/UITask.cpp @@ -0,0 +1,114 @@ +#include "UITask.h" +#include +#include + +#define AUTO_OFF_MILLIS 20000 // 20 seconds +#define BOOT_SCREEN_MILLIS 4000 // 4 seconds + +// 'meshcore', 128x13px +static const uint8_t meshcore_logo [] PROGMEM = { + 0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, + 0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe, + 0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc, + 0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00, + 0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00, + 0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, + 0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8, + 0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0, + 0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00, + 0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00, + 0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8, + 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8, + 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, +}; + +void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { + _prevBtnState = HIGH; + _auto_off = millis() + AUTO_OFF_MILLIS; + _node_prefs = node_prefs; + _display->turnOn(); + + // strip off dash and commit hash by changing dash to null terminator + // e.g: v1.2.3-abcdef -> v1.2.3 + char *version = strdup(firmware_version); + char *dash = strchr(version, '-'); + if(dash){ + *dash = 0; + } + + // v1.2.3 (1 Jan 2025) + sprintf(_version_info, "%s (%s)", version, build_date); +} + +void UITask::renderCurrScreen() { + char tmp[80]; + if (millis() < BOOT_SCREEN_MILLIS) { // boot screen + // meshcore logo + _display->setColor(DisplayDriver::BLUE); + int logoWidth = 128; + _display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); + + // version info + _display->setColor(DisplayDriver::LIGHT); + _display->setTextSize(1); + uint16_t versionWidth = _display->getTextWidth(_version_info); + _display->setCursor((_display->width() - versionWidth) / 2, 22); + _display->print(_version_info); + + // node type + const char* node_type = "< Repeater >"; + uint16_t typeWidth = _display->getTextWidth(node_type); + _display->setCursor((_display->width() - typeWidth) / 2, 35); + _display->print(node_type); + } else { // home screen + // node name + _display->setCursor(0, 0); + _display->setTextSize(1); + _display->setColor(DisplayDriver::GREEN); + _display->print(_node_prefs->node_name); + + // freq / sf + _display->setCursor(0, 20); + _display->setColor(DisplayDriver::YELLOW); + sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + _display->print(tmp); + + // bw / cr + _display->setCursor(0, 30); + sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + _display->print(tmp); + } +} + +void UITask::loop() { +#ifdef PIN_USER_BTN + if (millis() >= _next_read) { + int btnState = digitalRead(PIN_USER_BTN); + if (btnState != _prevBtnState) { + if (btnState == LOW) { // pressed? + if (_display->isOn()) { + // TODO: any action ? + } else { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + } + _prevBtnState = btnState; + } + _next_read = millis() + 200; // 5 reads per second + } +#endif + + if (_display->isOn()) { + if (millis() >= _next_refresh) { + _display->startFrame(); + renderCurrScreen(); + _display->endFrame(); + + _next_refresh = millis() + 1000; // refresh every second + } + if (millis() > _auto_off) { + _display->turnOff(); + } + } +} diff --git a/examples/simple_repeater_mqtt/UITask.h b/examples/simple_repeater_mqtt/UITask.h new file mode 100644 index 0000000000..a27259f117 --- /dev/null +++ b/examples/simple_repeater_mqtt/UITask.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +class UITask { + DisplayDriver* _display; + unsigned long _next_read, _next_refresh, _auto_off; + int _prevBtnState; + NodePrefs* _node_prefs; + char _version_info[32]; + + void renderCurrScreen(); +public: + UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; } + void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); + + void loop(); +}; \ No newline at end of file diff --git a/examples/simple_repeater_mqtt/main.cpp b/examples/simple_repeater_mqtt/main.cpp new file mode 100644 index 0000000000..74bc995197 --- /dev/null +++ b/examples/simple_repeater_mqtt/main.cpp @@ -0,0 +1,142 @@ +#include // needed for PlatformIO +#include + +#include "MyMesh.h" + +#ifdef ESP32 + #ifdef WIFI_SSID + #include + #endif + + #ifdef WITH_MQTT_BRIDGE + #include + + WiFiClient mqtt_net; + PubSubClient mqtt_client(mqtt_net); + #endif // WITH_MQTT_BRIDGE +#endif + +#ifdef DISPLAY_CLASS + #include "UITask.h" + static UITask ui_task(display); +#endif + +StdRNG fast_rng; +SimpleMeshTables tables; + +MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); + +void halt() { + while (1) ; +} + +static char command[160]; + +void setup() { + Serial.begin(115200); + delay(1000); + + board.begin(); + +#ifdef DISPLAY_CLASS + if (display.begin()) { + display.startFrame(); + display.setCursor(0, 0); + display.print("Please wait..."); + display.endFrame(); + } +#endif + + if (!radio_init()) { + halt(); + } + + fast_rng.begin(radio_get_rng_seed()); + + FILESYSTEM* fs; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + InternalFS.begin(); + fs = &InternalFS; + IdentityStore store(InternalFS, ""); +#elif defined(ESP32) + SPIFFS.begin(true); + fs = &SPIFFS; + IdentityStore store(SPIFFS, "/identity"); +#elif defined(RP2040_PLATFORM) + LittleFS.begin(); + fs = &LittleFS; + IdentityStore store(LittleFS, "/identity"); + store.begin(); +#else + #error "need to define filesystem" +#endif + if (!store.load("_main", the_mesh.self_id)) { + MESH_DEBUG_PRINTLN("Generating new keypair"); + the_mesh.self_id = radio_new_identity(); // create new random identity + int count = 0; + while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00 || the_mesh.self_id.pub_key[0] == 0xFF)) { // reserved id hashes + the_mesh.self_id = radio_new_identity(); count++; + } + store.save("_main", the_mesh.self_id); + } + + Serial.print("Repeater ID: "); + mesh::Utils::printHex(Serial, the_mesh.self_id.pub_key, PUB_KEY_SIZE); Serial.println(); + + command[0] = 0; + +#ifdef WIFI_SSID + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PWD); +#endif + +#ifdef WITH_MQTT_BRIDGE + the_mesh.attachMqttClient(&mqtt_client); +#endif + + sensors.begin(); + + the_mesh.begin(fs); + +#ifdef DISPLAY_CLASS + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); +#endif + + // send out initial Advertisement to the mesh + the_mesh.sendSelfAdvertisement(16000); +} + +void loop() { + int len = strlen(command); + while (Serial.available() && len < sizeof(command)-1) { + char c = Serial.read(); + if (c != '\n') { + command[len++] = c; + command[len] = 0; + Serial.print(c); + } + if (c == '\r') break; + } + if (len == sizeof(command)-1) { // command buffer full + command[sizeof(command)-1] = '\r'; + } + + if (len > 0 && command[len - 1] == '\r') { // received complete line + Serial.print('\n'); + command[len - 1] = 0; // replace newline with C string null terminator + char reply[160]; + the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! + if (reply[0]) { + Serial.print(" -> "); Serial.println(reply); + } + + command[0] = 0; // reset command buffer + } + + the_mesh.loop(); + sensors.loop(); +#ifdef DISPLAY_CLASS + ui_task.loop(); +#endif + rtc_clock.tick(); +} diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index c26a5bc69d..bc506ef8bf 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -207,3 +207,48 @@ build_src_filter = ${Heltec_lora32_v4.build_src_filter} lib_deps = ${Heltec_lora32_v4.lib_deps} ${esp32_ota.lib_deps} + +[env:heltec_v4_simple_repeater_mqtt] +extends = Heltec_lora32_v4 + +build_flags = + ${Heltec_lora32_v4.build_flags} + ; --- UI / base repeater behaviour --- + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MQTT Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + + ; --- WiFi config (like companion_radio_wifi) --- + -I examples/simple_repeater_mqtt + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + + ; --- MQTT bridge enable + defaults --- + -D WITH_MQTT_BRIDGE=1 + -D MQTT_DEFAULT_HOST='"192.168.88.10"' + -D MQTT_DEFAULT_PORT=1883 + -D MQTT_DEFAULT_USERNAME='"mqttuser"' + -D MQTT_DEFAULT_PASSWORD='"mqttpassword"' + -D MQTT_DEFAULT_CLIENT_ID='"heltec-v4-mqtt-repeater"' + + ; Optional debugging knobs you can uncomment as needed: + ; -D BRIDGE_DEBUG=1 + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 + +build_src_filter = ${Heltec_lora32_v4.build_src_filter} + ; base repeater behaviour, but using a dedicated simple_repeater_mqtt dir + + + + + + + +<../examples/simple_repeater_mqtt/*.cpp> + +lib_deps = + ${Heltec_lora32_v4.lib_deps} + ${esp32_ota.lib_deps} + densaugeo/base64 @ ~1.4.0 + knolleary/PubSubClient @ ^2.8 From c7f8f25d1395cc23bd8ef391de2076193091dd37 Mon Sep 17 00:00:00 2001 From: Gints Murans Date: Fri, 14 Nov 2025 04:22:19 +0200 Subject: [PATCH 2/2] [WIP] Refactor MQTT configuration structure and add support for dynamic topic management --- examples/simple_repeater_mqtt/MyMesh.cpp | 312 ++++++++++++++++++++--- examples/simple_repeater_mqtt/MyMesh.h | 23 +- 2 files changed, 290 insertions(+), 45 deletions(-) diff --git a/examples/simple_repeater_mqtt/MyMesh.cpp b/examples/simple_repeater_mqtt/MyMesh.cpp index d71da82617..cb62760b4d 100644 --- a/examples/simple_repeater_mqtt/MyMesh.cpp +++ b/examples/simple_repeater_mqtt/MyMesh.cpp @@ -76,28 +76,124 @@ #define MQTT_DEFAULT_CLIENT_ID "heltec-v4-mqtt-repeater" #endif - #ifndef MQTT_CLI_TOPIC_DOWN_BASE - #define MQTT_CLI_TOPIC_DOWN_BASE "mesh/cli/down" + #ifndef MQTT_DEFAULT_TOPIC_CLI_DOWN + #define MQTT_DEFAULT_TOPIC_CLI_DOWN "mesh/cli/down" #endif - #ifndef MQTT_CLI_TOPIC_UP_BASE - #define MQTT_CLI_TOPIC_UP_BASE "mesh/cli/up" + #ifndef MQTT_DEFAULT_TOPIC_CLI_UP + #define MQTT_DEFAULT_TOPIC_CLI_UP "mesh/cli/up" #endif - #ifndef MQTT_BACKBONE_TOPIC - #define MQTT_BACKBONE_TOPIC "mesh/backbone/raw" + #ifndef MQTT_DEFAULT_TOPIC_BACKBONE + #define MQTT_DEFAULT_TOPIC_BACKBONE "mesh/backbone/raw" #endif bool _backbone_ingress = false; // Trampoline to convert C callback into MyMesh::onMqttMessage - static MyMesh* g_mqtt_owner = nullptr; + static MyMesh* _g_mqtt_owner = nullptr; static void mqttCallbackTrampoline(char* topic, uint8_t* payload, unsigned int length) { - if (g_mqtt_owner) { - g_mqtt_owner->onMqttMessage(topic, payload, length); + if (_g_mqtt_owner) { + _g_mqtt_owner->onMqttMessage(topic, payload, length); } } #endif + +#ifdef WITH_MQTT_BRIDGE +void MyMesh::addHexIdToTopic(char* dest, const char* base_topic) { + strcpy(dest, base_topic); + strcat(dest, "/"); + char hex_id[9]; + mesh::Utils::toHex(hex_id, self_id.pub_key, 4); // 8 chars + hex_id[8] = 0; + strcat(dest, hex_id); +} + +void MyMesh::loadConfig() { + if (!_fs) { + MESH_DEBUG_PRINTLN("mqtt_cfg: FS not set"); + return; + } + + if (!_fs->exists("/mqtt_cfg")) { + // No file yet → keep defaults you set earlier. + return; + } + +#if defined(RP2040_PLATFORM) + File f = _fs->open("/mqtt_cfg", "r"); +#else + File f = _fs->open("/mqtt_cfg"); +#endif + if (!f) { + MESH_DEBUG_PRINTLN("mqtt_cfg: open failed"); + return; + } + + size_t sz = f.size(); + if (sz == 0) { + f.close(); + return; + } + + if (sz >= sizeof(MqttConfig)) { + // File was written with current struct size → overwrite all + f.read((uint8_t*)&_mqtt_cfg, sizeof(MqttConfig)); + } else { + // Old / smaller struct → overwrite only the first part, keep rest as defaults + f.read((uint8_t*)&_mqtt_cfg, sz); + } + + f.close(); + + // Sanity & null-termination + if (_mqtt_cfg.port == 0) _mqtt_cfg.port = MQTT_DEFAULT_PORT; + + _mqtt_cfg.host[sizeof(_mqtt_cfg.host) - 1] = '\0'; + _mqtt_cfg.user[sizeof(_mqtt_cfg.user) - 1] = '\0'; + _mqtt_cfg.pass[sizeof(_mqtt_cfg.pass) - 1] = '\0'; + _mqtt_cfg.client_id[sizeof(_mqtt_cfg.client_id) - 1] = '\0'; + _mqtt_cfg.topic_cli_down[sizeof(_mqtt_cfg.topic_cli_down) - 1] = '\0'; + _mqtt_cfg.topic_cli_up[sizeof(_mqtt_cfg.topic_cli_up) - 1] = '\0'; + _mqtt_cfg.topic_backbone[sizeof(_mqtt_cfg.topic_backbone) - 1] = '\0'; +} + +void MyMesh::saveConfig() { + if (!_fs) { + MESH_DEBUG_PRINTLN("mqtt_cfg: FS not set"); + return; + } + +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove("/mqtt_cfg"); + File f = _fs->open("/mqtt_cfg", FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = _fs->open("/mqtt_cfg", "w"); +#else + File f = _fs->open("/mqtt_cfg", "w", true); +#endif + + if (!f) { + MESH_DEBUG_PRINTLN("mqtt_cfg: save failed (open)"); + return; + } + + // Clamp / sanity + if (_mqtt_cfg.port == 0) _mqtt_cfg.port = MQTT_DEFAULT_PORT; + + _mqtt_cfg.host[sizeof(_mqtt_cfg.host) - 1] = '\0'; + _mqtt_cfg.user[sizeof(_mqtt_cfg.user) - 1] = '\0'; + _mqtt_cfg.pass[sizeof(_mqtt_cfg.pass) - 1] = '\0'; + _mqtt_cfg.client_id[sizeof(_mqtt_cfg.client_id) - 1] = '\0'; + _mqtt_cfg.topic_cli_down[sizeof(_mqtt_cfg.topic_cli_down) - 1] = '\0'; + _mqtt_cfg.topic_cli_up[sizeof(_mqtt_cfg.topic_cli_up) - 1] = '\0'; + _mqtt_cfg.topic_backbone[sizeof(_mqtt_cfg.topic_backbone) - 1] = '\0'; + + f.write((uint8_t*)&_mqtt_cfg, sizeof(MqttConfig)); + f.close(); +} +#endif + void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { #if MAX_NEIGHBOURS // check if neighbours enabled // find existing neighbour, else use least recently updated @@ -767,14 +863,17 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // MQTT defaults #ifdef WITH_MQTT_BRIDGE - _mqtt_cfg.host = MQTT_DEFAULT_HOST; + _mqtt_cfg.enabled = 1; + StrHelper::strncpy(_mqtt_cfg.host, MQTT_DEFAULT_HOST, sizeof(_mqtt_cfg.host)); _mqtt_cfg.port = MQTT_DEFAULT_PORT; - _mqtt_cfg.username = MQTT_DEFAULT_USERNAME; - _mqtt_cfg.password = MQTT_DEFAULT_PASSWORD; - _mqtt_cfg.client_id = MQTT_DEFAULT_CLIENT_ID; + StrHelper::strncpy(_mqtt_cfg.user, MQTT_DEFAULT_USERNAME, sizeof(_mqtt_cfg.user)); + StrHelper::strncpy(_mqtt_cfg.pass, MQTT_DEFAULT_PASSWORD, sizeof(_mqtt_cfg.pass)); + StrHelper::strncpy(_mqtt_cfg.client_id, MQTT_DEFAULT_CLIENT_ID, sizeof(_mqtt_cfg.client_id)); + StrHelper::strncpy(_mqtt_cfg.topic_cli_down, MQTT_DEFAULT_TOPIC_CLI_DOWN, sizeof(_mqtt_cfg.topic_cli_down)); + StrHelper::strncpy(_mqtt_cfg.topic_cli_up, MQTT_DEFAULT_TOPIC_CLI_UP, sizeof(_mqtt_cfg.topic_cli_up)); + StrHelper::strncpy(_mqtt_cfg.topic_backbone, MQTT_DEFAULT_TOPIC_BACKBONE, sizeof(_mqtt_cfg.topic_backbone)); _mqtt_last_connect_attempt = 0; - - g_mqtt_owner = this; + _g_mqtt_owner = this; #endif } @@ -795,14 +894,9 @@ void MyMesh::begin(FILESYSTEM *fs) { // MQTT #ifdef WITH_MQTT_BRIDGE - // Build per-node MQTT CLI topics using first 4 bytes of pubkey as hex - char hex_id[9]; - mesh::Utils::toHex(hex_id, self_id.pub_key, 4); // 8 chars - hex_id[8] = 0; - - _mqtt_topic_cli_down = String(MQTT_CLI_TOPIC_DOWN_BASE) + "/" + hex_id; - _mqtt_topic_cli_up = String(MQTT_CLI_TOPIC_UP_BASE) + "/" + hex_id; - _mqtt_topic_backbone = MQTT_BACKBONE_TOPIC; + loadConfig(); + addHexIdToTopic(_mqtt_topic_cli_down, _mqtt_cfg.topic_cli_down); + addHexIdToTopic(_mqtt_topic_cli_up, _mqtt_cfg.topic_cli_up); #endif radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); @@ -972,6 +1066,147 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } +#ifdef WITH_MQTT_BRIDGE +bool MyMesh::handleMqttCommand(uint32_t sender_timestamp, char *command, char *reply) { + const char *parts[6]; + int n = mesh::Utils::parseTextParts(command, parts, 6, ' '); + + if (n == 0 || strcmp(parts[0], "mqtt") != 0) { + return false; // not for us + } + + // Just "mqtt" or "mqtt show" → summary + if (n == 1 || (n >= 2 && strcmp(parts[1], "show") == 0)) { + // Short, single-line summary to keep reply small + // (you can also dump more detail to Serial if sender_timestamp == 0) + sprintf(reply, + "mqtt %s %s:%u cli_dn=%s cli_up=%s bb=%s", + _mqtt_cfg.enabled ? "on" : "off", + _mqtt_cfg.host, + (unsigned)_mqtt_cfg.port, + _mqtt_cfg.topic_cli_down, + _mqtt_cfg.topic_cli_up, + _mqtt_cfg.topic_backbone); + + if (sender_timestamp == 0) { + // For USB CLI we can be more verbose + Serial.println("MQTT config:"); + Serial.printf(" enabled: %s\n", _mqtt_cfg.enabled ? "on" : "off"); + Serial.printf(" host : %s:%u\n", _mqtt_cfg.host, (unsigned)_mqtt_cfg.port); + Serial.printf(" user : %s\n", _mqtt_cfg.user); + Serial.printf(" client : %s\n", _mqtt_cfg.client_id); + Serial.printf(" base topics:\n"); + Serial.printf(" cli_down : %s\n", _mqtt_cfg.topic_cli_down); + Serial.printf(" cli_up : %s\n", _mqtt_cfg.topic_cli_up); + Serial.printf(" backbone : %s\n", _mqtt_cfg.topic_backbone); + + Serial.println(" effective topics:"); + Serial.printf(" cli_down_full : %s\n", _mqtt_topic_cli_down); + Serial.printf(" cli_up_full : %s\n", _mqtt_topic_cli_up); + } + + return true; + } + + // "mqtt enable on|off" + if (strcmp(parts[1], "enable") == 0 && n >= 3) { + if (strcmp(parts[2], "on") == 0) { + _mqtt_cfg.enabled = 1; + } else if (strcmp(parts[2], "off") == 0) { + _mqtt_cfg.enabled = 0; + } else { + strcpy(reply, "Err - use 'on' or 'off'"); + return true; + } + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt host " + if (strcmp(parts[1], "host") == 0 && n >= 3) { + StrHelper::strncpy(_mqtt_cfg.host, parts[2], sizeof(_mqtt_cfg.host)); + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt port " + if (strcmp(parts[1], "port") == 0 && n >= 3) { + int p = atoi(parts[2]); + if (p <= 0 || p > 65535) { + strcpy(reply, "Err - bad port"); + return true; + } + _mqtt_cfg.port = (uint16_t)p; + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt user " + if (strcmp(parts[1], "user") == 0 && n >= 3) { + StrHelper::strncpy(_mqtt_cfg.user, parts[2], sizeof(_mqtt_cfg.user)); + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt pass " + if (strcmp(parts[1], "pass") == 0 && n >= 3) { + StrHelper::strncpy(_mqtt_cfg.pass, parts[2], sizeof(_mqtt_cfg.pass)); + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt client " + if (strcmp(parts[1], "client") == 0 && n >= 3) { + StrHelper::strncpy(_mqtt_cfg.client_id, parts[2], sizeof(_mqtt_cfg.client_id)); + saveConfig(); + strcpy(reply, "OK"); + return true; + } + + // "mqtt topic cli_down " + if (strcmp(parts[1], "topic") == 0 && n >= 4) { + if (strcmp(parts[2], "cli_down") == 0) { + StrHelper::strncpy(_mqtt_cfg.topic_cli_down, parts[3], sizeof(_mqtt_cfg.topic_cli_down)); + saveConfig(); + addHexIdToTopic(_mqtt_topic_cli_down, _mqtt_cfg.topic_cli_down); + strcpy(reply, "OK"); + return true; + } else if (strcmp(parts[2], "cli_up") == 0) { + StrHelper::strncpy(_mqtt_cfg.topic_cli_up, parts[3], sizeof(_mqtt_cfg.topic_cli_up)); + saveConfig(); + addHexIdToTopic(_mqtt_topic_cli_up, _mqtt_cfg.topic_cli_up); + strcpy(reply, "OK"); + return true; + } else if (strcmp(parts[2], "backbone") == 0) { + StrHelper::strncpy(_mqtt_cfg.topic_backbone, parts[3], sizeof(_mqtt_cfg.topic_backbone)); + saveConfig(); + strcpy(reply, "OK"); + return true; + } else { + strcpy(reply, "Err - topic cli_down|cli_up|backbone"); + return true; + } + } + + // "mqtt reload" to re-read from FS + if (strcmp(parts[1], "reload") == 0) { + loadConfig(); + addHexIdToTopic(_mqtt_topic_cli_down, _mqtt_cfg.topic_cli_down); + addHexIdToTopic(_mqtt_topic_cli_up, _mqtt_cfg.topic_cli_up); + strcpy(reply, "OK"); + return true; + } + + strcpy(reply, "Err - mqtt ??"); + return true; +} +#endif + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation @@ -1131,6 +1366,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply } else { strcpy(reply, "Err - ??"); } + } else if (memcmp(command, "mqtt", 4) == 0) { +#ifdef WITH_MQTT_BRIDGE + handleMqttCommand(sender_timestamp, command, reply); // MQTT commands +#endif } else{ _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands } @@ -1184,13 +1423,13 @@ void MyMesh::loop() { _mqtt_last_connect_attempt = now_ms; // make sure server config is up-to-date (in case host/port changed) - _mqtt->setServer(_mqtt_cfg.host.c_str(), _mqtt_cfg.port); + _mqtt->setServer(_mqtt_cfg.host, _mqtt_cfg.port); // basic blocking connect; you can refine later bool ok = _mqtt->connect( - _mqtt_cfg.client_id.c_str(), - _mqtt_cfg.username.c_str(), - _mqtt_cfg.password.c_str() + _mqtt_cfg.client_id, + _mqtt_cfg.user, + _mqtt_cfg.pass ); #if MESH_DEBUG @@ -1202,8 +1441,8 @@ void MyMesh::loop() { } #endif - if (ok && _mqtt_topic_cli_down.length() > 0) { - _mqtt->subscribe(_mqtt_topic_cli_down.c_str()); + if (ok) { + _mqtt->subscribe(_mqtt_topic_cli_down); #if MESH_DEBUG Serial.print("[MQTT] subscribed to "); Serial.println(_mqtt_topic_cli_down); @@ -1230,7 +1469,7 @@ void MyMesh::attachMqttClient(PubSubClient* client) { _mqtt_last_connect_attempt = 0; if (_mqtt) { - _mqtt->setServer(_mqtt_cfg.host.c_str(), _mqtt_cfg.port); + _mqtt->setServer(_mqtt_cfg.host, _mqtt_cfg.port); _mqtt->setCallback(mqttCallbackTrampoline); } } @@ -1239,8 +1478,7 @@ void MyMesh::onMqttMessage(const char* topic, const uint8_t* payload, unsigned i if (!_mqtt) return; // 1) Backbone raw packet bridge - if (_mqtt_topic_backbone.length() > 0 && - strcmp(topic, _mqtt_topic_backbone.c_str()) == 0) { + if (strcmp(topic, _mqtt_cfg.topic_backbone) == 0) { if (length < 1 + 8) return; // too short @@ -1289,8 +1527,7 @@ void MyMesh::onMqttMessage(const char* topic, const uint8_t* payload, unsigned i } // 2) CLI over MQTT - if (_mqtt_topic_cli_down.length() > 0 && - strcmp(topic, _mqtt_topic_cli_down.c_str()) == 0) { + if (strcmp(topic, _mqtt_topic_cli_down) == 0) { char cmd[160]; unsigned int len = min(length, (unsigned int)(sizeof(cmd) - 1)); @@ -1305,8 +1542,8 @@ void MyMesh::onMqttMessage(const char* topic, const uint8_t* payload, unsigned i char reply[160]; handleCommand(0, cmd, reply); - if (reply[0] && _mqtt_topic_cli_up.length() > 0) { - _mqtt->publish(_mqtt_topic_cli_up.c_str(), reply); + if (reply[0]) { + _mqtt->publish(_mqtt_topic_cli_up, reply); #if MESH_DEBUG Serial.print("[MQTT] CLI reply -> "); Serial.print(_mqtt_topic_cli_up); @@ -1321,7 +1558,6 @@ void MyMesh::onMqttMessage(const char* topic, const uint8_t* payload, unsigned i void MyMesh::publishMeshPacket(mesh::Packet* pkt) { if (!_mqtt || !_mqtt->connected()) return; - if (_mqtt_topic_backbone.length() == 0) return; // Serialize mesh::Packet to a temporary buffer uint8_t buf[256]; @@ -1345,6 +1581,6 @@ void MyMesh::publishMeshPacket(mesh::Packet* pkt) { memcpy(&frame[1 + 8], buf, packet_len); int frame_len = 1 + 8 + packet_len; - _mqtt->publish(_mqtt_topic_backbone.c_str(), frame, frame_len); + _mqtt->publish(_mqtt_cfg.topic_backbone, frame, frame_len); } #endif diff --git a/examples/simple_repeater_mqtt/MyMesh.h b/examples/simple_repeater_mqtt/MyMesh.h index f8c9cd9f24..46c114f894 100644 --- a/examples/simple_repeater_mqtt/MyMesh.h +++ b/examples/simple_repeater_mqtt/MyMesh.h @@ -41,11 +41,17 @@ class PubSubClient; struct MqttConfig { - String host; + uint8_t enabled; + + char host[64]; uint16_t port; - String username; - String password; - String client_id; + char user[32]; + char pass[32]; + char client_id[32]; + + char topic_cli_down[32]; + char topic_cli_up[32]; + char topic_backbone[32]; }; #endif @@ -132,10 +138,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { MqttConfig _mqtt_cfg; unsigned long _mqtt_last_connect_attempt = 0; - String _mqtt_topic_cli_down; - String _mqtt_topic_cli_up; - String _mqtt_topic_backbone; + char _mqtt_topic_cli_down[64]; + char _mqtt_topic_cli_up[64]; + void addHexIdToTopic(char* dest, const char* base_topic); + void loadConfig(); + void saveConfig(); + bool handleMqttCommand(uint32_t sender_timestamp, char *command, char *reply); void publishMeshPacket(mesh::Packet* pkt); #endif