/** * VStarCam React Native SDK * * This module provides a React Native bridge to the VStarCam P2P SDK, * enabling direct communication with VStarCam cameras for: * - P2P connection establishment * - WiFi configuration * - Video streaming * - PTZ control * - Camera settings */ import { NativeModules, NativeEventEmitter, Platform, requireNativeComponent, ViewStyle, UIManager, findNodeHandle, } from "react-native"; import React from "react"; // Video View Component export interface VStarCamVideoViewProps { /** Client pointer from create() call */ clientPtr: number; /** Video resolution: 1=high, 2=general, 4=low, 100=superHD */ resolution?: 1 | 2 | 4 | 100; /** Whether streaming is active */ streaming?: boolean; /** Whether live audio should play (unmuted) */ audioEnabled?: boolean; /** Style for the video view */ style?: ViewStyle; /** Called when stream starts */ onStreamStarted?: () => void; /** Called when stream stops */ onStreamStopped?: () => void; /** Called on stream error */ onStreamError?: (error: { message: string }) => void; } /** * Native video view component for VStarCam camera streaming. * * Usage: * ```tsx * * ``` */ // Video View Component registration with safety check let VStarCamVideoViewNative = null; if (Platform.OS === "android") { try { VStarCamVideoViewNative = requireNativeComponent("VStarCamVideoView"); } catch (e) { console.warn("VStarCamVideoView could not be registered:", e.message); } } export const VStarCamVideoView = VStarCamVideoViewNative || (() => null); /** * Capture a snapshot from the video view and save it to a file. * @param viewRef Reference to the VStarCamVideoView component * @param filePath Absolute path where the JPEG will be saved */ export function captureSnapshot(viewRef: any, filePath: string) { const node = findNodeHandle(viewRef); if (node) { UIManager.dispatchViewManagerCommand( node, (UIManager as any).VStarCamVideoView.Commands.captureSnapshot, [filePath] ); } } const LINKING_ERROR = `The package 'react-native-vstarcam' doesn't seem to be linked. Make sure: \n\n` + Platform.select({ ios: "- You have run 'pod install'\n", default: "" }) + "- You rebuilt the app after installing the package\n" + "- You are not using Expo Go (requires development build)"; // Diagnostic log for native modules - using warn to make it stand out! console.warn("[VStarCam] JS Initialization: Available NativeModules keys:", Object.keys(NativeModules)); console.warn("[VStarCam] Full NativeModules scan:", { VStarCam: !!NativeModules.VStarCam, WifiModule: !!NativeModules.WifiModule, VStarCamModule: !!NativeModules.VStarCamModule }); const VStarCamModule = NativeModules.VStarCam ? NativeModules.VStarCam : new Proxy( {}, { get(target, prop) { // Don't throw for internal JS/React checks if (prop === '$$typeof' || prop === 'constructor' || typeof prop === 'symbol') { return undefined; } // Log exactly what's being accessed when it fails console.error(`[VStarCam] Attempted to access property "${String(prop)}" but the native module is missing.`); throw new Error(LINKING_ERROR); }, } ); // Connection states export enum ConnectionState { CONNECTING = 1, INITIALIZING = 2, ONLINE = 3, CONNECT_FAILED = 4, DISCONNECTED = 5, INVALID_ID = 6, OFFLINE = 7, TIMEOUT = 8, WRONG_PWD = 9, INVALID_CLIENT = 10, MAX_SESSION = 11, } // Connection modes export enum ConnectionMode { NONE = 0, P2P = 1, // Direct P2P connection RELAY = 2, // Relay through cloud SOCKET = 3, // Socket connection } // Channel types for P2P communication export enum ChannelType { CMD = 0, // Command channel VIDEO = 1, // Video stream channel AUDIO = 2, // Audio receive channel TALK = 3, // Audio send (two-way audio) PLAYBACK = 4, // Playback channel ALARM = 5, // Alarm notification channel } // WiFi network info export interface WiFiNetwork { ssid: string; mac: string; security: string; signalStrength: number; channel: number; mode: string; } // Device info export interface DeviceInfo { serialNumber: string; model: string; firmware: string; manufacturer: string; features: { ptz: boolean; audio: boolean; twoWayAudio: boolean; nightVision: boolean; motionDetection: boolean; wifi: boolean; }; responsive?: boolean; } // Connection result export interface ConnectionResult { success: boolean; state: ConnectionState; mode?: ConnectionMode; error?: string; } // Command result export interface CommandResult { success: boolean; data?: Record; error?: string; } // Event types export type ConnectionEventListener = ( clientId: number, state: ConnectionState ) => void; export type CommandEventListener = ( clientId: number, command: number, data: Uint8Array ) => void; export type VideoFrameListener = ( clientId: number, frame: Uint8Array, width: number, height: number, timestamp: number ) => void; /** * VStarCam P2P Client * * Main class for interacting with VStarCam cameras */ class VStarCamClient { private clientPtr: number = 0; private currentUsername: string = "admin"; private currentPassword: string = "888888"; private eventEmitter: NativeEventEmitter; private connectionListeners: Set = new Set(); private commandListeners: Set = new Set(); private videoListeners: Set = new Set(); constructor() { this.eventEmitter = new NativeEventEmitter(VStarCamModule); this.setupEventListeners(); } private setupEventListeners() { this.eventEmitter.addListener("onConnectionStateChanged", (event) => { if (event.clientId === this.clientPtr) { this.connectionListeners.forEach((listener) => { listener(event.clientId, event.state); }); } }); this.eventEmitter.addListener("onCommandReceived", (event) => { if (event.clientId === this.clientPtr) { this.commandListeners.forEach((listener) => { listener(event.clientId, event.command, event.data); }); } }); this.eventEmitter.addListener("onVideoFrame", (event) => { if (event.clientId === this.clientPtr) { this.videoListeners.forEach((listener) => { listener( event.clientId, event.frame, event.width, event.height, event.timestamp ); }); } }); } async testBridge(): Promise { return VStarCamModule.testBridge(); } /** * Create a P2P client for a device * @param deviceId The device ID (DID) from VStarCam * @returns Client pointer (used internally) */ async create(deviceId: string): Promise { this.clientPtr = await VStarCamModule.clientCreate(deviceId); return this.clientPtr; } /** * Connect to the camera via P2P * @param lanScan Whether to scan LAN for the device * @param serverParam Server connection parameters * @param connectType Connection type (0 = P2P, 1 = Relay) */ async connect( lanScan: boolean = true, serverParam: string = "", connectType: number = 0 ): Promise { if (!this.clientPtr) { return { success: false, state: ConnectionState.INVALID_CLIENT, error: "Client not created", }; } const state = await VStarCamModule.clientConnect( this.clientPtr, lanScan, serverParam, connectType ); return { success: state === ConnectionState.ONLINE, state, }; } /** * Check the current connection mode * @returns Mode result including connection type and session handle */ async checkConnectionMode(): Promise { if (!this.clientPtr) return { success: false, mode: 0 }; return VStarCamModule.clientCheckMode(this.clientPtr); } /** * Get the current connection state of the client */ async getState(): Promise { if (!this.clientPtr) return { state: 5, isConnected: false }; return VStarCamModule.clientGetState(this.clientPtr); } /** * Login to the camera * @param username Camera username (default: admin) * @param password Camera password (default: 888888) */ async login( username: string = "admin", password: string = "888888" ): Promise { if (!this.clientPtr) return false; this.currentUsername = username; this.currentPassword = password; return VStarCamModule.clientLogin(this.clientPtr, username, password); } /** * Send a CGI command to the camera * @param cgi The CGI command (e.g., "get_status.cgi?") * @param timeout Timeout in seconds */ async sendCommand(cgi: string, timeout: number = 5): Promise { if (!this.clientPtr) return false; return VStarCamModule.clientWriteCgi(this.clientPtr, cgi, timeout); } /** * Get camera status/capabilities by sending get_status.cgi * Returns parsed key-value pairs from the camera response. * Includes fields like MaxZoomMultiple, support_WhiteLed_Ctrl, haveMotor, etc. */ async getStatus(): Promise | null> { if (!this.clientPtr) return null; const success = await this.sendCommand("get_status.cgi?"); if (!success) return null; return new Promise((resolve) => { const timeoutId = setTimeout(() => { this.removeCommandListener(handler); console.warn("[VStarCam] getStatus timed out"); resolve(null); }, 8000); const handler: CommandEventListener = (_, cmd, data) => { // cmd 24577 = status response (same as login response) if (cmd === 24577 || cmd === 24785) { clearTimeout(timeoutId); this.removeCommandListener(handler); try { const str = typeof data === "string" ? data : new TextDecoder().decode(data); const parsed = this.parseResponse(str); console.log("[VStarCam] getStatus response keys:", Object.keys(parsed).join(", ")); resolve(parsed); } catch (e) { console.error("[VStarCam] getStatus parse error:", e); resolve(null); } } }; this.addCommandListener(handler); }); } /** * Scan for available WiFi networks */ async scanWiFi(): Promise { if (!this.clientPtr) return []; const success = await this.sendCommand("wifi_scan.cgi?"); if (!success) return []; // Wait for response return new Promise((resolve) => { const timeout = setTimeout(() => resolve([]), 10000); const handler: CommandEventListener = (_, cmd, data) => { if (cmd === 24618 || cmd === 24584) { clearTimeout(timeout); this.removeCommandListener(handler); // Parse WiFi list from data const networks = this.parseWiFiList(data); resolve(networks); } }; this.addCommandListener(handler); }); } private parseWiFiList(data: Uint8Array): WiFiNetwork[] { // Implementation to parse CGI response const str = new TextDecoder().decode(data); const networks: WiFiNetwork[] = []; // Parse key=value pairs const params = this.parseResponse(str); const apNumber = parseInt(params["ap_number"] || "0", 10); for (let i = 0; i < apNumber; i++) { networks.push({ ssid: params[`ap_ssid[${i}]`] || "", mac: params[`ap_mac[${i}]`] || "", security: params[`ap_security[${i}]`] || "", signalStrength: parseInt(params[`ap_dbm0[${i}]`] || "0", 10), channel: parseInt(params[`ap_channel[${i}]`] || "0", 10), mode: params[`ap_mode[${i}]`] || "", }); } return networks; } private parseResponse(str: string): Record { const result: Record = {}; const pairs = str.split(";"); for (const pair of pairs) { const [key, value] = pair.split("="); if (key && value) { let cleanKey = key.trim(); // VStarCam often prefix variables with 'var ' if (cleanKey.startsWith("var ")) { cleanKey = cleanKey.substring(4).trim(); } let cleanValue = value.trim(); // Remove surrounding quotes if present if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) { cleanValue = cleanValue.substring(1, cleanValue.length - 1); } result[cleanKey] = cleanValue; } } return result; } /** * Configure camera WiFi * @param ssid WiFi network name * @param password WiFi password * @param security Security type (e.g., "WPA2") * @param channel WiFi channel */ async configureWiFi( ssid: string, password: string, security: string = "WPA2", channel: number = 0 ): Promise { const encodedSsid = encodeURIComponent(ssid); const encodedPassword = encodeURIComponent(password); const cgi = `set_wifi.cgi?ssid=${encodedSsid}&channel=${channel}&authtype=${security}&wpa_psk=${encodedPassword}&enable=1&`; const success = await this.sendCommand(cgi); if (!success) return false; // Wait for confirmation return new Promise((resolve) => { const waitTimeout = setTimeout(() => resolve(false), 10000); const handler: CommandEventListener = (_, cmd, data) => { if (cmd === 24593) { clearTimeout(waitTimeout); this.removeCommandListener(handler); const params = this.parseResponse(new TextDecoder().decode(data)); resolve(params["result"] === "0"); } }; this.addCommandListener(handler); }); } /** * Rotate the camera's admin password via `set_users.cgi`. * * Use this immediately after pairing to replace the factory default * (888888 / 88888888) with a per-camera random secret. This is the * primary defense against the "VUID + factory password → public stream" * attack: once rotated, possession of the printed serial alone is no * longer enough to authenticate against the camera. * * Preconditions: * - `login()` has succeeded with the CURRENT password. The CGI relies * on the established P2P session, plus loginuse/loginpas URL params * for the credential check (same pattern PTZ uses). * * On success: * - The camera's admin password is now `newPassword`. * - This client's cached `currentPassword` is updated, so subsequent * PTZ / settings / video calls continue to work without re-login. * - Caller MUST persist `newPassword` somewhere durable BEFORE the * return value resolves true (vault + backend). If we lose the * value, the camera becomes unreachable until a physical factory * reset. * * Format note: the firmware variants we've tested accept the * three-user slot form (user1/pwd1/pri1 + user2/user3 cleared). pri=2 * means admin. Other variants want different parameter names — * if a particular camera fails with result=-1, capture the response * here and iterate. * * @param newPassword The password to set. Caller should generate at * least 16 random characters; the camera firmware accepts up to ~32. * @returns true on confirmed success (camera replied result=0), false * on explicit failure or timeout. */ async rotateAdminPassword(newPassword: string): Promise { if (!this.clientPtr) return false; if (!newPassword || newPassword.length === 0) { console.warn("[VStarCam] rotateAdminPassword: empty password rejected"); return false; } const enc = encodeURIComponent; // loginuse/loginpas mirror getPtzBaseParams() — some firmware // revisions require URL-level auth on setters even after the // session login. Including them is the safe choice. const cgi = `set_users.cgi?loginuse=${enc(this.currentUsername)}&loginpas=${enc(this.currentPassword)}` + `&user1=admin&pwd1=${enc(newPassword)}&pri1=2` + `&user2=&pwd2=&pri2=&user3=&pwd3=&pri3=&`; const sent = await this.sendCommand(cgi); if (!sent) { console.warn("[VStarCam] rotateAdminPassword: sendCommand returned false"); return false; } // We don't know set_users.cgi's exact response cmd id, so listen for // any subsequent command response that carries a `result=` field // (the convention all setter CGIs follow). Anything within ~8s is // fair game. Binary frames (video, etc.) are filtered by the // result= substring check. return new Promise((resolve) => { let settled = false; const finish = (ok: boolean) => { if (settled) return; settled = true; clearTimeout(timeoutId); this.removeCommandListener(handler); if (ok) { // Mutate cached creds AFTER caller knows we succeeded — they // need this for the next CGI call to authenticate. this.currentPassword = newPassword; } resolve(ok); }; const timeoutId = setTimeout(() => { console.warn("[VStarCam] rotateAdminPassword: no result in 8s"); finish(false); }, 8000); const handler: CommandEventListener = (_, _cmd, data) => { try { const str = typeof data === "string" ? data : new TextDecoder().decode(data); if (!str.includes("result=")) return; const params = this.parseResponse(str); const result = params["result"]; // result=0 → success on every VStarCam setter we've seen. // Anything else (-1, -2, etc.) is an auth or validation failure. finish(result === "0"); } catch { // Parsing failure on a binary frame, etc. Ignore — we'll // either hit a real ack later or time out. } }; this.addCommandListener(handler); }); } /** * Get device information */ async getDeviceInfo(): Promise { try { // The native module handles credentials and basic command sending. // It returns a promise that resolves with the device info and responsive: true. return await VStarCamModule.clientGetDeviceInfo(this.clientPtr); } catch (e: any) { console.error("[VStarCam] getDeviceInfo failed:", e); return null; } } /** * PTZ Control - Move camera */ private getPtzBaseParams(): string { return `loginuse=${encodeURIComponent(this.currentUsername)}&loginpas=${encodeURIComponent(this.currentPassword)}`; } async ptzUp(continuous: boolean = false): Promise { const cmd = continuous ? "command=0&onestep=0" : "command=0&onestep=1"; return this.sendCommand(`decoder_control.cgi?${this.getPtzBaseParams()}&${cmd}&`); } async ptzDown(continuous: boolean = false): Promise { const cmd = continuous ? "command=2&onestep=0" : "command=2&onestep=1"; return this.sendCommand(`decoder_control.cgi?${this.getPtzBaseParams()}&${cmd}&`); } async ptzLeft(continuous: boolean = false): Promise { const cmd = continuous ? "command=4&onestep=0" : "command=5&onestep=1"; return this.sendCommand(`decoder_control.cgi?${this.getPtzBaseParams()}&${cmd}&`); } async ptzRight(continuous: boolean = false): Promise { const cmd = continuous ? "command=6&onestep=0" : "command=7&onestep=1"; return this.sendCommand(`decoder_control.cgi?${this.getPtzBaseParams()}&${cmd}&`); } async ptzStop(): Promise { return this.sendCommand(`decoder_control.cgi?${this.getPtzBaseParams()}&command=1&onestep=0&`); } /** * Start video stream * @param streamType 0 = main stream, 1 = sub stream */ async startVideo(streamType: number = 0): Promise { if (!this.clientPtr) return false; return VStarCamModule.startVideoStream(this.clientPtr, streamType); } /** * Stop video stream */ async stopVideo(): Promise { if (!this.clientPtr) return false; return VStarCamModule.stopVideoStream(this.clientPtr); } /** * Take a snapshot */ async takeSnapshot(): Promise { if (!this.clientPtr) return null; return VStarCamModule.takeSnapshot(this.clientPtr); } /** * Disconnect from camera */ async disconnect(): Promise { if (!this.clientPtr) return false; const result = await VStarCamModule.clientDisconnect(this.clientPtr); return result; } /** * Destroy client and release resources */ async destroy(): Promise { if (this.clientPtr) { await VStarCamModule.clientDestroy(this.clientPtr); this.clientPtr = 0; } this.connectionListeners.clear(); this.commandListeners.clear(); this.videoListeners.clear(); } // Event listener management addConnectionListener(listener: ConnectionEventListener) { this.connectionListeners.add(listener); } removeConnectionListener(listener: ConnectionEventListener) { this.connectionListeners.delete(listener); } addCommandListener(listener: CommandEventListener) { this.commandListeners.add(listener); } removeCommandListener(listener: CommandEventListener) { this.commandListeners.delete(listener); } addVideoListener(listener: VideoFrameListener) { this.videoListeners.add(listener); } removeVideoListener(listener: VideoFrameListener) { this.videoListeners.delete(listener); } /** * Get the current Wi-Fi SSID from the phone (Android only) */ async getPhoneSSID(): Promise { if (Platform.OS === 'android') { try { return await VStarCamModule.getWiFiSSID(); } catch (e) { console.warn("Failed to get phone SSID:", e); return null; } } return null; } get isConnected(): boolean { return this.clientPtr > 0; } } // Export singleton factory export function createVStarCamClient(): VStarCamClient { return new VStarCamClient(); } export { VStarCamClient }; export default VStarCamModule;