194 lines
12 KiB
PHP
194 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
namespace Belevo\EnelixEMS;
|
|
|
|
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
|
require_once __DIR__ . '/NetzfahrplanV4Datenarchiv.php';
|
|
|
|
/** Application measurement acquisition + acknowledged durable delivery. No device writes. */
|
|
trait ManagerNetzfahrplanV4DatenTrait
|
|
{
|
|
private function registriereNetzfahrplanV4Daten(): void
|
|
{
|
|
$this->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'));
|
|
$retry=min(900,60*(2**$failures));
|
|
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()+min(900,60*(2**min(5,$failures+1))))];
|
|
}
|
|
} 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);
|
|
$records=[];$candidate=$cursor;$bytes=0;$lastTime=null;
|
|
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');
|
|
$records[]=$r;$bytes+=strlen($line);$lastTime=$r->capturedAt;
|
|
$candidate=['day'=>$name,'offset'=>ftell($h)];
|
|
}
|
|
} 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')];
|
|
} finally { curl_close($h); }
|
|
}
|
|
}
|