5a084ac5a9
VBT260400xy → FLAT, VBT260402xy → NEW_CASE2, VBT260403xy → NEW_CASE1 2025MEDIP → LEGACY_5CH Auto-called on BLE connect. Logged to debug. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
537 lines
22 KiB
Kotlin
537 lines
22 KiB
Kotlin
package com.example.medilightv2android.ble
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.bluetooth.*
|
|
import android.bluetooth.le.*
|
|
import android.content.Context
|
|
import android.os.Build
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.util.Log
|
|
import com.example.medilightv2android.BuildConfig
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import java.util.UUID
|
|
|
|
data class BleDevice(
|
|
val device: BluetoothDevice,
|
|
var rssi: Int,
|
|
var isConnecting: Boolean = false,
|
|
var isConnected: Boolean = false
|
|
) {
|
|
val name: String @SuppressLint("MissingPermission") get() = try { device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
|
val address: String get() = device.address
|
|
}
|
|
|
|
@SuppressLint("MissingPermission")
|
|
class BleManager private constructor(private val context: Context) {
|
|
|
|
companion object {
|
|
private const val TAG = "BleManager"
|
|
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
|
|
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
|
|
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
|
|
|
|
@Volatile
|
|
private var instance: BleManager? = null
|
|
fun getInstance(context: Context): BleManager =
|
|
instance ?: synchronized(this) {
|
|
instance ?: BleManager(context.applicationContext).also { instance = it }
|
|
}
|
|
|
|
val SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
|
val TX_CHAR_UUID: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
|
|
val RX_CHAR_UUID: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
|
|
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
}
|
|
|
|
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
|
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
|
private var bluetoothGatt: BluetoothGatt? = null
|
|
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
|
private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
|
private val handler = Handler(Looper.getMainLooper())
|
|
private var scanTimer: Runnable? = null
|
|
private var batteryTimer: Runnable? = null
|
|
private var connectionTimer: Runnable? = null
|
|
private var isUserDisconnect = false
|
|
|
|
val discoveredDevices = mutableStateListOf<BleDevice>()
|
|
val isScanning = mutableStateOf(false)
|
|
val isConnected = mutableStateOf(false)
|
|
val connectedDeviceName = mutableStateOf("")
|
|
val batteryLevel = mutableStateOf(0)
|
|
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
|
|
val isServiceReady = mutableStateOf(false)
|
|
val isReconnecting = mutableStateOf(false)
|
|
val connectionError = mutableStateOf<String?>(null)
|
|
|
|
// Callbacks
|
|
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
|
var onNirsPowerOnReceived: (() -> Unit)? = null
|
|
var onNirsSensorActivated: (() -> Unit)? = null
|
|
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
|
|
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
|
|
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
|
var onUnexpectedDisconnect: (() -> Unit)? = null
|
|
var onReconnectionFailed: (() -> Unit)? = null
|
|
|
|
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
|
|
|
|
// Scanning
|
|
fun startScan() {
|
|
if (bluetoothAdapter?.isEnabled != true) {
|
|
bluetoothEnabled.value = false
|
|
return
|
|
}
|
|
bluetoothEnabled.value = true
|
|
discoveredDevices.clear()
|
|
isScanning.value = true
|
|
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
|
|
|
|
if (bluetoothLeScanner == null) {
|
|
loge { "BluetoothLeScanner is null" }
|
|
isScanning.value = false
|
|
return
|
|
}
|
|
|
|
val settings = ScanSettings.Builder()
|
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
.build()
|
|
|
|
try {
|
|
bluetoothLeScanner?.startScan(null, settings, scanCallback)
|
|
logd { "BLE scan started" }
|
|
} catch (e: Exception) {
|
|
loge { "Failed to start scan: ${e.message}" }
|
|
isScanning.value = false
|
|
return
|
|
}
|
|
|
|
scanTimer?.let { handler.removeCallbacks(it) }
|
|
val timeout = Runnable { stopScan() }
|
|
scanTimer = timeout
|
|
handler.postDelayed(timeout, 15000)
|
|
}
|
|
|
|
fun stopScan() {
|
|
try {
|
|
bluetoothLeScanner?.stopScan(scanCallback)
|
|
} catch (e: Exception) {
|
|
logw { "Failed to stop scan: ${e.message}" }
|
|
}
|
|
isScanning.value = false
|
|
scanTimer?.let { handler.removeCallbacks(it) }
|
|
scanTimer = null
|
|
}
|
|
|
|
private val scanCallback = object : ScanCallback() {
|
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
val device = result.device
|
|
val name = try { device.name } catch (_: Exception) { null } ?: return
|
|
if (name.isEmpty()) return
|
|
|
|
handler.post {
|
|
val existing = discoveredDevices.indexOfFirst { it.address == device.address }
|
|
if (existing >= 0) {
|
|
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
|
|
} else {
|
|
discoveredDevices.add(BleDevice(device, result.rssi))
|
|
logd { "Discovered: $name (${device.address}) rssi=${result.rssi}" }
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onScanFailed(errorCode: Int) {
|
|
loge { "Scan failed with error code: $errorCode" }
|
|
handler.post {
|
|
isScanning.value = false
|
|
connectionError.value = "Scan failed (error $errorCode)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Connection
|
|
fun connect(bleDevice: BleDevice) {
|
|
stopScan()
|
|
logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
|
|
|
|
// Clean up any existing GATT connection
|
|
bluetoothGatt?.let { gatt ->
|
|
logd { "Closing existing GATT connection" }
|
|
try {
|
|
gatt.disconnect()
|
|
gatt.close()
|
|
} catch (e: Exception) {
|
|
logw { "Error closing old GATT: ${e.message}" }
|
|
}
|
|
bluetoothGatt = null
|
|
txCharacteristic = null
|
|
rxCharacteristic = null
|
|
}
|
|
|
|
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
|
|
|
connectionError.value = null
|
|
|
|
try {
|
|
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
|
logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
|
|
} catch (e: Exception) {
|
|
loge { "connectGatt failed: ${e.message}" }
|
|
handler.post {
|
|
connectionError.value = "Connection failed: ${e.message}"
|
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Connection timeout
|
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
|
val timeout = Runnable {
|
|
if (!isConnected.value) {
|
|
logw { "Connection timeout" }
|
|
bluetoothGatt?.let { gatt ->
|
|
try {
|
|
gatt.disconnect()
|
|
gatt.close()
|
|
} catch (_: Exception) {}
|
|
}
|
|
bluetoothGatt = null
|
|
connectionError.value = "Connection timed out. Try again."
|
|
if (idx >= 0 && idx < discoveredDevices.size) {
|
|
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
|
}
|
|
}
|
|
}
|
|
connectionTimer = timeout
|
|
handler.postDelayed(timeout, 10000)
|
|
}
|
|
|
|
fun disconnect() {
|
|
isUserDisconnect = true
|
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
|
connectionTimer = null
|
|
bluetoothGatt?.disconnect()
|
|
}
|
|
|
|
// Reconnection
|
|
fun reconnectToLastDevice(lastDeviceAddress: String?) {
|
|
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
|
|
onReconnectionFailed?.invoke()
|
|
return
|
|
}
|
|
|
|
// Clean up existing
|
|
bluetoothGatt?.let { gatt ->
|
|
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
|
bluetoothGatt = null
|
|
}
|
|
|
|
isReconnecting.value = true
|
|
val device = bluetoothAdapter.getRemoteDevice(lastDeviceAddress)
|
|
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
|
|
|
handler.postDelayed({
|
|
if (isReconnecting.value) {
|
|
isReconnecting.value = false
|
|
bluetoothGatt?.let { gatt ->
|
|
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
|
}
|
|
bluetoothGatt = null
|
|
onReconnectionFailed?.invoke()
|
|
}
|
|
}, 10000)
|
|
}
|
|
|
|
// Send
|
|
fun sendRaw(data: ByteArray) {
|
|
val characteristic = txCharacteristic
|
|
val gatt = bluetoothGatt
|
|
if (characteristic == null) {
|
|
loge { "sendRaw: txCharacteristic is null!" }
|
|
return
|
|
}
|
|
if (gatt == null) {
|
|
loge { "sendRaw: bluetoothGatt is null!" }
|
|
return
|
|
}
|
|
val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII) else "?"
|
|
logd { "sendRaw: $cmdPreview (${data.size} bytes)" }
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
|
logd { "sendRaw result: $result" }
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
characteristic.value = data
|
|
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
@Suppress("DEPRECATION")
|
|
val ok = gatt.writeCharacteristic(characteristic)
|
|
logd { "sendRaw success: $ok" }
|
|
}
|
|
}
|
|
|
|
// Piezo Commands
|
|
val piezoCollector = PiezoPacketCollector()
|
|
|
|
fun sendPiezoPowerOn(freqOption: Int = 2, cycles: Int = 5) {
|
|
sendRaw(CRC16.buildCommand("mpa", intArrayOf(freqOption, cycles)))
|
|
}
|
|
|
|
fun sendPiezoStop() {
|
|
sendRaw(CRC16.buildCommand("mps"))
|
|
}
|
|
|
|
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
|
|
sendRaw(CRC16.buildCommand("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
|
|
}
|
|
|
|
/** 6-channel measurement (maa) */
|
|
fun sendAllChannels(mode: Int = 0) {
|
|
piezoCollector.startMultiChannel(6)
|
|
sendRaw(CRC16.buildCommand("maa", intArrayOf(mode)))
|
|
}
|
|
|
|
// NIRS Commands
|
|
fun sendNirsPowerOn() {
|
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
|
}
|
|
|
|
fun sendNirsPowerOff() {
|
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(0)))
|
|
}
|
|
|
|
fun sendMqqQuery() {
|
|
sendRaw(CRC16.buildCommand("mqq"))
|
|
}
|
|
|
|
fun sendMagQuery() {
|
|
sendRaw(CRC16.buildCommand("mag"))
|
|
}
|
|
|
|
fun sendMcjQuery() {
|
|
sendRaw(CRC16.buildCommand("mcj"))
|
|
}
|
|
|
|
fun sendBatteryQuery() {
|
|
sendRaw(CRC16.buildCommand("msn", intArrayOf(0)))
|
|
}
|
|
|
|
fun startBatteryPolling() {
|
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
|
// Query immediately, then every 30s
|
|
sendBatteryQuery()
|
|
val poll = object : Runnable {
|
|
override fun run() {
|
|
sendBatteryQuery()
|
|
handler.postDelayed(this, 30000)
|
|
}
|
|
}
|
|
batteryTimer = poll
|
|
handler.postDelayed(poll, 30000)
|
|
}
|
|
|
|
fun stopBatteryPolling() {
|
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
|
batteryTimer = null
|
|
}
|
|
|
|
private val gattCallback = object : BluetoothGattCallback() {
|
|
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
|
logd { "onConnectionStateChange: status=$status newState=$newState" }
|
|
handler.post {
|
|
// Cancel connection timeout
|
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
|
connectionTimer = null
|
|
|
|
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
|
|
// Connection failed with error
|
|
loge { "GATT connection failed with status: $status" }
|
|
bluetoothGatt = null
|
|
txCharacteristic = null
|
|
rxCharacteristic = null
|
|
isConnected.value = false
|
|
isServiceReady.value = false
|
|
isReconnecting.value = false
|
|
connectionError.value = "Connection failed (status $status). Try again."
|
|
|
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
|
|
|
gatt.close()
|
|
return@post
|
|
}
|
|
|
|
when (newState) {
|
|
BluetoothProfile.STATE_CONNECTED -> {
|
|
logd { "Connected to ${gatt.device.address}" }
|
|
isReconnecting.value = false
|
|
isUserDisconnect = false
|
|
connectionError.value = null
|
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
|
|
isConnected.value = true
|
|
connectedDeviceName.value = try { gatt.device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
|
// Auto-detect device preset from name
|
|
com.example.medilightv2android.managers.PiezoHW.autoDetectPreset(connectedDeviceName.value)
|
|
logd { "Device preset: ${com.example.medilightv2android.managers.PiezoHW.presetName} (from '${connectedDeviceName.value}')" }
|
|
|
|
// Save last connected device address
|
|
try {
|
|
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
|
prefs.edit().putString("last_device_address", gatt.device.address).apply()
|
|
} catch (_: Exception) {}
|
|
|
|
gatt.discoverServices()
|
|
onConnectionStateChanged?.invoke(true)
|
|
}
|
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
|
logd { "Disconnected from ${gatt.device.address}" }
|
|
stopBatteryPolling()
|
|
bluetoothGatt = null
|
|
txCharacteristic = null
|
|
rxCharacteristic = null
|
|
isConnected.value = false
|
|
isServiceReady.value = false
|
|
connectedDeviceName.value = ""
|
|
batteryLevel.value = 0
|
|
|
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
|
|
|
onConnectionStateChanged?.invoke(false)
|
|
|
|
if (!isUserDisconnect) {
|
|
onUnexpectedDisconnect?.invoke()
|
|
}
|
|
isUserDisconnect = false
|
|
gatt.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
|
logd { "onServicesDiscovered: status=$status" }
|
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
handler.post { connectionError.value = "Service discovery failed (status $status)" }
|
|
return
|
|
}
|
|
val service = gatt.getService(SERVICE_UUID)
|
|
if (service == null) {
|
|
loge { "NUS service not found!" }
|
|
handler.post { connectionError.value = "Device service not found" }
|
|
return
|
|
}
|
|
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
|
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
|
|
|
logd { "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}" }
|
|
|
|
rxCharacteristic?.let { rx ->
|
|
gatt.setCharacteristicNotification(rx, true)
|
|
val descriptor = rx.getDescriptor(CCCD_UUID)
|
|
if (descriptor != null) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
@Suppress("DEPRECATION")
|
|
gatt.writeDescriptor(descriptor)
|
|
}
|
|
} else {
|
|
logw { "CCCD descriptor not found for RX characteristic" }
|
|
// No CCCD but still mark ready if characteristics exist
|
|
handler.post {
|
|
if (txCharacteristic != null && rxCharacteristic != null) {
|
|
isServiceReady.value = true
|
|
logd { "Service ready (no CCCD)" }
|
|
}
|
|
}
|
|
}
|
|
} ?: run {
|
|
// No RX characteristic, mark ready if TX exists
|
|
handler.post {
|
|
if (txCharacteristic != null) {
|
|
isServiceReady.value = true
|
|
logd { "Service ready (no RX)" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
|
logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
|
|
if (descriptor.uuid == CCCD_UUID) {
|
|
handler.post {
|
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
|
if (txCharacteristic != null && rxCharacteristic != null) {
|
|
isServiceReady.value = true
|
|
startBatteryPolling()
|
|
logd { "Service ready! (CCCD written)" }
|
|
}
|
|
} else {
|
|
loge { "CCCD write failed with status: $status" }
|
|
connectionError.value = "Notification setup failed (status $status)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
|
if (value.size < 4) return
|
|
handler.post { processReceivedData(value) }
|
|
}
|
|
}
|
|
|
|
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
|
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
|
@Suppress("DEPRECATION")
|
|
val data = characteristic.value ?: return
|
|
if (data.size < 4) return
|
|
handler.post { processReceivedData(data) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun processReceivedData(data: ByteArray) {
|
|
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
|
logd { "Received: $prefix (${data.size} bytes), callback=${
|
|
when {
|
|
prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != null}"
|
|
prefix.startsWith("rta") -> "onNirsPowerOnReceived=${onNirsPowerOnReceived != null}"
|
|
prefix.startsWith("rqq") || prefix.startsWith("rsh") -> "onNirsSensorActivated=${onNirsSensorActivated != null}"
|
|
prefix.startsWith("rag") -> "onNirsMagReceived=${onNirsMagReceived != null}"
|
|
else -> "n/a"
|
|
}
|
|
}" }
|
|
when (prefix) {
|
|
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
|
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
|
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
|
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
|
"rpa:" -> onPiezoDataReceived?.invoke(data)
|
|
// Piezo echo responses → route to packet collector
|
|
"rer:" -> {
|
|
// VesiScan-Basic preliminary header — just acknowledge, reb: follows
|
|
logd { "rer: preliminary header (${data.size} bytes)" }
|
|
}
|
|
"reb:" -> piezoCollector.addPacket(data)
|
|
"red:" -> piezoCollector.addPacket(data)
|
|
"ree:", "raa:" -> piezoCollector.addPacket(data)
|
|
"rsn:" -> {
|
|
if (data.size >= 6) {
|
|
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
|
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
|
|
}
|
|
}
|
|
else -> logw { "Unknown prefix: '$prefix' hex=${data.take(8).joinToString(" ") { "%02X".format(it) }}" }
|
|
}
|
|
}
|
|
}
|