This commit is contained in:
Mateusz Gruszczyński
2026-09-21 22:58:30 +02:00
parent 3b969c8754
commit d80005de33
33 changed files with 722 additions and 94 deletions
+113
View File
@@ -1885,6 +1885,25 @@ button.outside-pill:focus-visible {
font-weight: 800;
}
.house-power-actions .house-emergency-stop {
border-color: color-mix(in srgb, var(--warning) 58%, var(--line));
color: var(--warning);
background: var(--warning-soft);
font-weight: 800;
}
.house-power-actions .house-emergency-stop:hover {
border-color: var(--warning);
background: color-mix(in srgb, var(--warning-soft) 72%, var(--surface-2));
}
.house-power-actions .house-emergency-stop.resume,
.house-power-actions .house-emergency-stop.resume.active {
border-color: var(--accent-border);
color: var(--accent-text);
background: var(--accent);
}
.zone-thermostat {
overflow: hidden;
}
@@ -4795,6 +4814,93 @@ body.simulation-standalone [data-view="simulation"] {
gap: 8px;
}
#controlPlan .plan-reasons {
grid-column: 1 / -1;
gap: 12px;
padding: 14px;
}
#controlPlan .plan-reason-subsection {
display: grid;
gap: 7px;
}
#controlPlan .plan-reason-subsection h4 {
margin: 0;
color: var(--muted);
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
#controlPlan .plan-reason-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
#controlPlan .plan-reason-list li {
display: grid;
grid-template-columns: minmax(130px, .75fr) minmax(0, 1.8fr);
gap: 14px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-muted);
}
#controlPlan .plan-reason-list li>div {
display: grid;
gap: 2px;
min-width: 0;
}
#controlPlan .plan-reason-list strong,
#controlPlan .plan-reason-list small,
#controlPlan .plan-reason-list span {
min-width: 0;
}
#controlPlan .plan-reason-list strong {
font-size: 12px;
}
#controlPlan .plan-reason-list small {
color: var(--muted);
font-size: 10px;
}
#controlPlan .plan-reason-list span {
color: var(--text-soft);
font-size: 11px;
line-height: 1.4;
}
#controlPlan .plan-reason-alert {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--line));
border-radius: 11px;
background: var(--warning-soft);
}
#controlPlan .plan-reason-alert strong {
color: var(--warning);
font-size: 12px;
}
#controlPlan .plan-reason-alert span,
#controlPlan .plan-reason-empty {
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
#controlPlan .plan-events .plan-rule-summary {
display: grid;
gap: 2px;
@@ -10812,3 +10918,10 @@ select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-tr
grid-column: 2;
}
}
@media (max-width: 620px) {
#controlPlan .plan-reason-list li {
grid-template-columns: 1fr;
gap: 5px;
}
}
+2 -1
View File
@@ -85,7 +85,8 @@
<div class="house-power-actions"><button id="housePowerOn" type="button" class="secondary"
data-action="house-power" data-value="true" data-i18n="house.powerOnAll">Turn all on</button><button
id="housePowerOff" type="button" class="secondary" data-action="house-power" data-value="false"
data-i18n="house.powerOffAll">Turn all off</button></div>
data-i18n="house.powerOffAll">Turn all off</button><button id="houseEmergencyStop" type="button"
class="secondary house-emergency-stop" data-action="house-emergency-stop" data-value="true" hidden>Emergency stop</button></div>
</div>
<div class="dashboard-global-divider"></div>
<div class="house-climate dashboard-house-climate" id="houseClimate"></div>
+1
View File
@@ -45,6 +45,7 @@ function applyBootstrapSnapshot(data) {
app.automations = data.automations || [];
app.flows = data.flows || [];
app.accessTokens = data.access_tokens || [];
app.house = data.house || { mode: 'cool', emergency_stop_enabled: false, emergency_stop_since: null };
app.settings = settingsFromSnapshot(data.settings, data.house?.mode || 'cool');
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
+1 -1
View File
@@ -25,7 +25,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
const app = {
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, house: { mode: 'cool', emergency_stop_enabled: false, emergency_stop_since: null }, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', authTokenSubmitted: false, ws: null, wsTimer: null, wsErrorTimer: null,
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {}, defaultLanguage: DEFAULT_LANGUAGE,
+81 -3
View File
@@ -23,7 +23,8 @@ function renderSummary() {
const average = temperatures.length ? temperatures.reduce((a, b) => a + b, 0) / temperatures.length : null;
const online = app.devices.filter(d => d.online).length;
const active = app.devices.filter(d => d.power).length;
const demand = app.zones.filter(z => z.enabled && z.demand).length;
const emergencyActive = !!(app.house?.emergency_stop_enabled || app.controlPlan?.emergency_stop_active);
const demand = emergencyActive ? 0 : app.zones.filter(z => z.enabled && z.demand).length;
const toolbarStatus = $('#toolbarRuntimeStatus');
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', { active, total: app.devices.length });
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}<small>°C</small>`;
@@ -46,9 +47,23 @@ function renderHouseClimate() {
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
const emergencyButton = $('#houseEmergencyStop');
const emergencyActive = !!(app.house?.emergency_stop_enabled || app.controlPlan?.emergency_stop_active);
const hasAutomaticControl = (app.zones || []).some(zone => zone.enabled)
|| (app.schedules || []).some(item => item.enabled)
|| (app.automations || []).some(item => item.enabled);
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
if (emergencyButton) {
emergencyButton.hidden = !emergencyActive && !hasAutomaticControl;
emergencyButton.dataset.value = emergencyActive ? 'false' : 'true';
emergencyButton.classList.toggle('resume', emergencyActive);
emergencyButton.classList.toggle('active', emergencyActive);
emergencyButton.setAttribute('aria-pressed', String(emergencyActive));
emergencyButton.textContent = tr(emergencyActive ? 'house.emergencyResume' : 'house.emergencyStop');
emergencyButton.title = tr(emergencyActive ? 'house.emergencyResumeHint' : 'house.emergencyStopHint');
}
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><button type="button" class="outside-pill" data-action="open-outdoor-history" title="${esc(tr('house.outdoorHistoryOpen'))}" aria-label="${esc(tr('house.outdoorHistoryOpen'))}"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></button></div>
<div class="house-mode-row">
<button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button>
@@ -58,6 +73,68 @@ function renderHouseClimate() {
<div class="preset-row house-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(p => `<button class="${preset === p ? 'active' : ''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset === p}">${esc(p === 'sleep' ? tr('house.sleepAll') : p === 'comfort' ? tr('house.comfortAll') : p === 'away' ? tr('house.awayAll') : tr('house.autoAll'))}</button>`).join('')}</div>`;
}
function planBlockedReasonLabel(reason) {
const key = {
emergency_stop: 'plan.reasonEmergency',
manual_override: 'plan.reasonManual',
zone_disabled: 'plan.reasonDisabled',
offline: 'plan.reasonOffline',
no_active_thermostat_intent: 'plan.reasonNoIntent',
}[reason];
return key ? tr(key) : reason;
}
function planZoneReason(zone) {
if (zone.blocked_reason) return planBlockedReasonLabel(zone.blocked_reason);
if (zone.local_thermostat_power === false) return tr('plan.reasonLocalOff');
if (zone.local_thermostat_power === true) return tr('plan.reasonLocalOn');
const current = zone.current_temperature == null ? NaN : Number(zone.current_temperature);
const target = zone.target_temperature == null ? NaN : Number(zone.target_temperature);
if (zone.demand && Number.isFinite(current) && Number.isFinite(target)) {
if (zone.mode === 'heat') return tr('plan.reasonHeatDemand', { current: fmtTemp(current), target: fmtTemp(target) });
if (zone.mode === 'cool') return tr('plan.reasonCoolDemand', { current: fmtTemp(current), target: fmtTemp(target) });
}
if (Number.isFinite(current) && Number.isFinite(target)) {
return tr('plan.reasonTargetSatisfied', { current: fmtTemp(current), target: fmtTemp(target) });
}
if (zone.current_schedule_name) return tr('plan.reasonSchedule', { schedule: zone.current_schedule_name });
return tr('plan.reasonWaiting');
}
function planReasonCard(plan) {
const zones = (plan.zones || []).map(zone => {
const reason = planZoneReason(zone);
const source = zone.control_owner === 'direct_manual' ? tr('plan.reasonSourceManual')
: zone.control_owner === 'local_thermostat' ? tr('plan.reasonSourceThermostat')
: zone.current_schedule_name ? tr('plan.reasonSourceSchedule', { schedule: zone.current_schedule_name })
: tr('plan.reasonSourceAutomation');
return `<li><div><strong>${esc(zone.zone_name)}</strong><small>${esc(source)}</small></div><span>${esc(reason)}</span></li>`;
}).join('');
const recentRules = (plan.rules || [])
.filter(rule => rule.enabled && rule.last_fired_at)
.sort((a, b) => new Date(b.last_fired_at) - new Date(a.last_fired_at))
.slice(0, 4)
.map(rule => {
const when = new Date(rule.last_fired_at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' });
const triggerDevice = rule.trigger_device_id ? (app.devices || []).find(device => device.id === rule.trigger_device_id) : null;
const current = triggerDevice?.current_temperature == null ? null : Number(triggerDevice.current_temperature);
const trigger = Number.isFinite(current)
? tr('plan.reasonTriggerReading', { trigger: automationTriggerLabel(rule), current: fmtTemp(current) })
: automationTriggerLabel(rule);
return `<li><div><strong>${esc(rule.name)}</strong><small>${esc(trigger)}</small></div><span>${esc(tr('plan.reasonLastTriggered', { when }))}</span></li>`;
}).join('');
const emergency = plan.emergency_stop_active
? `<div class="plan-reason-alert"><strong>${esc(tr('house.emergencyActive'))}</strong><span>${esc(tr('house.emergencyActiveHint'))}</span></div>`
: '';
const rules = recentRules
? `<div class="plan-reason-subsection"><h4>${esc(tr('plan.recentTriggers'))}</h4><ul class="plan-reason-list">${recentRules}</ul></div>`
: '';
return `<article class="panel plan-card plan-reasons"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.reasonsEyebrow'))}</span><h3>${esc(tr('plan.reasonsTitle'))}</h3></div></div>${emergency}<div class="plan-reason-subsection"><h4>${esc(tr('plan.currentReasons'))}</h4><ul class="plan-reason-list">${zones || `<li class="plan-reason-empty">${esc(tr('plan.noReasons'))}</li>`}</ul></div>${rules}</article>`;
}
function planEventMarkup(event) {
const when = event?.at ? new Date(event.at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '—';
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
@@ -121,7 +198,7 @@ function renderControlPlan() {
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules ${plan.emergency_stop_active ? 'disabled' : ''}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr(plan.emergency_stop_active ? 'plan.ruleCountPaused' : 'plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
const automation = (app.automations || []).find(item => item.id === rule.id);
const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null;
const displayName = flow?.name || rule.name;
@@ -129,7 +206,8 @@ function renderControlPlan() {
return `<li><time>${esc(automationTriggerLabel(rule))}</time><span class="plan-rule-summary"><strong>${esc(displayName)}</strong>${target ? `<small>→ ${esc(target)}</small>` : ''}</span></li>`;
}).join('')}</ul></article>` : '';
const content = house + groupCards + zones + ruleCard;
const reasonCard = planReasonCard(plan);
const content = house + groupCards + zones + ruleCard + reasonCard;
host.innerHTML = content;
if (section) section.hidden = !content;
}
+17
View File
@@ -164,6 +164,23 @@ document.addEventListener('click', async event => {
finally { button.disabled = false; }
return;
}
if (action === 'house-emergency-stop') {
const active = button.dataset.value === 'true';
if (active && !confirm(tr('house.emergencyStopConfirm'))) return;
try {
button.disabled = true;
const result = await enqueueClimateControlTask(async () => {
const response = await api('/api/house/emergency-stop', { method: 'POST', body: { active } });
await loadBootstrap();
return response;
});
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (active && failed) toast(tr('house.emergencyStoppedPartial', { count: failed }), true);
else toast(tr(active ? 'house.emergencyStopped' : 'house.emergencyResumed'));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
return;
}
if (action === 'house-preset') {
try {
const result = await enqueueClimateControlTask(async () => {
+8 -1
View File
@@ -111,7 +111,14 @@ async function handleWebSocketMessage(event) {
if (!isFormDirty($('#homeAssistantForm'))) { app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); renderHomeAssistantSettings(); }
renderHouseClimate(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad();
}
else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; app.house = app.house || {}; app.house.mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'house.emergency_stop_changed') {
app.house = app.house || { mode: app.settings?.house_mode || 'cool' };
app.house.emergency_stop_enabled = !!data.active;
app.house.emergency_stop_since = data.since || null;
renderHouseClimate();
scheduleControlPlanLoad();
}
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'configuration.imported') { await loadBootstrap(); return; }
else if (message.event === 'gree.frame_received') {