@apocaliss92/scrypted-reolink-native 0.0.1 → 0.0.2
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera.ts +89 -83
- package/src/connect.ts +146 -0
- package/src/main.ts +18 -6
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/camera.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ReolinkBaichuanIntercom } from "./intercom";
|
|
|
7
7
|
import ReolinkNativePlugin from "./main";
|
|
8
8
|
import { ReolinkPtzPresets } from "./presets";
|
|
9
9
|
import { parseStreamProfileFromId, StreamManager } from './stream-utils';
|
|
10
|
+
import { connectBaichuanWithTcpUdpFallback, createBaichuanApi, maskUid } from './connect';
|
|
10
11
|
|
|
11
12
|
export const moToB64 = async (mo: MediaObject) => {
|
|
12
13
|
const bufferImage = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
@@ -164,8 +165,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
164
165
|
floodlight: ReolinkCameraFloodlight;
|
|
165
166
|
pirSensor: ReolinkCameraPirSensor;
|
|
166
167
|
private baichuanApi: ReolinkBaichuanApi | undefined;
|
|
167
|
-
private
|
|
168
|
-
private refreshDeviceStatePromise: Promise<void> | undefined;
|
|
168
|
+
private refreshingState = false;
|
|
169
169
|
|
|
170
170
|
private subscribedToEvents = false;
|
|
171
171
|
private onSimpleEvent: ((ev: ReolinkSimpleEvent) => void) | undefined;
|
|
@@ -179,6 +179,8 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
179
179
|
private lastSnapshotTaken: number | undefined;
|
|
180
180
|
private streamManager: StreamManager;
|
|
181
181
|
|
|
182
|
+
private udpFallbackAlerted = false;
|
|
183
|
+
|
|
182
184
|
private dispatchEventsApplyTimer: NodeJS.Timeout | undefined;
|
|
183
185
|
private dispatchEventsApplySeq = 0;
|
|
184
186
|
|
|
@@ -195,6 +197,11 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
195
197
|
title: 'IP Address',
|
|
196
198
|
type: 'string',
|
|
197
199
|
},
|
|
200
|
+
uid: {
|
|
201
|
+
title: 'UID',
|
|
202
|
+
description: 'Reolink UID (required for battery cameras / BCUDP).',
|
|
203
|
+
type: 'string',
|
|
204
|
+
},
|
|
198
205
|
username: {
|
|
199
206
|
type: 'string',
|
|
200
207
|
title: 'Username',
|
|
@@ -462,7 +469,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
462
469
|
}
|
|
463
470
|
}
|
|
464
471
|
this.baichuanApi = undefined;
|
|
465
|
-
this.baichuanInitPromise = undefined;
|
|
466
472
|
this.subscribedToEvents = false;
|
|
467
473
|
this.eventsApi = undefined;
|
|
468
474
|
}
|
|
@@ -494,9 +500,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
494
500
|
async init() {
|
|
495
501
|
const logger = this.getLogger();
|
|
496
502
|
|
|
497
|
-
// Migrate older boolean value to the new multi-select format.
|
|
498
|
-
this.migrateDispatchEventsSetting();
|
|
499
|
-
|
|
500
503
|
// Initialize Baichuan API
|
|
501
504
|
await this.ensureClient();
|
|
502
505
|
|
|
@@ -507,21 +510,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
507
510
|
this.updateDeviceInfo();
|
|
508
511
|
this.updatePtzCaps();
|
|
509
512
|
|
|
510
|
-
const interfaces = await this.getDeviceInterfaces();
|
|
511
|
-
|
|
512
|
-
const device: Device = {
|
|
513
|
-
nativeId: this.nativeId,
|
|
514
|
-
providerNativeId: this.plugin.nativeId,
|
|
515
|
-
name: this.name,
|
|
516
|
-
interfaces,
|
|
517
|
-
type: this.type as ScryptedDeviceType,
|
|
518
|
-
info: this.info,
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
522
|
-
|
|
523
|
-
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
524
|
-
|
|
525
513
|
// Start event subscription after discovery.
|
|
526
514
|
try {
|
|
527
515
|
if (this.isEventDispatchEnabled()) {
|
|
@@ -544,65 +532,67 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
544
532
|
}
|
|
545
533
|
|
|
546
534
|
async ensureClient(): Promise<ReolinkBaichuanApi> {
|
|
547
|
-
if (this.baichuanInitPromise) {
|
|
548
|
-
return this.baichuanInitPromise;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
535
|
if (this.baichuanApi && this.baichuanApi.client.loggedIn) {
|
|
552
536
|
return this.baichuanApi;
|
|
553
537
|
}
|
|
554
538
|
|
|
555
|
-
const { ipAddress, username, password } = this.storageSettings.values;
|
|
539
|
+
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
556
540
|
|
|
557
541
|
if (!ipAddress || !username || !password) {
|
|
558
542
|
throw new Error('Missing camera credentials');
|
|
559
543
|
}
|
|
560
544
|
|
|
561
|
-
this.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
545
|
+
if (this.baichuanApi) {
|
|
546
|
+
await this.baichuanApi.close();
|
|
547
|
+
}
|
|
565
548
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
549
|
+
const debugOptions = this.getBaichuanDebugOptions();
|
|
550
|
+
const { api } = await connectBaichuanWithTcpUdpFallback(
|
|
551
|
+
{
|
|
569
552
|
host: ipAddress,
|
|
570
553
|
username,
|
|
571
554
|
password,
|
|
555
|
+
uid,
|
|
572
556
|
logger: this.console,
|
|
573
557
|
...(debugOptions ? { debugOptions } : {}),
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
558
|
+
},
|
|
559
|
+
({ uid: normalizedUid, uidMissing }) => {
|
|
560
|
+
const uidMsg = !uidMissing && normalizedUid ? `UID ${maskUid(normalizedUid)}` : 'UID MISSING';
|
|
561
|
+
if (!this.udpFallbackAlerted) {
|
|
562
|
+
this.udpFallbackAlerted = true;
|
|
563
|
+
this.log.a(
|
|
564
|
+
`Baichuan TCP failed for camera ${this.name} (${ipAddress}). This appears to be a battery camera: UID is required and UDP/BCUDP will be used (${uidMsg}).`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
);
|
|
579
569
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
finally {
|
|
584
|
-
// If login failed, allow future retries.
|
|
585
|
-
if (!this.baichuanApi?.client?.loggedIn) {
|
|
586
|
-
this.baichuanInitPromise = undefined;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
570
|
+
this.baichuanApi = api;
|
|
571
|
+
return api;
|
|
589
572
|
}
|
|
590
573
|
|
|
591
574
|
private async createStreamClient(): Promise<ReolinkBaichuanApi> {
|
|
592
|
-
|
|
575
|
+
// Ensure the main client is initialized first so we know if this device needs UDP.
|
|
576
|
+
const primary = await this.ensureClient();
|
|
577
|
+
const transport = primary.client.getTransport();
|
|
578
|
+
|
|
579
|
+
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
593
580
|
if (!ipAddress || !username || !password) {
|
|
594
581
|
throw new Error('Missing camera credentials');
|
|
595
582
|
}
|
|
596
583
|
|
|
597
|
-
const { ReolinkBaichuanApi } = await import('@apocaliss92/reolink-baichuan-js');
|
|
598
584
|
const debugOptions = this.getBaichuanDebugOptions();
|
|
599
|
-
const api =
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
585
|
+
const api = await createBaichuanApi(
|
|
586
|
+
{
|
|
587
|
+
host: ipAddress,
|
|
588
|
+
username,
|
|
589
|
+
password,
|
|
590
|
+
uid,
|
|
591
|
+
logger: this.console,
|
|
592
|
+
...(debugOptions ? { debugOptions } : {}),
|
|
593
|
+
},
|
|
594
|
+
transport,
|
|
595
|
+
);
|
|
606
596
|
await api.login();
|
|
607
597
|
return api;
|
|
608
598
|
}
|
|
@@ -612,30 +602,53 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
612
602
|
}
|
|
613
603
|
|
|
614
604
|
private async refreshDeviceState(): Promise<void> {
|
|
615
|
-
if (this.
|
|
605
|
+
if (this.refreshingState) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
this.refreshingState = true;
|
|
616
609
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const channel = this.getRtspChannel();
|
|
610
|
+
const logger = this.getLogger();
|
|
611
|
+
const api = await this.ensureClient();
|
|
612
|
+
const channel = this.getRtspChannel();
|
|
621
613
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
614
|
+
try {
|
|
615
|
+
const { capabilities, abilities, support, presets } = await api.getDeviceCapabilities(channel);
|
|
616
|
+
this.storageSettings.values.capabilities = capabilities;
|
|
617
|
+
this.ptzPresets.setCachedPtzPresets(presets);
|
|
618
|
+
this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
logger.error('Failed to refresh abilities', e);
|
|
622
|
+
}
|
|
631
623
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
|
|
624
|
+
// try {
|
|
625
|
+
// await this.refreshAuxDevicesStatus();
|
|
626
|
+
// }
|
|
627
|
+
// catch (e) {
|
|
628
|
+
// logger.error('Failed to refresh device status', e);
|
|
629
|
+
// }
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
|
|
633
|
+
const interfaces = await this.getDeviceInterfaces();
|
|
634
|
+
|
|
635
|
+
const device: Device = {
|
|
636
|
+
nativeId: this.nativeId,
|
|
637
|
+
providerNativeId: this.plugin.nativeId,
|
|
638
|
+
name: this.name,
|
|
639
|
+
interfaces,
|
|
640
|
+
type: this.type as ScryptedDeviceType,
|
|
641
|
+
info: this.info,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
645
|
+
|
|
646
|
+
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
647
|
+
} catch (e) {
|
|
648
|
+
logger.error('Failed to update device interfaces', e);
|
|
649
|
+
}
|
|
637
650
|
|
|
638
|
-
|
|
651
|
+
this.refreshingState = false;
|
|
639
652
|
}
|
|
640
653
|
|
|
641
654
|
private async ensureBaichuanEventSubscription(): Promise<void> {
|
|
@@ -1283,13 +1296,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
|
|
|
1283
1296
|
return this.getDispatchEventsSelection().has('objects');
|
|
1284
1297
|
}
|
|
1285
1298
|
|
|
1286
|
-
private migrateDispatchEventsSetting(): void {
|
|
1287
|
-
const cur = (this.storageSettings.values as any).dispatchEvents;
|
|
1288
|
-
if (typeof cur === 'boolean') {
|
|
1289
|
-
(this.storageSettings.values as any).dispatchEvents = cur ? ['motion', 'objects'] : [];
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
1299
|
private scheduleApplyEventDispatchSettings(): void {
|
|
1294
1300
|
// Debounce to avoid rapid apply loops while editing multi-select.
|
|
1295
1301
|
this.dispatchEventsApplySeq++;
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { BaichuanClientOptions, ReolinkBaichuanApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
|
|
3
|
+
export type BaichuanTransport = "tcp" | "udp";
|
|
4
|
+
|
|
5
|
+
export type BaichuanConnectInputs = {
|
|
6
|
+
host: string;
|
|
7
|
+
username: string;
|
|
8
|
+
password: string;
|
|
9
|
+
uid?: string;
|
|
10
|
+
logger?: Console;
|
|
11
|
+
debugOptions?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function normalizeUid(uid?: string): string | undefined {
|
|
15
|
+
const v = uid?.trim();
|
|
16
|
+
return v ? v : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function maskUid(uid: string): string {
|
|
20
|
+
const v = uid.trim();
|
|
21
|
+
if (v.length <= 8) return v;
|
|
22
|
+
return `${v.slice(0, 4)}…${v.slice(-4)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isTcpFailureThatShouldFallbackToUdp(e: unknown): boolean {
|
|
26
|
+
const message = (e as any)?.message || (e as any)?.toString?.() || "";
|
|
27
|
+
if (typeof message !== "string") return false;
|
|
28
|
+
|
|
29
|
+
// Fallback only on transport/connection style failures.
|
|
30
|
+
// Wrong credentials won't be fixed by switching to UDP.
|
|
31
|
+
return (
|
|
32
|
+
message.includes("ECONNREFUSED") ||
|
|
33
|
+
message.includes("ETIMEDOUT") ||
|
|
34
|
+
message.includes("EHOSTUNREACH") ||
|
|
35
|
+
message.includes("ENETUNREACH") ||
|
|
36
|
+
message.includes("socket hang up") ||
|
|
37
|
+
message.includes("TCP connection timeout") ||
|
|
38
|
+
message.includes("Baichuan socket closed")
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createBaichuanApi(inputs: BaichuanConnectInputs, transport: BaichuanTransport): Promise<ReolinkBaichuanApi> {
|
|
43
|
+
const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
|
|
44
|
+
|
|
45
|
+
const base: BaichuanClientOptions = {
|
|
46
|
+
host: inputs.host,
|
|
47
|
+
username: inputs.username,
|
|
48
|
+
password: inputs.password,
|
|
49
|
+
logger: inputs.logger,
|
|
50
|
+
...(inputs.debugOptions ? { debugOptions: inputs.debugOptions } : {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const attachErrorHandler = (api: ReolinkBaichuanApi) => {
|
|
54
|
+
// Critical: BaichuanClient emits 'error'. If nobody listens, Node treats it as an
|
|
55
|
+
// uncaught exception. Ensure we always have a listener.
|
|
56
|
+
try {
|
|
57
|
+
api.client.on("error", (err: unknown) => {
|
|
58
|
+
const logger = inputs.logger ?? console;
|
|
59
|
+
const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
|
|
60
|
+
logger.error(`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`);
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (transport === "tcp") {
|
|
68
|
+
const api = new ReolinkBaichuanApi({
|
|
69
|
+
...base,
|
|
70
|
+
keepAliveInterval: 10000,
|
|
71
|
+
tcpSocketKeepAlive: true,
|
|
72
|
+
transport: "tcp",
|
|
73
|
+
});
|
|
74
|
+
attachErrorHandler(api);
|
|
75
|
+
return api;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const uid = normalizeUid(inputs.uid);
|
|
79
|
+
if (!uid) {
|
|
80
|
+
throw new Error("UID is required for battery cameras (BCUDP)");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const api = new ReolinkBaichuanApi({
|
|
84
|
+
...base,
|
|
85
|
+
transport: "udp",
|
|
86
|
+
udp: {
|
|
87
|
+
mode: "uid",
|
|
88
|
+
uid,
|
|
89
|
+
host: inputs.host,
|
|
90
|
+
broadcast: false,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
attachErrorHandler(api);
|
|
94
|
+
return api;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UdpFallbackInfo = {
|
|
98
|
+
host: string;
|
|
99
|
+
uid?: string;
|
|
100
|
+
uidMissing: boolean;
|
|
101
|
+
tcpError: unknown;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export async function connectBaichuanWithTcpUdpFallback(
|
|
105
|
+
inputs: BaichuanConnectInputs,
|
|
106
|
+
onUdpFallback?: (info: UdpFallbackInfo) => void,
|
|
107
|
+
): Promise<{ api: ReolinkBaichuanApi; transport: BaichuanTransport }> {
|
|
108
|
+
let tcpApi: ReolinkBaichuanApi | undefined;
|
|
109
|
+
try {
|
|
110
|
+
tcpApi = await createBaichuanApi(inputs, "tcp");
|
|
111
|
+
await tcpApi.login();
|
|
112
|
+
return { api: tcpApi, transport: "tcp" };
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
try {
|
|
116
|
+
await tcpApi?.close();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!isTcpFailureThatShouldFallbackToUdp(e)) {
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const uid = normalizeUid(inputs.uid);
|
|
127
|
+
const uidMissing = !uid;
|
|
128
|
+
|
|
129
|
+
onUdpFallback?.({
|
|
130
|
+
host: inputs.host,
|
|
131
|
+
uid,
|
|
132
|
+
uidMissing,
|
|
133
|
+
tcpError: e,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (uidMissing) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Baichuan TCP failed and this camera likely requires UDP/BCUDP. Set the Reolink UID in settings to continue (ip=${inputs.host}).`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const udpApi = await createBaichuanApi(inputs, "udp");
|
|
143
|
+
await udpApi.login();
|
|
144
|
+
return { api: udpApi, transport: "udp" };
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
|
|
2
2
|
import { ReolinkNativeCamera } from "./camera";
|
|
3
|
+
import { connectBaichuanWithTcpUdpFallback, maskUid } from "./connect";
|
|
3
4
|
|
|
4
5
|
class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
|
5
6
|
devices = new Map<string, ReolinkNativeCamera>();
|
|
@@ -35,14 +36,24 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
35
36
|
|
|
36
37
|
const username = settings.username?.toString();
|
|
37
38
|
const password = settings.password?.toString();
|
|
39
|
+
const uid = settings.uid?.toString();
|
|
38
40
|
|
|
39
41
|
if (ipAddress && username && password) {
|
|
40
|
-
const {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
const { api } = await connectBaichuanWithTcpUdpFallback(
|
|
43
|
+
{
|
|
44
|
+
host: ipAddress,
|
|
45
|
+
username,
|
|
46
|
+
password,
|
|
47
|
+
uid,
|
|
48
|
+
logger: this.console,
|
|
49
|
+
},
|
|
50
|
+
({ uid: normalizedUid, uidMissing }) => {
|
|
51
|
+
const uidMsg = !uidMissing && normalizedUid ? `UID ${maskUid(normalizedUid)}` : 'UID MISSING';
|
|
52
|
+
this.console.log(
|
|
53
|
+
`Baichuan TCP failed during discovery for ${ipAddress}; falling back to UDP/BCUDP (${uidMsg}).`,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
46
57
|
|
|
47
58
|
try {
|
|
48
59
|
const deviceInfo = await api.getInfo();
|
|
@@ -70,6 +81,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
70
81
|
device.storageSettings.values.password = password;
|
|
71
82
|
device.storageSettings.values.rtspChannel = rtspChannel;
|
|
72
83
|
device.storageSettings.values.ipAddress = ipAddress;
|
|
84
|
+
if (uid) device.storageSettings.values.uid = uid;
|
|
73
85
|
device.storageSettings.values.capabilities = capabilities;
|
|
74
86
|
device.updateDeviceInfo();
|
|
75
87
|
|