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
11 changes: 11 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,17 @@ body::before{
.midea-login-hint a:hover{filter:brightness(1.05)}
.midea-msg{font-size:13px;color:var(--muted);padding:10px 0;line-height:1.5}

/* ============================================================
SMART HOME WIDGET
============================================================ */
.c-sh-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
.c-sh-card{background:var(--surface-2,#16212e);border:1px solid var(--line,rgba(255,255,255,.08));border-radius:12px;padding:12px}
.c-sh-name{font-weight:600;font-size:14px;margin-bottom:10px}
.c-sh-sw{border:1px solid var(--line-2,rgba(255,255,255,.14));border-radius:8px;padding:6px 12px;cursor:pointer;background:var(--surface-3,#1b2836);color:inherit}
.c-sh-sw.on{background:linear-gradient(145deg,var(--green,#4ade80),#15924f);border-color:transparent;color:#fff}
.c-sh-bri{width:100%;margin-top:10px}
.c-sh-msg{margin-top:10px;font-size:13px;color:var(--muted,#90a1b3)}

/* ============================================================
REDUCED MOTION
============================================================ */
Expand Down
7 changes: 7 additions & 0 deletions public/css/smarthome.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ input[type=range].sh-bri::-webkit-slider-thumb{-webkit-appearance:none;width:19p
.sh-sensorval{font-weight:800;font-size:26px;margin-top:10px}
.sh-empty{color:var(--faint,#5f6f7e);font-size:13px;padding:20px 0}
#sh-test-result{font-size:13px;font-weight:600;padding:8px 12px;border-radius:8px;margin-bottom:12px}
.sh-owner-sub{font-size:12px;color:var(--muted,#90a1b3);margin-bottom:10px}
.sh-owner-list{margin-top:10px;max-height:300px;overflow:auto;display:flex;flex-direction:column;gap:4px}
.sh-owner-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer}
.sh-owner-row:hover{background:var(--surface-3,#1b2836)}
.sh-owner-row input{width:16px;height:16px}
.sh-owner-btn{margin-top:10px;font-size:12px;color:var(--accent,#8b9cff);background:none;border:none;cursor:pointer;padding:0;display:flex;align-items:center;gap:6px}
.sh-owner-chips{font-size:12px;color:var(--muted,#90a1b3);margin-top:6px}
47 changes: 47 additions & 0 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,52 @@
if (patch) mideaControl(cardEl, patch);
}

// ─── Smart Home widget ──────────────────────────────────────────────────────
function shControl(id, patch, card) {
fetch('/api/v1/portal/smarthome/' + Number(id) + '/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patch: patch })
}).then(function (r) { return r.json(); }).then(function (j) {
if (j && j.reason === 'login_required') { showSmarthomeLogin(card); }
}).catch(function () {});
}
function showSmarthomeLogin(card) {
var msg = document.getElementById('smarthomeMsg');
if (msg) { msg.style.display = 'block'; msg.textContent = PT.smarthomeLoginToControl || 'Login required'; }
}
function renderSmarthomeCard(d) {
var el = document.createElement('div'); el.className = 'c-sh-card';
var st = d.state || {}, caps = d.capabilities || {};
var name = document.createElement('div'); name.className = 'c-sh-name'; name.textContent = d.name || ''; el.appendChild(name);
if (d.kind === 'scene') {
var b = document.createElement('button'); b.className = 'btn btn-sm'; b.textContent = PT.smarthomeActivate || 'Activate';
b.addEventListener('click', function () { shControl(d.id, {}, el); });
el.appendChild(b); return el;
}
var sw = document.createElement('button'); sw.className = 'c-sh-sw' + (st.on ? ' on' : '');
sw.textContent = PT.smarthomePower || 'Power';
sw.addEventListener('click', function () { var on = !sw.classList.contains('on'); sw.classList.toggle('on', on); shControl(d.id, { on: on }, el); });
el.appendChild(sw);
if (caps.bri) {
var range = document.createElement('input'); range.type = 'range'; range.min = 0; range.max = 100; range.value = (st.bri != null ? st.bri : 0); range.className = 'c-sh-bri';
range.addEventListener('change', function () { shControl(d.id, { bri: Number(range.value) }, el); });
el.appendChild(range);
}
return el;
}
function hydrateSmarthome() {
var card = document.querySelector('.c-smarthome');
if (!card) return;
fetch('/api/v1/portal/smarthome').then(function (r) { return r.json(); }).then(function (j) {
var list = document.getElementById('smarthome-list'); if (!list) return;
list.innerHTML = '';
if (!j || !j.data || !j.data.devices || !j.data.devices.length) { card.style.display = 'none'; return; }
card.style.display = '';
j.data.devices.forEach(function (d) { list.appendChild(renderSmarthomeCard(d)); });
}).catch(function () { card.style.display = 'none'; });
}

function hydrateMidea() {
var card = document.querySelector('.c-midea');
if (!card) return;
Expand Down Expand Up @@ -737,5 +783,6 @@
hydrateServices();
hydratePihole();
hydrateMidea();
hydrateSmarthome();

})();
8 changes: 6 additions & 2 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1770,11 +1770,12 @@
var widgetServices = document.getElementById('portal-widget-services');
var widgetPihole = document.getElementById('portal-widget-pihole');
var widgetMidea = document.getElementById('portal-widget-midea');
var widgetSmarthome = document.getElementById('portal-widget-smarthome');
var trustToggle = document.getElementById('portal-trust-owner-mapping');
var autoappearToggle = document.getElementById('portal-autoappear');
if (!enabledToggle) return;

[enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, trustToggle, autoappearToggle].forEach(function (el) {
[enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].forEach(function (el) {
if (el) el.addEventListener('click', function () {
el.classList.toggle('on');
el.dispatchEvent(new Event('change'));
Expand All @@ -1795,14 +1796,15 @@
setToggle(widgetServices, d.widgets && d.widgets.services);
setToggle(widgetPihole, d.widgets && d.widgets.pihole);
setToggle(widgetMidea, d.widgets && d.widgets.midea);
setToggle(widgetSmarthome, d.widgets && d.widgets.smarthome);
setToggle(trustToggle, d.trustOwnerMapping);
setToggle(autoappearToggle, d.autoappear !== false);
if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal');
}).catch(function (err) {
console.error('Failed to load portal settings:', err);
});

var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, trustToggle, autoappearToggle].filter(Boolean);
var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole, widgetMidea, widgetSmarthome, trustToggle, autoappearToggle].filter(Boolean);
SettingsAutosave.bind({
cluster: 'portal',
fields: portalFields,
Expand All @@ -1815,6 +1817,7 @@
'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true,
'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true,
'portal-widget-midea': widgetMidea ? widgetMidea.classList.contains('on') : true,
'portal-widget-smarthome': widgetSmarthome ? widgetSmarthome.classList.contains('on') : true,
'portal-trust-owner-mapping': trustToggle ? trustToggle.classList.contains('on') : false,
'portal-autoappear': autoappearToggle ? autoappearToggle.classList.contains('on') : true,
};
Expand All @@ -1828,6 +1831,7 @@
services: widgetServices ? widgetServices.classList.contains('on') : true,
pihole: widgetPihole ? widgetPihole.classList.contains('on') : true,
midea: widgetMidea ? widgetMidea.classList.contains('on') : true,
smarthome: widgetSmarthome ? widgetSmarthome.classList.contains('on') : true,
},
trust_owner_mapping: trustToggle ? trustToggle.classList.contains('on') : false,
autoappear: autoappearToggle ? autoappearToggle.classList.contains('on') : true,
Expand Down
67 changes: 66 additions & 1 deletion public/js/smarthome.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@
wrap.querySelector('input').addEventListener('change', (e) => send(r.id, { ct: Number(e.target.value) }).catch(() => {}));
body.appendChild(wrap);
}
if (r.kind === 'light' || r.kind === 'plug' || r.kind === 'group') {
const own = document.createElement('div');
const names = (r.owners || []).map((o) => o.username);
own.innerHTML = `<div class="sh-owner-chips">${names.length ? esc(names.join(', ')) : esc(T('smarthome.owners.none'))}</div>`;
const btn = document.createElement('button'); btn.className = 'sh-owner-btn'; btn.type = 'button';
btn.textContent = T('smarthome.owners.manage');
btn.addEventListener('click', () => openOwners(r));
own.appendChild(btn);
body.appendChild(own);
}
el.appendChild(body);
return el;
}
Expand All @@ -110,6 +120,11 @@
btn.textContent = T('smarthome.activate');
btn.addEventListener('click', () => send(r.id, {}).catch(() => {}));
el.appendChild(btn);
if (r.owners && r.owners.length) {
const chip = document.createElement('div'); chip.className = 'sh-owner-chips';
chip.textContent = r.owners.map((o) => o.username).join(', ');
el.appendChild(chip);
}
return el;
}

Expand Down Expand Up @@ -201,6 +216,7 @@
const ae = document.activeElement;
if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
if ($('#sh-connect-modal') && $('#sh-connect-modal').style.display === 'flex') return;
if ($('#sh-owner-modal') && $('#sh-owner-modal').style.display === 'flex') return;
const sel = $('#sh-gateway-select');
loadResources(sel && sel.value ? Number(sel.value) : undefined);
}, 30000);
Expand Down Expand Up @@ -266,6 +282,55 @@
});
}

document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); startAutoPoll(); });
let allUsers = null;
async function fetchUsers() {
if (allUsers) return allUsers;
try {
// /api/v1/users returns { ok:true, users:[...] }
const r = await (await fetch('/api/v1/users')).json(); allUsers = (r.users || []).map((u) => ({ id: u.id, username: u.username }));
} catch (_) { allUsers = []; }
return allUsers;
}
let ownerTarget = null;
async function openOwners(r) {
ownerTarget = r;
const modal = $('#sh-owner-modal'); if (!modal) return;
$('#sh-owner-sub').textContent = r.name || '';
$('#sh-owner-search').value = '';
const users = await fetchUsers();
const ownedIds = new Set((r.owners || []).map((o) => o.id));
const list = $('#sh-owner-list');
list.innerHTML = '';
users.forEach((u) => {
const row = document.createElement('label'); row.className = 'sh-owner-row'; row.dataset.name = (u.username || '').toLowerCase();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = String(u.id); cb.checked = ownedIds.has(u.id);
const span = document.createElement('span'); span.textContent = u.username;
row.appendChild(cb); row.appendChild(span); list.appendChild(row);
});
modal.style.display = 'flex';
}
function wireOwners() {
const search = $('#sh-owner-search');
if (search) search.addEventListener('input', () => {
const q = search.value.toLowerCase();
document.querySelectorAll('#sh-owner-list .sh-owner-row').forEach((row) => {
row.style.display = row.dataset.name.includes(q) ? '' : 'none';
});
});
const save = $('#sh-owner-save');
if (save) save.addEventListener('click', async () => {
if (!ownerTarget) return;
const ids = [...document.querySelectorAll('#sh-owner-list input:checked')].map((c) => Number(c.value));
try {
await api(`/resources/${ownerTarget.id}/owners`, { method: 'PUT', body: JSON.stringify({ userIds: ids }) });
$('#sh-owner-modal').style.display = 'none';
const sel = $('#sh-gateway-select'); await loadResources(sel && sel.value ? Number(sel.value) : undefined);
} catch (e) { alert(e.message); }
});
document.querySelectorAll('[data-sh-close-owner]').forEach((el) =>
el.addEventListener('click', () => { const m = $('#sh-owner-modal'); if (m) m.style.display = 'none'; }));
}

document.addEventListener('DOMContentLoaded', () => { wireModal(); fillRoutes(); wireConnect(); wireTest(); loadGateways(); startAutoPoll(); wireOwners(); });
window.SmartHome = { loadGateways, loadResources, api };
})();
17 changes: 16 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1928,6 +1928,7 @@
"settings.portal.widget_services": "Dienste",
"settings.portal.widget_pihole": "Pi-hole-Widget",
"settings.portal.widget_midea": "Klima-Widget",
"settings.portal.widget_smarthome": "Smart Home",
"settings.portal.autoappear": "Portal automatisch öffnen beim Verbinden",
"settings.portal.autoappear_help": "Wenn aktiv, öffnen VPN-Clients das Portal nach dem Verbinden automatisch. Deaktivieren, um das Portal erreichbar zu halten, ohne es automatisch zu öffnen.",
"settings.portal.trust_owner_mapping": "Gerät→Besitzer-Vertrauen (pro-Besitzer ohne Login)",
Expand Down Expand Up @@ -2087,6 +2088,10 @@
"error.smarthome.link_button": "Phoscon-Link-Fenster abgelaufen — „App authentifizieren“ erneut drücken",
"error.smarthome.no_route": "Gewählte Route ist nicht auflösbar",
"error.smarthome.no_api_key": "Gateway hat keinen API-Key — erneut verbinden",
"error.smarthome.not_assignable": "Dieser Gerätetyp kann keinen Nutzern zugewiesen werden",
"error.smarthome.owner_unknown_user": "Unbekannter Nutzer in der Besitzerliste",
"error.smarthome.user_ids_required": "userIds muss ein Array sein",
"error.smarthome.resource_not_found": "Smart-Home-Ressource nicht gefunden",
"smarthome.title": "Smart Home",
"smarthome.subtitle": "Phoscon/deCONZ-Lichter und -Sensoren einbinden, Haushaltsmitgliedern zuweisen und Logikketten erstellen.",
"smarthome.eyebrow": "System · Smart Home",
Expand Down Expand Up @@ -2135,11 +2140,21 @@
"smarthome.connect.apikey_ph": "leer lassen für Auto-Holen",
"smarthome.connect.apikey_hint": "Leer = GateControl holt den Key automatisch (vorher in Phoscon „App authentifizieren“).",
"smarthome.connect.acquire": "Verbinden & Key holen",
"smarthome.owners.title": "Besitzer zuweisen",
"smarthome.owners.manage": "Besitzer verwalten",
"smarthome.owners.none": "Niemand zugewiesen",
"smarthome.owners.save": "Speichern",
"smarthome.owners.search": "Nutzer suchen…",
"portal.midea.fan": "Lüfter",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
"portal.midea.extras": "Extras",
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Außen"
"portal.midea.outdoor": "Außen",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Ein/Aus",
"portal.smarthome.brightness": "Helligkeit",
"portal.smarthome.activate": "Aktivieren",
"portal.smarthome.login_to_control": "Zum Steuern bitte anmelden"
}
17 changes: 16 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,7 @@
"settings.portal.widget_services": "Services",
"settings.portal.widget_pihole": "Pi-hole widget",
"settings.portal.widget_midea": "Climate widget",
"settings.portal.widget_smarthome": "Smart Home",
"settings.portal.autoappear": "Auto-open portal on connect",
"settings.portal.autoappear_help": "When enabled, VPN clients automatically open the portal after connecting. Disable to keep the portal accessible but not auto-opened.",
"settings.portal.trust_owner_mapping": "Trust device→owner mapping (zero-login per-owner)",
Expand Down Expand Up @@ -2143,6 +2144,10 @@
"error.smarthome.link_button": "Phoscon link window expired — press \"Authenticate app\" again",
"error.smarthome.no_route": "Selected route cannot be resolved",
"error.smarthome.no_api_key": "Gateway has no API key — connect again",
"error.smarthome.not_assignable": "This device type cannot be assigned to users",
"error.smarthome.owner_unknown_user": "Unknown user in owner list",
"error.smarthome.user_ids_required": "userIds must be an array",
"error.smarthome.resource_not_found": "Smart Home resource not found",
"smarthome.title": "Smart Home",
"smarthome.subtitle": "Connect Phoscon/deCONZ lights and sensors, assign to household members, and create automation rules.",
"smarthome.eyebrow": "System · Smart Home",
Expand Down Expand Up @@ -2191,11 +2196,21 @@
"smarthome.connect.apikey_ph": "leave empty for auto-fetch",
"smarthome.connect.apikey_hint": "Empty = GateControl fetches the key automatically (first authenticate the app in Phoscon).",
"smarthome.connect.acquire": "Connect & fetch key",
"smarthome.owners.title": "Assign owners",
"smarthome.owners.manage": "Manage owners",
"smarthome.owners.none": "None assigned",
"smarthome.owners.save": "Save",
"smarthome.owners.search": "Search users…",
"portal.midea.fan": "Fan",
"portal.midea.fan_auto": "Auto",
"portal.midea.fan_silent": "Silent",
"portal.midea.extras": "Extras",
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Outdoor"
"portal.midea.outdoor": "Outdoor",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Power",
"portal.smarthome.brightness": "Brightness",
"portal.smarthome.activate": "Activate",
"portal.smarthome.login_to_control": "Log in to control"
}
Loading
Loading