diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1d16acccf..121183c46 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4,6 +4,7 @@ #include // for qsort() #include #include +#include #if defined(ESP_PLATFORM) #include #endif @@ -1149,6 +1150,11 @@ void MyMesh::begin(FILESYSTEM *fs) { NetworkInterface& boot_network = activeNetworkInterface(); if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 386c36b40..c0bd70158 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #if defined(ESP_PLATFORM) #include #endif @@ -950,6 +951,11 @@ void MyMesh::begin(FILESYSTEM *fs) { NetworkInterface& boot_network = activeNetworkInterface(); if (boot_network.isAutomatic()) { + char network_hostname[NetworkHostname::kBufferSize]; + NetworkHostname::build(network_hostname, sizeof(network_hostname), + _prefs.node_name, self_id.pub_key, PUB_KEY_SIZE); + boot_network.setHostname(network_hostname); + Serial.printf("Network: hostname %s\n", network_hostname); MQTTPrefs* obs = _cli.getObserverPrefs(); Serial.printf("Network: probing Ethernet for up to %lums\n", (unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS); diff --git a/src/helpers/NetworkHostname.h b/src/helpers/NetworkHostname.h new file mode 100644 index 000000000..d4dfe7c44 --- /dev/null +++ b/src/helpers/NetworkHostname.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include + +namespace NetworkHostname { + +// ESP-IDF documents a 32-byte hostname limit. Keep one byte for the trailing +// NUL so the same buffer is safe through Arduino and lwIP APIs. +static constexpr size_t kMaxLength = 31; +static constexpr size_t kBufferSize = kMaxLength + 1; + +static inline bool isAsciiAlphaNumeric(uint8_t ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9'); +} + +static inline char asciiLower(uint8_t ch) { + return ch >= 'A' && ch <= 'Z' ? static_cast(ch + ('a' - 'A')) + : static_cast(ch); +} + +/** + * Build a DHCP-safe hostname from the MeshCore node name. + * + * The result is lowercase, starts with "meshcore-", contains only letters, + * digits and hyphens, and never exceeds ESP-IDF's practical 31-character + * payload limit. If the readable name must be shortened, six hex digits from + * the stable node identity are retained to reduce truncation collisions. + */ +static inline bool build(char* dest, size_t dest_size, const char* node_name, + const uint8_t* stable_id, size_t stable_id_size) { + if (!dest || dest_size == 0) return false; + dest[0] = '\0'; + + static constexpr char kPrefix[] = "meshcore-"; + static constexpr char kFallback[] = "node"; + static constexpr char kHex[] = "0123456789abcdef"; + static constexpr size_t kPrefixLength = sizeof(kPrefix) - 1; + static constexpr size_t kSuffixLength = 7; // '-' plus six hex digits. + + // NodePrefs::node_name currently holds at most 31 bytes. A larger scratch + // buffer keeps this helper safe and independently testable with other input. + char slug[64]; + size_t slug_length = 0; + bool separator_pending = false; + if (node_name) { + for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1; + ++i) { + const uint8_t ch = static_cast(node_name[i]); + if (isAsciiAlphaNumeric(ch)) { + if (separator_pending && slug_length > 0 && + slug_length < sizeof(slug) - 1) { + slug[slug_length++] = '-'; + } + if (slug_length < sizeof(slug) - 1) { + slug[slug_length++] = asciiLower(ch); + } + separator_pending = false; + } else if (slug_length > 0) { + separator_pending = true; + } + } + } + + if (slug_length == 0) { + for (size_t i = 0; i < sizeof(kFallback) - 1; ++i) { + slug[slug_length++] = kFallback[i]; + } + } + slug[slug_length] = '\0'; + + size_t max_length = dest_size - 1; + if (max_length > kMaxLength) max_length = kMaxLength; + if (max_length == 0) return false; + + const bool needs_truncation = kPrefixLength + slug_length > max_length; + const bool can_add_identity = needs_truncation && stable_id && + stable_id_size >= 3 && max_length > kPrefixLength + kSuffixLength; + const size_t suffix_length = can_add_identity ? kSuffixLength : 0; + const size_t prefix_length = kPrefixLength < max_length + ? kPrefixLength : max_length; + size_t slug_budget = max_length - prefix_length - suffix_length; + if (slug_budget > slug_length) slug_budget = slug_length; + while (slug_budget > 0 && slug[slug_budget - 1] == '-') --slug_budget; + + size_t out = 0; + for (size_t i = 0; i < prefix_length; ++i) dest[out++] = kPrefix[i]; + for (size_t i = 0; i < slug_budget; ++i) dest[out++] = slug[i]; + if (can_add_identity) { + dest[out++] = '-'; + for (size_t i = 0; i < 3; ++i) { + dest[out++] = kHex[(stable_id[i] >> 4) & 0x0f]; + dest[out++] = kHex[stable_id[i] & 0x0f]; + } + } + dest[out] = '\0'; + return true; +} + +} // namespace NetworkHostname diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp index c8953aacb..dc0be519c 100644 --- a/src/helpers/NetworkInterface.cpp +++ b/src/helpers/NetworkInterface.cpp @@ -77,6 +77,7 @@ class NetworkInterfaceBase : public NetworkInterface { class WiFiNetworkInterface final : public NetworkInterfaceBase { bool _event_registered = false; + char _hostname[32] = {}; char _ssid[33] = {}; char _password[65] = {}; unsigned long _last_reconnect_attempt = 0; @@ -112,6 +113,11 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { return wifi_ssid && wifi_ssid[0] != '\0'; } + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + bool begin(const char* wifi_ssid, const char* wifi_password) override { if (!configValid(wifi_ssid)) return false; strncpy(_ssid, wifi_ssid, sizeof(_ssid) - 1); @@ -119,6 +125,9 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase { strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1); _password[sizeof(_password) - 1] = '\0'; + // Arduino-ESP32 applies this stored value when it creates the STA netif. + // It must be set before WiFi.mode()/begin() for the first DHCP exchange. + if (_hostname[0] != '\0') WiFi.setHostname(_hostname); WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.setAutoConnect(true); @@ -229,6 +238,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { private: bool _started = false; bool _event_registered = false; + char _hostname[32] = {}; std::atomic _event_state{static_cast(EventState::None)}; public: @@ -240,6 +250,11 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { int statusCode() const override { return isConnected() ? 1 : 0; } bool configValid(const char*) const override { return true; } + void setHostname(const char* hostname) override { + strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1); + _hostname[sizeof(_hostname) - 1] = '\0'; + } + bool begin(const char*, const char*) override { if (_started) return true; // DNS and WiFiClientSecure are transport-neutral sockets in this Arduino @@ -279,7 +294,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase { }); _event_registered = true; } - _started = beginConfiguredCH390(); + _started = beginConfiguredCH390(_hostname); if (_started && isConnected()) noteConnected(millis()); return _started; } @@ -444,6 +459,13 @@ class AutomaticNetworkInterface final : public NetworkInterface { } bool isAutomatic() const override { return true; } + void setHostname(const char* hostname) override { + // Whichever medium wins now or during a later failover presents the same + // stable DHCP identity to the LAN. + _ethernet.setHostname(hostname); + _wifi.setHostname(hostname); + } + bool begin(const char* wifi_ssid, const char* wifi_password) override { rememberWifi(wifi_ssid, wifi_password); if (!_ethernet_started) { diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h index 646a73cec..5ded0ff98 100644 --- a/src/helpers/NetworkInterface.h +++ b/src/helpers/NetworkInterface.h @@ -29,6 +29,9 @@ class NetworkInterface { virtual const char* statusName() const = 0; virtual int statusCode() const = 0; virtual bool configValid(const char* wifi_ssid) const = 0; + // Configure the DHCP hostname before begin()/bootstrap(). Implementations + // retain it for any later fallback interface start. + virtual void setHostname(const char* hostname) = 0; virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0; virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0; diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h index 1b3a5bd9e..30f110bf9 100644 --- a/src/helpers/ethernet/ch390/CH390Config.h +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -7,7 +7,7 @@ * Shared by the companion transport wrapper and the observer network adapter so * pin and static-IP behavior cannot drift between the two paths. */ -static inline bool beginConfiguredCH390() { +static inline bool beginConfiguredCH390(const char* hostname = nullptr) { ch390_config_t config = CH390_DEFAULT_CONFIG(); config.spi_miso_gpio = ETH_MISO_PIN; config.spi_mosi_gpio = ETH_MOSI_PIN; @@ -16,6 +16,28 @@ static inline bool beginConfiguredCH390() { config.int_gpio = ETH_INT_PIN; if (!CH390.begin(config)) return false; + if (hostname && hostname[0] != '\0') { +#if !(defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)) + // ESP32-CH390 creates and starts its esp_netif inside begin(), so its + // setHostname() cannot be called earlier. Stop the just-created DHCP + // client, apply the name to that exact Ethernet netif, then start a fresh + // DHCP transaction. This prevents a fast wired link from retaining the + // ESP-IDF default hostname for its initial lease. + const bool dhcp_stopped = CH390.disableDHCP(); + const bool hostname_set = CH390.setHostname(hostname); + const bool dhcp_started = CH390.enableDHCP(); + if (!dhcp_stopped || !hostname_set || !dhcp_started) { + Serial.printf("Network: could not fully apply Ethernet hostname %s\n", + hostname); + } +#else + if (!CH390.setHostname(hostname)) { + Serial.printf("Network: could not apply Ethernet hostname %s\n", + hostname); + } +#endif + } + #if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET) IPAddress ip(ETHERNET_STATIC_IP); IPAddress gateway(ETHERNET_STATIC_GATEWAY); diff --git a/test/README.md b/test/README.md index 0cba8fff5..ee33b4c5b 100644 --- a/test/README.md +++ b/test/README.md @@ -30,6 +30,7 @@ does not reflect the GoogleTest count — run the built binary directly | `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries | | `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover; WiFi current-outage start sticky across STA reconnect attempts | | `test_network_policy` | `src/helpers/NetworkPolicy.h` | transport-neutral MQTT link-transition actions and `start ota` selected-LAN versus forced/fallback SoftAP choice | +| `test_network_hostname` | `src/helpers/NetworkHostname.h` | DHCP-safe `meshcore-` hostname generation; invalid-character collapse; empty fallback; exact length boundary; stable identity suffix on truncation | | `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover | | `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection | | `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | diff --git a/test/test_network_hostname/test_network_hostname.cpp b/test/test_network_hostname/test_network_hostname.cpp new file mode 100644 index 000000000..8835c7d96 --- /dev/null +++ b/test/test_network_hostname/test_network_hostname.cpp @@ -0,0 +1,95 @@ +#include + +#include + +#include "helpers/NetworkHostname.h" + +namespace { + +const uint8_t kStableId[] = {0xab, 0xcd, 0x01}; + +void expectDhcpSafe(const char* hostname) { + ASSERT_NE(nullptr, hostname); + const size_t length = strlen(hostname); + EXPECT_GT(length, 0u); + EXPECT_LE(length, NetworkHostname::kMaxLength); + EXPECT_NE('-', hostname[0]); + EXPECT_NE('-', hostname[length - 1]); + for (size_t i = 0; i < length; ++i) { + const char ch = hostname[i]; + EXPECT_TRUE((ch >= 'a' && ch <= 'z') || + (ch >= '0' && ch <= '9') || ch == '-') + << "invalid byte at " << i; + } +} + +} // namespace + +TEST(NetworkHostname, PrefixesAndLowercasesReadableNodeName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "Hill Repeater 2", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-hill-repeater-2", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, CollapsesInvalidRunsAndTrimsEdges) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + " Adam's___Hill / Repeater! ", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-adam-s-hill-repeater", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-node", hostname); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, KeepsExactBoundaryWithoutIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "abcdefghijklmnopqrstuv", kStableId, + sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmnopqrstuv", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); +} + +TEST(NetworkHostname, TruncatesReadablePartAndAddsStableIdentitySuffix) { + char hostname[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + EXPECT_STREQ("meshcore-abcdefghijklmno-abcd01", hostname); + EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname)); + expectDhcpSafe(hostname); +} + +TEST(NetworkHostname, IdentitySuffixSeparatesOtherwiseCollidingNames) { + const uint8_t other_id[] = {0x12, 0x34, 0x56}; + char first[NetworkHostname::kBufferSize]; + char second[NetworkHostname::kBufferSize]; + ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + kStableId, sizeof(kStableId))); + ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), + "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + other_id, sizeof(other_id))); + EXPECT_STRNE(first, second); + EXPECT_STREQ("meshcore-abcdefghijklmno-123456", second); +} + +TEST(NetworkHostname, RejectsMissingOutputBuffer) { + EXPECT_FALSE(NetworkHostname::build(nullptr, 0, "node", kStableId, + sizeof(kStableId))); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}