fix(battery): keep native timer callbacks reload-safe
Tests / test (push) Failing after 53s

This commit is contained in:
dh
2026-10-08 05:15:27 +00:00
parent 5885681669
commit d42cfb3342
10 changed files with 535 additions and 6 deletions
+10 -4
View File
@@ -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) {
@@ -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
{
+1 -1
View File
@@ -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
+83
View File
@@ -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.
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
// Offline contract tests only. Native scheduling is verified by the separate kernel fixture.
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Standalone PHP CLI only; never load mocks into Symcon.');
}
$GLOBALS['deviceCalls'] = 0;
$GLOBALS['testParent'] = 20;
function MBUS_UpdateValues(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function ModBus_RequestRead(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function IPS_VariableExists(int $id): bool { return $id === 10; }
function IPS_GetObject(int $id): array { return ['ParentID' => $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";
@@ -0,0 +1,11 @@
{
"id": "{044221CE-A8E5-4413-A081-C3235FF73350}",
"name": "TimerLifecycleProbe",
"type": 3,
"vendor": "ENELIX Test",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENLTMR"
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
// Development-only native timer fixture. No device, manager or variable actions.
class TimerLifecycleProbe extends IPSModule
{
public function Create(): void
{
parent::Create();
$this->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);
}
}
@@ -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
}
+171
View File
@@ -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()<end:
snap=snapshot()
if any(t['Name']==name and t['Running'] for t in snap['timers']): return snap
time.sleep(.15)
raise RuntimeError('No running timer: '+name)
report['kernel']=rpc('IPS_GetKernelVersion',[])
if report['kernel']!='8.0': raise RuntimeError('Symcon 8.0 required')
report['kernelDate']=rpc('IPS_GetKernelDate',[])
if rpc('IPS_GetInstance',[mc])['ModuleInfo']['ModuleID'] != '{B8A5067A-AFC2-3798-FEDC-BCD02A45615E}':
raise RuntimeError('Module Control identity changed')
if rpc('IPS_ModuleExists',[module]) or target.exists(): raise RuntimeError('Existing fixture; refusing overwrite')
report['beforeInstances']=rpc('IPS_GetInstanceList',[])
report['beforeConfigurationHashes']={str(i):hashlib.sha256(rpc('IPS_GetConfiguration',[i]).encode()).hexdigest()
for i in report['beforeInstances']}
report['beforeTimers']=[rpc('IPS_GetTimer',[i]) for i in rpc('IPS_GetTimerList',[])]
report['preexistingProbeTimers'] = [t for t in report['beforeTimers'] if t['InstanceID'] in [32751,34162,50935]]
if report['preexistingProbeTimers']: raise RuntimeError('Uncleared previous fixture timers')
if len(report['beforeTimers']) > 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)
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class BatterieTimerLifecycleTest extends TestCase
{
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testTimerWrapperUndStriktPassiverRueckmeldecache(): void
{
global $count;
ob_start();
try {
require __DIR__ . '/BatterieTimer/lifecycle_checks.php';
$output = (string) ob_get_contents();
} finally {
ob_end_clean();
}
self::assertSame(28, $count);
self::assertSame(0, $GLOBALS['deviceCalls']);
self::assertStringContainsString('TOTAL 28 offline battery timer/cache contract checks passed.', $output);
}
}