This commit is contained in:
Mateusz Gruszczyński
2026-09-02 08:50:51 +02:00
parent a161d8785d
commit 9c6e51fc35
52 changed files with 3763 additions and 285 deletions
+6 -4
View File
@@ -184,7 +184,8 @@
</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>
<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>
<button type="button" class="secondary flow-shared-settings-link" data-action="flow-shared-input-settings" data-i18n="flow.sharedInputsSettingsLink">Wspólne wejścia Flow w Home Assistant →</button></div>
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
</div>
<section class="flow-list-section">
@@ -394,8 +395,8 @@
</div>
</section>
<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>
<section class="panel settings-block flow-shared-settings" id="flowSharedInputsSettings">
<div class="settings-block-head flow-shared-settings-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 flow-shared-add-button" 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>
@@ -727,13 +728,14 @@
<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>
<div class="dialog-actions flow-shared-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>
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
</dialog>
+124 -102
View File
@@ -302,117 +302,128 @@ async function importFlowFile(file) {
finally { const input = $('#flowImportFile'); if (input) input.value = ''; }
}
function flowTemplateGraph(key) {
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 });
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`;
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced'];
let flowPresetLoadPromise = null;
let flowPresetActiveCategory = '';
if (key === 'workday_comfort') {
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',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',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,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,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',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 };
function flowPresetText(value) {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return '';
return value[app.language] || value.en || value.pl || Object.values(value).find(item => typeof item === 'string') || '';
}
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() {
async function loadFlowPresets({ force = false } = {}) {
if (!force && Array.isArray(app.flowPresets)) return app.flowPresets;
if (!force && flowPresetLoadPromise) return flowPresetLoadPromise;
flowPresetLoadPromise = (async () => {
const indexResponse = await fetch(withBase('/presets/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!indexResponse.ok) throw new Error(`Presets HTTP ${indexResponse.status}`);
const manifest = await indexResponse.json();
const entries = Array.isArray(manifest?.presets) ? manifest.presets : [];
const presets = await Promise.all(entries.map(async entry => {
const filename = String(entry?.file || '');
if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error('Invalid preset filename');
const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`);
const preset = await response.json();
if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(`${filename}: invalid preset`);
return preset;
}));
app.flowPresets = presets;
return presets;
})();
try { return await flowPresetLoadPromise; }
finally { flowPresetLoadPromise = null; }
}
function materializeFlowPreset(preset) {
const zone1 = app.zones[0]?.id || '';
const variables = {
'$zone1': zone1,
'$zone2': app.zones[1]?.id || zone1,
'$device1': app.devices[0]?.id || '',
'$group1': app.groups[0]?.id || '',
'$shared1': app.flowSharedInputs?.[0]?.id || '',
};
const replaceValue = value => {
if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(variables, value)) return variables[value];
if (Array.isArray(value)) return value.map(replaceValue);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceValue(item)]));
return value;
};
const sourceNodes = Array.isArray(preset?.flow?.nodes) ? preset.flow.nodes : [];
const sourceEdges = Array.isArray(preset?.flow?.edges) ? preset.flow.edges : [];
const skipped = new Set();
const idMap = new Map();
const nodes = [];
sourceNodes.forEach(source => {
const originalConfig = source.config || {};
if (source.kind === 'group_state' && originalConfig.group_id === '$group1' && !variables.$group1) { skipped.add(source.id); return; }
const node = { ...source, id: newFlowId('node'), config: replaceValue(originalConfig) };
if (source.kind === 'group_action' && originalConfig.group_id === '$group1' && !variables.$group1) {
node.kind = 'zone_thermostat';
node.config = {
zone_id: zone1,
preset: node.config.preset || 'comfort',
setpoint: Number(node.config.setpoint ?? 21),
mode: ['heat','cool'].includes(node.config.mode) ? node.config.mode : 'auto',
cooldown_seconds: Number(node.config.cooldown_seconds || 60),
power: node.config.power ?? null,
};
}
idMap.set(source.id, node.id);
nodes.push(node);
});
const edges = sourceEdges
.filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to))
.map(edge => ({ id:newFlowId('edge'), from:idMap.get(edge.from), to:idMap.get(edge.to) }));
return { nodes, edges };
}
function renderFlowPresetBrowser(category = '') {
const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList');
if (!tabs || !host) return;
const presets = Array.isArray(app.flowPresets) ? app.flowPresets : [];
const byCategory = new Map();
presets.forEach(preset => {
const key = preset.category || 'advanced';
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key).push(preset);
});
const categories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...byCategory.keys()])].filter(key => byCategory.has(key));
if (!categories.length) { tabs.innerHTML = ''; host.innerHTML = ''; return; }
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : categories[0]);
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(tr(`flow.templateCategory.${key}`) || key)} <span class="badge">${byCategory.get(key).length}</span></button>`).join('');
const active = byCategory.get(flowPresetActiveCategory) || [];
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}`) || flowPresetActiveCategory)}</strong><span>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}.hint`) || '')}</span></div><div class="flow-template-category-grid">${active.map(preset => `<button type="button" class="flow-template-card" data-flow-template="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button>`).join('')}</div></section>`;
}
async function openFlowTemplates() {
if (!app.flowDraft) return;
const host = $('#flowTemplateList');
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('');
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs');
if (!host) return;
if (tabs) tabs.innerHTML = '';
host.innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`;
$('#flowTemplateDialog')?.showModal();
try {
await loadFlowPresets();
renderFlowPresetBrowser(flowPresetActiveCategory);
} catch (error) {
if (tabs) tabs.innerHTML = '';
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed') || 'Unable to load presets')}</strong><span>${esc(error.message)}</span></div>`;
}
}
function applyFlowTemplate(key) {
if (!app.flowDraft) return;
const preset = (app.flowPresets || []).find(item => item.id === key);
if (!preset) return toast(tr('flow.templatesLoadFailed') || 'Preset not found', true);
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
const template = flowTemplateGraph(key); app.flowDraft.nodes = template.nodes; app.flowDraft.edges = template.edges;
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);
const graph = materializeFlowPreset(preset);
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id;
$('#flowTemplateDialog')?.close(); renderFlowEditor();
}
@@ -585,6 +596,7 @@ document.addEventListener('keydown', event => {
document.addEventListener('click', event => {
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; }
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
const 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; }
@@ -603,6 +615,16 @@ document.addEventListener('click', event => {
else if (action === 'flow-logs') openFlowLogs();
else if (action === 'flow-select-all') selectAllFlowNodes();
else if (action === 'flow-clear-selection') clearFlowSelection();
else if (action === 'flow-shared-input-settings') {
if (!showView('homeassistant')) return;
requestAnimationFrame(() => {
const target = $('#flowSharedInputsSettings');
if (!target) return;
target.scrollIntoView({ behavior:'smooth', block:'start' });
target.classList.add('is-linked-target');
setTimeout(() => target.classList.remove('is-linked-target'), 1800);
});
}
else if (action === 'close-flow-editor') closeFlowEditor();
});
document.addEventListener('change', event => {
+20
View File
@@ -6259,3 +6259,23 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-input-actions button { flex:1; }
.flow-shared-input-fields .two { grid-template-columns:1fr; }
}
/* v0.9.9: Flow shared-input navigation and preset category tabs */
.flow-shared-settings-head { justify-content:space-between; align-items:flex-start; }
.flow-shared-settings-head > div { min-width:0; }
.flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; }
.flow-shared-dialog-actions { display:flex; align-items:center; justify-content:space-between; gap:24px; padding-top:14px; margin-top:4px; border-top:1px solid var(--line); }
.flow-shared-dialog-actions > button { min-width:112px; }
.flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; }
.flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; }
.flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; }
.flow-template-tab { flex:0 0 auto; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); padding:8px 12px; white-space:nowrap; }
.flow-template-tab.active { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); }
.flow-template-list { margin-top:14px; }
.flow-template-category-single { display:grid; gap:12px; }
.flow-template-category-single .flow-template-category-head { padding-bottom:2px; }
@media (max-width:760px) {
.flow-shared-settings-head { gap:12px; }
.flow-shared-add-button { width:auto; }
.flow-shared-dialog-actions { gap:16px; }
}