Files
gree-controller/web/js-dynamic/settings-ui.js
T
2026-09-17 12:55:29 +02:00

664 lines
40 KiB
JavaScript

function renderAccessTokens() {
const list = $('#accessTokenList');
if (!list) return;
list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => `
<div class="token-row">
<div><strong>${esc(item.name)}</strong><small class="mono">${esc(item.token_prefix)}</small><small>${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}</small></div>
<button type="button" class="danger" data-action="revoke-access-token" data-id="${esc(item.id)}">${esc(tr('actions.revoke'))}</button>
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
}
function metricHaEntities() {
return new Set([
app.settings?.home_assistant?.outdoor_entity_id,
...app.zones.map(zone => zone.ha_outdoor_entity_id).filter(Boolean),
...app.zones.filter(zone => ['home_assistant', 'combined'].includes(zone.sensor_source)).map(zoneHaEntityId),
...app.historyData.sensors.map(row => row.entity_id),
].filter(Boolean));
}
function flowHaEntities() {
return new Set((app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean));
}
function knownHaEntities() {
return [...new Set([
...Object.keys(app.sensorAliases || {}),
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
...app.zones.map(zone => zone.ha_outdoor_entity_id).filter(Boolean),
app.settings?.home_assistant?.outdoor_entity_id,
...(app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean),
...app.historyData.sensors.map(row => row.entity_id),
].filter(Boolean))].sort();
}
function renderHaEntitySuggestions() {
const host = $('#haEntitySuggestions'); if (!host) return;
const entities = [...new Set([
...knownHaEntities(),
...(app.haEntityCatalog || []).map(item => item?.entity_id).filter(Boolean),
])].sort();
host.innerHTML = entities.map(entity => {
const alias = haSensorLabel(entity);
return `<option value="${esc(entity)}" label="${esc(alias === entity ? '' : alias)}"></option>`;
}).join('');
}
function renderSensorAliases() {
const host = $('#sensorAliasList'); if (!host) return;
renderHaEntitySuggestions();
const entities = knownHaEntities();
const metricEntities = metricHaEntities(), flowEntities = flowHaEntities();
host.innerHTML = entities.length ? entities.map(entity => {
const badges = `${metricEntities.has(entity) ? `<span class="sensor-source-badge metrics">${esc(tr('settings.sensorMetricBadge'))}</span>` : ''}${flowEntities.has(entity) ? `<span class="sensor-source-badge flow">${esc(tr('settings.sensorFlowBadge'))}</span>` : ''}`;
return `<div class="sensor-alias-row"><div class="sensor-alias-entity"><span class="mono" title="${esc(entity)}">${esc(entity)}</span>${badges ? `<span class="sensor-source-badges">${badges}</span>` : ''}</div><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">${uiIcon('close')}</button></div>`;
}).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
}
function flowSharedInputKinds() {
return [
['constant', 'flow.node.constant'], ['ha_state', 'flow.node.haState'], ['ha_numeric', 'flow.node.haNumeric'],
['ha_attribute', 'flow.node.haAttribute'], ['ha_available', 'flow.node.haAvailable'],
['outdoor_temperature', 'flow.node.outdoorTemperature'], ['device_temperature', 'flow.node.deviceTemperature'],
['zone_temperature', 'flow.node.zoneTemperature'], ['house_mode', 'flow.node.houseMode'],
['device_state', 'flow.node.deviceState'], ['zone_state', 'flow.node.zoneState'],
['group_state', 'flow.node.groupState'], ['night_mode', 'flow.node.nightMode'],
];
}
function flowSharedInputDefaultConfig(kind) {
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '' };
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '' };
if (kind === 'ha_state' || kind === 'ha_numeric' || kind === 'ha_available') return { entity_id: '' };
if (kind === 'ha_attribute') return { entity_id: '', attribute: '' };
if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online' };
if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand' };
if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled' };
if (kind === 'constant') return { value: true };
return {};
}
function sharedFlowInputSourceSummary(item) {
if (!item) return '—';
const c = item.config || {};
if (item.kind === 'outdoor_temperature') return tr('flow.node.outdoorTemperature');
if (item.kind === 'device_temperature') return app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice');
if (item.kind === 'zone_temperature') return app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone');
if (item.kind === 'ha_state' || item.kind === 'ha_numeric' || item.kind === 'ha_available') return c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id';
if (item.kind === 'ha_attribute') return `${c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id'}.${c.attribute || 'attribute'}`;
if (item.kind === 'house_mode') return tr('flow.houseMode');
if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`;
if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`;
if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`;
return flowNodeSummary({ kind: item.kind, config: c });
}
function isHaSharedInputKind(kind) { return ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(kind); }
function flowSharedInputUsages(id) {
return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id));
}
function renderFlowSharedInputs() {
const host = $('#flowSharedInputList'); if (!host) return;
const items = app.flowSharedInputs || [];
host.innerHTML = items.length ? items.map(item => {
const usages = flowSharedInputUsages(item.id);
const usageMarkup = usages.length
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count: usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name: flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
return `<div class="flow-shared-input-row">
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
</div>`;
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
const revision = $('#flowSharedInputsRevision');
if (revision) revision.value = JSON.stringify(items);
}
function sharedFlowOptions(items, selected, label = item => item.name) {
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
}
function haEntityMatchesSharedKind(entity, kind) {
if (!entity?.entity_id) return false;
if (kind === 'ha_numeric') { const state = String(entity.state ?? '').trim(); return state !== '' && Number.isFinite(Number(state)); }
return true;
}
function haEntityPickerResults(kind, query = '') {
const needle = String(query || '').trim().toLocaleLowerCase();
return (app.haEntityCatalog || [])
.filter(entity => haEntityMatchesSharedKind(entity, kind))
.filter(entity => {
if (!needle) return true;
return [entity.entity_id, entity.name, entity.state, entity.unit, entity.device_class]
.some(value => String(value || '').toLocaleLowerCase().includes(needle));
})
.slice(0, 12);
}
function renderHaEntityPickerResults(queryInput, kind) {
const picker = queryInput?.closest?.('[data-ha-entity-picker]');
const host = picker?.querySelector?.('[data-ha-entity-results]');
const note = picker?.querySelector?.('[data-ha-entity-note]');
if (!host || !note) return;
if (app.haEntityCatalogLoading) {
note.textContent = tr('flow.haEntitySearchLoading');
host.hidden = true;
return;
}
if (!app.haEntityCatalogConfigured) {
note.textContent = tr('flow.haEntitySearchConnect');
host.hidden = true;
return;
}
const query = String(queryInput.value || '').trim();
const matchingCount = (app.haEntityCatalog || [])
.filter(entity => haEntityMatchesSharedKind(entity, kind))
.filter(entity => {
if (!query) return true;
const needle = query.toLocaleLowerCase();
return [entity.entity_id, entity.name, entity.state, entity.unit, entity.device_class]
.some(value => String(value || '').toLocaleLowerCase().includes(needle));
}).length;
const items = haEntityPickerResults(kind, query);
note.textContent = query
? tr('flow.haEntitySearchMatches', { count: matchingCount, total: app.haEntityCatalog.length })
: tr('flow.haEntitySearchCount', { count: app.haEntityCatalog.length });
host.innerHTML = items.length ? items.map(entity => {
const title = entity.name || entity.entity_id;
const state = `${entity.state || '—'}${entity.unit ? ` ${entity.unit}` : ''}`;
return `<button type="button" class="ha-entity-option" data-ha-entity-value="${esc(entity.entity_id)}" data-ha-entity-label="${esc(title)}"><span><strong>${esc(title)}</strong><code>${esc(entity.entity_id)}</code></span><small>${esc(state)}</small></button>`;
}).join('') : `<div class="ha-entity-empty">${esc(tr('flow.haEntitySearchEmpty'))}</div>`;
host.hidden = false;
}
async function loadHaEntityCatalog(force = false) {
const fresh = app.haEntityCatalogLoadedAt && Date.now() - app.haEntityCatalogLoadedAt < 60000;
if (!force && fresh) return app.haEntityCatalog;
if (app.haEntityCatalogLoading) return app.haEntityCatalog;
app.haEntityCatalogLoading = true;
const activeInput = $('#flowSharedInputFields [data-ha-entity-query]');
if (activeInput) renderHaEntityPickerResults(activeInput, $('#flowSharedInputKind')?.value || 'ha_state');
try {
const response = await api('/api/integrations/home-assistant/entities');
app.haEntityCatalog = Array.isArray(response.entities) ? response.entities : [];
app.haEntityCatalogConfigured = response.configured === true;
app.haEntityCatalogLoadedAt = Date.now();
renderHaEntitySuggestions();
} catch (_) {
app.haEntityCatalog = [];
app.haEntityCatalogConfigured = false;
app.haEntityCatalogLoadedAt = Date.now();
} finally {
app.haEntityCatalogLoading = false;
const input = $('#flowSharedInputFields [data-ha-entity-query]');
if (input) renderHaEntityPickerResults(input, $('#flowSharedInputKind')?.value || 'ha_state');
}
return app.haEntityCatalog;
}
function haEntityPickerMarkup(kind, value, placeholder) {
return `<div class="ha-entity-picker" data-ha-entity-picker>
<label class="ha-entity-search-field"><span>${esc(tr('flow.haEntitySearchLabel'))}</span><input type="search" autocomplete="off" data-ha-entity-query placeholder="${esc(tr('flow.haEntitySearchPlaceholder'))}"></label>
<small class="field-note" data-ha-entity-note>${esc(tr('flow.haEntitySearchHint'))}</small>
<div class="ha-entity-results" data-ha-entity-results hidden></div>
<label class="ha-entity-selected-field"><span>${esc(tr('flow.haEntitySelectedLabel'))}</span><input autocomplete="off" data-shared-config="entity_id" data-ha-entity-selected value="${esc(value || '')}" placeholder="${esc(placeholder)}"></label>
</div>`;
}
function bindHaEntityPicker(kind) {
const queryInput = $('#flowSharedInputFields [data-ha-entity-query]');
const selectedInput = $('#flowSharedInputFields [data-ha-entity-selected]');
const results = $('#flowSharedInputFields [data-ha-entity-results]');
if (!queryInput || !selectedInput || !results) return;
const refresh = () => renderHaEntityPickerResults(queryInput, kind);
queryInput.addEventListener('input', refresh);
queryInput.addEventListener('focus', refresh);
queryInput.addEventListener('keydown', event => {
if (event.key === 'Escape') results.hidden = true;
});
results.addEventListener('click', event => {
const option = event.target.closest?.('[data-ha-entity-value]');
if (!option) return;
selectedInput.value = option.dataset.haEntityValue || '';
selectedInput.dispatchEvent(new Event('input', { bubbles: true }));
selectedInput.dispatchEvent(new Event('change', { bubbles: true }));
queryInput.value = option.dataset.haEntityLabel || option.dataset.haEntityValue || '';
results.hidden = true;
});
loadHaEntityCatalog().then(refresh);
}
function renderFlowSharedInputFields(kind, config = {}) {
const host = $('#flowSharedInputFields'); if (!host) return;
const c = config || {};
let fields = '';
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
else if (kind === 'ha_state') fields = haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window');
else if (kind === 'ha_numeric') fields = haEntityPickerMarkup(kind, c.entity_id, 'sensor.energy_price');
else if (kind === 'ha_attribute') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'climate.living_room')}<label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>`;
else if (kind === 'ha_available') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window')}<p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (kind === 'outdoor_temperature') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>`;
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
host.innerHTML = fields;
if (isHaSharedInputKind(kind)) bindHaEntityPicker(kind);
const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult');
if (testPanel) testPanel.hidden = !isHaSharedInputKind(kind);
if (testResult) { testResult.hidden = true; testResult.innerHTML = ''; }
}
function openFlowSharedInputEditor(id = '') {
const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return;
const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null;
const kindSelect = form.kind;
kindSelect.innerHTML = flowSharedInputKinds().map(([kind, key]) => `<option value="${kind}">${esc(tr(key))}</option>`).join('');
form.id.value = item?.id || '';
form.name.value = item?.name || '';
form.kind.value = item?.kind || 'constant';
form.dataset.editingId = item?.id || '';
renderFlowSharedInputFields(form.kind.value, item?.config || flowSharedInputDefaultConfig(form.kind.value));
dialog.showModal();
setTimeout(() => form.name.focus(), 0);
}
function collectFlowSharedInputConfig(kind) {
const config = {};
$$('[data-shared-config]', $('#flowSharedInputFields')).forEach(field => {
const key = field.dataset.sharedConfig;
let value = field.value;
if (kind === 'constant' && key === 'value') value = value === 'true';
config[key] = value;
});
return config;
}
function renderLogRetention() {
const select = $('#logRetentionDays'); if (!select || !app.settings) return;
[...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; });
const days = String(app.settings.event_log_retention_days || 30);
if (![...select.options].some(option => option.value === days)) {
const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option);
}
select.value = days;
}
function updateConnectivityMetricFields() {
const form = $('#settingsForm');
if (!form) return;
const localEnabled = !!form.ping_metrics_enabled?.checked;
if (form.ping_interval_seconds) form.ping_interval_seconds.disabled = !localEnabled;
if (form.ping_sample_count) form.ping_sample_count.disabled = !localEnabled;
const cloudEnabled = !!form.gree_cloud_connectivity_metrics_enabled?.checked;
if (form.gree_cloud_connectivity_metrics_interval_seconds) form.gree_cloud_connectivity_metrics_interval_seconds.disabled = !cloudEnabled;
if (form.gree_cloud_connectivity_metrics_sample_count) form.gree_cloud_connectivity_metrics_sample_count.disabled = !cloudEnabled;
}
function renderSettings() {
if (!app.settings) return;
const form = $('#settingsForm');
if (!form) return;
form.controller_id.value = app.settings.controller_id || '';
form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15;
form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5;
form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000';
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
form.ping_metrics_enabled.checked = app.settings.ping_metrics_enabled !== false;
form.ping_interval_seconds.value = Number(app.settings.ping_interval_seconds || 60);
form.ping_sample_count.value = Number(app.settings.ping_sample_count || 3);
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
form.history_retention_days.value = app.settings.history_retention_days || 30;
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
const cloud = app.settings.gree_cloud || {};
form.gree_cloud_enabled.checked = !!cloud.enabled;
form.gree_cloud_region.value = cloud.region || 'Europe';
form.gree_cloud_username.value = cloud.username || '';
form.gree_cloud_password.value = '';
form.gree_cloud_password.placeholder = cloud.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.gree_cloud_polling_interval_seconds.value = Number(cloud.polling_interval_seconds || 60);
form.gree_cloud_connectivity_metrics_enabled.checked = !!cloud.connectivity_metrics_enabled;
form.gree_cloud_connectivity_metrics_interval_seconds.value = Number(cloud.connectivity_metrics_interval_seconds || 300);
form.gree_cloud_connectivity_metrics_sample_count.value = Number(cloud.connectivity_metrics_sample_count || 3);
updateConnectivityMetricFields();
const cloudInstance = $('#greeCloudInstanceId');
if (cloudInstance) cloudInstance.textContent = cloud.installation_id || '—';
const cloudLastContact = $('#greeCloudLastContact');
if (cloudLastContact) cloudLastContact.textContent = cloud.last_successful_contact ? dateTime(cloud.last_successful_contact) : tr('common.unavailable');
form.compressor_protection_enabled.checked = app.settings.compressor_protection_enabled !== false;
form.compressor_protection_minutes.value = (Number(app.settings.compressor_protection_seconds || 180) / 60).toFixed(1).replace(/\.0$/, '');
updateCompressorProtectionFields();
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
form.influx_version.value = String(app.settings.influxdb?.version || '2');
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
form.influx_url.value = app.settings.influxdb?.url || '';
form.influx_database.value = app.settings.influxdb?.database || 'gree_controller';
form.influx_username.value = app.settings.influxdb?.username || '';
form.influx_password.value = '';
form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.influx_org.value = app.settings.influxdb?.org || '';
form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller';
form.influx_token.value = '';
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
form.debug_cloud_requests.checked = !!app.settings.debug?.cloud_requests;
form.debug_cloud_mqtt.checked = !!app.settings.debug?.cloud_mqtt;
const n = app.settings.notifications || {};
form.notifications_enabled.checked = !!n.enabled; form.notifications_mode.value = n.mode || 'problems'; form.notifications_provider.value = n.provider || 'pushover';
form.pushover_app_token.value = ''; form.pushover_user_key.value = ''; form.slack_webhook_url.value = ''; form.discord_webhook_url.value = '';
form.pushover_app_token.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.applicationTokenPlaceholder');
form.pushover_user_key.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.userKeyPlaceholder');
form.slack_webhook_url.placeholder = n.slack_configured ? tr('settings.secretSaved') : 'https://hooks.slack.com/services/…';
form.discord_webhook_url.placeholder = n.discord_configured ? tr('settings.secretSaved') : 'https://discord.com/api/webhooks/…';
form.notification_cooldown_seconds.value = n.cooldown_seconds || 300; form.notification_failure_threshold.value = n.communication_failure_threshold || 3; form.notification_target_timeout.value = n.target_timeout_minutes || 60;
const alerts = n.alert_types || {};
form.notification_alert_stale_sensor.checked = alerts.stale_sensor !== false;
form.notification_alert_sensor_errors.checked = alerts.sensor_errors !== false;
form.notification_alert_communication.checked = alerts.communication !== false;
form.notification_alert_target_timeout.checked = alerts.target_timeout !== false;
form.notification_alert_automation.checked = alerts.automation !== false;
form.notification_alert_sensor_discrepancy.checked = alerts.sensor_discrepancy !== false;
form.notification_alert_control_errors.checked = alerts.control_errors !== false;
form.notification_alert_important_events.checked = alerts.important_events !== false;
form.notification_alert_other.checked = alerts.other !== false;
updateNotificationFields();
updateInfluxFields();
renderLogRetention();
renderGreeFrameStats();
renderSystemInfo();
renderSimulationModeBanner();
setSettingsTab(app.settingsTab);
markFormClean(form);
void refreshGreeCloudRuntimeStatus();
}
function updateCompressorProtectionFields() {
const form = $('#settingsForm');
if (!form?.compressor_protection_enabled || !form?.compressor_protection_minutes) return;
form.compressor_protection_minutes.disabled = !form.compressor_protection_enabled.checked;
}
function renderSimulationModeBanner() {
const banner = $('#simulationModeBanner');
if (!banner) return;
const enabled = !!app.settings?.simulator_enabled;
banner.hidden = !enabled;
document.body.classList.toggle('simulation-mode-enabled', enabled);
}
function setSettingsTab(tab) {
app.settingsTab = ['gree', 'cloud'].includes(tab) ? tab : 'app';
$$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; });
$$('[data-settings-tab]').forEach(button => {
const active = button.dataset.settingsTab === app.settingsTab;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
}
function renderSystemInfo() {
const host = $('#systemInfo');
if (!host) return;
const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000));
const uptime = Number(app.system?.uptime_seconds || 0) + elapsed;
const dbPath = String(app.system?.database || '');
const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—';
const ready = !!app.system?.control_ready;
const connected = app.connectionStatus === 'connected';
const items = [
[tr('settings.version'), app.system?.version || '—', 'version'],
[tr('settings.uptime'), formatDuration(uptime), 'uptime'],
[tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'],
[tr('settings.websocket'), tr(`status.${app.connectionStatus}`), connected ? 'ok' : 'warn'],
[tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), 'neutral'],
[tr('settings.httpBind'), app.system?.bind || '—', 'mono'],
[tr('settings.database'), database, 'mono'],
[tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'],
];
host.innerHTML = `<div class="system-panel-head"><div><span class="eyebrow">${esc(tr('settings.systemState'))}</span><h3>${esc(tr('settings.systemStatusTitle'))}</h3><p>${esc(tr('settings.systemStateHint'))}</p></div><span class="system-health ${ready && connected ? 'ok' : 'warn'}">${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}</span></div><div class="system-status-grid">${items.map(([label, value, tone]) => `<div class="system-status-item ${esc(tone)}"><small>${esc(label)}</small><strong>${esc(value)}</strong></div>`).join('')}</div>`;
}
function renderGreeFrameStats() {
const host = $('#greeFrameStats');
if (!host) return;
const total = Number(app.system?.gree_received_frames || 0);
const byDevice = app.system?.gree_received_frames_by_device || {};
const deviceRows = app.devices.filter(device => !device.simulated).map(device => {
const count = Number(byDevice[device.id] || 0);
return `<div class="gree-frame-stat"><small>${esc(device.name || device.id)}</small><strong>${count.toLocaleString(locale())}</strong></div>`;
}).join('');
host.innerHTML = `<div class="gree-frame-stat total"><small>${esc(tr('settings.receivedFramesTotal'))}</small><strong>${total.toLocaleString(locale())}</strong></div>${deviceRows || `<div class="gree-frame-stat"><small>${esc(tr('settings.receivedFramesDevices'))}</small><strong>0</strong></div>`}`;
}
function renderNightSettings() {
if (!app.settings) return;
const form = $('#nightModeForm');
if (!form) return;
form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled;
form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00';
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false;
markFormClean(form);
}
function renderHomeAssistantAuthState() {
const form = $('#homeAssistantForm');
if (!form || !app.settings?.home_assistant) return;
const ha = app.settings.home_assistant;
const supervisorDetected = ha.supervisor_detected === true;
const manualFallback = supervisorDetected && (ha.manual_auth_override === true || app.haManualFallbackVisible === true);
form.dataset.haManualOverride = manualFallback ? 'true' : 'false';
$$('[data-ha-manual-field]', form).forEach(node => { node.hidden = supervisorDetected && !manualFallback; });
form.ha_url.required = manualFallback;
form.ha_token.required = manualFallback && !ha.manual_token_configured;
const useSupervisor = $('#haUseSupervisor');
if (useSupervisor) useSupervisor.hidden = !supervisorDetected || !manualFallback;
const status = $('#haSupervisorStatus');
if (!status) return;
status.hidden = !supervisorDetected;
status.classList.remove('success', 'warning', 'error');
if (!supervisorDetected) return;
const testState = app.haSupervisorTestState;
if (manualFallback) {
status.classList.add(testState?.ok === false ? 'error' : 'warning');
status.innerHTML = `<strong>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedTitle') : tr('settings.haManualFallbackTitle'))}</strong><span>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedHint') : tr('settings.haManualFallbackHint'))}</span>`;
} else if (testState?.ok === true) {
status.classList.add('success');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorVerifiedTitle'))}</strong><span>${esc(tr('settings.haSupervisorVerifiedHint'))}</span>`;
} else if (ha.supervisor_token_detected) {
status.classList.add('success');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorAutoTitle'))}</strong><span>${esc(tr('settings.haSupervisorAutoHint'))}</span>`;
} else {
status.classList.add('warning');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorMissingTitle'))}</strong><span>${esc(tr('settings.haSupervisorMissingHint'))}</span>`;
}
}
function renderHomeAssistantSettings() {
if (!app.settings) return;
const form = $('#homeAssistantForm');
if (!form) return;
const ha = app.settings.home_assistant || {};
const supervisorDetected = ha.supervisor_detected === true;
form.ha_url.value = supervisorDetected ? (ha.manual_url || '') : (ha.url || '');
form.ha_url.readOnly = false;
form.ha_token.value = '';
form.ha_token.readOnly = false;
form.ha_token.placeholder = (supervisorDetected ? ha.manual_token_configured : ha.token_configured)
? tr('settings.haTokenSaved')
: tr('settings.haLongLivedToken');
form.ha_outdoor_entity_id.value = ha.outdoor_entity_id || '';
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(ha.sensor_stale_after_seconds || 300) / 60)));
form.ha_allow_invalid_tls.checked = !!ha.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
renderHomeAssistantAuthState();
renderSensorAliases();
renderFlowSharedInputs();
renderAccessTokens();
markFormClean(form);
}
function updateInfluxFields() {
const version = $('#settingsForm [name=influx_version]')?.value || '2';
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
}
function updateNotificationFields() { const provider = $('#settingsForm [name=notifications_provider]')?.value || 'pushover'; $$('[data-notification-provider]').forEach(n => n.hidden = n.dataset.notificationProvider !== provider); }
function logCategory(kind = '') { const prefix = String(kind).split('.')[0]; return ['device', 'zone', 'automation', 'settings'].includes(prefix) ? prefix : (['home_assistant', 'influx', 'notification'].includes(prefix) ? 'integration' : 'system'); }
function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) {
app.debugLines.push({ source, kind, message, timestamp, data });
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
renderDebugOverlay();
}
function renderDebugOverlay() {
const overlay = $('#debugOverlay'); if (!overlay) return;
const enabled = !!app.settings?.debug?.overlay_enabled;
overlay.hidden = !enabled;
if (!enabled) return;
if (!['all', 'requests', 'gree', 'cloud', 'mqtt'].includes(app.debugFilter)) app.debugFilter = 'all';
const status = $('#debugOverlayStatus');
if (status) {
const enabledSources = [
app.settings?.debug?.gree_frames ? tr('debug.gree') : '',
app.settings?.debug?.cloud_requests ? tr('debug.cloud') : '',
app.settings?.debug?.cloud_mqtt ? tr('debug.mqtt') : '',
].filter(Boolean);
status.textContent = enabledSources.length ? `${tr('debug.liveSources')}: ${enabledSources.join(' · ')}` : tr('debug.apiOnly');
}
$$('[data-debug-filter]', overlay).forEach(button => {
const active = button.dataset.debugFilter === app.debugFilter;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
const host = $('#debugOverlayLines'); if (!host) return;
const visible = app.debugLines.filter(line => {
if (app.debugFilter === 'gree') return line.source === 'GREE';
if (app.debugFilter === 'requests') return line.source === 'HTTP';
if (app.debugFilter === 'cloud') return line.source === 'CLOUD';
if (app.debugFilter === 'mqtt') return line.source === 'MQTT';
return true;
}).slice(-120);
host.innerHTML = visible.length ? visible.map(line => {
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : line.source === 'CLOUD' ? 'cloud' : line.source === 'MQTT' ? 'mqtt' : 'api';
return `<div class="debug-line source-${sourceClass}"><time>${esc(new Date(line.timestamp).toLocaleTimeString(locale()))}</time><b>${esc(line.source)}</b><span>${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}</span></div>`;
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}</div>`;
host.scrollTop = host.scrollHeight;
}
async function loadDebugBacklog() {
if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return;
try {
const data = await api('/api/events?limit=60');
app.debugLines = (data.events || []).reverse().map(item => ({ source: 'API', kind: item.kind, message: item.message, timestamp: item.timestamp, data: item.metadata })).slice(-120);
app.debugBacklogLoaded = true;
renderDebugOverlay();
} catch (_) { }
}
function formatDuration(seconds) {
const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60);
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
}
setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000);
let greeCloudRuntimeStatusCache = null;
let greeCloudRuntimeStatusCachedAt = 0;
let greeCloudRuntimeStatusRequest = null;
const GREE_CLOUD_RUNTIME_STATUS_CACHE_MS = 5000;
function invalidateGreeCloudRuntimeStatusCache() {
greeCloudRuntimeStatusCache = null;
greeCloudRuntimeStatusCachedAt = 0;
}
async function getGreeCloudRuntimeStatus(force = false) {
const now = Date.now();
if (!force && greeCloudRuntimeStatusCache && now - greeCloudRuntimeStatusCachedAt < GREE_CLOUD_RUNTIME_STATUS_CACHE_MS) {
return greeCloudRuntimeStatusCache;
}
if (!force && greeCloudRuntimeStatusRequest) return greeCloudRuntimeStatusRequest;
const request = api('/api/integrations/gree-cloud/status');
if (!force) greeCloudRuntimeStatusRequest = request;
try {
const status = await request;
greeCloudRuntimeStatusCache = status;
greeCloudRuntimeStatusCachedAt = Date.now();
return status;
} finally {
if (greeCloudRuntimeStatusRequest === request) greeCloudRuntimeStatusRequest = null;
}
}
async function refreshGreeCloudRuntimeStatus({ force = false } = {}) {
const summary = $('.cloud-runtime-summary');
if (!summary) return;
const metricSelectors = [
'#greeCloudAccountStatus', '#greeCloudMqttStatus', '#greeCloudDevicesOnline', '#greeCloudRestResponseTime',
'#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince',
'#greeCloudBroker', '#greeCloudTraffic',
];
const setMetric = (selector, value, visible) => {
const node = $(selector); if (!node) return 0;
const row = node.closest('div');
if (row) row.hidden = !visible;
if (visible) node.textContent = value;
return visible ? 1 : 0;
};
const lastContact = $('#greeCloudLastContact');
const lastContactRow = lastContact?.closest('small');
try {
const status = await getGreeCloudRuntimeStatus(force);
const runtime = status.runtime || {};
const enabled = status.enabled === true;
const deviceCount = Number(status.device_count || 0);
const onlineCount = Number(status.online_device_count || 0);
const accountStatus = String(status.account_status || '').trim();
const mqttStatus = String(status.mqtt_status || '').trim();
const restMs = Number(status.last_rest_response_time_ms);
const responseMs = Number(runtime.last_response_time_ms);
const traffic = [Number(runtime.requests_sent || 0), Number(runtime.responses_received || 0), Number(runtime.request_timeouts || 0)];
let visibleCount = 0;
visibleCount += setMetric('#greeCloudAccountStatus', accountStatus.replaceAll('_', ' '), enabled && !!accountStatus && accountStatus !== 'disabled');
visibleCount += setMetric('#greeCloudMqttStatus', mqttStatus.replaceAll('_', ' '), enabled && !!mqttStatus && (mqttStatus === 'connected' || deviceCount > 0 || !['disconnected', 'disabled'].includes(mqttStatus)));
visibleCount += setMetric('#greeCloudDevicesOnline', `${onlineCount} / ${deviceCount}`, deviceCount > 0);
visibleCount += setMetric('#greeCloudRestResponseTime', `${restMs} ms`, Number.isFinite(restMs) && restMs >= 0);
visibleCount += setMetric('#greeCloudResponseTime', `${responseMs} ms`, Number.isFinite(responseMs) && responseMs >= 0);
visibleCount += setMetric('#greeCloudLastDeviceResponse', dateTime(runtime.last_device_response), !!runtime.last_device_response);
visibleCount += setMetric('#greeCloudLastMqttMessage', dateTime(runtime.last_mqtt_message), !!runtime.last_mqtt_message);
visibleCount += setMetric('#greeCloudConnectedSince', dateTime(runtime.mqtt_connected_since), !!runtime.mqtt_connected_since);
visibleCount += setMetric('#greeCloudBroker', runtime.broker_host || '', !!String(runtime.broker_host || '').trim());
visibleCount += setMetric('#greeCloudTraffic', traffic.join(' / '), traffic.some(value => value > 0));
summary.hidden = visibleCount === 0;
if (lastContactRow) lastContactRow.hidden = !status.last_successful_contact;
if (lastContact && status.last_successful_contact) lastContact.textContent = dateTime(status.last_successful_contact);
} catch (_) {
metricSelectors.forEach(selector => setMetric(selector, '', false));
summary.hidden = true;
if (lastContactRow) lastContactRow.hidden = true;
}
}