@luxonis/depthai-viewer-common 0.0.1
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.
- package/README.md +11 -0
- package/package.json +20 -0
- package/src/components/ButtonLink.tsx +12 -0
- package/src/components/CTASubLabel.tsx +39 -0
- package/src/components/Link.tsx +10 -0
- package/src/components/Pipeline.tsx +35 -0
- package/src/components/Resizable.tsx +56 -0
- package/src/components/Streams.tsx +256 -0
- package/src/constants/connection.ts +45 -0
- package/src/constants/streams.ts +11 -0
- package/src/constants/url.ts +6 -0
- package/src/dai-connection/connection.ts +402 -0
- package/src/dai-connection/decoder.worker.ts +50 -0
- package/src/hooks/connection.ts +191 -0
- package/src/hooks/navigation.ts +25 -0
- package/src/hooks/search.ts +45 -0
- package/src/index.ts +27 -0
- package/src/providers/ConnectionProvider.tsx +39 -0
- package/src/providers/DataProvider.tsx +71 -0
- package/src/utils/arrays.ts +10 -0
- package/src/utils/dynamic-base-worker.ts +21 -0
- package/src/utils/functions.ts +3 -0
- package/src/utils/sorting.ts +42 -0
- package/src/utils/url.ts +8 -0
- package/src/window.d.ts +7 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type { Pipeline } from '@luxonis/depthai-pipeline-lib';
|
|
2
|
+
import { type PublicChannelData, VisualizerConnection } from '@luxonis/visualizer-protobuf';
|
|
3
|
+
// TODO: Outdated import. Replace after updating visualizer-protobuf
|
|
4
|
+
import type { VisualizerConnectionArgs } from '@luxonis/visualizer-protobuf/dist/lib/src/connection/connection.js';
|
|
5
|
+
import { DAI_SERVICE_NAME } from '../constants/connection.js';
|
|
6
|
+
import type { Topic } from '../hooks/connection.js';
|
|
7
|
+
import type { DecoderWorkerInput, DecoderWorkerOutput } from './decoder.worker.js';
|
|
8
|
+
import _ from 'lodash';
|
|
9
|
+
import { StreamTypes } from '../constants/streams.js';
|
|
10
|
+
|
|
11
|
+
export type DAIConnectionInitArgs = {
|
|
12
|
+
type: 'ws' | 'webrtc';
|
|
13
|
+
connectionUrl: string;
|
|
14
|
+
clientId: string;
|
|
15
|
+
token: string;
|
|
16
|
+
automaticTokenRefresh?: (token: string) => void;
|
|
17
|
+
} & {
|
|
18
|
+
enabledStreams: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DaiConnectionEventMap = {
|
|
22
|
+
open: () => void;
|
|
23
|
+
error: (error: MessageEvent<unknown> | ErrorEvent) => void;
|
|
24
|
+
close: () => void;
|
|
25
|
+
pipeline: (pipeline: Pipeline | null) => void;
|
|
26
|
+
topics: (topics: Topic[]) => void;
|
|
27
|
+
disconnect: (error: ErrorEvent) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type DAIService = keyof typeof DAI_SERVICE_NAME;
|
|
31
|
+
|
|
32
|
+
export class DAIConnection {
|
|
33
|
+
alive = true;
|
|
34
|
+
connection?: VisualizerConnection;
|
|
35
|
+
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
37
|
+
protected listeners: Partial<Record<keyof DaiConnectionEventMap, any[]>> = {};
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: Typed only on a usage level
|
|
39
|
+
protected serviceListeners: Record<string, (data: any) => void> = {};
|
|
40
|
+
|
|
41
|
+
protected enabledTopics: string[] = [];
|
|
42
|
+
protected previouslyEnabledTopics: string[] = [];
|
|
43
|
+
|
|
44
|
+
protected decoderWorker!: Worker;
|
|
45
|
+
protected allowedServices: Set<DAIService> = new Set();
|
|
46
|
+
protected activeServices: Set<DAIService> = new Set([]);
|
|
47
|
+
protected serviceInterval: NodeJS.Timeout | null = null;
|
|
48
|
+
|
|
49
|
+
protected topicsTouched = false;
|
|
50
|
+
protected channels: PublicChannelData[] = [];
|
|
51
|
+
protected groups: Record<string, string> = {};
|
|
52
|
+
protected topics: Topic[] = [];
|
|
53
|
+
protected pipeline: Pipeline | null = null;
|
|
54
|
+
|
|
55
|
+
protected recreateDecoderWorker() {
|
|
56
|
+
this.decoderWorker?.terminate();
|
|
57
|
+
this.decoderWorker = new Worker(new URL('./decoder.worker.js', import.meta.url), {
|
|
58
|
+
type: 'module',
|
|
59
|
+
/* @vite-ignore */
|
|
60
|
+
basePath: window.__basePath,
|
|
61
|
+
} as WorkerOptions);
|
|
62
|
+
|
|
63
|
+
this.decoderWorker.addEventListener('error', (event) =>
|
|
64
|
+
console.error('Decoder Worker Error:', event.message, event),
|
|
65
|
+
);
|
|
66
|
+
this.decoderWorker.addEventListener('messageerror', (event) =>
|
|
67
|
+
console.error('Decoder Worker Error (message):', event.data, event),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
this.decoderWorker.addEventListener(
|
|
71
|
+
'message',
|
|
72
|
+
({ data: { kind, data, name } }: MessageEvent<DecoderWorkerOutput>) => {
|
|
73
|
+
if (kind === 'pipeline') {
|
|
74
|
+
if (JSON.stringify(this.pipeline) !== JSON.stringify(data)) {
|
|
75
|
+
this.pipeline = data;
|
|
76
|
+
for (const listener of this.listeners.pipeline ?? []) {
|
|
77
|
+
listener(data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else if (name) {
|
|
81
|
+
this.serviceListeners[name]?.(data);
|
|
82
|
+
} else {
|
|
83
|
+
this.serviceListeners[data.name]?.(data);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
protected reportTopics(type?: 'ws' | 'webrtc') {
|
|
90
|
+
if (!this.groups || !this.channels || !this.alive) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const topicList: (Omit<Topic, 'annotations' | 'extraAnnotations'> & {
|
|
95
|
+
group: string | null;
|
|
96
|
+
})[] = [];
|
|
97
|
+
const annotationGroups: Record<string, string[]> = {};
|
|
98
|
+
const annotations: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const channel of this.channels) {
|
|
101
|
+
const group = this.groups[channel.topic];
|
|
102
|
+
|
|
103
|
+
if (channel.kind === 'annotations') {
|
|
104
|
+
annotations.push(channel.topic);
|
|
105
|
+
if (group) {
|
|
106
|
+
annotationGroups[group] ??= [];
|
|
107
|
+
annotationGroups[group].push(channel.topic);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const shouldOpenDefaultStream =
|
|
114
|
+
(this.enabledTopics.length === 0 ||
|
|
115
|
+
(this.enabledTopics.length !== 0 &&
|
|
116
|
+
this.enabledTopics.includes(StreamTypes.NeuralNetwork))) &&
|
|
117
|
+
!this.topicsTouched;
|
|
118
|
+
|
|
119
|
+
if (shouldOpenDefaultStream) {
|
|
120
|
+
// If the connection is over WebSockets we want to open RAW stream as default stream
|
|
121
|
+
if (
|
|
122
|
+
type === 'ws' &&
|
|
123
|
+
_.isEqual(channel.topic.toLowerCase(), StreamTypes.Raw.toLowerCase())
|
|
124
|
+
) {
|
|
125
|
+
this.topicsTouched = true;
|
|
126
|
+
this.enabledTopics.push(channel.topic);
|
|
127
|
+
|
|
128
|
+
if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
|
|
129
|
+
this.enabledTopics.push(StreamTypes.NeuralNetwork);
|
|
130
|
+
}
|
|
131
|
+
// If the connection is over WebRTC we want to open ENCODED stream as default stream
|
|
132
|
+
} else if (
|
|
133
|
+
type === 'webrtc' &&
|
|
134
|
+
_.isEqual(channel.topic.toLowerCase(), StreamTypes.Encoded.toLowerCase())
|
|
135
|
+
) {
|
|
136
|
+
this.topicsTouched = true;
|
|
137
|
+
this.enabledTopics.push(channel.topic);
|
|
138
|
+
|
|
139
|
+
if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
|
|
140
|
+
this.enabledTopics.push(StreamTypes.NeuralNetwork);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
topicList.push({
|
|
146
|
+
name: channel.topic,
|
|
147
|
+
kind: channel.kind,
|
|
148
|
+
enabled: this.enabledTopics.includes(channel.topic),
|
|
149
|
+
group: this.groups[channel.topic] ?? null,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const fullAnnotationGroups = Object.fromEntries(
|
|
154
|
+
Object.entries(annotationGroups).map(([group, topics]) => [
|
|
155
|
+
group,
|
|
156
|
+
{
|
|
157
|
+
topics,
|
|
158
|
+
extra: annotations.filter((annotation) => !topics.includes(annotation)),
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const updatedTopics = topicList.map<Topic>((topic) => ({
|
|
164
|
+
...topic,
|
|
165
|
+
annotations: fullAnnotationGroups[topic.group ?? '']?.topics ?? annotations,
|
|
166
|
+
extraAnnotations: fullAnnotationGroups[topic.group ?? '']?.extra ?? [],
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
if (JSON.stringify(this.topics) !== JSON.stringify(updatedTopics)) {
|
|
170
|
+
const [oldTopics, newTopics] = updatedTopics.reduce<[Set<string>, Set<string>]>(
|
|
171
|
+
(sets, topic) => {
|
|
172
|
+
const set = sets[Number(topic.enabled)];
|
|
173
|
+
for (const name of [topic.name, ...topic.annotations, ...topic.extraAnnotations]) {
|
|
174
|
+
set.add(name);
|
|
175
|
+
}
|
|
176
|
+
return sets;
|
|
177
|
+
},
|
|
178
|
+
[new Set(), new Set()],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
for (const annotation of newTopics) {
|
|
182
|
+
oldTopics.delete(annotation);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// TODO: Rewrite lib to control enabled annotations
|
|
186
|
+
// This setup subscribes to all annotations regardless of the enabled state
|
|
187
|
+
if (oldTopics.size !== 0) {
|
|
188
|
+
this.connection?.unsubscribe({ topics: [...oldTopics] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (newTopics.size !== 0) {
|
|
192
|
+
this.connection?.subscribe({ topics: [...newTopics] });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.topics = updatedTopics;
|
|
196
|
+
for (const listener of this.listeners.topics ?? []) {
|
|
197
|
+
listener(updatedTopics);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
protected pollServices() {
|
|
203
|
+
if (this.serviceInterval) {
|
|
204
|
+
clearInterval(this.serviceInterval);
|
|
205
|
+
}
|
|
206
|
+
this.serviceInterval = setInterval(() => {
|
|
207
|
+
for (const service of this.activeServices) {
|
|
208
|
+
this.fetchService(service);
|
|
209
|
+
}
|
|
210
|
+
}, 3_000);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
init({ enabledStreams, ...args }: DAIConnectionInitArgs) {
|
|
214
|
+
if (enabledStreams && enabledStreams.length > 0) {
|
|
215
|
+
console.debug('Initializing connection with predefined streams', enabledStreams);
|
|
216
|
+
this.enabledTopics = enabledStreams;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.recreateDecoderWorker();
|
|
220
|
+
this.setupConnection(args);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setupConnection({
|
|
224
|
+
type,
|
|
225
|
+
connectionUrl,
|
|
226
|
+
clientId,
|
|
227
|
+
token,
|
|
228
|
+
automaticTokenRefresh,
|
|
229
|
+
}: Pick<
|
|
230
|
+
DAIConnectionInitArgs,
|
|
231
|
+
'type' | 'clientId' | 'connectionUrl' | 'token' | 'automaticTokenRefresh'
|
|
232
|
+
>) {
|
|
233
|
+
// TODO: Enable Luxonis ICE Servers
|
|
234
|
+
this.connection = new VisualizerConnection({
|
|
235
|
+
type: type as never,
|
|
236
|
+
data: (type === 'ws'
|
|
237
|
+
? ({ connectionUrl } as (VisualizerConnectionArgs & {
|
|
238
|
+
type: 'ws';
|
|
239
|
+
})['data'])
|
|
240
|
+
: ({
|
|
241
|
+
config: {
|
|
242
|
+
clientId,
|
|
243
|
+
applicationIdentifier: '', // TODO:
|
|
244
|
+
authPayload: token,
|
|
245
|
+
signalingConnectionRetries: 5,
|
|
246
|
+
useLuxonisIceServers: true,
|
|
247
|
+
},
|
|
248
|
+
automaticTokenRefresh,
|
|
249
|
+
} as (VisualizerConnectionArgs & {
|
|
250
|
+
type: 'webrtc';
|
|
251
|
+
})['data'])) as never,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
this.connection.on('open', () => {
|
|
255
|
+
if (!this.alive) return;
|
|
256
|
+
for (const listener of this.listeners.open ?? []) {
|
|
257
|
+
listener();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.connection.on('close', () => {
|
|
262
|
+
if (!this.alive) return;
|
|
263
|
+
this.dispose();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
this.connection.on('disconnect', (error) => {
|
|
267
|
+
for (const listener of this.listeners.disconnect ?? []) {
|
|
268
|
+
listener(error);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
this.connection.on('services', (message) => {
|
|
273
|
+
if (!this.alive) return;
|
|
274
|
+
this.allowedServices.clear();
|
|
275
|
+
for (const service of message.data) {
|
|
276
|
+
this.allowedServices.add(service as DAIService);
|
|
277
|
+
if (
|
|
278
|
+
service === DAI_SERVICE_NAME.getDevices ||
|
|
279
|
+
service === DAI_SERVICE_NAME.pipelineSchema ||
|
|
280
|
+
service === DAI_SERVICE_NAME.topicGroups
|
|
281
|
+
) {
|
|
282
|
+
this.fetchService(service as DAIService);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.pollServices();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
this.connection.on('channels', (message) => {
|
|
289
|
+
if (!this.alive) return;
|
|
290
|
+
this.channels = message.data;
|
|
291
|
+
this.reportTopics(type);
|
|
292
|
+
|
|
293
|
+
// If no default topic is enabled we want to enable at least one topic
|
|
294
|
+
if (this.enabledTopics.length === 0 && this.channels.at(0)?.topic) {
|
|
295
|
+
console.warn(
|
|
296
|
+
`No default topic is enabled - using ${this.channels.at(0)?.topic} as default topic.`,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
this.toggleTopic(this.channels.at(0)!.topic);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async fetchService(
|
|
305
|
+
service: DAIService,
|
|
306
|
+
// biome-ignore lint/suspicious/noExplicitAny: Can't possibly be typed, we don't know request specifics
|
|
307
|
+
body?: Record<string, any> | string | number,
|
|
308
|
+
) {
|
|
309
|
+
if (!this.alive) {
|
|
310
|
+
console.warn(`DAI: fetchService called while not alive for service "${service}"`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const canPoll = this.allowedServices.has(service);
|
|
315
|
+
|
|
316
|
+
if (canPoll) {
|
|
317
|
+
const data = await this.connection?.request({
|
|
318
|
+
name: DAI_SERVICE_NAME[service],
|
|
319
|
+
timeout: 60_000,
|
|
320
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
321
|
+
body: body as Record<string, any>,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (data) {
|
|
325
|
+
this.decoderWorker.postMessage(
|
|
326
|
+
{
|
|
327
|
+
kind: service === 'pipelineSchema' ? 'pipeline' : 'json',
|
|
328
|
+
name: service,
|
|
329
|
+
data,
|
|
330
|
+
} as DecoderWorkerInput,
|
|
331
|
+
[data.buffer],
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.warn(
|
|
336
|
+
`DAI: fetchService called for service "${service}" while it's not allowed to poll`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setActiveServices(services: DAIService[]) {
|
|
342
|
+
this.activeServices = new Set(services);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
dispose() {
|
|
346
|
+
this.alive = false;
|
|
347
|
+
this.decoderWorker.terminate();
|
|
348
|
+
if (this.serviceInterval) {
|
|
349
|
+
clearInterval(this.serviceInterval);
|
|
350
|
+
}
|
|
351
|
+
this.connection?.close();
|
|
352
|
+
for (const listener of this.listeners.close ?? []) {
|
|
353
|
+
listener();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
handleKeyPress(key: string) {
|
|
358
|
+
void this.connection?.request({
|
|
359
|
+
name: DAI_SERVICE_NAME.keyPressed,
|
|
360
|
+
body: { key },
|
|
361
|
+
ignoreResponse: true,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
toggleTopic(topic: string) {
|
|
366
|
+
this.topicsTouched = true;
|
|
367
|
+
const enabledIndex = this.enabledTopics.indexOf(topic);
|
|
368
|
+
|
|
369
|
+
if (enabledIndex === -1) {
|
|
370
|
+
this.enabledTopics.push(topic);
|
|
371
|
+
} else {
|
|
372
|
+
this.enabledTopics.splice(enabledIndex, 1);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.reportTopics();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
stopTopics() {
|
|
379
|
+
this.topicsTouched = false;
|
|
380
|
+
this.previouslyEnabledTopics = this.enabledTopics;
|
|
381
|
+
|
|
382
|
+
this.enabledTopics = [];
|
|
383
|
+
this.reportTopics();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
restartTopics() {
|
|
387
|
+
this.topicsTouched = true;
|
|
388
|
+
this.enabledTopics = this.previouslyEnabledTopics;
|
|
389
|
+
this.previouslyEnabledTopics = [];
|
|
390
|
+
|
|
391
|
+
this.reportTopics();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
on<T extends keyof DaiConnectionEventMap>(event: T, callback: DaiConnectionEventMap[T]) {
|
|
395
|
+
this.listeners[event] ??= [];
|
|
396
|
+
this.listeners[event].push(callback);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
setOnService<T>(service: string, callback: (data: T) => void) {
|
|
400
|
+
this.serviceListeners[service] = callback;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Pipeline,
|
|
3
|
+
parsePipeline,
|
|
4
|
+
} from '../../../../node_modules/@luxonis/depthai-pipeline-lib/dist/src/services/pipeline.js';
|
|
5
|
+
|
|
6
|
+
function decodeService(kind: 'pipeline' | 'json', response: DataView | null) {
|
|
7
|
+
if (!response) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const decoder = new TextDecoder('utf-8');
|
|
13
|
+
const decodedPayload = decoder.decode(response);
|
|
14
|
+
let parsed = JSON.parse(decodedPayload);
|
|
15
|
+
|
|
16
|
+
if (kind === 'pipeline') {
|
|
17
|
+
parsed = parsePipeline(parsed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parsed;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('Error decoding JSON:', error);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DecoderWorkerInput = {
|
|
28
|
+
kind: 'pipeline' | 'json';
|
|
29
|
+
name?: string;
|
|
30
|
+
data: DataView;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type DecoderWorkerOutput = { name?: string } & (
|
|
34
|
+
| {
|
|
35
|
+
kind: 'pipeline';
|
|
36
|
+
data: Pipeline;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
kind: 'json';
|
|
40
|
+
data: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
self.addEventListener(
|
|
45
|
+
'message',
|
|
46
|
+
({ data: { kind, data, name } }: MessageEvent<DecoderWorkerInput>) => {
|
|
47
|
+
const decoded = decodeService(kind, data);
|
|
48
|
+
self.postMessage({ kind, name, data: decoded } as DecoderWorkerOutput);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useToast } from '@luxonis/common-fe-components';
|
|
2
|
+
import type { Pipeline } from '@luxonis/depthai-pipeline-lib';
|
|
3
|
+
import type { VisualizerConnection } from '@luxonis/visualizer-protobuf';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { StreamTypes } from '../constants/streams.js';
|
|
6
|
+
import { DAIConnection, type DAIService } from '../dai-connection/connection.js';
|
|
7
|
+
import { useFlatSearch } from './search.js';
|
|
8
|
+
|
|
9
|
+
export type Topic = {
|
|
10
|
+
name: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
kind: 'pointCloud' | 'image';
|
|
13
|
+
annotations: string[];
|
|
14
|
+
extraAnnotations: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ConnectionData = {
|
|
18
|
+
connected: boolean;
|
|
19
|
+
daiConnection: DAIConnection | null;
|
|
20
|
+
connections: VisualizerConnection[];
|
|
21
|
+
pipeline: Pipeline | null | undefined;
|
|
22
|
+
isPipelineLoading: boolean;
|
|
23
|
+
setIsPipelineLoading: (isLoading: boolean) => void;
|
|
24
|
+
topics: Topic[];
|
|
25
|
+
toggleTopic: (topic: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UseCreateConnectionArgs = {
|
|
29
|
+
type: 'webrtc' | 'ws';
|
|
30
|
+
connectionUrl: string;
|
|
31
|
+
token: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
activeServices: DAIService[];
|
|
34
|
+
onNewConnection?: (connection: DAIConnection) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function useCreateConnection({
|
|
38
|
+
type,
|
|
39
|
+
activeServices,
|
|
40
|
+
onNewConnection,
|
|
41
|
+
...params
|
|
42
|
+
}: UseCreateConnectionArgs): ConnectionData {
|
|
43
|
+
const { updateSearch, search } = useFlatSearch({
|
|
44
|
+
defaultInit: { streams: [], cid: '', t: '' },
|
|
45
|
+
arrayFields: ['streams'],
|
|
46
|
+
});
|
|
47
|
+
const [connected, setConnected] = React.useState(false);
|
|
48
|
+
const [topics, setTopics] = React.useState<Topic[]>([]);
|
|
49
|
+
const [pipeline, setPipeline] = React.useState<Pipeline | null | undefined>(undefined);
|
|
50
|
+
const [isPipelineLoading, setIsPipelineLoading] = React.useState(false);
|
|
51
|
+
|
|
52
|
+
const [connection, setConnection] = React.useState<DAIConnection | null>(null);
|
|
53
|
+
|
|
54
|
+
const { toast } = useToast();
|
|
55
|
+
const handleGetCameraIntrinsics = () => {
|
|
56
|
+
if (connection) {
|
|
57
|
+
const getCameraIntrinsics = localStorage.getItem('neuralCameraIntrinsics');
|
|
58
|
+
if (!getCameraIntrinsics) {
|
|
59
|
+
connection.setOnService('getCameraIntrinsics', (data) => {
|
|
60
|
+
// @ts-ignore - The responce is not typed
|
|
61
|
+
localStorage.setItem('neuralCameraIntrinsics', JSON.stringify(data.data));
|
|
62
|
+
});
|
|
63
|
+
connection.fetchService('getCameraIntrinsics');
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
console.warn('Failed to get camera intrinsics, no connection');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
handleGetCameraIntrinsics();
|
|
70
|
+
|
|
71
|
+
const refreshConnection = () => {
|
|
72
|
+
const newConnection = new DAIConnection();
|
|
73
|
+
|
|
74
|
+
newConnection.on('error', console.error);
|
|
75
|
+
newConnection.on('open', () => {
|
|
76
|
+
setConnected(true);
|
|
77
|
+
handleGetCameraIntrinsics();
|
|
78
|
+
setIsPipelineLoading(true);
|
|
79
|
+
});
|
|
80
|
+
newConnection.on('close', () => {
|
|
81
|
+
setConnected(false);
|
|
82
|
+
setTopics([]);
|
|
83
|
+
setPipeline(undefined);
|
|
84
|
+
setIsPipelineLoading(false);
|
|
85
|
+
});
|
|
86
|
+
newConnection.on('topics', setTopics);
|
|
87
|
+
newConnection.on('pipeline', (pipeline) => {
|
|
88
|
+
setPipeline(pipeline);
|
|
89
|
+
setIsPipelineLoading(false);
|
|
90
|
+
});
|
|
91
|
+
newConnection.on('disconnect', (_error) => {
|
|
92
|
+
toast({
|
|
93
|
+
description:
|
|
94
|
+
'The link to the application frontend is not valid. You need to open the frontend from Luxonis Hub again.',
|
|
95
|
+
colorVariant: 'error',
|
|
96
|
+
duration: 'long',
|
|
97
|
+
name: 'connection-error',
|
|
98
|
+
});
|
|
99
|
+
setConnected(false);
|
|
100
|
+
setTopics([]);
|
|
101
|
+
setPipeline(undefined);
|
|
102
|
+
});
|
|
103
|
+
onNewConnection?.(newConnection);
|
|
104
|
+
|
|
105
|
+
if (newConnection.alive) {
|
|
106
|
+
newConnection.init({
|
|
107
|
+
type,
|
|
108
|
+
...params,
|
|
109
|
+
automaticTokenRefresh: (token) => {
|
|
110
|
+
updateSearch('t', token);
|
|
111
|
+
},
|
|
112
|
+
enabledStreams: search.streams as string[],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
setConnection(newConnection);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `params` will always change on re-renders
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
if (!connected) {
|
|
122
|
+
refreshConnection();
|
|
123
|
+
} else {
|
|
124
|
+
handleGetCameraIntrinsics();
|
|
125
|
+
}
|
|
126
|
+
}, [params.clientId, params.connectionUrl, params.token, onNewConnection, connected]);
|
|
127
|
+
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
connection?.setActiveServices(activeServices);
|
|
130
|
+
}, [connection, activeServices]);
|
|
131
|
+
|
|
132
|
+
const connections = React.useMemo(
|
|
133
|
+
() => (connection?.connection ? [connection.connection] : []),
|
|
134
|
+
[connection],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (connected) {
|
|
139
|
+
const callback = (event: KeyboardEvent) => connection?.handleKeyPress(event.key);
|
|
140
|
+
window.addEventListener('keydown', callback);
|
|
141
|
+
return () => window.removeEventListener('keydown', callback);
|
|
142
|
+
}
|
|
143
|
+
}, [connected, connection]);
|
|
144
|
+
|
|
145
|
+
React.useEffect(() => {
|
|
146
|
+
if (topics.length > 0) {
|
|
147
|
+
if (
|
|
148
|
+
topics.find((topic) => topic.name === StreamTypes.PointCloud)?.enabled &&
|
|
149
|
+
topics.find((t) => t.name === StreamTypes.Encoded)?.enabled === false
|
|
150
|
+
) {
|
|
151
|
+
connection?.toggleTopic(StreamTypes.Encoded);
|
|
152
|
+
}
|
|
153
|
+
updateSearch(
|
|
154
|
+
'streams',
|
|
155
|
+
topics.filter((topic) => topic.enabled).map((topic) => topic.name),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}, [topics, updateSearch]);
|
|
159
|
+
|
|
160
|
+
const toggleTopic = React.useCallback(
|
|
161
|
+
(topicName: string, isRawSteamEnabledManually?: boolean): void => {
|
|
162
|
+
if (
|
|
163
|
+
topicName === StreamTypes.PointCloud &&
|
|
164
|
+
topics.find((topic) => topic.name === topicName)?.enabled === false &&
|
|
165
|
+
topics.find((t) => t.name === StreamTypes.Encoded)?.enabled === false
|
|
166
|
+
) {
|
|
167
|
+
connection?.toggleTopic(StreamTypes.Encoded);
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
topicName === StreamTypes.PointCloud &&
|
|
171
|
+
isRawSteamEnabledManually === false &&
|
|
172
|
+
topics.find((n) => n.name === 'Point Cloud')?.enabled
|
|
173
|
+
) {
|
|
174
|
+
connection?.toggleTopic(StreamTypes.Encoded);
|
|
175
|
+
}
|
|
176
|
+
connection?.toggleTopic(topicName);
|
|
177
|
+
},
|
|
178
|
+
[connection],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
connected,
|
|
183
|
+
daiConnection: connection,
|
|
184
|
+
connections,
|
|
185
|
+
pipeline,
|
|
186
|
+
isPipelineLoading,
|
|
187
|
+
setIsPipelineLoading,
|
|
188
|
+
topics,
|
|
189
|
+
toggleTopic,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useLocation } from 'react-router-dom';
|
|
3
|
+
import { parseUrl as defaultParseUrl } from '../utils/url.js';
|
|
4
|
+
|
|
5
|
+
export function useNavigation(parseUrl?: (pathname: string) => { basePath: string; page: string }) {
|
|
6
|
+
const location = useLocation();
|
|
7
|
+
|
|
8
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
|
9
|
+
const { page, basePath } = React.useMemo(
|
|
10
|
+
() => (parseUrl ?? defaultParseUrl)(location.pathname),
|
|
11
|
+
[location.pathname],
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const makePath = React.useCallback(
|
|
15
|
+
(page: string, options: { noSearch?: boolean; rootPage?: string } = {}) => {
|
|
16
|
+
const { noSearch = false, rootPage = 'streams' } = options;
|
|
17
|
+
const target = page === rootPage ? '' : page;
|
|
18
|
+
|
|
19
|
+
return `/${basePath}${basePath ? '/' : ''}${target}${noSearch ? '' : location.search}`;
|
|
20
|
+
},
|
|
21
|
+
[basePath, location.search],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return { basePath, page, makePath };
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
export type UseFlatSearchArgs = {
|
|
5
|
+
defaultInit?: Record<string, string | string[]>;
|
|
6
|
+
arrayFields?: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function useFlatSearch({ defaultInit = {}, arrayFields }: UseFlatSearchArgs) {
|
|
10
|
+
const [search, setSearch] = useSearchParams(defaultInit);
|
|
11
|
+
|
|
12
|
+
const flatSearch = React.useMemo(
|
|
13
|
+
() => ({
|
|
14
|
+
...defaultInit,
|
|
15
|
+
...Object.fromEntries(
|
|
16
|
+
[...search.entries()].map(([key, value]) => [
|
|
17
|
+
key,
|
|
18
|
+
arrayFields?.includes(key) ? (value.length === 0 ? [] : value.split(',')) : value,
|
|
19
|
+
]),
|
|
20
|
+
),
|
|
21
|
+
}),
|
|
22
|
+
[arrayFields, defaultInit, search],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const updateSearch = React.useCallback(
|
|
26
|
+
(key: string, value: string | number | string[] | number[] | null) => {
|
|
27
|
+
setSearch((current) => {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
current.set(key, value.join(','));
|
|
30
|
+
} else if (value !== null) {
|
|
31
|
+
current.set(key, value.toString());
|
|
32
|
+
} else {
|
|
33
|
+
current.delete(key);
|
|
34
|
+
}
|
|
35
|
+
return current;
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
[setSearch],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
search: flatSearch,
|
|
43
|
+
updateSearch: updateSearch,
|
|
44
|
+
};
|
|
45
|
+
}
|