feat(v4): confirm feedback with device reads

This commit is contained in:
dh
2026-10-04 15:34:36 +00:00
parent 30f7652803
commit 13e49ff60c
15 changed files with 693 additions and 29 deletions
+2
View File
@@ -6,6 +6,8 @@
**Fortsetzung Prognose am 04.10.2026:** Der aktuelle Runtime-Stand wurde gelesen, ohne die alten Diagnose- oder Installationsschritte zu wiederholen. Der abgeleitete Datensatz `lihrenmoos-physical-published-v2` ist `model_ready` und erfüllt mit mehr als 24 nutzbaren äquivalenten Stunden die Trainingsschwelle. Die V4-Einstellungen stehen seit Revision 2 in `shadow` auf `forecastSource=corrected_profile` und `measurementDataset=lihrenmoos-physical-published-v2`; `liveEnabled` und Stellfreigabe bleiben aus. Der erste echte Lauf zeigte einen kompatibilitätsbedingten Stopp bei mikrosekundengenauen `observedAt`-Zeitstempeln. Die Korrektur rundet ausschliesslich die kausale Verfügbarkeit von Prognoseereignissen auf die nächste volle Sekunde auf; Rohmessungen bleiben streng ganzsekündlich. 310 Python- und 16 Portal-Tests sind erfolgreich. Repository und Runtime-Build-Kontext enthalten bytegleich den geprüften Fix; der laufende Container enthält ihn noch nicht, weil beide Agentzugänge weder Docker-Socket noch `sudo` erhalten. Der vorbereitete, syntaktisch geprüfte Root-Rollout liegt unter `/home/agent/services/netplan-v4-shadow/commissioning/finish_corrected_forecast_rollout.sh` und baut/testet vor dem Austausch, hält das vorige Image als Rollback fest und ändert keine Stellfreigabe. Daniel hat danach einen Aktivbetrieb angefragt; der aktuelle V4-Kern ist jedoch technisch `shadow-only`, und die weiterhin unbelegte Rückmeldung (`feedback_source_skew`, `usableForTrial=false`, `canDispatch=false`) verbietet ein blosses Umstellen. Keine Live-Freigabe wurde erteilt oder eingebaut. Git-Klarstellung durch Daniel: Commit-Autor `dh_Agent <dh@belevo.ch>`, Gitea-Konto `dh`; Commit/Push auf `develop` und `beta` am 04.10.2026 ausdrücklich freigegeben. Der Agentzugang besitzt derzeit keine HTTPS-Schreibanmeldung für `dh`.
**Fortsetzung bestätigte Rückmeldung am 04.10.2026:** Daniels Freigabe des modellierten `virtual_split` gilt für den ausdrücklich begrenzten Feldtest. Die physische Rückmeldung wurde um synchrone, durch den Gerätetreiber bestätigte Lesevorgänge erweitert: M-Bus verwendet `MBUS_UpdateValues`, ModBus Device und ModBus Address verwenden `ModBus_RequestRead`. Nur ein erfolgreicher Geräteaufruf erzeugt `confirmedAt`; `VariableUpdated` bleibt getrennte Herkunftsinformation. Die Regeltest-Gates verlangen `deviceReadConfirmed=true`. Das reproduzierbare inkrementelle Paket liegt auf der Testanlage unter `/srv/agent/netplan-v4-confirmed-feedback-20261004`; 18 Paketprüfsummen, 37 Rückmeldetests, 7 Geräteabruf-Tests und 71 Regeltest-Prüfungen sind erfolgreich. Es ist noch **nicht im IP-Symcon-Kernel installiert**, weil der lokale JSON-RPC-Zugang ohne Anmeldung mit HTTP 401 antwortet. Die Installation selbst lässt alten Netzfahrplan und beide Regeltestfreigaben aus. Ein realer Stellversuch bleibt zusätzlich durch den nicht belegten unabhängigen Geräte-Watchdog und die fehlende Serverfreigabe blockiert; der vorhandene Symcon-`VorgabeTimeout` ist kein unabhängiger Hardware-Nachweis. Auf `enelix-services` und der Testanlage ist kein nutzbarer Git-HTTPS-Credential-Helper hinterlegt; die Prüfung des Tunnel-Servers lief zweimal in eine externe Connector-Freigabezeitüberschreitung. Tokeninhalt wurde weder gelesen noch ausgegeben. Commit/Push dieses Folgepakets stehen deshalb noch aus.
## 1. Zuerst lesen: Wo wir tatsächlich stehen
- Daniel möchte die **fertige, produktionsgeeignete Anwendung**, nicht weitere isolierte Sammler, Diagnosekategorien oder wiederholte Bestätigungsrunden. Er hat die fortlaufende Umsetzung mehrfach beauftragt. Probleme im Code selbst beheben, Tests und Auslieferung bündeln; ihn nur für wirklich notwendige Root-/Symcon-Ausführung oder echte Anlagenfreigaben einbeziehen.
@@ -0,0 +1,41 @@
{
"scope": "confirmed_feedback_trial_disabled",
"managerId": 17004,
"batteryInstanceId": 44234,
"files": {
"libs/NetzfahrplanV4Rueckmeldung.php": {
"before": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631",
"after": "0b261f6534c5ca9864018ea4c2caf97e6ce6913d92f13dcc926383b4f401a673"
},
"libs/NetzfahrplanV4Geraeteabruf.php": {
"before": null,
"after": "2b69a2ed27554139db6c996c906a4afe430918381e6359d23e8bb3369cd9be47"
},
"libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": {
"before": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7",
"after": "1de81499da07ad814c98ba778ca728e34817bdfcf29cc7e518f7b5f8fd7733b7"
}
},
"testSupport": {
"libs/BatterieNetzfahrplanV4TestTrait.php": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2",
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4"
},
"tests": {
"tests/V4Feedback/checks.php": "b1aacc540ba11adabdd140aaded9e2968d1328f261ca15756e6002db3427330a",
"tests/V4Feedback/device_read_checks.php": "5c5d9e50dd1d0f80cb01a2d95e63018eeec558ba78c07afde208e3c0ef304cc3",
"tests/V4ControlTrial/checks.php": "2f12e94aeb207e6939a645f2f45b5f54e57c18530360ed3b16aa52ff89a1ef39",
"tests/V4ControlTrial/additional_checks.php": "df01119f2b9c0befe1d14ae6b6ddf39c75d3687b669142e9b24b5f9f4821f2c3",
"tests/V4ControlTrial/feedback_checks.php": "e87d41dff8e3ecd414a59b3dd7a234b34183d37acca8cddaf847b82eae92e60c",
"tests/V4Receiver/fixture.php": "0eeb65e63250aff5ca364a82c2b2a71f9f229be2d9d412e3037014979deb793a"
},
"configurationSha256": "e393c1f01b30febc1d0785233474be43e98d849b152b4b2450b72efa6b73d540",
"dependencies": {
"libs/NetzfahrplanV4Regeltest.php": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249",
"libs/ManagerNetzfahrplanV4TestTrait.php": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18",
"libs/ManagerNetzfahrplanV4EmpfangTrait.php": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66",
"Batterie/module.php": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe",
"Manager/module.php": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79"
}
}
+18
View File
@@ -0,0 +1,18 @@
# V4 confirmed feedback for Lihrenmoos
This incremental package adds synchronous device reads before V4 feedback is evaluated.
- Grid input 40348 is refreshed through its M-Bus device.
- GoodWe and SolarEdge inputs are refreshed through their ModBus parent instances.
- A successful driver call is required; cached variable timestamps alone are not accepted.
- Daniel Haefliger accepted the existing `virtual_split` model for the bounded field test on 2026-10-04.
- The installer keeps the legacy schedule and both V4 trial permissions disabled.
- A real control trial still requires separate server authorization and evidence of an independent device-side watchdog.
Build a private staging directory:
```sh
python3 examples/V4ConfirmedFeedback/build_stage.py /srv/agent/netplan-v4-confirmed-feedback
```
Run the staged PHP checks before copying the package to the test plant. Execute `install.php` only from the IP-Symcon script editor on Lihrenmoos.
@@ -0,0 +1,49 @@
"""Create the reviewed Lihrenmoos confirmed-feedback package; never installs into Symcon."""
from pathlib import Path
import hashlib
import json
import sys
here = Path(__file__).resolve().parent
repo = here.parents[1]
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY")
target = Path(sys.argv[1]).resolve()
allowed_roots = [Path("/srv/agent"), Path("/home/agent/services/qa")]
if not any(target != root and target.is_relative_to(root) for root in allowed_roots):
raise SystemExit("Use a private agent staging directory, not live module paths")
if target.exists():
raise SystemExit("Destination must not exist")
manifest = json.loads((here / "MANIFEST.json").read_text())
contents = {}
for group in ("files", "testSupport", "tests"):
for name, record in manifest[group].items():
expected = record["after"] if group == "files" else record
source = repo / name
if source.is_symlink() or not source.resolve().is_relative_to(repo):
raise SystemExit("Unsafe source: " + name)
data = source.read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit("Reviewed source changed: " + name)
contents["source/" + name] = data
for name, expected in manifest["dependencies"].items():
if hashlib.sha256((repo / name).read_bytes()).hexdigest() != expected:
raise SystemExit("Dependency changed: " + name)
for name in ("install.php", "file_installer.php", "MANIFEST.json", "feedback-config.json", "README.md"):
contents[name] = (here / name).read_bytes()
if hashlib.sha256(contents["feedback-config.json"]).hexdigest() != manifest["configurationSha256"]:
raise SystemExit("Configuration changed")
target.mkdir(parents=True, mode=0o700)
for name, data in contents.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
path.chmod(0o640)
(target / "PACKAGE_HASHES.json").write_text(
json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in contents.items()}, indent=2) + "\n"
)
print("Private confirmed-feedback package created:", target, "; both trial permissions remain disabled.")
@@ -0,0 +1,71 @@
{
"version": 1,
"installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021",
"assetId": "anlage01-virtual-ev",
"managerId": 17004,
"batteryInstanceId": 44234,
"mode": "virtual_split",
"allowEstimatedForTrial": true,
"maxSkewSeconds": 30,
"maxAgeSeconds": 60,
"idleToleranceW": 50.0,
"trackingToleranceW": 200.0,
"sources": [
{
"key": "grid",
"variableId": 40348,
"parentId": 11490,
"ident": "Power_8",
"factorToW": 1000,
"role": "grid"
},
{
"key": "physical_goodwe1",
"variableId": 47725,
"parentId": 19742,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_goodwe2",
"variableId": 35724,
"parentId": 57658,
"ident": "A_6_3_35182",
"factorToW": -1,
"role": "physical"
},
{
"key": "physical_solaredge",
"variableId": 21447,
"parentId": 30789,
"ident": "Value",
"factorToW": 1,
"role": "physical"
},
{
"key": "ev_requested",
"variableId": 19651,
"parentId": 58448,
"ident": "Nennleistung_Soll_EV",
"factorToW": 1.0,
"role": "ev_request"
},
{
"key": "sdl_requested",
"variableId": 38943,
"parentId": 58448,
"ident": "Nennleistung_Soll_SDL",
"factorToW": 1.0,
"role": "sdl_request"
},
{
"key": "gateway_active",
"role": "gateway_active",
"variableId": 23483,
"parentId": 58448,
"ident": "State",
"factorToW": 1.0
}
]
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/** Atomic incremental installer for the confirmed-feedback update. */
function v4ConfirmedWrite(string $path, string $content, int $mode = 0644): void
{
if (is_link($path)) {
throw new RuntimeException('Symlink wird nicht ersetzt.');
}
$temp = tempnam(dirname($path), '.v4-confirmed-');
if ($temp === false) {
throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
}
try {
if (file_put_contents($temp, $content, LOCK_EX) !== strlen($content)
|| !chmod($temp, $mode)
|| !rename($temp, $path)) {
throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
}
} finally {
if (is_file($temp)) {
unlink($temp);
}
}
}
function v4ConfirmedFiles(string $stage, string $target): array
{
$allowed = [
'libs/NetzfahrplanV4Rueckmeldung.php',
'libs/NetzfahrplanV4Geraeteabruf.php',
'libs/BatterieNetzfahrplanV4RueckmeldungTrait.php',
];
$manifest = json_decode((string) file_get_contents($stage . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($manifest['scope'] ?? null) !== 'confirmed_feedback_trial_disabled'
|| count($manifest['files'] ?? []) !== count($allowed)
|| array_diff(array_keys($manifest['files']), $allowed)
|| array_diff($allowed, array_keys($manifest['files']))) {
throw new RuntimeException('Unerwarteter Dateiumfang.');
}
if (realpath($target) !== $target || is_link($target)) {
throw new RuntimeException('Modulpfad unerwartet.');
}
foreach ($manifest['dependencies'] as $name => $hash) {
if (!preg_match('~^(libs/[A-Za-z0-9]+\\.php|Batterie/module\\.php|Manager/module\\.php)$~D', $name)
|| is_link($target . '/' . $name)
|| hash_file('sha256', $target . '/' . $name) !== $hash) {
throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: ' . $name);
}
}
$before = [];
$source = [];
$already = true;
foreach ($allowed as $name) {
$path = $target . '/' . $name;
$candidate = $stage . '/source/' . $name;
if (is_link($path) || is_link($candidate) || realpath(dirname($path)) !== $target . '/' . dirname($name)) {
throw new RuntimeException('Unerwarteter Dateipfad.');
}
$source[$name] = (string) file_get_contents($candidate);
if (hash('sha256', $source[$name]) !== $manifest['files'][$name]['after']) {
throw new RuntimeException('Paketpruefsumme geaendert: ' . $name);
}
token_get_all($source[$name], TOKEN_PARSE);
$before[$name] = is_file($path) ? file_get_contents($path) : null;
$hash = $before[$name] === null ? null : hash('sha256', $before[$name]);
if (!in_array($hash, [$manifest['files'][$name]['before'], $manifest['files'][$name]['after']], true)) {
throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: ' . $name);
}
if ($hash !== $manifest['files'][$name]['after']) {
$already = false;
}
}
if ($already) {
return ['status' => 'already_installed', 'backup' => null, 'filesChanged' => []];
}
$backup = $stage . '/backups/' . gmdate('Ymd\\THis\\Z') . '-' . bin2hex(random_bytes(4));
if (!mkdir($backup, 0700, true)) {
throw new RuntimeException('Sicherung fehlgeschlagen.');
}
foreach ($allowed as $name) {
if ($before[$name] !== null) {
$destination = $backup . '/' . $name;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0700, true);
}
v4ConfirmedWrite($destination, $before[$name], 0600);
}
}
v4ConfirmedWrite(
$backup . '/MANIFEST.json',
json_encode($manifest, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n",
0600
);
$written = [];
try {
foreach ($allowed as $name) {
if ((is_file($target . '/' . $name) ? file_get_contents($target . '/' . $name) : null) !== $before[$name]) {
throw new RuntimeException('Parallele Aenderung waehrend Installation.');
}
if ($before[$name] === $source[$name]) {
continue;
}
v4ConfirmedWrite($target . '/' . $name, $source[$name]);
$written[] = $name;
}
} catch (Throwable $error) {
foreach (array_reverse($written) as $name) {
$path = $target . '/' . $name;
if (!is_link($path) && is_file($path)
&& hash_file('sha256', $path) === $manifest['files'][$name]['after']) {
if ($before[$name] === null) {
unlink($path);
} else {
v4ConfirmedWrite($path, $before[$name]);
}
}
}
throw $error;
}
return ['status' => 'installed', 'backup' => $backup, 'filesChanged' => $written];
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs confirmed reads and accepted virtual_split mapping; never starts a trial.
if (!function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
}
require_once __DIR__ . '/file_installer.php';
$manager = 17004;
$battery = 44234;
$locked = false;
$changedMapping = false;
$oldMapping = null;
$report = [
'scope' => 'confirmed_feedback_trial_disabled',
'startedAt' => gmdate('c'),
'actuatorPermissionGranted' => false,
'serverUpdateRequired' => true,
'acceptedModel' => 'virtual_split',
];
try {
$hashes = json_decode((string) file_get_contents(__DIR__ . '/PACKAGE_HASHES.json'), true, 32, JSON_THROW_ON_ERROR);
foreach ($hashes as $name => $hash) {
if (str_contains($name, '..') || str_starts_with($name, '/') || is_link(__DIR__ . '/' . $name)
|| hash_file('sha256', __DIR__ . '/' . $name) !== $hash) {
throw new RuntimeException('Vorbereitetes Paket veraendert.');
}
}
foreach ([
$manager => '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
$battery => '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
] as $id => $module) {
if (!IPS_InstanceExists($id) || IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== $module) {
throw new RuntimeException('Modulzuordnung ungueltig.');
}
if (IPS_HasChanges($id)) {
throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.');
}
}
$beforeManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$beforeBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
if (($beforeManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($beforeManager['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false
|| ($beforeBattery['NetzfahrplanV4RegeltestErlaubt'] ?? false) !== false) {
throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.');
}
$configuration = json_decode((string) file_get_contents(__DIR__ . '/feedback-config.json'), true, 32, JSON_THROW_ON_ERROR);
$manifest = json_decode((string) file_get_contents(__DIR__ . '/MANIFEST.json'), true, 32, JSON_THROW_ON_ERROR);
if (($configuration['managerId'] ?? null) !== $manager
|| ($configuration['batteryInstanceId'] ?? null) !== $battery
|| ($configuration['mode'] ?? null) !== 'virtual_split'
|| ($configuration['allowEstimatedForTrial'] ?? null) !== true
|| hash_file('sha256', __DIR__ . '/feedback-config.json') !== $manifest['configurationSha256']) {
throw new RuntimeException('Falsche Rueckmeldekonfiguration.');
}
$desired = json_encode($configuration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$previousConfiguration = $configuration;
$previousConfiguration['allowEstimatedForTrial'] = false;
$previousDesired = json_encode($previousConfiguration, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
$oldMapping = $beforeBattery['NetzfahrplanV4RueckmeldungKonfiguration'] ?? '{}';
if (!in_array($oldMapping, [$previousDesired, $desired], true)) {
throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.');
}
$controls = [];
foreach (IPS_GetModuleList() as $module) {
if ((IPS_GetModule($module)['Prefix'] ?? '') === 'MC') {
$controls = array_merge($controls, IPS_GetInstanceListByModuleID($module));
}
}
if (count($controls) !== 1 || !function_exists('MC_ReloadModule')) {
throw new RuntimeException('Module Control nicht eindeutig.');
}
$locked = IPS_SemaphoreEnter('ENELIX.V4.ConfirmedFeedbackInstall', 3000);
if (!$locked) {
throw new RuntimeException('Installation bereits aktiv.');
}
$report['files'] = v4ConfirmedFiles(__DIR__, '/var/lib/symcon/modules/Enelix-EMS');
if (MC_ReloadModule($controls[0], 'Enelix-EMS') === false) {
throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.');
}
$afterManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$afterBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $afterManager], [$beforeBattery, $afterBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value) {
throw new RuntimeException('Bestehender Parameter beim Reload veraendert: ' . $key);
}
}
}
if (!function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$report['status'] = 'waiting_for_registration';
echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n";
} else {
if ($afterBattery['NetzfahrplanV4RueckmeldungKonfiguration'] !== $desired) {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $desired);
$changedMapping = true;
IPS_ApplyChanges($battery);
}
$finalManager = json_decode(IPS_GetConfiguration($manager), true, 128, JSON_THROW_ON_ERROR);
$finalBattery = json_decode(IPS_GetConfiguration($battery), true, 128, JSON_THROW_ON_ERROR);
foreach ([[$beforeManager, $finalManager], [$beforeBattery, $finalBattery]] as $pair) {
foreach ($pair[0] as $key => $value) {
if ($key !== 'NetzfahrplanV4RueckmeldungKonfiguration'
&& (!array_key_exists($key, $pair[1]) || $pair[1][$key] !== $value)) {
throw new RuntimeException('Bestehender Parameter unerwartet veraendert: ' . $key);
}
}
}
if (($finalManager['NetzfahrplanAktiv'] ?? null) !== false
|| ($finalManager['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false
|| ($finalBattery['NetzfahrplanV4RegeltestErlaubt'] ?? null) !== false) {
throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.');
}
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($battery), true, 32, JSON_THROW_ON_ERROR);
$report['feedback'] = array_intersect_key($feedback, array_flip([
'status', 'kind', 'method', 'reason', 'reasons', 'estimated', 'batteryW', 'gridW',
'sourceOldestAt', 'sourceVariableOldestAt', 'confirmedAt', 'checkedAt', 'configHash',
'deviceReadConfirmed', 'usableForTrial', 'canDispatch',
]));
$report['status'] = 'confirmed_feedback_installed_trial_disabled';
$report['originalParametersPreserved'] = true;
echo "OK: Geraeteabfrage bestaetigt die physischen Messwerte; virtual_split ist fuer den begrenzten Regeltest akzeptiert.\n";
echo 'Rueckmeldung: ' . ($feedback['status'] ?? 'available')
. '; Geraeteabfrage: ' . (($feedback['deviceReadConfirmed'] ?? false) ? 'bestaetigt' : 'nicht bestaetigt')
. '; Modellanteil: ' . (($feedback['estimated'] ?? false) ? 'ja' : 'nein') . ".\n";
if (isset($feedback['reason'])) {
echo 'Aktueller Hinweis: ' . $feedback['reason'] . "\n";
}
echo "Beide Regeltestfreigaben AUS. Kein Stellbefehl ohne separat nachgewiesenen Geraete-Watchdog und Serverfreigabe.\n";
}
} catch (Throwable $error) {
$report['status'] = 'needs_review';
$report['reason'] = substr($error->getMessage(), 0, 260);
if ($changedMapping && $oldMapping !== null) {
try {
IPS_SetProperty($battery, 'NetzfahrplanV4RueckmeldungKonfiguration', $oldMapping);
IPS_ApplyChanges($battery);
$report['mappingRestored'] = true;
} catch (Throwable $ignored) {
$report['mappingRestored'] = false;
}
}
echo 'FEHLER: ' . $report['reason'] . "\nKeine V4-Stellfreigabe erteilt.\n";
} finally {
$report['finishedAt'] = gmdate('c');
v4ConfirmedWrite(__DIR__ . '/INSTALL_RESULT.json', json_encode($report, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . "\n", 0644);
if ($locked) {
IPS_SemaphoreLeave('ENELIX.V4.ConfirmedFeedbackInstall');
}
echo 'CONFIRMED FEEDBACK INSTALL REPORT: ' . __DIR__ . "/INSTALL_RESULT.json\n";
}
@@ -4,8 +4,9 @@ declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
/** Shared physical reading used by receiver and battery immediately before a trial command. */
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
private function registriereV4Rueckmeldung(): void
@@ -20,11 +21,41 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
return NetzfahrplanV4Rueckmeldung::read($c,static function(int $id):array {
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
if (!IPS_SemaphoreEnter($lock,5000)) throw new \RuntimeException('feedback_refresh_busy');
try {
$reader=static function(int $id):array {
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
},time());
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
};
$result=NetzfahrplanV4Geraeteabruf::snapshot(
$c,
static function(int $id):string {
if (!IPS_InstanceExists($id)) throw new \RuntimeException('feedback_parent_instance_missing');
return (string)(IPS_GetInstance($id)['ModuleInfo']['ModuleID']??'');
},
static function(int $id,string $module):bool {
if ($module===NetzfahrplanV4Geraeteabruf::MBUS_DEVICE) {
if (!function_exists('MBUS_UpdateValues') && !function_exists(__NAMESPACE__.'\\MBUS_UpdateValues')) throw new \RuntimeException('feedback_mbus_refresh_unavailable');
return MBUS_UpdateValues($id)===true;
}
if (in_array($module,[NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS],true)) {
if (!function_exists('ModBus_RequestRead') && !function_exists(__NAMESPACE__.'\\ModBus_RequestRead')) throw new \RuntimeException('feedback_modbus_refresh_unavailable');
return ModBus_RequestRead($id)===true;
}
return false;
},
$reader,
static fn():int=>time()
);
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
return $result;
} finally {
IPS_SemaphoreLeave($lock);
}
}
/** Read only. A returned current value is NOT actuator permission. */
@@ -32,7 +63,7 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
{
try { $r=$this->v4BatterieRueckmeldung(); }
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false]; }
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false,'deviceReadConfirmed'=>false]; }
return json_encode($r,JSON_THROW_ON_ERROR);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
/**
* Binds values to explicitly successful device reads.
*
* Transport calls, object metadata and the clock are injected so the complete
* coordination can be tested without IP-Symcon or real devices.
*/
final class NetzfahrplanV4Geraeteabruf
{
public const MBUS_DEVICE = '{B53BE2E5-892D-4537-94AC-EAC68A469188}';
public const MODBUS_DEVICE = '{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}';
public const MODBUS_ADDRESS = '{CB197E50-273D-4535-8C91-BB35273E3CA5}';
public static function snapshot(
array $config,
callable $moduleId,
callable $refresh,
callable $reader,
callable $clock
): array {
$config = NetzfahrplanV4Rueckmeldung::configuration($config);
$groups = [];
$sourcesById = [];
foreach ($config['sources'] as $source) {
$sourcesById[$source['variableId']] = $source;
if (in_array($source['role'], ['grid', 'physical'], true)) {
$groups[$source['parentId']][] = $source;
}
}
$snapshot = [];
foreach ($groups as $parentId => $sources) {
$module = $moduleId($parentId);
if (!in_array($module, [self::MBUS_DEVICE, self::MODBUS_DEVICE, self::MODBUS_ADDRESS], true)) {
throw new InvalidArgumentException('feedback_refresh_module_unsupported:' . $sources[0]['key']);
}
if ($refresh($parentId, $module) !== true) {
throw new InvalidArgumentException('feedback_device_read_failed:' . $sources[0]['key']);
}
$confirmedAt = $clock();
if (!is_int($confirmedAt) || $confirmedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($sources as $source) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$record['confirmedAt'] = $confirmedAt;
$record['confirmedModuleId'] = $module;
$snapshot[$source['variableId']] = $record;
}
}
foreach ($config['sources'] as $source) {
if (!isset($snapshot[$source['variableId']])) {
$record = $reader($source['variableId']);
if (!is_array($record)) {
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
}
$snapshot[$source['variableId']] = $record;
}
}
foreach ($snapshot as $variableId => $record) {
$again = $reader($variableId);
$keys = ['value', 'updated', 'changed', 'parentID', 'ident'];
if (!is_array($again) || array_intersect_key($again, array_flip($keys))
!== array_intersect_key($record, array_flip($keys))) {
throw new InvalidArgumentException(
'feedback_changed_after_confirmed_read:' . $sourcesById[$variableId]['key']
);
}
}
$checkedAt = $clock();
if (!is_int($checkedAt) || $checkedAt < 1) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
foreach ($snapshot as $record) {
if (isset($record['confirmedAt']) && $record['confirmedAt'] > $checkedAt) {
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
}
}
return NetzfahrplanV4Rueckmeldung::read(
$config,
static fn(int $variableId): array => $snapshot[$variableId],
$checkedAt
);
}
}
+14 -8
View File
@@ -72,28 +72,32 @@ final class NetzfahrplanV4Rueckmeldung
{
$c=self::configuration($config);
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
$first=[];$values=[];$times=[];$physicalTimes=[];$changed=[];
$first=[];$values=[];$times=[];$physicalTimes=[];$variableTimes=[];$changed=[];
foreach ($c['sources'] as $s) {
$r=$reader($s['variableId']);
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident']));
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']
|| (in_array($s['role'],['grid','physical'],true)
&& (!is_int($r['confirmedAt']??null) || $r['confirmedAt']<1 || $r['confirmedAt']>$now
|| $r['updated']>$r['confirmedAt']))) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident','confirmedAt']));
if ($s['role']==='gateway_active') {
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
} else {
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
}
// An unchanged command is held STATE, not proof of a fresh sensor or an actuator watchdog.
// VariableUpdated is retained as provenance. Freshness comes only from a successful device response.
if (in_array($s['role'],['grid','physical'],true)) {
if ($now-$r['updated']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
$times[]=$r['updated'];if ($s['role']==='physical') $physicalTimes[]=$r['updated'];
if ($now-$r['confirmedAt']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
$times[]=$r['confirmedAt'];$variableTimes[]=$r['updated'];
if ($s['role']==='physical') $physicalTimes[]=$r['confirmedAt'];
} else $changed[$s['role']]=$r['changed'];
}
foreach ($c['sources'] as $s) {
$second=$reader($s['variableId']);
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident']))!==$first[$s['key']]) {
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident','confirmedAt']))!==$first[$s['key']]) {
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
}
}
@@ -117,7 +121,8 @@ final class NetzfahrplanV4Rueckmeldung
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
'sourceSkewSeconds'=>max($times)-min($times),'maxAgeSeconds'=>$c['maxAgeSeconds'],
'sourceSkewSeconds'=>max($times)-min($times),'sourceVariableOldestAt'=>min($variableTimes),
'sourceVariableNewestAt'=>max($variableTimes),'deviceReadConfirmed'=>true,'maxAgeSeconds'=>$c['maxAgeSeconds'],
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
@@ -131,6 +136,7 @@ final class NetzfahrplanV4Rueckmeldung
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
}
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|| ($f['deviceReadConfirmed']??null)!==true
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
+1
View File
@@ -19,6 +19,7 @@ final class NetzfahrplanV4RueckmeldungTest extends TestCase
self::assertSame('',$err);self::assertStringContainsString('TOTAL ',$out);
}
public function testPhysicalFeedback(): void {$this->checkScript('checks.php');}
public function testConfirmedDeviceReads(): void {$this->checkScript('device_read_checks.php');}
public function testActualReceiverIntegration(): void {$this->checkScript('receiver_checks.php');}
public function testActualFallbackWrapper(): void {$this->checkScript('fallback_checks.php');}
public function testMessageWithoutVisibleDiagnostics(): void {$this->checkScript('message_checks.php');}
+8 -4
View File
@@ -10,9 +10,13 @@ namespace Belevo\EnelixEMS {
function hrtime($asNumber=true) { return (int)($GLOBALS['mono']*1e9); }
function IPS_SemaphoreEnter($key,$wait) { if (!empty($GLOBALS['locks'][$key])) return false;$GLOBALS['locks'][$key]=true;return true; }
function IPS_SemaphoreLeave($key) { unset($GLOBALS['locks'][$key]); }
function IPS_InstanceExists($id) { return isset($GLOBALS['objects'][$id]); }
function IPS_GetInstance($id) { return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; }
function IPS_InstanceExists($id) { return $id===200 || isset($GLOBALS['objects'][$id]); }
function IPS_GetInstance($id) {
if ($id===200) return ['InstanceStatus'=>102,'ModuleInfo'=>['ModuleID'=>'{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}']];
return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];
}
function ModBus_RequestRead($id) { return $id===200 && ($GLOBALS['deviceReadOk']??true); }
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); }
function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; }
@@ -102,7 +106,7 @@ namespace {
function check($ok,$name) { global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n"; }
function rejects(callable $call,$name) { try {$call();}catch(Throwable $e){check(true,$name);return;}throw new RuntimeException('FAIL did not reject '.$name); }
function fixture($permissions=true,$serverAuthority=true): array {
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];$GLOBALS['deviceReadOk']=true;
$r['plan']['inputQuality']=['loadBasis'=>'base_load','accountingEvidenceId'=>'synthetic-meter-boundary-v1'];
$r['plan']['peakCostIsEstimate']=true;
$a=['kind'=>'controlled_trial_authority','version'=>1,'sourcePlanRemainsShadow'=>true,
+5 -5
View File
@@ -2,11 +2,11 @@
// Run through actual Manager/Battery trial traits, fresh read helper, and simulated register driver.
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RueckmeldungKonfiguration']='{}';
rejects(fn()=>start($m,$s),'trial cannot use old filtered feedback when mapping missing');check($b->writes===[],'missing corrected feedback sends no command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61;
$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'physical feedback expiry returns battery to normal allocation');
check(end($b->writes)===0,'expired physical feedback revokes prior command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61;$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'independent battery read catches staleness without manager tick');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;
$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'failed confirmed read returns battery to normal allocation');
check(end($b->writes)===0,'failed confirmed read revokes prior command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'independent battery read catches device failure without manager tick');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=json_decode($b->props['NetzfahrplanV4RueckmeldungKonfiguration'],true);$c['trackingToleranceW']=250;
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($c);$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'mapping revision change revokes active battery session');
+6 -5
View File
@@ -11,7 +11,7 @@ function fx($mode='physical_sum') {
$now=1791032400;$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
'mode'=>$mode,'allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
$roles=['grid','physical','physical'];if($mode==='virtual_split')$roles=array_merge($roles,['ev_request','sdl_request','gateway_active']);$values=[3000.0,-2000.0,-1000.0,-3000.0,0.0,true];$r=[];
foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id];}
foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id,'confirmedAt'=>$now];}
return [$c,$r,$now];
}
function readit($c,$r,$n){return F::read($c,fn($id)=>$r[$id],$n);}
@@ -23,8 +23,9 @@ check(F::fingerprint($c)===F::fingerprint(json_decode(json_encode($c),true)),'co
$d=$c;$d['sources']=array_reverse($d['sources']);check(F::fingerprint($c)===F::fingerprint($d),'source list order canonical');
$d['trackingToleranceW']=201;check(F::fingerprint($c)!==F::fingerprint($d),'changed tolerance invalidates session hash');
foreach([true,'1',NAN,INF,null]as$bad){$x=$r;$x[101]['value']=$bad;fails(fn()=>readit($c,$x,$n),'invalid physical number '.gettype($bad));}
$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;fails(fn()=>readit($c,$x,$n),'physical age not refreshed by read');
$x=$r;$x[101]['updated']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent grid battery timestamps rejected');
$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;$f=readit($c,$x,$n);check($f['sourceVariableOldestAt']===$n-61,'confirmed read does not manufacture VariableUpdated');
$x=$r;unset($x[101]['confirmedAt']);fails(fn()=>readit($c,$x,$n),'physical source without confirmed device read rejected');
$x=$r;$x[101]['confirmedAt']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent confirmed device timestamps rejected');
$x=$r;$x[101]['updated']=$n+1;fails(fn()=>readit($c,$x,$n),'future original sample rejected');
$x=$r;$x[101]['parentID']=201;fails(fn()=>readit($c,$x,$n),'reused variable wrong parent rejected');
$x=$r;$x[101]['ident']='other';fails(fn()=>readit($c,$x,$n),'reused variable wrong ident rejected');
@@ -44,8 +45,8 @@ $r[101]['value']=20.0;$f=readit($c,$r,$n);check($f['batteryW']===20.0,'idle resi
$r[101]['value']=60.0;fails(fn()=>readit($c,$r,$n),'unexplained idle power refused');
$r[101]['value']=-2000.0;$r[102]['value']=-1000.0;$r[103]['value']=2000.0;$r[103]['updated']=$n;$r[103]['changed']=$n;
$f=readit($c,$r,$n);check($f['batteryW']===-3000.0,'single EV feedback follows physics not desired sign or held filter');
$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['updated']=$n-1;
fails(fn()=>readit($c,$r,$n),'SDL transition after physical sample cannot be guessed');
$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['confirmedAt']=$n-1;
fails(fn()=>readit($c,$r,$n),'SDL transition after physical confirmation cannot be guessed');
[$c,$r,$n]=fx('virtual_split');$c['allowEstimatedForTrial']=true;$r[103]['value']=3000.0;$r[104]['value']=1000.0;$r[101]['value']=1900.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
check($f['batteryW']===2925.0&&$f['externalModelW']===975.0,'small tracking error allocated but explicitly estimated');
check(!$f['separatelyMeasured']&&$f['heldFilterUsed']===false,'partition never labelled separately measured');
+51
View File
@@ -0,0 +1,51 @@
<?php
// Offline only, no IP-Symcon or real device access.
declare(strict_types=1);
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/../../libs/NetzfahrplanV4Geraeteabruf.php';
use Belevo\EnelixEMS\NetzfahrplanV4Geraeteabruf as D;
$count=0;
function dcheck($ok,$name){global $count;if(!$ok)throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n";}
function dfails($fn,$name){try{$fn();}catch(Throwable $e){dcheck(true,$name);return;}throw new RuntimeException('FAIL no rejection '.$name);}
function dfixture(){
$now=1791032400;
$config=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,
'sources'=>[
['key'=>'grid','role'=>'grid','variableId'=>10,'parentId'=>20,'ident'=>'grid','factorToW'=>1.0],
['key'=>'battery_a','role'=>'physical','variableId'=>11,'parentId'=>21,'ident'=>'a','factorToW'=>1.0],
['key'=>'battery_b','role'=>'physical','variableId'=>12,'parentId'=>21,'ident'=>'b','factorToW'=>1.0],
]];
$records=[
10=>['value'=>3000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>20,'ident'=>'grid'],
11=>['value'=>-1000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'a'],
12=>['value'=>-2000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'b'],
];
return [$config,$records,$now];
}
[$c,$r,$n]=dfixture();$calls=[];$times=[$n-1,$n-1,$n];
$f=D::snapshot($c,fn($id)=>$id===20?D::MBUS_DEVICE:D::MODBUS_DEVICE,
function($id,$module)use(&$calls){$calls[]=$id;return true;},
fn($id)=>$r[$id],function()use(&$times){return array_shift($times);});
sort($calls);dcheck($calls===[20,21],'one refresh per physical parent');
dcheck($f['deviceReadConfirmed']===true&&$f['sourceOldestAt']===$n-1,'successful read confirmation is freshness basis');
dcheck($f['sourceVariableOldestAt']===$n-100,'original VariableUpdated retained separately');
dcheck($f['batteryW']===-3000.0&&$f['usableForTrial'],'unchanged values accepted after confirmed device response');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>false,fn($id)=>$r[$id],function()use(&$times){return array_shift($times);}),
'failed device read rejected');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$reads=0;
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>true,
function($id)use(&$r,&$reads){$reads++;$x=$r[$id];if($reads>3&&$id===10)$x['value']++;return $x;},
function()use(&$times){return array_shift($times);}), 'change after confirmed response rejected');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
dfails(fn()=>D::snapshot($c,fn($id)=>'{00000000-0000-0000-0000-000000000000}',fn()=>true,fn($id)=>$r[$id],
function()use(&$times){return array_shift($times);}), 'unsupported device module rejected');
echo "TOTAL $count confirmed-device-read checks passed. Synthetic calls only.\n";