v0.15.20
This commit is contained in:
1 parent
abfbf0dd68
commit
aa1cb05b2f
24 files changed
+750
-71
No files matched your search
+143
-7
@@ -10,6 +10,7 @@ const FLOW_NODE_META = Object.freeze({
|
||||
delay: { titleKey: 'flow.node.delay', category: 'timeop' },
|
||||
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
|
||||
oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' },
|
||||
heating_curve: { titleKey: 'flow.node.heatingCurve', category: 'sensor' },
|
||||
outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' },
|
||||
device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' },
|
||||
zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' },
|
||||
@@ -82,6 +83,7 @@ function flowDefaultConfig(kind) {
|
||||
if (kind === 'delay') return { seconds: 30 };
|
||||
if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
|
||||
if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
|
||||
if (kind === 'heating_curve') return { source: 'outdoor_temperature', device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '', points: [{ x: -20, y: 30 }, { x: -10, y: 28 }, { x: 0, y: 25 }, { x: 10, y: 22 }, { x: 20, y: 19 }] };
|
||||
if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 };
|
||||
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 };
|
||||
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 };
|
||||
@@ -385,6 +387,11 @@ function flowNodeSummary(node) {
|
||||
if (node.kind === 'delay') return flowDurationLabel(c.seconds);
|
||||
if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||
if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`;
|
||||
if (node.kind === 'heating_curve') {
|
||||
const points = flowHeatingCurvePoints(c); const first = points[0], last = points[points.length - 1];
|
||||
const source = flowHeatingCurveSourceLabel(c);
|
||||
return points.length >= 2 ? `${source} · ${first.x}°→${first.y}° / ${last.x}°→${last.y}°` : source;
|
||||
}
|
||||
if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||
if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||
if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||
@@ -692,6 +699,80 @@ function sharedFlowReferenceComparisonFields(item, config) {
|
||||
return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`;
|
||||
}
|
||||
|
||||
function flowHeatingCurvePoints(config) {
|
||||
return (Array.isArray(config?.points) ? config.points : [])
|
||||
.map(point => ({ x: Number(point?.x), y: Number(point?.y) }))
|
||||
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
function flowHeatingCurveSourceLabel(config) {
|
||||
const source = config?.source || 'outdoor_temperature';
|
||||
if (source === 'device_temperature') return app.devices.find(item => item.id === config.device_id)?.name || tr('flow.node.deviceTemperature');
|
||||
if (source === 'zone_temperature') return app.zones.find(item => item.id === config.zone_id)?.name || tr('flow.node.zoneTemperature');
|
||||
if (source === 'ha_numeric') return config.entity_id ? haSensorLabel(config.entity_id) : tr('flow.node.haNumeric');
|
||||
return tr('flow.node.outdoorTemperature');
|
||||
}
|
||||
|
||||
function flowHeatingCurveInterpolate(points, x) {
|
||||
if (!points.length) return 0;
|
||||
if (x <= points[0].x) return points[0].y;
|
||||
if (x >= points[points.length - 1].x) return points[points.length - 1].y;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const a = points[index], b = points[index + 1];
|
||||
if (x < a.x || x > b.x || a.x === b.x) continue;
|
||||
return a.y + ((x - a.x) / (b.x - a.x)) * (b.y - a.y);
|
||||
}
|
||||
return points[0].y;
|
||||
}
|
||||
|
||||
function flowHeatingCurveChart(config) {
|
||||
const points = flowHeatingCurvePoints(config);
|
||||
if (points.length < 2) return `<div class="empty compact"><span>${esc(tr('flow.heatingCurveNeedsPoints'))}</span></div>`;
|
||||
const width = 360, height = 220, left = 46, right = 18, top = 16, bottom = 38;
|
||||
const plotW = width - left - right, plotH = height - top - bottom;
|
||||
const xs = points.map(point => point.x), ys = points.map(point => point.y);
|
||||
let minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
if (minX === maxX) { minX -= 1; maxX += 1; }
|
||||
if (minY === maxY) { minY -= 1; maxY += 1; }
|
||||
const xPad = Math.max(1, (maxX - minX) * .08), yPad = Math.max(1, (maxY - minY) * .12);
|
||||
minX -= xPad; maxX += xPad; minY -= yPad; maxY += yPad;
|
||||
const sx = value => left + ((value - minX) / (maxX - minX)) * plotW;
|
||||
const sy = value => top + plotH - ((value - minY) / (maxY - minY)) * plotH;
|
||||
const polyline = points.map(point => `${sx(point.x).toFixed(1)},${sy(point.y).toFixed(1)}`).join(' ');
|
||||
const circles = points.map((point, index) => `<circle cx="${sx(point.x).toFixed(1)}" cy="${sy(point.y).toFixed(1)}" r="5" data-flow-curve-chart-point="${index}"><title>${esc(`${point.x}°C → ${point.y}°C`)}</title></circle>`).join('');
|
||||
const xMid = (minX + maxX) / 2, yMid = (minY + maxY) / 2;
|
||||
return `<div class="flow-heating-curve-chart"><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(tr('flow.heatingCurveChartLabel'))}">
|
||||
<line class="axis" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotH}"/><line class="axis" x1="${left}" y1="${top + plotH}" x2="${left + plotW}" y2="${top + plotH}"/>
|
||||
<line class="grid" x1="${sx(xMid)}" y1="${top}" x2="${sx(xMid)}" y2="${top + plotH}"/><line class="grid" x1="${left}" y1="${sy(yMid)}" x2="${left + plotW}" y2="${sy(yMid)}"/>
|
||||
<polyline class="curve" points="${polyline}"/>${circles}
|
||||
<text x="${left}" y="${height - 18}" text-anchor="middle">${Number(minX.toFixed(1))}</text><text x="${left + plotW}" y="${height - 18}" text-anchor="middle">${Number(maxX.toFixed(1))}</text>
|
||||
<text x="${left - 8}" y="${top + 4}" text-anchor="end">${Number(maxY.toFixed(1))}</text><text x="${left - 8}" y="${top + plotH + 4}" text-anchor="end">${Number(minY.toFixed(1))}</text>
|
||||
<text class="axis-title" x="${left + plotW / 2}" y="${height - 3}" text-anchor="middle">${esc(tr('flow.heatingCurveAxisX'))}</text>
|
||||
<text class="axis-title" transform="translate(12 ${top + plotH / 2}) rotate(-90)" text-anchor="middle">${esc(tr('flow.heatingCurveAxisY'))}</text>
|
||||
</svg></div>`;
|
||||
}
|
||||
|
||||
function flowHeatingCurveEditor(config) {
|
||||
const source = config.source || 'outdoor_temperature';
|
||||
const points = flowHeatingCurvePoints(config);
|
||||
const sourceFields = source === 'device_temperature'
|
||||
? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, config.device_id)}</select></label>`
|
||||
: source === 'zone_temperature'
|
||||
? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, config.zone_id)}</select></label>`
|
||||
: source === 'ha_numeric'
|
||||
? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(config.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>`
|
||||
: '';
|
||||
const rows = points.map((point, index) => `<div class="flow-heating-curve-point-row"><label><span>X</span><input type="number" step="0.1" data-flow-curve-point="x" data-flow-curve-index="${index}" value="${point.x}"></label><label><span>Y</span><input type="number" step="0.1" data-flow-curve-point="y" data-flow-curve-index="${index}" value="${point.y}"></label><button type="button" class="secondary" data-flow-curve-remove="${index}" ${points.length <= 2 ? 'disabled' : ''} aria-label="${esc(tr('actions.delete'))}">${uiIcon('close')}</button></div>`).join('');
|
||||
return `<label><span>${esc(tr('flow.heatingCurveSource'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([value, label]) => `<option value="${value}" ${source === value ? 'selected' : ''}>${esc(label)}</option>`).join('')}</select></label>${sourceFields}${flowHeatingCurveChart(config)}<div class="flow-heating-curve-points-head"><strong>${esc(tr('flow.heatingCurvePoints'))}</strong><button type="button" class="secondary" data-flow-curve-add ${points.length >= 32 ? 'disabled' : ''}>${esc(tr('flow.heatingCurveAddPoint'))}</button></div><div class="flow-heating-curve-points">${rows}</div><p class="field-note">${esc(tr('flow.heatingCurveHint'))}</p>`;
|
||||
}
|
||||
|
||||
function flowIncomingHeatingCurve(nodeId) {
|
||||
if (!app.flowDraft) return null;
|
||||
const edge = app.flowDraft.edges.find(item => item.to === nodeId && flowNodeById(item.from)?.kind === 'heating_curve');
|
||||
return edge ? flowNodeById(edge.from) : null;
|
||||
}
|
||||
|
||||
function renderFlowInspector() {
|
||||
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
|
||||
host.classList.toggle('has-selection', Boolean(node));
|
||||
@@ -709,6 +790,7 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
|
||||
else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic !== 'median' ? 'selected' : ''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic === 'median' ? 'selected' : ''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label></div>${flowComparisonFields(c, false)}`; }
|
||||
else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span ?? 1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes || 2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
|
||||
else if (node.kind === 'heating_curve') fields = flowHeatingCurveEditor(c);
|
||||
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
|
||||
else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`;
|
||||
else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`;
|
||||
@@ -736,6 +818,14 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'device_feature_action') 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.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><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 === '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" list="haEntitySuggestions" 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>`;
|
||||
const incomingCurve = flowIncomingHeatingCurve(node.id);
|
||||
if (incomingCurve && ['zone_thermostat', 'device_action', 'group_action'].includes(node.kind)) {
|
||||
fields += `<p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }))}</p>`;
|
||||
} else if (incomingCurve && node.kind === 'device_feature_action') {
|
||||
fields += `<p class="field-note flow-value-binding-note">${esc(c.feature === 'target_temperature' ? tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }) : tr('flow.heatingCurveFeatureWarning'))}</p>`;
|
||||
} else if (incomingCurve && node.kind === 'ha_service_action') {
|
||||
fields += `<label><span>${esc(tr('flow.heatingCurveHaValueKey'))}</span><input data-flow-config="value_key" value="${esc(c.value_key || 'temperature')}" placeholder="temperature"></label><p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveHaBindingHint'))}</p>`;
|
||||
}
|
||||
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">${uiIcon('expand-vertical')}</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">${uiIcon('close')}</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
|
||||
}
|
||||
|
||||
@@ -1224,7 +1314,7 @@ function localDateTimeInputValue(date = new Date()) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
function flowSimulationOverrideNodes(flow = app.flowDraft) {
|
||||
return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
|
||||
return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'heating_curve', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
|
||||
}
|
||||
function flowSimulationLouverAxis(node) {
|
||||
if (node?.kind === 'device_state') {
|
||||
@@ -1243,7 +1333,7 @@ function renderFlowSimulationOverrides() {
|
||||
host.innerHTML = nodes.length ? nodes.map(node => {
|
||||
const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind;
|
||||
const louverAxis = flowSimulationLouverAxis(node);
|
||||
const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind);
|
||||
const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind);
|
||||
const control = louverAxis
|
||||
? `<select data-flow-sim-node="${esc(node.id)}"><option value="">${esc(tr('flow.useLiveValue'))}</option>${louverOptions(louverAxis, null)}</select>`
|
||||
: `<input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
|
||||
@@ -1256,7 +1346,7 @@ function collectFlowSimulationOverrides() {
|
||||
if (input.value.trim() === '') return;
|
||||
const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim();
|
||||
const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind;
|
||||
if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind))) value = Number(value);
|
||||
if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind))) value = Number(value);
|
||||
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
|
||||
result[input.dataset.flowSimNode] = value;
|
||||
});
|
||||
@@ -1283,7 +1373,7 @@ function flowSimpleSimulationControl(node) {
|
||||
const louverAxis = flowSimulationLouverAxis(node);
|
||||
const live = `<option value="">${esc(tr('flow.useLiveValue'))}</option>`;
|
||||
if (louverAxis) return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${louverOptions(louverAxis, null)}</select>`;
|
||||
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) {
|
||||
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) {
|
||||
return `<input type="number" step="0.1" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
|
||||
}
|
||||
if (source.kind === 'house_mode') {
|
||||
@@ -1309,7 +1399,7 @@ function collectFlowSimpleSimulationOverrides(flow) {
|
||||
const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return;
|
||||
const source = flowSimulationEffectiveSource(node);
|
||||
let value = raw;
|
||||
if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) value = Number(raw);
|
||||
if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) value = Number(raw);
|
||||
else if (/^(true|false)$/i.test(raw)) value = raw.toLowerCase() === 'true';
|
||||
result[node.id] = value;
|
||||
});
|
||||
@@ -1320,6 +1410,10 @@ function flowSimpleSimulationActualText(node, actual) {
|
||||
if (actual === null || actual === undefined) return tr('flow.simpleNoData');
|
||||
if (typeof actual === 'object') {
|
||||
if (actual.error) return tr('flow.simpleNoData');
|
||||
if (node?.kind === 'heating_curve') {
|
||||
const input = Number(actual.input), output = Number(actual.output);
|
||||
if (Number.isFinite(input) && Number.isFinite(output)) return `${input.toFixed(1)} °C → ${output.toFixed(1)} °C`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const source = flowSimulationEffectiveSource(node);
|
||||
@@ -1361,7 +1455,8 @@ function renderFlowSimpleSimulationResult(result) {
|
||||
return `<div class="flow-simple-condition ${item.matched ? 'pass' : 'fail'}"><span class="flow-simple-condition-mark">${uiIcon(item.matched ? 'check' : 'close')}</span><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${actual ? `<em>${esc(tr('flow.simpleCurrentValue', { value: actual }))}</em>` : ''}</div>`;
|
||||
}).join('');
|
||||
const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect');
|
||||
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
|
||||
const dynamicValue = Number.isFinite(Number(action.dynamic_value)) ? `${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}` : '';
|
||||
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${dynamicValue ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(dynamicValue)}</code></p>` : ''}${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -1398,7 +1493,7 @@ function openFlowDryRun() {
|
||||
function renderFlowDryRunResult(result) {
|
||||
const host = $('#flowTestResults');
|
||||
const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id;
|
||||
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
|
||||
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}${Number.isFinite(Number(action.dynamic_value)) ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(`${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}`)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
|
||||
}
|
||||
async function runFlowDryRun() {
|
||||
if (!app.flowDraft) return;
|
||||
@@ -1659,6 +1754,44 @@ function updateFlowConfig(input) {
|
||||
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
function updateFlowHeatingCurvePoint(input) {
|
||||
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
|
||||
const index = Number(input.dataset.flowCurveIndex); const axis = input.dataset.flowCurvePoint;
|
||||
const points = flowHeatingCurvePoints(node.config); const value = Number(input.value);
|
||||
if (!Number.isInteger(index) || !points[index] || !['x', 'y'].includes(axis) || !Number.isFinite(value)) return;
|
||||
points[index][axis] = value;
|
||||
const duplicateX = axis === 'x' && points.some((point, pointIndex) => pointIndex !== index && Math.abs(point.x - value) < 0.000001);
|
||||
if (duplicateX) { toast(tr('flow.heatingCurveDuplicateX'), true); renderFlowInspector(); return; }
|
||||
node.config.points = points.sort((a, b) => a.x - b.x);
|
||||
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
function addFlowHeatingCurvePoint() {
|
||||
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
|
||||
const points = flowHeatingCurvePoints(node.config); if (points.length >= 32) return;
|
||||
if (points.length < 2) {
|
||||
node.config.points = [{ x: -10, y: 28 }, { x: 10, y: 22 }];
|
||||
} else {
|
||||
let bestIndex = 0, bestGap = -Infinity;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const gap = points[index + 1].x - points[index].x;
|
||||
if (gap > bestGap) { bestGap = gap; bestIndex = index; }
|
||||
}
|
||||
const a = points[bestIndex], b = points[bestIndex + 1];
|
||||
const x = Number(((a.x + b.x) / 2).toFixed(1));
|
||||
const y = Number(flowHeatingCurveInterpolate(points, x).toFixed(1));
|
||||
points.push({ x, y }); node.config.points = points.sort((left, right) => left.x - right.x);
|
||||
}
|
||||
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
function removeFlowHeatingCurvePoint(index) {
|
||||
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
|
||||
const points = flowHeatingCurvePoints(node.config); if (points.length <= 2 || !points[index]) return;
|
||||
points.splice(index, 1); node.config.points = points;
|
||||
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
|
||||
}
|
||||
|
||||
let flowDrag = null;
|
||||
let flowMarquee = null;
|
||||
|
||||
@@ -1850,6 +1983,8 @@ document.addEventListener('keydown', event => {
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const curveAdd = event.target.closest?.('[data-flow-curve-add]'); if (curveAdd) { addFlowHeatingCurvePoint(); return; }
|
||||
const curveRemove = event.target.closest?.('[data-flow-curve-remove]'); if (curveRemove) { removeFlowHeatingCurvePoint(Number(curveRemove.dataset.flowCurveRemove)); return; }
|
||||
const contextAction = event.target.closest?.('[data-flow-context-action]');
|
||||
if (contextAction) {
|
||||
const action = contextAction.dataset.flowContextAction;
|
||||
@@ -1926,6 +2061,7 @@ document.addEventListener('click', event => {
|
||||
else if (action === 'close-flow-editor') closeFlowEditor();
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches?.('[data-flow-curve-point]')) { updateFlowHeatingCurvePoint(event.target); return; }
|
||||
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
|
||||
});
|
||||
$('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); });
|
||||
|
||||
Reference in new issue
Block a user