657 lines
41 KiB
JavaScript
657 lines
41 KiB
JavaScript
|
|
document.addEventListener('keydown', event => {
|
|
const target = event.target.closest?.('[data-temperature-kind]');
|
|
if (!target || event.target.matches('input')) return;
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
beginInlineTemperatureEdit(target);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', async event => {
|
|
const inlineTarget = event.target.closest('[data-temperature-kind]');
|
|
if (inlineTarget && !event.target.closest('button')) { beginInlineTemperatureEdit(inlineTarget); return; }
|
|
const historyRoute = event.target.closest('a[data-history-route]');
|
|
if (historyRoute) {
|
|
event.preventDefault();
|
|
const hours = historyRoute.dataset.historyHours;
|
|
if (hours && $('#historyHours')) $('#historyHours').value = hours;
|
|
else if ($('#historyHours')) $('#historyHours').value = '24';
|
|
const dialog = historyRoute.closest('dialog');
|
|
closeChartPreview(dialog?.querySelector('.chart-card.chart-fullscreen-fallback'));
|
|
if (dialog?.open) dialog.close();
|
|
app.historyTab = 'overview';
|
|
showView('history');
|
|
return;
|
|
}
|
|
const button = event.target.closest('button'); if (!button) return;
|
|
if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; }
|
|
if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; }
|
|
if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; }
|
|
if (button.dataset.nav) {
|
|
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
|
|
return;
|
|
}
|
|
if (button.dataset.go) { const more = $('#moreDialog'); if (more?.open) more.close(); showView(button.dataset.go); return; }
|
|
if (button.dataset.open) {
|
|
const form = document.getElementById(button.dataset.open.replace('Dialog', 'Form'));
|
|
if (form) form.reset();
|
|
if (button.dataset.open === 'zoneDialog') {
|
|
if (form) { form.dataset.editingZoneId = ''; form.dataset.separateHysteresisInitialized = 'false'; if (form.elements.id) form.elements.id.value = ''; }
|
|
updateZoneSensorFields();
|
|
updateZoneHysteresisFields();
|
|
}
|
|
if (button.dataset.open === 'groupDialog') {
|
|
if (form?.elements.id) form.elements.id.value = '';
|
|
renderGroupZoneChoices([]);
|
|
}
|
|
if (button.dataset.open === 'automationDialog') updateAutomationTargetFields();
|
|
if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField();
|
|
openDialog(button.dataset.open);
|
|
return;
|
|
}
|
|
if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; }
|
|
if (button.dataset.chartLegend !== undefined) {
|
|
const id = button.dataset.chartId;
|
|
const runtime = chartRuntime.get(id);
|
|
const index = Number(button.dataset.chartLegend);
|
|
const item = runtime?.series?.[index];
|
|
if (item) {
|
|
setChartSeriesHidden(id, item, index, !isChartSeriesHidden(id, item, index));
|
|
redrawHistoryChart(id);
|
|
}
|
|
return;
|
|
}
|
|
if (button.dataset.chartFullscreen) {
|
|
await toggleChartFullscreen(button.dataset.chartFullscreen);
|
|
return;
|
|
}
|
|
if (button.dataset.chartZoom) {
|
|
const id = button.dataset.chartId;
|
|
const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
|
const next = button.dataset.chartZoom === 'in' ? Math.min(MAX_CHART_ZOOM, current * 1.5) : button.dataset.chartZoom === 'out' ? Math.max(1, current / 1.5) : 1;
|
|
setChartZoom(id, next);
|
|
return;
|
|
}
|
|
if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; }
|
|
if (button.dataset.historyAction) { await handleHistoryAction(button); return; }
|
|
if (button.dataset.scheduleTemplate) {
|
|
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
|
|
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
|
try {
|
|
button.disabled = true;
|
|
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, { method: 'POST', body: { template: button.dataset.scheduleTemplate } });
|
|
await loadBootstrap(); toast(tr('schedules.templateApplied'));
|
|
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
|
return;
|
|
}
|
|
const action = button.dataset.action; if (!action) return;
|
|
if (action === 'open-outdoor-history') { await openOutdoorHistory(); return; }
|
|
if (action === 'add-cloud-device') {
|
|
const cloudId = button.dataset.cloudId; if (!cloudId) return;
|
|
button.disabled = true;
|
|
try {
|
|
await api(`/api/integrations/gree-cloud/devices/${encodeURIComponent(cloudId)}/add`, { method: 'POST' });
|
|
await loadBootstrap();
|
|
await loadCloudDiscovery({ open: false });
|
|
toast(tr('devices.cloudAdded'));
|
|
} catch (error) { toast(error.message, true); }
|
|
finally { button.disabled = false; }
|
|
return;
|
|
}
|
|
if (action === 'edit-device-group') { await openDeviceGroupsDialog(button.dataset.id || ''); return; }
|
|
if (action === 'delete-device-group') {
|
|
const group = (app.deviceGroups || []).find(item => item.id === button.dataset.id);
|
|
if (!group || !confirm(`${tr('actions.delete')} ${group.name}?`)) return;
|
|
try {
|
|
await api(`/api/device-groups/${encodeURIComponent(group.id)}`, { method: 'DELETE' });
|
|
await loadBootstrap();
|
|
renderDeviceGroupsDialogList();
|
|
await populateDeviceGroupForm(null);
|
|
toast(tr('devices.installationDeleted'));
|
|
} catch (error) { toast(error.message, true); }
|
|
return;
|
|
}
|
|
const device = app.devices.find(v => v.id === button.dataset.device);
|
|
if (action === 'toggle-network-jitter') { app.historyNetworkShowJitter = app.historyNetworkShowJitter === false; renderHistoryNavigation(); renderNetworkHistory(); return; }
|
|
if (action === 'open-ping' && device) { openDevicePing(device.id); return; }
|
|
if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; }
|
|
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({ power: !current?.power }));
|
|
if (action === 'temperature' && device) return queueDeviceTemperature(device.id, Number(button.dataset.delta));
|
|
if (action === 'mode' && device) return sendDeviceCommand(device.id, { mode: button.dataset.value, power: true });
|
|
if (action === 'fan' && device) return sendDeviceCommand(device.id, { fan_speed: Number(button.dataset.value) });
|
|
if (action === 'toggle' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
|
if (action === 'poll' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' })); renderAll(); toast(tr('devices.readDone')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
|
if (action === 'bind' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' })); renderAll(); toast(tr('devices.bound')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
|
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
|
|
if (action === 'energy-config' && device) return openDeviceDetails(device.id);
|
|
if (action === 'cloud-details' && device) return openDeviceDetails(device.id);
|
|
if (action === 'cloud-diagnostics' && device) return openCloudDiagnostics(device.id);
|
|
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
|
if (action === 'house-mode') {
|
|
try {
|
|
await enqueueClimateControlTask(async () => {
|
|
await api('/api/house/control', { method: 'POST', body: { mode: button.dataset.value } });
|
|
await loadBootstrap();
|
|
});
|
|
toast(tr('house.modeUpdated'));
|
|
} catch (error) { toast(error.message, true); }
|
|
return;
|
|
}
|
|
if (action === 'house-power') {
|
|
const power = button.dataset.value === 'true';
|
|
if (!power && !confirm(tr('house.powerOffConfirm'))) return;
|
|
try {
|
|
button.disabled = true;
|
|
const result = await enqueueClimateControlTask(async () => {
|
|
const response = await api('/api/house/power', { method: 'POST', body: { power } });
|
|
await loadBootstrap();
|
|
return response;
|
|
});
|
|
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
|
if (failed) toast(tr('house.powerPartial', { count: failed }), true);
|
|
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
|
|
} catch (error) { toast(error.message, true); }
|
|
finally { button.disabled = false; }
|
|
return;
|
|
}
|
|
if (action === 'house-preset') {
|
|
try {
|
|
const result = await enqueueClimateControlTask(async () => {
|
|
const response = await api('/api/house/preset', { method: 'POST', body: { preset: button.dataset.value } });
|
|
await loadBootstrap();
|
|
return response;
|
|
});
|
|
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
|
if (failed) toast(tr('house.powerPartial', { count: failed }), true); else toast(tr('house.presetUpdated'));
|
|
}
|
|
catch (error) { toast(error.message, true); }
|
|
return;
|
|
}
|
|
if (action === 'group-power') { const power = button.dataset.value === 'true'; if (!power) delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { power }); }
|
|
if (action === 'group-mode') return sendGroupControl(button.dataset.id, { mode: button.dataset.value });
|
|
if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { preset: button.dataset.value }); }
|
|
if (action === 'group-custom-open') {
|
|
const editor = button.closest('.group-card')?.querySelector('[data-group-custom-editor]');
|
|
const input = editor?.querySelector('[data-group-custom-temperature]');
|
|
const value = parseDecimal(input?.value);
|
|
app.groupCustomDrafts[button.dataset.id] = { open: true, value: Number.isFinite(value) ? value : 23 };
|
|
if (editor) { editor.hidden = false; input?.focus(); input?.select(); }
|
|
return;
|
|
}
|
|
if (action === 'group-custom-cancel') {
|
|
delete app.groupCustomDrafts[button.dataset.id];
|
|
renderGroups();
|
|
return;
|
|
}
|
|
if (action === 'group-custom-temperature') {
|
|
const input = button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
|
|
const value = parseDecimal(input?.value);
|
|
if (!Number.isFinite(value) || value < 8 || value > 30) return toast(tr('groups.customTemperatureRange'), true);
|
|
return sendGroupControl(button.dataset.id, { preset: 'custom', setpoint: Math.round(value * 10) / 10 });
|
|
}
|
|
if (action === 'open-compressor-queue') return openCompressorQueueDialog();
|
|
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
|
|
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', { count: app.zones.filter(zone => !!zone.compressor_pending_action).length }))) return; return cancelAllCompressorTasks(); }
|
|
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
|
if (action === 'zone-swing' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
|
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
|
if (action === 'zone-temperature') { const zone = app.zones.find(v => v.id === button.dataset.id); if (zone) { const base = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base + Number(button.dataset.delta)); } return; }
|
|
if (action === 'zone-mode') return sendZoneControl(button.dataset.id, { mode: button.dataset.value });
|
|
if (action === 'zone-preset') return sendZoneControl(button.dataset.id, { preset: button.dataset.value });
|
|
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id, { enabled: button.dataset.value === 'true' });
|
|
if (action === 'zone-resume-automation') {
|
|
const patch = button.dataset.resume === 'local'
|
|
? { clear_local_thermostat_override: true }
|
|
: { clear_device_manual_override: true };
|
|
return sendZoneControl(button.dataset.id, patch);
|
|
}
|
|
if (action === 'zone-go-control') {
|
|
showView('dashboard', { scroll: false });
|
|
setDashboardTab('thermostats', { scroll: false });
|
|
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
|
|
requestAnimationFrame(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
return;
|
|
}
|
|
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
|
|
if (action === 'edit-group') return populateGroup(button.dataset.id);
|
|
if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group');
|
|
if (action === 'edit-zone') return populateZone(button.dataset.id);
|
|
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
|
|
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
|
|
if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule');
|
|
if (action === 'edit-automation') return populateAutomation(button.dataset.id);
|
|
if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation');
|
|
if (action === 'revoke-access-token') {
|
|
if (!confirm(tr('confirm.revokeToken'))) return;
|
|
try {
|
|
await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, { method: 'DELETE' });
|
|
app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id);
|
|
renderAccessTokens();
|
|
toast(tr('toast.tokenRevoked'));
|
|
} catch (error) { toast(error.message, true); }
|
|
return;
|
|
}
|
|
});
|
|
|
|
$('#refreshButton').addEventListener('click', () => {
|
|
const form = activeDirtySettingsForm();
|
|
if (form && !confirmDiscardForm(form)) return;
|
|
loadBootstrap(true);
|
|
});
|
|
$('#connectionRetry')?.addEventListener('click', async event => {
|
|
const button = event.currentTarget;
|
|
button.disabled = true;
|
|
try {
|
|
if (app.ws) { try { app.ws.close(); } catch (_) { } app.ws = null; }
|
|
await loadBootstrap(false);
|
|
connectWebSocket();
|
|
} finally { button.disabled = false; }
|
|
});
|
|
$('#discoverButton').addEventListener('click', () => {
|
|
const form = $('#discoverForm'); form.reset();
|
|
form.protocol_version.value = '0'; form.passes.value = '3'; form.timeout_ms.value = String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
|
|
openDialog('discoverDialog');
|
|
});
|
|
$('#cloudDiscoverButton')?.addEventListener('click', async event => {
|
|
const button = event.currentTarget;
|
|
button.disabled = true;
|
|
try { await loadCloudDiscovery(); }
|
|
catch (error) { toast(error.message, true); }
|
|
finally { button.disabled = false; }
|
|
});
|
|
$('#historyRefresh').addEventListener('click', loadHistory);
|
|
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
|
|
$('#logsRefresh').addEventListener('click', loadLogs);
|
|
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
|
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
|
|
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
|
|
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
|
$('#settingsForm [name=compressor_protection_enabled]')?.addEventListener('change', updateCompressorProtectionFields);
|
|
$('#settingsForm [name=ping_metrics_enabled]')?.addEventListener('change', updateConnectivityMetricFields);
|
|
$('#settingsForm [name=gree_cloud_connectivity_metrics_enabled]')?.addEventListener('change', updateConnectivityMetricFields);
|
|
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
|
document.addEventListener('input', event => {
|
|
const input = event.target.closest?.('[data-group-custom-temperature]');
|
|
if (!input) return;
|
|
const id = input.dataset.groupCustomTemperature;
|
|
if (!id) return;
|
|
app.groupCustomDrafts[id] = { open: true, value: input.value };
|
|
});
|
|
|
|
document.addEventListener('keydown', event => {
|
|
const input = event.target.closest?.('[data-group-custom-temperature]');
|
|
if (!input || event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click();
|
|
});
|
|
|
|
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({ syncFromCommon: true }));
|
|
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
|
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
|
|
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope = event.target.value; app.simulationTarget = 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
|
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget = event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
|
$('#simulationOpenTab')?.addEventListener('click', () => { const params = new URLSearchParams({ standalone: '1' }); if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
|
|
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch (error) { toast(error.message, true); } });
|
|
document.addEventListener('fullscreenchange', () => { const button = $('#simulationFullscreen'); if (button) button.textContent = tr(document.fullscreenElement ? 'simulation.exitFullscreen' : 'simulation.fullscreen'); });
|
|
|
|
$('#testNotifications')?.addEventListener('click', async () => { const f = $('#settingsForm'); const body = { enabled: true, mode: f.notifications_mode.value, provider: f.notifications_provider.value, pushover_app_token: f.pushover_app_token.value.trim(), pushover_user_key: f.pushover_user_key.value.trim(), slack_webhook_url: f.slack_webhook_url.value.trim(), discord_webhook_url: f.discord_webhook_url.value.trim(), cooldown_seconds: Number(f.notification_cooldown_seconds.value || 300), communication_failure_threshold: Number(f.notification_failure_threshold.value || 3), target_timeout_minutes: Number(f.notification_target_timeout.value || 60), alert_types: { stale_sensor: f.notification_alert_stale_sensor.checked, sensor_errors: f.notification_alert_sensor_errors.checked, communication: f.notification_alert_communication.checked, target_timeout: f.notification_alert_target_timeout.checked, automation: f.notification_alert_automation.checked, sensor_discrepancy: f.notification_alert_sensor_discrepancy.checked, control_errors: f.notification_alert_control_errors.checked, important_events: f.notification_alert_important_events.checked, other: f.notification_alert_other.checked } }; try { await api('/api/integrations/notifications/test', { method: 'POST', body }); toast(tr('notifications.testSent')); } catch (e) { toast(e.message, true); } });
|
|
|
|
$('#copyZoneSettings')?.addEventListener('click', () => {
|
|
const f = $('#zoneForm');
|
|
const source = app.zones.find(zone => zone.id === $('#copyZoneSource')?.value);
|
|
if (!source) return;
|
|
const targetId = f.dataset.editingZoneId || '';
|
|
if (targetId && source.id === targetId) { toast(tr('zones.copyDifferent'), true); return; }
|
|
|
|
// Identity and sensor assignment belong to the destination profile and must never
|
|
// be changed by copying thermostat tuning from another profile.
|
|
const protectedValues = {
|
|
id: f.elements.id.value,
|
|
name: f.elements.name.value,
|
|
device_id: f.elements.device_id.value,
|
|
sensor_source: f.elements.sensor_source.value,
|
|
ha_entity_id: f.elements.ha_entity_id.value,
|
|
ha_outdoor_entity_id: f.elements.ha_outdoor_entity_id.value,
|
|
enabled: f.elements.enabled.checked,
|
|
};
|
|
const copyFields = ['setpoint', 'cool_comfort_setpoint', 'cool_sleep_setpoint', 'cool_away_setpoint', 'heat_comfort_setpoint', 'heat_sleep_setpoint', 'heat_away_setpoint', 'hysteresis', 'cool_hysteresis', 'heat_hysteresis', 'min_on_seconds', 'min_off_seconds', 'min_adjust_seconds', 'standby_offset_c', 'external_sensor_weight_percent', 'max_sensor_difference', 'sensor_stale_after_seconds'];
|
|
copyFields.forEach(name => {
|
|
const input = f.elements[name];
|
|
if (!input) return;
|
|
const value = name === 'external_sensor_weight_percent' ? (source.external_sensor_weight * 100) : source[name];
|
|
if (value !== undefined && value !== null) input.value = String(value);
|
|
});
|
|
f.elements.smart_fan.checked = source.smart_fan !== false;
|
|
f.elements.separate_hysteresis.checked = source.separate_hysteresis === true;
|
|
updateZoneHysteresisFields();
|
|
if (f.elements.mode_policy) f.elements.mode_policy.value = source.inherit_house_mode === false ? (source.mode || 'cool') : 'house';
|
|
|
|
f.elements.id.value = protectedValues.id;
|
|
f.elements.name.value = protectedValues.name;
|
|
f.elements.device_id.value = protectedValues.device_id;
|
|
f.elements.sensor_source.value = protectedValues.sensor_source;
|
|
f.elements.ha_entity_id.value = protectedValues.ha_entity_id;
|
|
f.elements.ha_outdoor_entity_id.value = protectedValues.ha_outdoor_entity_id;
|
|
f.elements.enabled.checked = protectedValues.enabled;
|
|
f.dataset.editingZoneId = targetId;
|
|
updateZoneSensorFields();
|
|
updateDirtyIndicator(f);
|
|
toast(tr('zones.copiedFrom', { name: source.name }));
|
|
});
|
|
|
|
$('#applyAutomationPreset')?.addEventListener('click', () => { const f = $('#automationForm'), p = $('#automationPreset')?.value; if (!p) return; const first = app.devices[0]?.id || ''; const presets = { hot: { name: tr('automations.presetHot'), trigger_kind: 'temperature_above', threshold: '28', action_power: 'true', action_mode: 'cool', action_target_temperature: '23', cooldown_seconds: '900' }, cold: { name: tr('automations.presetCold'), trigger_kind: 'temperature_below', threshold: '17', action_power: 'true', action_mode: 'heat', action_target_temperature: '21', cooldown_seconds: '900' }, morning: { name: tr('automations.presetMorning'), trigger_kind: 'time', at_time: '07:00', action_power: 'true', action_mode: 'auto', action_target_temperature: '22', cooldown_seconds: '3600' }, nightoff: { name: tr('automations.presetNightOff'), trigger_kind: 'time', at_time: '23:30', action_power: 'false', action_mode: '', action_target_temperature: '', cooldown_seconds: '3600' } }; const x = presets[p]; f.action_target_kind.value = 'device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k, v]) => { if (f[k]) f[k].value = v }); if (!f.trigger_device_id.value) f.trigger_device_id.value = first; if (!f.action_device_id.value) f.action_device_id.value = first; });
|
|
|
|
$('#tokenForm').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
if (!validateForm(form) || form.dataset.submitting === 'true') return;
|
|
setFormBusy(form, true, 'auth.connecting');
|
|
app.token = new FormData(form).get('token').trim();
|
|
localStorage.setItem('gree_controller_token', app.token);
|
|
if (app.ws) app.ws.close();
|
|
try { await loadBootstrap(); }
|
|
finally { setFormBusy(form, false); }
|
|
});
|
|
|
|
$('#discoverForm').addEventListener('submit', async event => {
|
|
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
await runFormTask(form, async () => {
|
|
const result = await api('/api/discovery', { method: 'POST', body: { protocol_version: Number(raw.protocol_version), passes: Number(raw.passes), timeout_ms: Number(raw.timeout_ms) } });
|
|
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found', { count: result.count })); showDiscoveryNames(result.new_device_ids || []);
|
|
}, { busyKey: 'actions.discovering' });
|
|
});
|
|
|
|
$('#discoveryNamesForm').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const inputs = $$('input[data-device-id]', form);
|
|
await runFormTask(form, async () => {
|
|
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, { method: 'PATCH', body: { name: input.value.trim() } })));
|
|
updated.forEach(updateDevice);
|
|
form.closest('dialog').close();
|
|
renderAll();
|
|
toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#renameDeviceForm').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
const saveMode = event.submitter?.dataset.saveMode || 'save';
|
|
const previous = app.devices.find(item => item.id === raw.id);
|
|
const result = $('#deviceConfigCheckResult');
|
|
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
|
|
await runFormTask(form, async () => {
|
|
const isCloud = previous?.connection_type === 'gree_cloud';
|
|
const patch = isCloud ? { name: raw.name.trim() } : { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) };
|
|
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
|
|
updateDevice(device);
|
|
if (saveMode !== 'check') {
|
|
form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); return;
|
|
}
|
|
try {
|
|
if (isCloud) {
|
|
const check = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
|
|
if (!check.ok) throw new Error(check.message || check.status || tr('settings.cloudConnectionFailed'));
|
|
if (result) { const message = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); result.hidden = false; result.classList.add('success'); result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`; }
|
|
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
|
|
}
|
|
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
|
|
if (!device.simulated && protocolChanged) {
|
|
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
|
|
updateDevice(device);
|
|
}
|
|
let probe;
|
|
try {
|
|
probe = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
|
} catch (firstError) {
|
|
if (device.simulated || protocolChanged) throw firstError;
|
|
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
|
|
updateDevice(device);
|
|
probe = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
|
}
|
|
if (result) {
|
|
result.hidden = false; result.classList.add('success');
|
|
const message = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
|
|
result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`;
|
|
}
|
|
markFormClean(form);
|
|
renderAll();
|
|
toast(tr('devices.savedAndChecked'));
|
|
} catch (error) {
|
|
if (result) {
|
|
result.hidden = false; result.classList.add('error');
|
|
const message = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
|
|
result.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
|
}
|
|
markFormClean(form);
|
|
renderAll();
|
|
toast(tr('devices.savedCheckFailed'), true);
|
|
}
|
|
});
|
|
});
|
|
|
|
$('#deviceDetailsForm')?.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const raw = Object.fromEntries(new FormData(form));
|
|
const deviceId = form.elements.id.value;
|
|
const installation = deviceInstallationForDevice(deviceId);
|
|
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
|
|
const patch = { name: String(raw.name || '').trim() };
|
|
if (!groupOwnsEnergy) {
|
|
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
|
Object.assign(patch, {
|
|
energy_source: raw.energy_source || 'auto',
|
|
ha_energy_entity_id: raw.ha_energy_entity_id || null,
|
|
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
|
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
|
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
|
});
|
|
}
|
|
await runFormTask(form, async () => {
|
|
const device = await api(`/api/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: patch });
|
|
updateDevice(device);
|
|
form.closest('dialog').close();
|
|
renderAll();
|
|
toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
|
|
|
|
|
|
$('#deviceGroupsButton')?.addEventListener('click', () => openDeviceGroupsDialog());
|
|
$('#deviceGroupNew')?.addEventListener('click', () => populateDeviceGroupForm(null));
|
|
$('#deviceGroupForm')?.addEventListener('change', event => {
|
|
const form = event.currentTarget;
|
|
if (event.target.name === 'kind' && form.kind.value === 'split') {
|
|
const checked = $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked');
|
|
checked.slice(1).forEach(input => { input.checked = false; });
|
|
syncDeviceGroupMemberSelects();
|
|
}
|
|
if (event.target.name === 'device_ids') {
|
|
if (form.kind.value === 'split' && event.target.checked) {
|
|
$$('#deviceGroupDeviceChoices input[name="device_ids"]:checked').forEach(input => { if (input !== event.target) input.checked = false; });
|
|
}
|
|
syncDeviceGroupMemberSelects();
|
|
}
|
|
if (event.target.name === 'ha_energy_entity_id') updateDeviceGroupEnergySensorMeta();
|
|
});
|
|
$('#deviceGroupForm')?.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const ids = selectedDeviceGroupIds();
|
|
if (!ids.length) return showFormError(form, tr('energy.selectAtLeastOne'));
|
|
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
|
const body = {
|
|
name: form.name.value.trim(),
|
|
kind: form.kind.value || 'split',
|
|
device_ids: ids,
|
|
energy_source: form.energy_source.value || 'auto',
|
|
energy_device_id: form.energy_device_id.value || null,
|
|
ha_energy_entity_id: form.ha_energy_entity_id.value || null,
|
|
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
|
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
|
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
|
outdoor_temperature_device_id: form.outdoor_temperature_device_id.value || null,
|
|
};
|
|
const id = form.elements.id.value;
|
|
await runFormTask(form, async () => {
|
|
await api(id ? `/api/device-groups/${encodeURIComponent(id)}` : '/api/device-groups', { method: id ? 'PUT' : 'POST', body });
|
|
await loadBootstrap();
|
|
renderDeviceGroupsDialogList();
|
|
await populateDeviceGroupForm(null);
|
|
toast(tr('devices.installationSaved'));
|
|
});
|
|
});
|
|
|
|
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
|
|
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
|
|
if (id) openCloudDiagnostics(id);
|
|
});
|
|
|
|
$('#pingDeviceSelect')?.addEventListener('change', event => {
|
|
app.pingMonitor.targetId = event.target.value;
|
|
renderPingDialog();
|
|
if (app.pingMonitor.running) schedulePingCycle(0);
|
|
});
|
|
$('#pingAllDevices')?.addEventListener('change', event => {
|
|
app.pingMonitor.all = event.target.checked;
|
|
renderPingDialog();
|
|
if (app.pingMonitor.running) schedulePingCycle(0);
|
|
});
|
|
$('#pingDialog')?.addEventListener('close', () => stopPingMonitor());
|
|
|
|
$('#deviceForm').addEventListener('submit', async event => {
|
|
event.preventDefault(); const form = event.currentTarget, data = Object.fromEntries(new FormData(form));
|
|
data.port = Number(data.port); data.protocol_version = Number(data.protocol_version); data.simulated = form.simulated.checked;
|
|
await runFormTask(form, async () => {
|
|
await api('/api/devices', { method: 'POST', body: data }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added'));
|
|
});
|
|
});
|
|
|
|
$('#zoneForm').addEventListener('submit', async event => {
|
|
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
const id = form.dataset.editingZoneId || raw.id; const body = {
|
|
name: raw.name, device_id: raw.device_id, enabled: form.enabled.checked,
|
|
mode: raw.mode_policy === 'house' ? 'cool' : raw.mode_policy, inherit_house_mode: raw.mode_policy === 'house', setpoint: parseDecimal(raw.setpoint),
|
|
cool_comfort_setpoint: parseDecimal(raw.cool_comfort_setpoint), cool_sleep_setpoint: parseDecimal(raw.cool_sleep_setpoint), cool_away_setpoint: parseDecimal(raw.cool_away_setpoint),
|
|
heat_comfort_setpoint: parseDecimal(raw.heat_comfort_setpoint), heat_sleep_setpoint: parseDecimal(raw.heat_sleep_setpoint), heat_away_setpoint: parseDecimal(raw.heat_away_setpoint),
|
|
hysteresis: parseDecimal(raw.hysteresis), separate_hysteresis: form.separate_hysteresis.checked, cool_hysteresis: parseDecimal(raw.cool_hysteresis), heat_hysteresis: parseDecimal(raw.heat_hysteresis), min_on_seconds: Number(raw.min_on_seconds), min_off_seconds: Number(raw.min_off_seconds),
|
|
min_adjust_seconds: Number(raw.min_adjust_seconds), standby_offset_c: parseDecimal(raw.standby_offset_c), smart_fan: form.smart_fan.checked,
|
|
sensor_source: raw.sensor_source, ha_entity_id: raw.ha_entity_id || null, ha_outdoor_entity_id: raw.ha_outdoor_entity_id || null, external_sensor_weight: Number(raw.external_sensor_weight_percent) / 100, max_sensor_difference: parseDecimal(raw.max_sensor_difference), sensor_stale_after_seconds: Number(raw.sensor_stale_after_seconds || 300), revision: raw.revision ? Number(raw.revision) : null
|
|
};
|
|
await runFormTask(form, async () => {
|
|
await api(id ? `/api/zones/${encodeURIComponent(id)}` : '/api/zones', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields);
|
|
$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields);
|
|
$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => {
|
|
const form = $('#temporaryThermostatForm');
|
|
form.start_delay_minutes.value = button.dataset.temporaryStartDelay;
|
|
}));
|
|
$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => {
|
|
const form = $('#temporaryThermostatForm');
|
|
form.duration_minutes.value = button.dataset.temporaryDuration;
|
|
}));
|
|
|
|
$('#temporaryThermostatStop')?.addEventListener('click', async event => {
|
|
const id = event.currentTarget.dataset.id; if (!id) return;
|
|
try {
|
|
event.currentTarget.disabled = true;
|
|
const zone = await enqueueZoneControlRequest(id, { clear_temporary_quick_thermostat: true });
|
|
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
|
|
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
|
|
} catch (error) { toast(error.message, true); }
|
|
finally { event.currentTarget.disabled = false; }
|
|
});
|
|
|
|
$('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
const activeSession = form.dataset.activeSession === 'true';
|
|
const zone = app.zones.find(item => item.id === raw.zone_id);
|
|
const existingSession = zone?.temporary_quick_thermostat;
|
|
const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind;
|
|
const request = { start_kind: startKind, finish_kind: raw.finish_kind, target_temperature: parseDecimal(raw.target_temperature) };
|
|
if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes);
|
|
if (!activeSession && startKind === 'at') {
|
|
const startAt = new Date(raw.start_at);
|
|
if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true);
|
|
request.start_at = startAt.toISOString();
|
|
}
|
|
if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes);
|
|
if (raw.finish_kind === 'until') {
|
|
const until = new Date(raw.until);
|
|
if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true);
|
|
const effectiveStart = activeSession ? new Date()
|
|
: startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000)
|
|
: startKind === 'at' ? new Date(raw.start_at) : new Date();
|
|
if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true);
|
|
request.until = until.toISOString();
|
|
}
|
|
if (['temperature_reached', 'temperature_stable'].includes(raw.finish_kind)) {
|
|
request.temperature_operator = raw.temperature_operator;
|
|
request.tolerance_c = parseDecimal(raw.tolerance_c);
|
|
if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes);
|
|
const safety = Number(raw.max_duration_minutes);
|
|
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
|
}
|
|
await runFormTask(form, async () => {
|
|
const updatedZone = await enqueueZoneControlRequest(raw.zone_id, { temporary_quick_thermostat: request });
|
|
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
|
|
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
|
|
}, { busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting' });
|
|
});
|
|
|
|
$('#groupForm')?.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
const id = raw.id || '';
|
|
const zone_ids = $$('input[name=zone_ids]:checked', form).map(input => input.value);
|
|
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
|
|
const existing = id ? app.groups.find(group => group.id === id) : null;
|
|
const body = { name: raw.name.trim(), zone_ids, power_enabled: existing?.power_enabled !== false };
|
|
await runFormTask(form, async () => {
|
|
await api(id ? `/api/groups/${encodeURIComponent(id)}` : '/api/groups', { method: id ? 'PUT' : 'POST', body });
|
|
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#scheduleForm').addEventListener('submit', async event => {
|
|
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
|
const id = raw.id, weekdays = $$('[name=weekday]:checked', form).map(v => Number(v.value));
|
|
const body = { name: raw.name, zone_id: raw.zone_id, enabled: form.enabled.checked, weekdays, start_time: raw.start_time, end_time: raw.end_time, preset: raw.preset, setpoint: parseDecimal(raw.setpoint || 23) };
|
|
await runFormTask(form, async () => {
|
|
await api(id ? `/api/schedules/${encodeURIComponent(id)}` : '/api/schedules', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#automationForm').addEventListener('submit', async event => {
|
|
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); const id = raw.id;
|
|
const groupTarget = raw.action_target_kind === 'group';
|
|
const action = {}; if (raw.action_power !== '') action.power = raw.action_power === 'true'; if (raw.action_mode) action.mode = raw.action_mode; if (!groupTarget && raw.action_target_temperature !== '') action.target_temperature = parseDecimal(raw.action_target_temperature); if (!groupTarget && raw.action_swing_vertical !== '') action.swing_vertical = raw.action_swing_vertical === 'true'; if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = raw.action_swing_horizontal === 'true';
|
|
const body = { name: raw.name, enabled: form.enabled.checked, trigger_kind: raw.trigger_kind, trigger_device_id: raw.trigger_device_id || null, threshold: raw.threshold === '' ? null : parseDecimal(raw.threshold), at_time: raw.at_time || null, action_device_id: groupTarget ? '' : raw.action_device_id, action_group_id: groupTarget ? (raw.action_group_id || null) : null, action_preset: groupTarget ? (raw.action_preset || null) : null, action, cooldown_seconds: Number(raw.cooldown_seconds) };
|
|
await runFormTask(form, async () => {
|
|
await api(id ? `/api/automations/${encodeURIComponent(id)}` : '/api/automations', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
|
});
|
|
});
|
|
|
|
$('#greeCloudReconnectButton')?.addEventListener('click', async event => {
|
|
const button = event.currentTarget;
|
|
try {
|
|
button.disabled = true;
|
|
await api('/api/integrations/gree-cloud/reconnect', { method: 'POST' });
|
|
await refreshGreeCloudRuntimeStatus({ force: true });
|
|
toast(tr('common.saved'));
|
|
} catch (error) {
|
|
toast(error.message, true);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|