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' },
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' },
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' },
});
function newFlowId(prefix = 'node') {
if (crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
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 === '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 === '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 };
return {};
}
function renderFlows() {
const host = $('#flowList'); if (!host) return;
host.innerHTML = app.flows.length ? app.flows.map(flow => `
${esc(flow.name)}
${esc(flow.description || tr('flow.defaultDescription'))}
${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}
${esc(tr('flow.blocks'))}${flow.nodes?.length || 0}
${esc(tr('nav.schedules'))}${flow.compiled_schedule_ids?.length || 0}
${esc(tr('nav.automations'))}${flow.compiled_automation_ids?.length || 0}
`).join('') : `
${esc(tr('flow.emptyTitle'))}${esc(tr('flow.emptyText'))}
`;
}
function flowDraftFrom(flow) {
return flow ? JSON.parse(JSON.stringify(flow)) : {
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, 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.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();
if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`);
}
function closeFlowEditor({ push = true, force = false } = {}) {
if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false;
$('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open');
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowConnectFrom = null; app.flowDirty = false;
if (push) updateBrowserUrl('/flows');
return true;
}
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 === '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 === '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')}`; }
return '';
}
function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); }
function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; }
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 `
${esc(flowNodeTitle(meta))}
${esc(flowNodeSummary(node))}
`;
}).join('');
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation();
const status = $('#flowCompileStatus');
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
}
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 ``;
}).join('');
}
function flowSelectOptions(items, selected, nameFn = item => item.name) {
return items.map(item => ``).join('');
}
function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); }
function renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
if (!node) { host.innerHTML = `${esc(tr('flow.selectBlock'))}${esc(tr('flow.selectBlockHint'))}
`; return; }
const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind };
let fields = '';
if (node.kind === 'weekday') fields = `${[1,2,3,4,5,6,7].map(day => ``).join('')}
`;
else if (node.kind === 'time_range') fields = ``;
else if (node.kind === 'date_range') fields = ``;
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
else if (node.kind === 'device_temperature') fields = `${flowComparisonFields(c)}`;
else if (node.kind === 'zone_temperature') fields = `${flowComparisonFields(c)}`;
else if (node.kind === 'ha_state') fields = ``;
else if (node.kind === 'ha_numeric') fields = `${flowComparisonFields(c, false)}`;
else if (node.kind === 'ha_attribute') fields = `${flowTextComparisonFields(c, true)}`;
else if (node.kind === 'ha_available') fields = `${esc(tr('flow.haAvailableHint'))}
`;
else if (node.kind === 'house_mode') fields = ``;
else if (node.kind === 'device_state') fields = `${flowTextComparisonFields(c)}`;
else if (node.kind === 'zone_state') fields = `${flowTextComparisonFields(c)}`;
else if (node.kind === 'group_state') fields = `${flowTextComparisonFields(c)}`;
else if (node.kind === 'night_mode') fields = `${esc(tr('flow.nightModeHint'))}
`;
else if (node.kind === 'constant') fields = ``;
else if (node.kind === 'logic_and') fields = `${esc(tr('flow.andHint'))}
`;
else if (node.kind === 'logic_or') fields = `${esc(tr('flow.orHint'))}
`;
else if (node.kind === 'logic_not') fields = `${esc(tr('flow.notHint'))}
`;
else if (node.kind === 'zone_thermostat') fields = ``;
else if (node.kind === 'device_action') fields = `${flowActionFields(c, false)}`;
else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`;
host.innerHTML = `${esc(tr('flow.blockSettings'))}
${esc(flowNodeTitle(meta))}
${fields}ID${esc(node.id)}
`;
}
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); return ``; }
function flowComparisonFields(c, temperature = true) { return ``; }
function flowOptionalBoolField(c, key) {
return ``;
}
function flowActionFields(c, group) {
const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat'];
const modeOptions = modes.map(v => ``).join('');
const base = ``;
const target = group
? ``
: ``;
const deviceOptions = group ? '' : `${esc(tr('flow.deviceOptions'))}
${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')}
${esc(tr('flow.deviceOptionsHint'))}
`;
return `${base}${target}${deviceOptions}`;
}
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 => FLOW_NODE_META[node.kind]?.category === 'action');
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 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.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; 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,
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 = ''; }
}
function flowTemplateGraph(key) {
const zone = app.zones[0]?.id || '', device = app.devices[0]?.id || '', group = app.groups[0]?.id || '';
const nodes = [], edges = [];
const add = (kind, x, y, config = flowDefaultConfig(kind)) => { const node = { id: newFlowId('node'), kind, x, y, config: { ...config } }; nodes.push(node); return node; };
const link = (a, b) => edges.push({ id: newFlowId('edge'), from: a.id, to: b.id });
let titleKey = `flow.template.${key}.title`, descriptionKey = `flow.template.${key}.description`;
if (key === 'workday_comfort') {
const days=add('weekday',50,80), time=add('time_range',250,80,{start:'06:00',end:'08:30'}), action=add('zone_thermostat',500,80,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,time); link(time,action);
} else if (key === 'weather_comfort') {
const days=add('weekday',40,50), time=add('time_range',40,170,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',40,290,{operator:'lt',value:12}), and=add('logic_and',290,160), action=add('zone_thermostat',520,160,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,and); link(time,and); link(outside,and); link(and,action);
} else if (key === 'smart_demand') {
const time=add('time_range',35,65,{start:'05:30',end:'23:00'}), outside=add('outdoor_temperature',35,185,{operator:'lt',value:10}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',280,245), mode=add('house_mode',280,80,{operator:'neq',value:'off'}), and=add('logic_and',500,165), action=add('zone_thermostat',735,165,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(time,and); link(mode,and); link(or,and); link(and,action);
} else if (key === 'device_resilience') {
const online=add('device_state',45,70,{device_id:device,field:'online',operator:'eq',value:'true'}), enabled=add('device_state',45,190,{device_id:device,field:'enabled',operator:'eq',value:'true'}), mode=add('house_mode',45,310,{operator:'neq',value:'off'}), and=add('logic_and',315,190), action=add('zone_thermostat',570,190,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',cooldown_seconds:60}); link(online,and); link(enabled,and); link(mode,and); link(and,action);
} else if (key === 'night_group') {
const time=add('time_range',60,120,{start:'22:30',end:'06:00'}), mode=add('house_mode',60,250,{operator:'neq',value:'off'}), and=add('logic_and',310,180), action=group ? add('group_action',560,180,{group_id:group,power:true,mode:'auto',preset:'sleep',cooldown_seconds:120}) : add('zone_thermostat',560,180,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:120}); link(time,and); link(mode,and); link(and,action);
} else if (key === 'ha_window_guard') {
const open=add('ha_state',45,120,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), mode=add('house_mode',45,250,{operator:'neq',value:'off'}), and=add('logic_and',300,180), action=add('zone_thermostat',555,180,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',power:false,cooldown_seconds:60}); link(open,and); link(mode,and); link(and,action);
} else if (key === 'presence_eco') {
const home=add('ha_state',45,80,{entity_id:'person.someone',operator:'eq',value:'home'}), away=add('ha_state',45,250,{entity_id:'person.someone',operator:'eq',value:'not_home'}), comfort=add('zone_thermostat',355,70,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:120}), eco=add('zone_thermostat',355,250,{zone_id:zone,preset:'away',setpoint:18,mode:'auto',cooldown_seconds:120}); link(home,comfort); link(away,eco);
} else if (key === 'frost_guard') {
const heat=add('house_mode',35,55,{operator:'eq',value:'heat'}), outside=add('outdoor_temperature',35,180,{operator:'lt',value:3}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:9}), and=add('logic_and',315,180), action=add('zone_thermostat',575,180,{zone_id:zone,preset:'custom',setpoint:12,mode:'heat',cooldown_seconds:300}); link(heat,and); link(outside,and); link(room,and); link(and,action);
} else if (key === 'nested_guard') {
const days=add('weekday',30,40), time=add('time_range',30,150,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',30,270,{operator:'lt',value:8}), room=add('zone_temperature',30,390,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',285,330), available=add('ha_available',285,450,{entity_id:'binary_sensor.window'}), window=add('ha_state',285,560,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',500,560), and=add('logic_and',520,245), action=add('zone_thermostat',780,245,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(window,not); link(days,and); link(time,and); link(or,and); link(available,and); link(not,and); link(and,action);
} else if (key === 'night_quiet') {
const night=add('night_mode',50,100), and=add('logic_and',315,165), action=group ? add('group_action',565,165,{group_id:group,power:null,mode:'auto',preset:'sleep',cooldown_seconds:180}) : add('zone_thermostat',565,165,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:180}); link(night,and); if (group) { const groupOn=add('group_state',50,235,{group_id:group,field:'power_enabled',operator:'eq',value:'true'}); link(groupOn,and); } link(and,action);
}
return { titleKey, descriptionKey, nodes, edges };
}
const FLOW_TEMPLATE_KEYS = ['workday_comfort','weather_comfort','smart_demand','device_resilience','night_group','ha_window_guard','presence_eco','frost_guard','nested_guard','night_quiet'];
function openFlowTemplates() {
if (!app.flowDraft) return;
const host = $('#flowTemplateList');
host.innerHTML = FLOW_TEMPLATE_KEYS.map(key => ``).join('');
$('#flowTemplateDialog')?.showModal();
}
function applyFlowTemplate(key) {
if (!app.flowDraft) return;
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
const template = flowTemplateGraph(key); app.flowDraft.nodes = template.nodes; app.flowDraft.edges = template.edges;
app.flowDraft.description = tr(template.descriptionKey); app.flowSelectedNodeId = null; app.flowDirty = true;
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = tr(template.titleKey);
$('#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'].includes(node.kind));
}
function renderFlowSimulationOverrides() {
const host = $('#flowSimulationOverrides'); if (!host) return;
const nodes = flowSimulationOverrideNodes();
host.innerHTML = nodes.length ? nodes.map(node => {
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind);
return ``;
}).join('') : `${esc(tr('flow.noSimulationOverrides'))}
`;
}
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();
if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind)) 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 = `${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules:0, automations:0 }))}
${(result.actions || []).map(action => `${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}
` : ''}${(action.trace || []).map(item => `
${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`;
}
async function runFlowDryRun() {
if (!app.flowDraft) return;
const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString();
$('#flowTestResults').innerHTML = `${esc(tr('flow.runningSimulation'))}
`;
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 = `${esc(tr('flow.simulationFailed'))}${esc(error.message)}
`; }
}
async function openFlowLogs() {
if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true);
$('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `${esc(tr('common.loading'))}
`; $('#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 => `${esc(event.kind)}${esc(event.level)}
${esc(event.message)}
${esc(new Date(event.timestamp).toLocaleString())}`).join('') : `${esc(tr('flow.noLogs'))}
`;
} catch (error) { $('#flowTestResults').innerHTML = `${esc(tr('flow.logsFailed'))}${esc(error.message)}
`; }
}
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, 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 api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
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; renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr('flow.saved'));
} catch (error) { toast(error.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); }
}
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 (['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 (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_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 => {
const nodeEl = event.target.closest?.('[data-flow-node]');
if (!nodeEl || event.target.closest('button,input,select,label')) return;
const node = flowNodeById(nodeEl.dataset.flowNode); if (!node) return;
app.flowSelectedNodeId = node.id; renderFlowInspector();
flowDrag = { id: node.id, x: event.clientX, y: event.clientY, left: Number(node.x || 0), top: Number(node.y || 0) };
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
});
document.addEventListener('pointermove', event => {
if (!flowDrag || !app.flowDraft) return;
const node = flowNodeById(flowDrag.id); if (!node) return;
node.x = Math.max(12, flowDrag.left + event.clientX - flowDrag.x); node.y = Math.max(12, flowDrag.top + event.clientY - flowDrag.y); app.flowDirty = true;
const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; renderFlowEdges(); }
});
document.addEventListener('pointerup', () => { flowDrag = null; });
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 template = event.target.closest?.('[data-flow-template]'); if (template) { applyFlowTemplate(template.dataset.flowTemplate); 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) { app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; }
const action = event.target.closest?.('[data-action]')?.dataset.action;
if (action === 'new-flow') openFlowEditor();
else if (action === 'edit-flow') openFlowEditor(event.target.closest('[data-action]').dataset.id);
else if (action === 'delete-flow') deleteFlow(event.target.closest('[data-action]').dataset.id);
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 === '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]));
$('#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(); });