v0.9.7
This commit is contained in:
+51
-58
@@ -180,14 +180,17 @@
|
||||
<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="section-actions flow-page-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 class="flow-list-section">
|
||||
<div class="flow-list-heading"><div><span class="eyebrow" data-i18n="flow.listEyebrow">Twoje automatyzacje</span><h2 data-i18n="flow.listTitle">Lista Flow</h2></div><span id="flowListCount" class="badge"></span></div>
|
||||
<div class="list-grid" id="flowList"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
@@ -370,67 +373,45 @@
|
||||
tokens for the Home Assistant integration.</p>
|
||||
<form class="settings-form standalone-settings-form" id="homeAssistantForm">
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head">
|
||||
<div>
|
||||
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
|
||||
<p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant
|
||||
temperature sensor.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haConnection">Połączenie z Home Assistant</h3><p data-i18n="settings.haConnectionHint">Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.</p></div></div>
|
||||
<div class="settings-grid">
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url"
|
||||
placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input
|
||||
type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep"
|
||||
placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input
|
||||
name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input
|
||||
name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age
|
||||
(min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1"
|
||||
value="5"></label>
|
||||
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated
|
||||
within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when
|
||||
available.</p>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span
|
||||
data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span
|
||||
data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted
|
||||
local Home Assistant server, for example https://192.168.50.25.</p>
|
||||
<div class="wide sensor-alias-manager">
|
||||
<div class="settings-subhead">
|
||||
<div><strong data-i18n="settings.sensorAliases">Sensor aliases</strong><small
|
||||
data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original
|
||||
entity_id remains unchanged.</small></div>
|
||||
</div>
|
||||
<div id="sensorAliasList" class="sensor-alias-list"></div>
|
||||
<div class="sensor-alias-add"><input id="sensorAliasEntity"
|
||||
placeholder="sensor.gabinet_temperature"><input id="sensorAliasName"
|
||||
data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button"
|
||||
class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
|
||||
</div>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest"
|
||||
data-i18n="settings.testHa">Test HA</button></div>
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server.</p>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head">
|
||||
<div>
|
||||
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
|
||||
<p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller
|
||||
integration in Home Assistant.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-manager">
|
||||
<div id="accessTokenList" class="token-list"></div>
|
||||
<div class="form-actions"><button type="button" class="primary" id="createAccessToken"
|
||||
data-i18n="settings.newToken">Create new token</button></div>
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haThermostatSources">Źródła temperatury sterowania</h3><p data-i18n="settings.haThermostatSourcesHint">Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.</p></div></div>
|
||||
<div class="settings-grid">
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" value="5"></label>
|
||||
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated within this time, the reading is treated as stale.</p>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor
|
||||
changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
|
||||
<section class="panel settings-block flow-shared-settings">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj stałe i warunki czujników raz, a potem używaj ich w wielu Flow.</p></div><button type="button" class="primary" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
|
||||
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]"><div id="flowSharedInputList" class="flow-shared-input-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.sensorAliases">Sensor aliases</h3><p data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original entity_id remains unchanged.</p></div></div>
|
||||
<div class="sensor-alias-manager">
|
||||
<div id="sensorAliasList" class="sensor-alias-list"></div>
|
||||
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
|
||||
<div class="token-manager"><div id="accessTokenList" class="token-list"></div><div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div></div>
|
||||
</section>
|
||||
<div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -720,12 +701,13 @@
|
||||
<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>
|
||||
<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><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</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 class="flow-workspace-toolbar"><button type="button" class="secondary" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div>
|
||||
<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>
|
||||
@@ -738,6 +720,17 @@
|
||||
|
||||
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
|
||||
|
||||
<dialog id="flowSharedInputDialog" class="flow-tool-dialog">
|
||||
<form id="flowSharedInputForm" class="dialog-form">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div>
|
||||
<input type="hidden" name="id">
|
||||
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label>
|
||||
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label>
|
||||
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
|
||||
<div class="dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<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>
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
|
||||
app.system = data.system || {};
|
||||
app.systemSnapshotAt = Date.now();
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
|
||||
+2
-2
@@ -32,9 +32,9 @@ const app = {
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
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: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [],
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
flowDraft: null, flowSelectedNodeId: null, flowConnectFrom: null, flowDirty: false,
|
||||
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false,
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
|
||||
+187
-42
@@ -15,6 +15,7 @@ const FLOW_NODE_META = Object.freeze({
|
||||
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' },
|
||||
@@ -45,6 +46,7 @@ function flowDefaultConfig(kind) {
|
||||
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 { input_id: app.flowSharedInputs?.[0]?.id || '' };
|
||||
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 };
|
||||
@@ -53,13 +55,15 @@ function flowDefaultConfig(kind) {
|
||||
|
||||
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>
|
||||
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><label class="flow-quick-toggle" title="${esc(tr('flow.quickToggleHint'))}"><input type="checkbox" data-flow-toggle="${esc(flow.id)}" ${flow.enabled ? 'checked' : ''}><span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></label></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: [],
|
||||
@@ -70,7 +74,7 @@ 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;
|
||||
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;
|
||||
@@ -82,7 +86,7 @@ function openFlowEditor(id = '', { push = true } = {}) {
|
||||
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;
|
||||
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false;
|
||||
if (push) updateBrowserUrl('/flows');
|
||||
return true;
|
||||
}
|
||||
@@ -105,6 +109,7 @@ function flowNodeSummary(node) {
|
||||
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); return item ? `${item.name} · ${sharedFlowInputSummary(item)}` : tr('flow.sharedInputMissing'); }
|
||||
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');
|
||||
@@ -123,7 +128,7 @@ function renderFlowEditor() {
|
||||
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">
|
||||
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))}</div>
|
||||
@@ -131,6 +136,7 @@ function renderFlowEditor() {
|
||||
</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();
|
||||
const status = $('#flowCompileStatus');
|
||||
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
||||
@@ -176,6 +182,7 @@ function renderFlowInspector() {
|
||||
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') 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>` : `<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>`;
|
||||
@@ -238,12 +245,12 @@ 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();
|
||||
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; if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor();
|
||||
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;
|
||||
@@ -296,47 +303,115 @@ async function importFlowFile(file) {
|
||||
}
|
||||
|
||||
function flowTemplateGraph(key) {
|
||||
const zone = app.zones[0]?.id || '', device = app.devices[0]?.id || '', group = app.groups[0]?.id || '';
|
||||
const zone = app.zones[0]?.id || '', zone2 = app.zones[1]?.id || zone, 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`;
|
||||
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 });
|
||||
const links = (items, target) => items.forEach(item => link(item, target));
|
||||
const thermostat = (x, y, config = {}) => add('zone_thermostat', x, y, { zone_id:zone, preset:'comfort', setpoint:21, mode:'auto', cooldown_seconds:90, ...config });
|
||||
const groupOrZone = (x, y, config = {}) => group
|
||||
? add('group_action', x, y, { group_id:group, power:null, mode:'auto', preset:'comfort', setpoint:21, cooldown_seconds:120, ...config })
|
||||
: thermostat(x, y, config);
|
||||
const 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);
|
||||
const days=add('weekday',40,70), time=add('time_range',250,70,{start:'06:00',end:'08:30'}), action=thermostat(500,70); 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);
|
||||
const days=add('weekday',35,40), time=add('time_range',35,155,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',35,270,{operator:'lt',value:12}), and=add('logic_and',300,155), action=thermostat(555,155); links([days,time,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);
|
||||
const time=add('time_range',30,40,{start:'05:30',end:'23:00'}), outside=add('outdoor_temperature',30,160,{operator:'lt',value:10}), room=add('zone_temperature',30,280,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',280,220), mode=add('house_mode',280,70,{operator:'neq',value:'off'}), and=add('logic_and',510,155), action=thermostat(760,155); links([outside,room],or); links([time,mode,or],and); link(and,action);
|
||||
} else if (key === 'morning_boost') {
|
||||
const days=add('weekday',35,50), time=add('time_range',35,170,{start:'05:30',end:'07:30'}), cold=add('outdoor_temperature',35,290,{operator:'lt',value:5}), heat=add('house_mode',35,410,{operator:'eq',value:'heat'}), and=add('logic_and',320,220), action=thermostat(585,220,{preset:'custom',setpoint:22.5,mode:'heat',cooldown_seconds:180}); links([days,time,cold,heat],and); link(and,action);
|
||||
} else if (key === 'weekend_comfort') {
|
||||
const days=add('weekday',40,90,{days:[6,7]}), time=add('time_range',250,90,{start:'08:00',end:'23:00'}), mode=add('house_mode',40,220,{operator:'neq',value:'off'}), and=add('logic_and',465,145), action=thermostat(710,145); links([days,time,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);
|
||||
const home=add('ha_state',45,75,{entity_id:'person.someone',operator:'eq',value:'home'}), away=add('ha_state',45,245,{entity_id:'person.someone',operator:'eq',value:'not_home'}), comfort=thermostat(360,65), eco=thermostat(360,245,{preset:'away',setpoint:18,cooldown_seconds:180}); link(home,comfort); link(away,eco);
|
||||
} else if (key === 'energy_price_eco') {
|
||||
const price=add('ha_numeric',40,70,{entity_id:'sensor.energy_price',operator:'gt',value:0.8}), occupied=add('ha_state',40,190,{entity_id:'person.someone',operator:'neq',value:'home'}), and=add('logic_and',315,130), action=thermostat(570,130,{preset:'away',setpoint:18,cooldown_seconds:300}); links([price,occupied],and); link(and,action);
|
||||
} else if (key === 'peak_power_guard') {
|
||||
const power=add('ha_numeric',40,75,{entity_id:'sensor.house_power',operator:'gt',value:5000}), mode=add('house_mode',40,200,{operator:'neq',value:'off'}), and=add('logic_and',315,140), action=thermostat(570,140,{power:false,preset:'auto',cooldown_seconds:180}); links([power,mode],and); link(and,action);
|
||||
} else if (key === 'mild_weather_eco') {
|
||||
const low=add('outdoor_temperature',35,55,{operator:'gte',value:17}), high=add('outdoor_temperature',35,175,{operator:'lte',value:24}), window=add('ha_state',35,295,{entity_id:'binary_sensor.window',operator:'eq',value:'off'}), and=add('logic_and',310,175), action=thermostat(565,175,{power:false,preset:'auto',cooldown_seconds:300}); links([low,high,window],and); link(and,action);
|
||||
} else if (key === 'unoccupied_shutdown') {
|
||||
const away=add('ha_state',35,70,{entity_id:'person.someone',operator:'eq',value:'not_home'}), time=add('time_range',35,190,{start:'09:00',end:'16:00'}), and=add('logic_and',310,130), action=group ? add('group_action',565,130,{group_id:group,power:false,mode:'',preset:'',setpoint:21,cooldown_seconds:300}) : thermostat(565,130,{power:false,preset:'away',cooldown_seconds:300}); links([away,time],and); link(and,action);
|
||||
} 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);
|
||||
const heat=add('house_mode',35,50,{operator:'eq',value:'heat'}), outside=add('outdoor_temperature',35,170,{operator:'lt',value:3}), room=add('zone_temperature',35,290,{zone_id:zone,operator:'lt',value:9}), and=add('logic_and',315,170), action=thermostat(575,170,{preset:'custom',setpoint:12,mode:'heat',cooldown_seconds:300}); links([heat,outside,room],and); link(and,action);
|
||||
} else if (key === 'ha_window_guard') {
|
||||
const open=add('ha_state',45,90,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), mode=add('house_mode',45,220,{operator:'neq',value:'off'}), and=add('logic_and',305,155), action=thermostat(560,155,{power:false,preset:'auto',cooldown_seconds:60}); links([open,mode],and); link(and,action);
|
||||
} else if (key === 'humidity_guard') {
|
||||
const humidity=add('ha_numeric',40,60,{entity_id:'sensor.living_room_humidity',operator:'gt',value:70}), cool=add('house_mode',40,180,{operator:'eq',value:'cool'}), occupied=add('ha_state',40,300,{entity_id:'person.someone',operator:'eq',value:'home'}), and=add('logic_and',315,180), action=thermostat(575,180,{preset:'custom',setpoint:22,mode:'cool',cooldown_seconds:180}); links([humidity,cool,occupied],and); link(and,action);
|
||||
} else if (key === 'overheat_guard') {
|
||||
const room=add('zone_temperature',35,65,{zone_id:zone,operator:'gt',value:28}), cool=add('house_mode',35,185,{operator:'eq',value:'cool'}), window=add('ha_state',35,305,{entity_id:'binary_sensor.window',operator:'eq',value:'off'}), and=add('logic_and',310,185), action=thermostat(570,185,{preset:'custom',setpoint:22,mode:'cool',cooldown_seconds:120}); links([room,cool,window],and); link(and,action);
|
||||
} else if (key === 'window_available_guard') {
|
||||
const available=add('ha_available',35,80,{entity_id:'binary_sensor.window'}), open=add('ha_state',35,210,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), and=add('logic_and',310,145), action=thermostat(565,145,{power:false,preset:'auto',cooldown_seconds:60}); links([available,open],and); link(and,action);
|
||||
} else if (key === 'night_group') {
|
||||
const time=add('time_range',50,100,{start:'22:30',end:'06:00'}), mode=add('house_mode',50,230,{operator:'neq',value:'off'}), and=add('logic_and',315,165), action=groupOrZone(570,165,{power:true,preset:'sleep',setpoint:20,cooldown_seconds:120}); links([time,mode],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);
|
||||
const night=add('night_mode',45,90), and=add('logic_and',310,155), action=groupOrZone(565,155,{preset:'sleep',setpoint:20,cooldown_seconds:180}); link(night,and); if (group) { const on=add('group_state',45,225,{group_id:group,field:'power_enabled',operator:'eq',value:'true'}); link(on,and); } link(and,action);
|
||||
} else if (key === 'sleep_temperature_guard') {
|
||||
const night=add('night_mode',35,55), room=add('zone_temperature',35,175,{zone_id:zone,operator:'gt',value:23.5}), cool=add('house_mode',35,295,{operator:'eq',value:'cool'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'sleep',setpoint:20,mode:'cool',cooldown_seconds:180}); links([night,room,cool],and); link(and,action);
|
||||
} else if (key === 'bedroom_window_night') {
|
||||
const night=add('night_mode',35,65), open=add('ha_state',35,185,{entity_id:'binary_sensor.bedroom_window',operator:'eq',value:'on'}), not=add('logic_not',275,185), and=add('logic_and',500,125), action=thermostat(745,125,{preset:'sleep',setpoint:20,cooldown_seconds:180}); link(open,not); links([night,not],and); link(and,action);
|
||||
} else if (key === 'device_resilience') {
|
||||
const online=add('device_state',40,55,{device_id:device,field:'online',operator:'eq',value:'true'}), enabled=add('device_state',40,175,{device_id:device,field:'enabled',operator:'eq',value:'true'}), mode=add('house_mode',40,295,{operator:'neq',value:'off'}), and=add('logic_and',315,175), action=thermostat(570,175,{preset:'auto'}); links([online,enabled,mode],and); link(and,action);
|
||||
} else if (key === 'sensor_availability_guard') {
|
||||
const available=add('ha_available',35,55,{entity_id:'sensor.room_temperature'}), temp=add('ha_numeric',35,175,{entity_id:'sensor.room_temperature',operator:'gt',value:26}), cool=add('house_mode',35,295,{operator:'eq',value:'cool'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'comfort',mode:'cool',cooldown_seconds:120}); links([available,temp,cool],and); link(and,action);
|
||||
} else if (key === 'offline_safe_off') {
|
||||
const offline=add('device_state',35,85,{device_id:device,field:'online',operator:'eq',value:'false'}), enabled=add('zone_state',35,215,{zone_id:zone,field:'enabled',operator:'eq',value:'true'}), and=add('logic_and',310,150), action=thermostat(565,150,{power:false,preset:'auto',cooldown_seconds:300}); links([offline,enabled],and); link(and,action);
|
||||
} else if (key === 'thermostat_enabled_guard') {
|
||||
const enabled=add('zone_state',35,55,{zone_id:zone,field:'enabled',operator:'eq',value:'true'}), demand=add('zone_state',35,175,{zone_id:zone,field:'demand',operator:'eq',value:'true'}), mode=add('house_mode',35,295,{operator:'neq',value:'off'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'auto',cooldown_seconds:120}); links([enabled,demand,mode],and); link(and,action);
|
||||
} else if (key === 'nested_guard') {
|
||||
const days=add('weekday',25,35), time=add('time_range',25,145,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',25,255,{operator:'lt',value:8}), room=add('zone_temperature',25,365,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',275,310), available=add('ha_available',275,430,{entity_id:'binary_sensor.window'}), window=add('ha_state',275,540,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',495,540), and=add('logic_and',510,235), action=thermostat(770,235); links([outside,room],or); link(window,not); links([days,time,or,available,not],and); link(and,action);
|
||||
} else if (key === 'occupancy_weather_matrix') {
|
||||
const home=add('ha_state',25,40,{entity_id:'person.someone',operator:'eq',value:'home'}), outside=add('outdoor_temperature',25,155,{operator:'lt',value:9}), room=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:20}), need=add('logic_or',275,215), window=add('ha_state',275,345,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',495,345), and=add('logic_and',505,150), action=thermostat(765,150); links([outside,room],need); link(window,not); links([home,need,not],and); link(and,action);
|
||||
} else if (key === 'dual_threshold_control') {
|
||||
const cold=add('zone_temperature',35,70,{zone_id:zone,operator:'lt',value:19}), hot=add('zone_temperature',35,245,{zone_id:zone,operator:'gt',value:25}), heat=add('house_mode',250,70,{operator:'eq',value:'heat'}), cool=add('house_mode',250,245,{operator:'eq',value:'cool'}), andHeat=add('logic_and',470,70), andCool=add('logic_and',470,245), heatAction=thermostat(715,70,{preset:'comfort',mode:'heat'}), coolAction=thermostat(715,245,{preset:'comfort',mode:'cool'}); links([cold,heat],andHeat); links([hot,cool],andCool); link(andHeat,heatAction); link(andCool,coolAction);
|
||||
} else if (key === 'multi_room_group_guard') {
|
||||
const roomA=add('zone_temperature',30,55,{zone_id:zone,operator:'gt',value:25}), roomB=add('zone_temperature',30,175,{zone_id:zone2,operator:'gt',value:25}), need=add('logic_or',280,115), cool=add('house_mode',280,245,{operator:'eq',value:'cool'}), and=add('logic_and',505,175), action=groupOrZone(760,175,{power:true,preset:'comfort',setpoint:22,cooldown_seconds:120}); links([roomA,roomB],need); links([need,cool],and); link(and,action);
|
||||
} else if (key === 'ha_attribute_mode_guard') {
|
||||
const running=add('ha_attribute',35,60,{entity_id:'climate.living_room',attribute:'hvac_action',operator:'neq',value:'idle'}), window=add('ha_state',35,180,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), available=add('ha_available',35,300,{entity_id:'climate.living_room'}), and=add('logic_and',315,180), action=thermostat(575,180,{power:false,preset:'auto',cooldown_seconds:60}); links([running,window,available],and); link(and,action);
|
||||
} else if (key === 'ha_gas_heating_off') {
|
||||
const available=add('ha_available',35,70,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',35,200,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), and=add('logic_and',315,135), action=thermostat(575,135,{power:false,preset:'auto',cooldown_seconds:120}); links([available,heating],and); link(and,action);
|
||||
} else if (key === 'ha_gas_heating_boost') {
|
||||
const available=add('ha_available',25,35,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',25,145,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), cold=add('zone_temperature',25,255,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,365,{operator:'eq',value:'heat'}), and=add('logic_and',320,200), action=thermostat(590,200,{power:true,preset:'custom',setpoint:23.5,mode:'heat',cooldown_seconds:180}); links([available,heating,cold,heat],and); link(and,action);
|
||||
} else if (key === 'ha_gas_heating_reduce') {
|
||||
const available=add('ha_available',35,50,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',35,165,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), warm=add('zone_temperature',35,280,{zone_id:zone,operator:'gte',value:21}), and=add('logic_and',315,165), action=thermostat(575,165,{power:true,preset:'custom',setpoint:18,mode:'heat',cooldown_seconds:180}); links([available,heating,warm],and); link(and,action);
|
||||
} else if (key === 'ha_gas_backup_heat') {
|
||||
const available=add('ha_available',25,40,{entity_id:'climate.gas_boiler'}), idle=add('ha_attribute',25,155,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'neq',value:'heating'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:19}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:22,mode:'heat',cooldown_seconds:180}); links([available,idle,cold,heat],and); link(and,action);
|
||||
} else if (key === 'ha_external_heat_source_off') {
|
||||
const available=add('ha_available',35,70,{entity_id:'binary_sensor.external_heat_source'}), active=add('ha_state',35,200,{entity_id:'binary_sensor.external_heat_source',operator:'eq',value:'on'}), and=add('logic_and',315,135), action=groupOrZone(575,135,{power:false,preset:'auto',cooldown_seconds:120}); links([available,active],and); link(and,action);
|
||||
} else if (key === 'ha_external_heat_source_assist') {
|
||||
const available=add('ha_available',25,40,{entity_id:'binary_sensor.external_heat_source'}), active=add('ha_state',25,155,{entity_id:'binary_sensor.external_heat_source',operator:'eq',value:'on'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:23,mode:'heat',cooldown_seconds:180}); links([available,active,cold,heat],and); link(and,action);
|
||||
} else if (key === 'ha_heating_demand_follow') {
|
||||
const available=add('ha_available',35,60,{entity_id:'binary_sensor.heating_demand'}), demand=add('ha_state',35,180,{entity_id:'binary_sensor.heating_demand',operator:'eq',value:'on'}), heat=add('house_mode',35,300,{operator:'eq',value:'heat'}), and=add('logic_and',315,180), action=thermostat(575,180,{power:true,preset:'custom',setpoint:22.5,mode:'heat',cooldown_seconds:120}); links([available,demand,heat],and); link(and,action);
|
||||
} else if (key === 'ha_boiler_supply_boost') {
|
||||
const available=add('ha_available',25,45,{entity_id:'sensor.boiler_supply_temperature'}), supply=add('ha_numeric',25,160,{entity_id:'sensor.boiler_supply_temperature',operator:'gt',value:45}), cold=add('zone_temperature',25,275,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,390,{operator:'eq',value:'heat'}), and=add('logic_and',320,215), action=thermostat(590,215,{power:true,preset:'custom',setpoint:23.5,mode:'heat',cooldown_seconds:180}); links([available,supply,cold,heat],and); link(and,action);
|
||||
} else if (key === 'ha_thermostat_idle_fallback') {
|
||||
const available=add('ha_available',25,40,{entity_id:'climate.gas_boiler'}), idle=add('ha_attribute',25,155,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'idle'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:19}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:21.5,mode:'heat',cooldown_seconds:240}); links([available,idle,cold,heat],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'];
|
||||
const FLOW_TEMPLATE_CATEGORIES = [
|
||||
['comfort', ['workday_comfort','weather_comfort','smart_demand','morning_boost','weekend_comfort']],
|
||||
['energy', ['presence_eco','energy_price_eco','peak_power_guard','mild_weather_eco','unoccupied_shutdown']],
|
||||
['safety', ['frost_guard','ha_window_guard','humidity_guard','overheat_guard','window_available_guard']],
|
||||
['night', ['night_group','night_quiet','sleep_temperature_guard','bedroom_window_night']],
|
||||
['reliability', ['device_resilience','sensor_availability_guard','offline_safe_off','thermostat_enabled_guard']],
|
||||
['home_assistant', ['ha_gas_heating_off','ha_gas_heating_boost','ha_gas_heating_reduce','ha_gas_backup_heat','ha_external_heat_source_off','ha_external_heat_source_assist','ha_heating_demand_follow','ha_boiler_supply_boost','ha_thermostat_idle_fallback']],
|
||||
['advanced', ['nested_guard','occupancy_weather_matrix','dual_threshold_control','multi_room_group_guard','ha_attribute_mode_guard']],
|
||||
];
|
||||
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('');
|
||||
host.innerHTML = FLOW_TEMPLATE_CATEGORIES.map(([category, keys]) => `<section class="flow-template-category"><div class="flow-template-category-head"><strong>${esc(tr(`flow.templateCategory.${category}`))}</strong><span>${esc(tr(`flow.templateCategory.${category}.hint`))}</span></div><div class="flow-template-category-grid">${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('')}</div></section>`).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;
|
||||
app.flowDraft.description = tr(template.descriptionKey); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
|
||||
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = tr(template.titleKey);
|
||||
$('#flowTemplateDialog')?.close(); renderFlowEditor();
|
||||
}
|
||||
@@ -346,13 +421,14 @@ function localDateTimeInputValue(date = new Date()) {
|
||||
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));
|
||||
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 numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind);
|
||||
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>`;
|
||||
}
|
||||
@@ -361,7 +437,8 @@ function collectFlowSimulationOverrides() {
|
||||
$$('[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);
|
||||
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;
|
||||
});
|
||||
@@ -414,6 +491,43 @@ async function deleteFlow(id) {
|
||||
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-flow-toggle="${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;
|
||||
@@ -427,21 +541,47 @@ function updateFlowConfig(input) {
|
||||
|
||||
let flowDrag = null;
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if ($('#flowEditor')?.hidden) return;
|
||||
const nodeEl = event.target.closest?.('[data-flow-node]');
|
||||
if (!nodeEl || event.target.closest('button,input,select,label')) return;
|
||||
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;
|
||||
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) };
|
||||
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;
|
||||
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(); }
|
||||
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; }
|
||||
@@ -449,11 +589,11 @@ document.addEventListener('click', event => {
|
||||
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;
|
||||
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(event.target.closest('[data-action]').dataset.id);
|
||||
else if (action === 'delete-flow') deleteFlow(event.target.closest('[data-action]').dataset.id);
|
||||
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
|
||||
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
|
||||
else if (action === 'save-flow') saveFlow();
|
||||
else if (action === 'import-flow') $('#flowImportFile')?.click();
|
||||
else if (action === 'export-flow') exportFlow();
|
||||
@@ -461,9 +601,14 @@ document.addEventListener('click', event => {
|
||||
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 === 'close-flow-editor') closeFlowEditor();
|
||||
});
|
||||
document.addEventListener('change', event => { if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); });
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
|
||||
const toggle = event.target.closest?.('[data-flow-toggle]'); if (toggle) toggleFlowEnabled(toggle.dataset.flowToggle, toggle.checked);
|
||||
});
|
||||
$('#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; });
|
||||
|
||||
@@ -230,6 +230,7 @@ function restoreTrackedForm(form) {
|
||||
else if (form.id === 'nightModeForm') renderNightSettings();
|
||||
else if (form.id === 'homeAssistantForm') {
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
|
||||
renderHomeAssistantSettings();
|
||||
} else markFormClean(form);
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ function connectWebSocket() {
|
||||
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.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;
|
||||
app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); 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(); }
|
||||
@@ -39,7 +39,7 @@ function connectWebSocket() {
|
||||
else if (message.event === 'settings.updated') {
|
||||
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
|
||||
app.settings = data;
|
||||
if (!haDirty) app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
if (!haDirty) { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); }
|
||||
if (!settingsDirty) renderSettings();
|
||||
if (!nightDirty) renderNightSettings();
|
||||
if (!haDirty) renderHomeAssistantSettings();
|
||||
|
||||
@@ -14,6 +14,7 @@ function knownHaEntities() {
|
||||
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||
app.settings?.home_assistant?.default_entity_id,
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
...(app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean),
|
||||
...app.historyData.sensors.map(row => row.entity_id),
|
||||
].filter(Boolean))].sort();
|
||||
}
|
||||
@@ -24,6 +25,89 @@ function renderSensorAliases() {
|
||||
host.innerHTML = entities.length ? entities.map(entity => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
|
||||
}
|
||||
|
||||
function flowSharedInputKinds() {
|
||||
return [
|
||||
['constant', 'flow.node.constant'], ['ha_state', 'flow.node.haState'], ['ha_numeric', 'flow.node.haNumeric'],
|
||||
['ha_attribute', 'flow.node.haAttribute'], ['ha_available', 'flow.node.haAvailable'],
|
||||
['outdoor_temperature', 'flow.node.outdoorTemperature'], ['device_temperature', 'flow.node.deviceTemperature'],
|
||||
['zone_temperature', 'flow.node.zoneTemperature'], ['house_mode', 'flow.node.houseMode'],
|
||||
['device_state', 'flow.node.deviceState'], ['zone_state', 'flow.node.zoneState'],
|
||||
['group_state', 'flow.node.groupState'], ['night_mode', 'flow.node.nightMode'],
|
||||
];
|
||||
}
|
||||
|
||||
function sharedFlowInputSummary(item) {
|
||||
if (!item) return '—';
|
||||
try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; }
|
||||
}
|
||||
|
||||
function renderFlowSharedInputs() {
|
||||
const host = $('#flowSharedInputList'); if (!host) return;
|
||||
const items = app.flowSharedInputs || [];
|
||||
host.innerHTML = items.length ? items.map(item => `<div class="flow-shared-input-row">
|
||||
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code></div>
|
||||
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
|
||||
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
|
||||
const revision = $('#flowSharedInputsRevision');
|
||||
if (revision) revision.value = JSON.stringify(items);
|
||||
}
|
||||
|
||||
function sharedFlowOptions(items, selected, label = item => item.name) {
|
||||
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
function sharedFlowOperatorOptions(selected = 'eq') {
|
||||
return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
||||
}
|
||||
|
||||
function renderFlowSharedInputFields(kind, config = {}) {
|
||||
const host = $('#flowSharedInputFields'); if (!host) return;
|
||||
const c = config || {};
|
||||
const comparison = (temperature = false) => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-shared-config="value" value="${Number(c.value ?? 0)}"></label></div>`;
|
||||
const textComparison = () => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-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.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
|
||||
let fields = '';
|
||||
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-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 (kind === 'ha_state') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>${textComparison()}`;
|
||||
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>${comparison(false)}`;
|
||||
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'eq')}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
|
||||
else if (kind === 'ha_available') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
|
||||
else if (kind === 'outdoor_temperature') fields = comparison(true);
|
||||
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>${comparison(true)}`;
|
||||
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>${comparison(true)}`;
|
||||
else if (kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-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-shared-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 (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-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>${textComparison()}`;
|
||||
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-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>${textComparison()}`;
|
||||
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
|
||||
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
host.innerHTML = fields;
|
||||
}
|
||||
|
||||
function openFlowSharedInputEditor(id = '') {
|
||||
const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return;
|
||||
const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null;
|
||||
const kindSelect = form.kind;
|
||||
kindSelect.innerHTML = flowSharedInputKinds().map(([kind,key]) => `<option value="${kind}">${esc(tr(key))}</option>`).join('');
|
||||
form.id.value = item?.id || '';
|
||||
form.name.value = item?.name || '';
|
||||
form.kind.value = item?.kind || 'constant';
|
||||
form.dataset.editingId = item?.id || '';
|
||||
renderFlowSharedInputFields(form.kind.value, item?.config || flowDefaultConfig(form.kind.value));
|
||||
dialog.showModal();
|
||||
setTimeout(() => form.name.focus(), 0);
|
||||
}
|
||||
|
||||
function collectFlowSharedInputConfig(kind) {
|
||||
const config = {};
|
||||
$$('[data-shared-config]', $('#flowSharedInputFields')).forEach(field => {
|
||||
const key = field.dataset.sharedConfig;
|
||||
let value = field.value;
|
||||
if (kind === 'constant' && key === 'value') value = value === 'true';
|
||||
else if (field.type === 'number') value = Number(value);
|
||||
config[key] = value;
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
function renderLogRetention() {
|
||||
const select = $('#logRetentionDays'); if (!select || !app.settings) return;
|
||||
[...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; });
|
||||
@@ -176,6 +260,7 @@ function renderHomeAssistantSettings() {
|
||||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
renderSensorAliases();
|
||||
renderFlowSharedInputs();
|
||||
renderAccessTokens();
|
||||
markFormClean(form);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ function currentSettingsBody() {
|
||||
sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300),
|
||||
allow_invalid_tls: !!ha.allow_invalid_tls,
|
||||
sensor_aliases: { ...(ha.sensor_aliases || {}) },
|
||||
flow_inputs: JSON.parse(JSON.stringify(ha.flow_inputs || [])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -140,6 +141,7 @@ function homeAssistantSettingsBodyFromForm(form) {
|
||||
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
|
||||
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
|
||||
sensor_aliases: { ...(app.sensorAliases || {}) },
|
||||
flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])),
|
||||
};
|
||||
return body;
|
||||
}
|
||||
@@ -147,6 +149,7 @@ function homeAssistantSettingsBodyFromForm(form) {
|
||||
async function saveRuntimeSettings(body, notify = true) {
|
||||
app.settings = await api('/api/settings', { method: 'PUT', body });
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
|
||||
renderSettings();
|
||||
renderNightSettings();
|
||||
renderHomeAssistantSettings();
|
||||
@@ -154,6 +157,7 @@ async function saveRuntimeSettings(body, notify = true) {
|
||||
renderSimulationModeBanner();
|
||||
renderSystemInfo();
|
||||
renderDebugOverlay();
|
||||
if (app.flowDraft) renderFlowEditor();
|
||||
scheduleControlPlanLoad();
|
||||
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
|
||||
if (notify) toast(tr('common.saved'));
|
||||
@@ -291,3 +295,37 @@ document.addEventListener('change', event => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
$('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEditor());
|
||||
$('#flowSharedInputKind')?.addEventListener('change', event => {
|
||||
renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value));
|
||||
});
|
||||
$('#flowSharedInputForm')?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = form.name.value.trim(), kind = form.kind.value;
|
||||
if (!name) return toast(tr('flow.sharedInputNameRequired'), true);
|
||||
const editingId = form.dataset.editingId || '';
|
||||
const id = editingId || newFlowId('shared');
|
||||
const item = { id, name, kind, config: collectFlowSharedInputConfig(kind) };
|
||||
const index = app.flowSharedInputs.findIndex(value => value.id === id);
|
||||
if (index >= 0) app.flowSharedInputs[index] = item; else app.flowSharedInputs.push(item);
|
||||
renderFlowSharedInputs();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
$('#flowSharedInputDialog')?.close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const edit = event.target.closest?.('[data-flow-shared-edit]');
|
||||
if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; }
|
||||
const remove = event.target.closest?.('[data-flow-shared-delete]');
|
||||
if (!remove) return;
|
||||
const id = remove.dataset.flowSharedDelete;
|
||||
const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return;
|
||||
const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0);
|
||||
const message = uses ? tr('flow.sharedInputDeleteUsed', { name:item.name, count:uses }) : tr('flow.sharedInputDeleteConfirm', { name:item.name });
|
||||
if (!confirm(message)) return;
|
||||
app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id);
|
||||
renderFlowSharedInputs();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
});
|
||||
|
||||
+34
-1
@@ -6146,7 +6146,7 @@ body.flow-editor-open { overflow:hidden; }
|
||||
.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-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:auto 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; }
|
||||
@@ -6226,3 +6226,36 @@ body.flow-editor-open { overflow:hidden; }
|
||||
.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; }
|
||||
|
||||
/* Flow list, multi-select and reusable inputs */
|
||||
.flow-page-actions { display:flex; align-items:center; gap:10px; }
|
||||
.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); }
|
||||
.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; }
|
||||
.flow-list-heading h2 { margin:3px 0 0; }
|
||||
.flow-card.is-disabled { opacity:.72; }
|
||||
.flow-card.is-disabled:hover { opacity:.9; }
|
||||
.flow-quick-toggle { display:flex; align-items:center; gap:7px; padding:7px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); font-size:.78rem; cursor:pointer; white-space:nowrap; }
|
||||
.flow-quick-toggle input { margin:0; }
|
||||
.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); }
|
||||
.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; }
|
||||
.flow-workspace-toolbar .badge { margin-left:2px; }
|
||||
.flow-shared-input-list { display:grid; gap:10px; }
|
||||
.flow-shared-input-row { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 14px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
||||
.flow-shared-input-row > div:first-child { min-width:0; display:grid; gap:4px; }
|
||||
.flow-shared-input-row small,.flow-shared-input-row code { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.flow-shared-input-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||
.flow-shared-input-fields { display:grid; gap:12px; }
|
||||
.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; }
|
||||
.flow-template-category { display:grid; gap:10px; }
|
||||
.flow-template-category + .flow-template-category { padding-top:18px; border-top:1px solid var(--line); }
|
||||
.flow-template-category-head { display:grid; gap:3px; }
|
||||
.flow-template-category-head span { color:var(--muted); font-size:.84rem; }
|
||||
.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; }
|
||||
@media (max-width:760px) {
|
||||
.flow-list-heading { align-items:flex-start; }
|
||||
.flow-shared-input-row { align-items:flex-start; flex-direction:column; }
|
||||
.flow-shared-input-actions { width:100%; }
|
||||
.flow-shared-input-actions button { flex:1; }
|
||||
.flow-shared-input-fields .two { grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user