From c034046e78bce8da1ebee69f65d0ca1e9d057ad6 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:46:18 +0200 Subject: [PATCH 1/3] feat(midea): subnet-directed broadcast discovery for multi-homed hosts --- public/js/midea.js | 43 +++++++++++++++++++++++++++ src/i18n/de.json | 6 ++++ src/i18n/en.json | 6 ++++ src/services/midea/mideaLan.js | 49 +++++++++++++++++++++++++++++-- templates/aurora/pages/midea.njk | 14 +++++++++ templates/default/pages/midea.njk | 18 ++++++++++++ templates/pro/pages/midea.njk | 18 ++++++++++++ tests/midea_lan.test.js | 13 ++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) diff --git a/public/js/midea.js b/public/js/midea.js index 8d129bc5..fb079ba2 100644 --- a/public/js/midea.js +++ b/public/js/midea.js @@ -93,6 +93,20 @@ ${esc(d.name)} ${esc(d.sn)} `).join(''); + // Populate the manual-by-IP cloud-device picker (keep the static first + // "no cloud" option; append cloud entries via DOM API → XSS-safe). + const sel = document.querySelector('#midea-ip-form select[name="sn"]'); + if (sel) { + const first = sel.querySelector('option'); + sel.innerHTML = ''; + if (first) sel.appendChild(first); + for (const d of devices) { + const o = document.createElement('option'); + o.value = d.sn; + o.textContent = `${d.name} (${d.sn})`; + sel.appendChild(o); + } + } } catch { /* not connected yet */ } } @@ -109,6 +123,35 @@ catch (e) { alert(e.message); } }); + // Manual add by IP (when discovery can't reach the device). A selected cloud + // device (sn) makes it a V3 add (keys fetched from the cloud); none = V2. + const ipForm = $('#midea-ip-form'); + if (ipForm) ipForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const f = ev.target; + const ip = (f.ip.value || '').trim(); + const name = (f.name.value || '').trim(); + const sn = f.sn ? f.sn.value : ''; + const msg = $('#midea-ip-msg'); + if (!ip) { f.ip.focus(); return; } + const btn = f.querySelector('button[type="submit"]'); + if (btn) btn.disabled = true; + msg.textContent = '…'; + try { + const body = { ip }; + if (name) body.name = name; + if (sn) body.sn = sn; + const { device } = await api('POST', '/devices', body); + msg.textContent = '✓ ' + ((device && device.name) || ip); + f.reset(); + await loadDevices(); + } catch (e) { + msg.textContent = e.message; + } finally { + if (btn) btn.disabled = false; + } + }); + loadDevices(); loadCloudDevices(); })(); diff --git a/src/i18n/de.json b/src/i18n/de.json index 19ac4723..9f19573f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -2054,6 +2054,12 @@ "midea.cloud.ratelimit": "Zu viele Anfragen — kurz warten und erneut versuchen", "midea.discover": "Geräte entdecken", "midea.discover.result": "Gerät(e) gefunden", + "midea.manual.title": "Manuell hinzufügen (per IP)", + "midea.manual.hint": "Wenn die Erkennung die Anlage nicht findet (anderes Subnetz, WLAN-Client-Isolation, Multi-Homed-Host), per LAN-IP hinzufügen. Bei einem V3-Gerät das passende Cloud-Gerät wählen, damit die Schlüssel geholt werden können.", + "midea.manual.ip": "IP-Adresse", + "midea.manual.name": "Name (optional)", + "midea.manual.cloud_device": "Cloud-Gerät (für V3-Schlüssel)", + "midea.manual.no_cloud": "— V2-Gerät ohne Cloud", "midea.devices.title": "Geräte", "midea.devices.add": "Hinzufügen", "midea.devices.none": "Noch keine Geräte", diff --git a/src/i18n/en.json b/src/i18n/en.json index 80deee77..95f9d24c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2054,6 +2054,12 @@ "midea.cloud.ratelimit": "Rate limited — wait a moment and retry", "midea.discover": "Discover devices", "midea.discover.result": "device(s) found", + "midea.manual.title": "Add manually (by IP)", + "midea.manual.hint": "If discovery doesn't find the unit (different subnet, Wi-Fi client isolation, multi-homed host), add it by its LAN IP. For a V3 device pick its cloud entry so the keys can be fetched.", + "midea.manual.ip": "IP address", + "midea.manual.name": "Name (optional)", + "midea.manual.cloud_device": "Cloud device (for V3 keys)", + "midea.manual.no_cloud": "— V2 device without cloud", "midea.devices.title": "Devices", "midea.devices.add": "Add", "midea.devices.none": "No devices yet", diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js index 14183e19..366bdf56 100644 --- a/src/services/midea/mideaLan.js +++ b/src/services/midea/mideaLan.js @@ -108,6 +108,40 @@ module.exports = Object.assign(module.exports, { // ---- Part C: UDP discovery (discover.py) ---- const dgram = require('node:dgram'); +const os = require('node:os'); + +// Derive the subnet-directed broadcast address for an IPv4 address+netmask, +// e.g. ('192.168.1.50','255.255.255.0') → '192.168.1.255'. Returns null on +// malformed input (never throws). +function computeBroadcast(address, netmask) { + if (typeof address !== 'string' || typeof netmask !== 'string') return null; + // /^\d{1,3}$/ rejects empty octets (e.g. a trailing-dot '192.168.1.') and non-digits. + const toOctets = (s) => s.split('.').map((o) => (/^\d{1,3}$/.test(o) ? Number(o) : NaN)); + const a = toOctets(address); + const m = toOctets(netmask); + if (a.length !== 4 || m.length !== 4) return null; + if (a.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null; + if (m.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null; + return a.map((o, i) => ((o & m[i]) | (~m[i] & 0xff))).join('.'); +} + +// Subnet-directed broadcast address per non-internal IPv4 interface. On a +// multi-homed host (VPN gateway: wg0 + docker + LAN) a single global +// 255.255.255.255 only egresses one interface; directed broadcasts reach +// every attached subnet. Deduplicated. +function subnetBroadcasts() { + const out = new Set(); + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const ni of ifaces[name] || []) { + if (ni.internal) continue; + if (ni.family !== 'IPv4' && ni.family !== 4) continue; + const bc = computeBroadcast(ni.address, ni.netmask); + if (bc) out.add(bc); + } + } + return [...out]; +} // 72-byte broadcast probe — from const.py DISCOVERY_MSG const DISCOVERY_MSG = Buffer.from( @@ -157,13 +191,24 @@ function discover({ timeoutMs = 3000, broadcast = '255.255.255.255', ports = [64 sock.on('error', () => { try { sock.close(); } catch {} resolve([]); }); sock.bind(() => { sock.setBroadcast(true); - for (const port of ports) for (let i = 0; i < 3; i++) sock.send(DISCOVERY_MSG, port, broadcast); + // Global broadcast + every interface's subnet-directed broadcast, deduped. + const targets = [...new Set([broadcast, ...subnetBroadcasts()])]; + for (const tgt of targets) { + for (const port of ports) { + for (let i = 0; i < 3; i++) { + // Per-send callback absorbs a single target's failure (e.g. an + // interface that rejects directed broadcast) so it can't trip the + // socket 'error' handler and abort discovery for all targets. + sock.send(DISCOVERY_MSG, port, tgt, () => {}); + } + } + } }); setTimeout(() => { try { sock.close(); } catch {} resolve([...found.values()]); }, timeoutMs); }); } -module.exports = Object.assign(module.exports, { detectVersion, parseDiscoveryResponse, discover, DISCOVERY_MSG }); +module.exports = Object.assign(module.exports, { detectVersion, parseDiscoveryResponse, discover, computeBroadcast, subnetBroadcasts, DISCOVERY_MSG }); // ---- Part D: TCP transport LanDevice ---- const net = require('node:net'); diff --git a/templates/aurora/pages/midea.njk b/templates/aurora/pages/midea.njk index d1afc980..a2c30dc3 100644 --- a/templates/aurora/pages/midea.njk +++ b/templates/aurora/pages/midea.njk @@ -23,6 +23,20 @@

+
+

{{ t('midea.manual.title') }}

+

{{ t('midea.manual.hint') }}

+
+ + + + +
+

+
+

{{ t('midea.devices.title') }}

diff --git a/templates/default/pages/midea.njk b/templates/default/pages/midea.njk index eea01793..ce15170e 100644 --- a/templates/default/pages/midea.njk +++ b/templates/default/pages/midea.njk @@ -27,6 +27,24 @@ +
+
+ {{ t('midea.manual.title') }} +
+
+

{{ t('midea.manual.hint') }}

+
+ + + + +
+

+
+
+
{{ t('midea.devices.title') }} diff --git a/templates/pro/pages/midea.njk b/templates/pro/pages/midea.njk index eea01793..ce15170e 100644 --- a/templates/pro/pages/midea.njk +++ b/templates/pro/pages/midea.njk @@ -27,6 +27,24 @@
+
+
+ {{ t('midea.manual.title') }} +
+
+

{{ t('midea.manual.hint') }}

+
+ + + + +
+

+
+
+
{{ t('midea.devices.title') }} diff --git a/tests/midea_lan.test.js b/tests/midea_lan.test.js index 15caaa12..cc34f9b3 100644 --- a/tests/midea_lan.test.js +++ b/tests/midea_lan.test.js @@ -62,6 +62,19 @@ test('detectVersion by magic bytes', () => { assert.equal(lan.detectVersion(Buffer.from('3c3f786d', 'hex')), 1); // ' { + assert.equal(lan.computeBroadcast('192.168.1.50', '255.255.255.0'), '192.168.1.255'); + assert.equal(lan.computeBroadcast('10.0.5.4', '255.255.255.0'), '10.0.5.255'); + assert.equal(lan.computeBroadcast('172.16.5.4', '255.255.0.0'), '172.16.255.255'); + assert.equal(lan.computeBroadcast('192.168.1.50', '255.255.255.128'), '192.168.1.127'); + assert.equal(lan.computeBroadcast('10.1.2.3', '255.0.0.0'), '10.255.255.255'); + // malformed inputs → null (never throws) + assert.equal(lan.computeBroadcast('not-an-ip', '255.255.255.0'), null); + assert.equal(lan.computeBroadcast('192.168.1.1', 'bad'), null); + assert.equal(lan.computeBroadcast('192.168.1.', '255.255.255.0'), null); // trailing-dot / empty octet + assert.equal(lan.computeBroadcast('192.168.1.999', '255.255.255.0'), null); // out-of-range octet +}); + // ---- LanDevice ---- test('LanDevice requires token/key for V3', () => { From c74df771cbe1fc5c33fa2f3fad39019060b81ce6 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:46:18 +0200 Subject: [PATCH 2/3] feat(midea): manual add-by-IP form on the admin page (3 themes) --- public/js/midea.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/public/js/midea.js b/public/js/midea.js index fb079ba2..58b94b17 100644 --- a/public/js/midea.js +++ b/public/js/midea.js @@ -97,9 +97,7 @@ // "no cloud" option; append cloud entries via DOM API → XSS-safe). const sel = document.querySelector('#midea-ip-form select[name="sn"]'); if (sel) { - const first = sel.querySelector('option'); - sel.innerHTML = ''; - if (first) sel.appendChild(first); + sel.options.length = 1; // keep the static "no cloud" option, drop prior cloud entries for (const d of devices) { const o = document.createElement('option'); o.value = d.sn; From 355e9d15cbdcf324422817f50acda91d3c3e50bf Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:47:52 +0200 Subject: [PATCH 3/3] docs(midea): changelog entry for discovery + add-by-IP follow-ups --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aebef87..7cc95079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ ## [Unreleased] -### Features -- Midea-Klimasteuerung (TP1): nativer LAN-Protokoll-Port (Discovery/V2/V3) + Midea-Cloud-Login, Admin-Seite `/midea` zum Verbinden, Entdecken und Live-Testen von Klimageräten. Lizenz-gegated über `midea_integration`. +### Änderungen +- Midea: subnetz-gerichtete Geräteerkennung für Multi-Homed-Hosts — Discovery sendet jetzt je Netzwerk-Interface an dessen Subnetz-Broadcast statt nur global (`255.255.255.255`). +- Midea: „Manuell hinzufügen (per IP)" auf der `/midea`-Seite — Gerät über seine LAN-IP einbinden, wenn die Erkennung es nicht findet (V3-Schlüssel weiterhin aus der Cloud). ---