@apocaliss92/scrypted-reolink-native 0.1.0 → 0.1.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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera-battery.ts +93 -79
- package/src/common.ts +4 -1
- package/src/connect.ts +4 -2
- package/src/intercom.ts +16 -0
- package/src/stream-utils.ts +49 -7
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/camera-battery.ts
CHANGED
|
@@ -21,6 +21,7 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
21
21
|
private batteryUpdateTimer: NodeJS.Timeout | undefined;
|
|
22
22
|
private lastBatteryLevel: number | undefined;
|
|
23
23
|
private forceNewSnapshot: boolean = false;
|
|
24
|
+
private batteryUpdateInProgress: boolean = false;
|
|
24
25
|
|
|
25
26
|
private isBatteryInfoLoggingEnabled(): boolean {
|
|
26
27
|
const debugLogs = this.storageSettings.values.debugLogs || [];
|
|
@@ -150,43 +151,15 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
150
151
|
this.sleeping = false;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
//
|
|
154
|
-
// NOTE: getBatteryInfo in UDP mode is best-effort and should not force reconnect/login.
|
|
155
|
-
try {
|
|
156
|
-
const batteryInfo = await api.getBatteryInfo(channel);
|
|
157
|
-
if (this.isBatteryInfoLoggingEnabled()) {
|
|
158
|
-
this.console.log('getBatteryInfo result:', JSON.stringify(batteryInfo));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Update battery percentage and charge status
|
|
162
|
-
if (batteryInfo.batteryPercent !== undefined) {
|
|
163
|
-
const oldLevel = this.lastBatteryLevel;
|
|
164
|
-
this.batteryLevel = batteryInfo.batteryPercent;
|
|
165
|
-
this.lastBatteryLevel = batteryInfo.batteryPercent;
|
|
166
|
-
|
|
167
|
-
// Log only if battery level changed
|
|
168
|
-
if (oldLevel !== undefined && oldLevel !== batteryInfo.batteryPercent) {
|
|
169
|
-
if (batteryInfo.chargeStatus !== undefined) {
|
|
170
|
-
// chargeStatus: "0"=charging, "1"=discharging, "2"=full
|
|
171
|
-
const charging = batteryInfo.chargeStatus === "0" || batteryInfo.chargeStatus === "2";
|
|
172
|
-
this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}% (charging: ${charging})`);
|
|
173
|
-
} else {
|
|
174
|
-
this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}%`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
} catch (batteryError) {
|
|
179
|
-
// Silently ignore battery info errors to avoid spam
|
|
180
|
-
this.console.debug('Failed to get battery info:', batteryError);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// When camera wakes up, align auxiliary devices state and force snapshot (once)
|
|
154
|
+
// When camera wakes up (transition from sleeping to awake), align auxiliary devices state and force snapshot (once)
|
|
184
155
|
if (wasSleeping) {
|
|
185
156
|
this.alignAuxDevicesState().catch(() => { });
|
|
186
157
|
if (this.forceNewSnapshot) {
|
|
187
158
|
this.takePicture().catch(() => { });
|
|
188
159
|
}
|
|
189
160
|
}
|
|
161
|
+
// NOTE: We don't call getBatteryInfo() here anymore to avoid timeouts.
|
|
162
|
+
// Battery updates are handled by updateBatteryAndSnapshot() which properly wakes the camera.
|
|
190
163
|
} else {
|
|
191
164
|
// Unknown state
|
|
192
165
|
this.console.debug(`Sleep status unknown: ${sleepStatus.reason}`);
|
|
@@ -198,77 +171,118 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
198
171
|
}
|
|
199
172
|
|
|
200
173
|
private async updateBatteryAndSnapshot(): Promise<void> {
|
|
174
|
+
// Prevent multiple simultaneous calls
|
|
175
|
+
if (this.batteryUpdateInProgress) {
|
|
176
|
+
this.console.debug('Battery update already in progress, skipping');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.batteryUpdateInProgress = true;
|
|
201
181
|
try {
|
|
202
182
|
const channel = this.getRtspChannel();
|
|
203
|
-
|
|
204
183
|
const updateIntervalMinutes = this.storageSettings.values.batteryUpdateIntervalMinutes ?? 10;
|
|
205
184
|
this.console.log(`Force battery update interval started (every ${updateIntervalMinutes} minutes)`);
|
|
206
185
|
|
|
207
|
-
|
|
186
|
+
// Ensure we have a client connection
|
|
187
|
+
const api = await this.ensureClient();
|
|
188
|
+
if (!api) {
|
|
189
|
+
this.console.warn('Failed to ensure client connection for battery update');
|
|
208
190
|
return;
|
|
209
191
|
}
|
|
210
192
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
193
|
+
// Check current sleep status
|
|
194
|
+
let sleepStatus = api.getSleepStatus({ channel });
|
|
195
|
+
|
|
196
|
+
// If camera is sleeping, wake it up
|
|
197
|
+
if (sleepStatus.state === 'sleeping') {
|
|
198
|
+
this.console.log('Camera is sleeping, waking up for periodic update...');
|
|
199
|
+
try {
|
|
200
|
+
await api.wakeUp(channel, { waitAfterWakeMs: 2000 });
|
|
201
|
+
this.console.log('Wake command sent, waiting for camera to wake up...');
|
|
202
|
+
} catch (wakeError) {
|
|
203
|
+
this.console.warn('Failed to wake up camera:', wakeError);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Poll until camera is awake (with timeout)
|
|
208
|
+
const wakeTimeoutMs = 30000; // 30 seconds max
|
|
209
|
+
const startWakePoll = Date.now();
|
|
210
|
+
let awake = false;
|
|
211
|
+
|
|
212
|
+
while (Date.now() - startWakePoll < wakeTimeoutMs) {
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 1000)); // Check every second
|
|
214
|
+
sleepStatus = api.getSleepStatus({ channel });
|
|
215
|
+
if (sleepStatus.state === 'awake') {
|
|
216
|
+
awake = true;
|
|
217
|
+
this.console.log('Camera is now awake');
|
|
218
|
+
this.sleeping = false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!awake) {
|
|
224
|
+
this.console.warn('Camera did not wake up within timeout, skipping update');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
} else if (sleepStatus.state === 'awake') {
|
|
228
|
+
this.sleeping = false;
|
|
214
229
|
}
|
|
215
230
|
|
|
231
|
+
// Now that camera is awake, update all states
|
|
232
|
+
// 1. Update battery info
|
|
216
233
|
try {
|
|
217
|
-
const batteryInfo = await
|
|
234
|
+
const batteryInfo = await api.getBatteryInfo(channel);
|
|
218
235
|
if (this.isBatteryInfoLoggingEnabled()) {
|
|
219
236
|
this.console.log('getBatteryInfo result:', JSON.stringify(batteryInfo));
|
|
220
237
|
}
|
|
238
|
+
|
|
221
239
|
if (batteryInfo.batteryPercent !== undefined) {
|
|
240
|
+
const oldLevel = this.lastBatteryLevel;
|
|
222
241
|
this.batteryLevel = batteryInfo.batteryPercent;
|
|
223
242
|
this.lastBatteryLevel = batteryInfo.batteryPercent;
|
|
243
|
+
|
|
244
|
+
// Log only if battery level changed
|
|
245
|
+
if (oldLevel !== undefined && oldLevel !== batteryInfo.batteryPercent) {
|
|
246
|
+
if (batteryInfo.chargeStatus !== undefined) {
|
|
247
|
+
// chargeStatus: "0"=charging, "1"=discharging, "2"=full
|
|
248
|
+
const charging = batteryInfo.chargeStatus === "0" || batteryInfo.chargeStatus === "2";
|
|
249
|
+
this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}% (charging: ${charging})`);
|
|
250
|
+
} else {
|
|
251
|
+
this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}%`);
|
|
252
|
+
}
|
|
253
|
+
} else if (oldLevel === undefined) {
|
|
254
|
+
// First time setting battery level
|
|
255
|
+
if (batteryInfo.chargeStatus !== undefined) {
|
|
256
|
+
const charging = batteryInfo.chargeStatus === "0" || batteryInfo.chargeStatus === "2";
|
|
257
|
+
this.console.log(`Battery level set: ${batteryInfo.batteryPercent}% (charging: ${charging})`);
|
|
258
|
+
} else {
|
|
259
|
+
this.console.log(`Battery level set: ${batteryInfo.batteryPercent}%`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
224
262
|
}
|
|
225
263
|
} catch (e) {
|
|
226
|
-
this.console.
|
|
264
|
+
this.console.warn('Failed to get battery info during periodic update:', e);
|
|
227
265
|
}
|
|
228
266
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}% (charging: ${charging})`);
|
|
245
|
-
// } else {
|
|
246
|
-
// this.console.log(`Battery level changed: ${oldLevel}% → ${batteryInfo.batteryPercent}%`);
|
|
247
|
-
// }
|
|
248
|
-
// } else if (oldLevel === undefined) {
|
|
249
|
-
// // First time setting battery level
|
|
250
|
-
// if (batteryInfo.chargeStatus !== undefined) {
|
|
251
|
-
// const charging = batteryInfo.chargeStatus === "0" || batteryInfo.chargeStatus === "2";
|
|
252
|
-
// this.console.log(`Battery level set: ${batteryInfo.batteryPercent}% (charging: ${charging})`);
|
|
253
|
-
// } else {
|
|
254
|
-
// this.console.log(`Battery level set: ${batteryInfo.batteryPercent}%`);
|
|
255
|
-
// }
|
|
256
|
-
// }
|
|
257
|
-
// }
|
|
258
|
-
|
|
259
|
-
// // Update snapshot
|
|
260
|
-
// try {
|
|
261
|
-
// const snapshotBuffer = await api.getSnapshot(channel);
|
|
262
|
-
// const mo = await sdk.mediaManager.createMediaObject(snapshotBuffer, 'image/jpeg');
|
|
263
|
-
// this.lastPicture = { mo, atMs: Date.now() };
|
|
264
|
-
// this.console.log('Snapshot updated');
|
|
265
|
-
// } catch (snapshotError) {
|
|
266
|
-
// this.console.warn('Failed to update snapshot during periodic update', snapshotError);
|
|
267
|
-
// }
|
|
268
|
-
|
|
269
|
-
// this.sleeping = false;
|
|
267
|
+
// 2. Align auxiliary devices state
|
|
268
|
+
try {
|
|
269
|
+
await this.alignAuxDevicesState();
|
|
270
|
+
} catch (e) {
|
|
271
|
+
this.console.warn('Failed to align auxiliary devices state:', e);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 3. Update snapshot
|
|
275
|
+
try {
|
|
276
|
+
this.forceNewSnapshot = true;
|
|
277
|
+
await this.takePicture();
|
|
278
|
+
this.console.log('Snapshot updated during periodic update');
|
|
279
|
+
} catch (snapshotError) {
|
|
280
|
+
this.console.warn('Failed to update snapshot during periodic update:', snapshotError);
|
|
281
|
+
}
|
|
270
282
|
} catch (e) {
|
|
271
283
|
this.console.warn('Failed to update battery and snapshot', e);
|
|
284
|
+
} finally {
|
|
285
|
+
this.batteryUpdateInProgress = false;
|
|
272
286
|
}
|
|
273
287
|
}
|
|
274
288
|
|
package/src/common.ts
CHANGED
|
@@ -480,6 +480,10 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
480
480
|
this.streamManager = new StreamManager({
|
|
481
481
|
createStreamClient: () => this.createStreamClient(),
|
|
482
482
|
getLogger: () => this.getLogger(),
|
|
483
|
+
credentials: {
|
|
484
|
+
username: this.storageSettings.values.username || '',
|
|
485
|
+
password: this.storageSettings.values.password || '',
|
|
486
|
+
},
|
|
483
487
|
});
|
|
484
488
|
|
|
485
489
|
setTimeout(async () => {
|
|
@@ -1317,7 +1321,6 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1317
1321
|
});
|
|
1318
1322
|
};
|
|
1319
1323
|
|
|
1320
|
-
// Use withBaichuanRetry (regular cameras have retry logic, battery cameras just execute)
|
|
1321
1324
|
return await this.withBaichuanRetry(createStreamFn);
|
|
1322
1325
|
}
|
|
1323
1326
|
|
package/src/connect.ts
CHANGED
|
@@ -66,9 +66,11 @@ export async function createBaichuanApi(props: {
|
|
|
66
66
|
// Only log if it's not a recoverable error to avoid spam
|
|
67
67
|
if (typeof msg === 'string' && (
|
|
68
68
|
msg.includes('Baichuan socket closed') ||
|
|
69
|
-
msg.includes('Baichuan UDP stream closed')
|
|
69
|
+
msg.includes('Baichuan UDP stream closed') ||
|
|
70
|
+
msg.includes('Not running')
|
|
70
71
|
)) {
|
|
71
|
-
// Silently ignore recoverable socket close errors
|
|
72
|
+
// Silently ignore recoverable socket close errors and "Not running" errors
|
|
73
|
+
// "Not running" is common for UDP/battery cameras when sleeping or during initialization
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
74
76
|
logger.error(`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`);
|
package/src/intercom.ts
CHANGED
|
@@ -69,6 +69,22 @@ export class ReolinkBaichuanIntercom {
|
|
|
69
69
|
|
|
70
70
|
const session = await this.camera.withBaichuanRetry(async () => {
|
|
71
71
|
const api = await this.camera.ensureClient();
|
|
72
|
+
|
|
73
|
+
// For UDP/battery cameras, wake up the camera if it's sleeping before creating talk session
|
|
74
|
+
if (this.camera.options?.type === 'battery') {
|
|
75
|
+
try {
|
|
76
|
+
const sleepStatus = api.getSleepStatus({ channel });
|
|
77
|
+
if (sleepStatus.state === 'sleeping') {
|
|
78
|
+
logger.log('Camera is sleeping, waking up for intercom...');
|
|
79
|
+
await api.wakeUp(channel, { waitAfterWakeMs: 2000 });
|
|
80
|
+
// Wait a bit more to ensure camera is fully awake
|
|
81
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
logger.debug('Failed to check/wake camera for intercom, proceeding anyway', e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
return await api.createTalkSession(channel, {
|
|
73
89
|
blocksPerPayload: this.blocksPerPayload,
|
|
74
90
|
});
|
package/src/stream-utils.ts
CHANGED
|
@@ -20,6 +20,14 @@ export interface StreamManagerOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
createStreamClient: () => Promise<ReolinkBaichuanApi>;
|
|
22
22
|
getLogger: () => Console;
|
|
23
|
+
/**
|
|
24
|
+
* Credentials to include in the TCP stream (username, password).
|
|
25
|
+
* Uses the same credentials as the main connection.
|
|
26
|
+
*/
|
|
27
|
+
credentials: {
|
|
28
|
+
username: string;
|
|
29
|
+
password: string;
|
|
30
|
+
};
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
|
|
@@ -207,7 +215,7 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
207
215
|
}): Promise<MediaObject> {
|
|
208
216
|
const { streamManager, channel, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
|
|
209
217
|
|
|
210
|
-
const { host, port, sdp, audio } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
|
|
218
|
+
const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
|
|
211
219
|
|
|
212
220
|
// Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
|
|
213
221
|
try {
|
|
@@ -230,8 +238,13 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
230
238
|
mso.audio.channels = audio.channels;
|
|
231
239
|
}
|
|
232
240
|
|
|
241
|
+
// Build URL with credentials: tcp://username:password@host:port
|
|
242
|
+
const encodedUsername = encodeURIComponent(username || '');
|
|
243
|
+
const encodedPassword = encodeURIComponent(password || '');
|
|
244
|
+
const url = `tcp://${encodedUsername}:${encodedPassword}@${host}:${port}`;
|
|
245
|
+
|
|
233
246
|
const rfc = {
|
|
234
|
-
url
|
|
247
|
+
url,
|
|
235
248
|
sdp,
|
|
236
249
|
mediaStreamOptions: mso as ResponseMediaStreamOptions,
|
|
237
250
|
};
|
|
@@ -241,9 +254,18 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
241
254
|
});
|
|
242
255
|
}
|
|
243
256
|
|
|
257
|
+
type RfcServerInfo = {
|
|
258
|
+
host: string;
|
|
259
|
+
port: number;
|
|
260
|
+
sdp: string;
|
|
261
|
+
audio?: { codec: string; sampleRate: number; channels: number };
|
|
262
|
+
username: string;
|
|
263
|
+
password: string;
|
|
264
|
+
};
|
|
265
|
+
|
|
244
266
|
export class StreamManager {
|
|
245
267
|
private nativeRfcServers = new Map<string, ScryptedRfc4571TcpServer>();
|
|
246
|
-
private nativeRfcServerCreatePromises = new Map<string, Promise<
|
|
268
|
+
private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
|
|
247
269
|
|
|
248
270
|
constructor(private opts: StreamManagerOptions) {
|
|
249
271
|
}
|
|
@@ -257,7 +279,7 @@ export class StreamManager {
|
|
|
257
279
|
channel: number,
|
|
258
280
|
profile: StreamProfile,
|
|
259
281
|
expectedVideoType?: 'H264' | 'H265',
|
|
260
|
-
): Promise<
|
|
282
|
+
): Promise<RfcServerInfo> {
|
|
261
283
|
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
262
284
|
if (existingCreate) {
|
|
263
285
|
return await existingCreate;
|
|
@@ -272,7 +294,14 @@ export class StreamManager {
|
|
|
272
294
|
);
|
|
273
295
|
}
|
|
274
296
|
else {
|
|
275
|
-
return {
|
|
297
|
+
return {
|
|
298
|
+
host: cached.host,
|
|
299
|
+
port: cached.port,
|
|
300
|
+
sdp: cached.sdp,
|
|
301
|
+
audio: cached.audio,
|
|
302
|
+
username: (cached as any).username || this.opts.credentials.username,
|
|
303
|
+
password: (cached as any).password || this.opts.credentials.password,
|
|
304
|
+
};
|
|
276
305
|
}
|
|
277
306
|
}
|
|
278
307
|
|
|
@@ -288,6 +317,10 @@ export class StreamManager {
|
|
|
288
317
|
|
|
289
318
|
const api = await this.opts.createStreamClient();
|
|
290
319
|
const { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
320
|
+
|
|
321
|
+
// Use the same credentials as the main connection
|
|
322
|
+
const { username, password } = this.opts.credentials;
|
|
323
|
+
|
|
291
324
|
const created = await createScryptedRfc4571TcpServer({
|
|
292
325
|
api,
|
|
293
326
|
channel,
|
|
@@ -295,6 +328,8 @@ export class StreamManager {
|
|
|
295
328
|
logger: this.getLogger(),
|
|
296
329
|
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
297
330
|
closeApiOnTeardown: true,
|
|
331
|
+
username,
|
|
332
|
+
password,
|
|
298
333
|
});
|
|
299
334
|
|
|
300
335
|
this.nativeRfcServers.set(streamKey, created);
|
|
@@ -303,7 +338,14 @@ export class StreamManager {
|
|
|
303
338
|
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
304
339
|
});
|
|
305
340
|
|
|
306
|
-
return {
|
|
341
|
+
return {
|
|
342
|
+
host: created.host,
|
|
343
|
+
port: created.port,
|
|
344
|
+
sdp: created.sdp,
|
|
345
|
+
audio: created.audio,
|
|
346
|
+
username: (created as any).username || this.opts.credentials.username,
|
|
347
|
+
password: (created as any).password || this.opts.credentials.password,
|
|
348
|
+
};
|
|
307
349
|
})();
|
|
308
350
|
|
|
309
351
|
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
@@ -320,7 +362,7 @@ export class StreamManager {
|
|
|
320
362
|
profile: StreamProfile,
|
|
321
363
|
streamKey: string,
|
|
322
364
|
expectedVideoType?: 'H264' | 'H265',
|
|
323
|
-
): Promise<
|
|
365
|
+
): Promise<RfcServerInfo> {
|
|
324
366
|
return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
|
|
325
367
|
}
|
|
326
368
|
}
|