This commit is contained in:
Mateusz Gruszczyński
2026-08-25 18:10:51 +02:00
parent 440c7c3a8d
commit 26d8f9f83a
21 changed files with 1031 additions and 98 deletions
+246 -21
View File
@@ -20,7 +20,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: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
@@ -28,6 +28,7 @@ const app = {
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {},
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false,
};
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
@@ -189,6 +190,7 @@ async function loadBootstrap(showMessage = false) {
const data = await api('/api/bootstrap');
app.devices = data.devices || [];
app.zones = data.zones || [];
app.groups = data.groups || [];
app.schedules = data.schedules || [];
app.automations = data.automations || [];
app.accessTokens = data.access_tokens || [];
@@ -212,6 +214,7 @@ async function loadBootstrap(showMessage = false) {
function renderAll() {
renderSummary();
renderHouseClimate();
renderGroups();
renderControlPlan();
renderSimulationPage();
renderDevices();
@@ -406,11 +409,74 @@ function buildZoneSimulation(zone, planZone) {
function simulationRuleAction(rule) {
const bits = [];
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`);
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
}
function simulationGroupsForZone(zoneId) {
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
}
function renderSimulationScopeControls(plan) {
const scope = $('#simulationScope');
const target = $('#simulationTarget');
if (!scope || !target) return;
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
scope.value = app.simulationScope;
const zones = plan?.zones || [];
const options = app.simulationScope === 'groups'
? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))]
: [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))];
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
target.value = app.simulationTarget;
}
function simulationFilteredZones(plan) {
const zones = plan?.zones || [];
if (app.simulationScope === 'groups') {
if (app.simulationTarget === 'all') {
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
return zones.filter(zone => grouped.has(zone.zone_id));
}
const group = app.groups.find(item => item.id === app.simulationTarget);
const wanted = new Set(group?.zone_ids || []);
return zones.filter(zone => wanted.has(zone.zone_id));
}
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
}
function simulationRuleVisible(rule, zones) {
if (app.simulationTarget === 'all') return true;
const zoneIds = new Set(zones.map(zone => zone.zone_id));
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
if (app.simulationScope === 'groups') {
if (rule.action_group_id === app.simulationTarget) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
const directZone = [...zoneIds][0];
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
function simulationLaneContext(planZone) {
if (app.simulationScope !== 'groups') return planZone.device_name || '';
const groups = simulationGroupsForZone(planZone.zone_id);
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
}
function updateSimulationUrl() {
if (app.currentView !== 'simulation') return;
const params = new URLSearchParams();
if (app.standaloneSimulation) params.set('standalone', '1');
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
const query = params.toString();
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
}
function renderSimulationPage() {
const summaryHost = $('#simulationSummary');
const timelineHost = $('#simulationTimeline');
@@ -424,17 +490,18 @@ function renderSimulationPage() {
return;
}
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
renderSimulationScopeControls(plan);
const zones = simulationFilteredZones(plan);
const enabledZones = 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: houseModeLabel(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})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).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 && simulationRuleVisible(rule, zones)).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);
@@ -468,7 +535,7 @@ function renderSimulationPage() {
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(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</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:houseModeLabel(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}));
@@ -489,7 +556,7 @@ function renderSimulationPage() {
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}));
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
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 = [], seen = new Set();
@@ -502,7 +569,7 @@ function renderSimulationPage() {
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>`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
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>`;
}
@@ -557,6 +624,8 @@ function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
const dashboardCount = $('#dashboardDeviceCount');
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
}
function zoneStrategyLabel(zone) {
@@ -612,6 +681,90 @@ function renderZones() {
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
const dashboard = $('#dashboardZones');
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
const dashboardCount = $('#dashboardZoneCount');
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
}
function groupZones(group) {
const wanted = new Set(group?.zone_ids || []);
return app.zones.filter(zone => wanted.has(zone.id));
}
function groupState(group) {
const zones = groupZones(group);
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
return {
zones,
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
};
}
function groupCard(group, detailed = false) {
const state = groupState(group);
const memberNames = state.zones.map(zone => zone.name);
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
const powerEnabled = group.power_enabled !== false;
const masterOff = app.settings?.house_power_enabled === false;
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div>
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}</div></div>
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderGroups() {
const empty = `<div class="empty"><strong>${esc(tr('groups.emptyTitle'))}</strong>${esc(tr('groups.emptyText'))}</div>`;
const dashboard = $('#dashboardGroups');
if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty;
const dashboardCount = $('#dashboardGroupCount');
if (dashboardCount) dashboardCount.textContent = String(app.groups.length);
const list = $('#groupList');
if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty;
}
async function sendGroupControl(id, patch) {
try {
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
if (result.group) {
const index = app.groups.findIndex(group => group.id === result.group.id);
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
}
(result.zones || []).forEach(zone => {
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
(result.devices || []).forEach(updateDevice);
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('groups.partial', {count: failed}), true);
else toast(tr('groups.controlUpdated'));
} catch (error) { toast(error.message, true); }
}
function renderGroupZoneChoices(selectedIds = null) {
const host = $('#groupZoneChoices'); if (!host) return;
const selected = new Set(selectedIds || []);
host.innerHTML = app.zones.length ? app.zones.map(zone => {
const device = app.devices.find(item => item.id === zone.device_id);
return `<label class="group-zone-choice"><input type="checkbox" name="zone_ids" value="${esc(zone.id)}" ${selected.has(zone.id) ? 'checked' : ''}><span><strong>${esc(zone.name)}</strong><small>${esc(device?.name || tr('common.noDevice'))}</small></span></label>`;
}).join('') : `<div class="empty compact">${esc(tr('groups.noZones'))}</div>`;
}
function populateGroup(id) {
const item = app.groups.find(group => group.id === id); if (!item) return;
const form = $('#groupForm'); form.reset();
form.elements.id.value = item.id;
form.elements.name.value = item.name;
renderGroupZoneChoices(item.zone_ids || []);
openDialog('groupDialog');
}
function renderSchedules() {
@@ -627,20 +780,27 @@ function renderSchedules() {
function renderAutomations() {
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionDevice = app.devices.find(d => d.id === item.action_device_id);
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
function fillSelects() {
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
const groupOptions = app.groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)}</option>`).join('');
['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => {
const select = $(selector); if (!select) return;
const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current;
});
const automationGroupSelect = $('#automationForm [name=action_group_id]');
if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; }
const zoneSelect = $('#scheduleForm [name=zone_id]');
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
const presetZone = $('#schedulePresetZone');
@@ -804,7 +964,7 @@ function formatDuration(seconds) {
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
}
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'};
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', groups:'/groups', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'};
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
function currentHistoryPath() {
@@ -830,7 +990,7 @@ function updateBrowserUrl(path, replace=false) {
function showView(name, {push=true, scroll=true}={}) {
app.currentView = name;
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
@@ -849,6 +1009,12 @@ function applyRouteFromLocation() {
const parts = routePath.split('/').filter(Boolean);
const first = parts[0] || 'dashboard';
const params = new URLSearchParams(location.search);
app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1';
document.body.classList.toggle('simulation-standalone', app.standaloneSimulation);
if (first === 'simulation') {
app.simulationScope = ['units','groups'].includes(params.get('scope')) ? params.get('scope') : 'units';
app.simulationTarget = params.get('target') || 'all';
}
if (first === 'history') {
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
app.historyZone = params.get('zone') || 'all';
@@ -946,6 +1112,26 @@ function updateSchedulePresetField() {
form.setpoint.required = form.preset.value === 'custom';
}
function updateAutomationTargetFields() {
const form = $('#automationForm'); if (!form) return;
const groupTarget = form.action_target_kind.value === 'group';
$('#automationDeviceTarget').hidden = groupTarget;
$('#automationGroupTarget').hidden = !groupTarget;
$('#automationGroupPreset').hidden = !groupTarget;
$('#automationTargetTemperature').hidden = groupTarget;
$('#automationGroupHint').hidden = !groupTarget;
form.action_device_id.required = !groupTarget;
form.action_group_id.required = groupTarget;
form.action_target_temperature.disabled = groupTarget;
if (groupTarget) form.action_target_temperature.value = '';
[...form.action_mode.options].forEach(option => {
if (!['dry','fan'].includes(option.value)) return;
option.disabled = groupTarget;
option.hidden = groupTarget;
});
if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = '';
}
function populateZone(id) {
const item = app.zones.find(v => v.id === id); if (!item) return;
const form = $('#zoneForm');
@@ -980,10 +1166,15 @@ function populateAutomation(id) {
const item = app.automations.find(v => v.id === id); if (!item) return;
const form = $('#automationForm'); form.reset(); fillSelects();
['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
const groupTarget = !!item.action_group_id;
form.action_target_kind.value = groupTarget ? 'group' : 'device';
if (groupTarget) form.action_group_id.value = item.action_group_id;
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
form.action_mode.value = item.action.mode || '';
form.action_preset.value = item.action_preset || '';
form.action_target_temperature.value = item.action.target_temperature ?? '';
form.enabled.checked = item.enabled;
updateAutomationTargetFields();
openDialog('automationDialog');
}
@@ -1359,13 +1550,16 @@ function connectWebSocket() {
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.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; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; 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(); }
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); 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 === '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(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); 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(); }
@@ -1383,7 +1577,7 @@ document.addEventListener('click', async event => {
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
return;
}
if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; }
if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; }
if (button.dataset.open) {
const form = document.getElementById(button.dataset.open.replace('Dialog','Form'));
if (form) form.reset();
@@ -1391,6 +1585,11 @@ document.addEventListener('click', async event => {
if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; }
updateZoneSensorFields();
}
if (button.dataset.open === 'groupDialog') {
if (form?.elements.id) form.elements.id.value = '';
renderGroupZoneChoices([]);
}
if (button.dataset.open === 'automationDialog') updateAutomationTargetFields();
if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField();
openDialog(button.dataset.open);
return;
@@ -1448,11 +1647,16 @@ document.addEventListener('click', async event => {
}
catch(error){ toast(error.message,true); } return;
}
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
if (action === 'edit-group') return populateGroup(button.dataset.id);
if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group');
if (action === 'edit-zone') return populateZone(button.dataset.id);
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
@@ -1486,6 +1690,12 @@ $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilte
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); });
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } });
document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); });
$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const saved=app.settings?.notifications||{}; const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60)}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast('Test notification sent');}catch(e){toast(e.message,true);} });
@@ -1527,7 +1737,7 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{
toast(tr('zones.copiedFrom',{name:source.name}));
});
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
$('#tokenForm').addEventListener('submit', event => {
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
@@ -1586,6 +1796,20 @@ $('#zoneForm').addEventListener('submit', async event => {
catch(error){toast(error.message,true);}
});
$('#groupForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id || '';
const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value);
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
const existing=id ? app.groups.find(group => group.id===id) : null;
const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false};
try {
await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body});
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
} catch(error){toast(error.message,true);}
});
$('#scheduleForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
@@ -1596,8 +1820,9 @@ $('#scheduleForm').addEventListener('submit', async event => {
$('#automationForm').addEventListener('submit', async event => {
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,action,cooldown_seconds:Number(raw.cooldown_seconds)};
const groupTarget=raw.action_target_kind==='group';
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)};
try { await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
catch(error){toast(error.message,true);}
});
+56 -13
View File
@@ -37,17 +37,37 @@
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
</div>
<div class="metrics" id="metrics"></div>
<div class="panel house-power-panel">
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.</p></div>
<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>
<div class="panel dashboard-global-panel">
<div class="dashboard-global-power">
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.</p></div>
<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>
</div>
<div class="dashboard-global-divider"></div>
<div class="house-climate dashboard-house-climate" id="houseClimate"></div>
</div>
<section class="dashboard-primary-section">
<div class="dashboard-section-head">
<div><span class="eyebrow" data-i18n="groups.eyebrow">Group control</span><h2 data-i18n="groups.dashboardTitle">Control selected air conditioners</h2><p data-i18n="groups.dashboardHint">Each group can use its own cooling/heating mode and Auto/Comfort/Sleep/Away profile.</p></div>
<div class="dashboard-section-actions"><span class="badge" id="dashboardGroupCount">0</span><button type="button" class="secondary" data-go="groups" data-i18n="groups.manage">Manage groups</button></div>
</div>
<div class="group-quick-grid dashboard-group-strip" id="dashboardGroups"></div>
</section>
<div class="dashboard-secondary-stack">
<details class="panel dashboard-disclosure">
<summary><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h3 data-i18n="plan.title">What happens next</h3></div><span class="dashboard-disclosure-chevron" aria-hidden="true"></span></summary>
<div class="dashboard-disclosure-body"><div class="automation-plan-grid" id="controlPlan"></div></div>
</details>
<details class="panel dashboard-disclosure">
<summary><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h3 data-i18n="dashboard.quickThermostats">Quick thermostats</h3></div><div class="dashboard-summary-side"><span class="badge" id="dashboardZoneCount">0</span><span class="dashboard-disclosure-chevron" aria-hidden="true"></span></div></summary>
<div class="dashboard-disclosure-body"><div class="list-grid dashboard-zones" id="dashboardZones"></div></div>
</details>
<details class="panel dashboard-disclosure">
<summary><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h3 data-i18n="dashboard.quickControl">Direct device control</h3></div><div class="dashboard-summary-side"><span class="badge" id="dashboardDeviceCount">0</span><span class="dashboard-disclosure-chevron" aria-hidden="true"></span></div></summary>
<div class="dashboard-disclosure-body"><div class="device-grid" id="dashboardDevices"></div></div>
</details>
</div>
<div class="panel house-climate" id="houseClimate"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h2 data-i18n="plan.title">What happens next</h2></div></div>
<div class="automation-plan-grid" id="controlPlan"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
<div class="device-grid" id="dashboardDevices"></div>
</section>
<section class="view" data-view="devices">
@@ -64,6 +84,12 @@
<div class="list-grid" id="zoneList"></div>
</section>
<section class="view" data-view="groups">
<div class="section-heading"><div><span class="eyebrow" data-i18n="groups.eyebrow">Group control</span><h1 data-i18n="nav.groups">Groups</h1></div><button class="secondary" data-open="groupDialog" data-i18n="groups.new">New group</button></div>
<p class="lead" data-i18n="groups.description">Combine zones such as upstairs or downstairs and control only that set. A group can run Sleep + Cooling while another uses Comfort + Heating.</p>
<div class="list-grid" id="groupList"></div>
</section>
<section class="view" data-view="schedules">
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
<div class="panel schedule-template-panel">
@@ -87,8 +113,9 @@
</section>
<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>
<div class="section-heading simulation-page-head"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><div class="simulation-head-actions"><button class="secondary" id="simulationOpenTab" data-i18n="simulation.openTab">Open only simulator in new tab</button><button class="secondary" id="simulationFullscreen" data-i18n="simulation.fullscreen">Full screen</button><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div></div>
<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="panel simulation-scope-toolbar"><label><span data-i18n="simulation.viewBy">View by</span><select id="simulationScope"><option value="units" data-i18n="simulation.unitsView">Individual units</option><option value="groups" data-i18n="simulation.groupsView">Groups</option></select></label><label><span data-i18n="simulation.show">Show</span><select id="simulationTarget"></select></label></div>
<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>
@@ -287,6 +314,7 @@
<dialog id="moreDialog" class="sheet">
<div class="dialog-head"><h2 data-i18n="nav.more">More</h2><button data-close>×</button></div>
<div class="menu-list">
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span></span></button>
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span></span></button>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span></span></button>
@@ -367,6 +395,17 @@
</form>
</dialog>
<dialog id="groupDialog">
<form method="dialog" id="groupForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="groups.group">Group</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="groups.namePlaceholder" placeholder="Upstairs"></label>
<fieldset><legend data-i18n="groups.members">Group members</legend><div class="group-zone-choices" id="groupZoneChoices"></div></fieldset>
<p class="field-note" data-i18n="groups.membersHint">Groups are built from thermostat zones so Comfort/Sleep profiles and schedules keep working.</p>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="scheduleDialog">
<form method="dialog" id="scheduleForm" class="dialog-form">
<input type="hidden" name="id">
@@ -392,9 +431,13 @@
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="text" inputmode="decimal" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
<hr><h3 data-i18n="automations.action">Action</h3>
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
<label><span data-i18n="automations.targetType">Target type</span><select name="action_target_kind"><option value="device" data-i18n="common.device">Device</option><option value="group" data-i18n="groups.group">Group</option></select></label>
<label id="automationDeviceTarget"><span data-i18n="common.device">Device</span><select name="action_device_id"></select></label>
<label id="automationGroupTarget" hidden><span data-i18n="groups.group">Group</span><select name="action_group_id"></select></label>
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="text" inputmode="decimal" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
<label id="automationGroupPreset" hidden><span data-i18n="groups.profile">Group profile</span><select name="action_preset"><option value="" data-i18n="actions.noChange">No change</option><option value="auto" data-i18n="preset.auto">Auto</option><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option></select></label>
<div class="two"><label id="automationTargetTemperature"><span data-i18n="common.targetC">Target °C</span><input type="text" inputmode="decimal" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
<p class="field-note" id="automationGroupHint" hidden data-i18n="automations.groupHint">For a group, Auto mode means follow the whole-house heating/cooling mode. Group presets are applied to all member zones.</p>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
+130
View File
@@ -632,3 +632,133 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.standalone-settings-form { max-width:1100px; }
.copy-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.6rem;align-items:end}.notification-provider{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.log-row{display:grid;grid-template-columns:5.5rem 7rem minmax(9rem,auto) 1fr;gap:.75rem;align-items:center;border-left:4px solid transparent}.log-row.error{border-left-color:var(--danger,#d9544d)}.log-row.warn{border-left-color:#c98a21}.log-row.info{border-left-color:var(--accent,#4d9c7d)}.log-category{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;opacity:.7}.category-device .log-category:before{content:'▣ '}.category-zone .log-category:before{content:'◎ '}.category-automation .log-category:before{content:'⚙ '}.category-integration .log-category:before{content:'↔ '}.category-settings .log-category:before{content:'☷ '}.category-system .log-category:before{content:'• '}@media(max-width:720px){.copy-settings-row,.notification-provider{grid-template-columns:1fr}.log-row{grid-template-columns:4.5rem 1fr}.log-row .kind,.log-row .message{grid-column:2}.logs-toolbar{align-items:stretch}}
/* Group controls and standalone simulator */
[hidden] { display:none !important; }
.dashboard-group-panel { display:grid; gap:14px; margin-bottom:18px; }
.dashboard-group-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
.dashboard-group-head h3 { margin:3px 0 5px; }
.dashboard-group-head p { margin:0; color:var(--muted); line-height:1.45; }
.group-quick-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr)); gap:12px; }
.group-card { display:grid; gap:13px; }
.group-card.group-off { opacity:.78; }
.group-state-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.group-state-summary > div { padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
.group-state-summary small,.group-control-block > small { display:block; margin-bottom:5px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.06em; }
.group-state-summary strong { display:block; overflow:hidden; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
.group-control-block { display:grid; gap:6px; }
.group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; }
.group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; }
.group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
.group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); }
.group-zone-choice strong,.group-zone-choice small { display:block; }
.group-zone-choice small { margin-top:3px; color:var(--muted); font-size:10px; }
.simulation-page-head { align-items:flex-start; }
.simulation-head-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
.simulation-scope-toolbar { display:flex; align-items:end; gap:12px; margin-bottom:16px; padding:14px; }
.simulation-scope-toolbar label { min-width:min(260px,100%); }
body.simulation-standalone .topbar,body.simulation-standalone .bottom-nav { display:none !important; }
body.simulation-standalone main { width:100%; max-width:none; margin:0; padding:18px; }
body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36px); }
:fullscreen main { width:100%; max-width:none; padding:18px; }
:fullscreen .topbar,:fullscreen .bottom-nav { display:none !important; }
@media (max-width:720px) {
.dashboard-group-head,.simulation-page-head { align-items:stretch; flex-direction:column; }
.simulation-head-actions { justify-content:stretch; }
.simulation-head-actions button { flex:1 1 180px; }
.simulation-scope-toolbar { align-items:stretch; flex-direction:column; }
.group-state-summary { grid-template-columns:1fr; }
}
/* Dashboard hierarchy: compact global controls -> wrapped groups -> on-demand details. */
[data-view="dashboard"] .hero { min-height:164px; padding:24px 28px; border-radius:24px; }
[data-view="dashboard"] .hero h1 { margin-bottom:6px; }
[data-view="dashboard"] .hero-temp { font-size:clamp(44px,8vw,68px); }
[data-view="dashboard"] .metrics { gap:10px; margin:10px 0 20px; }
[data-view="dashboard"] .metric { padding:14px 16px; border-radius:15px; }
[data-view="dashboard"] .metric span { margin-bottom:5px; }
[data-view="dashboard"] .metric strong { font-size:21px; }
.dashboard-global-panel { display:grid; gap:14px; padding:16px 18px; margin:0 0 20px; }
.dashboard-global-power { display:flex; align-items:center; justify-content:space-between; gap:16px; }
.dashboard-global-power h3 { margin:2px 0 3px; font-size:18px; }
.dashboard-global-power p { margin:0; max-width:680px; color:var(--muted); font-size:11px; line-height:1.4; }
.dashboard-global-divider { height:1px; background:var(--line); }
.dashboard-house-climate { margin:0; gap:12px; }
.dashboard-house-climate .house-climate-head h3 { font-size:18px; }
.dashboard-house-climate .house-climate-head p { font-size:11px; }
.dashboard-house-climate .outside-pill { padding:8px 11px; }
.dashboard-house-climate .house-mode-row button { min-height:38px; padding:8px 10px; }
.dashboard-house-climate .house-preset-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:6px; overflow:visible; padding:0; }
.dashboard-house-climate .house-preset-row button { min-width:0; min-height:36px; padding:7px 8px; font-size:11px; }
.dashboard-primary-section { margin-bottom:18px; }
.dashboard-section-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin:0 0 10px; }
.dashboard-section-head h2 { margin:3px 0 4px; font-size:21px; }
.dashboard-section-head p { margin:0; max-width:720px; color:var(--muted); font-size:11px; line-height:1.4; }
.dashboard-section-actions { display:flex; align-items:center; flex:0 0 auto; gap:7px; }
.dashboard-section-actions .badge { min-width:28px; justify-content:center; }
.dashboard-section-actions button { min-height:36px; padding:8px 11px; }
/* Groups wrap into rows. Horizontal scrolling is intentionally disabled on the dashboard. */
.dashboard-group-strip { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr)); gap:10px; overflow:visible; padding:0; }
.dashboard-group-strip .group-card { min-width:0; padding:13px; gap:8px; }
.dashboard-group-strip .list-card-head { gap:9px; }
.dashboard-group-strip .list-card-head h3 { font-size:16px; }
.dashboard-group-strip .list-card-head p { display:-webkit-box; overflow:hidden; max-width:none; font-size:11px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; white-space:normal; }
.dashboard-group-strip .badge { padding:4px 7px; font-size:10px; }
.dashboard-group-strip .group-control-block { gap:4px; }
.dashboard-group-strip .group-control-block > small { margin-bottom:1px; font-size:9px; }
.dashboard-group-strip .group-button-row { gap:5px; }
.dashboard-group-strip .group-mode-row,
.dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; }
.dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); }
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(4,minmax(0,1fr)); }
.dashboard-group-strip .group-button-row button,
.dashboard-group-strip .group-mode-row button,
.dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }
.dashboard-secondary-stack { display:grid; gap:8px; margin-top:4px; }
.dashboard-disclosure { overflow:hidden; padding:0; }
.dashboard-disclosure > summary { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:13px 15px; cursor:pointer; list-style:none; user-select:none; }
.dashboard-disclosure > summary::-webkit-details-marker { display:none; }
.dashboard-disclosure > summary h3 { margin:2px 0 0; font-size:16px; }
.dashboard-disclosure > summary .eyebrow { font-size:9px; }
.dashboard-summary-side { display:flex; align-items:center; gap:10px; }
.dashboard-summary-side .badge { min-width:28px; justify-content:center; }
.dashboard-disclosure-chevron { display:inline-block; color:var(--muted); font-size:18px; line-height:1; transition:transform .18s ease; }
.dashboard-disclosure[open] > summary { border-bottom:1px solid var(--line); }
.dashboard-disclosure[open] .dashboard-disclosure-chevron { transform:rotate(180deg); }
.dashboard-disclosure-body { padding:12px; }
.dashboard-disclosure-body .automation-plan-grid,
.dashboard-disclosure-body .dashboard-zones { margin-bottom:0; }
.dashboard-disclosure-body .device-grid,
.dashboard-disclosure-body .list-grid,
.dashboard-disclosure-body .automation-plan-grid { gap:10px; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); }
#dashboardZones .list-card, #dashboardDevices .device-card { padding:15px; }
#dashboardZones .preset-row,
#dashboardZones .mode-row,
#dashboardDevices .mode-row,
#dashboardDevices .fan-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(70px,1fr)); gap:5px; overflow:visible; padding:2px 0 6px; }
#dashboardZones .preset-row button,
#dashboardZones .mode-row button,
#dashboardDevices .mode-row button,
#dashboardDevices .fan-row button { min-width:0; min-height:32px; padding:6px 7px; font-size:10px; }
#dashboardDevices .temperature-control { gap:16px; padding:18px 0 14px; }
#dashboardDevices .target-temp { min-width:92px; font-size:40px; }
#dashboardDevices .current-line { margin:-6px 0 13px; }
#dashboardZones .thermostat-main { margin:12px 0 8px; }
@media (max-width: 700px) {
[data-view="dashboard"] .hero { min-height:145px; padding:20px; }
[data-view="dashboard"] .metrics { margin-bottom:16px; }
[data-view="dashboard"] .metric { padding:12px; }
.dashboard-global-panel { padding:14px; margin-bottom:18px; }
.dashboard-global-power { align-items:stretch; flex-direction:column; }
.dashboard-global-power .house-power-actions { display:grid; grid-template-columns:1fr 1fr; }
.dashboard-house-climate .house-preset-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
.dashboard-section-head { align-items:stretch; flex-direction:column; gap:10px; }
.dashboard-section-actions { justify-content:space-between; }
.dashboard-section-actions button { flex:1; }
.dashboard-group-strip { grid-template-columns:1fr; }
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
.dashboard-disclosure > summary { padding:12px 13px; }
.dashboard-disclosure-body { padding:10px; }
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v066';
const CACHE = 'gree-controller-v068-dashboard-wrap';
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];