feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s

Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+115
View File
@@ -0,0 +1,115 @@
<?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,
?callable $pause = null
): 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']);
}
$confirmed = false;
for ($attempt = 1; $attempt <= 4; ++$attempt) {
if ($refresh($parentId, $module) === true) {
$confirmed = true;
break;
}
if ($attempt < 4 && $pause !== null) {
$pause(200000);
}
}
if (!$confirmed) {
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);
// The variable may legitimately receive a newer process value immediately after
// the explicit device read. Keep the confirmed snapshot, but require the source
// identity to remain stable so a reconfigured/reparented variable is never accepted.
$identityKeys = ['parentID', 'ident'];
if (!is_array($again) || array_intersect_key($again, array_flip($identityKeys))
!== array_intersect_key($record, array_flip($identityKeys))) {
throw new InvalidArgumentException(
'feedback_source_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
);
}
}