990 lines
76 KiB
JavaScript
990 lines
76 KiB
JavaScript
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' },
|
||
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' },
|
||
});
|
||
|
||
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 === '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 };
|
||
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 ? '' : 'is-disabled'}">
|
||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><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(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, 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 === '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')}`; }
|
||
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 `<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(); refreshFlowSharedInputCurrentValues();
|
||
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 `<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 === '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)}`;
|
||
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 => 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.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,
|
||
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'].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'))}">★</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'))}">★</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 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); }
|
||
}
|
||
|
||
async function toggleFlowEnabled(id, enabled) {
|
||
const flow = app.flows.find(item => item.id === id); if (!flow) return;
|
||
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, 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 (['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 => {
|
||
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(); });
|