@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/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Reolink Native plugin for Scrypted",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
@@ -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
- // Try to get battery info only when camera is awake.
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
- if (!this.baichuanApi) {
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
- const sleepStatus = this.baichuanApi.getSleepStatus({ channel });
212
- if (sleepStatus.state !== 'awake') {
213
- return;
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 this.baichuanApi.getBatteryInfo(channel);
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.debug('Failed to get battery info during periodic update:', e);
264
+ this.console.warn('Failed to get battery info during periodic update:', e);
227
265
  }
228
266
 
229
- // // Wait a bit for the camera to fully wake up
230
- // await new Promise(resolve => setTimeout(resolve, 2000));
231
-
232
- // // Get battery info
233
- // const batteryInfo = await api.getBatteryStatus(channel);
234
- // if (batteryInfo.batteryPercent !== undefined) {
235
- // const oldLevel = this.lastBatteryLevel;
236
- // this.batteryLevel = batteryInfo.batteryPercent;
237
- // this.lastBatteryLevel = batteryInfo.batteryPercent;
238
-
239
- // // Log only if battery level changed
240
- // if (oldLevel !== undefined && oldLevel !== batteryInfo.batteryPercent) {
241
- // if (batteryInfo.chargeStatus !== undefined) {
242
- // // chargeStatus: "0"=charging, "1"=discharging, "2"=full
243
- // const charging = batteryInfo.chargeStatus === "0" || batteryInfo.chargeStatus === "2";
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
  });
@@ -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: `tcp://${host}:${port}`,
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<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }>>();
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<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
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 { host: cached.host, port: cached.port, sdp: cached.sdp, audio: cached.audio };
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 { host: created.host, port: created.port, sdp: created.sdp, audio: created.audio };
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<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
365
+ ): Promise<RfcServerInfo> {
324
366
  return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
325
367
  }
326
368
  }