v0.9.6
This commit is contained in:
@@ -176,6 +176,20 @@
|
||||
<div class="list-grid" id="groupList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="flows">
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow" data-i18n="flow.eyebrow">Visual logic</span>
|
||||
<h1 data-i18n="nav.flows">Flow</h1>
|
||||
</div><div class="section-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div>
|
||||
</div>
|
||||
<div class="panel flow-info-panel">
|
||||
<div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong>
|
||||
<p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.</p></div>
|
||||
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
|
||||
</div>
|
||||
<div class="list-grid" id="flowList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span>
|
||||
@@ -694,6 +708,53 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
|
||||
<header class="flow-editor-bar">
|
||||
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back">←</button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" placeholder="My automation"></div></div>
|
||||
<div class="flow-editor-actions"><button class="secondary" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span data-i18n="common.enabled">Enabled</span></label><span id="flowCompileStatus" class="badge"></span><button class="primary" type="button" data-action="save-flow" data-i18n="actions.save">Save</button></div>
|
||||
</header>
|
||||
<div class="flow-editor-body">
|
||||
<aside class="flow-palette">
|
||||
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.time">Time</span>
|
||||
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.sensors">Sensors</span>
|
||||
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.logic">Logic</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.actions">Actions</span>
|
||||
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
|
||||
</aside>
|
||||
<div class="flow-workspace-wrap">
|
||||
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
|
||||
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
|
||||
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
|
||||
</div>
|
||||
<div class="flow-natural-preview"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
|
||||
</div>
|
||||
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
|
||||
|
||||
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
|
||||
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
|
||||
<div id="flowTemplateList" class="flow-template-list"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="flowTestDialog" class="flow-tool-dialog">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div>
|
||||
<div id="flowSimulationControls" class="flow-simulation-controls">
|
||||
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p>
|
||||
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label>
|
||||
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div>
|
||||
<div class="dialog-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div>
|
||||
</div>
|
||||
<div id="flowTestResults" class="flow-test-results"></div>
|
||||
</dialog>
|
||||
|
||||
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
|
||||
<button class="active" data-nav="dashboard"><span>⌂</span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||
<button data-nav="devices"><span>▣</span><b data-i18n="nav.devices">Devices</b></button>
|
||||
@@ -708,6 +769,7 @@
|
||||
</div>
|
||||
<div class="menu-list">
|
||||
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span>›</span></button>
|
||||
<button data-go="flows"><span data-i18n="nav.flows">Flow</span><span>›</span></button>
|
||||
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
||||
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
||||
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.groups = data.groups || [];
|
||||
app.schedules = data.schedules || [];
|
||||
app.automations = data.automations || [];
|
||||
app.flows = data.flows || [];
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
|
||||
+2
-1
@@ -25,7 +25,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
@@ -34,6 +34,7 @@ const app = {
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
flowDraft: null, flowSelectedNodeId: null, flowConnectFrom: null, flowDirty: false,
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ function renderAll() {
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
renderAutomations();
|
||||
renderFlows();
|
||||
renderAccessTokens();
|
||||
fillSelects();
|
||||
renderSettings();
|
||||
@@ -65,6 +66,7 @@ function planEventMarkup(event) {
|
||||
|
||||
function automationTriggerLabel(item) {
|
||||
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
|
||||
if (item.trigger_kind === 'flow') return tr('flow.generated');
|
||||
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
|
||||
return tr(key, { temperature: fmtTemp(item.threshold) });
|
||||
}
|
||||
|
||||
+6
-5
@@ -455,20 +455,21 @@ function renderSchedules() {
|
||||
const days = item.weekdays.map(day => dayNames[day - 1]).join(', ');
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
function renderAutomations() {
|
||||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||||
const actionZone = item.action_zone_id ? app.zones.find(zone => zone.id === item.action_zone_id) : null;
|
||||
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
|
||||
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
|
||||
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
|
||||
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
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 => `<article class="list-card flow-card">
|
||||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><span class="badge ${flow.enabled ? 'active' : ''}">${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></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.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 `<article class="flow-node flow-node-${esc(meta.category)} ${app.flowSelectedNodeId === 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))}</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;
|
||||
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 `<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 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 === '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.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 => `<button type="button" class="flow-template-card" data-flow-template="${esc(key)}"><strong>${esc(tr(`flow.template.${key}.title`))}</strong><span>${esc(tr(`flow.template.${key}.description`))}</span></button>`).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 `<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();
|
||||
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 = `<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>`; }
|
||||
}
|
||||
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(event.kind)}</strong><span class="badge">${esc(event.level)}</span></div><p>${esc(event.message)}</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); }
|
||||
}
|
||||
|
||||
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(); });
|
||||
@@ -16,9 +16,10 @@ function setDashboardTab(tab, { scroll = true } = {}) {
|
||||
|
||||
function showView(name, { push = true, scroll = true } = {}) {
|
||||
if (!canLeaveCurrentView(name)) return false;
|
||||
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||
app.currentView = name;
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'flows', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
||||
if (push) updateBrowserUrl(pathForView(name));
|
||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
||||
|
||||
+5
-2
@@ -23,7 +23,8 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
|
||||
app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
@@ -33,6 +34,8 @@ function connectWebSocket() {
|
||||
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') {
|
||||
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
|
||||
app.settings = data;
|
||||
@@ -56,7 +59,7 @@ function connectWebSocket() {
|
||||
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
|
||||
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
|
||||
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
|
||||
} catch (_) { }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const VIEW_ROUTES = Object.freeze({
|
||||
groups: '/groups',
|
||||
schedules: '/schedules',
|
||||
automations: '/automations',
|
||||
flows: '/flows',
|
||||
simulation: '/simulation',
|
||||
night: '/night-mode',
|
||||
homeassistant: '/home-assistant',
|
||||
@@ -84,6 +85,10 @@ function applyRouteFromLocation() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === 'flows') {
|
||||
if (parts[1]) requestAnimationFrame(() => openFlowEditor(parts[1] === 'new' ? '' : parts[1], { push: false }));
|
||||
else if (!$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||
}
|
||||
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
|
||||
}
|
||||
|
||||
|
||||
+104
-1
@@ -6122,4 +6122,107 @@ textarea[aria-invalid="true"] {
|
||||
.compressor-queue-reason {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Visual Flow editor */
|
||||
.flow-info-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; }
|
||||
.flow-info-panel p { margin:.35rem 0 0; color:var(--muted); max-width:780px; }
|
||||
.flow-card .card-footer small { max-width:70%; }
|
||||
body.flow-editor-open { overflow:hidden; }
|
||||
.flow-editor { position:fixed; inset:0; z-index:80; background:var(--bg); display:flex; flex-direction:column; }
|
||||
.flow-editor[hidden] { display:none !important; }
|
||||
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
|
||||
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
|
||||
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
|
||||
.flow-editor-title input { border:0; background:transparent; color:var(--text); font-weight:750; font-size:1.12rem; padding:2px 0; min-width:280px; outline:none; }
|
||||
.flow-editor-actions { display:flex; align-items:center; gap:10px; }
|
||||
.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); }
|
||||
.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; }
|
||||
.flow-palette,.flow-inspector { background:var(--surface); overflow:auto; padding:16px; }
|
||||
.flow-palette { border-right:1px solid var(--line); }
|
||||
.flow-inspector { border-left:1px solid var(--line); }
|
||||
.flow-palette-head { display:grid; gap:4px; margin-bottom:18px; }
|
||||
.flow-palette-head small { color:var(--muted); line-height:1.35; }
|
||||
.flow-palette-group { display:grid; gap:7px; margin:0 0 18px; }
|
||||
.flow-palette-group > span { color:var(--muted); font-size:.72rem; font-weight:750; text-transform:uppercase; letter-spacing:.08em; margin-bottom:2px; }
|
||||
.flow-palette-group button { text-align:left; border:1px solid var(--line); background:var(--surface-2); color:var(--text); border-radius:10px; padding:9px 10px; }
|
||||
.flow-palette-group button:hover { border-color:var(--accent-border); background:var(--accent-soft); }
|
||||
.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:minmax(0,1fr) auto; background:var(--canvas); }
|
||||
.flow-workspace { position:relative; min-height:0; overflow:auto; outline:none; background-color:var(--canvas); background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px); background-size:24px 24px; }
|
||||
.flow-nodes { position:relative; width:2400px; height:1500px; }
|
||||
.flow-edges { position:absolute; inset:0; width:2400px; height:1500px; overflow:visible; pointer-events:none; z-index:1; }
|
||||
.flow-edges path { fill:none; stroke:var(--muted-2); stroke-width:2.5; pointer-events:stroke; cursor:pointer; }
|
||||
.flow-edges path:hover { stroke:var(--danger); stroke-width:4; }
|
||||
.flow-editor .flow-node { position:absolute; z-index:2; display:block; align-content:normal; gap:0; width:170px; min-height:78px; padding:0; border:1px solid var(--line-strong); border-radius:12px; background:var(--surface); box-shadow:0 10px 28px rgba(0,0,0,.18); user-select:none; }
|
||||
.flow-editor .flow-node.selected { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft),0 12px 30px rgba(0,0,0,.22); }
|
||||
.flow-editor .flow-node-time { border-top:3px solid var(--blue); }
|
||||
.flow-editor .flow-node-sensor { border-top:3px solid var(--teal); }
|
||||
.flow-editor .flow-node-logic { border-top:3px solid var(--purple); }
|
||||
.flow-editor .flow-node-action { border-top:3px solid var(--orange); }
|
||||
.flow-editor .flow-node-head { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 9px 6px; font-size:.82rem; font-weight:750; cursor:grab; }
|
||||
.flow-editor .flow-node-head button { border:0; background:transparent; color:var(--muted); padding:0 2px; font-size:1rem; }
|
||||
.flow-editor .flow-node-body { padding:5px 10px 10px; color:var(--muted); font-size:.76rem; line-height:1.35; overflow-wrap:anywhere; }
|
||||
.flow-editor .flow-port { position:absolute; top:50%; width:14px; height:14px; margin-top:-7px; border-radius:50%; border:2px solid var(--surface); background:var(--muted-2); padding:0; z-index:4; }
|
||||
.flow-editor .flow-port-in { left:-8px; }
|
||||
.flow-editor .flow-port-out { right:-8px; }
|
||||
.flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); }
|
||||
.flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; }
|
||||
.flow-natural-preview { display:grid; grid-template-columns:auto 1fr; gap:14px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); }
|
||||
.flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||
.flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; }
|
||||
.flow-inspector-head h3 { margin:2px 0 0; }
|
||||
.flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; }
|
||||
.flow-inspector input,.flow-inspector select { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:9px 10px; }
|
||||
.flow-inspector .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.flow-day-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; }
|
||||
.flow-day-grid label { display:flex; align-items:center; gap:7px; padding:8px; border:1px solid var(--line); border-radius:9px; margin:0; }
|
||||
.flow-day-grid input { width:auto; }
|
||||
.flow-inspector-meta { display:grid; gap:5px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); }
|
||||
.flow-inspector-meta code { font-size:.72rem; overflow-wrap:anywhere; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.flow-editor-body { grid-template-columns:180px minmax(360px,1fr) 260px; }
|
||||
.flow-editor-title input { min-width:180px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.flow-editor-bar { align-items:flex-start; flex-direction:column; }
|
||||
.flow-editor-actions { width:100%; justify-content:flex-end; }
|
||||
.flow-editor-body { grid-template-columns:132px minmax(500px,1fr); }
|
||||
.flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); }
|
||||
.flow-palette { padding:10px; }
|
||||
.flow-palette-group button { font-size:.76rem; padding:8px; }
|
||||
.flow-natural-preview { grid-template-columns:1fr; gap:4px; }
|
||||
}
|
||||
|
||||
/* Flow diagnostics, templates and portable Flow files */
|
||||
.flow-tool-dialog { width:min(860px,calc(100vw - 32px)); max-height:min(820px,calc(100vh - 32px)); overflow:auto; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); padding:18px; box-shadow:0 24px 70px rgba(0,0,0,.32); }
|
||||
.flow-tool-dialog::backdrop { background:rgba(0,0,0,.48); }
|
||||
.flow-template-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; margin-top:16px; }
|
||||
.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; }
|
||||
.flow-template-card:hover { border-color:var(--accent); }
|
||||
.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||
.flow-simulation-controls { display:grid; gap:14px; padding:4px 0 16px; border-bottom:1px solid var(--line); }
|
||||
.flow-simulation-overrides { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:8px; }
|
||||
.flow-test-results { display:grid; gap:10px; margin-top:16px; }
|
||||
.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; }
|
||||
.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; }
|
||||
.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||
.flow-test-action.pass { border-left:4px solid var(--green); }
|
||||
.flow-test-action.blocked { border-left:4px solid var(--line-strong); }
|
||||
.flow-trace { display:grid; gap:5px; margin-top:10px; }
|
||||
.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; }
|
||||
.flow-trace code { color:var(--muted); max-width:48%; overflow-wrap:anywhere; text-align:right; }
|
||||
.flow-log-row p { margin:8px 0 4px; }
|
||||
.flow-log-row small { color:var(--muted); }
|
||||
@media (max-width:760px) { .flow-test-summary { align-items:flex-start; flex-direction:column; } }
|
||||
.flow-editor-actions { flex-wrap:wrap; justify-content:flex-end; }
|
||||
.flow-editor-actions .secondary { padding-inline:10px; }
|
||||
@media (max-width:1100px) {
|
||||
.flow-editor-bar { align-items:flex-start; }
|
||||
.flow-editor-actions { max-width:70%; }
|
||||
}
|
||||
@media (max-width:760px) {
|
||||
.flow-editor-actions { max-width:none; justify-content:flex-start; }
|
||||
}
|
||||
.flow-device-options { margin:10px 0; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
||||
.flow-device-options summary { cursor:pointer; font-weight:650; }
|
||||
.flow-device-options[open] summary { margin-bottom:10px; }
|
||||
|
||||
Reference in New Issue
Block a user