"""Bounded native fixture test on ubuntu only; never reload Enelix-EMS.""" import hashlib import json from pathlib import Path import shutil import socket import sys import time import urllib.request if socket.gethostname() != 'ubuntu': raise RuntimeError('Development host only') base = Path('/srv/agent/battery-timer-native-20261007') folder = 'ENELIXTimerLifecycleProbe20261007' target = Path('/srv/symcon/v8/dev/data/modules') / folder module = '{044221CE-A8E5-4413-A081-C3235FF73350}' library = '{748CA183-27CD-4EC2-8730-A8A0A2B2C953}' mc = 34135 report = {'hardwareCommands':0,'reloadedLibrary':folder,'phases':[], 'scope':'Six explicitly authorized isolated reload tests, 2026-10-08', 'registrationReloads':0, 'testReloads':0, 'startedAt':time.time()} mode = 'wrapper' report['mode'] = mode cycles = 3 report['cycles'] = cycles report_path = base / ('report-six-reloads-20261008-' + str(int(time.time())) + '.json') instance = None def rpc(method, params, timeout=20): payload=json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params}).encode() req=urllib.request.Request('http://127.0.0.1:3777/api/',payload,{'Content-Type':'application/json'}) with urllib.request.urlopen(req,timeout=timeout) as r: result=json.load(r) if 'error' in result: raise RuntimeError(str(result['error'])) return result['result'] def snapshot(): ts=[] for tid in rpc('IPS_GetTimerList',[]): t=rpc('IPS_GetTimer',[tid]) if t.get('InstanceID')==instance: ts.append(t) return {'at':time.time(),'timers':ts,'state':json.loads(rpc('ENLTMR_ReadProbeState',[instance]))} def wait_running(name): end=time.monotonic()+18 while time.monotonic() 55: raise RuntimeError('Timer growth exceeds bounded test budget') expected_fixture = { 'library.json':'d856277670e083d8a2616ad5f9994f6cb109fd887c47be2e5d152316eddb4067', 'TimerLifecycleProbe/module.json':'17a886572a8164a909ecfc3075cada9af2d510d06fbba012ff597c247b732161', 'TimerLifecycleProbe/module.php':'c9b4dfc861ad29917052e2fdef44b13e90f219f954b14e3d952de7de0f232742', } found_fixture = {str(p.relative_to(base/'fixture')):hashlib.sha256(p.read_bytes()).hexdigest() for p in (base/'fixture').rglob('*') if p.is_file()} if found_fixture != expected_fixture: raise RuntimeError('Unreviewed fixture changes; aborting') report['originalFixtureHashes'] = found_fixture shutil.copytree(base/'fixture',target) if mode == 'wrapper': file = target/'TimerLifecycleProbe/module.php' source = file.read_text().replace("IPS_RequestAction(\\$_IPS['TARGET'], 'Report', 0);", "ENLTMR_Tick(\\$_IPS['TARGET'], 'Report');") source = source.replace("IPS_RequestAction(\\$_IPS['TARGET'], 'Read', 0);", "ENLTMR_Tick(\\$_IPS['TARGET'], 'Read');") file.write_text(source) report['fixtureSourceSha256'] = hashlib.sha256((target/'TimerLifecycleProbe/module.php').read_bytes()).hexdigest() try: rpc('MC_ReloadModule',[mc,folder]) report['registrationReloads'] += 1 if not rpc('IPS_ModuleExists',[module]): raise RuntimeError('Fixture registration failed') instance=rpc('IPS_CreateInstance',[module]) report['instance']=instance rpc('IPS_SetName',[instance,'Temporary ENELIX timer lifecycle probe']) rpc('IPS_SetProperty',[instance,'LifecycleMode',mode]) rpc('IPS_ApplyChanges',[instance]) phases = [('reload','Rueckmeldesampler'),('reload','Meldezyklus')] * cycles for action, timer in phases: before = wait_running(timer) if mode == 'idle': end = time.monotonic() + 15 while any(t['Running'] for t in before['timers']): if time.monotonic() > end: raise RuntimeError('No idle window') time.sleep(.05) before = snapshot() phase={'action':action,'during':timer,'before':before} start=time.monotonic() try: report['testReloads'] += 1 phase['result']=rpc('MC_ReloadModule',[mc,folder]) except RuntimeError as e: phase['error']=str(e) phase['durationSeconds']=time.monotonic()-start phase['after']=[] for _ in range(5): time.sleep(2) phase['after'].append(snapshot()) report['phases'].append(phase) report_path.write_text(json.dumps(report,indent=2)) print(json.dumps({'phase':action,'during':timer,'duration':phase['durationSeconds'], 'error':phase.get('error'),'first':phase['after'][0]['state'], 'last':phase['after'][-1]['state'], 'timers':phase['after'][-1]['timers']}),flush=True) states=phase['after'] if states[-1]['state']==states[0]['state']: report['stalled']=True break except Exception as e: report['error']=type(e).__name__+': '+str(e) raise finally: if instance is not None: if rpc('IPS_GetInstance',[instance])['ModuleInfo']['ModuleID']!=module: raise RuntimeError('Fixture identity changed; refusing cleanup') rpc('ENLTMR_StopProbe',[instance]) deadline = time.monotonic() + 3 while time.monotonic() < deadline: if not any(t['Running'] for t in snapshot()['timers']): break time.sleep(.1) rpc('IPS_DeleteInstance',[instance]) try: rpc('MC_DeleteModule',[mc,folder]) except Exception as e: report['cleanupError']=str(e) report['remainingFixtureInstances']=rpc('IPS_GetInstanceListByModuleID',[module]) if rpc('IPS_ModuleExists',[module]) else [] report['afterInstances']=rpc('IPS_GetInstanceList',[]) report['afterConfigurationHashes']={str(i):hashlib.sha256(rpc('IPS_GetConfiguration',[i]).encode()).hexdigest() for i in report['afterInstances']} report['originalConfigurationsPreserved'] = report['beforeConfigurationHashes'] == report['afterConfigurationHashes'] report['afterTimers']=[rpc('IPS_GetTimer',[i]) for i in rpc('IPS_GetTimerList',[])] report['orphanFixtureTimers']=[t for t in report['afterTimers'] if t['InstanceID'] == instance] report['originalInstancesPreserved']=set(report['beforeInstances'])==set(report['afterInstances']) report['finishedAt'] = time.time() report_path.write_text(json.dumps(report,indent=2)) print(json.dumps({'finished':True,'instance':instance,'cleanupError':report.get('cleanupError'), 'originalInstancesPreserved':report['originalInstancesPreserved'], 'orphanFixtureTimers':report['orphanFixtureTimers'],'report':str(report_path)}),flush=True) if mode == 'wrapper': recovered = len(report['phases']) == 2 * cycles and report['testReloads'] == 6 for phase in report['phases']: first, last = phase['after'][0], phase['after'][-1] recovered = recovered and all(last['state'][key] > first['state'][key] for key in ['readRuns', 'reportRuns', 'scriptEnds']) recovered = recovered and len(last['timers']) == 2 and all(t['LastRun'] > 0 for t in last['timers']) if phase['action'] == 'reload': recovered = recovered and 'error' not in phase recovered = (recovered and report['originalInstancesPreserved'] and report['originalConfigurationsPreserved'] and not report['orphanFixtureTimers'] and not report.get('cleanupError')) report['nativeTimerRecoveryPassed'] = bool(recovered) report['applyBusyErrors'] = [p['error'] for p in report['phases'] if p['action'] == 'apply' and 'error' in p] report_path.write_text(json.dumps(report, indent=2)) print(json.dumps({'nativeTimerRecoveryPassed':report['nativeTimerRecoveryPassed'], 'testReloads':report['testReloads'], 'originalConfigurationsPreserved':report['originalConfigurationsPreserved'], 'report':str(report_path)}),flush=True) # Busy ApplyChanges is reported separately, never counted as a successful initialization. raise SystemExit(0 if recovered else 1)