Next gate
Gates are operated by District IT. Current role can review the package but not advance it.
- CHANGELOG.md340 B
- README.md2,835 B
- config/config.example.json395 B
- config/config.schema.json1,770 B
- config/health-probes.json899 B
- fixtures/events.jsonl752 B
- fixtures/expected.json136 B
- manifest.json2,638 B
- mapping/event-map.json1,625 B
- src/Connector.php5,905 B
- src/Transport.php2,856 B
- tests/SandboxTest.php2,039 B
- SHA256SUMS
src/Connector.php
sha256 a74a7a3e933832ebeb10…1<?php 2declare(strict_types=1); 3 4namespace Msad17\Connectors\HikvisionIsapi\B2610051533155; 5 6/** 7 * Hikvision ISAPI — legacy monitor-only 8 * Connector build PK-261005-005 · v0.1.5 · generated 2026-10-05 15:33:15 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 = 'hikvision_isapi'; 19 public const VERSION = '0.1.5'; 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 'videoloss' => ['video.loss', 'p2'], 26 'tamperdetection' => ['video.tamper', 'p2'], 27 'VMD' => ['video.motion', 'info'], 28 'diskfull' => ['recorder.retention', 'p3'], 29 'IO' => ['io.duress', 'p1'], 30 'shelteralarm' => ['io.duress', 'p1'], 31 'facedetection' => ['video.motion', 'info'], 32 'diskerror' => ['recorder.retention', 'p3'], 33 ]; 34 35 /** Dot-paths into the vendor payload. */ 36 public const PATHS = [ 37 'type' => 'eventType', 38 'time' => 'dateTime', 39 'device' => 'ipAddress', 40 'state' => 'eventState', 41 ]; 42 43 /** Endpoints this connector is allowed to call (enforced by sandbox + egress policy). */ 44 public const ENDPOINTS = [ 45 ['GET', '/ISAPI/System/deviceInfo'], 46 ['GET', '/ISAPI/ContentMgmt/Storage/hdd'], 47 ['GET', '/ISAPI/Event/notification/alertStream'], 48 ]; 49 50 /** @param array<string,mixed> $config validated against config/config.schema.json */ 51 public function __construct(private readonly Transport $transport, private readonly array $config) 52 { 53 foreach (['base_url', 'site_id'] as $k) { 54 if (empty($config[$k])) { 55 throw new \InvalidArgumentException("config.{$k} is required"); 56 } 57 } 58 } 59 60 /** Liveness + latency probe. Called by the platform health monitor every poll interval. */ 61 public function probe(): array 62 { 63 $t0 = microtime(true); 64 $res = $this->transport->request('GET', '/ISAPI/System/deviceInfo', null); 65 return [ 66 'ok' => $res['status'] >= 200 && $res['status'] < 300, 67 'status' => $res['status'], 68 'latency_ms' => (int)round((microtime(true) - $t0) * 1000), 69 'stack' => self::STACK, 70 'mode' => self::MODE, 71 ]; 72 } 73 74 /** 75 * Pull and normalize pending vendor events. 76 * @return array{events: list<array<string,mixed>>, unmapped: int, unmapped_keys: list<string>} 77 */ 78 public function pullEvents(): array 79 { 80 $res = $this->transport->request('GET', '/ISAPI/Event/notification/alertStream', null); 81 $out = []; 82 $unmapped = []; 83 foreach (($res['events'] ?? []) as $raw) { 84 $n = is_array($raw) ? $this->normalize($raw) : null; 85 if ($n === null) { 86 $unmapped[] = is_array($raw) ? (string)(self::get($raw, self::PATHS['type']) ?? '?') : '?'; 87 continue; 88 } 89 $out[] = $n; 90 } 91 return ['events' => $out, 'unmapped' => count($unmapped), 'unmapped_keys' => array_values(array_unique($unmapped))]; 92 } 93 94 /** Map one vendor payload to the platform event schema; null = not mapped (logged, never dropped silently). */ 95 public function normalize(array $raw): ?array 96 { 97 $key = self::get($raw, self::PATHS['type']); 98 if (!is_string($key) || !isset(self::EVENT_MAP[$key])) { 99 return null; 100 } 101 [$type, $priority] = self::EVENT_MAP[$key]; 102 $state = self::PATHS['state'] !== null ? self::get($raw, self::PATHS['state']) : null; 103 $rtn = is_scalar($state) && in_array(strtolower((string)$state), ['false', 'inactive', '0', 'normal'], true); 104 $vendorDevice = (string)(self::get($raw, self::PATHS['device']) ?? 'unknown'); 105 106 return [ 107 'schema' => self::SCHEMA, 108 'type' => $type, 109 'priority' => $priority, 110 'rtn' => $rtn, 111 'site_id' => (string)$this->config['site_id'], 112 'device_id' => (string)($this->config['device_map'][$vendorDevice] ?? $vendorDevice), 113 'vendor_device' => $vendorDevice, 114 'vendor_key' => $key, 115 'occurred_at' => self::utc(self::get($raw, self::PATHS['time'])), 116 'source' => self::STACK . '@' . (string)parse_url((string)$this->config['base_url'], PHP_URL_HOST), 117 'raw_sha256' => hash('sha256', (string)json_encode($raw)), 118 ]; 119 } 120 121 /** Credential-free live stream locator; the platform injects auth at session time. */ 122 public function liveUri(string $deviceId): ?string 123 { 124 $tpl = null; 125 return $tpl === null ? null : str_replace('{device}', rawurlencode($deviceId), $tpl); 126 } 127 128 private static function get(mixed $a, ?string $path): mixed 129 { 130 if ($path === null || $path === '') { 131 return null; 132 } 133 foreach (explode('.', $path) as $seg) { 134 if (!is_array($a) || !array_key_exists($seg, $a)) { 135 return null; 136 } 137 $a = $a[$seg]; 138 } 139 return $a; 140 } 141 142 private static function utc(mixed $v): string 143 { 144 if (is_int($v) || is_float($v) || (is_string($v) && ctype_digit($v))) { 145 $n = (int)$v; 146 return gmdate('Y-m-d\TH:i:s\Z', $n > 20000000000 ? intdiv($n, 1000) : $n); 147 } 148 if (is_string($v) && ($t = strtotime($v)) !== false) { 149 return gmdate('Y-m-d\TH:i:s\Z', $t); 150 } 151 return gmdate('Y-m-d\TH:i:s\Z'); 152 } 153}