Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
41 changes: 41 additions & 0 deletions public/js/midea.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@
<span>${esc(d.name)} <span class="muted">${esc(d.sn)}</span></span>
<button class="btn btn-sm" data-add="${esc(d.sn)}" data-name="${esc(d.name)}">${T('midea.devices.add')}</button>
</div>`).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) {
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;
o.textContent = `${d.name} (${d.sn})`;
sel.appendChild(o);
}
}
} catch { /* not connected yet */ }
}

Expand All @@ -109,6 +121,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();
})();
6 changes: 6 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 47 additions & 2 deletions src/services/midea/mideaLan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 14 additions & 0 deletions templates/aurora/pages/midea.njk
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@
<p id="midea-cloud-msg" class="muted"></p>
</section>

<section class="card">
<h2>{{ t('midea.manual.title') }}</h2>
<p class="subtitle">{{ t('midea.manual.hint') }}</p>
<form id="midea-ip-form" class="form-grid">
<label>{{ t('midea.manual.ip') }}<input name="ip" type="text" inputmode="decimal" placeholder="192.168.x.y" autocomplete="off"></label>
<label>{{ t('midea.manual.name') }}<input name="name" type="text" autocomplete="off"></label>
<label>{{ t('midea.manual.cloud_device') }}
<select name="sn"><option value="">{{ t('midea.manual.no_cloud') }}</option></select>
</label>
<button type="submit" class="btn btn-primary">{{ t('midea.devices.add') }}</button>
</form>
<p id="midea-ip-msg" class="muted"></p>
</section>

<section class="card">
<h2>{{ t('midea.devices.title') }}</h2>
<div id="midea-cloud-list"></div>
Expand Down
18 changes: 18 additions & 0 deletions templates/default/pages/midea.njk
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@
</div>
</div>

<div class="card" style="margin-bottom:16px">
<div class="card-head">
<span class="card-title">{{ t('midea.manual.title') }}</span>
</div>
<div class="card-body">
<p style="color:var(--text-3);margin-bottom:8px">{{ t('midea.manual.hint') }}</p>
<form id="midea-ip-form" class="form-grid">
<label>{{ t('midea.manual.ip') }}<input name="ip" type="text" inputmode="decimal" placeholder="192.168.x.y" autocomplete="off"></label>
<label>{{ t('midea.manual.name') }}<input name="name" type="text" autocomplete="off"></label>
<label>{{ t('midea.manual.cloud_device') }}
<select name="sn"><option value="">{{ t('midea.manual.no_cloud') }}</option></select>
</label>
<button type="submit" class="btn btn-primary">{{ t('midea.devices.add') }}</button>
</form>
<p id="midea-ip-msg" style="color:var(--text-3);margin-top:8px"></p>
</div>
</div>

<div class="card">
<div class="card-head">
<span class="card-title">{{ t('midea.devices.title') }}</span>
Expand Down
18 changes: 18 additions & 0 deletions templates/pro/pages/midea.njk
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@
</div>
</div>

<div class="card" style="margin-bottom:16px">
<div class="card-head">
<span class="card-title">{{ t('midea.manual.title') }}</span>
</div>
<div class="card-body">
<p style="color:var(--text-3);margin-bottom:8px">{{ t('midea.manual.hint') }}</p>
<form id="midea-ip-form" class="form-grid">
<label>{{ t('midea.manual.ip') }}<input name="ip" type="text" inputmode="decimal" placeholder="192.168.x.y" autocomplete="off"></label>
<label>{{ t('midea.manual.name') }}<input name="name" type="text" autocomplete="off"></label>
<label>{{ t('midea.manual.cloud_device') }}
<select name="sn"><option value="">{{ t('midea.manual.no_cloud') }}</option></select>
</label>
<button type="submit" class="btn btn-primary">{{ t('midea.devices.add') }}</button>
</form>
<p id="midea-ip-msg" style="color:var(--text-3);margin-top:8px"></p>
</div>
</div>

<div class="card">
<div class="card-head">
<span class="card-title">{{ t('midea.devices.title') }}</span>
Expand Down
13 changes: 13 additions & 0 deletions tests/midea_lan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ test('detectVersion by magic bytes', () => {
assert.equal(lan.detectVersion(Buffer.from('3c3f786d', 'hex')), 1); // '<?xm' → V1 XML
});

test('computeBroadcast derives the subnet-directed broadcast address', () => {
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', () => {
Expand Down
Loading