If your network blocks mDNS (enterprise WiFi, some mesh routers), enter the mothership\'s IP address here. Leave blank to use automatic discovery.
' +
+ '
' +
'' +
'' +
'' +
@@ -718,6 +735,7 @@
state.wifiPass = document.getElementById('wifi-pass').value;
state.mothershipHost = document.getElementById('ms-host').value.trim();
state.mothershipPort = parseInt(document.getElementById('ms-port').value, 10) || 8080;
+ state.mothershipIP = document.getElementById('ms-ip').value.trim();
saveState();
goToStep(state.currentStepIndex + 1);
});
@@ -736,7 +754,7 @@
return fetch(CONFIG.provisioningEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ wifi_ssid: ssid, wifi_pass: pass }),
+ body: JSON.stringify({ wifi_ssid: ssid, wifi_pass: pass, ms_ip: state.mothershipIP || '' }),
})
.then(function (r) {
addProvLog('log', 'Mothership response: HTTP ' + r.status);
@@ -747,6 +765,7 @@
// Apply user overrides for mothership address
if (msHost) payload.ms_mdns = msHost;
if (msPort) payload.ms_port = msPort;
+ if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
addProvLog('log', 'Payload ready — node_id=' + (payload.node_id || '(none)') + ' ms_mdns=' + (payload.ms_mdns || '(none)'));
setProvStatus('Sending configuration to device...');
return sendPayloadOverSerial(payload, addProvLog, setProvStatus);
@@ -767,6 +786,7 @@
node_token: '',
ms_mdns: msHost || window.location.hostname,
ms_port: msPort,
+ ms_ip: state.mothershipIP || '',
debug: false,
};
addProvLog('log', 'Fallback payload — node_id=' + payload.node_id);
@@ -1466,6 +1486,7 @@
state.wifiPass = saved.wifiPass || '';
state.mothershipHost = saved.mothershipHost || '';
state.mothershipPort = saved.mothershipPort || 8080;
+ state.mothershipIP = saved.mothershipIP || '';
// After a page reload the serial port reference is gone. If we were at the
// flash step or beyond, drop back to connect so the user can re-select their
// device rather than landing on a broken flash screen.
diff --git a/docs/notes/mdns-override.md b/docs/notes/mdns-override.md
new file mode 100644
index 0000000..d1d9f89
--- /dev/null
+++ b/docs/notes/mdns-override.md
@@ -0,0 +1,46 @@
+# Mothership IP Override (mDNS-less Networks)
+
+## Problem
+
+mDNS (multicast DNS, port 5353) is blocked or filtered on some networks:
+
+- Enterprise WiFi with AP isolation
+- Mesh WiFi systems that suppress multicast between bands
+- VLAN-segmented networks where multicast traffic doesn't cross boundaries
+- Networks with aggressive multicast-to-unicast conversion that breaks mDNS
+
+On first boot, an ESP32 node has no cached mothership IP and relies entirely on mDNS discovery (`_spaxel._tcp`). If mDNS is blocked, the node enters `MOTHERSHIP_UNAVAILABLE` state and never connects.
+
+## Solution: Provisioning-time IP override
+
+The provisioning payload now supports an optional `ms_ip` field — a direct IPv4 address that bypasses mDNS on first connect.
+
+### How it works
+
+1. During provisioning (Web Serial wizard), the dashboard sends `ms_ip` in the POST body to `/api/provision`
+2. The mothership includes it in the provisioning payload JSON
+3. The firmware writes it to two NVS keys:
+ - `ms_ip` — the runtime fallback cache (used on subsequent boots if mDNS fails)
+ - `ms_ip_prov` — the provisioning-time override (used on first boot only)
+4. On first boot, the discovery order is:
+ 1. **Provisioned IP** (`ms_ip_prov`) — tried first, skips mDNS entirely on first attempt
+ 2. **mDNS query** (`_spaxel._tcp`) — standard discovery
+ 3. **Cached IP** (`ms_ip`) — fallback from a previous successful connection
+
+After a successful connection, `ms_ip` is updated to the current IP. The `ms_ip_prov` value persists but is only preferred on the very first connection attempt (`discovery_fail_count == 0`). If the provisioned IP fails, mDNS is tried next, then the cached IP.
+
+### When to use the override
+
+- The node is on a different VLAN/subnet from the mothership and mDNS doesn't cross boundaries
+- Enterprise or campus WiFi that blocks multicast
+- Mesh WiFi systems where nodes on different bands can't discover each other via mDNS
+- Any network where `ping spaxel.local` from a computer fails but `ping ` works
+
+### When NOT to use it
+
+- On normal home networks where mDNS works — leaving `ms_ip` blank lets the node adapt to DHCP IP changes automatically
+- The mothership IP changes frequently — the cached `ms_ip` updates on each successful connection, but a stale provisioned IP would cause one failed attempt before falling back to mDNS
+
+### Dashboard wizard
+
+The Web Serial onboarding wizard has an "Advanced: Network Troubleshooting" section on the WiFi credentials step. The "Mothership IP" field is auto-populated when the browser is accessing the dashboard via IP address (e.g., `http://192.168.1.100:8080`). Users on mDNS-blocking networks should enter the mothership's LAN IP there.
diff --git a/firmware/main/main.c b/firmware/main/main.c
index 60989a1..9ff520b 100644
--- a/firmware/main/main.c
+++ b/firmware/main/main.c
@@ -115,6 +115,10 @@ static esp_err_t load_nvs_config(void) {
len = sizeof(g_state.ms_ip);
nvs_get_str(nvs, NVS_KEY_MS_IP, g_state.ms_ip, &len);
+ // Load provisioning-time IP override
+ len = sizeof(g_state.ms_ip_prov);
+ nvs_get_str(nvs, NVS_KEY_MS_IP_PROV, g_state.ms_ip_prov, &len);
+
// Load node ID
len = sizeof(g_state.node_id);
nvs_get_str(nvs, NVS_KEY_NODE_ID, g_state.node_id, &len);
@@ -236,14 +240,24 @@ static void state_machine_task(void *arg) {
char ms_ip[64] = {0};
uint16_t ms_port = g_state.ms_port;
- // Try mDNS first
+ // 1. Try provisioned IP override first (for mDNS-less networks)
+ if (strlen(g_state.ms_ip_prov) > 0) {
+ ESP_LOGI(TAG, "Trying provisioned mothership IP: %s:%d", g_state.ms_ip_prov, ms_port);
+ strncpy(g_state.ms_ip, g_state.ms_ip_prov, sizeof(g_state.ms_ip) - 1);
+ // Skip mDNS on first attempt if provisioned IP is set
+ if (discovery_fail_count == 0) {
+ goto attempt_connect;
+ }
+ }
+
+ // 2. Try mDNS discovery
if (wifi_discover_mothership(ms_ip, sizeof(ms_ip), &ms_port)) {
ESP_LOGI(TAG, "Mothership discovered via mDNS: %s:%d", ms_ip, ms_port);
strncpy(g_state.ms_ip, ms_ip, sizeof(g_state.ms_ip) - 1);
g_state.ms_port = ms_port;
discovery_fail_count = 0;
} else if (strlen(g_state.ms_ip) > 0) {
- // Fallback to cached IP
+ // 3. Fallback to cached IP
ESP_LOGI(TAG, "Using cached mothership IP: %s", g_state.ms_ip);
} else {
discovery_fail_count++;
@@ -258,6 +272,8 @@ static void state_machine_task(void *arg) {
break;
}
+ attempt_connect:
+
// Attempt WebSocket connection
if (websocket_connect(g_state.ms_ip, g_state.ms_port)) {
ESP_LOGI(TAG, "WebSocket connected to mothership");
diff --git a/firmware/main/provision.c b/firmware/main/provision.c
index c3a10f0..8f2e2df 100644
--- a/firmware/main/provision.c
+++ b/firmware/main/provision.c
@@ -151,6 +151,12 @@ esp_err_t provision_write_nvs(cJSON *prov) {
nvs_set_str(nvs, NVS_KEY_MS_MDNS, mdns_name->valuestring);
}
+ cJSON *ms_ip = cJSON_GetObjectItem(prov, "ms_ip");
+ if (ms_ip && cJSON_IsString(ms_ip) && strlen(ms_ip->valuestring) > 0) {
+ nvs_set_str(nvs, NVS_KEY_MS_IP, ms_ip->valuestring);
+ nvs_set_str(nvs, NVS_KEY_MS_IP_PROV, ms_ip->valuestring);
+ }
+
cJSON *port = cJSON_GetObjectItem(prov, "ms_port");
if (port && cJSON_IsNumber(port) && port->valueint > 0) {
nvs_set_u16(nvs, NVS_KEY_MS_PORT, (uint16_t)port->valueint);
diff --git a/firmware/main/spaxel.h b/firmware/main/spaxel.h
index c3464b5..2844af8 100644
--- a/firmware/main/spaxel.h
+++ b/firmware/main/spaxel.h
@@ -58,6 +58,7 @@ typedef enum {
#define NVS_KEY_NODE_TOKEN "node_token"
#define NVS_KEY_MS_MDNS "ms_mdns"
#define NVS_KEY_MS_IP "ms_ip"
+#define NVS_KEY_MS_IP_PROV "ms_ip_prov"
#define NVS_KEY_MS_PORT "ms_port"
#define NVS_KEY_PASSIVE_BSS "passive_bss"
#define NVS_KEY_ROLE "role"
@@ -79,6 +80,7 @@ typedef struct {
char node_token[65];
char ms_mdns[65];
char ms_ip[47];
+ char ms_ip_prov[47];
uint16_t ms_port;
bool provisioned;
bool debug;
diff --git a/mothership/internal/provisioning/server.go b/mothership/internal/provisioning/server.go
index dec554b..5822e68 100644
--- a/mothership/internal/provisioning/server.go
+++ b/mothership/internal/provisioning/server.go
@@ -28,6 +28,7 @@ type Payload struct {
NodeID string `json:"node_id"`
NodeToken string `json:"node_token"`
MsMDNS string `json:"ms_mdns"`
+ MsIP string `json:"ms_ip,omitempty"` // Direct IPv4 override for mDNS-less networks
MsPort int `json:"ms_port"`
NTPServer string `json:"ntp_server"`
Debug bool `json:"debug"`
@@ -38,6 +39,7 @@ type provisionRequest struct {
WifiSSID string `json:"wifi_ssid"`
WifiPass string `json:"wifi_pass"`
MAC string `json:"mac,omitempty"` // optional; used to derive deterministic node_token
+ MsIP string `json:"ms_ip,omitempty"`
Debug bool `json:"debug,omitempty"`
}
@@ -185,6 +187,7 @@ func (s *Server) HandleProvision(w http.ResponseWriter, r *http.Request) {
NodeID: nodeID,
NodeToken: token,
MsMDNS: s.mdnsName,
+ MsIP: req.MsIP,
MsPort: s.msPort,
NTPServer: s.ntpServer,
Debug: req.Debug,