Next gate
Gates are operated by District IT. Current role can review the package but not advance it.
src/Connector.php
sha256 e5425aa6c13fbb1df5e6…1<?php 2declare(strict_types=1); 3 4namespace Msad17\Connectors\OnvifProfileS\B2610051208381; 5 6/** 7 * ONVIF Profile S/T — cameras & encoders 8 * Connector build PK-261005-001 · v0.1.1 · generated 2026-10-05 12:08:38 EDT 9 * 10 * Generated by Integration Bot for the MSAD 17 Security Platform. 11 * Owner after promotion: District IT. Developer support after promotion: none. 12 * 13 * MODE: READ-ONLY. No write path (unlock, schedule, credential, PTZ) is compiled into 14 * this artifact. Write capability requires a separate, dual-approved package. 15 */ 16final class Connector 17{ 18 public const STACK = 'onvif_profile_s'; 19 public const VERSION = '0.1.1'; 20 public const MODE = 'read_only'; 21 public const SCHEMA = 'msad17.event/1'; 22 23 /** Vendor event key => [normalized type, priority]. Source of truth: mapping/event-map.json */ 24 public const EVENT_MAP = [ 25 'tns1:VideoSource/SignalLoss' => ['video.loss', 'p2'], 26 'tns1:VideoSource/GlobalSceneChange/ImagingService' => ['video.tamper', 'p2'], 27 'tns1:RuleEngine/CellMotionDetector/Motion' => ['video.motion', 'info'], 28 'tns1:Device/Trigger/DigitalInput' => ['io.duress', 'p1'], 29 ]; 30 31 /** Dot-paths into the vendor payload. */ 32 public const PATHS = [ 33 'type' => 'Topic', 34 'time' => 'UtcTime', 35 'device' => 'Source.Device', 36 'state' => 'Data.State', 37 ]; 38 39 /** Endpoints this connector is allowed to call (enforced by sandbox + egress policy). */ 40 public const ENDPOINTS = [ 41 ['POST', '/onvif/device_service'], 42 ['POST', '/onvif/media_service'], 43 ['POST', '/onvif/event_service'], 44 ]; 45 46 /** @param array<string,mixed> $config validated against config/config.schema.json */ 47 public function __construct(private readonly Transport $transport, private readonly array $config) 48 { 49 foreach (['base_url', 'site_id'] as $k) { 50 if (empty($config[$k])) { 51 throw new \InvalidArgumentException("config.{$k} is required"); 52 } 53 } 54 } 55 56 /** Liveness + latency probe. Called by the platform health monitor every poll interval. */ 57 public function probe(): array 58 { 59 $t0 = microtime(true); 60 $res = $this->transport->request('POST', '/onvif/device_service', ['op' => 'GetSystemDateAndTime']); 61 return [ 62 'ok' => $res['status'] >= 200 && $res['status'] < 300, 63 'status' => $res['status'], 64 'latency_ms' => (int)round((microtime(true) - $t0) * 1000), 65 'stack' => self::STACK, 66 'mode' => self::MODE, 67 ]; 68 } 69 70 /** 71 * Pull and normalize pending vendor events. 72 * @return array{events: list<array<string,mixed>>, unmapped: int, unmapped_keys: list<string>} 73 */ 74 public function pullEvents(): array 75 { 76 $res = $this->transport->request('POST', '/onvif/event_service', ['op' => 'PullMessages', 'Timeout' => 'PT5S', 'MessageLimit' => 100]); 77 $out = []; 78 $unmapped = []; 79 foreach (($res['events'] ?? []) as $raw) { 80 $n = is_array($raw) ? $this->normalize($raw) : null; 81 if ($n === null) { 82 $unmapped[] = is_array($raw) ? (string)(self::get($raw, self::PATHS['type']) ?? '?') : '?'; 83 continue; 84 } 85 $out[] = $n; 86 } 87 return ['events' => $out, 'unmapped' => count($unmapped), 'unmapped_keys' => array_values(array_unique($unmapped))]; 88 } 89 90 /** Map one vendor payload to the platform event schema; null = not mapped (logged, never dropped silently). */ 91 public function normalize(array $raw): ?array 92 { 93 $key = self::get($raw, self::PATHS['type']); 94 if (!is_string($key) || !isset(self::EVENT_MAP[$key])) { 95 return null; 96 } 97 [$type, $priority] = self::EVENT_MAP[$key]; 98 $state = self::PATHS['state'] !== null ? self::get($raw, self::PATHS['state']) : null; 99 $rtn = is_scalar($state) && in_array(strtolower((string)$state), ['false', 'inactive', '0', 'normal'], true); 100 $vendorDevice = (string)(self::get($raw, self::PATHS['device']) ?? 'unknown'); 101 102 return [ 103 'schema' => self::SCHEMA, 104 'type' => $type, 105 'priority' => $priority, 106 'rtn' => $rtn, 107 'site_id' => (string)$this->config['site_id'], 108 'device_id' => (string)($this->config['device_map'][$vendorDevice] ?? $vendorDevice), 109 'vendor_device' => $vendorDevice, 110 'vendor_key' => $key, 111 'occurred_at' => self::utc(self::get($raw, self::PATHS['time'])), 112 'source' => self::STACK . '@' . (string)parse_url((string)$this->config['base_url'], PHP_URL_HOST), 113 'raw_sha256' => hash('sha256', (string)json_encode($raw)), 114 ]; 115 } 116 117 /** Credential-free live stream locator; the platform injects auth at session time. */ 118 public function liveUri(string $deviceId): ?string 119 { 120 $tpl = 'rtsp://{host}:554/onvif-media/media.amp?profile={device}'; 121 return $tpl === null ? null : str_replace('{device}', rawurlencode($deviceId), $tpl); 122 } 123 124 private static function get(mixed $a, ?string $path): mixed 125 { 126 if ($path === null || $path === '') { 127 return null; 128 } 129 foreach (explode('.', $path) as $seg) { 130 if (!is_array($a) || !array_key_exists($seg, $a)) { 131 return null; 132 } 133 $a = $a[$seg]; 134 } 135 return $a; 136 } 137 138 private static function utc(mixed $v): string 139 { 140 if (is_int($v) || is_float($v) || (is_string($v) && ctype_digit($v))) { 141 $n = (int)$v; 142 return gmdate('Y-m-d\TH:i:s\Z', $n > 20000000000 ? intdiv($n, 1000) : $n); 143 } 144 if (is_string($v) && ($t = strtotime($v)) !== false) { 145 return gmdate('Y-m-d\TH:i:s\Z', $t); 146 } 147 return gmdate('Y-m-d\TH:i:s\Z'); 148 } 149}