Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mdns-first-device-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ftw": patch
---

The setup wizard now installs a discovered device by its self-broadcast mDNS
(.local) name instead of its raw IP when the device advertises one, so the
connection survives DHCP lease changes. When only an IP address is used, the
wizard and device settings now tell the operator to reserve that IP for the
device in the router's DHCP settings.
4 changes: 2 additions & 2 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
if (modbus) {
html += '<fieldset><legend>Modbus TCP</legend>' +
'<div class="field-row"><div>' +
'<label>Host ' + help('IP of the Modbus-TCP device (e.g. Sungrow inverter LAN port).') + '</label>' +
'<label>Host ' + help('IP or hostname of the Modbus-TCP device (e.g. Sungrow inverter LAN port). If you use a raw IP, reserve it for the device in your router\'s DHCP settings so it can\'t change.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.capabilities.modbus.host" value="' + escHtml(modbus.host) + '">' +
'</div><div>' +
'<label>Port</label><input type="number" data-path="drivers.' + idx + '.capabilities.modbus.port" value="' + (modbus.port || 502) + '">' +
Expand Down Expand Up @@ -145,7 +145,7 @@
// post-render DOM edit mirrors the site-meter pattern above
// and avoids a re-render race with the async catalog fetch.
html += '<fieldset><legend>HTTP</legend>' +
'<label>Host / IP ' + help('Hostname (e.g. zap.local) or IP address of the device. mDNS names work when your OS resolver supports them; otherwise use the LAN IP.') + '</label>' +
'<label>Host / IP ' + help('Hostname (e.g. zap.local) or IP address of the device. Prefer the device\'s mDNS (.local) name when it broadcasts one — it survives DHCP lease changes. If you use a raw IP, reserve it for the device in your router\'s DHCP settings so it can\'t change.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.config.host" value="' + escHtml(lcfg.host || '') + '" placeholder="zap.local">' +
'<div class="drv-local-creds" data-drv-lua="' + escHtml(d.lua || '') + '"' + (localCreds ? '' : ' hidden') + '>' +
'<label style="margin-top:8px">Username ' + help('Username for the device\'s local API (HTTP Basic auth). For NIBE this is the local-API account you set up in the myUplink app.') + '</label>' +
Expand Down
23 changes: 20 additions & 3 deletions web/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,21 @@
margin: 1rem 0;
}

/* ---- Host addressing hint (mDNS name vs fixed IP) ---- */
.host-hint {
margin: 0 0 1rem;
padding: 10px 12px;
background: var(--ink-raised);
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.78rem;
color: var(--fg-dim);
line-height: 1.5;
}

.host-hint.mdns { border-color: color-mix(in srgb, var(--green-e) 45%, var(--line)); }
.host-hint.dhcp { border-color: color-mix(in srgb, var(--yellow) 55%, var(--line)); }

/* ---- Driver picker ---- */
.driver-desc {
margin-top: 0.5rem;
Expand Down Expand Up @@ -595,8 +610,8 @@ <h2>Find devices</h2>
<div id="manual-ip-form" style="display:none">
<div class="form-row">
<div class="form-group">
<label for="manual-ip">IP address</label>
<input type="text" id="manual-ip" placeholder="192.168.1.10">
<label for="manual-ip">IP address or hostname</label>
<input type="text" id="manual-ip" placeholder="192.168.1.10 or inverter.local">
</div>
<div class="form-group">
<label for="manual-port">Port</label>
Expand Down Expand Up @@ -644,7 +659,7 @@ <h2>Configure driver</h2>

<div class="form-row">
<div class="form-group">
<label for="drv-ip">IP address</label>
<label for="drv-ip">IP address or hostname</label>
<input type="text" id="drv-ip" placeholder="192.168.1.10">
</div>
<div class="form-group">
Expand All @@ -653,6 +668,8 @@ <h2>Configure driver</h2>
</div>
</div>

<p class="host-hint" id="drv-host-hint" style="display:none"></p>

<div class="form-group" id="drv-unitid-group">
<label for="drv-unitid">Unit ID (Modbus)</label>
<input type="number" id="drv-unitid" value="1" min="0" max="255">
Expand Down
82 changes: 68 additions & 14 deletions web/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

// Collected state
var configuredDrivers = []; // array of driver objects ready for config.drivers
var selectedDevice = null; // { ip, port, protocol } from scan or manual entry
var selectedDevice = null; // { ip, port, protocol, hostname } from scan or manual entry
var selectedCatalog = null; // CatalogEntry from /api/drivers/catalog
var driverCatalog = []; // full catalog cache

Expand Down Expand Up @@ -95,7 +95,7 @@
'<td>' + identity + '</td>' +
'<td><button class="btn-use">Use this device</button></td>';
tr.querySelector('.btn-use').addEventListener('click', function () {
useScanDevice(d.ip, d.port, proto, match && match.driver);
useScanDevice(d, proto, match && match.driver);
});
tbody.appendChild(tr);
});
Expand All @@ -112,16 +112,22 @@
document.getElementById('manual-ip-toggle').style.display = 'none';
};

function useScanDevice(ip, port, protocol, matchedFilename) {
selectedDevice = { ip: ip, port: port, protocol: protocol, matchedFilename: matchedFilename || '' };
function useScanDevice(dev, protocol, matchedFilename) {
selectedDevice = {
ip: dev.ip,
port: dev.port,
protocol: protocol,
hostname: dev.hostname || '',
matchedFilename: matchedFilename || ''
};
goStep(4);
}

window.useManualDevice = function () {
var ip = document.getElementById('manual-ip').value.trim();
var host = document.getElementById('manual-ip').value.trim();
var port = parseInt(document.getElementById('manual-port').value, 10) || 502;
if (!ip) return;
selectedDevice = { ip: ip, port: port, protocol: guessProtocol(port) };
if (!host) return;
selectedDevice = { ip: host, port: port, protocol: guessProtocol(port), hostname: '' };
goStep(4);
};

Expand Down Expand Up @@ -241,9 +247,15 @@
document.getElementById('drv-name').value = name;

if (selectedDevice) {
document.getElementById('drv-ip').value = selectedDevice.ip;
// Prefer the device's self-broadcast mDNS (.local) name over the raw
// IP: the name follows the device across DHCP lease changes, the IP
// doesn't. Other reverse-DNS names stay display-only — resolving them
// at runtime depends on the router, so the IP is the safer default.
var host = isMDNSName(selectedDevice.hostname) ? selectedDevice.hostname : selectedDevice.ip;
document.getElementById('drv-ip').value = host;
document.getElementById('drv-port').value = selectedDevice.port;
}
updateHostHint();

// Show/hide unit ID for modbus
var isModbus = !selectedCatalog.protocols || selectedCatalog.protocols.length === 0 ||
Expand Down Expand Up @@ -290,11 +302,51 @@
});
}

// A self-broadcast mDNS name (RFC 6762 .local). Only these are safe to
// prefer over the IP — any other hostname needs the router's DNS to
// resolve, which FTW can't verify from here.
function isMDNSName(host) {
return /\.local\.?$/i.test(host || '');
}

function isIPv4Literal(host) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host || '');
}

// Addressing hint under the host field: a .local name survives DHCP
// lease changes; a raw IP only stays valid if the operator reserves it
// for the device in the router's DHCP pool. Tracks user edits live.
function updateHostHint() {
var el = document.getElementById('drv-host-hint');
var input = document.getElementById('drv-ip');
if (!el || !input) return;
var host = input.value.trim();
el.style.display = 'none';
el.className = 'host-hint';
if (isMDNSName(host)) {
var discovered = '';
if (selectedDevice && selectedDevice.ip &&
host.toLowerCase() === (selectedDevice.hostname || '').toLowerCase()) {
discovered = ' (discovered at ' + selectedDevice.ip + ')';
}
el.textContent = 'Connecting by the device’s mDNS name' + discovered +
' — it keeps working even if your router assigns the device a new IP address.';
el.className = 'host-hint mdns';
el.style.display = 'block';
} else if (isIPv4Literal(host)) {
el.textContent = 'No mDNS (.local) name in use — FTW will connect to this fixed IP address. ' +
'Reserve it for the device in your router’s DHCP settings so a new ' +
'DHCP lease can’t move the device and break the connection.';
el.className = 'host-hint dhcp';
el.style.display = 'block';
}
}

window.saveDriver = function () {
var name = document.getElementById('drv-name').value.trim();
if (!name) { alert('Driver name is required.'); return; }

var ip = document.getElementById('drv-ip').value.trim();
var host = document.getElementById('drv-ip').value.trim();
var port = parseInt(document.getElementById('drv-port').value, 10);
var unitId = parseInt(document.getElementById('drv-unitid').value, 10) || 1;
var isSiteMeter = document.getElementById('drv-site-meter').checked;
Expand All @@ -310,7 +362,7 @@
? (parseFloat(document.getElementById('drv-battery-kwh').value) || 0)
: 0;

if (!ip) { alert('IP address is required.'); return; }
if (!host) { alert('IP address or hostname is required.'); return; }

// Determine protocol from catalog entry
var protocol = 'modbus';
Expand All @@ -336,11 +388,11 @@
}

if (protocol === 'modbus') {
driver.capabilities.modbus = { host: ip, port: port, unit_id: unitId };
driver.capabilities.modbus = { host: host, port: port, unit_id: unitId };
} else if (protocol === 'mqtt') {
driver.capabilities.mqtt = { host: ip, port: port };
driver.capabilities.mqtt = { host: host, port: port };
} else if (protocol === 'http') {
driver.capabilities.http = { allowed_hosts: [ip] };
driver.capabilities.http = { allowed_hosts: [host] };
// connection_defaults.host is declared only by drivers that take a
// user-configurable local endpoint — seed config.host from the IP the
// user just entered. Cloud drivers (Easee etc.) declare http_hosts
Expand All @@ -353,7 +405,7 @@
// Cloud drivers don't declare it and key off email/password.
if (Object.prototype.hasOwnProperty.call(connDefaults, 'host')) {
driver.config = driver.config || {};
driver.config.host = ip;
driver.config.host = host;
}
}

Expand Down Expand Up @@ -784,6 +836,8 @@
return n;
}

document.getElementById('drv-ip').addEventListener('input', updateHostHint);

renderDots();
goStep(initialStep());
})();
23 changes: 23 additions & 0 deletions web/setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,26 @@ describe("price provider defaults", () => {
assert.match(PRICE_JS, /\["sourceful", "elprisetjustnu", "entsoe", "none"\], "sourceful"/);
});
});

describe("setup wizard — mDNS-first device addressing", () => {
it("carries the discovered hostname into the selected device", () => {
assert.match(JS, /hostname:\s*dev\.hostname/,
"useScanDevice must not drop the scan's mDNS/DNS hostname");
});

it("prefers a self-broadcast .local name over the raw IP when prefilling", () => {
assert.match(JS, /isMDNSName\(selectedDevice\.hostname\)\s*\?\s*selectedDevice\.hostname\s*:\s*selectedDevice\.ip/,
"the host field must prefill the mDNS name when the device broadcasts one");
assert.match(JS, /\\\.local\\\.\?\$/,
"only RFC 6762 .local names qualify — other DNS names depend on the router");
});

it("tells the operator to reserve a raw IP in the router's DHCP pool", () => {
assert.match(HTML, /id=["']drv-host-hint["']/,
"the addressing hint element must exist under the host field");
assert.match(JS, /Reserve it for the device in your router/,
"an IP-literal host must surface the DHCP-reservation warning");
assert.match(JS, /addEventListener\(['"]input['"],\s*updateHostHint\)/,
"the hint must track manual edits of the host field");
});
});