普段使いのコーディングでは VSCode + GitHub Copilot を使って「AI ペアプロ」という形で進めていきます。AI ペアプロなので、常にプログラマが並走しています。AI に任せっきりというわけでもなく、AI が出力してきたコードを鵜呑みするわけでもなく、ある程度ですが AI コードを常にレビューしながら進めていきます。まあ、いろいろ面倒になって、ちょっとだけ動作確認した後に Git にコミットしてしまうことも多いのですが、場合によっては 1 日分の AI コードを戻してしまうことも多いです。
BLE 絡みと Web API 絡みでプロトタイプ的なコードを結構書いてきたのですが、それ以上の品質に AI コーディングが辿り着くのか?というと、現状では何とも言えません。このあたりは、私が「AI ペアプロ」に留まっている理由でもあり、巷のテクニカルな記事をみると大規模で手間のかかる AI コーディングならば良さそうなのですが、こう一歩ずつ試してくようなプロトタイプ型のコーディングではやっぱり「AI ペアプロ」のほうがよさそうな気がします。
とはいえ、手元で「Claude Code」を入れて(実際には Claude Cowork 用に入れたのですが)試してみると、ああ、確かに Web アプリケーションならば、仕様書を与えてコーディングさせることは可能っぽいです。というか、逆に言えば「AI ペアプロ」的な使い方は Claude Code だとやり辛いです。逆に言えば、夜間に一括でできるようなバッチ的な処理には非常に向いています。おそらく、単体テストの自動化と再帰的なコード修正がやり辛いのは、ここのバッチ的な処理が原因かもしれません。
という訳で、ここまでが愚痴…
AI ペアプロ型とバッチ型の両方を試してみる
恐らく一長一短があると思うのですが、
VSCode + GitHub Copilot を使った「AI ペアプロ」型
Claude Code を使った「バッチ型」
の両方を試してみます。巷のブログ記事だと、「Web サイトが一瞬でできました」タイプが多いので、仕様が決まったら一気に作る Claude Code のほうが向いているような気もしますが、そのあたりの非 Web アプリの場合はどうなのか?というお試しです。
mermaid sequenceDiagram actor User as User participant Screen as ChatScreen participant Activity as MainActivity participant Repo as ChatRepository participant Service as BleChatService participant Codec as MessageCodec participant Advertiser as BleAdvertiserManager
想定する職業はばらばらなので、自分の職種とは違うものが多いとは思うのですが、使い方のヒントにはなると思います。特に、手元に PDF やテキストデータ、Word ファイルなどで情報が文書として残っている場合は、お得に NotebookLM を活用できます。 ひとまず、NotebookLM で必要そうなファイルを突っ込んでみて、何か質問してみると、きっちりと PDF ファイルの内容を理解して回答してくれることがわかります。抜き出しだけではなくて、箇条書きでまとめたり表形式にフォーマットしなおしたりすることも可能です。
接触確認アプリでは Android/iOS の相互で BLE 通信を行うために、iOS での発信機のチェックもしておきます。 iOS の場合は、16 bit Service UUID 形式と Manufacturer Data 形式では発信できません。が、”発信できないこと” を確認するために、あえて両方の実装もしてあります。実際に iOS/Android の受信機で受信ができないことを確認してください。
EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンですが、iOS では送信できません。送信はできないのですが、確認のためにコードは作ってあります。Android の受信機で受信できないことを確認してください。 たまに、Android/iOS の受信機に到達することがあり(このときのアドバタイズデータはランダム値になっています)、微妙な感じがするのですが、使えないのは確かです。
class ENSimTransmitter: NSObject, ObservableObject {
private var peripheralManager: CBPeripheralManager?
private let serviceUUID = CBUUID(string: "FD6F") // Exposure Notification 16-bit UUID
private let altServiceUUID = CBUUID(string: "FF00") // Alternative UUID for testing
@Published var isTransmitting = false
@Published var transmissionStatus = "停止中"
@Published var bluetoothState = "Unknown"
@Published var localName = "ENSim"
@Published var useAltService: Bool = false
@Published var rpi: Data = ENSimTransmitter.generateRandomRpi()
override init() {
super.init()
setupPeripheralManager()
}
private func setupPeripheralManager() {
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
func startTransmitting() {
guard let manager = peripheralManager else {
print("PeripheralManager が初期化されていません")
return
}
guard manager.state == .poweredOn else {
print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
return
}
guard !isTransmitting else {
print("既にアドバタイズ中です")
return
}
let selectedService = useAltService ? altServiceUUID : serviceUUID
let serviceData: [CBUUID: Data] = [selectedService: rpi]
let advertisementData: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [selectedService],
CBAdvertisementDataLocalNameKey: localName,
// CBAdvertisementDataServiceDataKey: serviceData
]
manager.startAdvertising(advertisementData)
isTransmitting = true
transmissionStatus = "発信中..."
print("📡 EN シミュレーション発信開始")
print(" Service UUID (16-bit): \(useAltService ? altServiceUUID.uuidString : serviceUUID.uuidString)")
print(" Local Name: \(localName)")
print(" RPI (hex): \(rpi.map { String(format: "%02X", $0) }.joined())")
}
func stopTransmitting() {
guard let manager = peripheralManager, isTransmitting else { return }
manager.stopAdvertising()
isTransmitting = false
transmissionStatus = "停止中"
print("EN シミュレーション発信停止")
}
private static func generateRandomRpi() -> Data {
// let bytes = (0..<16).map { _ in UInt8.random(in: 0...255) }
// ランダムな uuid を生成して RPI として使用(デバッグ用)
// 送信は成功するが、Service Data の内容はランダム値になってしまうので、
// 実質利用ができない。
let uuid = UUID()
let uuidBytes = withUnsafeBytes(of: uuid.uuid) { Array($0) }
let bytes = Array(uuidBytes.prefix(16))
return Data(bytes)
}
}
Manufacturer Data 形式の発信
自由な形式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのですが、これも iOS では使えません。これも、使えないことを確認するためにコードを作ってあります。 先に書いた通り、startAdvertisingRawIBeacon 関数を作ってもデータは送信できません。
/// Advertises custom manufacturer data (often consumed as scan response data on the scanner side).
/// フォーマット: [0]=0x02 (type), [1]=0x10 (length=16), [2..17]=TempId(16byte)
class ManufacturerDataTransmitter: NSObject, ObservableObject {
private var peripheralManager: CBPeripheralManager?
@Published var isTransmitting = false
@Published var transmissionStatus = "停止中"
@Published var bluetoothState = "Unknown"
@Published var localName: String = "MFG"
/// 16-bit company identifier (Little Endian in the payload). Default: 0xFFFF for testing.
@Published var companyId: UInt16 = 0xFFFF
let beacon_type = 0x02
let beacon_length = 0x10
/// Arbitrary manufacturer payload. Default 16 zero bytes for easy overriding.
@Published var tempIdBytes: Data = Data(repeating: 0x00, count: 16)
/// Last advertisement dictionary for debugging.
private(set) var lastAdvertisementData: [String: Any]? = nil
override init() {
super.init()
setupPeripheralManager()
}
private func setupPeripheralManager() {
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
/// Start advertising manufacturer data. Uses CBAdvertisementDataManufacturerDataKey which may appear in scan response on the scanner side depending on size and platform rules.
func startTransmitting() {
guard let manager = peripheralManager else {
print("PeripheralManager が初期化されていません")
return
}
guard manager.state == .poweredOn else {
print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
return
}
guard !isTransmitting else {
print("既にアドバタイズ中です")
return
}
// Build manufacturer data: company ID (little endian) + payload.
var mfgData = Data()
mfgData.append(UInt8(companyId & 0xFF))
mfgData.append(UInt8((companyId >> 8) & 0xFF))
mfgData.append(UInt8(beacon_type))
mfgData.append(UInt8(beacon_length))
mfgData.append(tempIdBytes)
let advertisementData: [String: Any] = [
CBAdvertisementDataManufacturerDataKey: mfgData,
CBAdvertisementDataLocalNameKey: localName
]
lastAdvertisementData = advertisementData
manager.startAdvertising(advertisementData)
isTransmitting = true
transmissionStatus = "発信中..."
print("📡 Manufacturer 発信開始")
print(String(format: " Company ID: 0x%04X (LE)", companyId))
print(" tempIdBytes (hex): \(tempIdBytes.map { String(format: "%02X", $0) }.joined())")
print(" Local Name: \(localName)")
}
func stopTransmitting() {
guard let manager = peripheralManager, isTransmitting else { return }
manager.stopAdvertising()
isTransmitting = false
transmissionStatus = "停止中"
print("Manufacturer 発信停止")
}
}
実行
左から
Android で受信
iPhone で受信
iPhone で発信
という状態です。iBeacon の UUID は同じなので、major と minor で判断をします。
class BeaconTransmitter(
private val context: Context,
major: Int = 0,
minor: Int = 0
) {
companion object {
const val TAG = "BeaconTransmitter"
val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13") // FolkBears サービス
}
private var beaconTransmitter: org.altbeacon.beacon.BeaconTransmitter? = null
var major: Int = major
var minor: Int = minor
var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW
private fun startBeaconTransmission() {
// Permission check (Android 12+ requires BLUETOOTH_ADVERTISE)
val advertiseGranted = ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.BLUETOOTH_ADVERTISE
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (!advertiseGranted) {
Log.e(TAG, "BLUETOOTH_ADVERTISE permission not granted; cannot start advertising")
return
}
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null) {
Log.e(TAG, "BluetoothAdapter not available")
return
}
if (!adapter.isEnabled) {
Log.e(TAG, "BluetoothAdapter disabled; enable Bluetooth and retry")
return
}
// 以下 org.altbeacon.beacon を利用しない方法も検討
val support = org.altbeacon.beacon.BeaconTransmitter.checkTransmissionSupported(context)
if (support != org.altbeacon.beacon.BeaconTransmitter.SUPPORTED) {
Log.e(TAG, "Beacon transmission not supported: code=$support")
return
}
val beacon = Beacon.Builder()
.setId1(SERVICE_UUID.toString()) // UUID
.setId2(major.toString()) // Major (10進数文字列)
.setId3(minor.toString()) // Minor (10進数文字列)
.setManufacturer(0x004C) // Apple iBeacon のメーカーコード
.setTxPower(-59) // 信号強度 (dBm)は仮設定
.build()
// val beaconParser = BeaconParser().setBeaconLayout(BeaconParser.ALTBEACON_LAYOUT)
val beaconParser = BeaconParser().setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24")
val altBeaconTransmitter = BeaconTransmitter(context, beaconParser).apply {
advertiseMode = this@BeaconTransmitter.advertiseMode
advertiseTxPowerLevel = this@BeaconTransmitter.advertiseTxPowerLevel
isConnectable = false // 非コネクタブルに
}
try {
altBeaconTransmitter?.startAdvertising(beacon, object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d(TAG, "iBeacon 発信開始")
}
override fun onStartFailure(errorCode: Int) {
Log.e(TAG, "iBeacon 発信に失敗: $errorCode")
}
})
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException when starting advertising: ${e.message}")
} catch (e: Throwable) {
Log.e(TAG, "Unexpected error when starting advertising: ${e.message}")
}
}
///
/// @break Beacon の発信開始
///
fun startTransmitter() {
Log.d(TAG, "startTransmitter")
if (beaconTransmitter == null ) {
startBeaconTransmission()
}
}
///
/// @brief Beacon の発信停止
///
fun stopTransmitter() {
Log.d(TAG, "stopTransmitter")
beaconTransmitter?.stopAdvertising()
beaconTransmitter = null
}
}
EN API 形式の発信
EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンです。BluetoothLeAdvertiser を使います。 EN API の 0xFD6F と実験用の 0xFF00 のどちらかで送信できるようにします。 Android の場合は 0xFD6F も 0xFF00 も両方とも受信できます。iOS の場合は 0xFF00 のほうだけが受信できます。
class ENSimTransmitter(
private val context: Context,
tempIdBytes: ByteArray = ByteArray(16),
useAltService: Boolean = false
) {
companion object {
const val TAG = "ENSimTransmitter"
val SERVICE_UUID: UUID = UUID.fromString("0000FD6F-0000-1000-8000-00805F9B34FB")
val SERVICE_UUID_ALT: UUID = UUID.fromString("0000FF00-0000-1000-8000-00805F9B34FB")
val SERVICE_DATA_UUID_ALT: UUID = UUID.fromString("00000001-0000-1000-8000-00805F9B34FB")
}
var useAltService: Boolean = useAltService
var tempIdBytes: ByteArray = tempIdBytes
var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW
private var advertiser: BluetoothLeAdvertiser? = null
private var advertiseCallback: AdvertiseCallback? = null
@Volatile
private var isAdvertising = false
///
/// ENSim の発信開始
///
fun startTransmitter() {
Log.d(TAG, "startTransmitter")
startAdvertisingInternal()
}
///
/// ENSim の発信停止
///
fun stopTransmitter() {
Log.d(TAG, "stopTransmitter")
advertiser?.stopAdvertising(advertiseCallback)
}
private fun startAdvertisingInternal() {
if ( advertiser == null ) {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val adapter = bluetoothManager.adapter
advertiser = adapter.bluetoothLeAdvertiser
}
val adv = advertiser ?: run {
Log.e(TAG, "BluetoothLeAdvertiser を取得できませんでした")
return
}
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(advertiseMode)
.setTxPowerLevel(advertiseTxPowerLevel)
.setConnectable(false)
.build()
val targetUuid = if (useAltService) SERVICE_UUID_ALT else SERVICE_UUID
val dataUuidForPayload = if (useAltService) SERVICE_DATA_UUID_ALT else targetUuid
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(true)
.addServiceUuid(ParcelUuid(targetUuid))
.addServiceData(ParcelUuid(dataUuidForPayload), tempIdBytes)
.build()
advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
super.onStartSuccess(settingsInEffect)
isAdvertising = true
Log.d(TAG, "ENSim advertise start")
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
isAdvertising = false
Log.e(TAG, "ENSim advertise failed: $errorCode")
}
}
try {
adv.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
isAdvertising = false
Log.e(TAG, "startAdvertising exception: ${e.message}")
}
}
private fun String.toByteArrayFromHex(): ByteArray {
if (length % 2 != 0) return ByteArray(0)
return chunked(2)
.mapNotNull { it.toIntOrNull(16)?.toByte() }
.toByteArray()
}
}
class GattAdvertise(
private val context: Context
)
{
companion object {
const val TAG = "GattAdvertise"
val SERVICE_UUID: UUID = UUID.fromString("90FA7ABE-FAB6-485E-B700-1A17804CAA13") // FolkBears サービス
}
private var advertiser: BluetoothLeAdvertiser? = null
@Volatile
var isAdvertising = false
private var lastStopTime = 0L
private var backgroundRetryRunnable: Runnable? = null
var advertiseMode: Int = AdvertiseSettings.ADVERTISE_MODE_LOW_POWER
var advertiseTxPowerLevel: Int = AdvertiseSettings.ADVERTISE_TX_POWER_LOW
private var currentCallback: AdvertiseCallback? = null
private fun createAdvertiseCallback(): AdvertiseCallback {
return object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
super.onStartSuccess(settingsInEffect)
Log.d(TAG, "Advertising onStartSuccess")
isAdvertising = true
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
val reason: String
when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED -> {
Log.w(TAG, "Advertising already started on Android ${Build.VERSION.SDK_INT}, forcing stop and retry")
return
}
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> {
reason = "ADVERTISE_FAILED_FEATURE_UNSUPPORTED"
isAdvertising = false
}
ADVERTISE_FAILED_INTERNAL_ERROR -> {
reason = "ADVERTISE_FAILED_INTERNAL_ERROR"
isAdvertising = false
}
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
reason = "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"
isAdvertising = false
}
ADVERTISE_FAILED_DATA_TOO_LARGE -> {
reason = "ADVERTISE_FAILED_DATA_TOO_LARGE"
isAdvertising = false
}
else -> {
reason = "UNDOCUMENTED"
isAdvertising = false
}
}
Log.d(TAG, "Advertising onStartFailure: $errorCode - $reason")
}
}
}
private var data: AdvertiseData? = null
fun startAdvertising() {
if ( advertiser == null ) {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val adapter = bluetoothManager.adapter
advertiser = adapter.bluetoothLeAdvertiser
}
if (isAdvertising) {
Log.d(TAG, "Already advertising or starting: advertising=$isAdvertising")
return
}
data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(true)
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.build()
currentCallback = createAdvertiseCallback()
val settings = AdvertiseSettings.Builder()
.setTxPowerLevel(advertiseTxPowerLevel)
.setAdvertiseMode(advertiseMode)
.setConnectable(true)
.build()
advertiser?.startAdvertising(settings, data, currentCallback)
}
fun stopAdvertising() {
if ( isAdvertising == false ) {
Log.d(TAG, "Not currently advertising, skipping stop")
return
}
currentCallback?.let { advertiser?.stopAdvertising(it) }
isAdvertising = false
}
}
Manufacturer Data 形式の発信
自由な型式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのです。Manufacturer Data 形式の場合は、Android でも iOS でも受信が可能です。 ただし、iOS の場合は、Manufacturer Data 形式での発信ができないので、接触確認アプリ FolkBears の作成には向いていません…が、m5stack などの専用デバイスを作れば結構いけるのではないか、と思っています。その場合は、16 bit Service UUID を使う方法もあるのですが。
COCOA/EN API のように特定のデータを配信する形は、この Manufacturer Data 形式でやるのが一番いいのですが、後で記事にしますが iOS では Manufacturer Data での発信ができません。Manufacturer Data 形式で発信できるのは iBeacon 形式だけで、実際に発信しようとするとデータ部分がランダム値(?)になってしまうことになります。これは、実際に iOS 用の発信ツールを作ったときに確認します。
final class ManufacturerDataScan: NSObject, ObservableObject {
/// 受信時のコールバック。keyはCompany ID(16bit)を0xXXXXで表記。
var onManufacturerData: ((String, Data, NSNumber, CBPeripheral, Data) -> Void)?
@Published var isScanning = false
@Published var scanningStatus = "停止中"
private var centralManager: CBCentralManager!
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func startScan() {
guard centralManager.state == .poweredOn else {
print("ManufacturerDataScan: Bluetooth未準備 state=\(centralManager.state.rawValue)")
return
}
guard !isScanning else { return }
centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
isScanning = true
scanningStatus = "スキャン中..."
}
func stopScan() {
guard isScanning else { return }
centralManager.stopScan()
isScanning = false
scanningStatus = "停止中"
}
}
extension ManufacturerDataScan: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
print("ManufacturerDataScan: Bluetooth On")
case .unauthorized:
print("ManufacturerDataScan: unauthorized")
case .unsupported:
print("ManufacturerDataScan: unsupported")
case .poweredOff:
print("ManufacturerDataScan: Bluetooth Off")
default:
break
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard let data = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, !data.isEmpty else { return }
// Company IDは先頭2バイトLittle Endianで格納される
let companyId = data.prefix(2).reduce(0) { acc, byte in (acc << 8) | Int(byte) }
let key = String(format: "0x%04X", companyId)
let beacon_type = data[2]
let beacon_length = data[3]
if ( companyId == 0xFFFF ) {
if ( beacon_type == 0x02 && beacon_length == 0x10 ) {
let tempid = data.dropFirst(4)
onManufacturerData?(key, data, RSSI, peripheral, tempid)
}
}
}
}