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

445 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.filter(zone => ['home_assistant', 'combined'].includes(zone.sensor_source)).map(zone => zone.ha_entity_id),
...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.settings?.home_assistant?.default_entity_id,
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 renderSensorAliases() {
const host = $('#sensorAliasList'); if (!host) return;
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'))}">×</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 || 'entity_id';
if (item.kind === 'ha_attribute') return `${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 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 = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>`;
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>`;
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><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 = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><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;
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 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.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);
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 renderHomeAssistantSettings() {
if (!app.settings) return;
const form = $('#homeAssistantForm');
if (!form) return;
form.ha_url.value = app.settings.home_assistant?.url || '';
form.ha_token.value = '';
form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken');
form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || '';
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60)));
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
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);
async function refreshGreeCloudRuntimeStatus() {
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 api('/api/integrations/gree-cloud/status');
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;
}
}