v0.11.1
This commit is contained in:
@@ -10,4 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||
9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./verify_flow_logic.py
|
||||
b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./verify_flow_logic.py
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
CONDITIONS = {
|
||||
'weekday','time_range','date_range','cron_trigger','stable_for','delay','rolling_stat','oscillates',
|
||||
'weekday','time_range','date_range','cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates',
|
||||
'outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute',
|
||||
'ha_available','house_mode','device_state','zone_state','group_state','night_mode','constant','shared_input'
|
||||
}
|
||||
@@ -79,6 +79,7 @@ def cron_matches(expr: str, dt: datetime):
|
||||
class Runtime:
|
||||
since: datetime|None=None
|
||||
samples: list[tuple[datetime,float]]=field(default_factory=list)
|
||||
last_value: object|None=None
|
||||
|
||||
def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime):
|
||||
if not input_value:
|
||||
@@ -86,6 +87,28 @@ def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime):
|
||||
if rt.since is None: rt.since=now
|
||||
return (now-rt.since).total_seconds() >= seconds
|
||||
|
||||
def state_duration(rt: Runtime, input_value: bool, minimum: int, maximum: int|None, now: datetime):
|
||||
if not input_value:
|
||||
rt.since=None; return False,0
|
||||
if rt.since is None: rt.since=now
|
||||
elapsed=max(0,int((now-rt.since).total_seconds()))
|
||||
return elapsed >= minimum and (maximum is None or elapsed <= maximum), elapsed
|
||||
|
||||
def changed(rt: Runtime, current):
|
||||
result=rt.last_value is not None and rt.last_value != current
|
||||
rt.last_value=current
|
||||
return result
|
||||
|
||||
def rate_limit_status(rt: Runtime, maximum: int, period: int, now: datetime):
|
||||
cutoff=now-timedelta(seconds=period)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
return len(rt.samples) < maximum, len(rt.samples)
|
||||
|
||||
def rate_limit_record(rt: Runtime, period: int, now: datetime):
|
||||
cutoff=now-timedelta(seconds=period)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
rt.samples.append((now,1.0))
|
||||
|
||||
def rolling(rt: Runtime, sample: float, window: int, statistic: str, now: datetime):
|
||||
cutoff=now-timedelta(seconds=window)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
@@ -172,6 +195,17 @@ def validate_node_config(node, name):
|
||||
check(nonempty(c.get('expression')) and cron_valid(c['expression']), f'{tag}: invalid cron')
|
||||
elif kind in {'stable_for','delay'}:
|
||||
seconds=c.get('seconds'); check(isinstance(seconds,int) and not isinstance(seconds,bool) and 1 <= seconds <= 604800, f'{tag}: invalid duration')
|
||||
elif kind == 'state_duration':
|
||||
minimum=c.get('min_seconds',0); maximum=c.get('max_seconds')
|
||||
check(isinstance(minimum,int) and not isinstance(minimum,bool) and 0 <= minimum <= 604800, f'{tag}: invalid min duration')
|
||||
check(maximum is None or (isinstance(maximum,int) and not isinstance(maximum,bool) and minimum <= maximum <= 604800), f'{tag}: invalid max duration')
|
||||
check(minimum > 0 or maximum is not None, f'{tag}: empty duration range')
|
||||
elif kind == 'on_change':
|
||||
check(c.get('mode','result') in {'result','value'}, f'{tag}: invalid change mode')
|
||||
elif kind == 'rate_limit':
|
||||
count=c.get('max_count'); period=c.get('period_seconds')
|
||||
check(isinstance(count,int) and not isinstance(count,bool) and 1 <= count <= 1000, f'{tag}: invalid max count')
|
||||
check(isinstance(period,int) and not isinstance(period,bool) and 1 <= period <= 2678400, f'{tag}: invalid rate period')
|
||||
elif kind == 'rolling_stat':
|
||||
validate_source_config(c,tag)
|
||||
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
|
||||
@@ -248,7 +282,7 @@ def flow_action_truth_table(flow, action_id):
|
||||
node_id=stack.pop()
|
||||
if node_id in ancestors: continue
|
||||
ancestors.add(node_id); stack.extend(incoming.get(node_id,[]))
|
||||
primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay'}]
|
||||
primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay','state_duration','on_change','rate_limit'}]
|
||||
check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large')
|
||||
|
||||
def eval_node(node_id, assigned, memo):
|
||||
@@ -257,7 +291,7 @@ def flow_action_truth_table(flow, action_id):
|
||||
if kind == 'logic_and': value=bool(ins) and all(eval_node(x,assigned,memo) for x in ins)
|
||||
elif kind == 'logic_or': value=bool(ins) and any(eval_node(x,assigned,memo) for x in ins)
|
||||
elif kind == 'logic_not': value=len(ins)==1 and not eval_node(ins[0],assigned,memo)
|
||||
elif kind in {'stable_for','delay'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo)
|
||||
elif kind in {'stable_for','delay','state_duration','on_change','rate_limit'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo)
|
||||
else:
|
||||
predecessors=all(eval_node(x,assigned,memo) for x in ins)
|
||||
value=predecessors and assigned.get(node_id,False)
|
||||
@@ -303,8 +337,15 @@ def validate_graph(flow, name):
|
||||
ins=incoming.get(n['id'],[])
|
||||
if n['kind']=='logic_not': check(len(ins)==1, f'{name}: NOT arity')
|
||||
if n['kind'] in {'logic_and','logic_or'}: check(len(ins)>=1, f'{name}: logic arity')
|
||||
if n['kind'] in {'stable_for','delay'}: check(len(ins)==1, f'{name}: timed block arity')
|
||||
if n['kind'] in {'stable_for','delay','state_duration','on_change','rate_limit'}: check(len(ins)==1, f'{name}: stateful gate arity')
|
||||
if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition')
|
||||
if n['kind'] == 'rate_limit':
|
||||
targets=[by[target] for target in outgoing.get(n['id'],[]) if target in by]
|
||||
check(bool(targets) and all(target['kind'] in ACTIONS for target in targets), f'{name}: rate limit must be directly before action')
|
||||
if n['kind'] == 'on_change' and n.get('config',{}).get('mode','result') == 'value':
|
||||
sources=[by[source] for source in incoming.get(n['id'],[]) if source in by]
|
||||
blocked=LOGIC | {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}
|
||||
check(len(sources)==1 and sources[0]['kind'] not in blocked, f'{name}: value-change mode needs direct source input')
|
||||
for action in (n for n in nodes if n['kind'] in ACTIONS):
|
||||
outcomes=flow_action_truth_table(flow, action['id'])
|
||||
check(any(outcomes), f'{name}/{action["id"]}: action condition graph can never become true')
|
||||
@@ -328,6 +369,25 @@ def main():
|
||||
rt=Runtime(); check(not timed_gate(rt,True,30,base),'stable fired immediately'); check(not timed_gate(rt,True,30,base+timedelta(seconds=29)),'stable fired early'); check(timed_gate(rt,True,30,base+timedelta(seconds=30)),'stable did not fire'); check(not timed_gate(rt,False,30,base+timedelta(seconds=31)),'stable did not reset'); check(not timed_gate(rt,True,30,base+timedelta(seconds=32)),'stable restart should wait')
|
||||
rt=Runtime(); check(not timed_gate(rt,True,3,base),'delay immediate'); check(timed_gate(rt,True,3,base+timedelta(seconds=3)),'delay did not pass after wait')
|
||||
|
||||
rt=Runtime(); check(state_duration(rt,True,10,30,base)==(False,0),'state duration immediate'); check(state_duration(rt,True,10,30,base+timedelta(seconds=10))==(True,10),'state duration minimum'); check(state_duration(rt,True,10,30,base+timedelta(seconds=30))==(True,30),'state duration maximum boundary'); check(state_duration(rt,True,10,30,base+timedelta(seconds=31))==(False,31),'state duration exceeded maximum'); check(state_duration(rt,False,10,30,base+timedelta(seconds=32))==(False,0) and rt.since is None,'state duration reset')
|
||||
rt=Runtime(); check(not changed(rt,'off'),'change fired on first observation'); check(not changed(rt,'off'),'change fired without change'); check(changed(rt,'on'),'change not detected'); check(not changed(rt,'on'),'change repeated without new edge')
|
||||
rt=Runtime(); rate_limit_record(rt,60,base); rate_limit_record(rt,60,base+timedelta(seconds=10)); check(rate_limit_status(rt,2,60,base+timedelta(seconds=20))==(False,2),'rate limit did not block'); check(rate_limit_status(rt,2,60,base+timedelta(seconds=61))==(True,1),'rate limit did not prune rolling window')
|
||||
|
||||
synthetic={
|
||||
'nodes':[
|
||||
{'id':'source','kind':'constant','config':{'value':True}},
|
||||
{'id':'duration','kind':'state_duration','config':{'min_seconds':10,'max_seconds':60}},
|
||||
{'id':'change','kind':'on_change','config':{'mode':'result'}},
|
||||
{'id':'limit','kind':'rate_limit','config':{'max_count':2,'period_seconds':3600}},
|
||||
{'id':'action','kind':'ha_service_action','config':{'domain':'switch','service':'turn_on','entity_id':'switch.test','data':{}}},
|
||||
],
|
||||
'edges':[
|
||||
{'id':'e1','from':'source','to':'duration'}, {'id':'e2','from':'duration','to':'change'},
|
||||
{'id':'e3','from':'change','to':'limit'}, {'id':'e4','from':'limit','to':'action'},
|
||||
]
|
||||
}
|
||||
check(validate_graph(synthetic,'synthetic-new-stateful-flow')==(5,4),'new stateful flow graph validation failed')
|
||||
|
||||
rt=Runtime(); check(math.isclose(rolling(rt,10,60,'mean',base),10),'mean one'); check(math.isclose(rolling(rt,20,60,'mean',base+timedelta(seconds=10)),15),'mean two'); check(math.isclose(rolling(rt,30,60,'median',base+timedelta(seconds=20)),20),'median odd'); check(math.isclose(rolling(rt,40,60,'median',base+timedelta(seconds=30)),25),'median even'); check(math.isclose(rolling(rt,100,60,'mean',base+timedelta(seconds=100)),100),'window prune')
|
||||
rt=Runtime();
|
||||
for i,v in enumerate((20,22,19,23,20)):
|
||||
@@ -343,7 +403,7 @@ def main():
|
||||
api=(ROOT/'src/api/flows.rs').read_text()
|
||||
js=(ROOT/'web/js/flows.js').read_text()
|
||||
html=(ROOT/'web/index.html').read_text()
|
||||
for kind in ('cron_trigger','stable_for','delay','rolling_stat','oscillates'):
|
||||
for kind in ('cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'):
|
||||
check(f'"{kind}"' in api and f'"{kind}"' in engine, f'{kind}: backend wiring missing')
|
||||
check(kind in js and f'data-flow-add="{kind}"' in html, f'{kind}: UI wiring missing')
|
||||
meta_block=js.split('const FLOW_NODE_META = Object.freeze({',1)[1].split('});',1)[0]
|
||||
@@ -357,11 +417,15 @@ def main():
|
||||
check('Some(&mut runtime)' in api, 'dry-run does not use stateful runtime')
|
||||
check('conditions.iter().any(|condition| condition.kind == "cron_trigger")' in engine and 'last.minute() == now.minute()' in engine, 'CRON same-minute duplicate guard missing')
|
||||
check('previous.config == current.config' in api and 'runtime.retain' in api, 'runtime reset-on-edit protection missing')
|
||||
check('flow_record_rate_limited_execution' in engine and 'Ok(true)' in engine, 'rate limit is not recorded after successful execution')
|
||||
check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown')
|
||||
check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing')
|
||||
check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing')
|
||||
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
|
||||
for kind in CONDITIONS - {'stable_for','delay','rolling_stat','oscillates'}:
|
||||
for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}:
|
||||
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
|
||||
evaluator=engine.split('pub async fn evaluate_flow_conditions_trace',1)[1].split('async fn flow_conditions_match',1)[0]
|
||||
for kind in ('stable_for','delay','rolling_stat','oscillates','logic_and','logic_or','logic_not'):
|
||||
for kind in ('stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates','logic_and','logic_or','logic_not'):
|
||||
check(f'\"{kind}\"' in evaluator, f'{kind}: evaluator implementation missing')
|
||||
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
|
||||
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
|
||||
@@ -373,12 +437,12 @@ def main():
|
||||
for lang in ('pl','en'):
|
||||
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
|
||||
check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root')
|
||||
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction'):
|
||||
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction'):
|
||||
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
|
||||
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
|
||||
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
|
||||
check(not missing_titles, f'{lang}: missing Flow node title translations: {missing_titles}')
|
||||
referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|t\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html))
|
||||
referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|tr\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html))
|
||||
missing_referenced=sorted(key for key in referenced_flow_keys if key not in pack.get('translations',{}))
|
||||
check(not missing_referenced, f'{lang}: missing referenced Flow translations: {missing_referenced}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user