v0.14.2
This commit is contained in:
+14
-2
@@ -333,7 +333,7 @@
|
||||
<div class="panel chart-panel history-toolbar-panel">
|
||||
<div class="chart-toolbar">
|
||||
<div id="historyContextControls" class="history-context-controls"></div>
|
||||
<label class="history-range-control"><span data-i18n="history.range">Range</span><select id="historyHours"
|
||||
<label class="history-range-control" id="historyRangeControl"><span data-i18n="history.range">Range</span><select id="historyHours"
|
||||
data-i18n-aria="history.range" aria-label="History range">
|
||||
<option value="6" data-i18n="history.6h">6 hours</option>
|
||||
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
||||
@@ -341,7 +341,7 @@
|
||||
<option value="720" data-i18n="history.30d">30 days</option>
|
||||
<option value="2160" data-i18n="history.90d">90 days</option>
|
||||
<option value="8760" data-i18n="history.1y">1 year</option>
|
||||
</select></label>
|
||||
</select><small id="historyRangeHint" hidden></small></label>
|
||||
<button class="secondary history-refresh-button" id="historyRefresh"
|
||||
data-i18n="actions.refresh">Refresh</button>
|
||||
</div>
|
||||
@@ -1584,6 +1584,18 @@
|
||||
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>
|
||||
<div class="two" id="automationDeviceSwingOptions">
|
||||
<label><span data-i18n="devices.swingVertical">Vertical swing</span><select name="action_swing_vertical">
|
||||
<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="devices.swingHorizontal">Horizontal swing</span><select name="action_swing_horizontal">
|
||||
<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>
|
||||
</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
|
||||
|
||||
Vendored
+1
@@ -35,6 +35,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
|
||||
function applyBootstrapSnapshot(data) {
|
||||
app.devices = data.devices || [];
|
||||
app.deviceGroups = data.device_groups || [];
|
||||
app.deviceGroupEnergy = data.device_group_energy || {};
|
||||
app.zones = data.zones || [];
|
||||
app.groups = data.groups || [];
|
||||
app.schedules = data.schedules || [];
|
||||
|
||||
+97
-4
@@ -51,9 +51,45 @@ function normalizeEnergyHistoryTargets() {
|
||||
return available;
|
||||
}
|
||||
|
||||
function energyTargetPickerSummary(targets) {
|
||||
const selected = targets.filter(target => app.historyEnergyTargets.includes(target.id));
|
||||
if (!selected.length) return tr('energy.chooseTargets');
|
||||
if (selected.length === 1) return selected[0].label;
|
||||
return tr('energy.selectedCount', { count: selected.length });
|
||||
}
|
||||
|
||||
function resetHistoryRangeControl(host) {
|
||||
const range = $('#historyRangeControl');
|
||||
const toolbar = host?.closest('.chart-toolbar');
|
||||
const refresh = $('#historyRefresh');
|
||||
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
|
||||
}
|
||||
|
||||
function configureHistoryRangeControl(tab, host) {
|
||||
const range = $('#historyRangeControl');
|
||||
if (!range) return;
|
||||
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar');
|
||||
const label = range.querySelector('span');
|
||||
const hint = $('#historyRangeHint');
|
||||
const sixHours = range.querySelector('option[value="6"]');
|
||||
const energy = tab === 'energy';
|
||||
const rangeLabel = tr(energy ? 'energy.period' : 'history.range');
|
||||
if (label) label.textContent = rangeLabel;
|
||||
range.setAttribute('aria-label', rangeLabel);
|
||||
if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; }
|
||||
if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; }
|
||||
if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24';
|
||||
range.classList.toggle('energy-period-control', energy);
|
||||
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
|
||||
if (energy && host) host.appendChild(range);
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
|
||||
const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true;
|
||||
const host = $('#historyContextControls'); if (!host) return;
|
||||
resetHistoryRangeControl(host);
|
||||
host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
|
||||
const options = historyEntityOptions();
|
||||
if (app.historyTab === 'zones') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
|
||||
@@ -63,14 +99,13 @@ function renderHistoryNavigation() {
|
||||
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
|
||||
} else if (app.historyTab === 'energy') {
|
||||
const targets = normalizeEnergyHistoryTargets();
|
||||
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).join('');
|
||||
const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).join('');
|
||||
host.innerHTML = targets.length
|
||||
? `<label class="history-energy-targets"><span>${esc(tr('energy.targets'))}</span><select id="historyEnergyTargetSelect" multiple size="${Math.min(6, Math.max(2, targets.length))}">${targetOptions}</select><small>${esc(tr('energy.multiselectHint'))}</small></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
|
||||
? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
|
||||
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
|
||||
const targetSelect = $('#historyEnergyTargetSelect');
|
||||
if (targetSelect) [...targetSelect.options].forEach(option => { option.selected = app.historyEnergyTargets.includes(option.value); });
|
||||
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
|
||||
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
|
||||
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
|
||||
@@ -79,6 +114,7 @@ function renderHistoryNavigation() {
|
||||
} else {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
|
||||
}
|
||||
configureHistoryRangeControl(app.historyTab, host);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
@@ -475,6 +511,62 @@ function energyBucketLabel(timestamp, interval) {
|
||||
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
|
||||
}
|
||||
|
||||
function energyBucketTooltipLabel(timestamp, interval) {
|
||||
const date = new Date(timestamp);
|
||||
if (interval === 'hourly') return chartPreciseTime(timestamp);
|
||||
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'long', year: 'numeric' }).format(date);
|
||||
if (interval === 'weekly') return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
function bindEnergyChartTooltip(canvas, series, starts, maps, geometry) {
|
||||
const wrap = canvas?.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
if (!canvas || !wrap || !tooltip || !line || !starts.length) return;
|
||||
const { pad, width, height, slot, interval } = geometry;
|
||||
let activeIndex = -1;
|
||||
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; activeIndex = -1; };
|
||||
const showIndex = index => {
|
||||
index = clamp(index, 0, starts.length - 1);
|
||||
const start = starts[index];
|
||||
const px = pad.left + slot * index + slot / 2;
|
||||
const rows = series.map((item, seriesIndex) => ({ item, value: maps[seriesIndex].get(start) || 0 }));
|
||||
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
|
||||
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(energyBucketTooltipLabel(start, interval))}</strong><div class="chart-tooltip-values">${rows.map(({ item, value }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh</span></div>`).join('')}</div>`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let left = px + 12;
|
||||
if (left + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) left = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
activeIndex = index;
|
||||
};
|
||||
const indexFromClientX = clientX => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = clamp(clientX - rect.left, pad.left, width - pad.right - 1);
|
||||
return clamp(Math.floor((x - pad.left) / Math.max(1, slot)), 0, starts.length - 1);
|
||||
};
|
||||
|
||||
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showIndex(indexFromClientX(event.clientX)); };
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
|
||||
canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null;
|
||||
canvas.onkeydown = event => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
if (activeIndex < 0) activeIndex = event.key === 'ArrowRight' ? 0 : starts.length - 1;
|
||||
else activeIndex = clamp(activeIndex + (event.key === 'ArrowRight' ? 1 : -1), 0, starts.length - 1);
|
||||
showIndex(activeIndex);
|
||||
};
|
||||
}
|
||||
|
||||
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
|
||||
if (!canvas) return;
|
||||
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
|
||||
@@ -532,6 +624,7 @@ function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.
|
||||
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
|
||||
}
|
||||
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
|
||||
bindEnergyChartTooltip(canvas, visibleSeries, starts, maps, { pad, width, height, slot, interval });
|
||||
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
|
||||
updateChartZoomControls(canvas.id);
|
||||
}
|
||||
|
||||
+16
-1
@@ -25,7 +25,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], groups: [], deviceGroups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
@@ -59,6 +59,21 @@ const historyNumber = value => value === null || value === undefined || value ==
|
||||
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
||||
const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode);
|
||||
const fanLabel = value => ({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value] ? tr({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value]) : value);
|
||||
const deviceCommandFieldLabel = key => {
|
||||
const translationKey = {
|
||||
fan_speed: 'common.fan',
|
||||
swing_vertical: 'devices.swingVertical',
|
||||
swing_horizontal: 'devices.swingHorizontal',
|
||||
quiet: 'devices.quiet',
|
||||
turbo: 'devices.turbo',
|
||||
light: 'devices.light',
|
||||
air: 'devices.air',
|
||||
xfan: 'devices.xfan',
|
||||
health: 'devices.health',
|
||||
sleep: 'devices.sleep',
|
||||
}[key];
|
||||
return translationKey ? tr(translationKey) : key;
|
||||
};
|
||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
|
||||
const localResumeSeconds = value => {
|
||||
const timestamp = value ? new Date(value).getTime() : NaN;
|
||||
|
||||
+48
-6
@@ -53,6 +53,36 @@ function effectiveDeviceOutdoorTemperature(device) {
|
||||
return device?.outdoor_temperature;
|
||||
}
|
||||
|
||||
function installationEnergySnapshot(group) {
|
||||
if (!group) return null;
|
||||
const selectedSource = group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
|
||||
? 'gree_cloud'
|
||||
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
|
||||
if (selectedSource === 'gree_cloud' && group.energy_device_id) {
|
||||
const device = app.devices.find(item => item.id === group.energy_device_id);
|
||||
const live = device?.total_energy_kwh == null ? NaN : Number(device.total_energy_kwh);
|
||||
if (Number.isFinite(live) && live >= 0) {
|
||||
return { total_kwh: live, timestamp: device.last_cloud_sync || device.last_seen || null, source: 'gree_cloud', origin: 'cloud' };
|
||||
}
|
||||
}
|
||||
const cached = app.deviceGroupEnergy?.[group.id] || null;
|
||||
return cached?.source === selectedSource ? cached : null;
|
||||
}
|
||||
|
||||
function formatEnergyMeterTotal(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) return tr('common.unavailable');
|
||||
return `${number.toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh`;
|
||||
}
|
||||
|
||||
function installationEnergySourceShortLabel(group, snapshot) {
|
||||
if (snapshot?.source === 'home_assistant') return 'Home Assistant';
|
||||
if (snapshot?.source === 'gree_cloud') return 'GREE Cloud';
|
||||
if (group?.energy_source === 'home_assistant' || (group?.energy_source === 'auto' && group?.ha_energy_entity_id && !group?.energy_device_id)) return 'Home Assistant';
|
||||
if (group?.energy_source === 'gree_cloud' || group?.energy_device_id) return 'GREE Cloud';
|
||||
return tr('energy.auto');
|
||||
}
|
||||
|
||||
function deviceConnectionStatusLabel(device) {
|
||||
const key = {
|
||||
online: 'status.online',
|
||||
@@ -97,6 +127,7 @@ function manualDeviceProblem(device) {
|
||||
}
|
||||
|
||||
function manualLocalDeviceCard(device) {
|
||||
const caps = device.capabilities || {};
|
||||
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
const fans = [0, 1, 3, 5];
|
||||
const disabledZone = disabledZoneForDevice(device.id);
|
||||
@@ -118,8 +149,9 @@ function manualLocalDeviceCard(device) {
|
||||
${compressorQueuePanel(managedZone)}
|
||||
<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||
<div class="fan-row quick-control-row 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)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||||
<div class="device-toggles quick-control-row quick-control-row-3">
|
||||
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>
|
||||
<div class="device-toggles quick-control-row quick-control-row-4">
|
||||
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
|
||||
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||
</div>
|
||||
@@ -156,8 +188,9 @@ function manualCloudDeviceCard(device) {
|
||||
${compressorQueuePanel(managedZone)}
|
||||
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>` : ''}
|
||||
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>` : ''}
|
||||
<div class="device-toggles quick-control-row quick-control-row-3">
|
||||
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>`}
|
||||
<div class="device-toggles quick-control-row quick-control-row-4">
|
||||
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
|
||||
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||
</div>
|
||||
@@ -246,8 +279,16 @@ function renderDeviceInstallationsSummary() {
|
||||
const groups = app.deviceGroups || [];
|
||||
host.innerHTML = groups.length ? groups.map(group => {
|
||||
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
|
||||
const outdoor = app.devices.find(device => device.id === group.outdoor_temperature_device_id)?.name;
|
||||
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div><span>${esc(tr('energy.source'))}</span><b>${esc(installationEnergySourceLabel(group))}</b>${outdoor ? `<small>${esc(tr('devices.outdoorSourceDevice'))}: ${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
|
||||
const outdoorDevice = app.devices.find(device => device.id === group.outdoor_temperature_device_id);
|
||||
const outdoorValue = outdoorDevice?.outdoor_temperature == null ? NaN : Number(outdoorDevice.outdoor_temperature);
|
||||
const energy = installationEnergySnapshot(group);
|
||||
const sourceTitle = installationEnergySourceLabel(group);
|
||||
const sourceShort = installationEnergySourceShortLabel(group, energy);
|
||||
const lastReading = energy?.timestamp ? `${tr('energy.lastReading')}: ${dateTime(energy.timestamp)}` : '';
|
||||
const outdoor = outdoorDevice
|
||||
? `${tr('devices.sharedOutdoorMetric')}: ${Number.isFinite(outdoorValue) ? fmtTemp(outdoorValue) : tr('common.unavailable')} · ${outdoorDevice.name}`
|
||||
: '';
|
||||
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div class="installation-energy-metric"><span>${esc(tr('energy.totalMeter'))}</span><b title="${esc(sourceTitle)}">${esc(formatEnergyMeterTotal(energy?.total_kwh))}</b><small>${esc(sourceShort)}${lastReading ? ` · ${esc(lastReading)}` : ''}</small>${outdoor ? `<small>${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
|
||||
}).join('') : '';
|
||||
}
|
||||
|
||||
@@ -519,6 +560,7 @@ function zoneCard(zone, detailed = true) {
|
||||
${['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>
|
||||
${device ? `<div class="device-toggles thermostat-swing-row quick-control-row quick-control-row-2">${device.capabilities?.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="zone-swing" data-field="swing_vertical" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingVertical'))}</button>`}${device.capabilities?.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="zone-swing" data-field="swing_horizontal" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingHorizontal'))}</button>`}</div>` : ''}
|
||||
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</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>
|
||||
${compressorQueuePanel(zone)}
|
||||
|
||||
+2
-1
@@ -179,6 +179,7 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
|
||||
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', { count: app.zones.filter(zone => !!zone.compressor_pending_action).length }))) return; return cancelAllCompressorTasks(); }
|
||||
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||
if (action === 'zone-swing' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
||||
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
||||
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 });
|
||||
@@ -614,7 +615,7 @@ $('#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 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 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); if (!groupTarget && raw.action_swing_vertical !== '') action.swing_vertical = raw.action_swing_vertical === 'true'; if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = raw.action_swing_horizontal === 'true';
|
||||
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) };
|
||||
await runFormTask(form, async () => {
|
||||
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'));
|
||||
|
||||
+5
-5
@@ -87,7 +87,7 @@ function flowDefaultConfig(kind) {
|
||||
if (kind === 'night_mode') return {};
|
||||
if (kind === 'constant') return { value: true };
|
||||
if (kind === 'shared_input') return sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]);
|
||||
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 };
|
||||
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', power: null, swing_vertical: null, swing_horizontal: null, cooldown_seconds: 60 };
|
||||
if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 };
|
||||
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
|
||||
if (kind === 'ha_service_action') return { domain: 'switch', service: 'turn_on', entity_id: '', data: {}, cooldown_seconds: 60 };
|
||||
@@ -516,7 +516,7 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-flow-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>${flowTextComparisonFields(c, true)}`;
|
||||
else if (node.kind === 'ha_available') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
|
||||
else if (node.kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-flow-config="value">${['cool', 'heat', 'off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`;
|
||||
else if (node.kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
|
||||
else if (node.kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
|
||||
else if (node.kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
|
||||
else if (node.kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`;
|
||||
else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
@@ -530,7 +530,7 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
|
||||
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
|
||||
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
|
||||
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><details class="flow-device-options"><summary>${esc(tr('flow.thermostatDeviceOptions'))}</summary><div class="two">${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}</div><p class="field-note">${esc(tr('flow.thermostatDeviceOptionsHint'))}</p></details><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
|
||||
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
|
||||
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||
@@ -540,7 +540,7 @@ function renderFlowInspector() {
|
||||
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq', '='], ['neq', '≠']].map(([v, l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-flow-config="value" value="${esc(c.value ?? '')}"></label></div>`; }
|
||||
function flowComparisonFields(c, temperature = true) { return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${flowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-flow-config="value" value="${Number(c.value ?? 0)}"></label></div>`; }
|
||||
function flowOptionalBoolField(c, key) {
|
||||
return `<label><span>${esc(key)}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
|
||||
return `<label><span>${esc(deviceCommandFieldLabel(key))}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
|
||||
}
|
||||
function flowActionFields(c, group) {
|
||||
const modes = group ? ['auto', 'house', 'heat', 'cool'] : ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
@@ -549,7 +549,7 @@ function flowActionFields(c, group) {
|
||||
const target = group
|
||||
? `<label><span>${esc(tr('groups.profile'))}</span><select data-flow-config="preset"><option value="">${esc(tr('actions.noChange'))}</option>${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${c.setpoint ?? 21}"></label>`
|
||||
: `<label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="target_temperature" value="${c.target_temperature ?? ''}"></label>`;
|
||||
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>fan_speed</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
|
||||
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>${esc(deviceCommandFieldLabel('fan_speed'))}</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
|
||||
return `${base}${target}${deviceOptions}<label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||
}
|
||||
|
||||
|
||||
+49
-3
@@ -1,3 +1,31 @@
|
||||
function outdoorHistorySeries(devices, rows) {
|
||||
const selectedIds = new Set((devices || []).map(device => device.id));
|
||||
const handled = new Set();
|
||||
const definitions = [];
|
||||
|
||||
(app.deviceGroups || []).forEach(group => {
|
||||
if (!group.outdoor_temperature_device_id) return;
|
||||
const members = (group.device_ids || []).filter(id => selectedIds.has(id));
|
||||
if (!members.length) return;
|
||||
const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0];
|
||||
if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return;
|
||||
members.forEach(id => handled.add(id));
|
||||
definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` });
|
||||
});
|
||||
|
||||
(devices || []).filter(device => !handled.has(device.id)).forEach(device => {
|
||||
if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) {
|
||||
definitions.push({ representative: device.id, label: device.name });
|
||||
}
|
||||
});
|
||||
|
||||
return definitions.map((item, index) => ({
|
||||
label: item.label,
|
||||
color: historySeriesColor(index),
|
||||
value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN,
|
||||
}));
|
||||
}
|
||||
|
||||
function renderHistorySummary() {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
|
||||
@@ -20,7 +48,7 @@ function renderOverviewHistory() {
|
||||
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
|
||||
|
||||
const outdoorDeviceSeries = app.devices.filter(device => deviceRows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: `${device.name} · ${tr('history.greeOutdoor')}`, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
|
||||
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` }));
|
||||
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
|
||||
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
||||
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
|
||||
@@ -68,7 +96,7 @@ function renderDeviceHistory() {
|
||||
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
|
||||
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
|
||||
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
const outdoor = devices.filter(device => rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
|
||||
const outdoor = outdoorHistorySeries(devices, rows);
|
||||
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
|
||||
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
|
||||
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
|
||||
@@ -87,9 +115,19 @@ function renderSensorHistory() {
|
||||
|
||||
function customSeriesOptions() {
|
||||
const items = [];
|
||||
const groupedOutdoorIds = new Set();
|
||||
(app.deviceGroups || []).forEach(group => {
|
||||
if (!group.outdoor_temperature_device_id) return;
|
||||
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
||||
? group.outdoor_temperature_device_id
|
||||
: (group.device_ids || [])[0];
|
||||
if (!representative) return;
|
||||
(group.device_ids || []).forEach(id => groupedOutdoorIds.add(id));
|
||||
items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]);
|
||||
});
|
||||
app.devices.forEach(device => {
|
||||
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
|
||||
items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
|
||||
});
|
||||
app.zones.forEach(zone => {
|
||||
@@ -113,6 +151,14 @@ function customSeriesDefinition(key, index = 0) {
|
||||
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
|
||||
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
|
||||
}
|
||||
if (kind === 'installation' && field === 'outdoor') {
|
||||
const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null;
|
||||
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
||||
? group.outdoor_temperature_device_id
|
||||
: (group.device_ids || [])[0];
|
||||
if (!representative) return null;
|
||||
return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN };
|
||||
}
|
||||
if (kind === 'zone') {
|
||||
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
|
||||
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
|
||||
|
||||
+10
-1
@@ -540,11 +540,18 @@ function updateAutomationTargetFields() {
|
||||
$('#automationGroupTarget').hidden = !groupTarget;
|
||||
$('#automationGroupPreset').hidden = !groupTarget;
|
||||
$('#automationTargetTemperature').hidden = groupTarget;
|
||||
$('#automationDeviceSwingOptions').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_swing_vertical.disabled = groupTarget;
|
||||
form.action_swing_horizontal.disabled = groupTarget;
|
||||
if (groupTarget) {
|
||||
form.action_target_temperature.value = '';
|
||||
form.action_swing_vertical.value = '';
|
||||
form.action_swing_horizontal.value = '';
|
||||
}
|
||||
[...form.action_mode.options].forEach(option => {
|
||||
if (!['dry', 'fan'].includes(option.value)) return;
|
||||
option.disabled = groupTarget;
|
||||
@@ -688,6 +695,8 @@ function populateAutomation(id) {
|
||||
form.action_mode.value = item.action.mode || '';
|
||||
form.action_preset.value = item.action_preset || '';
|
||||
form.action_target_temperature.value = item.action.target_temperature ?? '';
|
||||
form.action_swing_vertical.value = item.action.swing_vertical == null ? '' : String(item.action.swing_vertical);
|
||||
form.action_swing_horizontal.value = item.action.swing_horizontal == null ? '' : String(item.action.swing_horizontal);
|
||||
form.enabled.checked = item.enabled;
|
||||
updateAutomationTargetFields();
|
||||
openDialog('automationDialog');
|
||||
|
||||
@@ -48,6 +48,25 @@ async function handleWebSocketMessage(event) {
|
||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (['device_group.updated', 'device_group.created'].includes(message.event)) { const i = app.deviceGroups.findIndex(v => v.id === data.id); if (i >= 0) app.deviceGroups[i] = data; else app.deviceGroups.push(data); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (message.event === 'device_group.deleted') { app.deviceGroups = app.deviceGroups.filter(v => v.id !== data.id); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (message.event === 'energy.updated') {
|
||||
const total = Number(data.total_kwh);
|
||||
if (Number.isFinite(total)) {
|
||||
const snapshot = { total_kwh: total, timestamp: data.timestamp || message.timestamp, source: data.source || '', origin: 'realtime' };
|
||||
const selectedEnergySource = group => group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
|
||||
? 'gree_cloud'
|
||||
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
|
||||
if (String(data.target_id || '').startsWith('group:')) {
|
||||
const groupId = String(data.target_id).slice(6);
|
||||
const group = (app.deviceGroups || []).find(item => item.id === groupId);
|
||||
if (group && selectedEnergySource(group) === snapshot.source) app.deviceGroupEnergy[groupId] = snapshot;
|
||||
} else if (snapshot.source === 'gree_cloud') {
|
||||
(app.deviceGroups || [])
|
||||
.filter(group => group.energy_device_id === data.target_id && selectedEnergySource(group) === 'gree_cloud')
|
||||
.forEach(group => { app.deviceGroupEnergy[group.id] = snapshot; });
|
||||
}
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); }
|
||||
|
||||
@@ -119,7 +119,7 @@ function renderFlowSharedInputFields(kind, config = {}) {
|
||||
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
|
||||
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
|
||||
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
|
||||
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
|
||||
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>`;
|
||||
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
|
||||
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
|
||||
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
|
||||
+8
-1
@@ -321,7 +321,14 @@ document.addEventListener('change', event => {
|
||||
const target = event.target;
|
||||
if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyEnergyTargetSelect') { app.historyEnergyTargets = [...target.selectedOptions].map(option => option.value).slice(0, 8); app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.matches?.('[data-history-energy-target]')) {
|
||||
const checked = $$('[data-history-energy-target]:checked').map(input => input.dataset.historyEnergyTarget).filter(Boolean);
|
||||
if (checked.length > 8) { target.checked = false; toast(tr('energy.maxTargets'), true); return; }
|
||||
app.historyEnergyTargets = checked;
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
updateBrowserUrl(currentHistoryPath());
|
||||
loadHistory();
|
||||
}
|
||||
else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
|
||||
+80
-2
@@ -2087,6 +2087,7 @@ legend {
|
||||
|
||||
.history-toolbar-panel {
|
||||
margin-bottom: 14px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.history-context-controls {
|
||||
@@ -2777,6 +2778,10 @@ legend {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-toolbar-panel .chart-toolbar.energy-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.history-context-controls {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -9518,13 +9523,86 @@ body.flow-editor-open {
|
||||
.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; }
|
||||
.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
|
||||
.installation-device-choices .check.disabled { opacity: .55; }
|
||||
.history-energy-targets select { min-width: min(360px, 72vw); }
|
||||
.history-energy-targets small { display: block; max-width: 360px; color: var(--muted); font-size: 10px; }
|
||||
.installation-energy-metric b {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.history-context-controls.energy-context-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.history-energy-targets { display: grid; gap: 7px; min-width: 0; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.history-energy-targets small,
|
||||
.history-range-control small { display: block; color: var(--muted); font-size: 10px; font-weight: 550; line-height: 1.35; }
|
||||
.history-energy-picker { position: relative; min-width: 0; }
|
||||
.history-energy-picker > summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 45px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.history-energy-picker > summary::-webkit-details-marker { display: none; }
|
||||
.history-energy-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.history-energy-picker > summary b {
|
||||
min-width: 34px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.history-energy-picker[open] > summary { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
|
||||
.history-energy-options {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
|
||||
}
|
||||
.history-energy-option {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 38px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-soft);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.history-energy-option:hover { background: var(--surface-muted); }
|
||||
.history-energy-option input { width: 17px; min-height: 17px; margin: 0; padding: 0; accent-color: var(--accent); }
|
||||
.history-energy-option span { overflow-wrap: anywhere; }
|
||||
.history-range-control.energy-period-control { min-width: 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.installation-summary-card,
|
||||
.installation-list-row { grid-template-columns: 1fr; }
|
||||
.installation-list-row > div { justify-content: flex-start; }
|
||||
.installation-device-choices { grid-template-columns: 1fr; }
|
||||
.history-context-controls.energy-context-controls { grid-template-columns: 1fr; }
|
||||
.history-energy-options { width: min(420px, calc(100vw - 40px)); max-height: 46vh; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user