v0.11.1
This commit is contained in:
+61
-1
@@ -4,6 +4,9 @@ const FLOW_NODE_META = Object.freeze({
|
||||
date_range: { titleKey: 'flow.node.dateRange', category: 'time' },
|
||||
cron_trigger: { titleKey: 'flow.node.cronTrigger', category: 'trigger' },
|
||||
stable_for: { titleKey: 'flow.node.stableFor', category: 'timeop' },
|
||||
state_duration: { titleKey: 'flow.node.stateDuration', category: 'timeop' },
|
||||
on_change: { titleKey: 'flow.node.onChange', category: 'timeop' },
|
||||
rate_limit: { titleKey: 'flow.node.rateLimit', category: 'timeop' },
|
||||
delay: { titleKey: 'flow.node.delay', category: 'timeop' },
|
||||
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
|
||||
oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' },
|
||||
@@ -64,6 +67,9 @@ function flowDefaultConfig(kind) {
|
||||
if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; }
|
||||
if (kind === 'cron_trigger') return { expression: '*/5 * * * *' };
|
||||
if (kind === 'stable_for') return { seconds: 180 };
|
||||
if (kind === 'state_duration') return { min_seconds: 60, max_seconds: 600 };
|
||||
if (kind === 'on_change') return { mode: 'result' };
|
||||
if (kind === 'rate_limit') return { max_count: 3, period_seconds: 3600 };
|
||||
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: '' };
|
||||
@@ -260,6 +266,9 @@ function flowNodeSummary(node) {
|
||||
if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`;
|
||||
if (node.kind === 'cron_trigger') return c.expression || '—';
|
||||
if (node.kind === 'stable_for') return flowDurationLabel(c.seconds);
|
||||
if (node.kind === 'state_duration') return `${flowDurationLabel(c.min_seconds || 0)}–${c.max_seconds == null ? '∞' : flowDurationLabel(c.max_seconds)}`;
|
||||
if (node.kind === 'on_change') return c.mode === 'value' ? tr('flow.changeModeValue') : tr('flow.changeModeResult');
|
||||
if (node.kind === 'rate_limit') return `${Number(c.max_count || 1)} × / ${flowDurationLabel(c.period_seconds || 3600)}`;
|
||||
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)}`;
|
||||
@@ -315,7 +324,7 @@ function renderFlowEditor() {
|
||||
}).join('');
|
||||
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
|
||||
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
|
||||
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); refreshFlowSharedInputCurrentValues();
|
||||
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
|
||||
const status = $('#flowCompileStatus');
|
||||
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
||||
}
|
||||
@@ -363,6 +372,9 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
|
||||
else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`;
|
||||
else if (node.kind === 'stable_for') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 180)}"></label><p class="field-note">${esc(tr('flow.stableForHint'))}</p>`;
|
||||
else if (node.kind === 'state_duration') fields = `<div class="two"><label><span>${esc(tr('flow.minDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="min_seconds" value="${Number(c.min_seconds || 0)}"></label><label><span>${esc(tr('flow.maxDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="max_seconds" value="${c.max_seconds == null ? '' : Number(c.max_seconds)}" placeholder="∞"></label></div><p class="field-note">${esc(tr('flow.stateDurationHint'))}</p>`;
|
||||
else if (node.kind === 'on_change') fields = `<label><span>${esc(tr('flow.changeMode'))}</span><select data-flow-config="mode"><option value="result" ${c.mode !== 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeResult'))}</option><option value="value" ${c.mode === 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeValue'))}</option></select></label><p class="field-note">${esc(tr('flow.onChangeHint'))}</p>`;
|
||||
else if (node.kind === 'rate_limit') fields = `<div class="two"><label><span>${esc(tr('flow.maxExecutions'))}</span><input type="number" min="1" max="1000" data-flow-config="max_count" value="${Number(c.max_count || 1)}"></label><label><span>${esc(tr('flow.periodSeconds'))}</span><input type="number" min="1" max="2678400" data-flow-config="period_seconds" value="${Number(c.period_seconds || 3600)}"></label></div><p class="field-note">${esc(tr('flow.rateLimitHint'))}</p>`;
|
||||
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" 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" 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>`; }
|
||||
@@ -437,6 +449,50 @@ function renderFlowInterpretation() {
|
||||
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
|
||||
}
|
||||
|
||||
function flowCronHumanSummary(expression) {
|
||||
const raw = String(expression || '').trim();
|
||||
const parts = raw.split(/\s+/);
|
||||
if (parts.length !== 5) return tr('flow.cronCustom', { expression: raw || '—' });
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
const allDays = day === '*' && month === '*' && weekday === '*';
|
||||
if (minute === '*' && hour === '*' && allDays) return tr('flow.cronEveryMinute');
|
||||
const everyMinutes = minute.match(/^\*\/(\d+)$/);
|
||||
if (everyMinutes && hour === '*' && allDays) return tr('flow.cronEveryMinutes', { minutes: everyMinutes[1] });
|
||||
if (/^\d{1,2}$/.test(minute) && hour === '*' && allDays) return tr('flow.cronHourlyAt', { minute: String(Number(minute)).padStart(2, '0') });
|
||||
if (/^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && allDays) return tr('flow.cronDailyAt', { time: `${String(Number(hour)).padStart(2, '0')}:${String(Number(minute)).padStart(2, '0')}` });
|
||||
return tr('flow.cronCustom', { expression: raw });
|
||||
}
|
||||
|
||||
function renderFlowRuntimeInfo() {
|
||||
const target = $('#flowRuntimeSummary');
|
||||
const container = $('#flowRuntimeInfo');
|
||||
if (!target || !app.flowDraft) return;
|
||||
const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5));
|
||||
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category));
|
||||
if (!actions.length) {
|
||||
target.textContent = tr('flow.runtimeNoActions', { seconds });
|
||||
if (container) container.title = tr('flow.runtimeHint');
|
||||
return;
|
||||
}
|
||||
const cronExpressions = new Set();
|
||||
let hasActionWithoutCron = false;
|
||||
actions.forEach(action => {
|
||||
const crons = flowUpstreamNodes(action.id).filter(node => node.kind === 'cron_trigger');
|
||||
if (!crons.length) hasActionWithoutCron = true;
|
||||
crons.forEach(node => cronExpressions.add(String(node.config?.expression || '').trim()));
|
||||
});
|
||||
if (!cronExpressions.size) {
|
||||
target.textContent = tr('flow.runtimeWithoutCron', { seconds });
|
||||
} else if (!hasActionWithoutCron && cronExpressions.size === 1) {
|
||||
target.textContent = tr('flow.runtimeWithCron', { seconds, cron: flowCronHumanSummary([...cronExpressions][0]) });
|
||||
} else if (!hasActionWithoutCron) {
|
||||
target.textContent = tr('flow.runtimeWithMultipleCrons', { seconds, count: cronExpressions.size });
|
||||
} else {
|
||||
target.textContent = tr('flow.runtimeMixed', { seconds, count: cronExpressions.size });
|
||||
}
|
||||
if (container) container.title = tr('flow.runtimeHint');
|
||||
}
|
||||
|
||||
function flowUpstreamNodes(id) {
|
||||
if (!app.flowDraft) return [];
|
||||
const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const seen = new Set(), stack = [id], out = [];
|
||||
@@ -921,6 +977,10 @@ function updateFlowConfig(input) {
|
||||
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
|
||||
else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } }
|
||||
else if (key === 'seconds' && ['stable_for','delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1));
|
||||
else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0));
|
||||
else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value));
|
||||
else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||
else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||
else if (key === 'window_seconds' && ['rolling_stat','oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10));
|
||||
else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0);
|
||||
else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001));
|
||||
|
||||
Reference in New Issue
Block a user