const SETTINGS_ENDPOINTS = { application: '/api/settings/application', gree: '/api/settings/gree', history: '/api/settings/history', influxdb: '/api/settings/influxdb', notifications: '/api/settings/notifications', night: '/api/settings/night', homeAssistant: '/api/settings/home-assistant', debug: '/api/settings/debug', }; function applySettingsSection(section, data) { app.settings = app.settings || {}; if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled; else if (section === 'gree') Object.assign(app.settings, data); else if (section === 'history') { app.settings.history_retention_days = Number(data.retention_days); app.settings.history_compaction_enabled = data.compaction_enabled !== false; app.settings.event_log_retention_days = Number(data.event_retention_days); } else if (section === 'influxdb') app.settings.influxdb = data; else if (section === 'notifications') app.settings.notifications = data; else if (section === 'night') app.settings.night_mode = data; else if (section === 'homeAssistant') { app.settings.home_assistant = data; app.settings.outdoor_assist_enabled = !!data.outdoor_assist_enabled; } else if (section === 'debug') app.settings.debug = data; } function applicationSettingsBodyFromForm(form) { return { simulator_enabled: form.simulator_enabled.checked }; } function greeSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); return { controller_id: raw.controller_id, poll_interval_seconds: Number(raw.poll_interval_seconds), zone_interval_seconds: Number(raw.zone_interval_seconds), discovery_timeout_ms: Number(raw.discovery_timeout_ms), discovery_broadcast: raw.discovery_broadcast, suppress_device_beep: form.suppress_device_beep.checked, compressor_protection_enabled: form.compressor_protection_enabled.checked, compressor_protection_seconds: Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))), }; } function historySettingsBodyFromForm(form, eventRetentionDays = null) { const raw = Object.fromEntries(new FormData(form)); return { retention_days: Number(raw.history_retention_days), compaction_enabled: form.history_compaction_enabled.checked, event_retention_days: eventRetentionDays == null ? Number(raw.event_log_retention_days) : Number(eventRetentionDays), }; } function influxDbSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = { enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, database: raw.influx_database, username: raw.influx_username, org: raw.influx_org, bucket: raw.influx_bucket, history_threshold_days: Number(raw.influx_threshold_days), }; if (raw.influx_password) body.password = raw.influx_password; if (raw.influx_token) body.token = raw.influx_token; return body; } function notificationSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = { enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider, cooldown_seconds: Number(raw.notification_cooldown_seconds || 300), communication_failure_threshold: Number(raw.notification_failure_threshold || 3), target_timeout_minutes: Number(raw.notification_target_timeout || 60), alert_types: { stale_sensor: form.notification_alert_stale_sensor.checked, sensor_errors: form.notification_alert_sensor_errors.checked, communication: form.notification_alert_communication.checked, target_timeout: form.notification_alert_target_timeout.checked, automation: form.notification_alert_automation.checked, sensor_discrepancy: form.notification_alert_sensor_discrepancy.checked, control_errors: form.notification_alert_control_errors.checked, important_events: form.notification_alert_important_events.checked, other: form.notification_alert_other.checked, }, }; if (raw.pushover_app_token) body.pushover_app_token = raw.pushover_app_token; if (raw.pushover_user_key) body.pushover_user_key = raw.pushover_user_key; if (raw.slack_webhook_url) body.slack_webhook_url = raw.slack_webhook_url; if (raw.discord_webhook_url) body.discord_webhook_url = raw.discord_webhook_url; return body; } function debugSettingsBodyFromForm(form) { return { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked }; } function nightSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); return { enabled: form.night_mode_enabled.checked, start_time: raw.night_mode_start, end_time: raw.night_mode_end, max_fan_speed: Number(raw.night_mode_max_fan_speed), force_quiet: form.night_mode_force_quiet.checked, use_native_sleep: form.night_mode_native_sleep.checked, }; } function homeAssistantSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = { url: raw.ha_url, default_entity_id: raw.ha_entity_id, outdoor_entity_id: raw.ha_outdoor_entity_id, sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), allow_invalid_tls: form.ha_allow_invalid_tls.checked, sensor_aliases: { ...(app.sensorAliases || {}) }, flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])), outdoor_assist_enabled: form.outdoor_assist_enabled.checked, }; if (raw.ha_token) body.token = raw.ha_token; return body; } function refreshSettingsUi() { renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); } async function saveMainSettings(form, notify = true) { const [application, gree, history, influxdb, notifications, debug] = await Promise.all([ api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }), api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }), api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }), api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }), api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }), api(SETTINGS_ENDPOINTS.debug, { method: 'PUT', body: debugSettingsBodyFromForm(form) }), ]); applySettingsSection('application', application); applySettingsSection('gree', gree); applySettingsSection('history', history); applySettingsSection('influxdb', influxdb); applySettingsSection('notifications', notifications); applySettingsSection('debug', debug); refreshSettingsUi(); if (notify) toast(tr('common.saved')); } async function saveNightSettings(form, notify = true) { const data = await api(SETTINGS_ENDPOINTS.night, { method: 'PUT', body: nightSettingsBodyFromForm(form) }); applySettingsSection('night', data); refreshSettingsUi(); if (notify) toast(tr('common.saved')); } async function saveHomeAssistantSettings(form, notify = true) { const data = await api(SETTINGS_ENDPOINTS.homeAssistant, { method: 'PUT', body: homeAssistantSettingsBodyFromForm(form) }); applySettingsSection('homeAssistant', data); app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); refreshSettingsUi(); if (notify) toast(tr('common.saved')); } $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveMainSettings(form, true)); }); $('#nightModeForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveNightSettings(form, true)); }); $('#homeAssistantForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveHomeAssistantSettings(form, true)); }); $('#addSensorAlias')?.addEventListener('click', () => { const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName'); const entity = entityInput.value.trim(), alias = aliasInput.value.trim(); if (!entity || !alias) return toast(tr('settings.aliasRequired'), true); app.sensorAliases[entity] = alias; entityInput.value = ''; aliasInput.value = ''; renderSensorAliases(); renderHistoryNavigation(); updateDirtyIndicator($('#homeAssistantForm')); }); document.addEventListener('input', event => { const input = event.target.closest('[data-sensor-alias]'); if (!input) return; const entity = input.dataset.sensorAlias, alias = input.value.trim(); if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; updateDirtyIndicator($('#homeAssistantForm')); }); document.addEventListener('click', event => { const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; delete app.sensorAliases[button.dataset.clearSensorAlias]; renderSensorAliases(); renderHistoryNavigation(); updateDirtyIndicator($('#homeAssistantForm')); }); $('#saveLogRetention')?.addEventListener('click', async () => { const days = Number($('#logRetentionDays')?.value || 30); try { const form = $('#settingsForm'); const result = await api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form, days) }); applySettingsSection('history', result); form.event_log_retention_days.value = result.event_retention_days; renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.event_retention_days })); } catch (error) { toast(error.message, true); } }); $('#createAccessToken').addEventListener('click', async event => { const button = event.currentTarget; button.disabled = true; try { const result = await api('/api/access-tokens', { method: 'POST', body: { name: 'Home Assistant' } }); if (result.item) app.accessTokens.unshift(result.item); renderAccessTokens(); $('#generatedAccessToken').value = result.token || ''; openDialog('generatedTokenDialog'); toast(tr('toast.tokenCreated')); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } }); $('#copyAccessToken').addEventListener('click', async () => { const input = $('#generatedAccessToken'); try { await navigator.clipboard.writeText(input.value); toast(tr('toast.tokenCopied')); } catch (_) { input.select(); document.execCommand('copy'); toast(tr('toast.tokenCopied')); } }); $('#haTest').addEventListener('click', async event => { const form = $('#homeAssistantForm'), button = event.currentTarget; if (!validateForm(form)) return; button.disabled = true; const idle = button.textContent; button.textContent = tr('settings.testingHa'); try { await saveHomeAssistantSettings(form, false); const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; const result = await api('/api/integrations/home-assistant/test', { method: 'POST', body: { entity_id: entity } }); markFormClean(form); toast(tr('toast.haTemperature', { temperature: result.temperature_c.toFixed(1) })); } catch (error) { presentFormError(form, error); } finally { button.disabled = false; button.textContent = idle; } }); $('#exportSettings').addEventListener('click', async () => { try { const data = await api('/api/configuration/export'); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `gree-controller-configuration-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); toast(tr('toast.exported')); } catch (error) { toast(error.message, true); } }); $('#importSettings').addEventListener('click', () => $('#importSettingsFile').click()); $('#importSettingsFile').addEventListener('change', async event => { const file = event.target.files?.[0]; if (!file) return; try { if (!confirm(tr('settings.importConfirm'))) return; const body = JSON.parse(await file.text()); await api('/api/configuration/import', { method: 'POST', body }); app.debugBacklogLoaded = false; await loadBootstrap(); toast(tr('toast.imported')); } catch (error) { toast(error.message, true); } finally { event.target.value = ''; } }); document.addEventListener('change', event => { const target = event.target; if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } else if (target.name === 'influx_version') updateInfluxFields(); }); $('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEditor()); $('#flowSharedInputKind')?.addEventListener('change', event => { renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value)); }); function evaluateFlowSharedHaTest(kind, config, result) { if (kind === 'ha_available') return { actual: result.available === true, valid: true }; if (kind === 'ha_attribute') { const actual = result.attributes?.[config.attribute]; return { actual, valid: actual !== undefined }; } if (kind === 'ha_numeric') { const actual = Number(result.state); return { actual, valid: Number.isFinite(actual) }; } return { actual: result.state, valid: true }; } $('#flowSharedInputTest')?.addEventListener('click', async event => { const form = $('#flowSharedInputForm'), resultHost = $('#flowSharedInputTestResult'); if (!form || !resultHost) return; const kind = form.kind.value, config = collectFlowSharedInputConfig(kind); const entityId = String(config.entity_id || '').trim(); if (!entityId) { resultHost.hidden = false; resultHost.classList.remove('pass'); resultHost.classList.add('fail'); resultHost.innerHTML = `${esc(tr('flow.sharedInputTestUnavailable'))}${esc(tr('flow.sharedInputTestEntityRequired'))}`; return; } const button = event.currentTarget, idle = button.textContent; button.disabled = true; button.textContent = tr('flow.sharedInputTesting'); resultHost.hidden = false; resultHost.innerHTML = `${esc(tr('flow.sharedInputTesting'))}`; try { const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } }); const evaluation = evaluateFlowSharedHaTest(kind, config, result); const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual)); const success = evaluation.valid; resultHost.classList.toggle('pass', success); resultHost.classList.toggle('fail', !success); resultHost.innerHTML = `
${esc(success ? tr('flow.sharedInputTestValueRead') : tr('flow.sharedInputTestUnavailable'))}${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}
${esc(tr('flow.sharedInputTestCurrent'))}
${esc(actual)}
${esc(result.entity_id)}${result.last_updated ? ` ยท ${esc(dateTime(result.last_updated))}` : ''}`; } catch (error) { resultHost.classList.remove('pass'); resultHost.classList.add('fail'); resultHost.innerHTML = `${esc(tr('flow.sharedInputTestUnavailable'))}${esc(error.message)}`; } finally { button.disabled = false; button.textContent = idle; } }); $('#flowSharedInputForm')?.addEventListener('submit', event => { event.preventDefault(); const form = event.currentTarget; const name = form.name.value.trim(), kind = form.kind.value; if (!name) return toast(tr('flow.sharedInputNameRequired'), true); const editingId = form.dataset.editingId || ''; const id = editingId || newFlowId('shared'); const item = { id, name, kind, config: collectFlowSharedInputConfig(kind) }; const index = app.flowSharedInputs.findIndex(value => value.id === id); if (index >= 0) app.flowSharedInputs[index] = item; else app.flowSharedInputs.push(item); renderFlowSharedInputs(); updateDirtyIndicator($('#homeAssistantForm')); $('#flowSharedInputDialog')?.close(); }); document.addEventListener('click', event => { const edit = event.target.closest?.('[data-flow-shared-edit]'); if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; } const openFlow = event.target.closest?.('[data-open-shared-flow]'); if (openFlow) { if (showView('flows')) requestAnimationFrame(() => openFlowEditor(openFlow.dataset.openSharedFlow)); return; } const remove = event.target.closest?.('[data-flow-shared-delete]'); if (!remove) return; const id = remove.dataset.flowSharedDelete; const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return; const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0); const message = uses ? tr('flow.sharedInputDeleteUsed', { name: item.name, count: uses }) : tr('flow.sharedInputDeleteConfirm', { name: item.name }); if (!confirm(message)) return; app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id); renderFlowSharedInputs(); updateDirtyIndicator($('#homeAssistantForm')); });