feat(provisioning): carry ms_ip in payload for mDNS-less networks

Add optional mothership IP override to the provisioning flow so nodes
on networks where mDNS is blocked (enterprise WiFi, mesh, VLANs) can
connect on first boot without manual intervention.

- Add ms_ip field to provisioning Payload and request structs
- Firmware writes ms_ip to both NVS_KEY_MS_IP and NVS_KEY_MS_IP_PROV
- Discovery prefers provisioned IP on first attempt, falls back to
  mDNS, then cached IP
- Web Serial wizard adds Mothership IP field in Network Troubleshooting
- Auto-populates IP when browser accesses dashboard by IP address
- Document when/how to use the override in docs/notes/mdns-override.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 01:05:16 -04:00
parent 4db836a8a8
commit e676694fdc
6 changed files with 100 additions and 6 deletions

View file

@ -50,6 +50,7 @@
wifiPass: '',
mothershipHost: '',
mothershipPort: 8080,
mothershipIP: '',
pollTimer: null,
calibrateTimer: null,
calibratePhase: 'idle',
@ -72,6 +73,7 @@
wifiPass: state.wifiPass,
mothershipHost: state.mothershipHost,
mothershipPort: state.mothershipPort,
mothershipIP: state.mothershipIP,
}));
} catch (e) { /* ignore */ }
}
@ -635,7 +637,7 @@
var fetchPromise = 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 || '' }),
});
var timeoutPromise = new Promise(function (_, reject) {
setTimeout(function () { reject(new Error('timeout')); }, 5000);
@ -645,6 +647,7 @@
payload = await resp.json();
if (msHost) payload.ms_mdns = msHost;
if (msPort) payload.ms_port = msPort;
if (state.mothershipIP) payload.ms_ip = state.mothershipIP;
provLog('log', 'Server payload: node_id=' + (payload.node_id || '(none)'));
} catch (err) {
provLog('warn', 'Mothership unreachable (' + (err.message || err) + '), using client-side payload');
@ -659,6 +662,7 @@
node_token: '',
ms_mdns: msHost || window.location.hostname,
ms_port: msPort,
ms_ip: state.mothershipIP || '',
debug: false,
};
}
@ -676,6 +680,14 @@
}
function renderProvisionWifi(contentEl) {
// Auto-populate ms_ip if the browser is accessing the mothership by IP directly
if (!state.mothershipIP) {
var host = window.location.hostname;
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
state.mothershipIP = host;
}
}
contentEl.innerHTML =
'<div class="wizard-step-content">' +
'<h2>Configure WiFi</h2>' +
@ -690,15 +702,20 @@
'<input type="password" id="wifi-pass" placeholder="Password" value="' + escapeAttr(state.wifiPass) + '" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">' +
'</div>' +
'<details class="wizard-details">' +
'<summary>Advanced: Mothership Address</summary>' +
'<summary>Advanced: Network Troubleshooting</summary>' +
'<div class="form-group" style="margin-top:8px">' +
'<label for="ms-host">Host <span class="wizard-muted">(leave blank to use ' + escapeAttr(window.location.hostname) + ')</span></label>' +
'<label for="ms-host">Mothership Host <span class="wizard-muted">(leave blank to use ' + escapeAttr(window.location.hostname) + ')</span></label>' +
'<input type="text" id="ms-host" placeholder="' + escapeAttr(window.location.hostname) + '" value="' + escapeAttr(state.mothershipHost) + '" autocomplete="off">' +
'</div>' +
'<div class="form-group">' +
'<label for="ms-port">Port</label>' +
'<input type="number" id="ms-port" value="' + state.mothershipPort + '" min="1" max="65535">' +
'</div>' +
'<div class="form-group">' +
'<label for="ms-ip">Mothership IP <span class="wizard-muted">(for networks where mDNS is blocked)</span></label>' +
'<input type="text" id="ms-ip" placeholder="e.g. 192.168.1.100" value="' + escapeAttr(state.mothershipIP) + '" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">' +
'<p class="wizard-muted" style="font-size:11px;margin-top:4px">If your network blocks mDNS (enterprise WiFi, some mesh routers), enter the mothership\'s IP address here. Leave blank to use automatic discovery.</p>' +
'</div>' +
'</details>' +
'<div id="provision-error" class="wizard-error" style="display:none"></div>' +
'<button type="submit" class="wizard-btn wizard-btn-primary">Next: Flash Firmware</button>' +
@ -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.

View file

@ -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 <mothership-ip>` 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.

View file

@ -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");

View file

@ -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);

View file

@ -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;

View file

@ -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,