664 lines
40 KiB
JavaScript
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;
|
|
}
|
|
}
|