This commit is contained in:
Mateusz Gruszczyński
2026-09-14 16:32:28 +02:00
parent c3fbc5ccc6
commit 021cbddba5
68 changed files with 7780 additions and 202 deletions
+141 -8
View File
@@ -213,13 +213,62 @@ function openDevicePing(id) {
startPingMonitor();
}
function applyOptimisticCloudCommand(device, command) {
if (!device || device.connection_type !== 'gree_cloud' || !command || typeof command !== 'object') return;
const fields = [
'power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical',
'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep',
];
for (const field of fields) {
if (Object.prototype.hasOwnProperty.call(command, field) && command[field] !== undefined) {
device[field] = command[field];
}
}
device.pending_command = true;
// A stale transport error should not visually override the command the user has just sent.
// The backend/push path will restore it if the publish actually fails.
device.last_error = null;
renderDevices();
}
async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) {
const disabledZone = disabledZoneForDevice(id);
if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false;
const initialDevice = app.devices.find(device => device.id === id);
const isCloud = initialDevice?.connection_type === 'gree_cloud';
// Local keeps the historical synchronous UX exactly as before Cloud support was added.
if (!isCloud) {
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => { }).then(() => {
const current = app.devices.find(device => device.id === id);
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
const body = disabledZone ? { ...command, manual_override: true } : command;
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
});
app.deviceControlQueue[id] = request;
try {
const device = await request;
updateDevice(device); renderAll();
return true;
} catch (error) {
toast(error.message, true);
try { await loadBootstrap(); } catch (_) { }
return false;
} finally {
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
}
}
// Cloud controls update immediately in the browser. MQTT ACK/push is confirmation, not
// a prerequisite for button/temperature feedback. Requests are still serialized per unit.
const command = typeof commandOrFactory === 'function' ? commandOrFactory(initialDevice) : commandOrFactory;
if (!command || typeof command !== 'object') return false;
applyOptimisticCloudCommand(initialDevice, command);
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => { }).then(() => {
const current = app.devices.find(device => device.id === id);
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
const body = disabledZone ? { ...command, manual_override: true } : command;
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
});
@@ -246,7 +295,12 @@ function queueDeviceTemperature(id, delta) {
if (!confirmManualCommandForDisabledZone(id)) return;
draft = { ...(draft || {}), disabledZoneConfirmed: true };
}
const next = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), 8, 30);
const caps = device.capabilities || {};
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
const step = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
const rawNext = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), minTemp, maxTemp);
const next = Math.round(rawNext / step) * step;
device.target_temperature = next;
clearTimeout(draft?.timer);
draft = {
@@ -365,7 +419,10 @@ function beginInlineTemperatureEdit(target) {
}
const value = parseDecimal(target.dataset.value);
if (!Number.isFinite(value) || !kind || !id) return;
const decimals = kind === 'zone' ? 1 : 0;
const step = kind === 'device' ? Number(target.dataset.tempStep || 1) : 0.5;
const minValue = kind === 'device' ? Number(target.dataset.tempMin || 8) : 8;
const maxValue = kind === 'device' ? Number(target.dataset.tempMax || 30) : 30;
const decimals = kind === 'zone' || step < 1 ? 1 : 0;
const input = document.createElement('input');
input.className = 'inline-temperature-input';
input.type = 'text';
@@ -383,8 +440,8 @@ function beginInlineTemperatureEdit(target) {
if (finished) return;
finished = true;
const parsed = parseDecimal(input.value);
if (!commit || !Number.isFinite(parsed) || parsed < 8 || parsed > 30) {
if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', { min: 8, max: 30 }), true);
if (!commit || !Number.isFinite(parsed) || parsed < minValue || parsed > maxValue) {
if (commit && (!Number.isFinite(parsed) || parsed < minValue || parsed > maxValue)) toast(tr('validation.range', { min: minValue, max: maxValue }), true);
renderAll();
return;
}
@@ -394,7 +451,10 @@ function beginInlineTemperatureEdit(target) {
return;
}
const device = app.devices.find(item => item.id === id);
if (device) await sendDeviceCommand(id, { target_temperature: Math.round(clamp(parsed, 8, 30)) });
if (device) {
const snapped = Math.round(clamp(parsed, minValue, maxValue) / step) * step;
await sendDeviceCommand(id, { target_temperature: snapped });
}
};
input.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
@@ -419,7 +479,9 @@ function showDiscoveryNames(ids) {
function populateDeviceRename(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#renameDeviceForm'); form.reset();
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip; form.port.value = String(device.port ?? 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
const isCloud = device.connection_type === 'gree_cloud';
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip || ''; form.port.value = String(device.port || 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
form.ip.disabled = isCloud; form.port.disabled = isCloud; form.protocol_version.disabled = isCloud;
const result = $('#deviceConfigCheckResult');
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
openDialog('renameDeviceDialog');
@@ -631,3 +693,74 @@ function populateAutomation(id) {
openDialog('automationDialog');
}
async function openDeviceDetails(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#deviceDetailsForm'); if (!form) return;
form.reset();
form.id.value = device.id;
form.name.value = device.name || '';
form.energy_source.value = device.energy_source || 'auto';
const title = $('#deviceDetailsTitle');
if (title) title.textContent = device.connection_type === 'gree_cloud' ? tr('devices.cloudDetails') : tr('energy.title');
const meta = $('#deviceDetailsMeta');
if (meta) meta.innerHTML = device.connection_type === 'gree_cloud'
? `<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div><div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div><div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(device.cloud_device_id || device.mac || '—')}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`
: `<div><span>${esc(tr('devices.connection'))}</span><strong>Local</strong></div><div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip || '—')}:${esc(device.port || 7000)}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`;
const source = form.energy_source;
const cloudOption = [...source.options].find(option => option.value === 'gree_cloud');
if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true;
const sensorSelect = form.ha_energy_entity_id;
sensorSelect.innerHTML = '<option value="">—</option>';
try {
const response = await api('/api/integrations/home-assistant/energy-sensors');
for (const sensor of (response.sensors || [])) {
const option = document.createElement('option');
option.value = sensor.entity_id;
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
option.dataset.unit = sensor.unit || '';
option.dataset.deviceClass = sensor.device_class || '';
option.dataset.stateClass = sensor.state_class || '';
sensorSelect.append(option);
}
} catch (_) {
// Home Assistant is optional; keep any already-saved entity available even when HA is offline.
}
if (device.ha_energy_entity_id && ![...sensorSelect.options].some(option => option.value === device.ha_energy_entity_id)) {
const option = document.createElement('option');
option.value = device.ha_energy_entity_id;
option.textContent = device.ha_energy_entity_id;
option.dataset.unit = device.ha_energy_unit || '';
option.dataset.deviceClass = device.ha_energy_device_class || '';
option.dataset.stateClass = device.ha_energy_state_class || '';
sensorSelect.append(option);
}
sensorSelect.value = device.ha_energy_entity_id || '';
updateDeviceEnergySensorMeta();
openDialog('deviceDetailsDialog');
}
function updateDeviceEnergySensorMeta() {
const select = $('#deviceDetailsForm')?.ha_energy_entity_id;
const meta = $('#deviceEnergySensorMeta');
if (!select || !meta) return;
const option = select.selectedOptions?.[0];
meta.textContent = option?.value
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
: tr('energy.noHaSensor');
}
async function openCloudDiagnostics(id) {
const dialog = $('#cloudDiagnosticsDialog');
const payload = $('#cloudDiagnosticsPayload');
if (!dialog || !payload) return;
dialog.dataset.deviceId = id;
payload.textContent = tr('common.loading');
openDialog('cloudDiagnosticsDialog');
try {
const diagnostics = await api(`/api/devices/${encodeURIComponent(id)}/cloud-diagnostics`);
payload.textContent = JSON.stringify(diagnostics, null, 2);
} catch (error) {
payload.textContent = error.message || String(error);
}
}