Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [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`.

---

## [1.101.1] — 2026-06-26

### Änderungen
Expand Down
114 changes: 114 additions & 0 deletions public/js/midea.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use strict';
(function () {
const GC = window.GC || {};
const headers = { 'Content-Type': 'application/json', 'x-csrf-token': GC.csrfToken };
const T = (k) => (GC.t && GC.t[k]) || k;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

async function api(method, path, body) {
const res = await fetch('/api/v1/midea' + path, {
method, headers, body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code });
return json;
}

const $ = (sel) => document.querySelector(sel);

async function loadDevices() {
const { devices } = await api('GET', '/devices');
const el = $('#midea-devices');
if (!devices.length) { el.innerHTML = `<p class="muted">${T('midea.devices.none')}</p>`; return; }
el.innerHTML = devices.map((d) => `
<div class="device-row" data-id="${d.id}">
<strong>${esc(d.name)}</strong> <span class="muted">${esc(d.ip || '')} · v${d.protocol_version}</span>
<span class="device-state"></span>
<label>${T('midea.device.target')} <input type="number" step="0.5" min="16" max="30" data-act="target" style="width:5em"></label>
<select data-act="mode">
${['auto','cool','heat','dry','fan'].map((m) => `<option value="${m}">${T('midea.mode.' + m)}</option>`).join('')}
</select>
<button class="btn btn-sm" data-act="test">${T('midea.device.test')}</button>
<button class="btn btn-sm" data-act="power">${T('midea.device.power')}</button>
<button class="btn btn-sm" data-act="remove">${T('midea.device.remove')}</button>
</div>`).join('');
}

async function refreshState(id, row) {
try {
const { state } = await api('GET', `/devices/${id}/state`);
row.querySelector('.device-state').textContent = state.offline
? T('midea.device.offline')
: `${state.power ? T('midea.device.on') : T('midea.device.off')} · ${T('midea.device.indoor')} ${state.indoorTemp}° · → ${state.targetTemp}° · ${T('midea.mode.' + state.mode)}`;
} catch (e) { row.querySelector('.device-state').textContent = e.message; }
}

document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
const row = btn.closest('.device-row');
const id = row.dataset.id;
try {
if (btn.dataset.act === 'test') { await api('POST', `/devices/${id}/test`); await refreshState(id, row); }
if (btn.dataset.act === 'power') {
const { state } = await api('GET', `/devices/${id}/state`);
await api('POST', `/devices/${id}/state`, { patch: { power: !(state && state.power) } });
await refreshState(id, row);
}
if (btn.dataset.act === 'remove') { await api('DELETE', `/devices/${id}`); await loadDevices(); }
} catch (e) { alert(e.message); }
});

document.addEventListener('change', async (ev) => {
const ctrl = ev.target.closest('[data-act="target"],[data-act="mode"]');
if (!ctrl) return;
const row = ctrl.closest('.device-row'); const id = row.dataset.id;
const patch = ctrl.dataset.act === 'target' ? { targetTemp: Number(ctrl.value) } : { mode: ctrl.value };
try { await api('POST', `/devices/${id}/state`, { patch }); await refreshState(id, row); }
catch (e) { alert(e.message); }
});

$('#midea-cloud-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const f = ev.target;
const msg = $('#midea-cloud-msg');
msg.textContent = '…';
try {
await api('POST', '/cloud/connect', { app: f.app.value, email: f.email.value, password: f.password.value });
msg.textContent = T('midea.cloud.connected');
await loadCloudDevices();
} catch (e) {
msg.textContent = e.code === 'MIDEA_CLOUD_2FA_REQUIRED' ? T('midea.cloud.twofa') : e.message;
}
});

async function loadCloudDevices() {
try {
const { devices } = await api('GET', '/cloud/devices');
$('#midea-cloud-list').innerHTML = devices.map((d) => `
<div class="cloud-row">
<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('');
} catch { /* not connected yet */ }
}

document.addEventListener('click', async (ev) => {
const add = ev.target.closest('button[data-add]');
if (!add) return;
add.disabled = true;
try { await api('POST', '/devices', { sn: add.dataset.add, name: add.dataset.name }); await loadDevices(); }
catch (e) { alert(e.message); } finally { add.disabled = false; }
});

$('#midea-discover').addEventListener('click', async () => {
try { const { devices } = await api('POST', '/discover'); alert(`${devices.length} ${T('midea.discover.result')}`); }
catch (e) { alert(e.message); }
});

loadDevices();
loadCloudDevices();
})();
22 changes: 22 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,28 @@ const migrations = [
`,
detect: (db) => hasColumn(db, 'peers', 'user_id'),
},
{
version: 60,
name: 'create_midea_devices',
sql: `CREATE TABLE IF NOT EXISTS midea_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
device_sn TEXT NOT NULL UNIQUE,
device_id TEXT,
ip TEXT,
port INTEGER NOT NULL DEFAULT 6444,
protocol_version INTEGER NOT NULL DEFAULT 3,
token_enc TEXT,
key_enc TEXT,
model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_midea_enabled ON midea_devices(enabled);`,
detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='midea_devices'").get(),
},
];

module.exports = { migrations };
34 changes: 33 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2040,5 +2040,37 @@
"settings.domains.server_ip_override": "Server-IP überschreiben",
"settings.domains.server_ip_warning": "Keine Domain konnte gegen die Server-IP bestätigt werden — bitte Server-IP prüfen/überschreiben. Bestehende Routen laufen weiter.",
"settings.domains.points_note": "Verifiziert heißt: öffentliches DNS zeigt auf die Server-IP. Bestehende Routen/Portal laufen unabhängig weiter.",
"settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern."
"settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern.",
"nav.midea": "Klimaanlage",
"midea.title": "Klimaanlage",
"midea.subtitle": "Midea-Klimageräte im Heimnetz steuern",
"midea.cloud.title": "Midea-Konto",
"midea.cloud.app": "App",
"midea.cloud.email": "E-Mail",
"midea.cloud.password": "Passwort",
"midea.cloud.connect": "Verbinden",
"midea.cloud.connected": "Konto verbunden",
"midea.cloud.twofa": "Zwei-Faktor/Captcha nötig — in der Midea-App bestätigen, dann erneut versuchen",
"midea.cloud.ratelimit": "Zu viele Anfragen — kurz warten und erneut versuchen",
"midea.discover": "Geräte entdecken",
"midea.discover.result": "Gerät(e) gefunden",
"midea.devices.title": "Geräte",
"midea.devices.add": "Hinzufügen",
"midea.devices.none": "Noch keine Geräte",
"midea.device.test": "Testen",
"midea.device.power": "Ein/Aus",
"midea.device.target": "Zieltemperatur",
"midea.device.indoor": "Innen",
"midea.device.mode": "Modus",
"midea.device.remove": "Entfernen",
"midea.device.offline": "Offline",
"midea.device.on": "Ein",
"midea.device.off": "Aus",
"midea.mode.auto": "Automatik",
"midea.mode.cool": "Kühlen",
"midea.mode.heat": "Heizen",
"midea.mode.dry": "Entfeuchten",
"midea.mode.fan": "Lüften",
"error.midea.email_password_required": "E-Mail und Passwort sind erforderlich",
"error.midea.sn_or_ip_required": "Geräte-SN oder IP ist erforderlich"
}
34 changes: 33 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2040,5 +2040,37 @@
"settings.domains.server_ip_override": "Override server IP",
"settings.domains.server_ip_warning": "Could not confirm any domain against the server IP — please check/override the server IP. Existing routes keep working.",
"settings.domains.points_note": "Verified means: public DNS points to this server's IP. Existing routes/portal keep working regardless.",
"settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first."
"settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first.",
"nav.midea": "Air Conditioning",
"midea.title": "Air Conditioning",
"midea.subtitle": "Control Midea climate devices on your LAN",
"midea.cloud.title": "Midea account",
"midea.cloud.app": "App",
"midea.cloud.email": "Email",
"midea.cloud.password": "Password",
"midea.cloud.connect": "Connect",
"midea.cloud.connected": "Account connected",
"midea.cloud.twofa": "Two-factor/captcha required — complete it in the Midea app, then retry",
"midea.cloud.ratelimit": "Rate limited — wait a moment and retry",
"midea.discover": "Discover devices",
"midea.discover.result": "device(s) found",
"midea.devices.title": "Devices",
"midea.devices.add": "Add",
"midea.devices.none": "No devices yet",
"midea.device.test": "Test",
"midea.device.power": "Power",
"midea.device.target": "Target temperature",
"midea.device.indoor": "Indoor",
"midea.device.mode": "Mode",
"midea.device.remove": "Remove",
"midea.device.offline": "Offline",
"midea.device.on": "On",
"midea.device.off": "Off",
"midea.mode.auto": "Auto",
"midea.mode.cool": "Cool",
"midea.mode.heat": "Heat",
"midea.mode.dry": "Dry",
"midea.mode.fan": "Fan",
"error.midea.email_password_required": "Email and password are required",
"error.midea.sn_or_ip_required": "Device SN or IP is required"
}
1 change: 1 addition & 0 deletions src/routes/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ router.use('/license', require('./license'));
router.use('/client', require('./client'));
router.use('/rdp', require('./rdp'));
router.use('/pihole', require('./pihole'));
router.use('/midea', require('./midea'));

module.exports = router;
107 changes: 107 additions & 0 deletions src/routes/api/midea.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use strict';

const { Router } = require('express');
const { requireFeature } = require('../../middleware/license');
const users = require('../../services/users');
const midea = require('../../services/midea');

const router = Router();

// Admin-only (Spec §9): reject token auth, require an admin session.
// Guard order: admin check FIRST, then requireFeature — mirrors routes/api/users.js lines 35-50.
router.use((req, res, next) => {
if (req.tokenAuth) {
return res.status(403).json({ ok: false, error: req.t('error.users.session_required') });
}

if (!req.session || !req.session.userId) {
return res.status(401).json({ ok: false, error: req.t('error.users.unauthorized') });
}

const user = users.getById(req.session.userId);
if (!user || user.role !== 'admin') {
return res.status(403).json({ ok: false, error: req.t('error.users.admin_required') });
}

next();
});

router.use(requireFeature('midea_integration'));

// Async error mapper: maps typed error codes and messages to HTTP status codes.
function wrap(fn) {
return async (req, res) => {
try {
await fn(req, res);
} catch (err) {
const status =
err.code === 'MIDEA_CLOUD_2FA_REQUIRED' ? 409
: err.code === 'MIDEA_DEVICE_EXISTS' ? 409
: err.code === 'MIDEA_CLOUD_RATE_LIMITED' ? 429
: /not found/i.test(err.message) ? 404
: 502;
res.status(status).json({ ok: false, error: err.message, code: err.code || null });
}
};
}

// POST /cloud/connect — authenticate with Midea cloud
router.post('/cloud/connect', wrap(async (req, res) => {
const { email, password, app } = req.body || {};
if (!email || !password) {
return res.status(400).json({ ok: false, error: req.t('error.midea.email_password_required') });
}
res.json(await midea.connectCloud(email, password, app || 'msmarthome'));
}));

// GET /cloud/devices — list devices from cloud account
router.get('/cloud/devices', wrap(async (req, res) => {
res.json({ devices: await midea.listCloudDevices() });
}));

// POST /discover — LAN discovery scan
router.post('/discover', wrap(async (req, res) => {
res.json({ devices: await midea.discoverLan({}) });
}));

// POST /devices — add a device (sn or ip required)
router.post('/devices', wrap(async (req, res) => {
const { sn, name, ip } = req.body || {};
if (!sn && !ip) {
return res.status(400).json({ ok: false, error: req.t('error.midea.sn_or_ip_required') });
}
res.json({ device: await midea.addDevice({ sn, name, ip }) });
}));

// GET /devices — list all devices (redacted: no token/key)
router.get('/devices', wrap(async (req, res) => {
res.json({ devices: midea.getDevices() });
}));

// GET /devices/:id/state — fetch live state from device
router.get('/devices/:id/state', wrap(async (req, res) => {
res.json({ state: await midea.getState(Number(req.params.id)) });
}));

// POST /devices/:id/state — push state patch to device
router.post('/devices/:id/state', wrap(async (req, res) => {
const patch = (req.body && req.body.patch) || req.body || {};
res.json({ state: await midea.setState(Number(req.params.id), patch) });
}));

// POST /devices/:id/test — connectivity test
router.post('/devices/:id/test', wrap(async (req, res) => {
res.json(await midea.testConnection(Number(req.params.id)));
}));

// DELETE /devices/:id — remove device
router.delete('/devices/:id', wrap(async (req, res) => {
res.json(midea.removeDevice(Number(req.params.id)));
}));

// GET /status — orchestrator status (all devices + lastPollAt)
router.get('/status', wrap(async (req, res) => {
res.json(midea.getStatus());
}));

module.exports = router;
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const pages = [
{ path: '/users', template: 'users', titleKey: 'nav.users' },
{ path: '/dns', template: 'dns', titleKey: 'nav.dns' },
{ path: '/pihole', template: 'pihole', titleKey: 'pihole.title' },
{ path: '/midea', template: 'midea', titleKey: 'midea.title' },
{ path: '/gateway-pools', template: 'gateway-pools', titleKey: 'gateway_pools.title' },
{ path: '/gateways', template: 'gateways', titleKey: 'nav.gateways' },
];
Expand Down
4 changes: 4 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ async function start() {
try { require('./services/pihole').start(); }
catch (err) { logger.warn({ err: err.message }, 'pihole start failed'); }

// Midea poll loop — best-effort; no-op without license or enrolled devices.
try { require('./services/midea').startPolling(); }
catch (err) { logger.warn({ err: err.message }, 'midea start failed'); }

// Internal DNS — rebuild the addn-hosts file on boot so route domains
// resolve to the gateway immediately. Without this, the file only gets
// its route A-records on the next peer/route mutation, leaving internal
Expand Down
1 change: 1 addition & 0 deletions src/services/license.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const COMMUNITY_FALLBACK = {
split_tunnel_preset: false,
internal_dns: false,
pihole_integration: false,
midea_integration: false,
gateway_peers: 1,
gateway_http_targets: 3,
gateway_tcp_routing: false,
Expand Down
Loading
Loading