This commit is contained in:
Mateusz Gruszczyński
2026-08-24 16:12:31 +02:00
parent 83f744e2cb
commit 66a5d6e5e9
22 changed files with 686 additions and 103 deletions
+21 -4
View File
@@ -369,6 +369,7 @@ function buildZoneSimulation(zone, planZone) {
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 nativeSleep = nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZone?.enabled === false
? tr('simulation.reasonDisabled')
: mode === 'off'
@@ -378,7 +379,7 @@ function buildZoneSimulation(zone, planZone) {
: demand
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning};
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning};
}
function simulationRuleAction(rule) {
@@ -450,7 +451,8 @@ function renderSimulationPage() {
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':''}));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
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}${sleepMeta}`,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'));
@@ -488,6 +490,18 @@ function scheduleControlPlanLoad() {
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
}
function deviceFeaturePanel(device) {
const features = [
['light', 'supports_light', tr('devices.light')],
['xfan', 'supports_xfan', tr('devices.xfan')],
['health', 'supports_health', tr('devices.health')],
['air', 'supports_air', tr('devices.air')],
['sleep', 'supports_sleep', tr('devices.sleep')],
].filter(([, support]) => device[support] === true);
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field,,label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
@@ -509,9 +523,10 @@ function deviceCard(device, detailed = false) {
<div class="fan-row">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">↕ ${esc(tr('devices.swing'))}</button>
<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>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${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>`;
}
@@ -663,6 +678,7 @@ function renderSettings() {
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false;
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;
@@ -1460,6 +1476,7 @@ function settingsBodyFromForm(form) {
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,
use_native_sleep:form.night_mode_native_sleep.checked,
},
influxdb:{
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
+10 -9
View File
@@ -139,7 +139,7 @@
<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="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-block-head"><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>
@@ -151,18 +151,19 @@
</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-block-head"><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>
<label class="check wide"><input type="checkbox" name="night_mode_native_sleep"> <span data-i18n="settings.nightNativeSleep">Use native Sleep 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-block-head"><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>
@@ -171,12 +172,12 @@
</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-block-head"><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-block-head"><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>
@@ -196,7 +197,7 @@
</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-block-head"><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>
@@ -204,7 +205,7 @@
</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-block-head"><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>
@@ -223,12 +224,12 @@
</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="settings-block-head"><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="settings-block-head"><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>
+8 -1
View File
@@ -529,7 +529,6 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.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; }
@@ -605,3 +604,11 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.logs-toolbar { grid-template-columns:1fr; align-items:stretch; }
.simulation-flow-head { align-items:flex-start; flex-direction:column; }
}
/* v0.5.4: settings headers no longer use decorative icons. */
.settings-block-head { gap:0; }
.device-capability-panel { display:grid; gap:8px; padding:12px 0 2px; border-top:1px solid var(--line); }
.device-capability-panel > small { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; }
.device-capability-buttons { display:flex; flex-wrap:wrap; gap:7px; }
.device-capability-buttons button { min-height:34px; padding:7px 11px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); color:var(--text); }
.device-capability-buttons button.active { border-color:var(--accent); color:var(--accent); }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v053';
const CACHE = 'gree-controller-v054';
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())));