RegisterPropertyBoolean('NetzfahrplanV4MessdatenAktiv', false); $this->RegisterPropertyBoolean('NetzfahrplanV4ArchivAktiv', false); $this->RegisterPropertyString('NetzfahrplanV4Messkonfiguration', '{}'); $this->RegisterPropertyString('NetzfahrplanV4Datensatz', ''); $this->RegisterAttributeString('NetzfahrplanV4DatenCursor', '{}'); $this->RegisterAttributeString('NetzfahrplanV4LetzteDatenquittung', '{}'); $this->RegisterAttributeString('NetzfahrplanV4ArchivTag', ''); $this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0); $this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0); $this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0); $this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199); $this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);"); } private function konfiguriereNetzfahrplanV4Daten(): void { $this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0); if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return; try { $this->v4DatenKonfiguration(); if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) { // One retry after the data-only upgrade, never a cursor reset or a device write. $this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0); $this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0); $this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1); } $this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000); } catch (\Throwable $e) { $this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]); } } private function v4DatenKonfiguration(): array { $id=$this->ReadAttributeString('LizenzInstallationID'); $dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz'); if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$dataset) || !preg_match('/^[0-9a-f-]{36}$/D',$id)) throw new \RuntimeException('Dataset/installation not configured'); $c=json_decode($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'),true,64,JSON_THROW_ON_ERROR); $c=NetzfahrplanV4Messaufnahme::configuration($c); if ($c['installationId']!==$id || ($c['managerId']??null)!==$this->InstanceID) throw new \RuntimeException('Measurement mapping belongs to another manager'); return $c; } private function v4DatenVerzeichnis(): string { $base=rtrim(IPS_GetKernelDir(),'/').'/data/enelix-v4'; $directory=$base.'/'.$this->ReadAttributeString('LizenzInstallationID'); // Fixed application-owned location; no configurable path or arbitrary file access. foreach ([$base,$directory] as $p) { if (is_link($p)) throw new \RuntimeException('Measurement directory symlink refused'); if (!is_dir($p) && !mkdir($p,0700,true) && !is_dir($p)) throw new \RuntimeException('Measurement directory unavailable'); if ((fileperms($p)&0007)!==0) throw new \RuntimeException('Measurement directory must remain private'); } return $directory; } private function v4DatenStatus(array $data): void { $receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR); $data['lastAcknowledgement']=$receipt; $this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'), 'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR)); } public function ErfasseNetzfahrplanV4Daten(): void { if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return; $lock='ENELIX.V4.ApplicationData.'.$this->InstanceID; if (!IPS_SemaphoreEnter($lock,0)) return; try { $c=$this->v4DatenKonfiguration(); $reader=static function(int $id):array { if (!IPS_VariableExists($id)) throw new \RuntimeException('Missing measurement'); $v=IPS_GetVariable($id);$o=IPS_GetObject($id); if (!in_array($v['VariableType'],[1,2],true)) throw new \RuntimeException('Numeric source required'); return ['value'=>GetValue($id),'updated'=>(int)$v['VariableUpdated'],'changed'=>(int)$v['VariableChanged'], 'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']]; }; $dir=$this->v4DatenVerzeichnis(); $archiveResult=null; if ($this->ReadPropertyBoolean('NetzfahrplanV4ArchivAktiv') && $this->ReadAttributeString('NetzfahrplanV4ArchivTag')!==gmdate('Y-m-d')) { try { $archiveCursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR); $archiveResult=NetzfahrplanV4Datenarchiv::run($dir,$archiveCursor,time()); if ($archiveResult['archived']<2 && ($archiveResult['status']??null)!=='writer_busy')$this->WriteAttributeString('NetzfahrplanV4ArchivTag',gmdate('Y-m-d')); } catch (\Throwable $e) { $archiveResult=['status'=>'archive_error','errorType'=>get_class($e),'originalsRetained'=>true]; } } try { $r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time()); NetzfahrplanV4Messaufnahme::append($dir,$r); $result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']), 'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false]; } catch (\Throwable $e) { // Storage/capture failure must not prevent retrying the already durable outbox. $result=['status'=>'capture_error','errorType'=>get_class($e),'dataRetained'=>true]; } if ($archiveResult!==null)$result['archive']=$archiveResult; $last=$this->ReadAttributeInteger('NetzfahrplanV4DatenVersuch'); $failures=min(5,$this->ReadAttributeInteger('NetzfahrplanV4DatenFehlerzahl')); // Planner inputs expire after 120 s. Drain an acknowledged backlog faster, but a // failed HTTP attempt still backs off to 60 s. Once caught up, normal upload is 60 s. $lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR); $acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??'')); $backlog=$acceptedEpoch===false || time()-$acceptedEpoch>120; $retry=$failures>0?60:($backlog?15:60); if (time()>=$last+$retry) { $this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch',time()); try { $result['delivery']=$this->v4DatenUebertragen($dir); if (($result['delivery']['status']??null)==='acknowledged') { $this->WriteAttributeString('NetzfahrplanV4LetzteDatenquittung',json_encode($result['delivery']+['receivedAt'=>gmdate('c')],JSON_THROW_ON_ERROR)); } $this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',0); } catch (\Throwable $e) { $this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',$failures+1); $httpCode=(int)$e->getCode(); $result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e), 'httpStatus'=>$httpCode>=100&&$httpCode<=599?$httpCode:null,'dataRetained'=>true, 'nextAttemptAt'=>gmdate('c',time()+60)]; } } else $result['delivery']=['status'=>'scheduled','nextAttemptAt'=>gmdate('c',$last+$retry)]; $this->v4DatenStatus($result); } catch (\Throwable $e) { // Sensor/configuration exceptions never enter the actuator or module-status path. $this->v4DatenStatus(['status'=>'capture_error','errorType'=>get_class($e)]); } finally { IPS_SemaphoreLeave($lock); } } /** Returns only complete records from the append-only prefix; cursor moves after matching ACK. */ private function v4DatenUebertragen(string $dir): array { if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new \RuntimeException('Forecast licence missing'); $cursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR); $day=$cursor['day']??'';$offset=$cursor['offset']??0; if (!is_string($day) || ($day!==''&&!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)) || !is_int($offset)||$offset<0) throw new \RuntimeException('Invalid data cursor'); $files=glob($dir.'/raw-*.jsonl'); if ($files===false||count($files)>400) throw new \RuntimeException('Measurement journal bounds exceeded'); sort($files,SORT_STRING); $lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR); $acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??'')); if ($acceptedEpoch===false) $acceptedEpoch=null; $records=[];$candidate=$cursor;$bytes=0;$lastTime=null;$lastEpoch=null;$skippedDuplicateTimestamps=0;$skippedAcknowledgedTimestamps=0; foreach ($files as $file) { $name=basename($file); if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||($day!==''&&$name<$day)) continue; if (is_link($file)||!is_file($file)||realpath($file)!==$file) throw new \RuntimeException('Invalid measurement journal'); $h=fopen($file,'rb');if($h===false)throw new \RuntimeException('Cannot open measurement journal'); try { $size=fstat($h)['size'];$position=$name===$day?$offset:0; if ($position>$size||fseek($h,$position)!==0) throw new \RuntimeException('Measurement journal shortened'); while (ftell($h)<$size) { $line=fgets($h,min(262146,$size-ftell($h)+1)); if ($line===false||!str_ends_with($line,"\n")) break; if (strlen($line)>262144) throw new \RuntimeException('Oversized measurement'); if (count($records)>=120||$bytes+strlen($line)>900000) break 2; $r=json_decode($line,false,64,JSON_THROW_ON_ERROR); if (!is_object($r)||($r->installationId??null)!==$this->ReadAttributeString('LizenzInstallationID') ||($r->kind??null)!=='raw_accounting_capture') throw new \RuntimeException('Journal identity invalid'); $epoch=strtotime((string)($r->capturedAt??'')); if ($epoch===false) throw new \RuntimeException('Journal capture timestamp invalid'); $candidate=['day'=>$name,'offset'=>ftell($h)]; if ($acceptedEpoch!==null && $epoch<=$acceptedEpoch) { $skippedAcknowledgedTimestamps++;continue; } $bytes+=strlen($line); if ($lastEpoch!==null && $epoch<$lastEpoch) throw new \RuntimeException('Measurement journal timestamp moved backwards'); if ($lastEpoch!==null && $epoch===$lastEpoch) { $skippedDuplicateTimestamps++;continue; } $records[]=$r;$lastTime=$r->capturedAt;$lastEpoch=$epoch; } } finally { fclose($h); } } if ($records===[]) return ['status'=>'up_to_date']; $id=$this->ReadAttributeString('LizenzInstallationID');$token=$this->ReadAttributeString('PrognoseInstallationsToken'); if ($token==='') throw new \RuntimeException('Existing device token unavailable'); $payload=json_encode(['version'=>1,'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'records'=>$records],JSON_THROW_ON_ERROR); $h=curl_init('https://license.enelix.ch/api/v1/installations/'.rawurlencode($id).'/prognosis/planner-v4/measurements'); if ($h===false) throw new \RuntimeException('Cannot prepare data transfer'); try { curl_setopt_array($h,[CURLOPT_POST=>true,CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>false, CURLOPT_CONNECTTIMEOUT=>2,CURLOPT_TIMEOUT=>8,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2, CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$token], CURLOPT_POSTFIELDS=>$payload]); $answer=curl_exec($h);$code=(int)curl_getinfo($h,CURLINFO_HTTP_CODE); if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code); $ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR); if (!in_array($ack['status']??null,['stored','duplicate'],true) ||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz') ||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch'); $this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR)); return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime, 'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'), 'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps, 'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps]; } finally { curl_close($h); } } }