diff --git a/Batterie/module.php b/Batterie/module.php index f6f157e..0b1c0bb 100644 --- a/Batterie/module.php +++ b/Batterie/module.php @@ -133,22 +133,22 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $this->RegisterTimer( 'Meldezyklus', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'Melden', false);" ); $this->RegisterTimer( 'RueckmeldungVerzoegert', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'Melden', true);" ); $this->RegisterTimer( 'VorgabeTimeout', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);" + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'VorgabeTimeout', false);" ); $this->RegisterTimer( 'AenderungFreigabe', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'AenderungFreigabe', 0);" + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'AenderungFreigabe', false);" ); } @@ -220,6 +220,12 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle } } + /** Keep timer execution in the generated module wrapper during module reload. */ + public function BatterieTimerAktion(string $ident, bool $wert): void + { + $this->RequestAction($ident, $ident === 'Melden' ? $wert : 0); + } + public function RequestAction($ident, $wert): void { switch ($ident) { diff --git a/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php index eb8484b..b9e5338 100644 --- a/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php +++ b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php @@ -13,7 +13,7 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait { $this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}'); $this->RegisterTimer('NetzfahrplanV4RueckmeldungAktualisieren', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', 0);"); + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', false);"); } private function konfiguriereV4Rueckmeldung(): void @@ -113,6 +113,12 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait } } + /** Strictly cache-only diagnostics; never starts a device read or renews sample times. */ + public function GetV4BatterieRueckmeldungCache(): string + { + return json_encode($this->v4BatterieRueckmeldung(), JSON_THROW_ON_ERROR); + } + /** Read only. A returned current value is NOT actuator permission. */ public function GetV4BatterieRueckmeldung(): string { diff --git a/libs/BatterieNetzfahrplanV4TestTrait.php b/libs/BatterieNetzfahrplanV4TestTrait.php index 20a5c5c..fb9eb13 100644 --- a/libs/BatterieNetzfahrplanV4TestTrait.php +++ b/libs/BatterieNetzfahrplanV4TestTrait.php @@ -24,7 +24,7 @@ trait BatterieNetzfahrplanV4TestTrait $this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', ''); $this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}'); $this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0, - "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);"); + "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', false);"); } private function v4BatterieTestSitzung(): array diff --git a/tests/BatterieTimer/README.md b/tests/BatterieTimer/README.md new file mode 100644 index 0000000..ccd82ea --- /dev/null +++ b/tests/BatterieTimer/README.md @@ -0,0 +1,83 @@ +# Batterie: Timer-Lebenszyklus + +## Korrektur vom 08.10.2026 + +Alle sechs nativen Batterietimer verwenden den generierten Modulwrapper +`ENELIX_BatterieTimerAktion` statt eines weiteren `IPS_RequestAction`-Dispatchs. +Der Wrapper ruft die bestehende Instanzmethode auf und erhaelt Ident, Werttyp, +Intervalle, Sperren und den bestehenden Melde-/Stellpfad. Keine neuen Timer, +Properties, Energiezaehler-Resets oder Aenderungen der EV-/SDL-Aufteilung. + +`ENELIX_GetV4BatterieRueckmeldungCache` ermoeglicht rein passive Diagnose. +Die Methode liest nur den bereits bestaetigten Cache mit den unveraenderten +Frische- und Identitaetspruefungen. Sie startet keine Geraeteabfrage und erneuert +keinen Zeitstempel. Die bisherige Rueckmelde-API bleibt erhalten. + +## Ursache und Beleggrenzen + +Im isolierten Symcon-8.0-Kernel liess sich beim Bibliotheksreload waehrend eines +laufenden `IPS_RequestAction`-Timers eine alte laufende Timergeneration mit +unstartenden neuen Timern reproduzieren. Das Ende des inneren Callbacks inklusive +`finally`/Semaphore-Freigabe wurde erreicht, das nachfolgende Ende des aeusseren +Timerskripts nicht. Damit ist der Fehler auf die native Dispatch-/Reload-Grenze +eingegrenzt; eine konkrete C++-Kernelursache ist nicht bewiesen. Status 102 ist +kein Laufnachweis. Das hardwarefreie Gegenbeispiel benoetigt weder Modbus noch +Manager oder Stellbefehle. + +Der Modulwrapper bestand zuerst zwei und am 08.10.2026 weitere sechs native +Reloads: drei waehrend des Samplers und drei waehrend des Melders. Beide Timer +liefen anschliessend automatisch weiter, ohne zusaetzliche Timergenerationen. +Die sechs Wiederholungen liefen 04:34:29 bis 04:35:48 UTC. Bestehende Instanzen +und Konfigurationshashes blieben unveraendert; die Testbibliothek wurde entfernt. +Dies ist ein Entwicklungskerneltest, keine Anlagenabnahme. + +Ein separates `ApplyChanges` waehrend gehaltener Batteriesperre kann weiterhin +mit Timeout abbrechen. Bereits laufende Timer liefen im Test weiter. Erfolgreiche +Initialisierung unter jeder Last oder die Reparatur einer bereits blockierten +Altgeneration sind durch diesen Patch nicht bewiesen. + +## Regressionen + +- `php tests/BatterieTimer/lifecycle_checks.php`: 28 isolierte Vertragspruefungen. +- `composer check`: Syntax und regulaere Suite, einschliesslich isolierter + PHPUnit-Einbindung dieser 28 Pruefungen. +- `native-fixture/` und `reload-six-20261008.py`: exakt getestete, hardwarefreie + native Regression. Nicht Bestandteil des normalen Unit-Testlaufs. + +Der native Treiber ist bewusst an den geprueften Entwicklungsserver `ubuntu`, +Symcon 8.0, lokale RPC-Adresse und Modul-Control 34135 gebunden. Vor erneuter +Ausfuehrung Serverregeln lesen, Testfreigabe einholen und aktuelle Zuordnungen +pruefen. Die Fixture unter `/srv/agent/battery-timer-native-20261007/fixture` +bereitstellen; der Treiber validiert deren exakte Hashes, verweigert vorhandene +Testreste und laedt ausschliesslich seine eigene Testbibliothek neu. +Nicht auf einer Anlage oder mit veraenderten IDs blind ausfuehren. Die enthaltene +optionale Destroy-Drain-Diagnose ist kein Produktionsloesungsweg. + +## Installation und Rueckfall + +Eine laufende alte `IPS_RequestAction`-Generation wird nicht durch eine neue +Quelldatei repariert. Ein zufaellig beobachtetes Ruhefenster garantiert keinen +racesicheren Erstwechsel. Vor Anlageninstallation Quellen, aktuelle Instanz- +konfigurationen und Bedieneinstellungen sichern; parallele Aenderungen erhalten. +Fuer den ersten Wechsel ist ein separat freigegebener kontrollierter Stop/Start +mit Installation waehrend des Stillstands der robuste Weg. Keine externen Ticks, +Create-Umgehungen, wiederholten Blind-Reloads oder gelockerten Frischepruefungen. + +Danach automatische LastRun-Fortschritte des 5-s-Melders und 1-s-Samplers, +Originalzeitstempel frischer bestaetigter Cachewerte und fehlende alte doppelte +Timer pruefen. Manager und Ladepunkte mitbeobachten. Keine V4-Aktivierung oder +Stellversuche ableiten. Der zuletzt dokumentierte Anlagenreload vom 08.10.2026 +04:47 UTC blockierte erneut Batterietimer und liess Easee getrennt; dieser +historische Befund muss vor Eingriffen erneut live geprueft werden. + +Rueckfall nur auf gesicherte betroffene Quelldateien, nicht pauschal auf alte +Konfigurationen oder Energiezustaende. Ein Quellrollback allein beseitigt keinen +bereits blockierten Kernelzustand. Veroeffentlichung, Installation und tatsaechlich +gemessener Kernelbetrieb sind getrennte Nachweise. + +## Pull-Request-Kurztext + +Timerdispatch fuer Batterie auf den generierten Modulwrapper umgestellt; +bestehende Aktionsvertraege und Schutzpruefungen unveraendert. Rein passive +Cache-Diagnose hinzugefuegt. Offline-Regressionen und isolierte native +Reload-Reproduktion dokumentieren Ursache, Korrektur und Migrationsgrenzen. diff --git a/tests/BatterieTimer/lifecycle_checks.php b/tests/BatterieTimer/lifecycle_checks.php new file mode 100644 index 0000000..890eeb2 --- /dev/null +++ b/tests/BatterieTimer/lifecycle_checks.php @@ -0,0 +1,124 @@ + $GLOBALS['testParent'], 'ObjectIdent' => 'grid']; } +function IPS_GetInstanceListByModuleID(string $id): array { return [17004]; } +function IPS_GetProperty(int $id, string $key): mixed +{ + return $key === 'AutomatischeSuche' ? false : '[{"Aktiv":true,"InstanzID":44234}]'; +} + +class IPSModule +{ + public int $InstanceID = 44234; + public array $properties = [], $attributes = [], $buffers = [], $timers = [], $calls = []; + public function Create(): void {} + public function __call(string $method, array $args): mixed + { + if (str_starts_with($method, 'RegisterProperty')) { $this->properties[$args[0]] ??= $args[1]; return null; } + if (str_starts_with($method, 'RegisterAttribute')) { $this->attributes[$args[0]] ??= $args[1]; return null; } + if (str_starts_with($method, 'RegisterVariable') || $method === 'EnableAction') return null; + if (str_starts_with($method, 'ReadProperty')) return $this->properties[$args[0]]; + if (str_starts_with($method, 'ReadAttribute')) return $this->attributes[$args[0]]; + if ($method === 'RegisterTimer') { $this->timers[$args[0]] = ['interval' => $args[1], 'script' => $args[2]]; return null; } + if ($method === 'GetBuffer') return $this->buffers[$args[0]] ?? ''; + throw new RuntimeException('Unexpected mock call: ' . $method); + } +} + +require __DIR__ . '/../../Batterie/module.php'; +class TimerContractBattery extends Batterie +{ + public function RequestAction($ident, $value): void { $this->calls[] = [$ident, $value]; } +} + +$battery = new TimerContractBattery(); +$battery->Create(); +$GLOBALS['testBattery'] = $battery; +function ENELIX_BatterieTimerAktion(int $id, string $ident, bool $value): void +{ + if ($id !== 44234) throw new RuntimeException('Wrong timer target'); + $GLOBALS['testBattery']->BatterieTimerAktion($ident, $value); +} + +$count = 0; +function check(bool $valid, string $name): void +{ + global $count; + if (!$valid) throw new RuntimeException('FAIL ' . $name); + ++$count; + echo 'PASS ' . $name . PHP_EOL; +} +function rejects(callable $call, string $reason): void +{ + try { $call(); } + catch (RuntimeException $error) { + check($error->getMessage() === $reason, $reason); + return; + } + throw new RuntimeException('Expected rejection: ' . $reason); +} + +$expected = [ + 'Meldezyklus' => ['Melden', false], + 'RueckmeldungVerzoegert' => ['Melden', true], + 'VorgabeTimeout' => ['VorgabeTimeout', 0], + 'AenderungFreigabe' => ['AenderungFreigabe', 0], + 'NetzfahrplanV4RueckmeldungAktualisieren' => ['NetzfahrplanV4RueckmeldungAktualisieren', 0], + 'NetzfahrplanV4TestWatchdog' => ['NetzfahrplanV4TestWatchdog', 0], +]; +check(count($battery->timers) === count($expected), 'same six native timers'); +$battery->attributes['ZustandBezogeneEnergie'] = 123.456; +$battery->attributes['EnergieStandZeit'] = 123456789; +$before = [$battery->properties, $battery->attributes, $battery->buffers]; +foreach ($expected as $name => $call) { + $timer = $battery->timers[$name]; + check($timer['interval'] === 0, $name . ' keeps Create interval zero'); + check(str_starts_with($timer['script'], 'ENELIX_BatterieTimerAktion(') + && !str_contains($timer['script'], 'IPS_RequestAction('), $name . ' uses generated wrapper'); + $_IPS = ['TARGET' => 44234]; + eval($timer['script']); + check(end($battery->calls) === $call, $name . ' preserves action and value types'); +} +check([$battery->properties, $battery->attributes, $battery->buffers] === $before, + 'timer entrypoint does not change configuration, energy or buffers'); + +$config = ['batteryInstanceId' => 44234, 'managerId' => 17004, + 'sources' => [['variableId' => 10, 'parentId' => 20, 'ident' => 'grid']]]; +$json = json_encode($config, JSON_THROW_ON_ERROR); +$battery->properties['NetzfahrplanV4RueckmeldungKonfiguration'] = $json; +$sample = ['kind' => 'local_physical_feedback', 'checkedAt' => time(), + 'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0]; +$validBuffers = ['V4ConfirmedFeedback' => json_encode($sample, JSON_THROW_ON_ERROR), + 'V4ConfirmedFeedbackConfig' => hash('sha256', $json), + 'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9)]; +$battery->buffers = $validBuffers; +check($battery->GetV4BatterieRueckmeldungCache() === $validBuffers['V4ConfirmedFeedback'], + 'cache-only API returns original confirmed sample and timestamps'); +check($battery->buffers === $validBuffers, 'cache inspection never renews buffers'); +foreach ([time() - 3, time() + 10] as $invalidTime) { + $battery->buffers = $validBuffers; + $battery->buffers['V4ConfirmedFeedback'] = json_encode(array_replace($sample, ['checkedAt' => $invalidTime])); + rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); +} +$battery->buffers = $validBuffers; +$battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3); +rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); +$battery->buffers = $validBuffers; +$battery->buffers['V4ConfirmedFeedbackConfig'] = 'changed'; +rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_confirmed_cache_stale_or_invalid'); +$battery->buffers = $validBuffers; +$GLOBALS['testParent'] = 99; +rejects(fn() => $battery->GetV4BatterieRueckmeldungCache(), 'feedback_cache_source_changed'); +check($GLOBALS['deviceCalls'] === 0, 'all cache checks perform zero device reads'); +echo "TOTAL $count offline battery timer/cache contract checks passed. Native scheduler not mocked as proof.\n"; diff --git a/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.json b/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.json new file mode 100644 index 0000000..1dcdbc5 --- /dev/null +++ b/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.json @@ -0,0 +1,11 @@ +{ + "id": "{044221CE-A8E5-4413-A081-C3235FF73350}", + "name": "TimerLifecycleProbe", + "type": 3, + "vendor": "ENELIX Test", + "aliases": [], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENLTMR" +} diff --git a/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.php b/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.php new file mode 100644 index 0000000..3d96d7d --- /dev/null +++ b/tests/BatterieTimer/native-fixture/TimerLifecycleProbe/module.php @@ -0,0 +1,90 @@ +RegisterPropertyInteger('ReadWorkMs', 1500); + $this->RegisterPropertyInteger('ReportWorkMs', 1500); + $this->RegisterPropertyString('LifecycleMode', 'baseline'); + $this->RegisterAttributeInteger('ReadRuns', 0); + $this->RegisterAttributeInteger('ReportRuns', 0); + $this->RegisterAttributeInteger('CallbackEnds', 0); + $this->RegisterAttributeInteger('ScriptEnds', 0); + $this->RegisterTimer('Meldezyklus', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'Report', 0); ENLTMR_MarkScriptEnd(\$_IPS['TARGET']);"); + $this->RegisterTimer('Rueckmeldesampler', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'Read', 0); ENLTMR_MarkScriptEnd(\$_IPS['TARGET']);"); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + $lock = 'ENELIX.TimerProbe.Battery.' . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Probe battery busy'); + IPS_SemaphoreLeave($lock); + $this->SetTimerInterval('Meldezyklus', 5000); + $this->SetTimerInterval('Rueckmeldesampler', 1000); + $this->SetStatus(102); + } + + public function Destroy(): void + { + IPS_LogMessage('ENELIXTimerLifecycleProbe', 'Destroy entry ' . $this->InstanceID); + // Isolated experiment only: determine whether unload can drain native timers. + if (IPS_InstanceExists($this->InstanceID) && $this->ReadPropertyString('LifecycleMode') === 'drain') { + $this->SetTimerInterval('Meldezyklus', 0); + $this->SetTimerInterval('Rueckmeldesampler', 0); + $deadline = microtime(true) + 2.5; + do { + $running = false; + foreach (IPS_GetTimerList() as $id) { + $timer = IPS_GetTimer($id); + if ($timer['InstanceID'] === $this->InstanceID && $timer['Running']) $running = true; + } + if ($running) usleep(20000); + } while ($running && microtime(true) < $deadline); + IPS_LogMessage('ENELIXTimerLifecycleProbe', 'Destroy drain running=' . (int) $running); + } + parent::Destroy(); + } + + public function Tick(string $ident): void + { + $this->RequestAction($ident, 0); + } + + public function StopProbe(): void + { + $this->SetTimerInterval('Meldezyklus', 0); + $this->SetTimerInterval('Rueckmeldesampler', 0); + } + + public function RequestAction($Ident, $Value): void + { + if (!in_array($Ident, ['Read', 'Report'], true)) throw new InvalidArgumentException('Unknown probe action'); + $lock = 'ENELIX.TimerProbe.' . ($Ident === 'Read' ? 'Feedback.' : 'Battery.') . $this->InstanceID; + if (!IPS_SemaphoreEnter($lock, 0)) return; + try { + usleep(max(0, min(2000, $this->ReadPropertyInteger($Ident . 'WorkMs'))) * 1000); + $this->WriteAttributeInteger($Ident . 'Runs', $this->ReadAttributeInteger($Ident . 'Runs') + 1); + } finally { + IPS_SemaphoreLeave($lock); + } + $this->WriteAttributeInteger('CallbackEnds', $this->ReadAttributeInteger('CallbackEnds') + 1); + } + + public function MarkScriptEnd(): void + { + $this->WriteAttributeInteger('ScriptEnds', $this->ReadAttributeInteger('ScriptEnds') + 1); + } + + public function ReadProbeState(): string + { + return json_encode(['readRuns' => $this->ReadAttributeInteger('ReadRuns'), + 'callbackEnds' => $this->ReadAttributeInteger('CallbackEnds'), + 'scriptEnds' => $this->ReadAttributeInteger('ScriptEnds'), + 'reportRuns' => $this->ReadAttributeInteger('ReportRuns')], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/BatterieTimer/native-fixture/library.json b/tests/BatterieTimer/native-fixture/library.json new file mode 100644 index 0000000..cb0c8c6 --- /dev/null +++ b/tests/BatterieTimer/native-fixture/library.json @@ -0,0 +1,10 @@ +{ + "id": "{748CA183-27CD-4EC2-8730-A8A0A2B2C953}", + "author": "ENELIX Test", + "name": "ENELIX Timer Lifecycle Probe", + "url": "", + "compatibility": {"version": "8.0"}, + "version": "1.0", + "build": 1, + "date": 1791397591 +} diff --git a/tests/BatterieTimer/reload-six-20261008.py b/tests/BatterieTimer/reload-six-20261008.py new file mode 100644 index 0000000..e3365eb --- /dev/null +++ b/tests/BatterieTimer/reload-six-20261008.py @@ -0,0 +1,171 @@ +"""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) diff --git a/tests/BatterieTimerLifecycleTest.php b/tests/BatterieTimerLifecycleTest.php new file mode 100644 index 0000000..39efbd1 --- /dev/null +++ b/tests/BatterieTimerLifecycleTest.php @@ -0,0 +1,28 @@ +