8 || array_keys($sources) !== range(0, count($sources) - 1)) { throw new InvalidArgumentException('V4 benoetigt 1 bis 8 explizite Wirkenergie-Bezugsquellen.'); } $result = []; $parent = null; foreach ($sources as $source) { if (!is_array($source) || count($source) !== 5 || ($source['Messgroesse'] ?? null) !== 'WirkenergieBezug') { throw new InvalidArgumentException('V4 Bezugszaehler: Messgroesse oder Quellenschema ungueltig.'); } $id = $source['VariableID'] ?? null; $pid = $source['ElternID'] ?? null; $ident = $source['Ident'] ?? null; $factor = $source['FaktorZuKWh'] ?? null; if (!is_int($id) || $id <= 0 || !is_int($pid) || $pid <= 0 || !is_string($ident) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $ident) || (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor <= 0 || $factor > 1.0e6 || isset($result[$id])) { throw new InvalidArgumentException('V4 Bezugszaehler: ID, Ident, Einheit oder doppelte Quelle ungueltig.'); } if ($parent !== null && $parent !== $pid) { throw new InvalidArgumentException('V4 T1/T2 muessen zum selben physischen Bezugszaehler gehoeren.'); } $parent = $pid; foreach ($result as $existing) { if ($existing['Ident'] === $ident) { throw new InvalidArgumentException('V4 Bezugszaehler: Register doppelt angegeben.'); } } $result[$id] = ['VariableID' => $id, 'ElternID' => $pid, 'Ident' => $ident, 'FaktorZuKWh' => (float) $factor, 'Messgroesse' => 'WirkenergieBezug']; } ksort($result, SORT_NUMERIC); return array_values($result); } public static function identitaet(array $sources): string { // Source/factor changes invalidate old measurement evidence; names do not matter. return 'symcon-active-import:' . hash('sha256', json_encode(self::quellen($sources), JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); } private static function probe(array $source, callable $read, int $now, int $maxAge): array { $m = $read($source['VariableID']); if (!is_array($m) || ($m['parentID'] ?? null) !== $source['ElternID'] || ($m['ident'] ?? null) !== $source['Ident']) { throw new InvalidArgumentException('V4 Bezugszaehler: Variable passt nicht zum konfigurierten Register.'); } $value = $m['value'] ?? null; $updated = $m['updated'] ?? null; if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0 || !is_int($updated) || $updated <= 0 || $updated > $now || $now - $updated > $maxAge) { throw new InvalidArgumentException('V4 Bezugszaehler: Teilwert fehlt, ist veraltet oder ungueltig.'); } $kwh = (float) $value * $source['FaktorZuKWh']; if (!is_finite($kwh) || $kwh > 1.0e12) { throw new InvalidArgumentException('V4 Bezugszaehler: Energie ausserhalb des Messbereichs.'); } return ['variableId' => $source['VariableID'], 'rawValue' => (float) $value, 'totalKwh' => $kwh, 'updatedAt' => $updated]; } /** $read must return only value, updated, ident, parentID. Never uses an archive fallback. */ public static function lesen(array $sources, callable $read, int $now, int $maxAge = 60, int $maxSkew = 2): array { if ($now <= 0 || $maxAge < 1 || $maxAge > 300 || $maxSkew < 0 || $maxSkew > 5) { throw new InvalidArgumentException('V4 Bezugszaehler: Zeitfenster ungueltig.'); } $sources = self::quellen($sources); $samples = []; foreach ($sources as $source) { $samples[] = self::probe($source, $read, $now, $maxAge); } // Reread all channels to reject concurrent updates, including changes in the same second. foreach ($sources as $i => $source) { if ($samples[$i] !== self::probe($source, $read, $now, $maxAge)) { throw new InvalidArgumentException('V4 Bezugszaehler wurde waehrend des Lesens aktualisiert.'); } } $times = array_column($samples, 'updatedAt'); if (max($times) - min($times) > $maxSkew) { throw new InvalidArgumentException('V4 Bezugszaehler: T1/T2-Zeitpunkte liegen zu weit auseinander.'); } return ['meterId' => self::identitaet($sources), 'quantity' => 'active_import', 'unit' => 'kWh', 'totalKwh' => array_sum(array_column($samples, 'totalKwh')), 'observedAt' => gmdate('c', $now), 'sourceObservationFrom' => gmdate('c', min($times)), 'sourceObservationUntil' => gmdate('c', max($times)), 'components' => $samples, 'billingEvidence' => false, 'historyComplete' => false]; } }