- 增加了wrapper处理原生app的交互
This commit is contained in:
9
app.json
9
app.json
@@ -16,7 +16,14 @@
|
|||||||
"libraryName": "duooomi-ble-sdk",
|
"libraryName": "duooomi-ble-sdk",
|
||||||
"group": "com.duooomi",
|
"group": "com.duooomi",
|
||||||
"package": "com.duooomi.ble.sdk",
|
"package": "com.duooomi.ble.sdk",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0",
|
||||||
|
"publishing": [
|
||||||
|
{
|
||||||
|
"type": "localDirectory",
|
||||||
|
"name": "dist",
|
||||||
|
"path": "./dist/android/maven"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class BleSDK {
|
|||||||
|
|
||||||
// 批量设备发现缓冲
|
// 批量设备发现缓冲
|
||||||
private pendingDevices: Array<{ id: string; name: string | null; rssi: number | null }> = []
|
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<typeof setTimeout> | null = null
|
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
@@ -146,59 +147,84 @@ class BleSDK {
|
|||||||
// ====== 接收原生 App 的消息指令 ======
|
// ====== 接收原生 App 的消息指令 ======
|
||||||
private setupMessageHandler() {
|
private setupMessageHandler() {
|
||||||
addMessageListener((event: Record<string, any>) => {
|
addMessageListener((event: Record<string, any>) => {
|
||||||
const { action, ...params } = event
|
void this.handleAction(event)
|
||||||
console.log(`[BleSDK] Received action: ${action}`, params)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAction(event: Record<string, any>) {
|
||||||
|
const { action, requestId, ...params } = event
|
||||||
|
console.log(`[BleSDK] Received action: ${action}`, params)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data: any = null
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'scan':
|
case 'scan':
|
||||||
this.startScan()
|
await this.startScan()
|
||||||
|
data = { started: true }
|
||||||
break
|
break
|
||||||
case 'stopScan':
|
case 'stopScan':
|
||||||
this.stopScan()
|
this.stopScan()
|
||||||
|
data = { stopped: true }
|
||||||
break
|
break
|
||||||
case 'connect':
|
case 'connect':
|
||||||
this.connect(params.deviceId)
|
data = await this.connect(params.deviceId)
|
||||||
break
|
break
|
||||||
case 'disconnect':
|
case 'disconnect':
|
||||||
this.disconnect()
|
data = await this.disconnect()
|
||||||
break
|
break
|
||||||
case 'getDeviceInfo':
|
case 'getDeviceInfo':
|
||||||
this.getDeviceInfo()
|
data = await this.getDeviceInfo()
|
||||||
break
|
break
|
||||||
case 'getVersion':
|
case 'getVersion':
|
||||||
this.getVersion()
|
data = await this.getVersion()
|
||||||
break
|
break
|
||||||
case 'bind':
|
case 'bind':
|
||||||
this.bind(params.userId)
|
data = await this.bind(params.userId)
|
||||||
break
|
break
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
this.unbind(params.userId)
|
data = await this.unbind(params.userId)
|
||||||
break
|
break
|
||||||
case 'deleteFile':
|
case 'deleteFile':
|
||||||
this.deleteFile(params.key)
|
data = await this.deleteFile(params.key)
|
||||||
break
|
break
|
||||||
case 'transferFile':
|
case 'transferFile':
|
||||||
this.transferFile(params.fileUri, params.commandType)
|
data = await this.transferFile(params.fileUri, params.commandType)
|
||||||
break
|
break
|
||||||
default:
|
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() {
|
private async startScan() {
|
||||||
try {
|
const hasPerms = await this.requestPermissions()
|
||||||
const hasPerms = await this.requestPermissions()
|
if (!hasPerms) {
|
||||||
if (!hasPerms) {
|
const error = new Error('Bluetooth permission denied')
|
||||||
sendMessage({ event: 'error', type: 'permission', message: 'Bluetooth permission denied' })
|
this.updateState(STATE_KEYS.ERROR, error.message)
|
||||||
return
|
sendMessage({ event: 'error', type: 'permission', message: error.message })
|
||||||
}
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
this.updateState(STATE_KEYS.BT_STATE, 'scanning')
|
this.updateState(STATE_KEYS.BT_STATE, 'scanning')
|
||||||
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, [])
|
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, [])
|
||||||
this.pendingDevices = []
|
this.pendingDevices = []
|
||||||
|
this.discoveredDevices = []
|
||||||
|
|
||||||
await this.bleClient.startScan(
|
await this.bleClient.startScan(
|
||||||
[BLE_UUIDS.SERVICE],
|
[BLE_UUIDS.SERVICE],
|
||||||
@@ -223,6 +249,7 @@ class BleSDK {
|
|||||||
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
||||||
this.updateState(STATE_KEYS.ERROR, e.message)
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
||||||
sendMessage({ event: 'error', type: 'scan', message: e.message })
|
sendMessage({ event: 'error', type: 'scan', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +262,7 @@ class BleSDK {
|
|||||||
|
|
||||||
private async connect(deviceId: string) {
|
private async connect(deviceId: string) {
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
sendMessage({ event: 'error', type: 'connect', message: 'deviceId is required' })
|
throw new Error('deviceId is required')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopScan()
|
this.stopScan()
|
||||||
@@ -258,10 +284,15 @@ class BleSDK {
|
|||||||
|
|
||||||
// 自动获取设备信息
|
// 自动获取设备信息
|
||||||
this.getDeviceInfo().catch(() => {})
|
this.getDeviceInfo().catch(() => {})
|
||||||
|
return {
|
||||||
|
id: device.id,
|
||||||
|
name: device.name || device.localName || null,
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
this.updateState(STATE_KEYS.BT_STATE, 'idle')
|
||||||
this.updateState(STATE_KEYS.ERROR, e.message)
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
||||||
sendMessage({ event: 'error', type: 'connect', message: 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')
|
this.updateState(STATE_KEYS.BT_STATE, 'disconnected')
|
||||||
|
|
||||||
sendMessage({ event: 'disconnected' })
|
sendMessage({ event: 'disconnected' })
|
||||||
|
return { success: true }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'disconnect', message: e.message })
|
sendMessage({ event: 'error', type: 'disconnect', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDeviceInfo() {
|
private async getDeviceInfo() {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
const promise = this.createWaitable<DeviceInfo>('deviceInfo')
|
const promise = this.createWaitable<DeviceInfo>('deviceInfo')
|
||||||
await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId)
|
await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId)
|
||||||
return await promise
|
return await promise
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'deviceInfo', message: e.message })
|
sendMessage({ event: 'error', type: 'deviceInfo', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getVersion() {
|
private async getVersion() {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
const promise = this.createWaitable<VersionInfo>('versionInfo')
|
const promise = this.createWaitable<VersionInfo>('versionInfo')
|
||||||
await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId)
|
await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId)
|
||||||
return await promise
|
return await promise
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'version', message: e.message })
|
sendMessage({ event: 'error', type: 'version', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async bind(userId: string) {
|
private async bind(userId: string) {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
const promise = this.createWaitable<BindingResponse>('bindDevice')
|
const promise = this.createWaitable<BindingResponse>('bindDevice')
|
||||||
await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId)
|
await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId)
|
||||||
return await promise
|
return await promise
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'bind', message: e.message })
|
sendMessage({ event: 'error', type: 'bind', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async unbind(userId: string) {
|
private async unbind(userId: string) {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
const promise = this.createWaitable<UnBindResponse>('unbindDevice')
|
const promise = this.createWaitable<UnBindResponse>('unbindDevice')
|
||||||
await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId)
|
await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId)
|
||||||
return await promise
|
return await promise
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'unbind', message: e.message })
|
sendMessage({ event: 'error', type: 'unbind', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteFile(key: string) {
|
private async deleteFile(key: string) {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
const promise = this.createWaitable<DeleteFileResponse>('deleteFile')
|
const promise = this.createWaitable<DeleteFileResponse>('deleteFile')
|
||||||
await this.deviceInfoService.deleteFile(this.connectedDeviceId, key)
|
await this.deviceInfoService.deleteFile(this.connectedDeviceId, key)
|
||||||
return await promise
|
return await promise
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
sendMessage({ event: 'error', type: 'deleteFile', message: e.message })
|
sendMessage({ event: 'error', type: 'deleteFile', message: e.message })
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async transferFile(fileUri: string, commandType?: number) {
|
private async transferFile(fileUri: string, commandType?: number) {
|
||||||
if (!this.connectedDeviceId) return
|
if (!this.connectedDeviceId) throw new Error('No device connected')
|
||||||
try {
|
try {
|
||||||
this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0)
|
this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0)
|
||||||
const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO
|
const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO
|
||||||
@@ -356,9 +394,11 @@ class BleSDK {
|
|||||||
)
|
)
|
||||||
|
|
||||||
sendMessage({ event: 'transferComplete' })
|
sendMessage({ event: 'transferComplete' })
|
||||||
|
return { success: true }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.updateState(STATE_KEYS.ERROR, e.message)
|
this.updateState(STATE_KEYS.ERROR, e.message)
|
||||||
sendMessage({ event: 'error', type: 'transfer', message: 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 }) {
|
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)
|
this.pendingDevices.push(device)
|
||||||
|
|
||||||
if (this.flushTimer) clearTimeout(this.flushTimer)
|
if (this.flushTimer) clearTimeout(this.flushTimer)
|
||||||
@@ -382,12 +424,38 @@ class BleSDK {
|
|||||||
|
|
||||||
private flushDevices() {
|
private flushDevices() {
|
||||||
if (this.pendingDevices.length === 0) return
|
if (this.pendingDevices.length === 0) return
|
||||||
const devices = [...this.pendingDevices]
|
const devices = [...this.discoveredDevices]
|
||||||
this.pendingDevices = []
|
this.pendingDevices = []
|
||||||
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices)
|
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices)
|
||||||
sendMessage({ event: 'devicesFound', 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<T>(opId: string, timeout = 10000): Promise<T> {
|
private createWaitable<T>(opId: string, timeout = 10000): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"build:ios": "npx expo-brownfield build:ios",
|
"build:brownfield:ios": "pnpx expo-brownfield build:ios",
|
||||||
"build:android": "npx expo-brownfield build:android",
|
"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"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
38
scripts/package-android.mjs
Normal file
38
scripts/package-android.mjs
Normal file
@@ -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}`)
|
||||||
64
scripts/package-ios.mjs
Normal file
64
scripts/package-ios.mjs
Normal file
@@ -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}`)
|
||||||
63
wrappers/android/README.md
Normal file
63
wrappers/android/README.md
Normal file
@@ -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` 等调用也不会生效。
|
||||||
4
wrappers/android/build.gradle.kts
Normal file
4
wrappers/android/build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
wrappers/android/settings.gradle.kts
Normal file
19
wrappers/android/settings.gradle.kts
Normal file
@@ -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")
|
||||||
28
wrappers/android/wrapper/build.gradle.kts
Normal file
28
wrappers/android/wrapper/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
0
wrappers/android/wrapper/consumer-rules.pro
Normal file
0
wrappers/android/wrapper/consumer-rules.pro
Normal file
2
wrappers/android/wrapper/src/main/AndroidManifest.xml
Normal file
2
wrappers/android/wrapper/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="com.duooomi.ble.wrapper" />
|
||||||
@@ -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<Removable>()
|
||||||
|
private var messageListenerId: String? = null
|
||||||
|
private var started = false
|
||||||
|
private val pendingRequests = ConcurrentHashMap<String, CancellableContinuation<Map<String, Any?>>>()
|
||||||
|
|
||||||
|
/** 接收 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<DuooomiDiscoveredDevice>) -> 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<String, Any?>)?.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<String, Any?>)?.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<Map<String, Any?>>)?.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<String, Any?> = emptyMap(),
|
||||||
|
): Map<String, Any?> = 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<String, Any?> ?: emptyMap()
|
||||||
|
continuation.resume(data)
|
||||||
|
} else {
|
||||||
|
val error = message["error"] as? String ?: "Unknown error"
|
||||||
|
continuation.resumeWithException(DuooomiBleManagerException(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 解绑结果信息。 */
|
||||||
|
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<String, Any?>, key: String): String {
|
||||||
|
return optionalString(map, key)
|
||||||
|
?: throw DuooomiBleManagerException("Missing string value for key: $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun optionalString(map: Map<String, Any?>, 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<String, Any?>, key: String): Int {
|
||||||
|
return optionalInt(map, key)
|
||||||
|
?: throw DuooomiBleManagerException("Missing integer value for key: $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun optionalInt(map: Map<String, Any?>, 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<String, Any?>, 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<String, Any?>, key: String): List<String> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return map[key] as? List<String> ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
wrappers/ios/Package.swift
Normal file
30
wrappers/ios/Package.swift
Normal file
@@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
62
wrappers/ios/README.md
Normal file
62
wrappers/ios/README.md
Normal file
@@ -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()` 不会执行,消息也不会被处理。
|
||||||
289
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleManager.swift
Normal file
289
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleManager.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
180
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleModels.swift
Normal file
180
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleModels.swift
Normal file
@@ -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]) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
115
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift
Normal file
115
wrappers/ios/Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user