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:
parent
4db836a8a8
commit
e676694fdc
6 changed files with 100 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
46
docs/notes/mdns-override.md
Normal file
46
docs/notes/mdns-override.md
Normal 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.
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue