333 lines
30 KiB
JavaScript
333 lines
30 KiB
JavaScript
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 deviceZoneDisabled(deviceId) {
|
||
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
|
||
}
|
||
|
||
function deviceCard(device, detailed = false) {
|
||
const modes = ['auto','cool','dry','fan','heat'];
|
||
const fans = [0,1,3,5];
|
||
const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
|
||
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
|
||
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
|
||
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
|
||
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
|
||
const locked = zoneLocked ? ' disabled' : '';
|
||
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
|
||
const quickRow = detailed ? '' : ' quick-control-row';
|
||
return `<article class="device-card${quickClass} ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
||
<div class="device-head">
|
||
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
|
||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}>⏻</button>
|
||
</div>
|
||
<div class="temperature-control">
|
||
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}>−</button>
|
||
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
|
||
</div>
|
||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
|
||
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
|
||
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
|
||
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.swing'))}</button>
|
||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}"${locked}>${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)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
|
||
</div>
|
||
${detailed ? deviceFeaturePanel(device) : ''}
|
||
${detailed ? `<div class="card-footer">${networkInfo}<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>`;
|
||
}
|
||
|
||
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) {
|
||
if (zone.sensor_source === 'combined') return tr('zones.combinedSource');
|
||
if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource');
|
||
return tr('zones.greeSource');
|
||
}
|
||
|
||
function zoneControlSourceLabel(source) {
|
||
return ({
|
||
device: tr('zones.sourceDevice'),
|
||
external: tr('zones.sourceExternal'),
|
||
combined: tr('zones.sourceCombined'),
|
||
device_fallback: tr('zones.sourceFallback'),
|
||
device_discrepancy_fallback: tr('zones.sourceDiscrepancy'),
|
||
unavailable: tr('zones.sourceUnavailable'),
|
||
})[source] || source || tr('zones.sourceUnavailable');
|
||
}
|
||
|
||
function zonePresetLabel(preset) {
|
||
const key = `preset.${preset || 'comfort'}`;
|
||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||
}
|
||
|
||
function zoneControlOwnerLabel(zone) {
|
||
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
|
||
const source=zone.control_source || '';
|
||
if(owner==='global_off') return tr('zones.ownerGlobalOff');
|
||
if(owner==='direct_manual') {
|
||
if(source==='home_assistant_direct') return tr('zones.ownerHaDirect');
|
||
if(source==='web_direct') return tr('zones.ownerWebDirect');
|
||
return tr('zones.ownerExternal');
|
||
}
|
||
if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat');
|
||
return tr('zones.ownerAutomation');
|
||
}
|
||
|
||
function zoneControlOwnerMeta(zone) {
|
||
const parts=[];
|
||
if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
|
||
if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
|
||
if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function zoneRuntimeStatusLabel(zone, effectiveMode) {
|
||
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
|
||
if (!zone.enabled) return tr('common.disabled');
|
||
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
|
||
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
|
||
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
||
return zone.demand ? tr('zones.requesting') : tr('zones.satisfied');
|
||
}
|
||
|
||
function zoneCard(zone, detailed = true) {
|
||
const device = app.devices.find(d => d.id === zone.device_id);
|
||
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
|
||
const sensorDetails = zone.sensor_source === 'device'
|
||
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
|
||
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
|
||
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
|
||
const target = Number(zone.effective_setpoint ?? zone.setpoint);
|
||
const manual = zone.manual_preset || 'auto';
|
||
const displayPreset = zone.manual_preset || zone.active_preset || 'comfort';
|
||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
|
||
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
|
||
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
|
||
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
|
||
const deviceManualOverride = zone.device_manual_override === true;
|
||
const localThermostatPower = zone.local_thermostat_power;
|
||
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
|
||
const temporarySession = zone.temporary_quick_thermostat || null;
|
||
const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null;
|
||
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
|
||
const override = deviceManualOverride
|
||
? (zone.device_manual_override_until
|
||
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
|
||
: tr('zones.manualDeviceNoBoundary'))
|
||
: temporarySession
|
||
? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'})
|
||
: localThermostatOverride
|
||
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||
: (zone.manual_override_until
|
||
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
|
||
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
||
const manualTakeover = deviceManualOverride
|
||
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||
: localThermostatOverride && !temporarySession
|
||
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
|
||
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}">${esc(tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
|
||
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
|
||
: '';
|
||
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
||
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
|
||
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
|
||
const temporaryThermostatControl = temporarySession
|
||
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
|
||
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
|
||
|
||
if (detailed) {
|
||
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
||
const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup');
|
||
const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly');
|
||
return `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-config="${esc(zone.id)}">
|
||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
|
||
<div class="zone-config-status">
|
||
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
|
||
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
|
||
</div>
|
||
<div class="zone-config-grid">
|
||
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
|
||
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
|
||
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C</strong></div>
|
||
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
|
||
</div>
|
||
<div class="sensor-detail">${esc(sensorDetails)}</div>
|
||
${manualTakeover}
|
||
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||
</article>`;
|
||
}
|
||
|
||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
|
||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
|
||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||
<div class="preset-row quick-control-row quick-control-row-4">
|
||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||
</div>
|
||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
|
||
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
|
||
${manualTakeover}
|
||
</article>`;
|
||
}
|
||
|
||
function renderZones() {
|
||
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||
$('#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></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);
|
||
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
|
||
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() {
|
||
const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`));
|
||
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
|
||
const zone = app.zones.find(z => z.id === item.zone_id);
|
||
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
|
||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||
}
|
||
|
||
function renderAutomations() {
|
||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||
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');
|
||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||
const copyZone=$('#copyZoneSource');
|
||
if(copyZone){
|
||
const current=copyZone.value;
|
||
const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || '';
|
||
const sources=app.zones.filter(zone=>zone.id!==editingZoneId);
|
||
copyZone.innerHTML=`<option value="">${esc(tr('zones.copyFrom'))}</option>`+sources.map(z=>`<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
|
||
copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:'';
|
||
}
|
||
}
|
||
|