diff --git a/dashboard/js/onboard.js b/dashboard/js/onboard.js index 999c337..73dada1 100644 --- a/dashboard/js/onboard.js +++ b/dashboard/js/onboard.js @@ -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 = '
' + '

Configure WiFi

' + @@ -690,15 +702,20 @@ '' + '
' + '
' + - 'Advanced: Mothership Address' + + 'Advanced: Network Troubleshooting' + '
' + - '' + + '' + '' + '
' + '
' + '' + '' + '
' + + '
' + + '' + + '' + + '

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,