function currentSettingsBody() { const settings = app.settings || {}; const influx = settings.influxdb || {}; const ha = settings.home_assistant || {}; return { controller_id: settings.controller_id || 'gree-controller', simulator_enabled: !!settings.simulator_enabled, poll_interval_seconds: Number(settings.poll_interval_seconds || 15), zone_interval_seconds: Number(settings.zone_interval_seconds || 5), discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', house_mode: settings.house_mode || 'cool', house_power_enabled: settings.house_power_enabled !== false, control_strategy: 'setpoint', outdoor_assist_enabled: !!settings.outdoor_assist_enabled, history_retention_days: Number(settings.history_retention_days || 30), history_compaction_enabled: settings.history_compaction_enabled !== false, event_log_retention_days: Number(settings.event_log_retention_days || 30), suppress_device_beep: !!settings.suppress_device_beep, compressor_protection_enabled: settings.compressor_protection_enabled !== false, compressor_protection_seconds: Number(settings.compressor_protection_seconds || 180), notifications: { enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover', pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '', cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60), alert_types: { stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false, sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false, communication: settings.notifications?.alert_types?.communication !== false, target_timeout: settings.notifications?.alert_types?.target_timeout !== false, automation: settings.notifications?.alert_types?.automation !== false, control_errors: settings.notifications?.alert_types?.control_errors !== false, important_events: settings.notifications?.alert_types?.important_events !== false, other: settings.notifications?.alert_types?.other !== false, }, }, night_mode: { enabled: !!settings.night_mode?.enabled, start_time: settings.night_mode?.start_time || '22:00', end_time: settings.night_mode?.end_time || '06:00', max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1), force_quiet: settings.night_mode?.force_quiet !== false, use_native_sleep: settings.night_mode?.use_native_sleep !== false, }, influxdb: { enabled: !!influx.enabled, version: String(influx.version || '2'), url: influx.url || '', database: influx.database || 'gree_controller', username: influx.username || '', password: '', org: influx.org || '', bucket: influx.bucket || 'gree_controller', token: '', history_threshold_days: Number(influx.history_threshold_days || 30), }, debug: { overlay_enabled: !!settings.debug?.overlay_enabled, gree_frames: !!settings.debug?.gree_frames, }, home_assistant: { url: ha.url || '', token: '', default_entity_id: ha.default_entity_id || '', outdoor_entity_id: ha.outdoor_entity_id || '', sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), allow_invalid_tls: !!ha.allow_invalid_tls, sensor_aliases: { ...(ha.sensor_aliases || {}) }, flow_inputs: JSON.parse(JSON.stringify(ha.flow_inputs || [])), }, }; } function settingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.controller_id = raw.controller_id; body.simulator_enabled = form.simulator_enabled.checked; body.poll_interval_seconds = Number(raw.poll_interval_seconds); body.zone_interval_seconds = Number(raw.zone_interval_seconds); body.discovery_timeout_ms = Number(raw.discovery_timeout_ms); body.discovery_broadcast = raw.discovery_broadcast; body.history_retention_days = Number(raw.history_retention_days); body.history_compaction_enabled = form.history_compaction_enabled.checked; body.event_log_retention_days = Number(raw.event_log_retention_days); body.suppress_device_beep = form.suppress_device_beep.checked; body.compressor_protection_enabled = form.compressor_protection_enabled.checked; body.compressor_protection_seconds = Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))); body.influxdb = { enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, database: raw.influx_database, username: raw.influx_username, password: raw.influx_password, org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token, history_threshold_days: Number(raw.influx_threshold_days), }; body.debug = { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked }; body.notifications = { enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider, pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '', slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '', 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, control_errors: form.notification_alert_control_errors.checked, important_events: form.notification_alert_important_events.checked, other: form.notification_alert_other.checked, }, }; return body; } function nightSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.night_mode = { 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, }; return body; } function homeAssistantSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked; body.home_assistant = { url: raw.ha_url, token: raw.ha_token, 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 || [])), }; return body; } async function saveRuntimeSettings(body, notify = true) { app.settings = await api('/api/settings', { method: 'PUT', body }); app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (notify) toast(tr('common.saved')); return app.settings; } $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true)); }); $('#nightModeForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true)); }); $('#homeAssistantForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(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 result = await api('/api/events/retention', { method: 'PUT', body: { days } }); app.settings.event_log_retention_days = result.days; $('#settingsForm').event_log_retention_days.value = result.days; renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.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 saveRuntimeSettings(homeAssistantSettingsBodyFromForm(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; } }); $('#simulationRefresh')?.addEventListener('click', async () => { try { await loadControlPlan(); toast(tr('common.updated')); } catch (error) { toast(error.message, true); } }); $('#exportSettings').addEventListener('click', async () => { try { const data = await api('/api/settings/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-settings-${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/settings/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, flowDefaultConfig(event.target.value)); }); $('#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 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')); });