This commit is contained in:
Mateusz Gruszczyński
2026-09-03 13:08:28 +02:00
parent d7b6d0478f
commit b8ff31f205
52 changed files with 3874 additions and 1912 deletions
+4 -4
View File
@@ -2,7 +2,7 @@ async function loadBootstrap(showMessage = false) {
if (app.loading) return;
app.loading = true;
try {
const data = await api('/api/bootstrap');
const [data, sections] = await Promise.all([api('/api/bootstrap'), fetchSettingsSections()]);
app.devices = data.devices || [];
app.zones = data.zones || [];
app.groups = data.groups || [];
@@ -10,9 +10,9 @@ async function loadBootstrap(showMessage = false) {
app.automations = data.automations || [];
app.flows = data.flows || [];
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
app.settings = aggregateSettingsSections(sections, data.house?.mode || 'cool');
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
app.system = data.system || {};
app.systemSnapshotAt = Date.now();
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
-1
View File
@@ -469,7 +469,6 @@ async function sendGroupControl(id, patch) {
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
(result.devices || []).forEach(updateDevice);
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
+48 -41
View File
@@ -10,6 +10,52 @@ async function loadLogs() {
} catch (error) { toast(error.message, true); }
}
async function handleWebSocketMessage(event) {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); }
else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); }
else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.debug.updated') { applySettingsSection('debug', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
else if (message.event === 'settings.night.updated') { applySettingsSection('night', data); if (!isFormDirty($('#nightModeForm'))) renderNightSettings(); renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.home_assistant.updated') {
applySettingsSection('homeAssistant', data);
if (!isFormDirty($('#homeAssistantForm'))) { app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); renderHomeAssistantSettings(); }
renderHouseClimate(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad();
}
else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame_received') {
app.system = app.system || {};
app.system.gree_received_frames = Number(data.total || 0);
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
renderGreeFrameStats();
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
} catch (_) { }
}
function connectWebSocket() {
if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return;
clearTimeout(app.wsTimer);
@@ -19,47 +65,8 @@ function connectWebSocket() {
ws.onopen = () => updateConnectionIndicator('connected');
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onerror = () => updateConnectionIndicator('connectionError');
let messageQueue = Promise.resolve();
ws.onmessage = event => {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.updated') {
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
app.settings = data;
if (!haDirty) { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); }
if (!settingsDirty) renderSettings();
if (!nightDirty) renderNightSettings();
if (!haDirty) renderHomeAssistantSettings();
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad();
}
else if (message.event === 'house.power_changed') { renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame_received') {
app.system = app.system || {};
app.system.gree_received_frames = Number(data.total || 0);
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
renderGreeFrameStats();
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
} catch (_) { }
messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => {});
};
}
+175 -115
View File
@@ -1,103 +1,127 @@
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 currentSettingsBody() {
const settings = app.settings || {};
const influx = settings.influxdb || {};
const ha = settings.home_assistant || {};
async function fetchSettingsSections() {
const [application, gree, history, influxdb, notifications, night, homeAssistant, debug] = await Promise.all([
api(SETTINGS_ENDPOINTS.application),
api(SETTINGS_ENDPOINTS.gree),
api(SETTINGS_ENDPOINTS.history),
api(SETTINGS_ENDPOINTS.influxdb),
api(SETTINGS_ENDPOINTS.notifications),
api(SETTINGS_ENDPOINTS.night),
api(SETTINGS_ENDPOINTS.homeAssistant),
api(SETTINGS_ENDPOINTS.debug),
]);
return { application, gree, history, influxdb, notifications, night, homeAssistant, debug };
}
function aggregateSettingsSections(sections, houseMode = app.settings?.house_mode || 'cool') {
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 || [])),
},
simulator_enabled: !!sections.application.simulator_enabled,
controller_id: sections.gree.controller_id,
poll_interval_seconds: Number(sections.gree.poll_interval_seconds),
zone_interval_seconds: Number(sections.gree.zone_interval_seconds),
discovery_timeout_ms: Number(sections.gree.discovery_timeout_ms),
discovery_broadcast: sections.gree.discovery_broadcast,
suppress_device_beep: !!sections.gree.suppress_device_beep,
compressor_protection_enabled: sections.gree.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(sections.gree.compressor_protection_seconds),
history_retention_days: Number(sections.history.retention_days),
history_compaction_enabled: sections.history.compaction_enabled !== false,
event_log_retention_days: Number(sections.history.event_retention_days),
influxdb: sections.influxdb,
notifications: sections.notifications,
night_mode: sections.night,
home_assistant: sections.homeAssistant,
outdoor_assist_enabled: !!sections.homeAssistant.outdoor_assist_enabled,
debug: sections.debug,
house_mode: houseMode || 'cool',
};
}
function settingsBodyFromForm(form) {
async function loadSettingsSections(houseMode = app.settings?.house_mode || 'cool') {
const sections = await fetchSettingsSections();
app.settings = aggregateSettingsSections(sections, houseMode);
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
return app.settings;
}
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));
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,
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),
};
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 || '',
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),
@@ -112,13 +136,20 @@ function settingsBodyFromForm(form) {
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));
const body = currentSettingsBody();
body.night_mode = {
return {
enabled: form.night_mode_enabled.checked,
start_time: raw.night_mode_start,
end_time: raw.night_mode_end,
@@ -126,30 +157,25 @@ function nightSettingsBodyFromForm(form) {
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 = {
const body = {
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 || [])),
outdoor_assist_enabled: form.outdoor_assist_enabled.checked,
};
if (raw.ha_token) body.token = raw.ha_token;
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 || []));
function refreshSettingsUi() {
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
@@ -160,23 +186,56 @@ async function saveRuntimeSettings(body, notify = true) {
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'));
return app.settings;
}
$('#settingsForm').addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true));
await runFormTask(form, () => saveMainSettings(form, true));
});
$('#nightModeForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true));
await runFormTask(form, () => saveNightSettings(form, true));
});
$('#homeAssistantForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true));
await runFormTask(form, () => saveHomeAssistantSettings(form, true));
});
$('#addSensorAlias')?.addEventListener('click', () => {
@@ -206,10 +265,11 @@ document.addEventListener('click', event => {
$('#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 }));
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); }
});
@@ -246,7 +306,7 @@ $('#haTest').addEventListener('click', async event => {
const idle = button.textContent;
button.textContent = tr('settings.testingHa');
try {
await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false);
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);
@@ -257,11 +317,11 @@ $('#haTest').addEventListener('click', async event => {
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/settings/export');
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-settings-${new Date().toISOString().slice(0, 10)}.json`;
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); }
@@ -273,7 +333,7 @@ $('#importSettingsFile').addEventListener('change', async event => {
try {
if (!confirm(tr('settings.importConfirm'))) return;
const body = JSON.parse(await file.text());
await api('/api/settings/import', { method: 'POST', body });
await api('/api/configuration/import', { method: 'POST', body });
app.debugBacklogLoaded = false;
await loadBootstrap();
toast(tr('toast.imported'));