feat(network): implement hostname configuration for automatic interfaces

This commit is contained in:
agessaman committed 2026-09-03 17:16:30 -07:00
1 parent 88b57db2c6
commit 0c65fc79db
8 files changed
+258 -2

No files matched your search

+6
View File
@@ -4,6 +4,7 @@
#include <stdlib.h> // for qsort()
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#include <helpers/NetworkHostname.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#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);
+6
View File
@@ -3,6 +3,7 @@
#include <climits>
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#include <helpers/NetworkHostname.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#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);
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
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<char>(ch + ('a' - 'A'))
: static_cast<char>(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<uint8_t>(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
+23 -1
View File
@@ -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<uint8_t> _event_state{static_cast<uint8_t>(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) {
+3
View File
@@ -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;
+23 -1
View File
@@ -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);
+1
View File
@@ -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 |
@@ -0,0 +1,95 @@
#include <gtest/gtest.h>
#include <cstring>
#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();
}