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
@@ -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";
}