@trillboards/edge-sdk 0.2.4 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +4 -3
  2. package/deploy/docker/Dockerfile.cloud-sense +117 -0
  3. package/deploy/docker/Dockerfile.cpu +30 -21
  4. package/deploy/docker/Dockerfile.cuda +30 -21
  5. package/deploy/docker/Dockerfile.openvino +30 -20
  6. package/deploy/docker/README.md +9 -5
  7. package/deploy/docker/connector-entrypoint.sh +122 -0
  8. package/deploy/helm/README.md +1 -1
  9. package/deploy/k8s/daemonset.yaml +1 -1
  10. package/deploy/macos/README.md +161 -0
  11. package/deploy/macos/TrillboardsSetupApp.m +603 -0
  12. package/deploy/macos/build-macos-pkg.sh +588 -0
  13. package/deploy/macos/com.trillboards.edge.plist +62 -0
  14. package/deploy/macos/com.trillboards.litertlm.plist +45 -0
  15. package/deploy/macos/pair.command +519 -0
  16. package/deploy/macos/scripts/ensure-litertlm.sh +62 -0
  17. package/deploy/macos/scripts/postinstall +153 -0
  18. package/deploy/macos/scripts/setup-litertlm.sh +272 -0
  19. package/deploy/macos/test-install-pair.sh +383 -0
  20. package/deploy/macos/test-setup-litertlm-reinstall.sh +109 -0
  21. package/deploy/systemd/install.sh +25 -15
  22. package/deploy/systemd/trillboards-edge.service +4 -1
  23. package/deploy/windows/README.md +70 -0
  24. package/deploy/windows/build-windows.ps1 +225 -0
  25. package/deploy/windows/bundle-windows-installer.ps1 +153 -0
  26. package/deploy/windows/check-feeds.js +26 -0
  27. package/deploy/windows/e2e/fixtures/person.jpg +0 -0
  28. package/deploy/windows/e2e/test-local-sense.ps1 +185 -0
  29. package/deploy/windows/install-service.ps1 +6 -1
  30. package/deploy/windows/install-update.ps1 +101 -0
  31. package/deploy/windows/installer.nsi +138 -0
  32. package/deploy/windows/register-litertlm-task.ps1 +42 -0
  33. package/deploy/windows/register-task.ps1 +44 -0
  34. package/deploy/windows/run-connector.ps1 +69 -0
  35. package/deploy/windows/setup-litertlm.ps1 +361 -0
  36. package/deploy/windows/stop-existing.ps1 +72 -0
  37. package/deploy/windows/trillboards-edge-task.vbs +25 -0
  38. package/deploy/windows/trillboards-edge.cmd +33 -0
  39. package/deploy/windows/trillboards-pair.cmd +276 -0
  40. package/deploy/windows/trillboards-setup.ps1 +456 -0
  41. package/deploy/windows/trillboards-setup.vbs +13 -0
  42. package/dist/AudienceAnalyzeClient.d.ts +136 -0
  43. package/dist/AudienceAnalyzeClient.d.ts.map +1 -0
  44. package/dist/AudienceAnalyzeClient.js +246 -0
  45. package/dist/AudienceAnalyzeClient.js.map +1 -0
  46. package/dist/ChannelProber.d.ts +150 -0
  47. package/dist/ChannelProber.d.ts.map +1 -0
  48. package/dist/ChannelProber.js +887 -0
  49. package/dist/ChannelProber.js.map +1 -0
  50. package/dist/CloudEscalationPolicy.d.ts +143 -0
  51. package/dist/CloudEscalationPolicy.d.ts.map +1 -0
  52. package/dist/CloudEscalationPolicy.js +137 -0
  53. package/dist/CloudEscalationPolicy.js.map +1 -0
  54. package/dist/CloudSensingManager.d.ts +557 -0
  55. package/dist/CloudSensingManager.d.ts.map +1 -0
  56. package/dist/CloudSensingManager.js +1267 -0
  57. package/dist/CloudSensingManager.js.map +1 -0
  58. package/dist/DiscoveryReportClient.d.ts +66 -0
  59. package/dist/DiscoveryReportClient.d.ts.map +1 -0
  60. package/dist/DiscoveryReportClient.js +85 -0
  61. package/dist/DiscoveryReportClient.js.map +1 -0
  62. package/dist/EdgeAgent.d.ts +12 -2
  63. package/dist/EdgeAgent.d.ts.map +1 -1
  64. package/dist/EdgeAgent.js +55 -13
  65. package/dist/EdgeAgent.js.map +1 -1
  66. package/dist/LiteRtVlmProvider.d.ts +193 -0
  67. package/dist/LiteRtVlmProvider.d.ts.map +1 -0
  68. package/dist/LiteRtVlmProvider.js +436 -0
  69. package/dist/LiteRtVlmProvider.js.map +1 -0
  70. package/dist/MosaicSource.d.ts +108 -0
  71. package/dist/MosaicSource.d.ts.map +1 -0
  72. package/dist/MosaicSource.js +274 -0
  73. package/dist/MosaicSource.js.map +1 -0
  74. package/dist/OnvifClient.d.ts +83 -0
  75. package/dist/OnvifClient.d.ts.map +1 -0
  76. package/dist/OnvifClient.js +209 -0
  77. package/dist/OnvifClient.js.map +1 -0
  78. package/dist/PairClient.d.ts +106 -0
  79. package/dist/PairClient.d.ts.map +1 -0
  80. package/dist/PairClient.js +186 -0
  81. package/dist/PairClient.js.map +1 -0
  82. package/dist/RecorderAddressRecovery.d.ts +53 -0
  83. package/dist/RecorderAddressRecovery.d.ts.map +1 -0
  84. package/dist/RecorderAddressRecovery.js +359 -0
  85. package/dist/RecorderAddressRecovery.js.map +1 -0
  86. package/dist/SceneChangeDetector.d.ts +291 -0
  87. package/dist/SceneChangeDetector.d.ts.map +1 -0
  88. package/dist/SceneChangeDetector.js +682 -0
  89. package/dist/SceneChangeDetector.js.map +1 -0
  90. package/dist/SensingProfileClient.d.ts +59 -0
  91. package/dist/SensingProfileClient.d.ts.map +1 -0
  92. package/dist/SensingProfileClient.js +78 -0
  93. package/dist/SensingProfileClient.js.map +1 -0
  94. package/dist/SetupSessionClient.d.ts +114 -0
  95. package/dist/SetupSessionClient.d.ts.map +1 -0
  96. package/dist/SetupSessionClient.js +95 -0
  97. package/dist/SetupSessionClient.js.map +1 -0
  98. package/dist/VlmAdmissionGate.d.ts +126 -0
  99. package/dist/VlmAdmissionGate.d.ts.map +1 -0
  100. package/dist/VlmAdmissionGate.js +166 -0
  101. package/dist/VlmAdmissionGate.js.map +1 -0
  102. package/dist/autoSense.d.ts +266 -0
  103. package/dist/autoSense.d.ts.map +1 -0
  104. package/dist/autoSense.js +606 -0
  105. package/dist/autoSense.js.map +1 -0
  106. package/dist/cli.d.ts +27 -1
  107. package/dist/cli.d.ts.map +1 -1
  108. package/dist/cli.js +526 -27
  109. package/dist/cli.js.map +1 -1
  110. package/dist/combinedSense.d.ts +128 -0
  111. package/dist/combinedSense.d.ts.map +1 -0
  112. package/dist/combinedSense.js +261 -0
  113. package/dist/combinedSense.js.map +1 -0
  114. package/dist/config.d.ts +351 -0
  115. package/dist/config.d.ts.map +1 -1
  116. package/dist/config.js +99 -2
  117. package/dist/config.js.map +1 -1
  118. package/dist/connectorCadence.d.ts +135 -0
  119. package/dist/connectorCadence.d.ts.map +1 -0
  120. package/dist/connectorCadence.js +151 -0
  121. package/dist/connectorCadence.js.map +1 -0
  122. package/dist/dataDir.d.ts +33 -0
  123. package/dist/dataDir.d.ts.map +1 -0
  124. package/dist/dataDir.js +76 -0
  125. package/dist/dataDir.js.map +1 -0
  126. package/dist/deployedCadence.d.ts +167 -0
  127. package/dist/deployedCadence.d.ts.map +1 -0
  128. package/dist/deployedCadence.js +209 -0
  129. package/dist/deployedCadence.js.map +1 -0
  130. package/dist/deployedCadenceSource.d.ts +48 -0
  131. package/dist/deployedCadenceSource.d.ts.map +1 -0
  132. package/dist/deployedCadenceSource.js +74 -0
  133. package/dist/deployedCadenceSource.js.map +1 -0
  134. package/dist/diagnose.d.ts +1 -1
  135. package/dist/diagnose.d.ts.map +1 -1
  136. package/dist/diagnose.js +15 -13
  137. package/dist/diagnose.js.map +1 -1
  138. package/dist/discovery.d.ts +131 -0
  139. package/dist/discovery.d.ts.map +1 -0
  140. package/dist/discovery.js +499 -0
  141. package/dist/discovery.js.map +1 -0
  142. package/dist/gemmaModelOta.d.ts +118 -0
  143. package/dist/gemmaModelOta.d.ts.map +1 -0
  144. package/dist/gemmaModelOta.js +291 -0
  145. package/dist/gemmaModelOta.js.map +1 -0
  146. package/dist/index.d.ts +16 -1
  147. package/dist/index.d.ts.map +1 -1
  148. package/dist/index.js +40 -1
  149. package/dist/index.js.map +1 -1
  150. package/dist/init.d.ts.map +1 -1
  151. package/dist/init.js +25 -87
  152. package/dist/init.js.map +1 -1
  153. package/dist/keepAwake.d.ts +33 -0
  154. package/dist/keepAwake.d.ts.map +1 -0
  155. package/dist/keepAwake.js +54 -0
  156. package/dist/keepAwake.js.map +1 -0
  157. package/dist/legacyRecorderProfile.d.ts +20 -0
  158. package/dist/legacyRecorderProfile.d.ts.map +1 -0
  159. package/dist/legacyRecorderProfile.js +27 -0
  160. package/dist/legacyRecorderProfile.js.map +1 -0
  161. package/dist/localInferenceSchema.d.ts +132 -0
  162. package/dist/localInferenceSchema.d.ts.map +1 -0
  163. package/dist/localInferenceSchema.js +274 -0
  164. package/dist/localInferenceSchema.js.map +1 -0
  165. package/dist/localSenseE2eHarness.d.ts +101 -0
  166. package/dist/localSenseE2eHarness.d.ts.map +1 -0
  167. package/dist/localSenseE2eHarness.js +279 -0
  168. package/dist/localSenseE2eHarness.js.map +1 -0
  169. package/dist/localSensing.d.ts +284 -0
  170. package/dist/localSensing.d.ts.map +1 -0
  171. package/dist/localSensing.js +512 -0
  172. package/dist/localSensing.js.map +1 -0
  173. package/dist/mcp-server.d.ts.map +1 -1
  174. package/dist/mcp-server.js +3 -1
  175. package/dist/mcp-server.js.map +1 -1
  176. package/dist/modelIntegrity.d.ts +80 -0
  177. package/dist/modelIntegrity.d.ts.map +1 -0
  178. package/dist/modelIntegrity.js +194 -0
  179. package/dist/modelIntegrity.js.map +1 -0
  180. package/dist/pairVerify.d.ts +100 -0
  181. package/dist/pairVerify.d.ts.map +1 -0
  182. package/dist/pairVerify.js +296 -0
  183. package/dist/pairVerify.js.map +1 -0
  184. package/dist/qrScanner.d.ts +33 -0
  185. package/dist/qrScanner.d.ts.map +1 -0
  186. package/dist/qrScanner.js +103 -0
  187. package/dist/qrScanner.js.map +1 -0
  188. package/dist/runtimePaths.d.ts +10 -0
  189. package/dist/runtimePaths.d.ts.map +1 -0
  190. package/dist/runtimePaths.js +79 -0
  191. package/dist/runtimePaths.js.map +1 -0
  192. package/dist/sensingRunMode.d.ts +86 -0
  193. package/dist/sensingRunMode.d.ts.map +1 -0
  194. package/dist/sensingRunMode.js +346 -0
  195. package/dist/sensingRunMode.js.map +1 -0
  196. package/dist/setupMode.d.ts +98 -0
  197. package/dist/setupMode.d.ts.map +1 -0
  198. package/dist/setupMode.js +828 -0
  199. package/dist/setupMode.js.map +1 -0
  200. package/dist/setupProgress.d.ts +31 -0
  201. package/dist/setupProgress.d.ts.map +1 -0
  202. package/dist/setupProgress.js +173 -0
  203. package/dist/setupProgress.js.map +1 -0
  204. package/package.json +13 -4
@@ -0,0 +1,887 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.channelKeyFor = channelKeyFor;
37
+ exports.normalizeRecorderBrandHint = normalizeRecorderBrandHint;
38
+ exports.brandPathCandidates = brandPathCandidates;
39
+ exports.credentialFreePathTemplate = credentialFreePathTemplate;
40
+ exports.materializeCredentialPathTemplate = materializeCredentialPathTemplate;
41
+ exports.buildRtspUrl = buildRtspUrl;
42
+ exports.injectCreds = injectCreds;
43
+ exports.pathTemplateOf = pathTemplateOf;
44
+ exports.directStreamChannelKey = directStreamChannelKey;
45
+ exports.sanitizeRtspUrlForLog = sanitizeRtspUrlForLog;
46
+ exports.grabOneFrame = grabOneFrame;
47
+ exports.grabOneFrameAnyTransport = grabOneFrameAnyTransport;
48
+ exports.classifyFfmpegStderr = classifyFfmpegStderr;
49
+ exports.probeHost = probeHost;
50
+ const child_process_1 = require("child_process");
51
+ const crypto_1 = require("crypto");
52
+ const fs = __importStar(require("fs"));
53
+ const os = __importStar(require("os"));
54
+ const path = __importStar(require("path"));
55
+ const OnvifClient_1 = require("./OnvifClient");
56
+ const runtimePaths_1 = require("./runtimePaths");
57
+ // Empty recorder inputs are black except for a small timestamp/channel OSD.
58
+ const EMPTY_FRAME_MIN_DARK_PERCENT = 98;
59
+ const EMPTY_FRAME_PIXEL_THRESHOLD = 32;
60
+ /** Stable, name-free channel identity → the server-side cameraKey. */
61
+ function channelKeyFor(parts) {
62
+ if (parts.serial) {
63
+ const src = parts.sourceToken || parts.profileToken || String(parts.channel ?? 0);
64
+ return `onvif:${parts.serial}:${src}`.slice(0, 128);
65
+ }
66
+ const seed = `${parts.host}:${parts.port}:${parts.channel ?? 0}`;
67
+ return `nvr:${(0, crypto_1.createHash)('sha1').update(seed).digest('hex').slice(0, 16)}`;
68
+ }
69
+ /**
70
+ * Brand RTSP path candidates, SUBSTREAM FIRST (cheaper session slot; good
71
+ * enough for validation), widened to the US SMB installed base per the
72
+ * product review (Hikvision/Dahua/Reolink alone missed Lorex/Swann/XMeye-class
73
+ * recorders — exactly what gas stations bought retail).
74
+ */
75
+ function normalizeRecorderBrandHint(brand) {
76
+ const normalized = String(brand || '').trim().toLowerCase();
77
+ if (!normalized || normalized === 'auto')
78
+ return undefined;
79
+ if (normalized.includes('axis'))
80
+ return 'axis';
81
+ if (normalized.includes('hanwha') ||
82
+ normalized.includes('wisenet') ||
83
+ normalized.includes('samsung techwin'))
84
+ return 'hanwha';
85
+ if (normalized.includes('avigilon') || normalized.includes('motorola solutions'))
86
+ return 'avigilon';
87
+ if (normalized === 'unv' || normalized.includes('uniview'))
88
+ return 'uniview';
89
+ if (normalized.includes('bosch'))
90
+ return 'bosch';
91
+ if (normalized.includes('ubiquiti') || normalized.includes('unifi'))
92
+ return 'ubiquiti';
93
+ if (normalized.includes('mobotix'))
94
+ return 'mobotix';
95
+ // Honeywell Commercial Security (Honeywell Building Automation — NOT Resideo,
96
+ // which took only the residential line in the 2018 spinoff). Deliberately
97
+ // matched BEFORE the Hikvision/Dahua families so a Honeywell-branded unit
98
+ // never falls into one of them by substring: Honeywell is a Dahua OEM through
99
+ // April 2022 and a Vivotek OEM since, and the "Honeywell is a Hikvision OEM"
100
+ // lists that circulate carry no model map, firmware or UI evidence at all.
101
+ // `maxpro` covers the MAXPRO NVR/VMS appliances, which report their own name.
102
+ if (normalized.includes('honeywell') || normalized.includes('maxpro'))
103
+ return 'honeywell';
104
+ if (normalized.includes('hikvision'))
105
+ return 'hikvision';
106
+ if (normalized.includes('hilook'))
107
+ return 'hilook';
108
+ if (normalized.includes('annke'))
109
+ return 'annke';
110
+ if (normalized.includes('dahua'))
111
+ return 'dahua';
112
+ if (normalized.includes('lorex'))
113
+ return 'lorex';
114
+ if (normalized.includes('amcrest')) {
115
+ return /recorder|nvr|dvr/.test(normalized) ? 'amcrest-recorder' : 'amcrest';
116
+ }
117
+ if (normalized.includes('reolink'))
118
+ return 'reolink';
119
+ if (normalized.includes('invid'))
120
+ return 'invid';
121
+ if (normalized.includes('swann'))
122
+ return 'swann';
123
+ if (normalized === 'tvt' || normalized.includes('tvt digital'))
124
+ return 'tvt';
125
+ if (normalized === 'generic-dvr' ||
126
+ normalized.includes('xmeye') ||
127
+ normalized.includes('xiongmai') ||
128
+ normalized.includes('netsurveillance') ||
129
+ normalized.includes('net-surveillance') ||
130
+ normalized.includes('icsee')) {
131
+ return 'xmeye';
132
+ }
133
+ return normalized;
134
+ }
135
+ function isXmeyeBrand(brand) {
136
+ return normalizeRecorderBrandHint(brand) === 'xmeye';
137
+ }
138
+ function brandPathCandidates(brand, ch, credentials) {
139
+ const b = normalizeRecorderBrandHint(brand) || '';
140
+ const hik = [`/Streaming/Channels/${ch}02`, `/Streaming/Channels/${ch}01`];
141
+ const dahua = [
142
+ `/cam/realmonitor?channel=${ch}&subtype=1`,
143
+ `/cam/realmonitor?channel=${ch}&subtype=0`,
144
+ ];
145
+ const uniview = [`/unicast/c${ch}/s1/live`, `/unicast/c${ch}/s0/live`];
146
+ const xmeyeUser = encodeURIComponent(credentials?.username ?? 'admin');
147
+ const xmeyePassword = encodeURIComponent(credentials?.password ?? '');
148
+ const hanwha = [`/LiveChannel/${Math.max(0, ch - 1)}/media.smp`];
149
+ const xmeye = [
150
+ `/user=${xmeyeUser}&password=${xmeyePassword}&channel=${ch}&stream=1.sdp?real_stream`,
151
+ `/user=${xmeyeUser}&password=${xmeyePassword}&channel=${ch}&stream=0.sdp?real_stream`,
152
+ ];
153
+ const h264Preview = [
154
+ `/h264Preview_${String(ch).padStart(2, '0')}_sub`,
155
+ `/h264Preview_${String(ch).padStart(2, '0')}_main`,
156
+ ];
157
+ const reolinkCurrent = [
158
+ `/Preview_${String(ch).padStart(2, '0')}_sub`,
159
+ `/Preview_${String(ch).padStart(2, '0')}_main`,
160
+ ];
161
+ // Honeywell. EVERY string here is lifted from a Honeywell PDF with a worked
162
+ // example — nothing is inferred from a family resemblance, because a guessed
163
+ // path that answers 401 is indistinguishable from a wrong password and trips
164
+ // the auth breaker on the owner's own recorder.
165
+ //
166
+ // /live1sN.sdp and /live/<Camera ID>/<Streaming ID> — 35 + 60 Series
167
+ // cameras, both forms printed with examples in 800-26904-F (2024-11-27):
168
+ // "Mainstream: rtsp://192.168.0.108:554/live1s1.sdp", ".../live/1/1".
169
+ // Stream ids: 1 main, 2 sub, 3 third.
170
+ // /live.sdp, /live2.sdp — 30 Series (a.k.a. Performance Series) cameras.
171
+ // Documented as `rtsp://<ip>:<rtsp port>/<access name for stream 1 to 3>`
172
+ // with live.sdp as the factory access name.
173
+ //
174
+ // The Dahua family follows because the Performance Series HQA DVR IS a Dahua
175
+ // XVR: its own user guide (800-23287-A) defaults TCP to 37777, offers 37776
176
+ // as the alternate HTTP port, and lists CVI/AHD/CVBS channel types.
177
+ //
178
+ // What is deliberately ABSENT: a per-channel path for a Honeywell NVR. The 30
179
+ // Series NVR, 35 Series NVR, 35 Series Pro NVR and HQA DVR guides all confirm
180
+ // the box SERVES RTSP on 554 and none of them publishes the channel URL; every
181
+ // rtsp:// example in those manuals is the recorder acting as a CLIENT adding a
182
+ // third-party camera. The ONVIF conformance database agrees on the shape of
183
+ // the gap — of 638 Honeywell registrations, 525 are `device` and all 111
184
+ // recorder-prefixed models (HN*, HEN*, MAXPRO, HUS, HNCNV, PROWATCH) are
185
+ // `client`, category "Video Management System". Honeywell recorders CONSUME
186
+ // ONVIF; they do not answer GetProfiles. So beyond channel 1 the only
187
+ // evidence-backed candidates are the Dahua ones, and inventing a
188
+ // `/live/<ch>/…` recorder path to fill the hole would be exactly the guess
189
+ // this comment exists to refuse.
190
+ const honeywell = ch === 1
191
+ ? [
192
+ `/live/1/2`,
193
+ `/live1s2.sdp`,
194
+ `/live2.sdp`,
195
+ `/live/1/1`,
196
+ `/live1s1.sdp`,
197
+ `/live.sdp`,
198
+ ...dahua,
199
+ ]
200
+ : dahua;
201
+ const tvt = [
202
+ `/?chID=${ch}&streamType=sub1&linkType=tcp`,
203
+ `/?chID=${ch}&streamType=main&linkType=tcp`,
204
+ `/chID=${ch}&streamType=sub1&linkType=tcp`,
205
+ `/chID=${ch}&streamType=main&linkType=tcp`,
206
+ `/ch${String(ch).padStart(2, '0')}/1`,
207
+ `/ch${String(ch).padStart(2, '0')}/0`,
208
+ ];
209
+ switch (b) {
210
+ case 'hikvision':
211
+ case 'hilook':
212
+ case 'annke': // Hikvision OEM
213
+ return hik;
214
+ case 'dahua':
215
+ case 'lorex': // Dahua OEM
216
+ case 'amcrest': // Amcrest IP-camera firmware uses Dahua realmonitor paths
217
+ return dahua;
218
+ case 'amcrest-recorder':
219
+ case 'amcrest-nvr':
220
+ case 'amcrest-dvr':
221
+ // Keep recorder and IP-camera families explicit: unsupported Amcrest
222
+ // paths can answer 401, which is indistinguishable from bad credentials
223
+ // until a stream succeeds and must not bypass the auth circuit breaker.
224
+ return h264Preview;
225
+ case 'reolink':
226
+ // Preserve the installed-base h264Preview paths first, then cover the
227
+ // current Reolink NVR Preview_XX form without risking old deployments.
228
+ return [...h264Preview, ...reolinkCurrent];
229
+ case 'honeywell':
230
+ return honeywell;
231
+ case 'hanwha':
232
+ return hanwha;
233
+ case 'axis':
234
+ // Axis cameras expose this documented path; Axis recorder appliances
235
+ // otherwise remain ONVIF/direct-camera integrations rather than guessed
236
+ // per-channel proxies.
237
+ return ch === 1 ? ['/axis-media/media.amp'] : [];
238
+ case 'uniview':
239
+ case 'unv':
240
+ return uniview;
241
+ case 'avigilon':
242
+ case 'bosch':
243
+ case 'mobotix':
244
+ case 'ubiquiti':
245
+ // These enterprise/protect appliances do not share one verified
246
+ // recorder-level channel proxy. Use their ONVIF media profiles or a
247
+ // direct camera/Protect RTSP(S) link instead of inventing paths.
248
+ return [];
249
+ case 'xmeye':
250
+ case 'icsee':
251
+ case 'generic-dvr':
252
+ return xmeye;
253
+ case 'invid':
254
+ case 'tvt':
255
+ case 'swann': // mixed OEM: TVT-style first, then the two big families
256
+ return [...tvt, ...hik, ...dahua];
257
+ default:
258
+ // Unknown brand: walk common retail/OEM families (substreams first).
259
+ // TVT/INVID-class recorders can return a misleading 401 on Dahua-style
260
+ // paths even when the credentials are correct, so try TVT before Dahua.
261
+ return [...hik, ...tvt, ...dahua, ...uniview];
262
+ }
263
+ }
264
+ /** Remove XM query credentials before a probe result leaves the connector. */
265
+ function credentialFreePathTemplate(pathValue, brand) {
266
+ if (!isXmeyeBrand(brand))
267
+ return pathValue;
268
+ return pathValue.replace(/^\/user=[^&]*&password=[^&]*(&channel=)/i, '/user={username}&password={password}$1');
269
+ }
270
+ /** Restore a credential-free path recipe while writing the local-only config. */
271
+ function materializeCredentialPathTemplate(pathTemplate, username, password) {
272
+ return pathTemplate
273
+ .replace(/\{username\}/g, encodeURIComponent(username))
274
+ .replace(/\{password\}/g, encodeURIComponent(password));
275
+ }
276
+ /** Percent-encode credentials into an rtsp:// URL (the Hiten `pass@123` class). */
277
+ function buildRtspUrl(host, port, path, username, password) {
278
+ const u = encodeURIComponent(username);
279
+ const p = encodeURIComponent(password);
280
+ const norm = path.startsWith('/') ? path : `/${path}`;
281
+ return `rtsp://${u}:${p}@${host}:${port}${norm}`;
282
+ }
283
+ /** Inject creds into an ONVIF-returned rtsp URI (they usually come credless). */
284
+ function injectCreds(uri, username, password) {
285
+ try {
286
+ const u = new URL(uri);
287
+ u.username = encodeURIComponent(username);
288
+ u.password = encodeURIComponent(password);
289
+ return u.toString();
290
+ }
291
+ catch {
292
+ return uri;
293
+ }
294
+ }
295
+ /** Strip credentials+host → the storable path template. */
296
+ function pathTemplateOf(uri) {
297
+ try {
298
+ const u = new URL(uri);
299
+ return `${u.pathname}${u.search || ''}`;
300
+ }
301
+ catch {
302
+ return uri.replace(/^rtsp:\/\/[^/]+/i, '');
303
+ }
304
+ }
305
+ function directStreamChannelKey(streamUrl) {
306
+ return `direct:${(0, crypto_1.createHash)('sha1').update(streamUrl).digest('hex').slice(0, 24)}`;
307
+ }
308
+ function sanitizeRtspUrlForLog(text) {
309
+ return String(text || '').replace(/\brtsps?:\/\/[^\s'"<>]+/gi, (raw) => {
310
+ try {
311
+ const u = new URL(raw);
312
+ const auth = u.username || u.password ? '***:***@' : '';
313
+ const pathPart = u.pathname && u.pathname !== '/' ? '/[redacted]' : '';
314
+ const queryPart = u.search ? '?[redacted]' : '';
315
+ return `${u.protocol}//${auth}${u.host}${pathPart}${queryPart}`;
316
+ }
317
+ catch {
318
+ return raw
319
+ .replace(/\/\/([^:]+):([^@]+)@/, '//***:***@')
320
+ .replace(/^(rtsps?:\/\/[^/?#\s]+).+$/i, '$1/[redacted]');
321
+ }
322
+ });
323
+ }
324
+ function escapeFfconcatValue(value) {
325
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
326
+ }
327
+ function buildFrameInputArgs(rtspUrl, timeoutMs, transport) {
328
+ if (!/^rtsps?:\/\//i.test(rtspUrl)) {
329
+ return {
330
+ args: ['-rw_timeout', String(timeoutMs * 1000), '-i', rtspUrl],
331
+ cleanup: () => undefined,
332
+ };
333
+ }
334
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trillboards-rtsp-'));
335
+ const file = path.join(dir, 'input.ffconcat');
336
+ fs.writeFileSync(file, [
337
+ 'ffconcat version 1.0',
338
+ `file '${escapeFfconcatValue(rtspUrl)}'`,
339
+ `option rtsp_transport ${transport}`,
340
+ `option timeout ${timeoutMs * 1000}`,
341
+ '',
342
+ ].join('\n'), { mode: 0o600 });
343
+ try {
344
+ fs.chmodSync(file, 0o600);
345
+ }
346
+ catch {
347
+ /* best-effort on platforms with ACL semantics */
348
+ }
349
+ return {
350
+ args: [
351
+ '-protocol_whitelist', 'file,crypto,tcp,tls,rtp,rtsp,udp,http,https',
352
+ '-safe', '0',
353
+ '-f', 'concat',
354
+ '-i', file,
355
+ ],
356
+ cleanup: () => {
357
+ try {
358
+ fs.unlinkSync(file);
359
+ }
360
+ catch {
361
+ /* already gone */
362
+ }
363
+ try {
364
+ fs.rmdirSync(dir);
365
+ }
366
+ catch {
367
+ /* best-effort cleanup */
368
+ }
369
+ },
370
+ };
371
+ }
372
+ function blackFramePercent(stderr) {
373
+ const matches = [...stderr.matchAll(/\bpblack:(\d+(?:\.\d+)?)/gi)];
374
+ if (matches.length === 0)
375
+ return null;
376
+ return Math.max(...matches.map((match) => Number(match[1])).filter(Number.isFinite));
377
+ }
378
+ /**
379
+ * Grab ONE frame (downscaled JPEG) from an RTSP URL via the bundled ffmpeg.
380
+ * `-timeout` (µs) is the rtsp demuxer's socket timeout — NOT `-rw_timeout`
381
+ * (the #7946 regression). Classification reads ffmpeg's stderr.
382
+ */
383
+ function grabOneFrame(rtspUrl, { ffmpegPath, timeoutMs = 6000, spawner = child_process_1.spawn, transport = 'tcp', } = {}) {
384
+ return new Promise((resolve) => {
385
+ const input = buildFrameInputArgs(rtspUrl, timeoutMs, transport);
386
+ const resolvedFfmpegPath = (0, runtimePaths_1.resolveFfmpegBinaryPath)(ffmpegPath);
387
+ const args = [
388
+ '-hide_banner',
389
+ '-loglevel', 'info',
390
+ ...input.args,
391
+ '-frames:v', '1',
392
+ '-vf',
393
+ `blackframe=amount=${EMPTY_FRAME_MIN_DARK_PERCENT}:threshold=${EMPTY_FRAME_PIXEL_THRESHOLD},scale=320:-2`,
394
+ '-q:v', '7',
395
+ '-f', 'image2',
396
+ 'pipe:1',
397
+ ];
398
+ let child;
399
+ try {
400
+ child = spawner(resolvedFfmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
401
+ }
402
+ catch (err) {
403
+ input.cleanup();
404
+ resolve({ ok: false, failureClass: 'rtsp_unreachable', message: `ffmpeg spawn failed: ${err.message}` });
405
+ return;
406
+ }
407
+ const out = [];
408
+ const errChunks = [];
409
+ let settled = false;
410
+ const settle = (r) => {
411
+ if (settled)
412
+ return;
413
+ settled = true;
414
+ input.cleanup();
415
+ try {
416
+ child.kill('SIGKILL');
417
+ }
418
+ catch {
419
+ /* gone */
420
+ }
421
+ resolve(r);
422
+ };
423
+ // Hard wall-clock guard ABOVE the demuxer timeout (a hung decode must not
424
+ // wedge the probe queue).
425
+ const timer = setTimeout(() => settle({ ok: false, failureClass: 'rtsp_timeout', message: `no frame within ${timeoutMs}ms` }), timeoutMs + 2000);
426
+ child.stdout?.on('data', (c) => out.push(c));
427
+ child.stderr?.on('data', (c) => errChunks.push(c));
428
+ child.on('error', (err) => {
429
+ clearTimeout(timer);
430
+ settle({ ok: false, failureClass: 'rtsp_unreachable', message: err.message });
431
+ });
432
+ child.on('close', () => {
433
+ clearTimeout(timer);
434
+ const stderr = Buffer.concat(errChunks).toString('utf8');
435
+ const jpeg = Buffer.concat(out);
436
+ if (jpeg.length > 100) {
437
+ const darkPercent = blackFramePercent(stderr);
438
+ if (darkPercent != null && darkPercent >= EMPTY_FRAME_MIN_DARK_PERCENT) {
439
+ settle({
440
+ ok: false,
441
+ failureClass: 'empty_channel',
442
+ message: 'Stream is effectively black; no camera image was detected.',
443
+ });
444
+ return;
445
+ }
446
+ settle({ ok: true, jpegBase64: jpeg.toString('base64') });
447
+ return;
448
+ }
449
+ settle({ ok: false, ...classifyFfmpegStderr(stderr) });
450
+ });
451
+ });
452
+ }
453
+ function shouldTryNextTransport(failureClass) {
454
+ return (failureClass === 'rtsp_timeout' ||
455
+ failureClass === 'nvr_busy' ||
456
+ failureClass === 'no_frames' ||
457
+ failureClass === 'decode_error');
458
+ }
459
+ async function grabOneFrameAnyTransport(rtspUrl, opts) {
460
+ let strongestFailure = null;
461
+ for (const transport of ['tcp', 'udp']) {
462
+ const grab = await grabOneFrame(rtspUrl, { ...opts, transport });
463
+ if (grab.ok) {
464
+ return { ...grab, transport };
465
+ }
466
+ const failed = { ...grab, transport };
467
+ if (!strongestFailure ||
468
+ failureSignalRank(failed.failureClass) >= failureSignalRank(strongestFailure.failureClass)) {
469
+ strongestFailure = failed;
470
+ }
471
+ if (!shouldTryNextTransport(grab.failureClass)) {
472
+ break;
473
+ }
474
+ }
475
+ return strongestFailure || { ok: false, failureClass: 'no_frames', message: 'stream produced no frame' };
476
+ }
477
+ /** Map ffmpeg's stderr to a failure class the portal can act on. */
478
+ function classifyFfmpegStderr(stderr) {
479
+ // Classify on CREDENTIAL-FREE text, and match status codes as whole tokens.
480
+ //
481
+ // ffmpeg echoes the RTSP URL it failed to open — credentials included — into
482
+ // its own error lines ("Impossible to open 'rtsp://admin:pass%40123@…'").
483
+ // `@` percent-encodes to `%40`, so the extremely common recorder password
484
+ // shape `pass@123` renders as `pass%40123`, which CONTAINS the substring
485
+ // `401`. Matching a bare `401` against that raw buffer classified a perfectly
486
+ // correct login as `rtsp_auth`, which trips the auth circuit breaker in
487
+ // probeHost() and ABORTS the entire host probe with "Recorder rejected the
488
+ // username/password" — so the owner re-types a password that was never wrong
489
+ // and the camera is never added. Same shape for `404` (`@4…` → `%404`) and
490
+ // for a channel path that legitimately contains the digits (channel 4's
491
+ // Hikvision candidate is `/Streaming/Channels/401`).
492
+ //
493
+ // Redacting first removes the credentials and the path from every rtsp URL in
494
+ // the buffer; the word boundaries stop any remaining digit run inside a
495
+ // longer token (e.g. an ffconcat temp-dir name) from reading as a status code.
496
+ const s = sanitizeRtspUrlForLog(stderr).toLowerCase();
497
+ const line = sanitizeRtspUrlForLog(stderr.trim().split('\n').pop() || '').slice(0, 200);
498
+ if (/\b401\b|unauthorized/.test(s))
499
+ return { failureClass: 'rtsp_auth', message: line };
500
+ if (/\b(?:453|455)\b|not enough bandwidth|session limit|too many/.test(s))
501
+ return { failureClass: 'nvr_busy', message: line };
502
+ if (/\b404\b|not found|describe.*(fail|error)|no such|stream not exist/.test(s))
503
+ return { failureClass: 'bad_path', message: line };
504
+ if (/refused|no route|unreachable|network is down/.test(s))
505
+ return { failureClass: 'rtsp_unreachable', message: line };
506
+ if (/timed? ?out|timeout/.test(s))
507
+ return { failureClass: 'rtsp_timeout', message: line };
508
+ if (/(missing pps|non-existing pps|missing sps|non-existing sps|decode_slice_header|illegal poc|invalid nal|corrupt(?:ed)?|error while decoding|concealing \d+|reference picture missing|mmco|cabac|sps_id|pps_id)/.test(s)) {
509
+ return { failureClass: 'decode_error', message: line };
510
+ }
511
+ if (/immediate exit|end of file|could not find codec|invalid data/.test(s))
512
+ return { failureClass: 'no_frames', message: line };
513
+ return { failureClass: 'no_frames', message: line || 'stream produced no frame' };
514
+ }
515
+ function failureSignalRank(failureClass) {
516
+ switch (failureClass) {
517
+ case 'bad_path':
518
+ case 'empty_channel':
519
+ return 0;
520
+ case 'no_frames':
521
+ return 1;
522
+ case 'decode_error':
523
+ return 2;
524
+ case 'rtsp_timeout':
525
+ return 3;
526
+ case 'rtsp_unreachable':
527
+ return 4;
528
+ case 'nvr_busy':
529
+ return 5;
530
+ case 'rtsp_forbidden':
531
+ case 'rtsp_auth':
532
+ return 6;
533
+ default:
534
+ return -1;
535
+ }
536
+ }
537
+ /** Tiny promise pool (per-host RTSP concurrency cap). */
538
+ async function pooled(items, limit, fn) {
539
+ let next = 0;
540
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
541
+ while (next < items.length) {
542
+ const i = next++;
543
+ await fn(items[i], i);
544
+ }
545
+ });
546
+ await Promise.all(workers);
547
+ }
548
+ /**
549
+ * Probe one recorder. Streams results via opts.onResult as channels settle and
550
+ * returns the full list. NEVER throws for per-channel problems — only for
551
+ * programmer errors; credential rejection yields a single `rtsp_auth` result
552
+ * for the host (the circuit-breaker contract).
553
+ */
554
+ async function probeHost(target, opts = {}) {
555
+ const host = target.host;
556
+ const rtspPort = target.port ?? 554;
557
+ const concurrency = opts.concurrency ?? 1;
558
+ const requestedBrand = normalizeRecorderBrandHint(target.brandHint);
559
+ const probeStartedAt = Date.now();
560
+ let grabTimeoutMs = opts.grabTimeoutMs ?? 6000;
561
+ let deadline = probeStartedAt + (opts.totalBudgetMs ?? 90000);
562
+ const applyResolvedBrandBudgets = (resolvedBrand) => {
563
+ if (resolvedBrand !== 'xmeye')
564
+ return;
565
+ if (opts.grabTimeoutMs == null)
566
+ grabTimeoutMs = 10000;
567
+ if (opts.totalBudgetMs == null)
568
+ deadline = probeStartedAt + 180000;
569
+ };
570
+ applyResolvedBrandBudgets(requestedBrand);
571
+ const results = [];
572
+ const emit = async (r) => {
573
+ const recorderResult = { ...r, sourceType: 'recorder' };
574
+ results.push(recorderResult);
575
+ if (opts.onResult)
576
+ await opts.onResult(recorderResult);
577
+ };
578
+ const budgetLeft = () => deadline - Date.now();
579
+ // ── Stage 1: prefer ONVIF enumeration, but do not assume ONVIF and RTSP
580
+ // share an auth realm. Older recorders often require a separately-enabled
581
+ // ONVIF account while accepting the owner's normal login for RTSP.
582
+ let serial = null;
583
+ let brand = requestedBrand;
584
+ let onvifProfiles = [];
585
+ let onvifAvailable = false;
586
+ let onvifAuthRejected = false;
587
+ const onvifPorts = target.onvifPort ? [target.onvifPort] : [80, 8899];
588
+ for (const onvifPort of onvifPorts) {
589
+ const client = new OnvifClient_1.OnvifClient({ host, port: onvifPort, username: target.username, password: target.password, timeoutMs: 5000 }, opts.poster);
590
+ try {
591
+ const info = await client.getDeviceInformation();
592
+ serial = info.serialNumber;
593
+ if (!brand && info.manufacturer)
594
+ brand = normalizeRecorderBrandHint(info.manufacturer);
595
+ applyResolvedBrandBudgets(brand);
596
+ onvifAvailable = true;
597
+ try {
598
+ onvifProfiles = await client.getProfiles();
599
+ }
600
+ catch (err) {
601
+ if (err.kind === 'auth')
602
+ throw err;
603
+ onvifProfiles = []; // device service ok, media flaky → brand fallback
604
+ }
605
+ // Resolve real URIs per profile (still ONVIF-authed — no RTSP yet).
606
+ if (onvifProfiles.length > 0) {
607
+ const uris = new Map();
608
+ for (const p of onvifProfiles) {
609
+ if (budgetLeft() < 10000)
610
+ break;
611
+ try {
612
+ const uri = await client.getStreamUri(p.token);
613
+ if (uri)
614
+ uris.set(p.token, uri);
615
+ }
616
+ catch (err) {
617
+ if (err.kind === 'auth')
618
+ throw err;
619
+ }
620
+ }
621
+ // ── Stage 2a: validate each REAL URI (substreams naturally dedupe by
622
+ // videoSourceToken: keep ONE profile per source — prefer the later,
623
+ // typically lower-res, profile for validation cheapness).
624
+ const bySource = new Map();
625
+ for (const p of onvifProfiles) {
626
+ const uri = uris.get(p.token);
627
+ if (!uri)
628
+ continue;
629
+ const src = p.videoSourceToken || p.token;
630
+ bySource.set(src, { profile: p, uri }); // later profile wins (substream)
631
+ }
632
+ const entries = [...bySource.entries()];
633
+ let channelIdx = 0;
634
+ await pooled(entries, concurrency, async ([src, { profile, uri }]) => {
635
+ if (budgetLeft() <= 0)
636
+ return;
637
+ const channel = ++channelIdx;
638
+ const grab = await grabOneFrameAnyTransport(injectCreds(uri, target.username, target.password), {
639
+ ffmpegPath: opts.ffmpegPath,
640
+ timeoutMs: Math.min(grabTimeoutMs, Math.max(2000, budgetLeft())),
641
+ spawner: opts.spawner,
642
+ });
643
+ const key = channelKeyFor({ serial, sourceToken: src, profileToken: profile.token, host, port: rtspPort, channel });
644
+ if (grab.ok) {
645
+ await emit({
646
+ channelKey: key,
647
+ status: 'validated',
648
+ name: profile.name || undefined,
649
+ brand,
650
+ host,
651
+ port: rtspPort,
652
+ channel,
653
+ serial: serial || undefined,
654
+ profileToken: profile.token,
655
+ pathTemplate: credentialFreePathTemplate(pathTemplateOf(uri), brand),
656
+ transport: grab.transport,
657
+ thumbnailBase64: grab.jpegBase64,
658
+ });
659
+ }
660
+ else {
661
+ // ONVIF creds were ACCEPTED — an RTSP 401 here is a PERMISSION
662
+ // problem (account can't stream), not a password problem.
663
+ const failureClass = grab.failureClass === 'rtsp_auth' ? 'rtsp_forbidden' : grab.failureClass;
664
+ await emit({
665
+ channelKey: key,
666
+ status: failureClass === 'empty_channel' ? 'empty' : 'failed',
667
+ failureClass,
668
+ message: failureClass === 'empty_channel'
669
+ ? 'No camera image detected on this channel.'
670
+ : grab.message,
671
+ name: profile.name || undefined,
672
+ brand,
673
+ host,
674
+ port: rtspPort,
675
+ channel,
676
+ serial: serial || undefined,
677
+ profileToken: profile.token,
678
+ pathTemplate: credentialFreePathTemplate(pathTemplateOf(uri), brand),
679
+ });
680
+ }
681
+ });
682
+ return results;
683
+ }
684
+ break; // ONVIF authed but no profiles → brand fallback below (creds proven)
685
+ }
686
+ catch (err) {
687
+ const kind = err.kind;
688
+ if (kind === 'auth') {
689
+ // Stop touching ONVIF ports, then let one RTSP proof decide whether the
690
+ // recorder login is actually wrong. INVID/TVT-class DVRs commonly reject
691
+ // ONVIF until a separate ONVIF user is enabled while RTSP works normally.
692
+ onvifAuthRejected = true;
693
+ break;
694
+ }
695
+ // unreachable/not_onvif on this port → try next ONVIF port, else fall back.
696
+ }
697
+ }
698
+ // Prove the RTSP auth realm independently when ONVIF rejected the account or
699
+ // when discovery could not identify a brand. The root DESCRIBE/frame result
700
+ // is used only as an auth proof, never emitted as a camera (it is often a
701
+ // recorder grid view). A 401 stops immediately; only a frame or authenticated
702
+ // decode data proves the login and lets path-specific 401s be treated as
703
+ // wrong paths. A root 404 is not proof because that endpoint may be public.
704
+ let rtspCredentialsProven = false;
705
+ if (!onvifAvailable && (onvifAuthRejected || !brand)) {
706
+ const rootGrab = await grabOneFrameAnyTransport(buildRtspUrl(host, rtspPort, '/', target.username, target.password), {
707
+ ffmpegPath: opts.ffmpegPath,
708
+ timeoutMs: Math.min(grabTimeoutMs, Math.max(2000, budgetLeft())),
709
+ spawner: opts.spawner,
710
+ });
711
+ if (rootGrab.failureClass === 'rtsp_auth') {
712
+ await emit({
713
+ channelKey: channelKeyFor({ host, port: rtspPort, channel: 0 }),
714
+ status: 'failed',
715
+ failureClass: 'rtsp_auth',
716
+ message: 'Recorder rejected the username/password (stopped immediately so the account does not get locked).',
717
+ host,
718
+ port: rtspPort,
719
+ brand,
720
+ });
721
+ return results;
722
+ }
723
+ if (rootGrab.failureClass === 'nvr_busy') {
724
+ await emit({
725
+ channelKey: channelKeyFor({ host, port: rtspPort, channel: 0 }),
726
+ status: 'failed',
727
+ failureClass: 'nvr_busy',
728
+ message: rootGrab.message,
729
+ host,
730
+ port: rtspPort,
731
+ brand,
732
+ });
733
+ return results;
734
+ }
735
+ rtspCredentialsProven =
736
+ rootGrab.ok ||
737
+ rootGrab.failureClass === 'decode_error' ||
738
+ rootGrab.failureClass === 'empty_channel';
739
+ }
740
+ // ── Stage 2b: brand-candidate walk (no ONVIF enumeration available). The
741
+ // FIRST RTSP auth rejection trips the breaker (≤1 RTSP auth touch beyond the
742
+ // ONVIF attempt). Channels walk until maxConsecutiveDead in a row.
743
+ const maxDead = opts.maxConsecutiveDead ?? (brand === 'xmeye' ? 8 : 4);
744
+ const maxChannels = Math.min(opts.maxChannels ?? 32, 64);
745
+ const hinted = Number.isFinite(Number(target.channelHint)) ? Math.trunc(Number(target.channelHint)) : null;
746
+ const walkTo = hinted ? Math.min(hinted, maxChannels) : maxChannels;
747
+ const firstCandidates = brandPathCandidates(brand, 1, target);
748
+ if (firstCandidates.length === 0) {
749
+ await emit({
750
+ channelKey: channelKeyFor({ serial, host, port: rtspPort, channel: 0 }),
751
+ status: 'failed',
752
+ failureClass: 'bad_path',
753
+ message: brand === 'ubiquiti'
754
+ ? 'UniFi Protect did not expose ONVIF media profiles. Paste the local RTSPS stream link from Protect.'
755
+ : 'This device did not expose ONVIF media profiles or a verified recorder-level RTSP channel path. Enable ONVIF/Profile T or use a direct camera stream link.',
756
+ host,
757
+ port: rtspPort,
758
+ brand,
759
+ });
760
+ return results;
761
+ }
762
+ let consecutiveDead = 0;
763
+ for (let ch = 1; ch <= walkTo; ch++) {
764
+ if (budgetLeft() <= 0)
765
+ break;
766
+ if (!hinted && consecutiveDead >= maxDead)
767
+ break;
768
+ const key = channelKeyFor({ serial, host, port: rtspPort, channel: ch });
769
+ let channelResult = null;
770
+ const candidates = ch === 1 ? firstCandidates : brandPathCandidates(brand, ch, target);
771
+ if (candidates.length === 0)
772
+ break;
773
+ for (const path of candidates) {
774
+ if (budgetLeft() <= 0)
775
+ break;
776
+ const grab = await grabOneFrameAnyTransport(buildRtspUrl(host, rtspPort, path, target.username, target.password), {
777
+ ffmpegPath: opts.ffmpegPath,
778
+ timeoutMs: Math.min(grabTimeoutMs, Math.max(2000, budgetLeft())),
779
+ spawner: opts.spawner,
780
+ });
781
+ if (grab.ok ||
782
+ grab.failureClass === 'decode_error' ||
783
+ grab.failureClass === 'empty_channel') {
784
+ // Any authenticated RTSP payload proves the login for the rest of this
785
+ // host. Some DVRs return 401 for empty channels or unsupported vendor
786
+ // paths, so a later 401 must not erase an earlier stream success.
787
+ rtspCredentialsProven = true;
788
+ }
789
+ if (grab.ok) {
790
+ channelResult = {
791
+ channelKey: key,
792
+ status: 'validated',
793
+ brand,
794
+ host,
795
+ port: rtspPort,
796
+ channel: ch,
797
+ serial: serial || undefined,
798
+ pathTemplate: credentialFreePathTemplate(path, brand),
799
+ transport: grab.transport,
800
+ thumbnailBase64: grab.jpegBase64,
801
+ };
802
+ break;
803
+ }
804
+ if (grab.failureClass === 'rtsp_auth') {
805
+ if (rtspCredentialsProven) {
806
+ // An earlier RTSP frame/decode already proved these credentials. Some
807
+ // DVRs answer 401 for unsupported vendor paths, so keep walking
808
+ // candidates instead of falsely blaming the password.
809
+ const failedCandidate = {
810
+ channelKey: key,
811
+ status: 'failed',
812
+ failureClass: 'bad_path',
813
+ message: 'Recorder login worked, but this stream path was rejected.',
814
+ brand,
815
+ host,
816
+ port: rtspPort,
817
+ channel: ch,
818
+ pathTemplate: credentialFreePathTemplate(path, brand),
819
+ };
820
+ if (!channelResult ||
821
+ failureSignalRank(failedCandidate.failureClass) >=
822
+ failureSignalRank(channelResult.failureClass)) {
823
+ channelResult = failedCandidate;
824
+ }
825
+ continue;
826
+ }
827
+ // Without ONVIF proof this IS (probably) a bad password → breaker.
828
+ // With ONVIF-proven creds it's a permission problem instead.
829
+ await emit({
830
+ channelKey: key,
831
+ status: 'failed',
832
+ failureClass: onvifAvailable ? 'rtsp_forbidden' : 'rtsp_auth',
833
+ message: onvifAvailable
834
+ ? 'Recorder accepted the password but refused streaming (account lacks live-view/RTSP permission).'
835
+ : 'Recorder rejected the username/password (stopped immediately so the account does not get locked).',
836
+ host,
837
+ port: rtspPort,
838
+ channel: ch,
839
+ brand,
840
+ });
841
+ return results; // CIRCUIT BREAKER
842
+ }
843
+ const failedCandidate = {
844
+ channelKey: key,
845
+ status: 'failed',
846
+ failureClass: grab.failureClass,
847
+ message: grab.message,
848
+ brand,
849
+ host,
850
+ port: rtspPort,
851
+ channel: ch,
852
+ pathTemplate: credentialFreePathTemplate(path, brand),
853
+ };
854
+ if (!channelResult ||
855
+ failureSignalRank(grab.failureClass) >= failureSignalRank(channelResult.failureClass)) {
856
+ channelResult = failedCandidate;
857
+ }
858
+ // bad/no-frame/decode results can vary across substream/mainstream
859
+ // candidates, so keep walking and preserve the strongest signal.
860
+ // Connection-level failures stop the walk (same socket will fail again).
861
+ if (grab.failureClass !== 'bad_path' &&
862
+ grab.failureClass !== 'no_frames' &&
863
+ grab.failureClass !== 'decode_error' &&
864
+ grab.failureClass !== 'empty_channel')
865
+ break;
866
+ }
867
+ if (channelResult) {
868
+ if (channelResult.status === 'validated')
869
+ consecutiveDead = 0;
870
+ else
871
+ consecutiveDead++;
872
+ // A hinted walk marks trailing dead channels as EMPTY (neutral "no
873
+ // camera on this channel"), not scary red failures (product review #16).
874
+ if (channelResult.status === 'failed' &&
875
+ (channelResult.failureClass === 'empty_channel' ||
876
+ (hinted &&
877
+ (channelResult.failureClass === 'bad_path' ||
878
+ channelResult.failureClass === 'no_frames' ||
879
+ channelResult.failureClass === 'rtsp_timeout')))) {
880
+ channelResult = { ...channelResult, status: 'empty', failureClass: 'empty_channel', message: 'No camera detected on this channel.' };
881
+ }
882
+ await emit(channelResult);
883
+ }
884
+ }
885
+ return results;
886
+ }
887
+ //# sourceMappingURL=ChannelProber.js.map