'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac', 'LeistungVariableID' => 103, 'SOCVariableID' => 104, 'Leistungsfaktor' => 1.0]]; $objects = [ 101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2], 102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2], 103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2], ]; $installationId = '11111111-2222-4333-8444-555555555555'; $setup = NetzfahrplanV4Einrichtung::erstellen($installationId, 900, ['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60], [['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1000], ['ID' => 'battery-wr', 'Typ' => 'battery']], $assets, static fn(int $id): array => $objects[$id]); return ['capture' => $setup['capture'], 'installationId' => $installationId, 'managerId' => 900, 'batteryId' => 901, 'assets' => $assets, 'controllers' => [['InstanzID' => 901, 'IstleistungVariableID' => 103, 'LadezustandVariableID' => 104, 'NetzleistungVariableID' => 101, 'MesswertMaxAlter' => 60]]]; } function feedbackSetupCreate(array $f): array { return NetzfahrplanV4RueckmeldungEinrichtung::erstellen($f['capture'], $f['installationId'], $f['managerId'], $f['batteryId'], $f['assets'], $f['controllers']); } $f = feedbackSetupFixture(); $config = feedbackSetupCreate($f); feedbackSetupCheck($config === feedbackSetupCreate($f), 'automatic feedback deterministic'); feedbackSetupCheck($config['installationId'] === $f['installationId'] && $config['managerId'] === 900 && $config['batteryInstanceId'] === 901 && $config['assetId'] === 'battery', 'single exact plant and controller binding'); feedbackSetupCheck($config['mode'] === 'physical_sum' && !$config['allowEstimatedForTrial'], 'only separately metered physical feedback'); feedbackSetupCheck(count($config['sources']) === 2 && array_column($config['sources'], 'variableId') === [103, 101], 'battery and grid sampled exactly once without PV double count'); feedbackSetupCheck(array_column($config['sources'], 'role') === ['physical', 'grid'], 'no invented request or gateway sources'); feedbackSetupCheck($config['maxAgeSeconds'] === 60 && $config['maxSkewSeconds'] === 30, 'existing strict freshness defaults'); feedbackSetupCheck($config['idleToleranceW'] === 0.0 && $config['trackingToleranceW'] === 0.0, 'no relaxed tracking tolerance'); feedbackSetupCheck(!str_contains(json_encode($config), 'watchdog') && !str_contains(json_encode($config), 'canDispatch') && !str_contains(json_encode($config), 'confirmedAt'), 'setup creates neither confirmation nor control permission'); $roundtrip = json_decode(json_encode($f), true); feedbackSetupCheck(feedbackSetupCreate($roundtrip) === $config, 'JSON float normalization stable'); $changed = $f; $changed['controllers'][0]['MesswertMaxAlter'] = 8; feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 8 && feedbackSetupCreate($changed)['maxSkewSeconds'] === 8, 'controller stricter freshness preserved'); $changed = $f; $changed['controllers'][0]['MesswertMaxAlter'] = 600; feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 60, 'large local setting cannot relax feedback freshness'); $changed = $f; foreach ($changed['capture']['accounting']['sources'] as &$source) { if ($source['role'] === 'physical_storage') $source['maxAgeSeconds'] = 7; } unset($source); feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 7, 'capture source stricter freshness preserved'); $changed = $f; $changed['assets'][0]['Name'] = 'private name'; $changed['controllers'][0]['Password'] = 'do-not-export'; feedbackSetupCheck(feedbackSetupCreate($changed) === $config, 'only allowlisted nonsecret fields leave setup'); $changed = $f; $changed['installationId'] = $changed['capture']['installationId'] = '99999999-2222-4333-8444-555555555555'; feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed)) !== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'distinct plant cannot share feedback identity'); $changed = $f; foreach ($changed['capture']['accounting']['sources'] as &$source) { if ($source['role'] === 'physical_storage') $source['parentId'] = 205; } unset($source); feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed)) !== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'changed physical identity changes fingerprint'); foreach ([ static function (array &$x): void { $x['managerId']++; }, static function (array &$x): void { $x['batteryId']++; }, static function (array &$x): void { $x['assets'] = []; }, static function (array &$x): void { $x['assets'][] = $x['assets'][0]; }, static function (array &$x): void { $x['controllers'] = []; }, static function (array &$x): void { $x['controllers'][] = $x['controllers'][0]; }, static function (array &$x): void { $x['controllers'][0]['Aktiv'] = false; }, static function (array &$x): void { $x['controllers'][0]['IstleistungVariableID'] = 105; }, static function (array &$x): void { $x['controllers'][0]['LadezustandVariableID'] = 105; }, static function (array &$x): void { $x['controllers'][0]['NetzleistungVariableID'] = 105; }, static function (array &$x): void { $x['controllers'][0]['MesswertMaxAlter'] = 0; }, static function (array &$x): void { unset($x['assets'][0]['SOCVariableID']); }, static function (array &$x): void { $x['assets'][0]['SOCVariableID'] = $x['controllers'][0]['LadezustandVariableID'] = 103; }, static function (array &$x): void { $x['assets'][0]['Virtuell'] = true; }, static function (array &$x): void { $x['assets'][0]['SDLAktiv'] = true; }, static function (array &$x): void { $x['assets'][0]['Kopplung'] = 'dc'; }, static function (array &$x): void { $x['assets'][0]['ID'] = 'different'; }, static function (array &$x): void { $x['assets'][0]['Leistungsfaktor'] = -1; }, static function (array &$x): void { unset($x['capture']['accounting']['storageMode']); }, ] as $index => $change) { $changed = $f; $change($changed); feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'ambiguous or incompatible binding ' . $index); } foreach (['grid', 'physical_storage'] as $role) { foreach ([-1, 1000] as $factor) { $changed = $f; foreach ($changed['capture']['accounting']['sources'] as &$source) { if ($source['role'] === $role) $source['factorToW'] = $factor; } unset($source); feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'controller raw W units ' . $role . ' ' . $factor); } } $changed = $f; $changed['capture']['accounting']['sources'][] = [ 'key' => 'other', 'role' => 'reference', 'variableId' => 105, 'parentId' => 205, 'ident' => 'Other', 'factorToW' => 1.0, 'maxAgeSeconds' => 60, 'dependsOn' => [], ]; feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist accounting roles require explicit mapping'); $changed = $f; $changed['capture']['accounting']['sources'][0]['dependsOn'] = ['grid']; feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'derived physical source cannot become automatic feedback'); $changed = $f; $changed['capture']['extraSources'][] = [ 'key' => 'soc', 'variableId' => 104, 'parentId' => 203, 'ident' => 'SOC', 'unit' => 'percent', ]; feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist extra sources require explicit mapping'); $GLOBALS['feedbackTestNow'] = 1791450000; $GLOBALS['feedbackTestDeviceCalls'] = 0; $GLOBALS['feedbackTestGetterCalls'] = 0; $GLOBALS['feedbackTestManagerRunning'] = false; $GLOBALS['feedbackTestGetterConfig'] = ''; $GLOBALS['feedbackTestReadSucceeds'] = true; $GLOBALS['feedbackTestModules'] = [201 => NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, 203 => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE]; $GLOBALS['feedbackTestObjects'] = [101 => ['ParentID' => 201, 'ObjectIdent' => 'Grid'], 103 => ['ParentID' => 203, 'ObjectIdent' => 'Battery']]; $GLOBALS['feedbackTestValues'] = [101 => 1000, 103 => 250]; function time(): int { return $GLOBALS['feedbackTestNow']; } function usleep(int $duration): void {} function ENELIX_GetV4RueckmeldungKonfiguration(int $manager, int $battery): string { ++$GLOBALS['feedbackTestGetterCalls']; if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('forbidden Manager -> battery cache -> Manager cycle'); if ($manager !== 900 || $battery !== 901) throw new \RuntimeException('wrong getter invocation'); return $GLOBALS['feedbackTestGetterConfig']; } function IPS_SemaphoreEnter(string $key, int $timeout): bool { return true; } function IPS_SemaphoreLeave(string $key): void {} function IPS_VariableExists(int $id): bool { return isset($GLOBALS['feedbackTestObjects'][$id]); } function IPS_GetVariable(int $id): array { return ['VariableUpdated' => time() - 120, 'VariableChanged' => time() - 180]; } function IPS_GetObject(int $id): array { return $GLOBALS['feedbackTestObjects'][$id]; } function GetValue(int $id): int { return $GLOBALS['feedbackTestValues'][$id]; } function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['feedbackTestModules'][$id]); } function IPS_GetInstance(int $id): array { return ['ModuleInfo' => ['ModuleID' => $GLOBALS['feedbackTestModules'][$id]]]; } function MBUS_UpdateValues(int $id): bool { ++$GLOBALS['feedbackTestDeviceCalls']; return $GLOBALS['feedbackTestReadSucceeds']; } function ModBus_RequestRead(int $id): bool { return MBUS_UpdateValues($id); } final class FeedbackSetupBattery { use BatterieNetzfahrplanV4RueckmeldungTrait; public int $InstanceID = 901; public array $managers = [], $buffers = [], $timers = []; public string $property = '{}'; public function register(): void { $this->registriereV4Rueckmeldung(); } public function configure(): void { $this->konfiguriereV4Rueckmeldung(); } public function tick(): void { $this->aktualisiereV4Rueckmeldung(); } public function effective(): string { return $this->effektiveV4RueckmeldungKonfiguration(); } private function zugeordneteManagerIDs(): array { if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('cache must not inspect Manager assignment'); return $this->managers; } protected function ReadPropertyString(string $key): string { return $this->property; } protected function RegisterPropertyString(string $key, string $value): void {} protected function RegisterTimer(string $key, int $interval, string $script): void { $this->timers[$key] = compact('interval', 'script'); } protected function SetTimerInterval(string $key, int $interval): void { $this->timers[$key]['interval'] = $interval; } protected function SetBuffer(string $key, string $value): void { $this->buffers[$key] = $value; } protected function GetBuffer(string $key): string { return $this->buffers[$key] ?? ''; } } $battery = new FeedbackSetupBattery(); $battery->register(); $timerScript = $battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script']; feedbackSetupCheck($timerScript === "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', false);", 'native timer wrapper unchanged'); $battery->configure(); $battery->tick(); feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['interval'] === 1000, 'existing sampler discovers assignment after battery setup'); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0 && $GLOBALS['feedbackTestGetterCalls'] === 0, 'unassigned battery neither queries manager nor reads devices'); $battery->managers = [900]; $battery->tick(); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0, 'empty manager config when learning off performs no device I/O'); $GLOBALS['feedbackTestGetterConfig'] = json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'assignment alone never produces fake fresh sample'); $battery->tick(); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 2, 'late manager assignment uses existing sampler without ApplyChanges'); $sample = json_decode($battery->GetV4BatterieRueckmeldungCache(), true); feedbackSetupCheck($sample['batteryW'] === 250 && $sample['gridW'] === 1000, 'confirmed physical snapshot values'); feedbackSetupCheck($sample['deviceReadConfirmed'] && $sample['separatelyMeasured'] && !$sample['estimated'], 'real adapter response required'); feedbackSetupCheck($sample['sourceVariableOldestAt'] === time() - 120 && $sample['sourceOldestAt'] === time(), 'original unchanged variable timestamps retained separately from device confirmation'); feedbackSetupCheck(!$sample['canDispatch'] && !$sample['gatewayWatchdogVerified'], 'physical sampler never invents permission or watchdog'); $buffers = $battery->buffers; $calls = $GLOBALS['feedbackTestDeviceCalls']; $getters = $GLOBALS['feedbackTestGetterCalls']; $GLOBALS['feedbackTestManagerRunning'] = true; $battery->GetV4BatterieRueckmeldungCache(); feedbackSetupCheck($battery->buffers === $buffers && $GLOBALS['feedbackTestDeviceCalls'] === $calls, 'cache getter remains device I/O free and never renews timestamps'); feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters, 'Manager -> battery cache never calls back into Manager or assignment lookup'); $GLOBALS['feedbackTestManagerRunning'] = false; $context = json_decode($buffers['V4ConfirmedFeedbackContext'], true); feedbackSetupCheck($context['autoMode'] && $context['propertyHash'] === hash('sha256', '{}') && json_decode($context['configuration'], true) === $config, 'sampler stores exact automatic probe context'); foreach ([ ['autoMode', false], ['propertyHash', str_repeat('0', 64)], ['configuration', '{}'], ] as [$key, $value]) { $brokenContext = $context; $brokenContext[$key] = $value; $battery->buffers['V4ConfirmedFeedbackContext'] = json_encode($brokenContext); feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached context drift refused: ' . $key); $battery->buffers = $buffers; } foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId', 'configHash'] as $key) { $brokenSample = $sample; $brokenSample[$key] = is_int($brokenSample[$key]) ? $brokenSample[$key] + 1 : 'wrong'; $battery->buffers['V4ConfirmedFeedback'] = json_encode($brokenSample); feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached probe binding drift refused: ' . $key); $battery->buffers = $buffers; } $battery->property = json_encode($config); feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic to explicit property change invalidates old sample'); $battery->property = '{}'; $GLOBALS['feedbackTestNow'] += 3; feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic cache expires after two seconds without contacting Manager'); $GLOBALS['feedbackTestNow'] -= 3; $battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3); feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic monotonic cache TTL remains two seconds'); $battery->buffers = $buffers; $changedConfig = $config; $changedConfig['maxAgeSeconds'] = 10; $GLOBALS['feedbackTestGetterConfig'] = json_encode($changedConfig); $getters = $GLOBALS['feedbackTestGetterCalls']; feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash'] === $sample['configHash'] && $GLOBALS['feedbackTestGetterCalls'] === $getters, 'still-fresh probe stays bound to sampled configuration; Manager drift is observed only by sampler'); $battery->tick(); feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash'] === NetzfahrplanV4Rueckmeldung::fingerprint($changedConfig), 'next confirmed sample adopts changed automatic config'); $GLOBALS['feedbackTestGetterConfig'] = ''; $calls = $GLOBALS['feedbackTestDeviceCalls']; $battery->tick(); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $battery->buffers['V4ConfirmedFeedback'] === '', 'learning off clears automatic cache without device I/O'); $GLOBALS['feedbackTestGetterConfig'] = json_encode($config); $battery->managers = [900, 902]; $getters = $GLOBALS['feedbackTestGetterCalls']; $battery->tick(); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $GLOBALS['feedbackTestGetterCalls'] === $getters, 'ambiguous manager assignment does not choose or read a device'); $battery->managers = [900]; $battery->property = " \n" . json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . " \n"; $explicit = $battery->property; $getters = $GLOBALS['feedbackTestGetterCalls']; feedbackSetupCheck($battery->effective() === $explicit, 'explicit specialist configuration retained byte for byte'); $battery->tick(); feedbackSetupCheck($battery->property === $explicit && $GLOBALS['feedbackTestGetterCalls'] === $getters, 'explicit mapping never calls manager or rewrites properties'); feedbackSetupCheck($battery->buffers['V4ConfirmedFeedbackConfig'] === hash('sha256', $explicit), 'existing explicit configuration cache hashing unchanged'); $GLOBALS['feedbackTestManagerRunning'] = true; $battery->GetV4BatterieRueckmeldungCache(); $GLOBALS['feedbackTestManagerRunning'] = false; feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters, 'explicit cache also performs no Manager callbacks'); $battery->property .= ' '; feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'explicit property byte drift invalidates probe'); $battery->property = '{}'; $badBinding = $config; $badBinding['managerId'] = 902; $GLOBALS['feedbackTestGetterConfig'] = json_encode($badBinding); $calls = $GLOBALS['feedbackTestDeviceCalls']; feedbackSetupRefuses(static fn() => $battery->tick(), 'manager returned wrong binding refused'); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'wrong automatic binding refused before device I/O'); $GLOBALS['feedbackTestGetterConfig'] = json_encode($config); $GLOBALS['feedbackTestReadSucceeds'] = false; feedbackSetupRefuses(static fn() => $battery->tick(), 'failed physical device response is never confirmed'); feedbackSetupCheck($battery->buffers['V4ConfirmedFeedback'] === '', 'failed response clears old confirmed sample'); $GLOBALS['feedbackTestReadSucceeds'] = true; $GLOBALS['feedbackTestModules'] = [201 => 'unsupported', 203 => 'unsupported']; $calls = $GLOBALS['feedbackTestDeviceCalls']; feedbackSetupRefuses(static fn() => $battery->tick(), 'virtual or unsupported source parent refused'); feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'unsupported parents not refreshed'); feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'] === $timerScript, 'configuration and sampling never replace native timer callback'); echo 'TOTAL ' . $count . ' feedback setup checks passed' . PHP_EOL;