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-002 · Milestone XProtect — API Gateway

v0.1.2 · target OHCHS · built 2026-10-05 12:08:50 EDT by it-admin-01
P1 1 P2 2 Unacked 3 Video 17/19 15:19:32 EDT OPSOC Operator 01
BuiltSandboxApprovedPromoted
bundle sha256 8f3e0eeafebcc97b… 12 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 b3a65146a8699006fb2f…
1<?php
2declare(strict_types=1);
3
4namespace Msad17\Connectors\MilestoneApigw\B2610051208502;
5
6/**
7 * Milestone XProtect — API Gateway
8 * Connector build PK-261005-002 · v0.1.2 · generated 2026-10-05 12:08:50 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 = 'milestone_apigw';
19    public const VERSION = '0.1.2';
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        'Motion Started' => ['video.motion', 'info'],
26        'Communication Error' => ['video.loss', 'p2'],
27        'Tampering' => ['video.tamper', 'p2'],
28        'Recording Storage Full' => ['recorder.retention', 'p3'],
29    ];
30
31    /** Dot-paths into the vendor payload. */
32    public const PATHS = [
33        'type' => 'type',
34        'time' => 'time',
35        'device' => 'source',
36        'state' => null,
37    ];
38
39    /** Endpoints this connector is allowed to call (enforced by sandbox + egress policy). */
40    public const ENDPOINTS = [
41        ['POST', '/API/IDP/connect/token'],
42        ['GET', '/api/rest/v1/cameras'],
43        ['GET', '/api/rest/v1/recordingServers'],
44        ['GET', '/api/ws/events/v1'],
45    ];
46
47    /** @param array<string,mixed> $config validated against config/config.schema.json */
48    public function __construct(private readonly Transport $transport, private readonly array $config)
49    {
50        foreach (['base_url', 'site_id'] as $k) {
51            if (empty($config[$k])) {
52                throw new \InvalidArgumentException("config.{$k} is required");
53            }
54        }
55    }
56
57    /** Liveness + latency probe. Called by the platform health monitor every poll interval. */
58    public function probe(): array
59    {
60        $t0 = microtime(true);
61        $res = $this->transport->request('GET', '/api/rest/v1/recordingServers', null);
62        return [
63            'ok' => $res['status'] >= 200 && $res['status'] < 300,
64            'status' => $res['status'],
65            'latency_ms' => (int)round((microtime(true) - $t0) * 1000),
66            'stack' => self::STACK,
67            'mode' => self::MODE,
68        ];
69    }
70
71    /**
72     * Pull and normalize pending vendor events.
73     * @return array{events: list<array<string,mixed>>, unmapped: int, unmapped_keys: list<string>}
74     */
75    public function pullEvents(): array
76    {
77        $res = $this->transport->request('GET', '/api/ws/events/v1', null);
78        $out = [];
79        $unmapped = [];
80        foreach (($res['events'] ?? []) as $raw) {
81            $n = is_array($raw) ? $this->normalize($raw) : null;
82            if ($n === null) {
83                $unmapped[] = is_array($raw) ? (string)(self::get($raw, self::PATHS['type']) ?? '?') : '?';
84                continue;
85            }
86            $out[] = $n;
87        }
88        return ['events' => $out, 'unmapped' => count($unmapped), 'unmapped_keys' => array_values(array_unique($unmapped))];
89    }
90
91    /** Map one vendor payload to the platform event schema; null = not mapped (logged, never dropped silently). */
92    public function normalize(array $raw): ?array
93    {
94        $key = self::get($raw, self::PATHS['type']);
95        if (!is_string($key) || !isset(self::EVENT_MAP[$key])) {
96            return null;
97        }
98        [$type, $priority] = self::EVENT_MAP[$key];
99        $state = self::PATHS['state'] !== null ? self::get($raw, self::PATHS['state']) : null;
100        $rtn = is_scalar($state) && in_array(strtolower((string)$state), ['false', 'inactive', '0', 'normal'], true);
101        $vendorDevice = (string)(self::get($raw, self::PATHS['device']) ?? 'unknown');
102
103        return [
104            'schema' => self::SCHEMA,
105            'type' => $type,
106            'priority' => $priority,
107            'rtn' => $rtn,
108            'site_id' => (string)$this->config['site_id'],
109            'device_id' => (string)($this->config['device_map'][$vendorDevice] ?? $vendorDevice),
110            'vendor_device' => $vendorDevice,
111            'vendor_key' => $key,
112            'occurred_at' => self::utc(self::get($raw, self::PATHS['time'])),
113            'source' => self::STACK . '@' . (string)parse_url((string)$this->config['base_url'], PHP_URL_HOST),
114            'raw_sha256' => hash('sha256', (string)json_encode($raw)),
115        ];
116    }
117
118    /** Credential-free live stream locator; the platform injects auth at session time. */
119    public function liveUri(string $deviceId): ?string
120    {
121        $tpl = 'xprotect://cameras/{device}/live';
122        return $tpl === null ? null : str_replace('{device}', rawurlencode($deviceId), $tpl);
123    }
124
125    private static function get(mixed $a, ?string $path): mixed
126    {
127        if ($path === null || $path === '') {
128            return null;
129        }
130        foreach (explode('.', $path) as $seg) {
131            if (!is_array($a) || !array_key_exists($seg, $a)) {
132                return null;
133            }
134            $a = $a[$seg];
135        }
136        return $a;
137    }
138
139    private static function utc(mixed $v): string
140    {
141        if (is_int($v) || is_float($v) || (is_string($v) && ctype_digit($v))) {
142            $n = (int)$v;
143            return gmdate('Y-m-d\TH:i:s\Z', $n > 20000000000 ? intdiv($n, 1000) : $n);
144        }
145        if (is_string($v) && ($t = strtotime($v)) !== false) {
146            return gmdate('Y-m-d\TH:i:s\Z', $t);
147        }
148        return gmdate('Y-m-d\TH:i:s\Z');
149    }
150}