Files
gree-controller/web/js/settings.js
T
2026-09-02 10:12:51 +02:00

373 lines
18 KiB
JavaScript

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; }
});
$('#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, 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'));
});