diff --git a/docs/NETPLAN_V4_AGENT_HANDOFF.md b/docs/NETPLAN_V4_AGENT_HANDOFF.md index 2bae37f..989d7d3 100644 --- a/docs/NETPLAN_V4_AGENT_HANDOFF.md +++ b/docs/NETPLAN_V4_AGENT_HANDOFF.md @@ -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 `, 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. diff --git a/examples/V4ConfirmedFeedback/MANIFEST.json b/examples/V4ConfirmedFeedback/MANIFEST.json new file mode 100644 index 0000000..0984be8 --- /dev/null +++ b/examples/V4ConfirmedFeedback/MANIFEST.json @@ -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" + } +} diff --git a/examples/V4ConfirmedFeedback/README.md b/examples/V4ConfirmedFeedback/README.md new file mode 100644 index 0000000..7c00342 --- /dev/null +++ b/examples/V4ConfirmedFeedback/README.md @@ -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. diff --git a/examples/V4ConfirmedFeedback/build_stage.py b/examples/V4ConfirmedFeedback/build_stage.py new file mode 100644 index 0000000..55a129f --- /dev/null +++ b/examples/V4ConfirmedFeedback/build_stage.py @@ -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.") diff --git a/examples/V4ConfirmedFeedback/feedback-config.json b/examples/V4ConfirmedFeedback/feedback-config.json new file mode 100644 index 0000000..73a9b4e --- /dev/null +++ b/examples/V4ConfirmedFeedback/feedback-config.json @@ -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 + } + ] +} diff --git a/examples/V4ConfirmedFeedback/file_installer.php b/examples/V4ConfirmedFeedback/file_installer.php new file mode 100644 index 0000000..92b37cd --- /dev/null +++ b/examples/V4ConfirmedFeedback/file_installer.php @@ -0,0 +1,127 @@ + $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]; +} diff --git a/examples/V4ConfirmedFeedback/install.php b/examples/V4ConfirmedFeedback/install.php new file mode 100644 index 0000000..4463d31 --- /dev/null +++ b/examples/V4ConfirmedFeedback/install.php @@ -0,0 +1,161 @@ + '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"; +} diff --git a/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php index da72811..0ddfcdd 100644 --- a/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php +++ b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php @@ -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 { - 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()); + + $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']]; + }; + $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); } } diff --git a/libs/NetzfahrplanV4Geraeteabruf.php b/libs/NetzfahrplanV4Geraeteabruf.php new file mode 100644 index 0000000..097943e --- /dev/null +++ b/libs/NetzfahrplanV4Geraeteabruf.php @@ -0,0 +1,101 @@ + $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 + ); + } +} diff --git a/libs/NetzfahrplanV4Rueckmeldung.php b/libs/NetzfahrplanV4Rueckmeldung.php index 1ecd110..df89be3 100644 --- a/libs/NetzfahrplanV4Rueckmeldung.php +++ b/libs/NetzfahrplanV4Rueckmeldung.php @@ -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'] diff --git a/tests/NetzfahrplanV4RueckmeldungTest.php b/tests/NetzfahrplanV4RueckmeldungTest.php index d975f5b..9b1852c 100644 --- a/tests/NetzfahrplanV4RueckmeldungTest.php +++ b/tests/NetzfahrplanV4RueckmeldungTest.php @@ -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');} diff --git a/tests/V4ControlTrial/checks.php b/tests/V4ControlTrial/checks.php index d9cfae7..37cb0bd 100644 --- a/tests/V4ControlTrial/checks.php +++ b/tests/V4ControlTrial/checks.php @@ -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, diff --git a/tests/V4ControlTrial/feedback_checks.php b/tests/V4ControlTrial/feedback_checks.php index 00c384e..7b23e76 100644 --- a/tests/V4ControlTrial/feedback_checks.php +++ b/tests/V4ControlTrial/feedback_checks.php @@ -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'); diff --git a/tests/V4Feedback/checks.php b/tests/V4Feedback/checks.php index 06cb6e7..59ba0db 100644 --- a/tests/V4Feedback/checks.php +++ b/tests/V4Feedback/checks.php @@ -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'); diff --git a/tests/V4Feedback/device_read_checks.php b/tests/V4Feedback/device_read_checks.php new file mode 100644 index 0000000..6b00aca --- /dev/null +++ b/tests/V4Feedback/device_read_checks.php @@ -0,0 +1,51 @@ +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";