From b47c565b0d434aa4ece54ce73deda00f39ae2cbe Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 25 Oct 2025 22:37:26 -0700 Subject: [PATCH] Add timezone support to MQTT Bridge and CLI configuration - Introduce timezone settings in NodePrefs for persistent storage - Update CLI commands to get/set timezone and offset - Modify MQTT message builder to utilize timezone for accurate timestamps - Implement timezone handling in MQTTBridge for local time conversion - Include timezone library dependency in platformio.ini --- examples/simple_repeater/MyMesh.cpp | 4 + src/helpers/CommonCLI.cpp | 19 +++ src/helpers/CommonCLI.h | 3 + src/helpers/MQTTMessageBuilder.cpp | 32 +++-- src/helpers/MQTTMessageBuilder.h | 3 + src/helpers/bridges/MQTTBridge.cpp | 177 ++++++++++++++++++++++++++-- src/helpers/bridges/MQTTBridge.h | 5 + variants/heltec_v3/platformio.ini | 1 + 8 files changed, 222 insertions(+), 22 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index da0d04b8..354cb54a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -733,6 +733,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // WiFi defaults StrHelper::strncpy(_prefs.wifi_ssid, "ssid_here", sizeof(_prefs.wifi_ssid)); StrHelper::strncpy(_prefs.wifi_password, "password_here", sizeof(_prefs.wifi_password)); + + // Timezone defaults (Pacific Time with DST support) + StrHelper::strncpy(_prefs.timezone_string, "America/Los_Angeles", sizeof(_prefs.timezone_string)); + _prefs.timezone_offset = -8; // fallback } void MyMesh::begin(FILESYSTEM *fs) { diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f61500dc..44b159d5 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -69,6 +69,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 file.read((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 file.read((uint8_t *)&_prefs->advert_loc_policy, sizeof (_prefs->advert_loc_policy)); // 161 +<<<<<<< HEAD file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 (from upstream) // MQTT settings - skip reading from main prefs file (now stored separately) // For backward compatibility, we'll skip these bytes if they exist in old files @@ -173,6 +174,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 file.write((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 file.write((uint8_t *)&_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161 +<<<<<<< HEAD file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 (from upstream) // MQTT settings - no longer saved here (stored in separate /mqtt_prefs file) // Write zeros/padding to maintain file format compatibility @@ -403,6 +405,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch sprintf(reply, "> %s", _prefs->wifi_ssid); } else if (memcmp(config, "wifi.pwd", 8) == 0) { sprintf(reply, "> %s", _prefs->wifi_password); + } else if (memcmp(config, "timezone", 8) == 0) { + sprintf(reply, "> %s", _prefs->timezone_string); + } else if (memcmp(config, "timezone.offset", 15) == 0) { + sprintf(reply, "> %d", _prefs->timezone_offset); #endif } else { sprintf(reply, "??: %s", config); @@ -634,6 +640,19 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch StrHelper::strncpy(_prefs->wifi_password, &config[9], sizeof(_prefs->wifi_password)); savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "timezone ", 10) == 0) { + StrHelper::strncpy(_prefs->timezone_string, &config[10], sizeof(_prefs->timezone_string)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "timezone.offset ", 17) == 0) { + int8_t offset = _atoi(&config[17]); + if (offset >= -12 && offset <= 14) { + _prefs->timezone_offset = offset; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: timezone offset must be between -12 and +14"); + } #endif } else { sprintf(reply, "unknown config: %s", config); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c8964a26..7307e5f7 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -96,6 +96,7 @@ struct MQTTPrefs { // Timezone settings char timezone_string[32]; // Timezone string (e.g., "America/Los_Angeles") int8_t timezone_offset; // Timezone offset in hours (-12 to +14) - fallback +<<<<<<< HEAD // MQTT server settings char mqtt_server[64]; // MQTT server hostname @@ -108,6 +109,8 @@ struct MQTTPrefs { uint8_t mqtt_analyzer_eu_enabled; // Enable EU analyzer server char mqtt_owner_public_key[65]; // Owner public key (hex string, same length as repeater public key) char mqtt_email[64]; // Owner email address for matching nodes with owners +======= +>>>>>>> 0b761e8 (Add timezone support to MQTT Bridge and CLI configuration) }; #endif diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index 7f4e390f..987042f3 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -1,6 +1,7 @@ #include "MQTTMessageBuilder.h" #include #include +#include int MQTTMessageBuilder::buildStatusMessage( const char* origin, @@ -102,29 +103,36 @@ int MQTTMessageBuilder::buildPacketJSON( bool is_tx, const char* origin, const char* origin_id, + Timezone* timezone, char* buffer, size_t buffer_size ) { if (!packet) return 0; - // Get current device time + // Get current device time (should be UTC since system timezone is set to UTC) time_t now = time(nullptr); - struct tm* timeinfo = localtime(&now); - // Format timestamp in ISO 8601 format + // Convert to local time using timezone library (for timestamp field only) + time_t local_time = timezone ? timezone->toLocal(now) : now; + struct tm* local_timeinfo = localtime(&local_time); + + // Format timestamp in ISO 8601 format (LOCAL TIME) char timestamp[32]; - if (timeinfo) { - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", timeinfo); + if (local_timeinfo) { + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S.000000", local_timeinfo); } else { strcpy(timestamp, "2024-01-01T12:00:00.000000"); } - // Format time and date + // Get UTC time (since system timezone is UTC, time() returns UTC) + struct tm* utc_timeinfo = gmtime(&now); + + // Format time and date (ALWAYS UTC) char time_str[16]; char date_str[16]; - if (timeinfo) { - strftime(time_str, sizeof(time_str), "%H:%M:%S", timeinfo); - strftime(date_str, sizeof(date_str), "%d/%m/%Y", timeinfo); + if (utc_timeinfo) { + strftime(time_str, sizeof(time_str), "%H:%M:%S", utc_timeinfo); + strftime(date_str, sizeof(date_str), "%d/%m/%Y", utc_timeinfo); } else { strcpy(time_str, "12:00:00"); strcpy(date_str, "01/01/2024"); @@ -169,6 +177,7 @@ int MQTTMessageBuilder::buildRawJSON( mesh::Packet* packet, const char* origin, const char* origin_id, + Timezone* timezone, char* buffer, size_t buffer_size ) { @@ -176,7 +185,10 @@ int MQTTMessageBuilder::buildRawJSON( // Get current device time time_t now = time(nullptr); - struct tm* timeinfo = localtime(&now); + + // Convert to local time using timezone library + time_t local_time = timezone ? timezone->toLocal(now) : now; + struct tm* timeinfo = localtime(&local_time); // Format timestamp in ISO 8601 format char timestamp[32]; diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 490e32b9..9b39c712 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -3,6 +3,7 @@ #include "MeshCore.h" #include #include +#include /** * @brief Utility class for building MQTT JSON messages @@ -122,6 +123,7 @@ public: bool is_tx, const char* origin, const char* origin_id, + Timezone* timezone, char* buffer, size_t buffer_size ); @@ -140,6 +142,7 @@ public: mesh::Packet* packet, const char* origin, const char* origin_id, + Timezone* timezone, char* buffer, size_t buffer_size ); diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index c4e8e9e9..27b79154 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -2,6 +2,7 @@ #include "../MQTTMessageBuilder.h" #include #include +#include #ifdef WITH_MQTT_BRIDGE @@ -9,13 +10,14 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCCloc : BridgeBase(prefs, mgr, rtc), _mqtt_client(nullptr), _wifi_client(nullptr), _active_brokers(0), _queue_head(0), _queue_tail(0), _queue_count(0), _last_status_publish(0), _status_interval(300000), // 5 minutes default - _ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false) { + _ntp_client(_ntp_udp, "pool.ntp.org", 0, 60000), _last_ntp_sync(0), _ntp_synced(false), + _timezone(nullptr) { // Initialize default values strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); - strncpy(_iata, "SEA", sizeof(_iata) - 1); + strncpy(_iata, "XXX", sizeof(_iata) - 1); strncpy(_device_id, "DEVICE_ID_PLACEHOLDER", sizeof(_device_id) - 1); - strncpy(_firmware_version, "v1.9.1", sizeof(_firmware_version) - 1); + strncpy(_firmware_version, "unknown", sizeof(_firmware_version) - 1); strncpy(_board_model, "unknown", sizeof(_board_model) - 1); _status_enabled = true; _packets_enabled = true; @@ -319,7 +321,7 @@ void MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx) { // Build packet message int len = MQTTMessageBuilder::buildPacketJSON( - packet, is_tx, _origin, origin_id, json_buffer, sizeof(json_buffer) + packet, is_tx, _origin, origin_id, _timezone, json_buffer, sizeof(json_buffer) ); if (len > 0) { @@ -349,7 +351,7 @@ void MQTTBridge::publishRaw(mesh::Packet* packet) { // Build raw message int len = MQTTMessageBuilder::buildRawJSON( - packet, _origin, origin_id, json_buffer, sizeof(json_buffer) + packet, _origin, origin_id, _timezone, json_buffer, sizeof(json_buffer) ); if (len > 0) { @@ -483,7 +485,11 @@ void MQTTBridge::syncTimeWithNTP() { if (_ntp_client.forceUpdate()) { unsigned long epochTime = _ntp_client.getEpochTime(); - // Update the device's RTC clock + // Set system timezone to UTC first + // This ensures time() returns UTC time + configTime(0, 0, "pool.ntp.org"); + + // Update the device's RTC clock with UTC time if (_rtc) { _rtc->setCurrentTime(epochTime); _ntp_synced = true; @@ -491,12 +497,36 @@ void MQTTBridge::syncTimeWithNTP() { MQTT_DEBUG_PRINTLN("Time synced: %lu", epochTime); - // Show current time - struct tm* timeinfo = localtime((time_t*)&epochTime); - if (timeinfo) { - MQTT_DEBUG_PRINTLN("Current time: %04d-%02d-%02d %02d:%02d:%02d UTC", - timeinfo->tm_year + 1900, timeinfo->tm_mon + 1, timeinfo->tm_mday, - timeinfo->tm_hour, timeinfo->tm_min, timeinfo->tm_sec); + // Set timezone from string (with DST support) + MQTT_DEBUG_PRINTLN("Setting timezone: %s", _prefs->timezone_string); + + // Create timezone object based on timezone string + Timezone* tz = createTimezoneFromString(_prefs->timezone_string); + if (tz) { + MQTT_DEBUG_PRINTLN("Timezone created successfully"); + // Store timezone for later use in message building + _timezone = tz; + } else { + MQTT_DEBUG_PRINTLN("Failed to create timezone, using UTC"); + // Create UTC timezone as fallback + TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; + _timezone = new Timezone(utc, utc); + } + + // Show current time in both UTC and local + struct tm* utc_timeinfo = gmtime((time_t*)&epochTime); + struct tm* local_timeinfo = localtime((time_t*)&epochTime); + + if (utc_timeinfo) { + MQTT_DEBUG_PRINTLN("UTC time: %04d-%02d-%02d %02d:%02d:%02d", + utc_timeinfo->tm_year + 1900, utc_timeinfo->tm_mon + 1, utc_timeinfo->tm_mday, + utc_timeinfo->tm_hour, utc_timeinfo->tm_min, utc_timeinfo->tm_sec); + } + + if (local_timeinfo) { + MQTT_DEBUG_PRINTLN("Local time: %04d-%02d-%02d %02d:%02d:%02d", + local_timeinfo->tm_year + 1900, local_timeinfo->tm_mon + 1, local_timeinfo->tm_mday, + local_timeinfo->tm_hour, local_timeinfo->tm_min, local_timeinfo->tm_sec); } } else { MQTT_DEBUG_PRINTLN("No RTC clock available for time sync"); @@ -508,4 +538,127 @@ void MQTTBridge::syncTimeWithNTP() { _ntp_client.end(); } +Timezone* MQTTBridge::createTimezoneFromString(const char* tz_string) { + // Create Timezone objects for common IANA timezone strings + // Using TimeChangeRule definitions for proper DST handling + + // North America + if (strcmp(tz_string, "America/Los_Angeles") == 0 || strcmp(tz_string, "America/Vancouver") == 0) { + TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; // UTC-8 + TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; // UTC-7 + return new Timezone(pdt, pst); + } else if (strcmp(tz_string, "America/Denver") == 0) { + TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; // UTC-7 + TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; // UTC-6 + return new Timezone(mdt, mst); + } else if (strcmp(tz_string, "America/Chicago") == 0) { + TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; // UTC-6 + TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; // UTC-5 + return new Timezone(cdt, cst); + } else if (strcmp(tz_string, "America/New_York") == 0 || strcmp(tz_string, "America/Toronto") == 0) { + TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; // UTC-5 + TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; // UTC-4 + return new Timezone(edt, est); + } else if (strcmp(tz_string, "America/Anchorage") == 0) { + TimeChangeRule akst = {"AKST", First, Sun, Nov, 2, -540}; // UTC-9 + TimeChangeRule akdt = {"AKDT", Second, Sun, Mar, 2, -480}; // UTC-8 + return new Timezone(akdt, akst); + } else if (strcmp(tz_string, "Pacific/Honolulu") == 0) { + TimeChangeRule hst = {"HST", Last, Sun, Oct, 2, -600}; // UTC-10 (no DST) + return new Timezone(hst, hst); + + // Europe + } else if (strcmp(tz_string, "Europe/London") == 0) { + TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; // UTC+0 + TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; // UTC+1 + return new Timezone(bst, gmt); + } else if (strcmp(tz_string, "Europe/Paris") == 0 || strcmp(tz_string, "Europe/Berlin") == 0) { + TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; // UTC+1 + TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; // UTC+2 + return new Timezone(cest, cet); + } else if (strcmp(tz_string, "Europe/Moscow") == 0) { + TimeChangeRule msk = {"MSK", Last, Sun, Oct, 3, 180}; // UTC+3 (no DST since 2014) + return new Timezone(msk, msk); + + // Asia + } else if (strcmp(tz_string, "Asia/Tokyo") == 0) { + TimeChangeRule jst = {"JST", Last, Sun, Oct, 2, 540}; // UTC+9 (no DST) + return new Timezone(jst, jst); + } else if (strcmp(tz_string, "Asia/Shanghai") == 0 || strcmp(tz_string, "Asia/Hong_Kong") == 0) { + TimeChangeRule cst = {"CST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) + return new Timezone(cst, cst); + } else if (strcmp(tz_string, "Asia/Kolkata") == 0) { + TimeChangeRule ist = {"IST", Last, Sun, Oct, 2, 330}; // UTC+5:30 (no DST) + return new Timezone(ist, ist); + } else if (strcmp(tz_string, "Asia/Dubai") == 0) { + TimeChangeRule gst = {"GST", Last, Sun, Oct, 2, 240}; // UTC+4 (no DST) + return new Timezone(gst, gst); + + // Australia + } else if (strcmp(tz_string, "Australia/Sydney") == 0 || strcmp(tz_string, "Australia/Melbourne") == 0) { + TimeChangeRule aest = {"AEST", First, Sun, Apr, 3, 600}; // UTC+10 + TimeChangeRule aedt = {"AEDT", First, Sun, Oct, 2, 660}; // UTC+11 + return new Timezone(aedt, aest); + } else if (strcmp(tz_string, "Australia/Perth") == 0) { + TimeChangeRule awst = {"AWST", Last, Sun, Oct, 2, 480}; // UTC+8 (no DST) + return new Timezone(awst, awst); + + // Timezone abbreviations (with DST handling) + } else if (strcmp(tz_string, "PDT") == 0 || strcmp(tz_string, "PST") == 0) { + // Pacific Time (PST/PDT) + TimeChangeRule pst = {"PST", First, Sun, Nov, 2, -480}; // UTC-8 + TimeChangeRule pdt = {"PDT", Second, Sun, Mar, 2, -420}; // UTC-7 + return new Timezone(pdt, pst); + } else if (strcmp(tz_string, "MDT") == 0 || strcmp(tz_string, "MST") == 0) { + // Mountain Time (MST/MDT) + TimeChangeRule mst = {"MST", First, Sun, Nov, 2, -420}; // UTC-7 + TimeChangeRule mdt = {"MDT", Second, Sun, Mar, 2, -360}; // UTC-6 + return new Timezone(mdt, mst); + } else if (strcmp(tz_string, "CDT") == 0 || strcmp(tz_string, "CST") == 0) { + // Central Time (CST/CDT) + TimeChangeRule cst = {"CST", First, Sun, Nov, 2, -360}; // UTC-6 + TimeChangeRule cdt = {"CDT", Second, Sun, Mar, 2, -300}; // UTC-5 + return new Timezone(cdt, cst); + } else if (strcmp(tz_string, "EDT") == 0 || strcmp(tz_string, "EST") == 0) { + // Eastern Time (EST/EDT) + TimeChangeRule est = {"EST", First, Sun, Nov, 2, -300}; // UTC-5 + TimeChangeRule edt = {"EDT", Second, Sun, Mar, 2, -240}; // UTC-4 + return new Timezone(edt, est); + } else if (strcmp(tz_string, "BST") == 0 || strcmp(tz_string, "GMT") == 0) { + // British Time (GMT/BST) + TimeChangeRule gmt = {"GMT", Last, Sun, Oct, 2, 0}; // UTC+0 + TimeChangeRule bst = {"BST", Last, Sun, Mar, 1, 60}; // UTC+1 + return new Timezone(bst, gmt); + } else if (strcmp(tz_string, "CEST") == 0 || strcmp(tz_string, "CET") == 0) { + // Central European Time (CET/CEST) + TimeChangeRule cet = {"CET", Last, Sun, Oct, 3, 60}; // UTC+1 + TimeChangeRule cest = {"CEST", Last, Sun, Mar, 2, 120}; // UTC+2 + return new Timezone(cest, cet); + + // UTC and simple offsets + } else if (strcmp(tz_string, "UTC") == 0) { + TimeChangeRule utc = {"UTC", Last, Sun, Mar, 0, 0}; + return new Timezone(utc, utc); + } else if (strncmp(tz_string, "UTC", 3) == 0) { + // Handle UTC+/-X format (UTC-8, UTC+5, etc.) + int offset = atoi(tz_string + 3); + TimeChangeRule utc_offset = {"UTC", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(utc_offset, utc_offset); + } else if (strncmp(tz_string, "GMT", 3) == 0) { + // Handle GMT+/-X format (GMT-8, GMT+5, etc.) + int offset = atoi(tz_string + 3); + TimeChangeRule gmt_offset = {"GMT", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(gmt_offset, gmt_offset); + } else if (strncmp(tz_string, "+", 1) == 0 || strncmp(tz_string, "-", 1) == 0) { + // Handle simple +/-X format (+5, -8, etc.) + int offset = atoi(tz_string); + TimeChangeRule offset_tz = {"TZ", Last, Sun, Mar, 0, offset * 60}; + return new Timezone(offset_tz, offset_tz); + } else { + // Unknown timezone, return null + MQTT_DEBUG_PRINTLN("Unknown timezone: %s", tz_string); + return nullptr; + } +} + #endif diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 2396c1ef..58483c13 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -6,6 +6,7 @@ #include #include #include +#include #if defined(MQTT_DEBUG) && defined(ARDUINO) #include @@ -95,6 +96,9 @@ private: unsigned long _last_ntp_sync; bool _ntp_synced; + // Timezone handling + Timezone* _timezone; + // Internal methods void connectToBrokers(); void processPacketQueue(); @@ -106,6 +110,7 @@ private: bool isAnyBrokerConnected(); void setBrokerDefaults(); void syncTimeWithNTP(); + Timezone* createTimezoneFromString(const char* tz_string); public: /** diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 2864f4cb..489fd43e 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -132,6 +132,7 @@ lib_deps = knolleary/PubSubClient @ ^2.8 bblanchon/ArduinoJson @ 6.17.3 arduino-libraries/NTPClient + JChristensen/Timezone [env:Heltec_v3_room_server] extends = Heltec_lora32_v3