function renderAccessTokens() { const list = $('#accessTokenList'); if (!list) return; list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => `
${esc(item.name)}${esc(item.token_prefix)}${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}
`).join('') : `
${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
`; } 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 ``; }).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) ? `${esc(tr('settings.sensorMetricBadge'))}` : ''}${flowEntities.has(entity) ? `${esc(tr('settings.sensorFlowBadge'))}` : ''}`; return `
${esc(entity)}${badges ? `${badges}` : ''}
`; }).join('') : `
${esc(tr('settings.noSensorAliases'))}
`; } 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 ? `
${esc(tr('flow.sharedInputUsedBy', { count: usages.length }))}
${usages.slice(0, 4).map(flow => ``).join('')}${usages.length > 4 ? `+${usages.length - 4}` : ''}
` : `${esc(tr('flow.sharedInputUnused'))}`; return `
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}${esc(item.id)}${usageMarkup}
`; }).join('') : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputsEmptyHint'))}
`; const revision = $('#flowSharedInputsRevision'); if (revision) revision.value = JSON.stringify(items); } function sharedFlowOptions(items, selected, label = item => item.name) { return items.map(item => ``).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 ``; }).join('') : `
${esc(tr('flow.haEntitySearchEmpty'))}
`; 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 `
${esc(tr('flow.haEntitySearchHint'))}
`; } 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 = ``; 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')}`; else if (kind === 'ha_available') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window')}

${esc(tr('flow.haAvailableHint'))}

`; else if (kind === 'outdoor_temperature') fields = `

${esc(tr('flow.sharedInputSourceOnlyHint'))}

`; else if (kind === 'device_temperature') fields = ``; else if (kind === 'zone_temperature') fields = ``; else if (kind === 'house_mode') fields = `

${esc(tr('flow.sharedInputSourceOnlyHint'))}

`; else if (kind === 'device_state') fields = ``; else if (kind === 'zone_state') fields = ``; else if (kind === 'group_state') fields = ``; else if (kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; 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]) => ``).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 = `
${esc(tr('settings.systemState'))}

${esc(tr('settings.systemStatusTitle'))}

${esc(tr('settings.systemStateHint'))}

${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}
${items.map(([label, value, tone]) => `
${esc(label)}${esc(value)}
`).join('')}
`; } 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 `
${esc(device.name || device.id)}${count.toLocaleString(locale())}
`; }).join(''); host.innerHTML = `
${esc(tr('settings.receivedFramesTotal'))}${total.toLocaleString(locale())}
${deviceRows || `
${esc(tr('settings.receivedFramesDevices'))}0
`}`; } 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 = `${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedTitle') : tr('settings.haManualFallbackTitle'))}${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedHint') : tr('settings.haManualFallbackHint'))}`; } else if (testState?.ok === true) { status.classList.add('success'); status.innerHTML = `${esc(tr('settings.haSupervisorVerifiedTitle'))}${esc(tr('settings.haSupervisorVerifiedHint'))}`; } else if (ha.supervisor_token_detected) { status.classList.add('success'); status.innerHTML = `${esc(tr('settings.haSupervisorAutoTitle'))}${esc(tr('settings.haSupervisorAutoHint'))}`; } else { status.classList.add('warning'); status.innerHTML = `${esc(tr('settings.haSupervisorMissingTitle'))}${esc(tr('settings.haSupervisorMissingHint'))}`; } } 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 `
${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
`; }).join('') : `
${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}
`; 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; } }