v0.5.3
This commit is contained in:
+172
-59
@@ -20,7 +20,7 @@ const app = {
|
||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false,
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {},
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
@@ -38,6 +38,17 @@ const historyNumber = value => value === null || value === undefined || value ==
|
||||
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
||||
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
||||
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
|
||||
|
||||
function updateConnectionIndicator(status) {
|
||||
app.connectionStatus = status || 'connecting';
|
||||
const node = $('#connectionLabel');
|
||||
if (!node) return;
|
||||
const label = tr(`status.${app.connectionStatus}`);
|
||||
node.className = `connection-dot ${app.connectionStatus}`;
|
||||
node.setAttribute('aria-label', label);
|
||||
node.title = label;
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const resolved = app.theme === 'light' || app.theme === 'dark'
|
||||
@@ -61,8 +72,7 @@ function applyTranslations() {
|
||||
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
||||
$('#languageSelect').value = app.language;
|
||||
$('#themeSelect').value = app.theme;
|
||||
const connectionLabel = $('#connectionLabel');
|
||||
if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`);
|
||||
updateConnectionIndicator(app.connectionStatus);
|
||||
renderAll();
|
||||
if (app.currentView === 'logs') loadLogs();
|
||||
if (app.currentView === 'history' && app.zones.length) loadHistory();
|
||||
@@ -175,6 +185,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.automations = data.automations || [];
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
|
||||
app.system = data.system || {};
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
renderAll();
|
||||
@@ -348,8 +359,16 @@ function buildZoneSimulation(zone, planZone) {
|
||||
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
|
||||
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
|
||||
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
|
||||
const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
|
||||
const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual');
|
||||
const nightActive = !!app.controlPlan?.night_mode_active;
|
||||
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
|
||||
let fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
|
||||
if (nightActive) {
|
||||
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
|
||||
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
|
||||
}
|
||||
const quiet = nightActive && app.settings?.night_mode?.force_quiet
|
||||
? tr('simulation.quietIfSupported')
|
||||
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
|
||||
const reasoning = !zone?.enabled || planZone?.enabled === false
|
||||
? tr('simulation.reasonDisabled')
|
||||
: mode === 'off'
|
||||
@@ -373,70 +392,93 @@ function simulationRuleAction(rule) {
|
||||
function renderSimulationPage() {
|
||||
const summaryHost = $('#simulationSummary');
|
||||
const timelineHost = $('#simulationTimeline');
|
||||
const zonesHost = $('#simulationZones');
|
||||
const boardHost = $('#simulationFlowBoard');
|
||||
const rulesHost = $('#simulationRules');
|
||||
if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return;
|
||||
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
|
||||
const plan = app.controlPlan;
|
||||
if (!plan) {
|
||||
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||
timelineHost.innerHTML = '';
|
||||
zonesHost.innerHTML = '';
|
||||
rulesHost.innerHTML = '';
|
||||
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||
const nightOn = !!plan.night_mode_active;
|
||||
summaryHost.innerHTML = [
|
||||
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
||||
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
|
||||
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})},
|
||||
{label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`},
|
||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||
|
||||
const zones = plan.zones || [];
|
||||
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
|
||||
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
|
||||
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const pathBetween = (ax, ay, bx, by, cls='') => {
|
||||
const bend = Math.max(36, (bx - ax) * .45);
|
||||
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax+bend} ${ay}, ${bx-bend} ${by}, ${bx} ${by}"/>`;
|
||||
};
|
||||
const verticalLink = (ax, ay, bx, by, cls='bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay+35}, ${bx} ${by-35}, ${bx} ${by}"/>`;
|
||||
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
|
||||
|
||||
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
|
||||
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:modeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')}));
|
||||
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
|
||||
|
||||
zones.forEach((planZone, index) => {
|
||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||
const sim = buildZoneSimulation(zone, planZone);
|
||||
const status = simulationStatusInfo(sim.status);
|
||||
const y = topY + index * rowH;
|
||||
const mid = y + nodeH / 2;
|
||||
const next = (planZone.next_events || [])[0];
|
||||
const source = zoneControlSourceLabel(planZone.control_source);
|
||||
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
|
||||
? haSensorLabel(zone?.ha_entity_id || source)
|
||||
: (planZone.device_name || tr('common.device'));
|
||||
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
||||
const quietText = sim.quiet;
|
||||
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
|
||||
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
|
||||
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
|
||||
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(planZone.device_name || '')}</span></div>`);
|
||||
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
|
||||
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:modeLabel(sim.mode)}));
|
||||
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
|
||||
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''}));
|
||||
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'}));
|
||||
|
||||
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
|
||||
links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link'));
|
||||
links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link'));
|
||||
links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link'));
|
||||
links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus'));
|
||||
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus'));
|
||||
});
|
||||
|
||||
boardHost.style.width = `${boardW}px`;
|
||||
boardHost.style.height = `${boardH}px`;
|
||||
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
|
||||
|
||||
const timelineItems = [];
|
||||
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||
(plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
const unique = [], seen = new Set();
|
||||
for (const item of timelineItems) {
|
||||
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(item);
|
||||
if (unique.length >= 16) break;
|
||||
if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break;
|
||||
}
|
||||
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
||||
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
|
||||
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||
|
||||
zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => {
|
||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||
const sim = buildZoneSimulation(zone, planZone);
|
||||
const status = simulationStatusInfo(sim.status);
|
||||
const events = (planZone.next_events || []).slice(0, 4);
|
||||
const sourceLabel = zoneControlSourceLabel(planZone.control_source);
|
||||
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
||||
const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`;
|
||||
return `<article class="panel simulation-zone-card ${planZone.enabled ? '' : 'disabled'}">
|
||||
<div class="simulation-zone-head"><div><span class="eyebrow">${esc(planZone.device_name || tr('common.noDevice'))}</span><h3>${esc(planZone.zone_name)}</h3><p>${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}</p></div><span class="badge ${status.badge}">${esc(status.label)}</span></div>
|
||||
<div class="simulation-flow">
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepRoom'))}</small><strong>${esc(fmtTemp(sim.current))}</strong><span>${esc(sourceLabel)}</span></div>
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepTarget'))}</small><strong>${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}</strong><span>${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}</span></div>
|
||||
<div class="simulation-step"><small>${esc(tr('simulation.stepDecision'))}</small><strong>${esc(status.label)}</strong><span>${esc(sim.reasoning)}</span></div>
|
||||
<div class="simulation-step accent"><small>${esc(tr('simulation.stepCommand'))}</small><strong>${esc(commandText)}</strong><span>${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}</span></div>
|
||||
</div>
|
||||
<div class="simulation-metrics">
|
||||
<div><small>${esc(tr('simulation.deviceSetpoint'))}</small><strong>${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}</strong></div>
|
||||
<div><small>${esc(tr('common.fan'))}</small><strong>${esc(fanText)}</strong></div>
|
||||
<div><small>${esc(tr('devices.quiet'))}</small><strong>${esc(sim.quiet)}</strong></div>
|
||||
<div><small>${esc(tr('simulation.outdoor'))}</small><strong>${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}</strong></div>
|
||||
</div>
|
||||
<ul class="plan-events simulation-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('zones.emptyText'))}</div>`;
|
||||
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
@@ -451,7 +493,7 @@ function deviceCard(device, detailed = false) {
|
||||
const fans = [0,1,3,5];
|
||||
const error = device.last_error
|
||||
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
|
||||
: `<small>${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</small>`;
|
||||
: `<small>${esc(device.ip)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</small>`;
|
||||
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
||||
<div class="device-head">
|
||||
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
|
||||
@@ -470,7 +512,7 @@ function deviceCard(device, detailed = false) {
|
||||
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
|
||||
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
|
||||
</div>
|
||||
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.rename'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -577,6 +619,32 @@ function renderAccessTokens() {
|
||||
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
|
||||
}
|
||||
|
||||
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.historyData.sensors.map(row => row.entity_id),
|
||||
].filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function renderSensorAliases() {
|
||||
const host = $('#sensorAliasList'); if (!host) return;
|
||||
const entities = knownHaEntities();
|
||||
host.innerHTML = entities.length ? entities.map(entity => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><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 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');
|
||||
@@ -587,8 +655,14 @@ function renderSettings() {
|
||||
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;
|
||||
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.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;
|
||||
@@ -611,6 +685,8 @@ function renderSettings() {
|
||||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
updateInfluxFields();
|
||||
renderSensorAliases();
|
||||
renderLogRetention();
|
||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||
}
|
||||
|
||||
@@ -855,7 +931,7 @@ function historyEntityOptions() {
|
||||
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
].filter(Boolean))].sort();
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(entity)}</option>`).join('');
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
|
||||
return {zones, devices, sensors, entities};
|
||||
}
|
||||
|
||||
@@ -1004,7 +1080,7 @@ function renderOverviewHistory() {
|
||||
|
||||
const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||
const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))];
|
||||
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${entity}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')];
|
||||
const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries];
|
||||
drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries);
|
||||
@@ -1063,7 +1139,7 @@ function renderSensorHistory() {
|
||||
const entities=[...new Set(rows.map(row=>row.entity_id))];
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint'));
|
||||
const series=entities.map((entity,index)=>({label:entity,color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series);
|
||||
}
|
||||
|
||||
@@ -1082,7 +1158,7 @@ function customSeriesOptions() {
|
||||
items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||
items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||
});
|
||||
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${entity}`]));
|
||||
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`]));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -1101,7 +1177,7 @@ function customSeriesDefinition(key,index=0) {
|
||||
const info=fields[field]; if(!info) return null;
|
||||
return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN};
|
||||
}
|
||||
if(kind==='ha') return {key,label:`HA · ${id}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
||||
if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1180,6 +1256,7 @@ async function handleHistoryAction(button) {
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
renderLogRetention();
|
||||
try {
|
||||
const data = await api('/api/events?limit=150');
|
||||
const logs = data.events || [];
|
||||
@@ -1195,21 +1272,21 @@ function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws;
|
||||
ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); };
|
||||
ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||
ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.connectionError'); };
|
||||
ws.onopen = () => updateConnectionIndicator('connected');
|
||||
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||
ws.onerror = () => updateConnectionIndicator('connectionError');
|
||||
ws.onmessage = event => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
@@ -1378,7 +1455,12 @@ function settingsBodyFromForm(form) {
|
||||
discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast,
|
||||
house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked,
|
||||
history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked,
|
||||
event_log_retention_days:Number(raw.event_log_retention_days),
|
||||
suppress_device_beep:form.suppress_device_beep.checked,
|
||||
night_mode:{
|
||||
enabled:form.night_mode_enabled.checked,start_time:raw.night_mode_start,end_time:raw.night_mode_end,
|
||||
max_fan_speed:Number(raw.night_mode_max_fan_speed),force_quiet:form.night_mode_force_quiet.checked,
|
||||
},
|
||||
influxdb:{
|
||||
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
|
||||
database:raw.influx_database, username:raw.influx_username, password:raw.influx_password,
|
||||
@@ -1386,18 +1468,49 @@ function settingsBodyFromForm(form) {
|
||||
history_threshold_days:Number(raw.influx_threshold_days),
|
||||
},
|
||||
debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked},
|
||||
home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked},
|
||||
home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked,sensor_aliases:{...(app.sensorAliases||{})}},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSettingsForm(form, notify=true) {
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
}
|
||||
|
||||
$('#settingsForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#addSensorAlias')?.addEventListener('click', () => {
|
||||
const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName');
|
||||
const entity = entityInput.value.trim(), alias = aliasInput.value.trim();
|
||||
if (!entity || !alias) return toast(tr('settings.aliasRequired'), true);
|
||||
app.sensorAliases[entity] = alias;
|
||||
entityInput.value = ''; aliasInput.value = '';
|
||||
renderSensorAliases(); renderHistoryNavigation();
|
||||
});
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
const input = event.target.closest('[data-sensor-alias]'); if (!input) return;
|
||||
const entity = input.dataset.sensorAlias, alias = input.value.trim();
|
||||
if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity];
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return;
|
||||
delete app.sensorAliases[button.dataset.clearSensorAlias];
|
||||
renderSensorAliases(); renderHistoryNavigation();
|
||||
});
|
||||
|
||||
$('#saveLogRetention')?.addEventListener('click', async () => {
|
||||
const days = Number($('#logRetentionDays')?.value || 30);
|
||||
try {
|
||||
const result = await api('/api/events/retention', {method:'PUT', body:{days}});
|
||||
app.settings.event_log_retention_days = result.days;
|
||||
$('#settingsForm').event_log_retention_days.value = result.days;
|
||||
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days}));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
|
||||
$('#createAccessToken').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
|
||||
Reference in New Issue
Block a user