Files
gree-controller/web/js/settings.js
T
2026-09-15 09:04:29 +02:00

484 lines
23 KiB
JavaScript

const SETTINGS_ENDPOINTS = {
application: '/api/settings/application',
gree: '/api/settings/gree',
greeCloud: '/api/settings/gree-cloud',
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 === 'greeCloud') app.settings.gree_cloud = 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 greeCloudSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = {
enabled: form.gree_cloud_enabled.checked,
region: raw.gree_cloud_region || 'Europe',
username: raw.gree_cloud_username || '',
polling_interval_seconds: Number(raw.gree_cloud_polling_interval_seconds || 60),
};
if (raw.gree_cloud_password) body.password = raw.gree_cloud_password;
return body;
}
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,
cloud_requests: form.debug_cloud_requests.checked,
cloud_mqtt: form.debug_cloud_mqtt.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, greeCloud, 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.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(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('greeCloud', greeCloud);
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.matches?.('[data-history-energy-target]')) {
const checked = $$('[data-history-energy-target]:checked').map(input => input.dataset.historyEnergyTarget).filter(Boolean);
if (checked.length > 8) { target.checked = false; toast(tr('energy.maxTargets'), true); return; }
app.historyEnergyTargets = checked;
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
updateBrowserUrl(currentHistoryPath());
loadHistory();
}
else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
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 = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(tr('flow.sharedInputTestEntityRequired'))}</span>`;
return;
}
const button = event.currentTarget, idle = button.textContent;
button.disabled = true; button.textContent = tr('flow.sharedInputTesting');
resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`;
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 = `<div><strong>${esc(success ? tr('flow.sharedInputTestValueRead') : tr('flow.sharedInputTestUnavailable'))}</strong><span class="badge ${success ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
} catch (error) {
resultHost.classList.remove('pass'); resultHost.classList.add('fail');
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(error.message)}</span>`;
} 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'));
});
async function saveGreeCloudSettings(form = $('#settingsForm')) {
const data = await api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) });
applySettingsSection('greeCloud', data);
return data;
}
function renderCloudDiscoveryDevices(devices) {
const list = $('#cloudDiscoveryList');
if (!list) return;
const items = Array.isArray(devices) ? devices : [];
list.innerHTML = items.length ? items.map(device => `
<div class="discovery-name-row cloud-discovery-row">
<span><strong>${esc(device.name || 'GREE')}</strong><small>${esc(device.model || 'GREE')} · ${esc(device.mac || device.id)} · ${esc(tr(device.online ? 'status.online' : 'status.offline'))}</small></span>
<button type="button" class="${device.already_added ? 'secondary' : 'primary'}" data-action="add-cloud-device" data-cloud-id="${esc(device.id)}" ${device.already_added ? 'disabled' : ''}>${esc(device.already_added ? tr('devices.cloudAlreadyAdded') : tr('actions.add'))}</button>
</div>`).join('') : `<div class="empty"><strong>${esc(tr('devices.cloudDiscoveryEmpty'))}</strong>${esc(tr('devices.cloudDiscoveryEmptyHint'))}</div>`;
}
async function loadCloudDiscovery({ open = true } = {}) {
const result = await api('/api/integrations/gree-cloud/devices');
renderCloudDiscoveryDevices(result.devices || []);
if (open) openDialog('cloudDiscoveryDialog');
return result;
}
$('#greeCloudTestButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
const form = $('#settingsForm');
const resultBox = $('#greeCloudTestResult');
button.disabled = true;
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
try {
await saveGreeCloudSettings(form);
const result = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add(result.ok ? 'success' : 'error');
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
}
if (result.ok) {
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
applySettingsSection('greeCloud', refreshed);
renderSettings();
}
} catch (error) {
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`; }
} finally { button.disabled = false; }
});
$('#greeCloudRefreshButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await saveGreeCloudSettings($('#settingsForm')); await loadCloudDiscovery(); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#cloudDiscoveryRefresh')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await loadCloudDiscovery({ open: false }); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});