Files
gree-controller/web/js/flows.js
T
2026-09-02 23:10:44 +02:00

1102 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const FLOW_NODE_META = Object.freeze({
weekday: { titleKey: 'flow.node.weekday', category: 'time' },
time_range: { titleKey: 'flow.node.timeRange', category: 'time' },
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' },
outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' },
device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' },
zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' },
ha_state: { titleKey: 'flow.node.haState', category: 'sensor' },
ha_numeric: { titleKey: 'flow.node.haNumeric', category: 'sensor' },
ha_attribute: { titleKey: 'flow.node.haAttribute', category: 'sensor' },
ha_available: { titleKey: 'flow.node.haAvailable', category: 'sensor' },
house_mode: { titleKey: 'flow.node.houseMode', category: 'sensor' },
device_state: { titleKey: 'flow.node.deviceState', category: 'sensor' },
zone_state: { titleKey: 'flow.node.zoneState', category: 'sensor' },
group_state: { titleKey: 'flow.node.groupState', category: 'sensor' },
night_mode: { titleKey: 'flow.node.nightMode', category: 'time' },
constant: { titleKey: 'flow.node.constant', category: 'sensor' },
shared_input: { titleKey: 'flow.node.sharedInput', category: 'sensor' },
logic_and: { titleKey: 'flow.node.and', category: 'logic' },
logic_or: { titleKey: 'flow.node.or', category: 'logic' },
logic_not: { titleKey: 'flow.node.not', category: 'logic' },
zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' },
device_action: { titleKey: 'flow.node.greeDevice', category: 'action' },
group_action: { titleKey: 'flow.node.group', category: 'action' },
ha_service_action: { titleKey: 'flow.node.haServiceAction', category: 'haaction' },
});
function newFlowId(prefix = 'node') {
if (crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sharedFlowInputRequiresComparison(kind) {
return ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','house_mode','device_state','zone_state','group_state'].includes(kind);
}
function sharedFlowReferenceComparisonDefaults(item) {
const kind = item?.kind || '';
if (['outdoor_temperature','device_temperature','zone_temperature'].includes(kind)) return { operator:'lt', value:20 };
if (kind === 'ha_numeric') return { operator:'lt', value:0 };
if (kind === 'ha_state') return { operator:'eq', value:'on' };
if (kind === 'ha_attribute') return { operator:'eq', value:'' };
if (kind === 'house_mode') return { operator:'eq', value:'cool' };
if (kind === 'device_state') return { operator:'eq', value:'true' };
if (kind === 'zone_state') return { operator:'eq', value:'true' };
if (kind === 'group_state') return { operator:'eq', value:'true' };
return {};
}
function sharedFlowReferenceDefaultConfig(item) {
const config = { input_id:item?.id || '' };
if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item));
return config;
}
function flowDefaultConfig(kind) {
if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] };
if (kind === 'time_range') return { start: '06:00', end: '08:00' };
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: '' };
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 };
if (kind === 'ha_state') return { entity_id: '', operator: 'eq', value: 'on' };
if (kind === 'ha_numeric') return { entity_id: '', operator: 'lt', value: 20 };
if (kind === 'ha_attribute') return { entity_id: '', attribute: '', operator: 'eq', value: '' };
if (kind === 'ha_available') return { entity_id: '' };
if (kind === 'house_mode') return { operator: 'eq', value: 'cool' };
if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online', operator: 'eq', value: 'true' };
if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand', operator: 'eq', value: 'true' };
if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled', operator: 'eq', value: 'true' };
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 === '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 };
return {};
}
function renderFlows() {
const host = $('#flowList'); if (!host) return;
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}">
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="zone-enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span>✎</span>${esc(tr('common.disabled'))}</button>` : `<button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '✓' : '○'}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button>`}</div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
}
function flowDraftFrom(flow) {
return flow ? JSON.parse(JSON.stringify(flow)) : {
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, draft: false, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
};
}
function openFlowEditor(id = '', { push = true } = {}) {
const flow = id ? app.flows.find(item => item.id === id) : null;
if (id && !flow) return toast(tr('flow.notFound'), true);
app.flowDraft = flowDraftFrom(flow);
app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false;
$('#flowName').value = app.flowDraft.name || '';
$('#flowEnabled').checked = app.flowDraft.enabled !== false;
$('#flowEditor').hidden = false;
document.body.classList.add('flow-editor-open');
renderFlowEditor();
startFlowSharedInputValueRefresh();
if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`);
}
function closeFlowEditor({ push = true, force = false } = {}) {
if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false;
stopFlowSharedInputValueRefresh();
$('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open');
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false;
if (push) updateBrowserUrl('/flows');
return true;
}
function flowSharedInputValueSignature(item) {
return `${item?.kind || ''}:${JSON.stringify(item?.config || {})}`;
}
function flowSharedInputLocalObservation(item) {
if (!item) return { hasValue:false, value:null };
const c = item.config || {};
if (item.kind === 'outdoor_temperature') return { hasValue:app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value:app.outdoorTemperature };
if (item.kind === 'device_temperature') {
const value = app.devices.find(device => device.id === c.device_id)?.current_temperature;
return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value };
}
if (item.kind === 'zone_temperature') {
const value = app.zones.find(zone => zone.id === c.zone_id)?.current_temperature;
return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value };
}
if (item.kind === 'house_mode') {
const value = app.settings?.house_mode;
return { hasValue:value !== null && value !== undefined && value !== '', value };
}
if (item.kind === 'device_state') {
const device = app.devices.find(value => value.id === c.device_id), value = device?.[c.field];
return { hasValue:value !== undefined && value !== null, value };
}
if (item.kind === 'zone_state') {
const zone = app.zones.find(value => value.id === c.zone_id), value = zone?.[c.field];
return { hasValue:value !== undefined && value !== null, value };
}
if (item.kind === 'group_state') {
const group = app.groups.find(value => value.id === c.group_id), value = group?.[c.field || 'power_enabled'];
return { hasValue:value !== undefined && value !== null, value };
}
if (item.kind === 'night_mode') {
const value = app.controlPlan?.night_mode_active;
return { hasValue:typeof value === 'boolean', value };
}
if (item.kind === 'constant') return { hasValue:true, value:c.value };
return null;
}
function flowSharedInputObservation(item) {
const local = flowSharedInputLocalObservation(item);
if (local) return local;
const cached = app.flowSharedInputValueCache?.[item?.id];
return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue:false, value:null };
}
function flowSharedInputCurrentText(item, observation = flowSharedInputObservation(item)) {
if (!observation?.hasValue) return '—';
const value = observation.value;
if (['outdoor_temperature','device_temperature','zone_temperature'].includes(item.kind)) return fmtTemp(value);
if (item.kind === 'ha_numeric') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
const unit = String(observation.unit || '').trim();
return `${Number.isInteger(numeric) ? numeric : Number(numeric.toFixed(2))}${unit ? ` ${unit}` : ''}`;
}
if (item.kind === 'house_mode') return houseModeLabel(String(value));
if (typeof value === 'boolean') return value ? tr('common.yes') : tr('common.no');
if (value === null || value === undefined || value === '') return '—';
return String(value);
}
function renderFlowSharedInputCurrentValues() {
if (!app.flowDraft) return;
$$('[data-flow-shared-current]').forEach(host => {
const item = (app.flowSharedInputs || []).find(value => value.id === host.dataset.flowSharedCurrent);
const text = item ? flowSharedInputCurrentText(item) : '—';
host.title = text;
host.innerHTML = `<span>${esc(tr('flow.sharedInputTestCurrent'))}</span><strong>${esc(text)}</strong>`;
});
}
async function loadFlowSharedInputHaValue(item) {
if (!item || !['ha_state','ha_numeric','ha_attribute','ha_available'].includes(item.kind)) return;
const signature = flowSharedInputValueSignature(item), cached = app.flowSharedInputValueCache?.[item.id];
if (cached?.signature === signature && Date.now() - Number(cached.fetchedAt || 0) < 15000) return;
if (app.flowSharedInputValueRequests?.[item.id] === signature) return;
app.flowSharedInputValueRequests[item.id] = signature;
try {
const entityId = String(item.config?.entity_id || '').trim();
if (!entityId) throw new Error('missing entity_id');
const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } });
let value = result.state, hasValue = true;
if (item.kind === 'ha_available') value = result.available === true;
else if (item.kind === 'ha_attribute') {
value = result.attributes?.[item.config?.attribute];
hasValue = value !== undefined;
} else if (item.kind === 'ha_numeric') {
value = Number(result.state);
hasValue = Number.isFinite(value);
}
app.flowSharedInputValueCache[item.id] = {
signature, fetchedAt:Date.now(), hasValue, value,
unit:item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '',
};
} catch (_) {
app.flowSharedInputValueCache[item.id] = { signature, fetchedAt:Date.now(), hasValue:false, value:null };
} finally {
if (app.flowSharedInputValueRequests?.[item.id] === signature) delete app.flowSharedInputValueRequests[item.id];
renderFlowSharedInputCurrentValues();
}
}
function refreshFlowSharedInputCurrentValues() {
if (!app.flowDraft) return;
renderFlowSharedInputCurrentValues();
const ids = new Set((app.flowDraft.nodes || []).filter(node => node.kind === 'shared_input').map(node => node.config?.input_id).filter(Boolean));
ids.forEach(id => {
const item = (app.flowSharedInputs || []).find(value => value.id === id);
if (item) loadFlowSharedInputHaValue(item);
});
}
function startFlowSharedInputValueRefresh() {
stopFlowSharedInputValueRefresh();
refreshFlowSharedInputCurrentValues();
app.flowSharedInputValueTimer = setInterval(refreshFlowSharedInputCurrentValues, 5000);
}
function stopFlowSharedInputValueRefresh() {
if (app.flowSharedInputValueTimer) clearInterval(app.flowSharedInputValueTimer);
app.flowSharedInputValueTimer = null;
}
function flowNodeSummary(node) {
const c = node.config || {};
if (node.kind === 'weekday') return (c.days || []).map(day => tr(`day.${day}`)).join(', ') || '—';
if (node.kind === 'time_range') return `${c.start || '—'}${c.end || '—'}`;
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)}`;
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)}`;
if (node.kind === 'ha_state') return `${c.entity_id || 'entity_id'} ${c.operator === 'neq' ? '≠' : '='} ${c.value || '—'}`;
if (node.kind === 'ha_numeric') return `${c.entity_id || 'entity_id'} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'ha_attribute') return `${c.entity_id || 'entity_id'}.${c.attribute || 'attribute'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'ha_available') return `${c.entity_id || 'entity_id'} · ${tr('flow.available')}`;
if (node.kind === 'house_mode') return `${tr('flow.houseMode')} ${flowOperatorLabel(c.operator)} ${c.value || '—'}`;
if (node.kind === 'device_state') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'zone_state') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'group_state') return `${app.groups.find(g => g.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'night_mode') return tr('flow.nightModeActive');
if (node.kind === 'constant') return c.value === false ? tr('common.no') : tr('common.yes');
if (node.kind === 'shared_input') {
const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id);
if (!item) return tr('flow.sharedInputMissing');
if (sharedFlowInputRequiresComparison(item.kind)) {
return c.operator
? `${item.name} · ${sharedFlowInputSourceSummary(item)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`
: `${item.name} · ${tr('flow.selectOperator')}`;
}
return `${item.name} · ${sharedFlowInputSourceSummary(item)}`;
}
if (node.kind === 'logic_and') return tr('flow.allConditions');
if (node.kind === 'logic_or') return tr('flow.anyCondition');
if (node.kind === 'logic_not') return tr('flow.invertCondition');
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
if (node.kind === 'ha_service_action') return `${c.domain || 'domain'}.${c.service || 'service'} · ${c.entity_id || 'entity_id'}`;
return '';
}
function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); }
function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; }
function flowDurationLabel(seconds) { const value = Number(seconds || 0); return value >= 60 && value % 60 === 0 ? `${value / 60} min` : `${value} s`; }
function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); }
function renderFlowEditor() {
const draft = app.flowDraft; if (!draft) return;
const nodesHost = $('#flowNodes');
nodesHost.innerHTML = draft.nodes.map(node => {
const meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' };
return `<article class="flow-node flow-node-${esc(meta.category)} ${(app.flowSelectedNodeIds || []).includes(node.id) ? 'selected' : ''}" data-flow-node="${esc(node.id)}" style="left:${Number(node.x || 0)}px;top:${Number(node.y || 0)}px">
<button class="flow-port flow-port-in" type="button" data-flow-input="${esc(node.id)}" title="${esc(tr('flow.connectHere'))}"></button>
<div class="flow-node-head"><span>${esc(flowNodeTitle(meta))}</span><button type="button" data-flow-remove="${esc(node.id)}" aria-label="${esc(tr('actions.delete'))}">×</button></div>
<div class="flow-node-body">${esc(flowNodeSummary(node))}${node.kind === 'shared_input' && node.config?.input_id ? `<div class="flow-node-current" data-flow-shared-current="${esc(node.config.input_id)}"><span>${esc(tr('flow.sharedInputTestCurrent'))}</span><strong>—</strong></div>` : ''}</div>
<button class="flow-port flow-port-out ${app.flowConnectFrom === node.id ? 'armed' : ''}" type="button" data-flow-output="${esc(node.id)}" title="${esc(tr('flow.startConnection'))}"></button>
</article>`;
}).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(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
const status = $('#flowCompileStatus');
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
status.classList.toggle('flow-draft-badge', draft.draft === true);
}
function renderFlowEdges() {
const svg = $('#flowEdges'), workspace = $('#flowWorkspace'); if (!svg || !workspace || !app.flowDraft) return;
const rect = workspace.getBoundingClientRect();
svg.setAttribute('viewBox', `0 0 ${Math.max(workspace.clientWidth, workspace.scrollWidth)} ${Math.max(workspace.clientHeight, workspace.scrollHeight)}`);
svg.innerHTML = app.flowDraft.edges.map(edge => {
const from = $(`[data-flow-node="${CSS.escape(edge.from)}"]`), to = $(`[data-flow-node="${CSS.escape(edge.to)}"]`); if (!from || !to) return '';
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
const x1 = a.right - rect.left + workspace.scrollLeft - 2, y1 = a.top - rect.top + workspace.scrollTop + a.height / 2;
const x2 = b.left - rect.left + workspace.scrollLeft + 2, y2 = b.top - rect.top + workspace.scrollTop + b.height / 2;
const bend = Math.max(55, Math.abs(x2 - x1) * .45);
return `<path data-flow-edge="${esc(edge.id)}" d="M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}"/>`;
}).join('');
}
function flowSelectOptions(items, selected, nameFn = item => item.name) {
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(nameFn(item))}</option>`).join('');
}
function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === selected ? 'selected' : ''}>${l}</option>`).join(''); }
function sharedFlowReferenceComparisonFields(item, config) {
if (!item || !sharedFlowInputRequiresComparison(item.kind)) return '';
const defaults = sharedFlowReferenceComparisonDefaults(item);
const selected = config.operator || '';
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item.kind);
const fullOperators = numeric || item.kind === 'ha_attribute';
const operatorOptions = `${!selected ? `<option value="" selected disabled>${esc(tr('flow.selectOperator'))}</option>` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('')}`;
const value = config.value ?? defaults.value ?? '';
let valueField = `<input data-flow-config="value" value="${esc(value)}">`;
if (numeric) valueField = `<input type="number" step="0.1" data-flow-config="value" value="${Number(value ?? 0)}">`;
else if (item.kind === 'house_mode') valueField = `<select data-flow-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${String(value) === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select>`;
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 renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
if (!node) { host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind };
let fields = '';
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`;
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>`; }
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)}`;
else if (node.kind === 'ha_state') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><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.state'))}</span><input data-flow-config="value" value="${esc(c.value || '')}" placeholder="on"></label></div>`;
else if (node.kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>${flowComparisonFields(c, false)}`;
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 === '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>`;
else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
else if (node.kind === 'shared_input') {
const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id) || app.flowSharedInputs?.[0];
fields = (app.flowSharedInputs || []).length
? `<label><span>${esc(tr('flow.sharedInputTitle'))}</span><select data-flow-config="input_id">${flowSelectOptions(app.flowSharedInputs || [], c.input_id)}</select></label><p class="field-note">${esc(tr('flow.sharedInputNodeHint'))}</p>${sharedFlowReferenceComparisonFields(item, c)}`
: `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputNodeEmptyHint'))}</span></div>`;
}
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.5" 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 === '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><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>`;
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><button type="button" class="danger" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
}
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>`;
}
function flowActionFields(c, group) {
const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat'];
const modeOptions = modes.map(v => `<option value="${v}" ${c.mode === v ? 'selected' : ''}>${esc(v === 'auto' ? 'Auto' : v === 'house' ? tr('flow.houseMode') : (tr(`mode.${v}`) || v))}</option>`).join('');
const base = `<div class="two"><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><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="">${esc(tr('actions.noChange'))}</option>${modeOptions}</select></label></div>`;
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.5" 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>`;
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>`;
}
function flowExpressionSummary(actionId) {
if (!app.flowDraft) return '';
const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node]));
const incoming = id => app.flowDraft.edges.filter(edge => edge.to === id).map(edge => edge.from);
const describe = (id, stack = new Set()) => {
if (stack.has(id)) return '…';
const node = byId.get(id); if (!node) return '';
const next = new Set(stack); next.add(id);
const parts = incoming(id).map(input => describe(input, next)).filter(Boolean);
if (node.kind === 'logic_and') return `(${parts.join(` ${tr('flow.and')} `)})`;
if (node.kind === 'logic_or') return `(${parts.join(` ${tr('flow.or')} `)})`;
if (node.kind === 'logic_not') return `${tr('flow.not')} (${parts[0] || '—'})`;
const own = flowNodeSummary(node);
return parts.length ? `(${parts.join(` ${tr('flow.and')} `)}) ${tr('flow.and')} ${own}` : own;
};
const roots = incoming(actionId).map(id => describe(id)).filter(Boolean);
return roots.length ? roots.join(` ${tr('flow.and')} `) : tr('flow.always');
}
function renderFlowInterpretation() {
const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return;
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category));
if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; }
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 = [];
while (stack.length) { const current = stack.pop(); app.flowDraft.edges.filter(edge => edge.to === current).forEach(edge => { if (seen.has(edge.from)) return; seen.add(edge.from); const node = byId.get(edge.from); if (node) { out.push(node); stack.push(node.id); } }); }
return out;
}
function addFlowNode(kind) {
if (!app.flowDraft || !FLOW_NODE_META[kind]) return;
const count = app.flowDraft.nodes.length;
const node = { id: newFlowId('node'), kind, x: 80 + (count % 5) * 190, y: 70 + Math.floor(count / 5) * 130, config: flowDefaultConfig(kind) };
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor();
}
function removeFlowNode(id) {
if (!app.flowDraft) return;
app.flowDraft.nodes = app.flowDraft.nodes.filter(node => node.id !== id); app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.from !== id && edge.to !== id);
if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; app.flowSelectedNodeIds = (app.flowSelectedNodeIds || []).filter(nodeId => nodeId !== id); if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor();
}
function connectFlowNodes(from, to) {
if (!app.flowDraft || !from || !to || from === to) return;
if (app.flowDraft.edges.some(edge => edge.from === from && edge.to === to)) return;
app.flowDraft.edges.push({ id: newFlowId('edge'), from, to }); app.flowDirty = true; app.flowConnectFrom = null; renderFlowEditor();
}
function removeFlowEdge(id) {
if (!app.flowDraft) return;
app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor();
}
function flowSourcePayload() {
if (!app.flowDraft) return null;
return {
name: ($('#flowName')?.value || app.flowDraft.name || '').trim(),
enabled: $('#flowEnabled')?.checked !== false,
draft: app.flowDraft.draft === true,
description: app.flowDraft.description || '',
nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [],
};
}
function downloadJsonFile(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const a = document.createElement('a');
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function exportFlow() {
if (!app.flowDraft) return;
try {
const envelope = app.flowDraft.id && !app.flowDirty
? await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/export`)
: { format: 'gree-controller-flow', version: 1, exported_at: new Date().toISOString(), flow: flowSourcePayload() };
const safe = (flowSourcePayload()?.name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow';
downloadJsonFile(`${safe}.flow.json`, envelope); toast(tr('flow.exported'));
} catch (error) { toast(error.message, true); }
}
async function importFlowFile(file) {
if (!file) return;
try {
const payload = JSON.parse(await file.text());
const saved = await api('/api/flows/import', { method: 'POST', body: payload });
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
renderFlows(); await loadBootstrap(); openFlowEditor(saved.id); toast(tr('flow.imported'));
} catch (error) { toast(`${tr('flow.importFailed')}: ${error.message}`, true); }
finally { const input = $('#flowImportFile'); if (input) input.value = ''; }
}
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced'];
const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites';
const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent';
let flowPresetLoadPromise = null;
let flowPresetActiveCategory = '';
let flowPresetPreviewId = '';
let flowPresetSearch = '';
function flowPresetStoredIds(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
} catch (_) { return []; }
}
function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); }
function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); }
function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} }
function toggleFlowPresetFavorite(id) {
const ids = flowPresetFavoriteIds();
if (ids.has(id)) ids.delete(id); else ids.add(id);
saveFlowPresetIds(FLOW_PRESET_FAVORITES_KEY, [...ids]);
renderFlowPresetBrowser(flowPresetActiveCategory);
}
function markFlowPresetRecent(id) {
const ids = flowPresetRecentIds().filter(value => value !== id);
ids.unshift(id);
saveFlowPresetIds(FLOW_PRESET_RECENT_KEY, ids.slice(0, 8));
}
function flowPresetText(value) {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return '';
return value[app.language] || value.en || value.pl || Object.values(value).find(item => typeof item === 'string') || '';
}
async function loadFlowPresets({ force = false } = {}) {
if (!force && Array.isArray(app.flowPresets)) return app.flowPresets;
if (!force && flowPresetLoadPromise) return flowPresetLoadPromise;
flowPresetLoadPromise = (async () => {
const indexResponse = await fetch(withBase('/presets/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!indexResponse.ok) throw new Error(`Presets HTTP ${indexResponse.status}`);
const manifest = await indexResponse.json();
const entries = Array.isArray(manifest?.presets) ? manifest.presets : [];
const presets = await Promise.all(entries.map(async entry => {
const filename = String(entry?.file || '');
if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error('Invalid preset filename');
const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`);
const preset = await response.json();
if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(`${filename}: invalid preset`);
return preset;
}));
app.flowPresets = presets;
return presets;
})();
try { return await flowPresetLoadPromise; }
finally { flowPresetLoadPromise = null; }
}
function materializeFlowPreset(preset) {
const zone1 = app.zones[0]?.id || '';
const variables = {
'$zone1': zone1,
'$zone2': app.zones[1]?.id || zone1,
'$device1': app.devices[0]?.id || '',
'$group1': app.groups[0]?.id || '',
'$shared1': app.flowSharedInputs?.[0]?.id || '',
};
const replaceValue = value => {
if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(variables, value)) return variables[value];
if (Array.isArray(value)) return value.map(replaceValue);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceValue(item)]));
return value;
};
const sourceNodes = Array.isArray(preset?.flow?.nodes) ? preset.flow.nodes : [];
const sourceEdges = Array.isArray(preset?.flow?.edges) ? preset.flow.edges : [];
const skipped = new Set();
const idMap = new Map();
const nodes = [];
sourceNodes.forEach(source => {
const originalConfig = source.config || {};
if (source.kind === 'group_state' && originalConfig.group_id === '$group1' && !variables.$group1) { skipped.add(source.id); return; }
const node = { ...source, id: newFlowId('node'), config: replaceValue(originalConfig) };
if (source.kind === 'group_action' && originalConfig.group_id === '$group1' && !variables.$group1) {
node.kind = 'zone_thermostat';
node.config = {
zone_id: zone1,
preset: node.config.preset || 'comfort',
setpoint: Number(node.config.setpoint ?? 21),
mode: ['heat','cool'].includes(node.config.mode) ? node.config.mode : 'auto',
cooldown_seconds: Number(node.config.cooldown_seconds || 60),
power: node.config.power ?? null,
};
}
idMap.set(source.id, node.id);
nodes.push(node);
});
const edges = sourceEdges
.filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to))
.map(edge => ({ id:newFlowId('edge'), from:idMap.get(edge.from), to:idMap.get(edge.to) }));
return { nodes, edges };
}
function flowPresetRequirements(preset) {
const nodes = preset?.flow?.nodes || [];
const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() };
const visit = value => {
if (typeof value === 'string') {
const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value);
if (match) placeholders[match[1]].add(value);
return;
}
if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === 'object') Object.values(value).forEach(visit);
};
nodes.forEach(node => visit(node.config || {}));
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available','ha_service_action'].includes(node.kind));
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
const requirements = [];
const missing = [];
const addCount = (kind, count, available, key) => {
if (!count) return;
const label = tr(key, { count });
requirements.push({ label, ok:available >= count });
if (available < count) missing.push(label);
};
addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone');
addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice');
addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup');
addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared');
if (hasHa) {
const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured);
requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready });
if (!ready) missing.push(tr('flow.templateRequiresHa'));
}
if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true });
return { requirements, missing };
}
function flowPresetPreviewGraph(preset) {
const nodes = preset?.flow?.nodes || [], edges = preset?.flow?.edges || [];
if (!nodes.length) return '';
const byId = new Map(nodes.map(node => [node.id, node]));
const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0));
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY);
const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 });
const lines = edges.map(edge => {
const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return '';
const a = point(from), b = point(to);
return `<line x1="${a.x.toFixed(2)}" y1="${a.y.toFixed(2)}" x2="${b.x.toFixed(2)}" y2="${b.y.toFixed(2)}"></line>`;
}).join('');
const blocks = nodes.map(node => {
const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' };
return `<div class="flow-template-preview-node flow-template-preview-node-${esc(meta.category)}" style="left:${p.x.toFixed(2)}%;top:${p.y.toFixed(2)}%" title="${esc(flowNodeTitle(meta))}">${esc(flowNodeTitle(meta))}</div>`;
}).join('');
return `<div class="flow-template-preview-canvas"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>${blocks}</div>`;
}
function renderFlowPresetPreview(preset) {
const host = $('#flowTemplatePreview'); if (!host) return;
if (!preset) {
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatePreview'))}</strong><span>${esc(tr('flow.templateSelectPreview'))}</span></div>`;
return;
}
const req = flowPresetRequirements(preset);
const favorite = flowPresetFavoriteIds().has(preset.id);
const reqMarkup = req.requirements.length
? req.requirements.map(item => `<span class="flow-template-requirement ${item.ok ? 'ok' : 'missing'} ${item.info ? 'info' : ''}">${item.ok ? '✓' : '!' } ${esc(item.label)}</span>`).join('')
: `<span class="flow-template-requirement ok">✓ ${esc(tr('flow.templateRequirementsReady'))}</span>`;
host.innerHTML = `<div class="flow-template-preview-head"><div><span class="eyebrow">${esc(tr('flow.templatePreview'))}</span><h3>${esc(flowPresetText(preset.name) || preset.id)}</h3></div><button type="button" class="flow-template-favorite ${favorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">${favorite ? '★' : '☆'}</button></div>
<p>${esc(flowPresetText(preset.description))}</p>
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div>
${flowPresetPreviewGraph(preset)}
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div>
<div class="form-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
}
function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); }
function flowPresetCategoryHint(key) { const name = `flow.templateCategory.${key}.hint`, value = tr(name); return value === name ? '' : value; }
function renderFlowPresetBrowser(category = '') {
const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList');
if (!tabs || !host) return;
const presets = Array.isArray(app.flowPresets) ? app.flowPresets : [];
const byCategory = new Map();
presets.forEach(preset => {
const key = preset.category || 'advanced';
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key).push(preset);
});
const favorites = flowPresetFavoriteIds();
const recent = flowPresetRecentIds();
byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id)));
byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean));
const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length);
const categories = ['favorites','recent', ...normalCategories];
if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; }
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]);
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(flowPresetCategoryLabel(key))} <span class="badge">${(byCategory.get(key) || []).length}</span></button>`).join('');
const query = flowPresetSearch.trim().toLocaleLowerCase(locale());
const activeAll = byCategory.get(flowPresetActiveCategory) || [];
const active = query ? activeAll.filter(preset => `${flowPresetText(preset.name)} ${flowPresetText(preset.description)} ${preset.id}`.toLocaleLowerCase(locale()).includes(query)) : activeAll;
const emptyKey = query ? 'flow.templateNoResults' : flowPresetActiveCategory === 'favorites' ? 'flow.templateNoFavorites' : flowPresetActiveCategory === 'recent' ? 'flow.templateNoRecent' : 'flow.templateNoResults';
const cards = active.map(preset => {
const isFavorite = favorites.has(preset.id), selected = preset.id === flowPresetPreviewId;
return `<article class="flow-template-card ${selected ? 'selected' : ''}"><button type="button" class="flow-template-card-main" data-flow-template-preview="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button><button type="button" class="flow-template-favorite ${isFavorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">${isFavorite ? '★' : '☆'}</button></article>`;
}).join('');
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(flowPresetCategoryLabel(flowPresetActiveCategory))}</strong><span>${esc(flowPresetCategoryHint(flowPresetActiveCategory))}</span></div>${cards ? `<div class="flow-template-category-grid">${cards}</div>` : `<div class="empty compact"><span>${esc(tr(emptyKey))}</span></div>`}</section>`;
if (!active.some(preset => preset.id === flowPresetPreviewId)) flowPresetPreviewId = active[0]?.id || '';
renderFlowPresetPreview(presets.find(preset => preset.id === flowPresetPreviewId) || null);
}
async function openFlowTemplates() {
if (!app.flowDraft) return;
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs'), search = $('#flowTemplateSearch');
if (!host) return;
if (tabs) tabs.innerHTML = '';
if (search) { search.value = ''; flowPresetSearch = ''; }
host.innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`;
renderFlowPresetPreview(null);
$('#flowTemplateDialog')?.showModal();
try {
await loadFlowPresets();
renderFlowPresetBrowser(flowPresetActiveCategory);
} catch (error) {
if (tabs) tabs.innerHTML = '';
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed') || 'Unable to load presets')}</strong><span>${esc(error.message)}</span></div>`;
}
}
function previewFlowTemplate(key) {
const preset = (app.flowPresets || []).find(item => item.id === key); if (!preset) return;
flowPresetPreviewId = preset.id;
renderFlowPresetBrowser(flowPresetActiveCategory);
}
function applyFlowTemplate(key) {
if (!app.flowDraft) return;
const preset = (app.flowPresets || []).find(item => item.id === key);
if (!preset) return toast(tr('flow.templatesLoadFailed') || 'Preset not found', true);
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
const graph = materializeFlowPreset(preset);
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id;
markFlowPresetRecent(preset.id);
$('#flowTemplateDialog')?.close(); renderFlowEditor();
}
function localDateTimeInputValue(date = new Date()) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function flowSimulationOverrideNodes() {
return (app.flowDraft?.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));
}
function renderFlowSimulationOverrides() {
const host = $('#flowSimulationOverrides'); if (!host) return;
const nodes = flowSimulationOverrideNodes();
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 numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind);
return `<label><span>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))} · ${esc(flowNodeSummary(node))}</span><input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}"></label>`;
}).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`;
}
function collectFlowSimulationOverrides() {
const result = {};
$$('[data-flow-sim-node]', $('#flowSimulationOverrides')).forEach(input => {
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 && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind)) value = Number(value);
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
result[input.dataset.flowSimNode] = value;
});
return result;
}
function openFlowDryRun() {
if (!app.flowDraft) return;
$('#flowTestTitle').textContent = tr('flow.dryRun'); $('#flowSimulationControls').hidden = false; $('#flowTestResults').innerHTML = '';
$('#flowSimulationAt').value = localDateTimeInputValue(); renderFlowSimulationOverrides(); $('#flowTestDialog')?.showModal();
}
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>${item.matched ? '✓' : '×'} ${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;
const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString();
$('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('flow.runningSimulation'))}</p>`;
try {
const result = await api('/api/flows/simulate', { method:'POST', body:{ flow:flowSourcePayload(), flow_id:app.flowDraft.id || null, at, overrides:collectFlowSimulationOverrides(), log:true } });
renderFlowDryRunResult(result);
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
}
function flowLogLevelLabel(level = '') {
const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()];
return key ? tr(key) : level;
}
function flowLogKindLabel(kind = '') {
const key = {
'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted',
'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError',
'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired',
'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone',
'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict',
}[kind];
return key ? tr(key) : kind;
}
function flowLogMessage(event = {}) {
const name = app.flowDraft?.name || tr('nav.flows');
const key = {
'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted',
'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError',
'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired',
'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone',
'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict',
}[event.kind];
return key ? tr(key, { name }) : (event.message || '—');
}
async function openFlowLogs() {
if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true);
$('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`; $('#flowTestDialog')?.showModal();
try {
const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`);
const events = result.events || [];
$('#flowTestResults').innerHTML = events.length ? events.map(event => `<article class="flow-log-row"><div><strong>${esc(flowLogKindLabel(event.kind))}</strong><span class="badge">${esc(flowLogLevelLabel(event.level))}</span></div><p>${esc(flowLogMessage(event))}</p><small>${esc(new Date(event.timestamp).toLocaleString())}</small></article>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.noLogs'))}</strong></div>`;
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
}
async function persistFlowDraft(body) {
return api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
}
async function applySavedFlow(saved, messageKey) {
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false;
$('#flowEnabled').checked = saved.enabled === true;
renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr(messageKey));
}
async function saveFlow() {
if (!app.flowDraft) return;
const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true);
const body = { name, enabled: $('#flowEnabled').checked, draft: false, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
try {
const saved = await persistFlowDraft(body);
await applySavedFlow(saved, 'flow.saved');
} catch (error) {
if (error.status !== 400) return toast(error.message, true);
const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason:error.message }));
if (!saveDraft) return toast(error.message, true);
try {
const saved = await persistFlowDraft({ ...body, enabled:false, draft:true });
await applySavedFlow(saved, 'flow.savedAsDraft');
} catch (draftError) { toast(draftError.message, true); }
}
}
async function deleteFlow(id) {
const flow = app.flows.find(item => item.id === id); if (!flow) return;
if (!confirm(tr('flow.deleteConfirm', { name: flow.name }))) return;
try { await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'DELETE' }); app.flows = app.flows.filter(item => item.id !== id); renderFlows(); await loadBootstrap(); toast(tr('flow.deleted')); } catch (error) { toast(error.message, true); }
}
async function toggleFlowEnabled(id, enabled) {
const flow = app.flows.find(item => item.id === id); if (!flow) return;
if (flow.draft) return toast(tr('flow.draftCannotEnable'), true);
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
if (toggle) toggle.disabled = true;
const body = { name:flow.name, enabled, draft:false, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
try {
const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body });
const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved;
renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled'));
} catch (error) { renderFlows(); toast(error.message, true); }
}
function setFlowSelection(ids, primary = null) {
if (!app.flowDraft) return;
const valid = new Set(app.flowDraft.nodes.map(node => node.id));
app.flowSelectedNodeIds = [...new Set((ids || []).filter(id => valid.has(id)))];
app.flowSelectedNodeId = primary && valid.has(primary) ? primary : (app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null);
renderFlowEditor();
}
function selectAllFlowNodes() {
if (!app.flowDraft) return;
const lastNode = app.flowDraft.nodes[app.flowDraft.nodes.length - 1];
setFlowSelection(app.flowDraft.nodes.map(node => node.id), app.flowSelectedNodeId || lastNode?.id || null);
}
function clearFlowSelection() { setFlowSelection([], null); }
function removeSelectedFlowNodes() {
if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return;
const selected = new Set(app.flowSelectedNodeIds);
app.flowDraft.nodes = app.flowDraft.nodes.filter(node => !selected.has(node.id));
app.flowDraft.edges = app.flowDraft.edges.filter(edge => !selected.has(edge.from) && !selected.has(edge.to));
if (app.flowConnectFrom && selected.has(app.flowConnectFrom)) app.flowConnectFrom = null;
app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor();
}
function updateFlowConfig(input) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node) return;
const key = input.dataset.flowConfig; if (!key) return;
if (key === 'days') node.config.days = $$('[data-flow-config="days"]', $('#flowInspector')).filter(el => el.checked).map(el => Number(el.value));
else if (node.kind === 'shared_input' && key === 'input_id') {
const item = (app.flowSharedInputs || []).find(value => value.id === input.value);
node.config = sharedFlowReferenceDefaultConfig(item);
}
else if (node.kind === 'shared_input' && key === 'operator') {
node.config.operator = input.value;
if (node.config.value == null) {
const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id);
node.config.value = sharedFlowReferenceComparisonDefaults(item).value ?? '';
}
}
else if (node.kind === 'shared_input' && key === 'value') {
const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id);
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item?.kind);
node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value;
}
else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
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));
else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1)));
else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action','ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
else node.config[key] = input.value;
app.flowDirty = true; renderFlowEditor();
}
let flowDrag = null;
document.addEventListener('pointerdown', event => {
if ($('#flowEditor')?.hidden) return;
const nodeEl = event.target.closest?.('[data-flow-node]');
if (!nodeEl) {
if (event.target.closest?.('#flowWorkspace') && !event.target.closest('button,input,select,label')) clearFlowSelection();
return;
}
if (event.target.closest('button,input,select,label')) return;
const node = flowNodeById(nodeEl.dataset.flowNode); if (!node) return;
const multiModifier = event.shiftKey || event.ctrlKey || event.metaKey;
let selected = [...(app.flowSelectedNodeIds || [])];
if (multiModifier) {
selected = selected.includes(node.id) ? selected.filter(id => id !== node.id) : [...selected, node.id];
if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; }
} else if (!selected.includes(node.id)) selected = [node.id];
app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor();
const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id:item.id, left:Number(item.x || 0), top:Number(item.y || 0) }));
flowDrag = { x:event.clientX, y:event.clientY, starts };
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
});
document.addEventListener('pointermove', event => {
if (!flowDrag || !app.flowDraft) return;
let dx = event.clientX - flowDrag.x, dy = event.clientY - flowDrag.y;
const minLeft = Math.min(...flowDrag.starts.map(item => item.left));
const minTop = Math.min(...flowDrag.starts.map(item => item.top));
dx = Math.max(dx, 12 - minLeft); dy = Math.max(dy, 12 - minTop);
flowDrag.starts.forEach(start => {
const node = flowNodeById(start.id); if (!node) return;
node.x = start.left + dx; node.y = start.top + dy;
const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; }
});
app.flowDirty = true; renderFlowEdges();
});
document.addEventListener('pointerup', () => { flowDrag = null; });
document.addEventListener('keydown', event => {
if ($('#flowEditor')?.hidden || !app.flowDraft) return;
if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; }
if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); }
});
document.addEventListener('click', event => {
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; }
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
const templateFavorite = event.target.closest?.('[data-flow-template-favorite]'); if (templateFavorite) { toggleFlowPresetFavorite(templateFavorite.dataset.flowTemplateFavorite); return; }
const templatePreview = event.target.closest?.('[data-flow-template-preview]'); if (templatePreview) { previewFlowTemplate(templatePreview.dataset.flowTemplatePreview); return; }
const templateUse = event.target.closest?.('[data-flow-template-use]'); if (templateUse) { applyFlowTemplate(templateUse.dataset.flowTemplateUse); return; }
const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; }
const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; }
const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
const node = event.target.closest?.('[data-flow-node]'); if (node) { if (!(app.flowSelectedNodeIds || []).includes(node.dataset.flowNode)) app.flowSelectedNodeIds = [node.dataset.flowNode]; app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; }
const actionButton = event.target.closest?.('[data-action]'); const action = actionButton?.dataset.action;
if (action === 'new-flow') openFlowEditor();
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true');
else if (action === 'save-flow') saveFlow();
else if (action === 'import-flow') $('#flowImportFile')?.click();
else if (action === 'export-flow') exportFlow();
else if (action === 'flow-templates') openFlowTemplates();
else if (action === 'flow-dry-run') openFlowDryRun();
else if (action === 'run-flow-dry-run') runFlowDryRun();
else if (action === 'flow-logs') openFlowLogs();
else if (action === 'flow-select-all') selectAllFlowNodes();
else if (action === 'flow-clear-selection') clearFlowSelection();
else if (action === 'flow-shared-input-settings') {
if (!showView('homeassistant')) return;
requestAnimationFrame(() => {
const target = $('#flowSharedInputsSettings');
if (!target) return;
target.scrollIntoView({ behavior:'smooth', block:'start' });
target.classList.add('is-linked-target');
setTimeout(() => target.classList.remove('is-linked-target'), 1800);
});
}
else if (action === 'close-flow-editor') closeFlowEditor();
});
document.addEventListener('change', event => {
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
});
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
$('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); });
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; });
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });