diff --git a/app.json b/app.json index 76d0c95..b6fbe85 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,14 @@ "libraryName": "duooomi-ble-sdk", "group": "com.duooomi", "package": "com.duooomi.ble.sdk", - "version": "1.0.0" + "version": "1.0.0", + "publishing": [ + { + "type": "localDirectory", + "name": "dist", + "path": "./dist/android/maven" + } + ] } } ], diff --git a/ble/sdk/BleSDK.ts b/ble/sdk/BleSDK.ts index 25cb1d9..7662ed8 100644 --- a/ble/sdk/BleSDK.ts +++ b/ble/sdk/BleSDK.ts @@ -57,6 +57,7 @@ class BleSDK { // 批量设备发现缓冲 private pendingDevices: Array<{ id: string; name: string | null; rssi: number | null }> = [] + private discoveredDevices: Array<{ id: string; name: string | null; rssi: number | null }> = [] private flushTimer: ReturnType | null = null initialize() { @@ -146,59 +147,84 @@ class BleSDK { // ====== 接收原生 App 的消息指令 ====== private setupMessageHandler() { addMessageListener((event: Record) => { - const { action, ...params } = event - console.log(`[BleSDK] Received action: ${action}`, params) + void this.handleAction(event) + }) + } + + private async handleAction(event: Record) { + const { action, requestId, ...params } = event + console.log(`[BleSDK] Received action: ${action}`, params) + + try { + let data: any = null switch (action) { case 'scan': - this.startScan() + await this.startScan() + data = { started: true } break case 'stopScan': this.stopScan() + data = { stopped: true } break case 'connect': - this.connect(params.deviceId) + data = await this.connect(params.deviceId) break case 'disconnect': - this.disconnect() + data = await this.disconnect() break case 'getDeviceInfo': - this.getDeviceInfo() + data = await this.getDeviceInfo() break case 'getVersion': - this.getVersion() + data = await this.getVersion() break case 'bind': - this.bind(params.userId) + data = await this.bind(params.userId) break case 'unbind': - this.unbind(params.userId) + data = await this.unbind(params.userId) break case 'deleteFile': - this.deleteFile(params.key) + data = await this.deleteFile(params.key) break case 'transferFile': - this.transferFile(params.fileUri, params.commandType) + data = await this.transferFile(params.fileUri, params.commandType) break default: - sendMessage({ event: 'error', type: 'unknownAction', message: `Unknown action: ${action}` }) + throw new Error(`Unknown action: ${action}`) } - }) + + if (requestId) { + this.sendResponse(requestId, action, data) + } + } catch (e: any) { + const message = e instanceof Error ? e.message : String(e) + if (requestId) { + this.sendErrorResponse(requestId, action, message) + return + } + + sendMessage({ event: 'error', type: action ?? 'unknownAction', message }) + } } // ====== 蓝牙操作实现 ====== private async startScan() { - try { - const hasPerms = await this.requestPermissions() - if (!hasPerms) { - sendMessage({ event: 'error', type: 'permission', message: 'Bluetooth permission denied' }) - return - } + const hasPerms = await this.requestPermissions() + if (!hasPerms) { + const error = new Error('Bluetooth permission denied') + this.updateState(STATE_KEYS.ERROR, error.message) + sendMessage({ event: 'error', type: 'permission', message: error.message }) + throw error + } + try { this.updateState(STATE_KEYS.BT_STATE, 'scanning') this.updateState(STATE_KEYS.DISCOVERED_DEVICES, []) this.pendingDevices = [] + this.discoveredDevices = [] await this.bleClient.startScan( [BLE_UUIDS.SERVICE], @@ -223,6 +249,7 @@ class BleSDK { this.updateState(STATE_KEYS.BT_STATE, 'idle') this.updateState(STATE_KEYS.ERROR, e.message) sendMessage({ event: 'error', type: 'scan', message: e.message }) + throw e } } @@ -235,8 +262,7 @@ class BleSDK { private async connect(deviceId: string) { if (!deviceId) { - sendMessage({ event: 'error', type: 'connect', message: 'deviceId is required' }) - return + throw new Error('deviceId is required') } this.stopScan() @@ -258,10 +284,15 @@ class BleSDK { // 自动获取设备信息 this.getDeviceInfo().catch(() => {}) + return { + id: device.id, + name: device.name || device.localName || null, + } } catch (e: any) { this.updateState(STATE_KEYS.BT_STATE, 'idle') this.updateState(STATE_KEYS.ERROR, e.message) sendMessage({ event: 'error', type: 'connect', message: e.message }) + throw e } } @@ -278,68 +309,75 @@ class BleSDK { this.updateState(STATE_KEYS.BT_STATE, 'disconnected') sendMessage({ event: 'disconnected' }) + return { success: true } } catch (e: any) { sendMessage({ event: 'error', type: 'disconnect', message: e.message }) + throw e } } private async getDeviceInfo() { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { const promise = this.createWaitable('deviceInfo') await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId) return await promise } catch (e: any) { sendMessage({ event: 'error', type: 'deviceInfo', message: e.message }) + throw e } } private async getVersion() { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { const promise = this.createWaitable('versionInfo') await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId) return await promise } catch (e: any) { sendMessage({ event: 'error', type: 'version', message: e.message }) + throw e } } private async bind(userId: string) { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { const promise = this.createWaitable('bindDevice') await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId) return await promise } catch (e: any) { sendMessage({ event: 'error', type: 'bind', message: e.message }) + throw e } } private async unbind(userId: string) { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { const promise = this.createWaitable('unbindDevice') await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId) return await promise } catch (e: any) { sendMessage({ event: 'error', type: 'unbind', message: e.message }) + throw e } } private async deleteFile(key: string) { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { const promise = this.createWaitable('deleteFile') await this.deviceInfoService.deleteFile(this.connectedDeviceId, key) return await promise } catch (e: any) { sendMessage({ event: 'error', type: 'deleteFile', message: e.message }) + throw e } } private async transferFile(fileUri: string, commandType?: number) { - if (!this.connectedDeviceId) return + if (!this.connectedDeviceId) throw new Error('No device connected') try { this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0) const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO @@ -356,9 +394,11 @@ class BleSDK { ) sendMessage({ event: 'transferComplete' }) + return { success: true } } catch (e: any) { this.updateState(STATE_KEYS.ERROR, e.message) sendMessage({ event: 'error', type: 'transfer', message: e.message }) + throw e } } @@ -373,7 +413,9 @@ class BleSDK { } private queueDevice(device: { id: string; name: string | null; rssi: number | null }) { - if (this.pendingDevices.some((d) => d.id === device.id)) return + if (this.discoveredDevices.some((d) => d.id === device.id)) return + + this.discoveredDevices.push(device) this.pendingDevices.push(device) if (this.flushTimer) clearTimeout(this.flushTimer) @@ -382,12 +424,38 @@ class BleSDK { private flushDevices() { if (this.pendingDevices.length === 0) return - const devices = [...this.pendingDevices] + const devices = [...this.discoveredDevices] this.pendingDevices = [] this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices) sendMessage({ event: 'devicesFound', devices }) } + private sendResponse(requestId: string, action: string, data: any) { + sendMessage({ event: 'response', requestId, action, success: true, data: this.serializeForNative(data) }) + } + + private sendErrorResponse(requestId: string, action: string | undefined, message: string) { + sendMessage({ event: 'response', requestId, action, success: false, error: message }) + } + + private serializeForNative(value: any): any { + if (typeof value === 'bigint') { + return value.toString() + } + + if (Array.isArray(value)) { + return value.map((item) => this.serializeForNative(item)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, this.serializeForNative(nestedValue)]) + ) + } + + return value + } + private createWaitable(opId: string, timeout = 10000): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { diff --git a/package.json b/package.json index 020fb4b..b3b5b1a 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,12 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "build:ios": "npx expo-brownfield build:ios", - "build:android": "npx expo-brownfield build:android", + "build:brownfield:ios": "pnpx expo-brownfield build:ios", + "build:brownfield:android": "pnpx expo-brownfield build:android", + "build:ios": "node ./scripts/package-ios.mjs", + "build:android": "node ./scripts/package-android.mjs", + "package:ios": "node ./scripts/package-ios.mjs", + "package:android": "node ./scripts/package-android.mjs", "lint": "expo lint" }, "dependencies": { diff --git a/scripts/package-android.mjs b/scripts/package-android.mjs new file mode 100644 index 0000000..9195046 --- /dev/null +++ b/scripts/package-android.mjs @@ -0,0 +1,38 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const rootDir = path.resolve(__dirname, '..') +const distDir = path.join(rootDir, 'dist', 'android') +const mavenDir = path.join(distDir, 'maven') +const wrapperDir = path.join(distDir, 'wrapper') +const wrapperTemplateDir = path.join(rootDir, 'wrappers', 'android') + +function run(command, args) { + const executable = process.platform === 'win32' ? `${command}.cmd` : command + const result = spawnSync(executable, args, { + cwd: rootDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +rmSync(distDir, { recursive: true, force: true }) +mkdirSync(distDir, { recursive: true }) + +run('pnpx', ['expo-brownfield', 'build:android', '--release']) + +if (!existsSync(mavenDir)) { + throw new Error(`Missing Android Maven output: ${mavenDir}`) +} + +cpSync(wrapperTemplateDir, wrapperDir, { recursive: true }) + +console.log(`Android wrapper package ready: ${wrapperDir}`) +console.log(`Android Maven repo ready: ${mavenDir}`) diff --git a/scripts/package-ios.mjs b/scripts/package-ios.mjs new file mode 100644 index 0000000..8e4901f --- /dev/null +++ b/scripts/package-ios.mjs @@ -0,0 +1,64 @@ +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const rootDir = path.resolve(__dirname, '..') +const distDir = path.join(rootDir, 'dist', 'ios') +const tempArtifactsDir = path.join(distDir, '.artifacts') +const packageDir = path.join(distDir, 'DuooomiBleWrapper') +const frameworksDir = path.join(packageDir, 'Frameworks') +const wrapperTemplateDir = path.join(rootDir, 'wrappers', 'ios') + +function run(command, args) { + const executable = process.platform === 'win32' ? `${command}.cmd` : command + const result = spawnSync(executable, args, { + cwd: rootDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +function findDirectoryByName(dir, expectedName) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory() && entry.name === expectedName) { + return fullPath + } + } + + return null +} + +rmSync(distDir, { recursive: true, force: true }) +mkdirSync(frameworksDir, { recursive: true }) + +run('pnpx', ['expo-brownfield', 'build:ios', '--release', '--artifacts', tempArtifactsDir]) + +const sdkFramework = findDirectoryByName(tempArtifactsDir, 'DuooomiBleSDK.xcframework') +const hermesFramework = findDirectoryByName(tempArtifactsDir, 'hermesvm.xcframework') + +if (!sdkFramework) { + throw new Error(`Missing iOS artifact: ${path.join(tempArtifactsDir, 'DuooomiBleSDK.xcframework')}`) +} + +if (!hermesFramework) { + throw new Error(`Missing iOS artifact: ${path.join(tempArtifactsDir, 'hermesvm.xcframework')}`) +} + +cpSync(wrapperTemplateDir, packageDir, { recursive: true }) +cpSync(sdkFramework, path.join(frameworksDir, 'DuooomiBleSDK.xcframework'), { recursive: true }) +cpSync(hermesFramework, path.join(frameworksDir, 'hermesvm.xcframework'), { recursive: true }) + +rmSync(tempArtifactsDir, { recursive: true, force: true }) + +if (!existsSync(path.join(packageDir, 'Package.swift'))) { + throw new Error('Swift wrapper package is incomplete: missing Package.swift') +} + +console.log(`iOS wrapper package ready: ${packageDir}`) diff --git a/wrappers/android/README.md b/wrappers/android/README.md new file mode 100644 index 0000000..3c3ed46 --- /dev/null +++ b/wrappers/android/README.md @@ -0,0 +1,63 @@ +# Android Wrapper 输出说明 + +该目录会在执行 `pnpm build:android` 后被复制到 `dist/android/wrapper`,用于交付给 Android 原生宿主工程。 + +## 目录内容 + +- `../maven`:brownfield AAR 及其依赖对应的 Maven 仓库 +- `wrapper/`:对外暴露 `DuooomiBleManager` 和 `DuooomiBleRuntime` 的 Android Library 模块 + +## 宿主 App 接入步骤 + +1. 将 `dist/android/maven` 配置为宿主工程可访问的 Maven 仓库。 +2. 将 `dist/android/wrapper` 作为 composite build 引入,或者直接把 `wrapper` 模块复制到宿主工程。 +3. 在宿主 App 的一个长期存在的容器里挂载 Brownfield 对应的 React Native View。 +4. 运行时启动后,通过 `runtime.manager` 调用蓝牙能力。 + +## 最小使用示例 + +```kotlin +val runtime = DuooomiBleRuntime() +runtime.start(container = rootView) { context -> + createReactNativeView(context) +} + +lifecycleScope.launch { + val result = runtime.manager.connect(deviceId = "device-id") + Log.d("DuooomiBle", result.toString()) +} +``` + +如果是扫描,仍然沿用监听方式: + +```kotlin +runtime.manager.onDiscoveredDevicesChange = { devices -> + Log.d("DuooomiBle", devices.toString()) +} + +runtime.manager.scan() +``` + +## 如果宿主需要先初始化 Brownfield Host + +```kotlin +val runtime = DuooomiBleRuntime() +runtime.start( + container = rootView, + initializeHost = { + initializeBrownfieldHost() + } +) { context -> + createReactNativeView(context) +} +``` + +## 说明 + +- `DuooomiBleRuntime` 负责挂载隐藏的 RN 容器并启动 JS 运行时 +- `DuooomiBleManager` 的大部分命令方法为 `suspend`,例如 `connect`、`bind`、`transferFile` +- `scan` / `stopScan` 仍然保持事件监听方式 +- `DuooomiBleManager` 负责发送 `expo-brownfield` 消息和订阅 `BrownfieldState` +- `createReactNativeView(context)` 需要由宿主工程提供,因为不同宿主的 Brownfield 接入方式可能不同 + +如果没有先调用 `DuooomiBleRuntime.start(...)`,那么 JS 入口不会真正启动,`scan`、`connect` 等调用也不会生效。 diff --git a/wrappers/android/build.gradle.kts b/wrappers/android/build.gradle.kts new file mode 100644 index 0000000..d023d53 --- /dev/null +++ b/wrappers/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.library") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/wrappers/android/settings.gradle.kts b/wrappers/android/settings.gradle.kts new file mode 100644 index 0000000..033a3cf --- /dev/null +++ b/wrappers/android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = uri("../maven")) + } +} + +rootProject.name = "duooomi-ble-wrapper" +include(":wrapper") diff --git a/wrappers/android/wrapper/build.gradle.kts b/wrappers/android/wrapper/build.gradle.kts new file mode 100644 index 0000000..c75987d --- /dev/null +++ b/wrappers/android/wrapper/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.duooomi.ble.wrapper" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("com.duooomi:duooomi-ble-sdk:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/wrappers/android/wrapper/consumer-rules.pro b/wrappers/android/wrapper/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/wrappers/android/wrapper/src/main/AndroidManifest.xml b/wrappers/android/wrapper/src/main/AndroidManifest.xml new file mode 100644 index 0000000..22b1138 --- /dev/null +++ b/wrappers/android/wrapper/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleManager.kt b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleManager.kt new file mode 100644 index 0000000..407020a --- /dev/null +++ b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleManager.kt @@ -0,0 +1,341 @@ +package com.duooomi.ble.wrapper + +import expo.modules.brownfield.BrownfieldMessage +import expo.modules.brownfield.BrownfieldMessaging +import expo.modules.brownfield.BrownfieldState +import expo.modules.brownfield.Removable +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** 表示 `DuooomiBleManager` 异步命令调用失败。 */ +class DuooomiBleManagerException(message: String) : Exception(message) + +/** + * 原生宿主调用的 BLE 门面。 + * + * 该类型负责把宿主方法调用转换为 `expo-brownfield` 消息, + * 并将 JS 侧共享状态变化转发给宿主回调。 + */ +class DuooomiBleManager { + companion object { + private const val BT_STATE = "btState" + private const val CONNECTED_DEVICE = "connectedDevice" + private const val DEVICE_INFO = "deviceInfo" + private const val VERSION = "version" + private const val IS_ACTIVATED = "isActivated" + private const val TRANSFER_PROGRESS = "transferProgress" + private const val ERROR = "error" + private const val DISCOVERED_DEVICES = "discoveredDevices" + } + + private val sharedStateSubscriptions = mutableListOf() + private var messageListenerId: String? = null + private var started = false + private val pendingRequests = ConcurrentHashMap>>() + + /** 接收 JS 侧原始消息事件。 */ + var onMessage: ((BrownfieldMessage) -> Unit)? = null + /** 接收蓝牙状态变化,例如 `idle`、`scanning`、`connected`。 */ + var onBluetoothStateChange: ((String) -> Unit)? = null + /** 接收当前已连接设备信息。 */ + var onConnectedDeviceChange: ((DuooomiConnectedDevice) -> Unit)? = null + /** 接收设备详情信息。 */ + var onDeviceInfoChange: ((DuooomiDeviceInfo) -> Unit)? = null + /** 接收固件版本信息。 */ + var onVersionChange: ((String) -> Unit)? = null + /** 接收设备激活状态变化。 */ + var onActivationChange: ((Boolean) -> Unit)? = null + /** 接收文件传输进度,范围为 `0..100`。 */ + var onTransferProgressChange: ((Int) -> Unit)? = null + /** 接收 SDK 上报的错误字符串。 */ + var onError: ((String) -> Unit)? = null + /** 接收扫描发现的设备列表。 */ + var onDiscoveredDevicesChange: ((List) -> Unit)? = null + + /** + * 开始订阅 Brownfield 消息与共享状态。 + * + * 调用前应先通过 [DuooomiBleRuntime] 启动 RN/Brownfield 运行时。 + */ + fun start() { + if (started) return + started = true + + sharedStateSubscriptions += BrownfieldState.subscribe(BT_STATE) { value -> + (value as? String)?.let { onBluetoothStateChange?.invoke(it) } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(CONNECTED_DEVICE) { value -> + @Suppress("UNCHECKED_CAST") + (value as? Map)?.let { + onConnectedDeviceChange?.invoke( + DuooomiConnectedDevice( + id = DuooomiBleValueDecoder.requiredString(it, "id"), + name = DuooomiBleValueDecoder.optionalString(it, "name"), + ) + ) + } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(DEVICE_INFO) { value -> + @Suppress("UNCHECKED_CAST") + (value as? Map)?.let { + onDeviceInfoChange?.invoke( + DuooomiDeviceInfo( + allspace = DuooomiBleValueDecoder.requiredString(it, "allspace"), + freespace = DuooomiBleValueDecoder.requiredString(it, "freespace"), + name = DuooomiBleValueDecoder.requiredString(it, "name"), + size = DuooomiBleValueDecoder.requiredString(it, "size"), + brand = DuooomiBleValueDecoder.requiredString(it, "brand"), + powerlevel = DuooomiBleValueDecoder.requiredInt(it, "powerlevel"), + ) + ) + } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(VERSION) { value -> + (value as? String)?.let { onVersionChange?.invoke(it) } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(IS_ACTIVATED) { value -> + (value as? Boolean)?.let { onActivationChange?.invoke(it) } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(TRANSFER_PROGRESS) { value -> + (value as? Int)?.let { onTransferProgressChange?.invoke(it) } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(ERROR) { value -> + (value as? String)?.let { onError?.invoke(it) } + } + + sharedStateSubscriptions += BrownfieldState.subscribe(DISCOVERED_DEVICES) { value -> + @Suppress("UNCHECKED_CAST") + (value as? List>)?.let { devices -> + onDiscoveredDevicesChange?.invoke( + devices.mapNotNull { device -> + runCatching { + DuooomiDiscoveredDevice( + id = DuooomiBleValueDecoder.requiredString(device, "id"), + name = DuooomiBleValueDecoder.optionalString(device, "name"), + rssi = DuooomiBleValueDecoder.optionalInt(device, "rssi"), + ) + }.getOrNull() + } + ) + } + } + + messageListenerId = BrownfieldMessaging.addListener { event -> + if (handleResponse(event)) { + return@addListener + } + + onMessage?.invoke(event) + } + } + + /** 停止订阅 Brownfield 消息与共享状态。 */ + fun stop() { + started = false + sharedStateSubscriptions.forEach { it.remove() } + sharedStateSubscriptions.clear() + pendingRequests.forEach { (_, continuation) -> + continuation.resumeWithException(DuooomiBleManagerException("Manager stopped before response was received")) + } + pendingRequests.clear() + + messageListenerId?.let(BrownfieldMessaging::removeListener) + messageListenerId = null + } + + /** 开始扫描蓝牙设备。 */ + fun scan() { + send(mapOf("action" to "scan")) + } + + /** 停止扫描蓝牙设备。 */ + fun stopScan() { + send(mapOf("action" to "stopScan")) + } + + /** + * 连接指定设备。 + * + * @param deviceId 设备唯一标识。 + * @return 连接成功后的设备摘要信息。 + */ + suspend fun connect(deviceId: String): DuooomiConnectedDevice { + val result = sendAsync( + action = "connect", + params = mapOf("deviceId" to deviceId), + ) + return DuooomiConnectedDevice( + id = DuooomiBleValueDecoder.requiredString(result, "id"), + name = DuooomiBleValueDecoder.optionalString(result, "name"), + ) + } + + /** + * 断开当前设备连接。 + * + * @return 断开连接结果。 + */ + suspend fun disconnect(): DuooomiCommandResult { + val result = sendAsync(action = "disconnect") + return DuooomiCommandResult( + success = DuooomiBleValueDecoder.requiredBoolean(result, "success"), + ) + } + + /** + * 获取当前已连接设备的详细信息。 + * + * @return 设备详情数据。 + */ + suspend fun getDeviceInfo(): DuooomiDeviceInfo { + val result = sendAsync(action = "getDeviceInfo") + return DuooomiDeviceInfo( + allspace = DuooomiBleValueDecoder.requiredString(result, "allspace"), + freespace = DuooomiBleValueDecoder.requiredString(result, "freespace"), + name = DuooomiBleValueDecoder.requiredString(result, "name"), + size = DuooomiBleValueDecoder.requiredString(result, "size"), + brand = DuooomiBleValueDecoder.requiredString(result, "brand"), + powerlevel = DuooomiBleValueDecoder.requiredInt(result, "powerlevel"), + ) + } + + /** + * 获取当前已连接设备的版本信息。 + * + * @return 版本信息数据。 + */ + suspend fun getVersion(): DuooomiVersionInfo { + val result = sendAsync(action = "getVersion") + return DuooomiVersionInfo( + version = DuooomiBleValueDecoder.requiredString(result, "version"), + type = DuooomiBleValueDecoder.requiredString(result, "type"), + ) + } + + /** + * 绑定当前设备到指定用户。 + * + * @param userId 业务侧用户标识。 + * @return 绑定结果数据。 + */ + suspend fun bind(userId: String): DuooomiBindingResult { + val result = sendAsync( + action = "bind", + params = mapOf("userId" to userId), + ) + return DuooomiBindingResult( + type = DuooomiBleValueDecoder.requiredInt(result, "type"), + sn = DuooomiBleValueDecoder.requiredString(result, "sn"), + success = DuooomiBleValueDecoder.requiredInt(result, "success"), + contents = DuooomiBleValueDecoder.stringList(result, "contents"), + ) + } + + /** + * 解绑当前设备与指定用户的关系。 + * + * @param userId 业务侧用户标识。 + * @return 解绑结果数据。 + */ + suspend fun unbind(userId: String): DuooomiUnbindResult { + val result = sendAsync( + action = "unbind", + params = mapOf("userId" to userId), + ) + return DuooomiUnbindResult( + success = DuooomiBleValueDecoder.requiredInt(result, "success"), + ) + } + + /** + * 删除设备中的指定文件。 + * + * @param key 设备侧文件 key。 + * @return 删除结果数据。 + */ + suspend fun deleteFile(key: String): DuooomiDeleteFileResult { + val result = sendAsync( + action = "deleteFile", + params = mapOf("key" to key), + ) + return DuooomiDeleteFileResult( + type = DuooomiBleValueDecoder.requiredInt(result, "type"), + success = DuooomiBleValueDecoder.requiredInt(result, "success"), + ) + } + + /** + * 向设备传输文件。 + * + * @param fileUri 待传输文件的本地 URI。 + * @param commandType 传输命令类型;不传时由 JS 侧使用默认值。 + * @return 传输完成结果。 + */ + suspend fun transferFile(fileUri: String, commandType: Int? = null): DuooomiCommandResult { + val result = sendAsync( + action = "transferFile", + params = buildMap { + put("fileUri", fileUri) + commandType?.let { put("commandType", it) } + }, + ) + return DuooomiCommandResult( + success = DuooomiBleValueDecoder.requiredBoolean(result, "success"), + ) + } + + private fun send(message: BrownfieldMessage) { + BrownfieldMessaging.sendMessage(message) + } + + private suspend fun sendAsync( + action: String, + params: Map = emptyMap(), + ): Map = suspendCancellableCoroutine { continuation -> + val requestId = UUID.randomUUID().toString() + pendingRequests[requestId] = continuation + + continuation.invokeOnCancellation { + pendingRequests.remove(requestId) + } + + send( + buildMap { + putAll(params) + put("action", action) + put("requestId", requestId) + } + ) + } + + private fun handleResponse(message: BrownfieldMessage): Boolean { + if (message["event"] != "response") { + return false + } + + val requestId = message["requestId"] as? String ?: return false + val continuation = pendingRequests.remove(requestId) ?: return true + val success = message["success"] as? Boolean ?: false + + if (success) { + @Suppress("UNCHECKED_CAST") + val data = message["data"] as? Map ?: emptyMap() + continuation.resume(data) + } else { + val error = message["error"] as? String ?: "Unknown error" + continuation.resumeWithException(DuooomiBleManagerException(error)) + } + + return true + } +} diff --git a/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleModels.kt b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleModels.kt new file mode 100644 index 0000000..5c7b332 --- /dev/null +++ b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleModels.kt @@ -0,0 +1,99 @@ +package com.duooomi.ble.wrapper + +/** 扫描阶段发现的设备摘要信息。 */ +data class DuooomiDiscoveredDevice( + val id: String, + val name: String?, + val rssi: Int?, +) + +/** 设备连接结果摘要。 */ +data class DuooomiConnectedDevice( + val id: String, + val name: String?, +) + +/** 设备详细信息。 */ +data class DuooomiDeviceInfo( + val allspace: String, + val freespace: String, + val name: String, + val size: String, + val brand: String, + val powerlevel: Int, +) + +/** 设备版本信息。 */ +data class DuooomiVersionInfo( + val version: String, + val type: String, +) + +/** 绑定结果信息。 */ +data class DuooomiBindingResult( + val type: Int, + val sn: String, + val success: Int, + val contents: List, +) + +/** 解绑结果信息。 */ +data class DuooomiUnbindResult( + val success: Int, +) + +/** 删除文件结果信息。 */ +data class DuooomiDeleteFileResult( + val type: Int, + val success: Int, +) + +/** 通用布尔完成结果。 */ +data class DuooomiCommandResult( + val success: Boolean, +) + +internal object DuooomiBleValueDecoder { + fun requiredString(map: Map, key: String): String { + return optionalString(map, key) + ?: throw DuooomiBleManagerException("Missing string value for key: $key") + } + + fun optionalString(map: Map, key: String): String? { + val value = map[key] ?: return null + return when (value) { + is String -> value + is Number -> value.toString() + else -> null + } + } + + fun requiredInt(map: Map, key: String): Int { + return optionalInt(map, key) + ?: throw DuooomiBleManagerException("Missing integer value for key: $key") + } + + fun optionalInt(map: Map, key: String): Int? { + val value = map[key] ?: return null + return when (value) { + is Int -> value + is Number -> value.toInt() + is String -> value.toIntOrNull() + else -> null + } + } + + fun requiredBoolean(map: Map, key: String): Boolean { + val value = map[key] + return when (value) { + is Boolean -> value + is Number -> value.toInt() != 0 + else -> throw DuooomiBleManagerException("Missing boolean value for key: $key") + } + } + + fun stringList(map: Map, key: String): List { + @Suppress("UNCHECKED_CAST") + return map[key] as? List ?: emptyList() + } +} diff --git a/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleRuntime.kt b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleRuntime.kt new file mode 100644 index 0000000..a75efb6 --- /dev/null +++ b/wrappers/android/wrapper/src/main/kotlin/com/duooomi/ble/wrapper/DuooomiBleRuntime.kt @@ -0,0 +1,56 @@ +package com.duooomi.ble.wrapper + +import android.content.Context +import android.view.View +import android.view.ViewGroup + +/** + * 负责在宿主视图层中挂载隐藏的 RN 容器并启动 JS 运行时。 + * + * @property manager 运行时启动后对外复用的 BLE 门面实例。 + */ +class DuooomiBleRuntime( + val manager: DuooomiBleManager = DuooomiBleManager(), +) { + private var mountedView: View? = null + + /** + * 在指定容器中启动 Brownfield 运行时。 + * + * @param container 用于挂载隐藏 React Native 视图的宿主容器。 + * @param initializeHost 可选的宿主初始化逻辑。 + * @param createReactNativeView 由宿主提供的 RN View 构造逻辑。 + */ + fun start( + container: ViewGroup, + initializeHost: (() -> Unit)? = null, + createReactNativeView: ((Context) -> View)? + ) { + if (mountedView != null) { + return + } + + initializeHost?.invoke() + + val reactNativeView = createReactNativeView(container.context) + reactNativeView.alpha = 0f + reactNativeView.isClickable = false + reactNativeView.isFocusable = false + reactNativeView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + + val layoutParams = ViewGroup.LayoutParams(1, 1) + container.addView(reactNativeView, layoutParams) + + mountedView = reactNativeView + manager.start() + } + + /** 停止运行时并移除已挂载的隐藏 RN 视图。 */ + fun stop() { + manager.stop() + mountedView?.let { view -> + (view.parent as? ViewGroup)?.removeView(view) + } + mountedView = null + } +} diff --git a/wrappers/ios/Package.swift b/wrappers/ios/Package.swift new file mode 100644 index 0000000..94658d7 --- /dev/null +++ b/wrappers/ios/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "DuooomiBleWrapper", + platforms: [ + .iOS(.v15), + ], + products: [ + .library( + name: "DuooomiBleWrapper", + targets: ["DuooomiBleWrapper"] + ), + ], + targets: [ + .binaryTarget( + name: "DuooomiBleSDK", + path: "Frameworks/DuooomiBleSDK.xcframework" + ), + .binaryTarget( + name: "hermesvm", + path: "Frameworks/hermesvm.xcframework" + ), + .target( + name: "DuooomiBleWrapper", + dependencies: ["DuooomiBleSDK", "hermesvm"], + path: "Sources/DuooomiBleWrapper" + ), + ] +) diff --git a/wrappers/ios/README.md b/wrappers/ios/README.md new file mode 100644 index 0000000..98be16a --- /dev/null +++ b/wrappers/ios/README.md @@ -0,0 +1,62 @@ +# DuooomiBleWrapper + +该目录由 `pnpm build:ios` 生成,是可以直接交付给 iOS 原生宿主工程使用的 Swift Package。 + +## 目录内容 + +- `Frameworks/DuooomiBleSDK.xcframework` +- `Frameworks/hermesvm.xcframework` +- `Sources/DuooomiBleWrapper/DuooomiBleManager.swift` +- `Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift` + +## 宿主 App 接入步骤 + +1. 将 `dist/ios/DuooomiBleWrapper` 作为本地 Swift Package 引入宿主工程。 +2. 在宿主 App 的一个长期存在的容器视图里启动 Brownfield 运行时。 +3. 运行时启动完成后,通过 `DuooomiBleManager.shared` 调用蓝牙能力。 + +## 最小使用示例 + +```swift +try DuooomiBleRuntime.shared.start(in: view) + +Task { + let result = try await DuooomiBleManager.shared.connect(deviceId: "device-id") + print(result) +} +``` + +如果是扫描,仍然沿用监听方式: + +```swift +DuooomiBleManager.shared.onDiscoveredDevicesChange = { devices in + print(devices) +} + +DuooomiBleManager.shared.scan() +``` + +## 如果宿主需要显式初始化 Brownfield + +如果你的宿主工程不能被 `DuooomiBleRuntime` 自动识别出 `ReactNativeHostManager` 或 `ReactNativeView`,可以显式传入初始化逻辑: + +```swift +try DuooomiBleRuntime.shared.start( + in: view, + initializeHost: { + ReactNativeHostManager.shared.initialize() + }, + createReactNativeView: { + ReactNativeView() + } +) +``` + +## 说明 + +- `DuooomiBleRuntime` 负责启动 Expo Brownfield 对应的 RN/JS 运行时 +- `DuooomiBleManager` 的大部分命令方法为 `async throws`,例如 `connect`、`bind`、`transferFile` +- `scan` / `stopScan` 仍然保持事件监听方式 +- `DuooomiBleManager` 也会订阅 `ble/sdk/BleSDK.ts` 中定义的 shared state 并转发给宿主 + +如果只调用 `DuooomiBleManager` 而没有先启动 `DuooomiBleRuntime`,JS 侧的 `bleSDK.initialize()` 不会执行,消息也不会被处理。 diff --git a/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleManager.swift b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleManager.swift new file mode 100644 index 0000000..f76bbe1 --- /dev/null +++ b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleManager.swift @@ -0,0 +1,289 @@ +import Combine +import Foundation +import DuooomiBleSDK + +/// `DuooomiBleManager` 异步命令调用失败时抛出的错误。 +public struct DuooomiBleManagerError: Error, LocalizedError { + /// 错误描述。 + public let message: String + + public var errorDescription: String? { + message + } + + /// 创建一个新的 manager 错误。 + /// - Parameter message: 错误描述。 + public init(message: String) { + self.message = message + } +} + +/// 原生宿主调用的 BLE 门面。 +/// +/// 该类型负责把宿主侧的方法调用转换为 `expo-brownfield` 消息, +/// 并将 JS 侧共享状态变化转发给原生回调。 +public final class DuooomiBleManager { + /// 供宿主全局复用的单例实例。 + public static let shared = DuooomiBleManager() + + private enum StateKey { + static let btState = "btState" + static let connectedDevice = "connectedDevice" + static let deviceInfo = "deviceInfo" + static let version = "version" + static let isActivated = "isActivated" + static let transferProgress = "transferProgress" + static let error = "error" + static let discoveredDevices = "discoveredDevices" + } + + private var sharedStateCancellables: [AnyCancellable] = [] + private var messageListenerId: String? + private var started = false + private let pendingRequestsLock = NSLock() + private var pendingRequests: [String: (Result<[String: Any?], Error>) -> Void] = [:] + + /// 接收 JS 侧原始消息事件。 + public var onMessage: (([String: Any?]) -> Void)? + /// 接收蓝牙状态变化,例如 `idle`、`scanning`、`connected`。 + public var onBluetoothStateChange: ((String) -> Void)? + /// 接收当前已连接设备信息。 + public var onConnectedDeviceChange: ((DuooomiConnectedDevice) -> Void)? + /// 接收设备详情信息。 + public var onDeviceInfoChange: ((DuooomiDeviceInfo) -> Void)? + /// 接收固件版本信息。 + public var onVersionChange: ((String) -> Void)? + /// 接收设备激活状态变化。 + public var onActivationChange: ((Bool) -> Void)? + /// 接收文件传输进度,范围为 `0...100`。 + public var onTransferProgressChange: ((Int) -> Void)? + /// 接收 SDK 上报的错误字符串。 + public var onError: ((String) -> Void)? + /// 接收扫描发现的设备列表。 + public var onDiscoveredDevicesChange: (([DuooomiDiscoveredDevice]) -> Void)? + + private init() {} + + /// 开始订阅 Brownfield 消息与共享状态。 + /// + /// 调用前应先通过 `DuooomiBleRuntime` 启动 RN/Brownfield 运行时。 + public func start() { + guard !started else { return } + started = true + + sharedStateCancellables = [ + BrownfieldStateInternal.shared.subscribe(StateKey.btState) { [weak self] value in + if let state = value as? String { + self?.onBluetoothStateChange?(state) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.connectedDevice) { [weak self] value in + if let device = value as? [String: Any?], let decoded = try? DuooomiConnectedDevice(dictionary: device) { + self?.onConnectedDeviceChange?(decoded) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.deviceInfo) { [weak self] value in + if let info = value as? [String: Any?], let decoded = try? DuooomiDeviceInfo(dictionary: info) { + self?.onDeviceInfoChange?(decoded) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.version) { [weak self] value in + if let version = value as? String { + self?.onVersionChange?(version) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.isActivated) { [weak self] value in + if let isActivated = value as? Bool { + self?.onActivationChange?(isActivated) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.transferProgress) { [weak self] value in + if let progress = value as? Int { + self?.onTransferProgressChange?(progress) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.error) { [weak self] value in + if let error = value as? String { + self?.onError?(error) + } + }, + BrownfieldStateInternal.shared.subscribe(StateKey.discoveredDevices) { [weak self] value in + if let devices = value as? [[String: Any]] { + let decoded = devices.compactMap { try? DuooomiDiscoveredDevice(dictionary: $0) } + self?.onDiscoveredDevicesChange?(decoded) + } + }, + ] + + messageListenerId = BrownfieldMessagingInternal.shared.addListener { [weak self] message in + guard let self else { return } + + if self.handleResponse(message) { + return + } + + self.onMessage?(message) + } + } + + /// 停止订阅 Brownfield 消息与共享状态。 + public func stop() { + started = false + sharedStateCancellables.removeAll() + cancelPendingRequests() + + if let messageListenerId { + BrownfieldMessagingInternal.shared.removeListener(id: messageListenerId) + self.messageListenerId = nil + } + } + + /// 开始扫描蓝牙设备。 + public func scan() { + send(["action": "scan"]) + } + + /// 停止扫描蓝牙设备。 + public func stopScan() { + send(["action": "stopScan"]) + } + + /// 连接指定设备。 + /// - Parameter deviceId: 设备唯一标识。 + /// - Returns: 连接成功后的设备摘要信息。 + public func connect(deviceId: String) async throws -> DuooomiConnectedDevice { + let result = try await sendAsync(action: "connect", params: [ + "deviceId": deviceId, + ]) + return try DuooomiConnectedDevice(dictionary: result) + } + + /// 断开当前设备连接。 + /// - Returns: 断开连接结果。 + public func disconnect() async throws -> DuooomiCommandResult { + let result = try await sendAsync(action: "disconnect") + return try DuooomiCommandResult(dictionary: result) + } + + /// 获取当前已连接设备的详细信息。 + /// - Returns: 设备详情数据。 + public func getDeviceInfo() async throws -> DuooomiDeviceInfo { + let result = try await sendAsync(action: "getDeviceInfo") + return try DuooomiDeviceInfo(dictionary: result) + } + + /// 获取当前已连接设备的版本信息。 + /// - Returns: 版本信息数据。 + public func getVersion() async throws -> DuooomiVersionInfo { + let result = try await sendAsync(action: "getVersion") + return try DuooomiVersionInfo(dictionary: result) + } + + /// 绑定当前设备到指定用户。 + /// - Parameter userId: 业务侧用户标识。 + /// - Returns: 绑定结果数据。 + public func bind(userId: String) async throws -> DuooomiBindingResult { + let result = try await sendAsync(action: "bind", params: [ + "userId": userId, + ]) + return try DuooomiBindingResult(dictionary: result) + } + + /// 解绑当前设备与指定用户的关系。 + /// - Parameter userId: 业务侧用户标识。 + /// - Returns: 解绑结果数据。 + public func unbind(userId: String) async throws -> DuooomiUnbindResult { + let result = try await sendAsync(action: "unbind", params: [ + "userId": userId, + ]) + return try DuooomiUnbindResult(dictionary: result) + } + + /// 删除设备中的指定文件。 + /// - Parameter key: 设备侧文件 key。 + /// - Returns: 删除结果数据。 + public func deleteFile(key: String) async throws -> DuooomiDeleteFileResult { + let result = try await sendAsync(action: "deleteFile", params: [ + "key": key, + ]) + return try DuooomiDeleteFileResult(dictionary: result) + } + + /// 向设备传输文件。 + /// - Parameters: + /// - fileUri: 待传输文件的本地 URI。 + /// - commandType: 传输命令类型;不传时由 JS 侧使用默认值。 + /// - Returns: 传输完成结果。 + public func transferFile(fileUri: String, commandType: Int? = nil) async throws -> DuooomiCommandResult { + var params: [String: Any?] = [ + "fileUri": fileUri, + ] + + if let commandType { + params["commandType"] = commandType + } + + let result = try await sendAsync(action: "transferFile", params: params) + return try DuooomiCommandResult(dictionary: result) + } + + private func send(_ message: [String: Any?]) { + BrownfieldMessagingInternal.shared.sendMessage(message) + } + + private func sendAsync(action: String, params: [String: Any?] = [:]) async throws -> [String: Any?] { + let requestId = UUID().uuidString + + return try await withCheckedThrowingContinuation { continuation in + storePendingRequest(requestId) { result in + continuation.resume(with: result) + } + + var message = params + message["action"] = action + message["requestId"] = requestId + send(message) + } + } + + private func storePendingRequest(_ requestId: String, completion: @escaping (Result<[String: Any?], Error>) -> Void) { + pendingRequestsLock.lock() + pendingRequests[requestId] = completion + pendingRequestsLock.unlock() + } + + private func completePendingRequest(_ requestId: String, result: Result<[String: Any?], Error>) { + pendingRequestsLock.lock() + let completion = pendingRequests.removeValue(forKey: requestId) + pendingRequestsLock.unlock() + completion?(result) + } + + private func cancelPendingRequests() { + pendingRequestsLock.lock() + let requests = pendingRequests + pendingRequests.removeAll() + pendingRequestsLock.unlock() + + for completion in requests.values { + completion(.failure(DuooomiBleManagerError(message: "Manager stopped before response was received"))) + } + } + + private func handleResponse(_ message: [String: Any?]) -> Bool { + guard (message["event"] as? String) == "response", + let requestId = message["requestId"] as? String else { + return false + } + + if let success = message["success"] as? Bool, success { + let data = message["data"] as? [String: Any?] ?? [:] + completePendingRequest(requestId, result: .success(data)) + return true + } + + let errorMessage = message["error"] as? String ?? "Unknown error" + completePendingRequest(requestId, result: .failure(DuooomiBleManagerError(message: errorMessage))) + return true + } +} diff --git a/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleModels.swift b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleModels.swift new file mode 100644 index 0000000..0788603 --- /dev/null +++ b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleModels.swift @@ -0,0 +1,180 @@ +import Foundation + +/// 扫描阶段发现的设备摘要信息。 +public struct DuooomiDiscoveredDevice { + /// 设备唯一标识。 + public let id: String + /// 设备名称,可能为空。 + public let name: String? + /// 信号强度,可能为空。 + public let rssi: Int? + + init(dictionary: [String: Any]) throws { + self.id = try DuooomiBleValueDecoder.requiredString(dictionary, key: "id") + self.name = DuooomiBleValueDecoder.optionalString(dictionary, key: "name") + self.rssi = DuooomiBleValueDecoder.optionalInt(dictionary, key: "rssi") + } +} + +/// 设备连接结果摘要。 +public struct DuooomiConnectedDevice { + /// 设备唯一标识。 + public let id: String + /// 设备名称,可能为空。 + public let name: String? + + init(dictionary: [String: Any?]) throws { + self.id = try DuooomiBleValueDecoder.requiredString(dictionary, key: "id") + self.name = DuooomiBleValueDecoder.optionalString(dictionary, key: "name") + } +} + +/// 设备详细信息。 +public struct DuooomiDeviceInfo { + /// 总存储空间,字符串表示以避免跨桥数值精度问题。 + public let allspace: String + /// 可用存储空间,字符串表示以避免跨桥数值精度问题。 + public let freespace: String + /// 设备名称。 + public let name: String + /// 设备分辨率,例如 `360x360`。 + public let size: String + /// 品牌名。 + public let brand: String + /// 电量百分比,范围 `0...100`。 + public let powerlevel: Int + + init(dictionary: [String: Any?]) throws { + self.allspace = try DuooomiBleValueDecoder.requiredString(dictionary, key: "allspace") + self.freespace = try DuooomiBleValueDecoder.requiredString(dictionary, key: "freespace") + self.name = try DuooomiBleValueDecoder.requiredString(dictionary, key: "name") + self.size = try DuooomiBleValueDecoder.requiredString(dictionary, key: "size") + self.brand = try DuooomiBleValueDecoder.requiredString(dictionary, key: "brand") + self.powerlevel = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "powerlevel") + } +} + +/// 设备版本信息。 +public struct DuooomiVersionInfo { + /// 固件版本号。 + public let version: String + /// 协议返回的版本类型字段。 + public let type: String + + init(dictionary: [String: Any?]) throws { + self.version = try DuooomiBleValueDecoder.requiredString(dictionary, key: "version") + self.type = try DuooomiBleValueDecoder.requiredString(dictionary, key: "type") + } +} + +/// 绑定结果信息。 +public struct DuooomiBindingResult { + /// 协议类型。 + public let type: Int + /// 设备 SN。 + public let sn: String + /// 绑定结果,`1` 表示成功。 + public let success: Int + /// 已存在内容列表。 + public let contents: [String] + + init(dictionary: [String: Any?]) throws { + self.type = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "type") + self.sn = try DuooomiBleValueDecoder.requiredString(dictionary, key: "sn") + self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success") + self.contents = DuooomiBleValueDecoder.stringArray(dictionary, key: "contents") + } +} + +/// 解绑结果信息。 +public struct DuooomiUnbindResult { + /// 解绑结果,`1` 表示成功。 + public let success: Int + + init(dictionary: [String: Any?]) throws { + self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success") + } +} + +/// 删除文件结果信息。 +public struct DuooomiDeleteFileResult { + /// 协议类型。 + public let type: Int + /// 删除结果,`0` 成功,`1` 失败,`2` 文件不存在。 + public let success: Int + + init(dictionary: [String: Any?]) throws { + self.type = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "type") + self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success") + } +} + +/// 通用布尔完成结果。 +public struct DuooomiCommandResult { + /// 是否执行成功。 + public let success: Bool + + init(dictionary: [String: Any?]) throws { + self.success = try DuooomiBleValueDecoder.requiredBool(dictionary, key: "success") + } +} + +enum DuooomiBleValueDecoder { + static func requiredString(_ dictionary: [String: Any?], key: String) throws -> String { + if let value = optionalString(dictionary, key: key) { + return value + } + throw DuooomiBleManagerError(message: "Missing string value for key: \(key)") + } + + static func optionalString(_ dictionary: [String: Any?], key: String) -> String? { + if let value = dictionary[key] as? String { + return value + } + + if let value = dictionary[key] as? NSNumber { + return value.stringValue + } + + return nil + } + + static func requiredInt(_ dictionary: [String: Any?], key: String) throws -> Int { + if let value = optionalInt(dictionary, key: key) { + return value + } + throw DuooomiBleManagerError(message: "Missing integer value for key: \(key)") + } + + static func optionalInt(_ dictionary: [String: Any?], key: String) -> Int? { + if let value = dictionary[key] as? Int { + return value + } + + if let value = dictionary[key] as? NSNumber { + return value.intValue + } + + if let value = dictionary[key] as? String { + return Int(value) + } + + return nil + } + + static func requiredBool(_ dictionary: [String: Any?], key: String) throws -> Bool { + if let value = dictionary[key] as? Bool { + return value + } + + if let value = dictionary[key] as? NSNumber { + return value.boolValue + } + + throw DuooomiBleManagerError(message: "Missing boolean value for key: \(key)") + } + + static func stringArray(_ dictionary: [String: Any?], key: String) -> [String] { + (dictionary[key] as? [String]) ?? [] + } +} diff --git a/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift new file mode 100644 index 0000000..212a51e --- /dev/null +++ b/wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift @@ -0,0 +1,115 @@ +import Foundation +import UIKit + +/// Brownfield 运行时启动失败时抛出的错误。 +public enum DuooomiBleRuntimeError: Error, LocalizedError { + case hostManagerUnavailable + case reactNativeViewUnavailable + + public var errorDescription: String? { + switch self { + case .hostManagerUnavailable: + return "Unable to locate ReactNativeHostManager. Make sure the brownfield framework is linked correctly." + case .reactNativeViewUnavailable: + return "Unable to create ReactNativeView automatically. Pass createReactNativeView when starting the runtime." + } + } +} + +/// 负责在宿主视图层中挂载隐藏的 RN 容器并启动 JS 运行时。 +public final class DuooomiBleRuntime { + /// 供宿主全局复用的单例实例。 + public static let shared = DuooomiBleRuntime() + + private weak var mountedView: UIView? + private weak var containerView: UIView? + + private init() {} + + /// 在指定容器中启动 Brownfield 运行时。 + /// + /// - Parameters: + /// - containerView: 用于挂载隐藏 React Native 视图的宿主容器。 + /// - initializeHost: 可选的宿主初始化逻辑;若不传则尝试自动初始化。 + /// - createReactNativeView: 可选的 RN View 构造逻辑;若不传则尝试自动创建。 + /// - Throws: `DuooomiBleRuntimeError`。 + public func start( + in containerView: UIView, + initializeHost: (() -> Void)? = nil, + createReactNativeView: (() -> UIView)? = nil + ) throws { + if mountedView != nil { + return + } + + if let initializeHost { + initializeHost() + } else { + try autoInitializeHostManager() + } + + let reactNativeView = createReactNativeView?() ?? try autoCreateReactNativeView() + reactNativeView.isHidden = true + reactNativeView.isUserInteractionEnabled = false + reactNativeView.frame = CGRect(x: 0, y: 0, width: 1, height: 1) + reactNativeView.accessibilityElementsHidden = true + + containerView.addSubview(reactNativeView) + + self.containerView = containerView + self.mountedView = reactNativeView + + DuooomiBleManager.shared.start() + } + + /// 停止运行时并移除已挂载的隐藏 RN 视图。 + public func stop() { + DuooomiBleManager.shared.stop() + mountedView?.removeFromSuperview() + mountedView = nil + containerView = nil + } + + private func autoInitializeHostManager() throws { + let possibleClassNames = [ + "ReactNativeHostManager", + "DuooomiBleSDK.ReactNativeHostManager", + ] + + for className in possibleClassNames { + guard let managerClass = NSClassFromString(className) as? NSObject.Type else { + continue + } + + let sharedSelector = NSSelectorFromString("shared") + let initializeSelector = NSSelectorFromString("initialize") + let managerObject = (managerClass as AnyObject).perform(sharedSelector)?.takeUnretainedValue() as? NSObject + + if let managerObject, managerObject.responds(to: initializeSelector) { + managerObject.perform(initializeSelector) + return + } + } + + throw DuooomiBleRuntimeError.hostManagerUnavailable + } + + private func autoCreateReactNativeView() throws -> UIView { + let possibleClassNames = [ + "ReactNativeView", + "DuooomiBleSDK.ReactNativeView", + ] + + for className in possibleClassNames { + guard let viewClass = NSClassFromString(className) as? NSObject.Type else { + continue + } + + if let view = viewClass.init() as? UIView { + return view + } + } + + throw DuooomiBleRuntimeError.reactNativeViewUnavailable + } +}