SIMULATION No cameras, door controllers or dispatch links are connected to this host. Device telemetry, video and alarms are synthetic. Platform services, permissions, audit chain, package install and sandbox are real.

PK-261005-005 · Hikvision ISAPI — legacy monitor-only

v0.1.5 · target OHCHS · built 2026-10-05 15:33:15 EDT by it-admin-01
P1 3 P2 2 Unacked 5 Video 17/19 17:17:12 EDT OPSOC Operator 01
BuiltSandboxApprovedPromoted
bundle sha256 185d785b1e2c2237… 13 files mode read_only

Next gate

Gates are operated by District IT. Current role can review the package but not advance it.

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}