This commit is contained in:
Mateusz Gruszczyński
2026-08-24 15:20:29 +02:00
parent 4f70e36e89
commit 83f744e2cb
19 changed files with 744 additions and 176 deletions
+172 -59
View File
@@ -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;
+107 -68
View File
@@ -23,7 +23,7 @@
<body>
<header class="topbar">
<div class="brand">
<div><strong>GREE Controller</strong><small id="connectionLabel" data-i18n="status.connecting">Connecting</small></div>
<div class="brand-line"><strong>GREE Controller</strong><small id="connectionLabel" class="connection-dot connecting" role="status" aria-label="Connecting" title="Connecting"></small></div>
</div>
<div class="top-actions">
<select class="toolbar-select" id="languageSelect" data-i18n-aria="controls.language" aria-label="Language">
@@ -93,14 +93,16 @@
<section class="view" data-view="simulation">
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
<p class="lead" data-i18n="simulation.description">Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.</p>
<p class="lead" data-i18n="simulation.description">Live preview of the thermostat and automation logic. The flow board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each GREE unit.</p>
<div class="simulation-summary-grid" id="simulationSummary"></div>
<div class="panel simulation-flow-shell">
<div class="simulation-flow-head"><div><span class="eyebrow" data-i18n="simulation.flowEyebrow">Live flow</span><h2 data-i18n="simulation.flowTitle">Automation flow board</h2></div><div class="flow-legend"><span><i class="flow-legend-dot input"></i><b data-i18n="simulation.legendInput">Input</b></span><span><i class="flow-legend-dot logic"></i><b data-i18n="simulation.legendLogic">Logic</b></span><span><i class="flow-legend-dot action"></i><b data-i18n="simulation.legendAction">Action</b></span></div></div>
<div class="simulation-flow-scroll"><div class="simulation-flow-board" id="simulationFlowBoard"></div></div>
</div>
<div class="panel simulation-timeline-panel">
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span><h2 data-i18n="simulation.timelineTitle">What will happen next</h2></div></div>
<div class="simulation-timeline" id="simulationTimeline"></div>
</div>
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.zoneEyebrow">Zones</span><h2 data-i18n="simulation.zoneTitle">Thermostat decision flow</h2></div></div>
<div class="simulation-zone-grid" id="simulationZones"></div>
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span><h2 data-i18n="simulation.rulesTitle">Automation rules</h2></div></div>
<div class="simulation-rules-grid" id="simulationRules"></div>
</section>
@@ -117,15 +119,15 @@
<div class="panel chart-panel history-toolbar-panel">
<div class="chart-toolbar">
<div id="historyContextControls" class="history-context-controls"></div>
<select id="historyHours" aria-label="History range">
<label class="history-range-control"><span data-i18n="history.range">Range</span><select id="historyHours" aria-label="History range">
<option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option>
<option value="2160" data-i18n="history.90d">90 days</option>
<option value="8760" data-i18n="history.1y">1 year</option>
</select>
<button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
</select></label>
<button class="secondary history-refresh-button" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
</div>
<div class="history-summary" id="historySummary"></div>
</div>
@@ -135,72 +137,109 @@
<section class="view" data-view="settings">
<div class="section-heading"><div><span class="eyebrow" data-i18n="settings.system">System</span><h1 data-i18n="nav.settings">Settings</h1></div></div>
<form class="panel form-grid" id="settingsForm">
<h3 data-i18n="settings.controller">Controller</h3>
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
<hr>
<h3 data-i18n="settings.metrics">Metrics storage</h3>
<p class="field-note wide" data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p>
<label><span data-i18n="settings.retentionDays">Local retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
<label class="check"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
<hr>
<h3 data-i18n="settings.greeCommands">GREE commands</h3>
<label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label>
<p class="field-note wide" data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p>
<hr>
<h3 data-i18n="settings.influx">Long-term InfluxDB history</h3>
<p class="field-note wide" data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p>
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
<div class="wide influx-fields" data-influx-fields="1">
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
<div class="wide influx-fields" data-influx-fields="2">
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
<hr>
<h3 data-i18n="settings.debug">On-screen debug</h3>
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
<hr>
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://10.87.65.2.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
<hr>
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
<p class="field-note wide" data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p>
<div class="wide token-manager">
<div id="accessTokenList" class="token-list"></div>
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
</div>
<hr>
<h3 data-i18n="settings.backup">Configuration backup</h3>
<p class="field-note wide warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p>
<div class="form-actions wide backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
<form class="settings-form" id="settingsForm">
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.controller">Controller</h3><p data-i18n="settings.controllerHint">Runtime and LAN discovery settings.</p></div></div>
<div class="settings-grid">
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.nightMode">Night mode</h3><p data-i18n="settings.nightModeHint">During selected hours the thermostat limits fan speed and can request Quiet on supported units.</p></div></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="night_mode_enabled"> <span data-i18n="settings.nightModeEnabled">Enable night mode</span></label>
<label><span data-i18n="settings.nightStart">Start</span><input type="time" name="night_mode_start" value="22:00"></label>
<label><span data-i18n="settings.nightEnd">End</span><input type="time" name="night_mode_end" value="06:00"></label>
<label><span data-i18n="settings.nightMaxFan">Maximum fan speed</span><select name="night_mode_max_fan_speed"><option value="1" data-i18n="fan.low">Low</option><option value="2" data-i18n="fan.mediumLow">Medium-low</option><option value="3" data-i18n="fan.medium">Medium</option><option value="4" data-i18n="fan.mediumHigh">Medium-high</option><option value="5" data-i18n="fan.high">High</option></select></label>
<label class="check"><input type="checkbox" name="night_mode_force_quiet"> <span data-i18n="settings.nightForceQuiet">Use Quiet when supported</span></label>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
<div class="settings-grid">
<label><span data-i18n="settings.retentionDays">Local metric retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
<label><span data-i18n="settings.eventRetentionDays">Event/log retention (days)</span><input type="number" name="event_log_retention_days" min="1" max="3650" required></label>
<label class="check wide"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.greeCommands">GREE commands</h3><p data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p></div></div>
<div class="settings-grid"><label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label></div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.influx">Long-term InfluxDB history</h3><p data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p></div></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
<div class="wide influx-fields" data-influx-fields="1">
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
<div class="wide influx-fields" data-influx-fields="2">
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.debug">On-screen debug</h3><p data-i18n="settings.debugHint">Live diagnostics displayed on every application page.</p></div></div>
<div class="settings-grid">
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon">HA</span><div><h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3><p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant temperature sensor.</p></div></div>
<div class="settings-grid">
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://192.168.50.25.</p>
<div class="wide sensor-alias-manager">
<div class="settings-subhead"><div><strong data-i18n="settings.sensorAliases">Sensor aliases</strong><small data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original entity_id remains unchanged.</small></div></div>
<div id="sensorAliasList" class="sensor-alias-list"></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
</div>
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
<div class="token-manager"><div id="accessTokenList" class="token-list"></div><div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div></div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><span class="settings-block-icon"></span><div><h3 data-i18n="settings.backup">Configuration backup</h3><p class="warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p></div></div>
<div class="form-actions backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
</section>
<div class="settings-save-bar"><span data-i18n="settings.saveHint">Save changes made in the blocks above.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
<div class="panel system-panel" id="systemInfo"></div>
</section>
<section class="view" data-view="logs">
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
<div class="panel logs-toolbar"><div><strong data-i18n="logs.retention">Log retention</strong><small data-i18n="logs.retentionHint">Old event rows are removed automatically during maintenance.</small></div><label><span data-i18n="logs.keepFor">Keep for</span><select id="logRetentionDays"><option value="7">7 days</option><option value="14">14 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label><button class="secondary" id="saveLogRetention" data-i18n="actions.save">Save</button></div>
<div class="panel log-list" id="logList"></div>
</section>
</main>
@@ -260,7 +299,7 @@
<dialog id="renameDeviceDialog">
<form method="dialog" id="renameDeviceForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="devices.rename">Rename device</h2><button type="button" data-close>×</button></div>
<div class="dialog-head"><h2 data-i18n="devices.nameProtocol">Name / protocol</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label>
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key and performs a new bind on the next request.</p>
+99
View File
@@ -506,3 +506,102 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
}
/* Compact connection state: a colored dot replaces the Connected/Disconnected label. */
.brand-line { display:flex; align-items:center; gap:8px; min-width:0; }
.brand-line strong { display:block; }
.connection-dot { display:inline-block !important; width:9px; height:9px; flex:0 0 9px; margin:0 !important; border-radius:50%; background:var(--muted); box-shadow:0 0 0 3px color-mix(in srgb, var(--muted) 15%, transparent); }
.connection-dot.connected { background:#37c96b; box-shadow:0 0 0 3px rgba(55,201,107,.14), 0 0 10px rgba(55,201,107,.3); }
.connection-dot.disconnected, .connection-dot.connectionError { background:#ef5b5b; box-shadow:0 0 0 3px rgba(239,91,91,.14), 0 0 10px rgba(239,91,91,.24); }
.connection-dot.connecting { background:#e2a93b; box-shadow:0 0 0 3px rgba(226,169,59,.14); animation:connection-pulse 1.25s ease-in-out infinite; }
@keyframes connection-pulse { 50% { opacity:.45; transform:scale(.86); } }
/* v0.5.3 usability refinements */
.history-toolbar-panel .chart-toolbar { display:grid; grid-template-columns:minmax(260px,1fr) minmax(180px,.55fr) auto; gap:12px; align-items:end; }
.history-toolbar-panel .chart-toolbar > * { min-width:0; }
.history-context-controls { display:block; width:100%; min-width:0; }
.history-context-controls label, .history-range-control { width:100%; }
.history-range-control { display:grid; gap:7px; }
.history-refresh-button { min-height:45px; align-self:end; }
.settings-form { display:grid; gap:14px; }
.settings-block { display:grid; gap:18px; }
.settings-block-head { display:flex; align-items:flex-start; gap:13px; }
.settings-block-head h3 { margin:1px 0 4px; font-size:18px; }
.settings-block-head p { margin:0; color:var(--muted); font-size:12px; line-height:1.5; }
.settings-block-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border:1px solid var(--line); border-radius:11px; color:var(--accent); background:var(--surface-muted); font-size:14px; font-weight:800; }
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.settings-grid .wide { grid-column:1/-1; }
.settings-subhead { display:flex; justify-content:space-between; gap:12px; margin-bottom:10px; }
.settings-subhead strong, .settings-subhead small { display:block; }
.settings-subhead small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; }
.settings-save-bar { position:sticky; bottom:18px; z-index:9; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:13px 15px; border:1px solid var(--line-strong); border-radius:16px; background:color-mix(in srgb,var(--surface) 94%,transparent); box-shadow:0 8px 30px rgba(0,0,0,.18); backdrop-filter:blur(16px); }
.settings-save-bar span { color:var(--muted); font-size:12px; }
.sensor-alias-manager { padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--surface-muted); }
.sensor-alias-list { display:grid; gap:8px; }
.sensor-alias-row { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) 34px; gap:8px; align-items:center; }
.sensor-alias-row > span { overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
.sensor-alias-row input { min-height:40px; }
.sensor-alias-clear { width:34px; height:34px; padding:0; border-radius:10px; color:var(--muted); background:transparent; }
.sensor-alias-add { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) auto; gap:8px; margin-top:10px; }
.logs-toolbar { display:grid; grid-template-columns:1fr minmax(160px,220px) auto; gap:14px; align-items:end; margin-bottom:14px; }
.logs-toolbar > div strong, .logs-toolbar > div small { display:block; }
.logs-toolbar > div small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; }
.simulation-flow-shell { padding:0; overflow:hidden; margin-bottom:18px; }
.simulation-flow-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; padding:18px 18px 14px; border-bottom:1px solid var(--line); }
.simulation-flow-head h2 { margin:3px 0 0; }
.flow-legend { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:11px; }
.flow-legend span { display:flex; align-items:center; gap:6px; }
.flow-legend b { font-weight:650; }
.flow-legend-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); }
.flow-legend-dot.input { background:var(--teal); }
.flow-legend-dot.logic { background:var(--purple); }
.flow-legend-dot.action { background:var(--accent); }
.simulation-flow-scroll { overflow:auto; max-width:100%; scrollbar-width:thin; }
.simulation-flow-board { position:relative; min-width:1325px; background-color:var(--surface-muted); background-image:radial-gradient(circle,var(--grid) 1px,transparent 1.5px); background-size:20px 20px; }
.flow-links { position:absolute; inset:0; z-index:1; pointer-events:none; overflow:visible; }
.flow-link { fill:none; stroke:color-mix(in srgb,var(--muted) 48%,transparent); stroke-width:2; vector-effect:non-scaling-stroke; }
.flow-link.bus { stroke-dasharray:5 6; opacity:.55; }
.flow-link.input-link { stroke:color-mix(in srgb,var(--teal) 65%,var(--muted)); }
.flow-link.logic-link { stroke:color-mix(in srgb,var(--purple) 58%,var(--muted)); }
.flow-link.action-link { stroke:color-mix(in srgb,var(--accent) 60%,var(--muted)); }
.flow-link.active-link { stroke:var(--accent); stroke-width:3; filter:drop-shadow(0 0 4px color-mix(in srgb,var(--accent) 35%,transparent)); }
.flow-link.night-link { stroke:var(--info); stroke-dasharray:6 5; stroke-width:2.5; }
.flow-lane-label { position:absolute; z-index:2; left:45px; width:1230px; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:11px; }
.flow-lane-label::after { content:""; height:1px; flex:1; background:var(--line); }
.flow-lane-label strong { color:var(--text-soft); font-size:12px; }
.flow-node { position:absolute; z-index:3; display:grid; align-content:start; gap:6px; padding:12px 13px; border:1px solid var(--line-strong); border-radius:12px; background:color-mix(in srgb,var(--surface) 96%,transparent); box-shadow:0 8px 24px rgba(0,0,0,.15); }
.flow-node.input { border-left:3px solid var(--teal); }
.flow-node.logic { border-left:3px solid var(--purple); }
.flow-node.action { border-left:3px solid var(--accent); }
.flow-node.event { border-left:3px solid var(--info); }
.flow-node.decision.demand { box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 28%,transparent),0 8px 24px rgba(0,0,0,.15); }
.flow-node.global { min-height:106px; }
.flow-node.night-active { border-color:color-mix(in srgb,var(--info) 45%,var(--line-strong)); }
.flow-node-top { display:flex; align-items:center; justify-content:space-between; gap:6px; color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; }
.flow-node h3 { overflow:hidden; margin:0; color:var(--text); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
.flow-node > strong { color:var(--text); font-size:17px; line-height:1.25; }
.flow-node p { display:-webkit-box; overflow:hidden; margin:0; color:var(--muted); font-size:10px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; }
.flow-node-badge { max-width:86px; overflow:hidden; padding:3px 6px; border-radius:999px; color:var(--muted); background:var(--surface-muted); font-size:8px; text-overflow:ellipsis; white-space:nowrap; }
.flow-node-badge.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,var(--surface)); }
.flow-port { position:absolute; top:50%; width:9px; height:9px; margin-top:-5px; border:2px solid var(--surface); border-radius:50%; background:var(--muted); }
.flow-port.in { left:-6px; }
.flow-port.out { right:-6px; }
.flow-node.input .flow-port { background:var(--teal); }
.flow-node.logic .flow-port { background:var(--purple); }
.flow-node.action .flow-port { background:var(--accent); }
.flow-node.event .flow-port { background:var(--info); }
@media (max-width: 760px) {
.history-toolbar-panel .chart-toolbar { grid-template-columns:1fr 1fr; }
.history-context-controls { grid-column:1/-1; }
.history-refresh-button { grid-column:auto; }
.settings-grid { grid-template-columns:1fr; }
.settings-grid .wide { grid-column:auto; }
.settings-save-bar { bottom:76px; }
.sensor-alias-row, .sensor-alias-add { grid-template-columns:1fr; }
.sensor-alias-clear { justify-self:end; }
.logs-toolbar { grid-template-columns:1fr; align-items:stretch; }
.simulation-flow-head { align-items:flex-start; flex-direction:column; }
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v044';
const CACHE = 'gree-controller-v053';
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));