From fbfc24c9e579f180a61ec85f0710aa7d1cb39e9e Mon Sep 17 00:00:00 2001 From: Tony Date: Mon, 27 Apr 2026 08:00:32 +0800 Subject: [PATCH] Initial commit --- .dart_tool/extension_discovery/README.md | 31 ++ .dart_tool/extension_discovery/vs_code.json | 1 + .dart_tool/package_config.json | 178 +++++++++++ .dart_tool/package_graph.json | 232 +++++++++++++++ .dart_tool/version | 1 + android/build.gradle | 46 +++ android/settings.gradle | 1 + .../ble_relay_host/BleRelayHostPlugin.kt | 277 ++++++++++++++++++ ios/Classes/BleRelayHostPlugin.swift | 23 ++ lib/ble_relay_host.dart | 18 ++ lib/src/ble_relay_host.dart | 101 +++++++ linux/CMakeLists.txt | 27 ++ linux/ble_relay_host_plugin.cc | 48 +++ linux/ble_relay_host_plugin.h | 16 + macos/Classes/BleRelayHostPlugin.swift | 22 ++ pubspec.lock | 213 ++++++++++++++ pubspec.yaml | 33 +++ windows/CMakeLists.txt | 28 ++ windows/ble_relay_host_plugin.cpp | 47 +++ windows/ble_relay_host_plugin.h | 23 ++ 20 files changed, 1366 insertions(+) create mode 100644 .dart_tool/extension_discovery/README.md create mode 100644 .dart_tool/extension_discovery/vs_code.json create mode 100644 .dart_tool/package_config.json create mode 100644 .dart_tool/package_graph.json create mode 100644 .dart_tool/version create mode 100644 android/build.gradle create mode 100644 android/settings.gradle create mode 100644 android/src/main/kotlin/com/dalimaster/ble_relay_host/BleRelayHostPlugin.kt create mode 100644 ios/Classes/BleRelayHostPlugin.swift create mode 100644 lib/ble_relay_host.dart create mode 100644 lib/src/ble_relay_host.dart create mode 100644 linux/CMakeLists.txt create mode 100644 linux/ble_relay_host_plugin.cc create mode 100644 linux/ble_relay_host_plugin.h create mode 100644 macos/Classes/BleRelayHostPlugin.swift create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 windows/CMakeLists.txt create mode 100644 windows/ble_relay_host_plugin.cpp create mode 100644 windows/ble_relay_host_plugin.h diff --git a/.dart_tool/extension_discovery/README.md b/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/.dart_tool/extension_discovery/README.md @@ -0,0 +1,31 @@ +Extension Discovery Cache +========================= + +This folder is used by `package:extension_discovery` to cache lists of +packages that contains extensions for other packages. + +DO NOT USE THIS FOLDER +---------------------- + + * Do not read (or rely) the contents of this folder. + * Do write to this folder. + +If you're interested in the lists of extensions stored in this folder use the +API offered by package `extension_discovery` to get this information. + +If this package doesn't work for your use-case, then don't try to read the +contents of this folder. It may change, and will not remain stable. + +Use package `extension_discovery` +--------------------------------- + +If you want to access information from this folder. + +Feel free to delete this folder +------------------------------- + +Files in this folder act as a cache, and the cache is discarded if the files +are older than the modification time of `.dart_tool/package_config.json`. + +Hence, it should never be necessary to clear this cache manually, if you find a +need to do please file a bug. diff --git a/.dart_tool/extension_discovery/vs_code.json b/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..4653d3f --- /dev/null +++ b/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"ble_relay_host","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/.dart_tool/package_config.json b/.dart_tool/package_config.json new file mode 100644 index 0000000..d137220 --- /dev/null +++ b/.dart_tool/package_config.json @@ -0,0 +1,178 @@ +{ + "configVersion": 2, + "packages": [ + { + "name": "async", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/async-2.13.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "boolean_selector", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "characters", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/characters-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "clock", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/clock-1.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "collection", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/collection-1.19.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "fake_async", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/fake_async-1.3.3", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "flutter", + "rootUri": "file:///Users/tonylu/git/flutter/packages/flutter", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "flutter_lints", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/flutter_lints-5.0.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "flutter_test", + "rootUri": "file:///Users/tonylu/git/flutter/packages/flutter_test", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "leak_tracker", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/leak_tracker-11.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_flutter_testing", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/leak_tracker_flutter_testing-3.0.10", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "leak_tracker_testing", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/leak_tracker_testing-3.0.2", + "packageUri": "lib/", + "languageVersion": "3.2" + }, + { + "name": "lints", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/lints-5.1.1", + "packageUri": "lib/", + "languageVersion": "3.6" + }, + { + "name": "matcher", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/matcher-0.12.17", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "material_color_utilities", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/material_color_utilities-0.11.1", + "packageUri": "lib/", + "languageVersion": "2.17" + }, + { + "name": "meta", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/meta-1.16.0", + "packageUri": "lib/", + "languageVersion": "2.12" + }, + { + "name": "path", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/path-1.9.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "plugin_platform_interface", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/plugin_platform_interface-2.1.8", + "packageUri": "lib/", + "languageVersion": "3.0" + }, + { + "name": "sky_engine", + "rootUri": "file:///Users/tonylu/git/flutter/bin/cache/pkg/sky_engine", + "packageUri": "lib/", + "languageVersion": "3.8" + }, + { + "name": "source_span", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/source_span-1.10.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "stack_trace", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/stack_trace-1.12.1", + "packageUri": "lib/", + "languageVersion": "3.4" + }, + { + "name": "stream_channel", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/stream_channel-2.1.4", + "packageUri": "lib/", + "languageVersion": "3.3" + }, + { + "name": "string_scanner", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/string_scanner-1.4.1", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "term_glyph", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/term_glyph-1.2.2", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "test_api", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/test_api-0.7.6", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "vector_math", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/vector_math-2.2.0", + "packageUri": "lib/", + "languageVersion": "3.1" + }, + { + "name": "vm_service", + "rootUri": "file:///Users/tonylu/.pub-cache/hosted/pub.dev/vm_service-15.1.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, + { + "name": "ble_relay_host", + "rootUri": "../", + "packageUri": "lib/", + "languageVersion": "3.1" + } + ], + "generator": "pub", + "generatorVersion": "3.9.2", + "flutterRoot": "file:///Users/tonylu/git/flutter", + "flutterVersion": "3.35.4", + "pubCache": "file:///Users/tonylu/.pub-cache" +} diff --git a/.dart_tool/package_graph.json b/.dart_tool/package_graph.json new file mode 100644 index 0000000..c1578ba --- /dev/null +++ b/.dart_tool/package_graph.json @@ -0,0 +1,232 @@ +{ + "roots": [ + "ble_relay_host" + ], + "packages": [ + { + "name": "ble_relay_host", + "version": "0.1.0", + "dependencies": [ + "flutter", + "plugin_platform_interface" + ], + "devDependencies": [ + "flutter_lints", + "flutter_test" + ] + }, + { + "name": "flutter_lints", + "version": "5.0.0", + "dependencies": [ + "lints" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "test_api", + "version": "0.7.6", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "stream_channel", + "version": "2.1.4", + "dependencies": [ + "async" + ] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "meta", + "version": "1.16.0", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.17", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.11.1", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.0", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "lints", + "version": "5.1.1", + "dependencies": [] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "async", + "version": "2.13.1", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.2", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "plugin_platform_interface", + "version": "2.1.8", + "dependencies": [ + "meta" + ] + }, + { + "name": "vm_service", + "version": "15.1.0", + "dependencies": [] + } + ], + "configVersion": 1 +} \ No newline at end of file diff --git a/.dart_tool/version b/.dart_tool/version new file mode 100644 index 0000000..7b1bd83 --- /dev/null +++ b/.dart_tool/version @@ -0,0 +1 @@ +3.35.4 \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..a84d77a --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,46 @@ +group 'com.dalimaster.ble_relay_host' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.9.20' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'com.dalimaster.ble_relay_host' + compileSdk 34 + + defaultConfig { + minSdk 21 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..f215a71 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +include ':ble_relay_host' diff --git a/android/src/main/kotlin/com/dalimaster/ble_relay_host/BleRelayHostPlugin.kt b/android/src/main/kotlin/com/dalimaster/ble_relay_host/BleRelayHostPlugin.kt new file mode 100644 index 0000000..a91b5bf --- /dev/null +++ b/android/src/main/kotlin/com/dalimaster/ble_relay_host/BleRelayHostPlugin.kt @@ -0,0 +1,277 @@ +package com.dalimaster.ble_relay_host + +import android.bluetooth.* +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.os.ParcelUuid +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.util.UUID + +private const val TAG = "BleRelayHostPlugin" +private const val CHANNEL = "com.dalimaster.ble_relay_host" +private const val EVENT_CHANNEL = "com.dalimaster.ble_relay_host/clientWrites" + +class BleRelayHostPlugin : FlutterPlugin, MethodCallHandler { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + + private var context: Context? = null + private var bluetoothManager: BluetoothManager? = null + private var bluetoothAdapter: BluetoothAdapter? = null + private var gattServer: BluetoothGattServer? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var relayCharacteristic: BluetoothGattCharacteristic? = null + private val connectedDevices = mutableSetOf() + private var pendingStartResult: Result? = null + + // ----------------------------------------------------------------------- + // FlutterPlugin callbacks + // ----------------------------------------------------------------------- + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + bluetoothManager = + binding.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + bluetoothAdapter = bluetoothManager?.adapter + + methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + eventSink = sink + } + override fun onCancel(arguments: Any?) { + eventSink = null + } + }) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + stopInternal() + context = null + } + + // ----------------------------------------------------------------------- + // MethodCallHandler + // ----------------------------------------------------------------------- + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "start" -> { + val advertisedName = call.argument("advertisedName") ?: "DALI-Bridge" + val serviceUuidStr = call.argument("serviceUuid") + ?: "0000fff0-0000-1000-8000-00805f9b34fb" + val charUuidStr = call.argument("characteristicUuid") + ?: "0000fff1-0000-1000-8000-00805f9b34fb" + startInternal(advertisedName, serviceUuidStr, charUuidStr, result) + } + "push" -> { + val data = call.argument("data") + if (data != null) pushInternal(data) + result.success(null) + } + "stop" -> { + pendingStartResult?.error("CANCELLED", "BLE relay host start cancelled", null) + pendingStartResult = null + stopInternal() + result.success(null) + } + "isSupported" -> result.success(bluetoothAdapter != null && bluetoothAdapter!!.isMultipleAdvertisementSupported) + else -> result.notImplemented() + } + } + + // ----------------------------------------------------------------------- + // BLE internals + // ----------------------------------------------------------------------- + + private fun startInternal( + advertisedName: String, + serviceUuidStr: String, + charUuidStr: String, + result: Result, + ) { + val ctx = context ?: return result.error("NO_CONTEXT", "No context", null) + val adapter = bluetoothAdapter + ?: return result.error("BLE_UNAVAILABLE", "Bluetooth adapter not available", null) + + if (!adapter.isMultipleAdvertisementSupported) { + return result.error("NOT_SUPPORTED", "BLE peripheral advertising not supported on this device", null) + } + + if (pendingStartResult != null) { + return result.error("START_IN_PROGRESS", "BLE relay host start already in progress", null) + } + + stopInternal() + + val serviceUuid = UUID.fromString(serviceUuidStr) + val charUuid = UUID.fromString(charUuidStr) + + val characteristic = BluetoothGattCharacteristic( + charUuid, + BluetoothGattCharacteristic.PROPERTY_READ or + BluetoothGattCharacteristic.PROPERTY_WRITE or + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ or + BluetoothGattCharacteristic.PERMISSION_WRITE, + ) + // Client Characteristic Configuration Descriptor for notifications. + val cccd = BluetoothGattDescriptor( + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE, + ) + characteristic.addDescriptor(cccd) + relayCharacteristic = characteristic + + val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY) + service.addCharacteristic(characteristic) + + val server = bluetoothManager!!.openGattServer(ctx, gattServerCallback) + ?: return result.error("GATT_SERVER_FAILED", "Unable to open GATT server", null) + server.addService(service) + gattServer = server + + // Start advertising. + try { + adapter.name = advertisedName + } catch (e: SecurityException) { + Log.w(TAG, "Cannot set device name: ${e.message}") + } + + advertiser = adapter.bluetoothLeAdvertiser + ?: return result.error("ADVERTISER_UNAVAILABLE", "BLE advertiser not available", null) + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + val data = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .addServiceUuid(ParcelUuid(serviceUuid)) + .build() + pendingStartResult = result + advertiser?.startAdvertising(settings, data, advertiseCallback) + } + + private fun pushInternal(data: ByteArray) { + val server = gattServer ?: return + val char = relayCharacteristic ?: return + char.value = data + val devsCopy: Set + synchronized(connectedDevices) { devsCopy = connectedDevices.toSet() } + for (device in devsCopy) { + try { + server.notifyCharacteristicChanged(device, char, false) + } catch (e: Exception) { + Log.e(TAG, "notifyCharacteristicChanged failed: ${e.message}") + } + } + } + + private fun stopInternal() { + advertiser?.stopAdvertising(advertiseCallback) + advertiser = null + synchronized(connectedDevices) { connectedDevices.clear() } + gattServer?.close() + gattServer = null + relayCharacteristic = null + } + + // ----------------------------------------------------------------------- + // GATT server callback + // ----------------------------------------------------------------------- + + private val gattServerCallback = object : BluetoothGattServerCallback() { + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Client connected: ${device.address}") + synchronized(connectedDevices) { connectedDevices.add(device) } + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Client disconnected: ${device.address}") + synchronized(connectedDevices) { connectedDevices.remove(device) } + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray, + ) { + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + // Forward the written frame to Flutter. + eventSink?.success(value) + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray, + ) { + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic, + ) { + gattServer?.sendResponse( + device, requestId, BluetoothGatt.GATT_SUCCESS, 0, + characteristic.value ?: ByteArray(0), + ) + } + } + + // ----------------------------------------------------------------------- + // Advertise callback + // ----------------------------------------------------------------------- + + private val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + Log.d(TAG, "BLE advertising started") + pendingStartResult?.success(null) + pendingStartResult = null + } + override fun onStartFailure(errorCode: Int) { + Log.e(TAG, "BLE advertising failed: $errorCode") + pendingStartResult?.error( + "ADVERTISE_FAILED", + "BLE advertising failed with code $errorCode", + errorCode, + ) + pendingStartResult = null + stopInternal() + } + } +} diff --git a/ios/Classes/BleRelayHostPlugin.swift b/ios/Classes/BleRelayHostPlugin.swift new file mode 100644 index 0000000..99707ce --- /dev/null +++ b/ios/Classes/BleRelayHostPlugin.swift @@ -0,0 +1,23 @@ +import Foundation +import Flutter + +/// iOS stub — BLE peripheral (CoreBluetooth CBPeripheralManager) is available on iOS but +/// not yet implemented. `isSupported` returns false; all other methods are no-ops. +public class BleRelayHostPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.dalimaster.ble_relay_host", + binaryMessenger: registrar.messenger()) + let instance = BleRelayHostPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + result(false) + default: + result(nil) + } + } +} diff --git a/lib/ble_relay_host.dart b/lib/ble_relay_host.dart new file mode 100644 index 0000000..8a4878b --- /dev/null +++ b/lib/ble_relay_host.dart @@ -0,0 +1,18 @@ +/// BLE GATT-server peripheral host adapter for the DALI Toolkit relay feature. +/// +/// Usage: +/// ```dart +/// final host = BleRelayHost(); +/// if (host.isSupported) { +/// await host.start( +/// advertisedName: 'DALI-Bridge', +/// onClientWrite: (data) async { /* forward to DALI upstream */ }, +/// ); +/// // Push DALI responses back to connected BLE clients: +/// await host.push(responseBytes); +/// await host.stop(); +/// } +/// ``` +library ble_relay_host; + +export 'src/ble_relay_host.dart'; diff --git a/lib/src/ble_relay_host.dart b/lib/src/ble_relay_host.dart new file mode 100644 index 0000000..42464e8 --- /dev/null +++ b/lib/src/ble_relay_host.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// GATT service UUID shared between DALI Toolkit BLE central (client) and the +/// relay host peripheral. Must match the UUID used in `ble.dart` / `ble_web.dart`. +const String kBleRelayServiceUuid = '0000fff0-0000-1000-8000-00805f9b34fb'; + +/// GATT characteristic UUID for bidirectional DALI frame data. +const String kBleRelayCharacteristicUuid = '0000fff1-0000-1000-8000-00805f9b34fb'; + +/// Method-channel name (must match Android/iOS/macOS native implementations). +const String _kChannelName = 'com.dalimaster.ble_relay_host'; + +/// An abstraction over the platform BLE GATT-server implementation. +/// +/// On Android (API 21+) this wires into [BluetoothGattServer] + [BluetoothLeAdvertiser]. +/// On all other platforms, [isSupported] returns `false` and every method is a no-op. +class BleRelayHost { + BleRelayHost() : _channel = const MethodChannel(_kChannelName); + + @visibleForTesting + BleRelayHost.withChannel(MethodChannel channel) : _channel = channel; + + final MethodChannel _channel; + + StreamSubscription? _eventSub; + Future Function(Uint8List data)? _onClientWrite; + bool _running = false; + + bool get isRunning => _running; + + /// Returns true if the current platform has a native implementation. + /// On Web this is always false; on non-Android platforms this calls the + /// native `isSupported` method to let the platform decide. + bool get isSupported { + if (kIsWeb) return false; + // Synchronous check is not possible via MethodChannel; instead we rely on + // the static per-platform capability flags defined at the package root. + // See [BleRelayHostCapability.isSupported]. + return defaultTargetPlatform == TargetPlatform.android; + } + + /// Starts BLE advertising and opens the GATT server. + /// + /// [advertisedName] — the Bluetooth device name visible to scanning clients. + /// [onClientWrite] — callback invoked whenever a connected client writes a + /// frame to the relay characteristic. Forward the bytes to + /// the upstream DALI transport. + Future start({ + required String advertisedName, + required Future Function(Uint8List data) onClientWrite, + }) async { + if (!isSupported) return; + _onClientWrite = onClientWrite; + + // Listen for inbound writes from BLE clients via event channel. + const eventChannel = EventChannel('$_kChannelName/clientWrites'); + _eventSub?.cancel(); + _eventSub = eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Uint8List) { + _onClientWrite?.call(event); + } else if (event is List) { + _onClientWrite?.call(Uint8List.fromList(event.cast())); + } + }); + + try { + await _channel.invokeMethod('start', { + 'advertisedName': advertisedName, + 'serviceUuid': kBleRelayServiceUuid, + 'characteristicUuid': kBleRelayCharacteristicUuid, + }); + _running = true; + } catch (_) { + await _eventSub?.cancel(); + _eventSub = null; + _onClientWrite = null; + rethrow; + } + } + + /// Sends [data] as a GATT notification to all connected clients. + Future push(Uint8List data) async { + if (!_running) return; + await _channel.invokeMethod('push', {'data': data}); + } + + /// Stops advertising, disconnects clients, and closes the GATT server. + Future stop() async { + await _eventSub?.cancel(); + _eventSub = null; + _onClientWrite = null; + if (_running) { + await _channel.invokeMethod('stop'); + } + _running = false; + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..7753969 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "ble_relay_host") +project(${PROJECT_NAME} LANGUAGES CXX) + +cmake_policy(VERSION 3.10...3.24) + +set(PLUGIN_NAME "ble_relay_host_plugin") + +list(APPEND PLUGIN_SOURCES + "ble_relay_host_plugin.cc" +) + +add_library(${PLUGIN_NAME} SHARED + ${PLUGIN_SOURCES} +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +set(ble_relay_host_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/linux/ble_relay_host_plugin.cc b/linux/ble_relay_host_plugin.cc new file mode 100644 index 0000000..c055a92 --- /dev/null +++ b/linux/ble_relay_host_plugin.cc @@ -0,0 +1,48 @@ +#include "ble_relay_host_plugin.h" +#include + +#define BLE_RELAY_HOST_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), ble_relay_host_plugin_get_type(), BleRelayHostPlugin)) + +struct _BleRelayHostPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(BleRelayHostPlugin, ble_relay_host_plugin, g_object_get_type()) + +static void ble_relay_host_plugin_handle_method_call( + BleRelayHostPlugin* self, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + const gchar* method = fl_method_call_get_name(method_call); + if (strcmp(method, "isSupported") == 0) { + g_autoptr(FlValue) result = fl_value_new_bool(FALSE); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); + } else { + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_null())); + } + fl_method_call_respond(method_call, response, nullptr); +} + +static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + BleRelayHostPlugin* plugin = BLE_RELAY_HOST_PLUGIN(user_data); + ble_relay_host_plugin_handle_method_call(plugin, method_call); +} + +void ble_relay_host_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + BleRelayHostPlugin* plugin = BLE_RELAY_HOST_PLUGIN( + g_object_new(ble_relay_host_plugin_get_type(), nullptr)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + g_autoptr(FlMethodChannel) channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + "com.dalimaster.ble_relay_host", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, method_call_cb, + g_object_ref(plugin), + g_object_unref); + g_object_unref(plugin); +} + +static void ble_relay_host_plugin_class_init(BleRelayHostPluginClass* klass) {} +static void ble_relay_host_plugin_init(BleRelayHostPlugin* self) {} diff --git a/linux/ble_relay_host_plugin.h b/linux/ble_relay_host_plugin.h new file mode 100644 index 0000000..3979134 --- /dev/null +++ b/linux/ble_relay_host_plugin.h @@ -0,0 +1,16 @@ +#ifndef FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_ +#define FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#define BLE_RELAY_HOST_PLUGIN_TYPE (ble_relay_host_plugin_get_type()) + +G_DECLARE_FINAL_TYPE(BleRelayHostPlugin, ble_relay_host_plugin, BLE_RELAY_HOST, PLUGIN, GObject) + +void ble_relay_host_plugin_register_with_registrar(FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_ diff --git a/macos/Classes/BleRelayHostPlugin.swift b/macos/Classes/BleRelayHostPlugin.swift new file mode 100644 index 0000000..f3a3107 --- /dev/null +++ b/macos/Classes/BleRelayHostPlugin.swift @@ -0,0 +1,22 @@ +import Foundation +import FlutterMacOS + +/// macOS stub — BLE peripheral mode not yet implemented. +public class BleRelayHostPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.dalimaster.ble_relay_host", + binaryMessenger: registrar.messenger) + let instance = BleRelayHostPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isSupported": + result(false) + default: + result(nil) + } + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..209a351 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: "direct main" + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..996000f --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,33 @@ +name: ble_relay_host +description: "BLE GATT-server peripheral host adapter for the DALI Toolkit relay feature. Provides a simple start/stop/push API backed by platform-specific BLE advertising." +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.1.4 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + plugin: + platforms: + android: + package: com.dalimaster.ble_relay_host + pluginClass: BleRelayHostPlugin + ios: + pluginClass: BleRelayHostPlugin + macos: + pluginClass: BleRelayHostPlugin + linux: + pluginClass: BleRelayHostPlugin + windows: + pluginClass: BleRelayHostPluginCApi diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..81e54dd --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "ble_relay_host") +project(${PROJECT_NAME} LANGUAGES CXX) + +cmake_policy(VERSION 3.14...3.25) + +set(PLUGIN_NAME "ble_relay_host_plugin") + +list(APPEND PLUGIN_SOURCES + "ble_relay_host_plugin.cpp" +) + +add_library(${PLUGIN_NAME} SHARED + "include/ble_relay_host/ble_relay_host_plugin_c_api.h" + ${PLUGIN_SOURCES} +) + +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +set(ble_relay_host_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/windows/ble_relay_host_plugin.cpp b/windows/ble_relay_host_plugin.cpp new file mode 100644 index 0000000..0f2f0ab --- /dev/null +++ b/windows/ble_relay_host_plugin.cpp @@ -0,0 +1,47 @@ +#include "ble_relay_host_plugin.h" + +// Windows stub — BLE peripheral not yet implemented. + +#include +#include +#include + +namespace ble_relay_host { + +class BleRelayHostPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) { + auto channel = std::make_unique>( + registrar->messenger(), "com.dalimaster.ble_relay_host", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); + } + + BleRelayHostPlugin() {} + virtual ~BleRelayHostPlugin() {} + + private: + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (method_call.method_name().compare("isSupported") == 0) { + result->Success(flutter::EncodableValue(false)); + } else { + result->Success(); + } + } +}; + +} // namespace ble_relay_host + +void BleRelayHostPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + ble_relay_host::BleRelayHostPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/windows/ble_relay_host_plugin.h b/windows/ble_relay_host_plugin.h new file mode 100644 index 0000000..3ee4540 --- /dev/null +++ b/windows/ble_relay_host_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_ +#define FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void BleRelayHostPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_BLE_RELAY_HOST_PLUGIN_H_