This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user