Functionality restored, build successful

This commit is contained in:
jack 2025-06-15 20:08:56 -05:00
parent fef53af475
commit f1f5bbc5e0
36 changed files with 1938 additions and 24 deletions

View file

@ -1,5 +1,7 @@
# Android Konfigurator
<img src="konfigurator.png" width=300px/>
## Overview
This software is used for configuring and initiating KTag games.

View file

@ -2,13 +2,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/konfigurator_icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/konfigurator_icon_round"
android:supportsRtl="true"
android:theme="@style/Theme.Konfigurator"
tools:targetApi="31">

View file

@ -0,0 +1,12 @@
package club.clubk.ktag.konfigurator
sealed class AppState {
object TitleScreen : AppState()
object GameSettings : AppState()
object TeamSettings : AppState()
object PregameTimer : AppState()
object Countdown : AppState()
object GameTimer : AppState()
object GameOver : AppState()
object WrapUp : AppState()
}

View file

@ -0,0 +1,322 @@
package club.clubk.ktag.konfigurator
import android.annotation.SuppressLint
import android.content.Context
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.util.Log
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG_BLE_SCAN = "BLE Scanner"
private const val TAG_BLE_AD = "BLE Advertiser"
private const val SCAN_HOLDOFF_PERIOD_MS: Long = 5000 // 5 seconds
class BleManager private constructor(context: Context) {
// Use applicationContext to prevent memory leaks
private val appContext: Context = context.applicationContext
// Bluetooth-related properties
private val bluetoothManager: BluetoothManager =
appContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bluetoothLeAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
private val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
private var currentAdvertiseCallback: AdvertiseCallback? = null
private var currentScanCallback: ScanCallback? = null
private var isAdvertising: Boolean = false
private var isScanning: Boolean = false
// Timestamp for the last scan stop
@Volatile
private var lastScanStopTimeMs: Long = 0
// Coroutine scope for managing delays
private val bleManagerScope = CoroutineScope(Dispatchers.Main + Job()) // Use Main dispatcher for UI-related callbacks if needed, or Default for background work
private var scanStartJob: Job? = null
// Singleton instance
companion object {
@Volatile
private var instance: BleManager? = null
/**
* Gets or creates the BleManager instance
*
* @param context The context used to get the application context
* @return BleManager instance
*/
fun getInstance(context: Context): BleManager {
// Double-checked locking pattern
return instance ?: synchronized(this) {
instance ?: BleManager(context).also { instance = it }
}
}
}
// Utility function to check if Bluetooth is available and enabled
private fun isBluetoothAvailable(): Boolean {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled
}
@SuppressLint("MissingPermission")
fun startAdvertising(data: ByteArray, advertiseCallback: AdvertiseCallback) {
stopAdvertising()
if (!isBluetoothAvailable()) {
Log.e(TAG_BLE_AD, "Bluetooth is not enabled!")
throw IllegalStateException("Bluetooth is not enabled!")
}
currentAdvertiseCallback = advertiseCallback
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(false)
.setTimeout(0)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.build()
// Calculate available space for manufacturer data
// 2 bytes for manufacturer ID
// Maximum total payload is 31 bytes
// Format: [Length (1 byte)] [Type (1 byte)] [Manufacturer ID (2 bytes)] [Data (remaining bytes)]
val maxManufacturerDataSize = 27 // 31 - 4 bytes overhead
val truncatedData = if (data.size > maxManufacturerDataSize) {
Log.w(
TAG_BLE_AD,
"Data exceeded maximum size (${data.size} > $maxManufacturerDataSize bytes), truncating..."
)
data.copyOfRange(0, maxManufacturerDataSize)
} else {
data
}
Log.d(TAG_BLE_AD, "Advertisement structure:")
Log.d(TAG_BLE_AD, "- Total payload max: 31 bytes")
Log.d(TAG_BLE_AD, "- Overhead: 4 bytes (Length: 1, Type: 1, Manufacturer ID: 2)")
Log.d(TAG_BLE_AD, "- Available for manufacturer data: $maxManufacturerDataSize bytes")
Log.d(TAG_BLE_AD, "- Actual data size: ${truncatedData.size} bytes")
Log.d(TAG_BLE_AD, "- Data: ${truncatedData.joinToString(" ") { String.format("%02X", it) }}")
val advertiseData = AdvertiseData.Builder()
.addManufacturerData(0xFFFF, truncatedData)
.build()
Log.i(
TAG_BLE_AD, "Starting advertisement with settings: Mode=${settings.mode}, " +
"TxPower=${settings.txPowerLevel}, Connectable=${settings.isConnectable}"
)
try {
bluetoothLeAdvertiser?.startAdvertising(settings, advertiseData, advertiseCallback)
isAdvertising = true
} catch (e: SecurityException) {
Log.e(TAG_BLE_AD, "Permission missing for starting advertisement", e)
advertiseCallback.onStartFailure(AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR) // Notify caller
}
}
@SuppressLint("MissingPermission")
fun stopAdvertising() {
Log.d(TAG_BLE_AD, "Attempting to stop advertising...")
currentAdvertiseCallback?.let { callback ->
try {
bluetoothLeAdvertiser?.stopAdvertising(callback)
Log.i(TAG_BLE_AD, "Advertisement stopping process initiated.")
} catch (e: SecurityException) {
Log.e(TAG_BLE_AD, "Permission missing for stopping advertisement", e)
} finally {
currentAdvertiseCallback = null // Clear callback regardless of success/failure to stop
isAdvertising = false
}
} ?: Log.d(TAG_BLE_AD, "No active advertisement to stop or advertiser is null.")
// isAdvertising should be reliably set to false when stop is called or if no active ad
if (currentAdvertiseCallback == null) isAdvertising = false
}
@SuppressLint("MissingPermission")
fun startScanning(scanCallback: ScanCallback) {
// Cancel any pending scan start operation
scanStartJob?.cancel()
scanStartJob = bleManagerScope.launch {
val currentTimeMs = System.currentTimeMillis()
val timeSinceLastScanStopMs = currentTimeMs - lastScanStopTimeMs
if (isScanning) { // If already scanning with a *different* callback, stop it first
Log.d(TAG_BLE_SCAN, "Already scanning, but a new scan request received. Stopping current scan first.")
internalStopScan() // Use an internal stop that doesn't update lastScanStopTimeMs yet
}
if (lastScanStopTimeMs > 0 && timeSinceLastScanStopMs < SCAN_HOLDOFF_PERIOD_MS) {
val delayNeededMs = SCAN_HOLDOFF_PERIOD_MS - timeSinceLastScanStopMs
Log.i(TAG_BLE_SCAN, "Scan holdoff active. Delaying scan start by ${delayNeededMs}ms")
delay(delayNeededMs)
}
// After potential delay, stop any existing scan (e.g. if one was running with a different callback)
// This also ensures that if stopScanning() was called very recently, we respect that.
if (currentScanCallback != null && currentScanCallback != scanCallback) {
internalStopScan() // Stop previous scan if a new one is requested with different callback
}
currentScanCallback = scanCallback // Set the new callback
val filter = ScanFilter.Builder()
.setManufacturerData(
0xFFFF, // Manufacturer ID
byteArrayOf(0x4B, 0x54, 0x61, 0x67) // "KTag"
)
.build()
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
if (!isBluetoothAvailable()) {
Log.e(TAG_BLE_SCAN, "Bluetooth is not enabled! Cannot start scan.")
scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
return@launch
}
Log.i(TAG_BLE_SCAN, "Starting scan...")
try {
bluetoothLeScanner?.startScan(listOf(filter), settings, scanCallback)
isScanning = true
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for starting scan", e)
scanCallback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) // Notify caller
isScanning = false // Ensure state is correct
}
}
}
@SuppressLint("MissingPermission")
fun stopScanning() {
// Cancel any pending scan start job, as we are now explicitly stopping.
scanStartJob?.cancel()
internalStopScan()
}
// Internal stop scan function to avoid recursive calls and manage state correctly
@SuppressLint("MissingPermission")
private fun internalStopScan() {
Log.d(TAG_BLE_SCAN, "Attempting to stop scanning (internal)...")
currentScanCallback?.let { callback ->
try {
bluetoothLeScanner?.stopScan(callback)
Log.i(TAG_BLE_SCAN, "Scan stopping process initiated.")
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for stopping scan", e)
} finally {
// This is a good place to record the stop time
lastScanStopTimeMs = System.currentTimeMillis()
currentScanCallback = null // Clear callback
isScanning = false // Update scanning state
}
} ?: Log.d(TAG_BLE_SCAN, "No active scan to stop or scanner is null.")
// Ensure scanning state is false if no callback
if (currentScanCallback == null) isScanning = false
}
suspend fun configureDevice(configPacket: ParametersPacket): Boolean {
// Ensure configureDevice uses the managed startScanning/stopScanning
return withTimeout(5000) { // Timeout for the whole configuration operation
val advertisementDetected = CompletableDeferred<Boolean>()
var tempAdvertiseCallback: AdvertiseCallback? = null
var tempScanCallback: ScanCallback? = null
try {
tempAdvertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
Log.d(TAG_BLE_AD,"Config advertisement started successfully.")
// Only start scanning AFTER advertisement has successfully started
tempScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d(TAG_BLE_SCAN, ">>> ScanCallback.onScanResult ENTERED")
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
if (data.size >= 4 &&
data[0] == 0x4B.toByte() && // K
data[1] == 0x54.toByte() && // T
data[2] == 0x61.toByte() && // a
data[3] == 0x67.toByte() // g
) {
Log.d(TAG_BLE_SCAN, "KTag packet scanned, checking for parameter packet..." + data[4].toString() + " " + data[12].toString())
val scannedPacket = byteArrayToKTagPacket(data)
if (scannedPacket is ParametersPacket) {
Log.d(TAG_BLE_SCAN, "Parameter packet scanned, checking for parameter match...")
if (result.device.address == configPacket.targetAddress
&& scannedPacket.subtype == 3 // Ensure this is the ACK subtype
&& scannedPacket.key1 == configPacket.key1 // Example: check relevant params
&& scannedPacket.value1 == configPacket.value1
&& scannedPacket.key2 == configPacket.key2
&& scannedPacket.value2 == configPacket.value2
) {
Log.i(TAG_BLE_SCAN, "Parameters match, configuration successful for ${result.device.address}!")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(true)
}
}
}
}
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG_BLE_SCAN, "Config scan failed with error: $errorCode")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(false)
}
}
}
// Use the main startScanning function which includes holdoff logic
this@BleManager.startScanning(tempScanCallback!!)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
Log.e(TAG_BLE_AD, "Failed to start config advertisement, error: $errorCode")
if (!advertisementDetected.isCompleted) {
advertisementDetected.complete(false)
}
}
}
Log.d(TAG_BLE_AD, "Sending configuration packet to ${configPacket.targetAddress}...")
this@BleManager.startAdvertising(kTagPacketToByteArray(configPacket),
tempAdvertiseCallback
)
val success = advertisementDetected.await() // Wait for scan result or ad failure
success
} catch (e: TimeoutCancellationException) {
Log.e(TAG_BLE_SCAN, "Config confirmation timed out for ${configPacket.targetAddress}")
false
} finally {
Log.d(TAG_BLE_SCAN, "Cleaning up configureDevice resources for ${configPacket.targetAddress}")
// Stop the specific advertising and scanning session for this configuration attempt
// Check callbacks before stopping to ensure they were set
tempAdvertiseCallback?.let { this@BleManager.stopAdvertising() } // Uses the main stop which clears currentAdvertiseCallback
tempScanCallback?.let { this@BleManager.stopScanning() } // Uses the main stop which clears currentScanCallback and sets holdoff time
}
}
}
}

View file

@ -0,0 +1,7 @@
package club.clubk.ktag.konfigurator
data class ConfigurationProgress(
val configuredCount: Int,
val totalCount: Int,
val isComplete: Boolean
)

View file

@ -0,0 +1,24 @@
package club.clubk.ktag.konfigurator
import java.util.UUID
data class Device(val uuid: UUID = UUID.randomUUID(),
var name: String = "Unknown Device",
var address: String = "FF:FF:FF:FF:FF:FF",
var deviceType : Int? = null,
var team : Int? = null,
var playerID : Int? = null,
var deviceState: DeviceState? = null
) {
fun deviceTypeName(): String {
return when(deviceType) {
0 -> "Little Boy BLuE"
1 -> "2020TPC"
2 -> "Mobile App"
3 -> "32ESPecial"
else -> "Unknown Device Type"
}
}
}

View file

@ -0,0 +1,8 @@
package club.clubk.ktag.konfigurator
sealed class DeviceState {
object Configurable : DeviceState()
object Ready : DeviceState()
object Playing : DeviceState()
object WrapUp : DeviceState()
}

View file

@ -0,0 +1,9 @@
package club.clubk.ktag.konfigurator
data class GameConfig(var name: String = "Default",
var gameLength: Int = 600000,
var pregameLength: Int = 60000,
var numRounds: Int = 2,
var maxHealth: Int = 10,
var numBombs: Int = 1 // Special Weapons Received on Game Reentry
)

View file

@ -0,0 +1,365 @@
package club.clubk.ktag.konfigurator
val kTagByte = byteArrayOf(
0x4B.toByte(), // K
0x54.toByte(), // T
0x61.toByte(), // a
0x67.toByte() // g
)
// Data classes for packet parsing
sealed class KTagPacket {
abstract val type: Int
abstract val typeName: String
abstract val eventNumber: Int
}
data class InstigateGamePacket(
override val type: Int,
override val typeName: String = "Instigate Game",
override val eventNumber: Int,
val gameLength: Int,
val countdownTime: Int
) : KTagPacket()
data class EventPacket(
override val type: Int,
override val typeName: String = "Event",
override val eventNumber: Int,
val targetAddress: String,
val eventId: Int,
val eventData: Int
) : KTagPacket()
data class TagPacket(
override val type: Int,
override val typeName: String = "Tag",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val damage: Int,
val color: Int,
val targetAddress: String
) : KTagPacket()
data class ConsolePacket(
override val type: Int,
override val typeName: String = "Console",
override val eventNumber: Int,
val consoleString: String
) : KTagPacket()
data class StatusPacket(
override val type: Int,
override val typeName: String = "Status",
override val eventNumber: Int,
val txPower: Int,
val protocol: Int,
val teamId: Int,
val playerId: Int,
val health: Int,
val maxHealth: Int,
val primaryColor: Int,
val secondaryColor: Int,
val systemKState: Int
) : KTagPacket()
data class ParametersPacket(
override val type: Int,
override val typeName: String = "Parameters",
override val eventNumber: Int,
val targetAddress: String,
val subtype: Int,
val key1: Int,
val value1: Int,
val key2: Int,
val value2: Int
) : KTagPacket()
data class HelloPacket(
override val type: Int,
override val typeName: String = "Hello",
override val eventNumber: Int,
val majorVersion: Int,
val minorVersion: Int,
val deviceType: Int,
val teamId: Int,
val deviceName: String
) : KTagPacket()
fun byteArrayToKTagPacket(data: ByteArray): KTagPacket {
val type = data[4].toInt()
val eventNumber = data[5].toInt()
return when (type) {
0x01 -> InstigateGamePacket(
type = type,
eventNumber = eventNumber,
gameLength = bytesToInt(data, 6, 4),
countdownTime = bytesToInt(data, 10, 4)
)
0x02 -> EventPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
eventId = bytesToInt(data, 12, 4),
eventData = bytesToInt(data, 16, 4)
)
0x03 -> TagPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[9].toInt(),
damage = bytesToInt(data, 10, 2),
color = bytesToInt(data, 12, 4),
targetAddress = bytesToMacAddress(data, 16)
)
0x04 -> ConsolePacket(
type = type,
eventNumber = eventNumber,
consoleString = data.slice(6..26).toByteArray().let { bytes ->
// Find the position of the first null byte if any
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
// If there's a null byte, take only up to that position, otherwise take all bytes
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
0x05 -> StatusPacket(
type = type,
eventNumber = eventNumber,
txPower = data[6].toInt(),
protocol = data[7].toInt(),
teamId = data[8].toInt(),
playerId = data[8].toInt(),
health = bytesToInt(data, 10, 2),
maxHealth = bytesToInt(data, 12, 2),
primaryColor = bytesToInt(data, 14, 4),
secondaryColor = bytesToInt(data, 18, 4),
systemKState = data[22].toInt()
)
0x06 -> ParametersPacket(
type = type,
eventNumber = eventNumber,
targetAddress = bytesToMacAddress(data, 6),
subtype = data[12].toInt(),
key1 = bytesToInt(data, 13, 2),
value1 = bytesToInt(data, 15, 4),
key2 = bytesToInt(data, 19, 2),
value2 = bytesToInt(data, 21, 4)
)
0x07 -> HelloPacket(
type = type,
eventNumber = eventNumber,
majorVersion = data[6].toInt(),
minorVersion = data[7].toInt(),
deviceType = bytesToInt(data, 8, 2),
teamId = data[10].toInt(),
deviceName = data.slice(11..26).toByteArray().let { bytes ->
// Find the position of the first null byte if any
val nullPos = bytes.indexOfFirst { it == 0.toByte() }
// If there's a null byte, take only up to that position, otherwise take all bytes
if (nullPos >= 0) {
bytes.slice(0 until nullPos).toByteArray()
} else {
bytes
}.toString(Charsets.US_ASCII).trim()
}
)
else -> StatusPacket(
type = type,
typeName = "Unknown",
eventNumber = eventNumber,
txPower = 0,
protocol = 0,
teamId = 0,
playerId = 0,
health = 0,
maxHealth = 0,
primaryColor = 0,
secondaryColor = 0,
systemKState = 0
)
}
}
fun kTagPacketToByteArray(kTagPacket: KTagPacket): ByteArray {
var payload: ByteArray = byteArrayOf(0x00.toByte())
when (kTagPacket) {
is ParametersPacket -> {
val addressBytes = macAddressToBytes(kTagPacket.targetAddress)
payload = byteArrayOf(
0x06.toByte(), // Packet Type: Parameter
(kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
*addressBytes,
(kTagPacket.subtype and 0xFF).toByte(),
(kTagPacket.key1 and 0xFF).toByte(),
((kTagPacket.key1 shr 8) and 0xFF).toByte(),
(kTagPacket.value1 and 0xFF).toByte(),
((kTagPacket.value1 shr 8) and 0xFF).toByte(),
((kTagPacket.value1 shr 16) and 0xFF).toByte(),
((kTagPacket.value1 shr 24) and 0xFF).toByte(),
(kTagPacket.key2 and 0xFF).toByte(),
((kTagPacket.key2 shr 8) and 0xFF).toByte(),
(kTagPacket.value2 and 0xFF).toByte(),
((kTagPacket.value2 shr 8) and 0xFF).toByte(),
((kTagPacket.value2 shr 16) and 0xFF).toByte(),
((kTagPacket.value2 shr 24) and 0xFF).toByte()
) + ByteArray(2) { 0xFF.toByte() }
}
is ConsolePacket -> {
// something
}
is EventPacket -> {
val addressBytes = macAddressToBytes(kTagPacket.targetAddress)
payload = byteArrayOf(
0x02.toByte(), // Packet Type: Event
(kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
*addressBytes,
(kTagPacket.eventId and 0xFF).toByte(),
((kTagPacket.eventId shr 8) and 0xFF).toByte(),
((kTagPacket.eventId shr 16) and 0xFF).toByte(),
((kTagPacket.eventId shr 24) and 0xFF).toByte(), // Event ID
(kTagPacket.eventData and 0xFF).toByte(),
((kTagPacket.eventData shr 8) and 0xFF).toByte(),
((kTagPacket.eventData shr 16) and 0xFF).toByte(),
((kTagPacket.eventData shr 24) and 0xFF).toByte() // Event data
) + ByteArray(7) { 0xFF.toByte() }
}
is HelloPacket -> {
// something
}
is InstigateGamePacket -> {
payload = byteArrayOf(
0x01.toByte(), // Packet Type: Instigate Game
(kTagPacket.eventNumber and 0xFF).toByte(), // Event counter
(kTagPacket.gameLength and 0xFF).toByte(),
((kTagPacket.gameLength shr 8) and 0xFF).toByte(),
((kTagPacket.gameLength shr 16) and 0xFF).toByte(),
((kTagPacket.gameLength shr 24) and 0xFF).toByte(), // Game length
(kTagPacket.countdownTime and 0xFF).toByte(),
((kTagPacket.countdownTime shr 8) and 0xFF).toByte(),
((kTagPacket.countdownTime shr 16) and 0xFF).toByte(),
((kTagPacket.countdownTime shr 24) and 0xFF).toByte() // Time until countdown
) + ByteArray(21) { 0xFF.toByte() }
}
is StatusPacket -> {
// something
}
is TagPacket -> {
// something
}
}
return kTagByte + payload
}
// Helper function to convert bytes to integers
private fun bytesToInt(data: ByteArray, offset: Int, length: Int): Int {
var result = 0
for (i in 0 until length) {
result = result or ((data[offset + i].toInt() and 0xFF) shl (8 * i))
}
return result
}
// Helper function to convert bytes to MAC address string
private fun bytesToMacAddress(data: ByteArray, offset: Int): String {
return (0..5).joinToString(":") {
String.format("%02X", data[offset + it])
}
}
private fun macAddressToBytes(address: String): ByteArray {
val cleanAddress = address.replace(":", "").replace("-", "")
// Check if the address is valid (12 hex characters = 6 bytes)
if (cleanAddress.length != 12) {
throw IllegalArgumentException("Invalid BLE address length")
}
// Convert the hex string to bytes. Reverse the bytes to little-endian format.
val bytes = ByteArray(6)
var j = 5
for (i in 0 until 6) {
val hexByte = cleanAddress.substring(i * 2, i * 2 + 2)
bytes[j] = hexByte.toInt(16).toByte()
j--
}
return bytes
}
class InstigatePacketGenerator {
private var eventCount = 0
fun generatePacket(gameLength: Int, timeUntilCountdown: Int): InstigateGamePacket {
val newInstigateGamePacket = InstigateGamePacket(
type = 1, // instigate
eventNumber = eventCount,
gameLength = gameLength,
countdownTime = timeUntilCountdown
)
eventCount++
return newInstigateGamePacket
}
}
class EventPacketGenerator {
private var eventCount = 0
fun generatePacket(targetAddress: String, eventId: Int = 0, eventData: Int = 0): EventPacket {
val newEventPacket = EventPacket(
type = 2, // event
eventNumber = eventCount,
targetAddress = targetAddress,
eventId = eventId,
eventData = eventData
)
eventCount++
return newEventPacket
}
}
class ParametersPacketGenerator {
private var eventCount = 0
fun generatePacket(targetAddress: String, subtype: Int,
key1: Int = 0, value1: Int = 0,
key2: Int = 0, value2: Int = 0): ParametersPacket {
val newParametersPacket = ParametersPacket(
type = 6, // parameters
eventNumber = eventCount,
targetAddress = targetAddress,
subtype = subtype,
key1 = key1, value1 = value1,
key2 = key2, value2 = value2
)
eventCount++
return newParametersPacket
}
}

View file

@ -1,47 +1,655 @@
package club.clubk.ktag.konfigurator
import android.Manifest
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat
// import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import club.clubk.ktag.konfigurator.ui.theme.KonfiguratorTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG = "Konfigurator"
class MainActivity : ComponentActivity() {
private val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.ACCESS_FINE_LOCATION, // Required for BLE operations
Manifest.permission.ACCESS_COARSE_LOCATION // Required for BLE operations
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.entries.all { it.value }
Log.d(TAG, "Permission results: ${permissions.map { "${it.key}: ${it.value}" }}")
if (allGranted) {
Log.i(TAG, "All permissions granted")
mainScreen()
} else {
// Show which permissions were denied
val deniedPermissions = permissions.filter { !it.value }.keys
Log.w(TAG, "Some permissions denied: $deniedPermissions")
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
PermissionDeniedScreen(
deniedPermissions = deniedPermissions.toList()
) { requestPermissions() }
}
}
}
}
}
private fun checkAndRequestPermissions() {
Log.d(TAG, "Checking permissions: ${requiredPermissions.joinToString()}")
if (hasRequiredPermissions()) {
Log.i(TAG, "All required permissions already granted")
mainScreen()
} else {
Log.i(TAG, "Requesting permissions")
requestPermissions()
}
}
private fun hasRequiredPermissions(): Boolean {
return requiredPermissions.all { permission ->
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() {
Log.d(TAG, "Launching permission request")
permissionLauncher.launch(requiredPermissions)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
checkAndRequestPermissions()
// enableEdgeToEdge()
}
private fun mainScreen() {
setContent {
KonfiguratorTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
StateMachine(StateMachineViewModel(applicationContext))
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
fun PermissionDeniedScreen(
deniedPermissions: List<String>,
onRequestPermissions: () -> Unit
) {
Log.d(TAG, "Showing permission denied screen for permissions: $deniedPermissions")
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Hello $name!",
modifier = modifier
text = "The following permissions are required:",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.dp)
)
deniedPermissions.forEach { permission ->
Text(
text = "${permission.split(".").last()}",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 4.dp)
)
}
Button(
onClick = {
Log.d(TAG, "Permission request button clicked")
onRequestPermissions()
},
modifier = Modifier.padding(top = 16.dp)
) {
Text("Grant Permissions")
}
}
}
@Composable
fun StateMachine(stateMachine: StateMachineViewModel) { // stateMachine: StateMachineViewModel = viewModel()
val appState by stateMachine.appState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
when (appState) {
AppState.TitleScreen -> TitleScreen(stateMachine)
AppState.GameSettings -> GameSettingsScreen(stateMachine)
AppState.TeamSettings -> TeamSettingsScreen(stateMachine)
AppState.PregameTimer -> PregameTimerScreen(stateMachine)
AppState.Countdown -> CountdownScreen(stateMachine)
AppState.GameTimer -> GameTimerScreen(stateMachine)
AppState.GameOver -> GameOverScreen(stateMachine)
AppState.WrapUp -> Text("Wrap Up")
}
}
}
}
// ------------
// TITLE SCREEN
//
@Composable
fun BigLogo() {
Icon(
painter = painterResource(id = R.drawable.konfigurator),
contentDescription = "Konfigurator Logo",
modifier = Modifier.size(192.dp),
tint = Color.Unspecified
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
KonfiguratorTheme {
Greeting("Android")
fun TitleScreen(stateMachine: StateMachineViewModel) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Konfigurator", style = MaterialTheme.typography.headlineMedium)
BigLogo()
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { stateMachine.nextState() },
modifier = Modifier.fillMaxWidth()
) {
Text("New Game")
}
}
// -------------
// GAME SETTINGS
// -------------
@Composable
fun GameSettingsScreen(stateMachine: StateMachineViewModel) {
val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Game Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(16.dp)
)
GameConfigEditor(oldGameConfig = currentGameConfig,
onSave = {
stateMachine.updateGameConfig(it)
stateMachine.nextState()
})
}
}
@Composable
fun GameConfigEditor(oldGameConfig: GameConfig,
onSave: (GameConfig) -> Unit,
modifier: Modifier = Modifier) {
key(oldGameConfig) {
var name by rememberSaveable(oldGameConfig.name) { mutableStateOf(oldGameConfig.name) }
var gameLength by rememberSaveable(oldGameConfig.gameLength) { mutableStateOf(oldGameConfig.gameLength.toString()) }
var pregameLength by rememberSaveable(oldGameConfig.pregameLength) { mutableStateOf(oldGameConfig.pregameLength.toString()) }
var numRounds by rememberSaveable(oldGameConfig.numRounds) { mutableStateOf(oldGameConfig.numRounds.toString()) }
var maxHealth by rememberSaveable(oldGameConfig.maxHealth) { mutableStateOf(oldGameConfig.maxHealth.toString()) }
var numBombs by rememberSaveable(oldGameConfig.numBombs) { mutableStateOf(oldGameConfig.numBombs.toString()) }
// For tracking validation errors
var gameLengthError by rememberSaveable { mutableStateOf(false) }
var pregameLengthError by rememberSaveable { mutableStateOf(false) }
var numRoundsError by rememberSaveable { mutableStateOf(false) }
var maxHealthError by rememberSaveable { mutableStateOf(false) }
var numBombsError by rememberSaveable { mutableStateOf(false) }
// Function to validate the form
fun validateForm(): Boolean {
gameLengthError = !isValidInteger(gameLength)
pregameLengthError = !isValidInteger(pregameLength)
numRoundsError = !isValidInteger(numRounds)
maxHealthError = !isValidInteger(maxHealth)
numBombsError = !isValidInteger(numBombs)
return !gameLengthError && !pregameLengthError && !numRoundsError && !maxHealthError && !numBombsError
}
// Function to update the data class when values change
fun saveGameConfig() {
// Only update if all integer fields are valid
if (validateForm()) {
val newGameConfig = GameConfig(
name = name,
gameLength = gameLength.toIntOrNull() ?: oldGameConfig.gameLength,
pregameLength = pregameLength.toIntOrNull() ?: oldGameConfig.pregameLength,
numRounds = numRounds.toIntOrNull() ?: oldGameConfig.numRounds,
maxHealth = maxHealth.toIntOrNull() ?: oldGameConfig.maxHealth,
numBombs = numBombs.toIntOrNull() ?: oldGameConfig.numBombs
)
onSave(newGameConfig)
}
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// String value field
OutlinedTextField(
value = name,
onValueChange = {
name = it
},
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
// Integer value fields
IntegerTextField(
value = gameLength,
onValueChange = {
gameLength = it
// gameLengthError = !isValidInteger(it)
},
label = "Game Length",
isError = gameLengthError
)
IntegerTextField(
value = pregameLength,
onValueChange = {
pregameLength = it
// pregameLengthError = !isValidInteger(it)
},
label = "Pregame Length",
isError = pregameLengthError
)
IntegerTextField(
value = numRounds,
onValueChange = {
numRounds = it
// numRoundsError = !isValidInteger(it)
},
label = "Number Of Rounds",
isError = numRoundsError
)
IntegerTextField(
value = maxHealth,
onValueChange = {
maxHealth = it
// maxHealthError = !isValidInteger(it)
},
label = "Max Health",
isError = maxHealthError
)
IntegerTextField(
value = numBombs,
onValueChange = {
numBombs = it
// numBombsError = !isValidInteger(it)
},
label = "Number Of Bombs",
isError = numBombsError
)
}
Spacer(modifier = Modifier.height(8.dp))
Button( // Save button
onClick = { saveGameConfig() },
modifier = Modifier.fillMaxWidth()
) {
Text("Confirm")
}
}
}
// Helper function to validate integer input
private fun isValidInteger(input: String): Boolean {
return input.toIntOrNull() != null || input.isEmpty()
}
// Helper composable for integer input fields
@Composable
private fun IntegerTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
isError: Boolean
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = isError,
supportingText = if (isError) {
{ Text("Please enter a valid integer") }
} else null,
modifier = Modifier.fillMaxWidth()
)
}
// -------------
// TEAM SETTINGS
// -------------
@Composable
fun TeamSettingsScreen(stateMachine: StateMachineViewModel) {
Log.d("MAINACTIVITY", "recomposing team setting screen")
val coroutineScope = rememberCoroutineScope()
val devices by stateMachine.devices.collectAsState()
val allDevicesReady by stateMachine.allDevicesReady.collectAsState()
Scaffold(
// Bottom bar that stays fixed at the bottom
bottomBar = {
BottomAppBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
if (allDevicesReady) {
Button(
onClick = { stateMachine.nextState() },
modifier = Modifier.fillMaxWidth()
) {
Text("Start Game")
}
} else {
SideBySideButtons(
"Apply",
"Ready",
onLeftButtonClick = { coroutineScope.launch { stateMachine.configureDevices() } },
onRightButtonClick = { stateMachine.readyAllDevices() }
)
}
}
}
}
) { paddingValues ->
DeviceList(stateMachine, devices, Modifier.fillMaxSize().padding(paddingValues))
}
}
@Composable
fun DeviceList(stateMachine: StateMachineViewModel, devices: List<Device>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
items(devices) { device ->
DeviceCard(stateMachine, device)
}
}
}
@Composable
fun SideBySideButtons(
leftButtonText: String,
rightButtonText: String,
onLeftButtonClick: () -> Unit,
onRightButtonClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) // Add space between buttons
) {
Button(
onClick = onLeftButtonClick,
modifier = Modifier.weight(1f) // Takes up half of the available width
) {
Text(text = leftButtonText)
}
Button(
onClick = onRightButtonClick,
modifier = Modifier.weight(1f) // Takes up half of the available width
) {
Text(text = rightButtonText)
}
}
}
@Composable
fun DeviceCard(stateMachine: StateMachineViewModel, device: Device) {
val backgroundColor = when (device.team) {
0 -> Color.Magenta
1 -> Color.Red
2 -> Color.Blue
else -> MaterialTheme.colorScheme.surface // Default color
}
Card(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
onClick = { stateMachine.cycleDeviceTeam(device) },
colors = CardDefaults.cardColors(
containerColor = backgroundColor
)) {
Text(device.name + " | " + device.deviceTypeName())
Text(if(device.deviceState == DeviceState.Ready) {"READY"} else {"NOT READY"})
}
}
// -------------
// PREGAME TIMER
// -------------
fun formatTimeToMinutesSeconds(remainingSeconds: Int): String {
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
// Format to ensure seconds always shows with two digits (e.g., "01" instead of "1")
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
@Composable
fun CountdownTimer(timeInMillis: Long, onCountdownComplete: () -> Unit) {
// Store remaining time as state
var remainingTime by remember { mutableLongStateOf(timeInMillis) }
val initialTime = remember { timeInMillis }
// Use LaunchedEffect to create a coroutine that runs the countdown
LaunchedEffect(key1 = timeInMillis) {
// Reset the timer if the input time changes
remainingTime = timeInMillis
// Continue counting down until we reach zero
while (remainingTime > 0) {
delay(1000) // Update every second
remainingTime -= 1000
Log.d("CountdownTimer", "Remaining time: ${remainingTime / 1000} seconds")
}
// Log when countdown reaches zero
Log.d("CountdownTimer", "Countdown finished!")
onCountdownComplete()
}
val remainingSeconds = (remainingTime / 1000).toInt()
val progress = if (initialTime > 0) remainingTime.toFloat() / initialTime.toFloat() else 0f
val formattedTime = formatTimeToMinutesSeconds(remainingSeconds)
// UI to display the countdown
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Display remaining time in seconds
Text(
text = formattedTime,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Display progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
fun PregameTimerScreen(stateMachine: StateMachineViewModel) {
val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Game Starts In",
style = MaterialTheme.typography.headlineMedium
)
CountdownTimer(currentGameConfig.pregameLength.toLong(), onCountdownComplete = {
stateMachine.nextState()
})
}
}
// ---------
// COUNTDOWN
// ---------
@Composable
fun CountdownScreen(stateMachine: StateMachineViewModel) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CountdownTimer(6000.toLong(), onCountdownComplete = {
stateMachine.nextState()
})
}
}
// ----------
// GAME TIMER
// ----------
@Composable
fun GameTimerScreen(stateMachine: StateMachineViewModel) {
val currentGameConfig by stateMachine.currentGameConfig.collectAsState()
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Game In Progress",
style = MaterialTheme.typography.headlineMedium
)
CountdownTimer(currentGameConfig.gameLength.toLong(), onCountdownComplete = {
stateMachine.nextState()
})
Button(
onClick = { stateMachine.nextState() },
modifier = Modifier.fillMaxWidth()
) {
Text("End Game")
}
}
}
// ---------
// GAME OVER
// ---------
@Composable
fun GameOverScreen(stateMachine: StateMachineViewModel) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Game Over",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(24.dp))
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { stateMachine.nextState() },
modifier = Modifier.fillMaxWidth()
) {
Text("Play Again")
}
}
}

View file

@ -0,0 +1,116 @@
package club.clubk.ktag.konfigurator
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
// Assuming ParametersPacket and BleManager are defined as in your existing code
class MultiDeviceConfigurator(private val bleManager: BleManager) {
// Mutex to ensure only one device configuration (advertise + scan for ack) happens at a time
private val bleOperationMutex = Mutex()
/**
* Configures a list of devices sequentially in terms of BLE operations,
* but the overall process can be managed as part of a larger concurrent workflow.
*
* @param deviceConfigs List of ParametersPacket for each device to configure.
* @param onDeviceConfigured A callback invoked for each device with its address and success status.
* @param onProgressUpdate A callback to report overall progress (e.g., "Configuring device X of Y").
* @return A map of device addresses to their configuration success status.
*/
suspend fun configureMultipleDevices(
deviceConfigs: List<ParametersPacket>,
onDeviceConfigured: suspend (deviceAddress: String, success: Boolean) -> Unit,
onProgressUpdate: suspend (configuredCount: Int, totalCount: Int) -> Unit
): Map<String, Boolean> {
val results = mutableMapOf<String, Boolean>()
var configuredCount = 0
val totalCount = deviceConfigs.size
onProgressUpdate(configuredCount, totalCount)
for (configPacket in deviceConfigs) {
// Ensure only one BLE configuration operation (advertising + specific scan)
// happens at a time across all calls that might use this shared BleManager.
// If configureDevice itself manages a Mutex for its specific startAdvertising/startScanning,
// this outer Mutex might be redundant for that part, but it's good for clarity
// if other BLE operations could interfere.
// For now, let's assume configureDevice is a self-contained operation.
// The critical part is that `bleManager.configureDevice` itself does a full
// advertise -> scan -> stop cycle.
var success = false
try {
// The bleOperationMutex ensures that if multiple coroutines call
// configureMultipleDevices, or if other parts of your app try to use
// bleManager for advertising/scanning, they don't interfere with the
// current device's configuration sequence.
bleOperationMutex.withLock {
Log.i(
"MultiDeviceConfigurator",
"Attempting to configure ${configPacket.targetAddress}"
)
// The existing configureDevice already has its own timeout.
success = bleManager.configureDevice(configPacket)
}
} catch (e: Exception) {
// Catch any unexpected exceptions during the configuration of a single device
Log.e(
"MultiDeviceConfigurator",
"Exception configuring ${configPacket.targetAddress}",
e
)
success = false
}
results[configPacket.targetAddress] = success
configuredCount++
onDeviceConfigured(configPacket.targetAddress, success)
onProgressUpdate(configuredCount, totalCount)
if (success) {
Log.i(
"MultiDeviceConfigurator",
"Successfully configured ${configPacket.targetAddress}"
)
} else {
Log.w(
"MultiDeviceConfigurator",
"Failed to configure ${configPacket.targetAddress}"
)
}
// Optional: Add a small delay between configuring devices if peripherals need time
// delay(100)
}
return results
}
}
// --- Example Usage (e.g., in a ViewModel or a Service) ---
suspend fun exampleUsage(bleManager: BleManager, allDeviceConfigs: List<ParametersPacket>) {
val configurator = MultiDeviceConfigurator(bleManager)
Log.d("App", "Starting configuration for ${allDeviceConfigs.size} devices...")
// This whole block can be launched in a specific coroutine scope (e.g., viewModelScope)
// The configureMultipleDevices function itself is sequential for BLE ops,
// but the call to it can be non-blocking for the UI.
val configurationResults = configurator.configureMultipleDevices(
deviceConfigs = allDeviceConfigs,
onDeviceConfigured = { deviceAddress, success ->
Log.d("App", "Device $deviceAddress configuration result: $success")
// Update UI for this specific device
},
onProgressUpdate = { configured, total ->
Log.d("App", "Progress: $configured / $total devices configured.")
// Update overall progress UI
}
)
Log.d("App", "All device configurations attempted. Results: $configurationResults")
// Process overall results
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.konfigurator
import java.util.UUID
data class Player(val uuid: UUID = UUID.randomUUID()) {
var name : String = "Anonymous"
var id : Int = 0
var numWins : Int = 0
var numLosses : Int = 0
}

View file

@ -0,0 +1,57 @@
package club.clubk.ktag.konfigurator
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.SoundPool
import android.os.Build
import android.util.Log
class SoundManager(private val context: Context) {
private var soundPool: SoundPool? = null
private var doubleChirpID: Int = 0
private var isLoaded = false
init {
// Initialize the SoundPool
soundPool = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
SoundPool.Builder()
.setMaxStreams(3) // Allow up to 3 simultaneous sounds
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
.build()
} else {
@Suppress("DEPRECATION")
SoundPool(3, AudioManager.STREAM_MUSIC, 0)
}
// Set up load listener to know when sound is ready to play
soundPool?.setOnLoadCompleteListener { _, _, status ->
isLoaded = status == 0
}
// Load the click sound (place your click.wav file in res/raw)
doubleChirpID = soundPool?.load(context, R.raw.double_chirp, 1) ?: 0
}
fun playDoubleChirp() {
if (isLoaded) {
// Play the sound with:
// leftVolume, rightVolume (1f = full volume)
// priority (0 = lowest)
// loop (0 = no loop)
// rate (1f = normal playback speed)
soundPool?.play(doubleChirpID, 1f, 1f, 0, 0, 1f)
}
}
// Important: Release resources when no longer needed
fun release() {
soundPool?.release()
soundPool = null
}
}

View file

@ -0,0 +1,348 @@
package club.clubk.ktag.konfigurator
import android.annotation.SuppressLint
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
private const val TAG_BLE_SCAN = "BLE Scanner"
private const val TAG_BLE_AD = "BLE Advertiser"
private const val TAG_STATE_MACHINE = "StateMachineVM"
class StateMachineViewModel(context: Context) : ViewModel() {
private val _appState = MutableStateFlow<AppState>(AppState.TitleScreen)
val appState: StateFlow<AppState> = _appState.asStateFlow()
private val _devices = MutableStateFlow<List<Device>>(emptyList())
val devices: StateFlow<List<Device>> = _devices.asStateFlow()
private val _allDevicesReady = MutableStateFlow<Boolean>(false)
val allDevicesReady = _allDevicesReady.asStateFlow()
private val _currentGameConfig = MutableStateFlow(GameConfig())
val currentGameConfig = _currentGameConfig.asStateFlow()
// State for configuration progress
private val _configurationProgress = MutableStateFlow<ConfigurationProgress?>(null)
val configurationProgress: StateFlow<ConfigurationProgress?> = _configurationProgress.asStateFlow()
// State for individual device configuration status
private val _deviceConfigStatus = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val deviceConfigStatus: StateFlow<Map<String, Boolean>> = _deviceConfigStatus.asStateFlow()
private val bleManager = BleManager.getInstance(context)
private val instigatePacketGenerator = InstigatePacketGenerator()
private val eventPacketGenerator = EventPacketGenerator()
private val parameterPacketGenerator = ParametersPacketGenerator()
private val multiDeviceConfigurator = MultiDeviceConfigurator(bleManager)
private val soundManager = SoundManager(context)
override fun onCleared() {
soundManager.release()
super.onCleared()
}
@SuppressLint("MissingPermission")
private val scanCallbackDefault = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
result.scanRecord?.manufacturerSpecificData?.get(0xFFFF)?.let { data ->
if (data.size >= 4 &&
data[0] == 0x4B.toByte() && // K
data[1] == 0x54.toByte() && // T
data[2] == 0x61.toByte() && // a
data[3] == 0x67.toByte() // g
) {
val packet = byteArrayToKTagPacket(data)
val scannedDevice = Device(address = result.device.address)
when (packet) {
is HelloPacket -> {
// Log.d(TAG_BLE_SCAN, "HelloPacket scanned")
scannedDevice.name = packet.deviceName
scannedDevice.deviceType = packet.deviceType
scannedDevice.team = packet.teamId
scannedDevice.deviceState = DeviceState.Configurable
addOrRefreshDevice(scannedDevice)
}
is ConsolePacket -> {
// Log.d(TAG_BLE_SCAN, "ConsolePacket scanned")
}
is EventPacket -> {
// Log.d(TAG_BLE_SCAN, "EventPacket scanned")
}
is InstigateGamePacket -> {
// Log.d(TAG_BLE_SCAN, "InstigateGamePacket scanned")
}
is ParametersPacket -> {
// Log.d(TAG_BLE_SCAN, "ParametersPacket scanned")
}
is StatusPacket -> {
when (packet.systemKState) {
3 -> scannedDevice.deviceState = DeviceState.Ready
7 -> scannedDevice.deviceState = DeviceState.Playing
9 -> scannedDevice.deviceState = DeviceState.WrapUp
}
addOrRefreshDevice(scannedDevice)
}
is TagPacket -> {
// Log.d(TAG_BLE_SCAN, "TagPacket scanned")
}
}
}
}
}
override fun onScanFailed(errorCode: Int) {
val errorMessage = when (errorCode) {
SCAN_FAILED_ALREADY_STARTED -> "Already started"
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
else -> "Unknown error $errorCode"
}
Log.e(TAG_BLE_SCAN, "BLE Scan failed: $errorMessage")
}
}
private val advertisingCallbackDefault = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
super.onStartSuccess(settingsInEffect)
Log.i(
TAG_BLE_AD, "Advertisement started successfully with settings: " +
"Mode=${settingsInEffect.mode}, TxPower=${settingsInEffect.txPowerLevel}"
)
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
val errorMessage = when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
else -> "Unknown error $errorCode"
}
Log.e(TAG_BLE_AD, "Failed to start advertising: $errorMessage")
}
}
private fun addOrRefreshDevice(newDevice: Device) {
val currentDevices = _devices.value.toMutableList()
val index = currentDevices.indexOfFirst { it.address == newDevice.address }
if (index == -1) {
currentDevices += newDevice
}
else {
var oldDevice = currentDevices[index]
newDevice.name = oldDevice.name
newDevice.deviceType = oldDevice.deviceType ?: newDevice.deviceType
newDevice.team = oldDevice.team ?: newDevice.team
newDevice.playerID = oldDevice.playerID ?: newDevice.playerID
newDevice.deviceState = newDevice.deviceState ?: oldDevice.deviceState
currentDevices[index] = newDevice
}
_devices.value = currentDevices
Log.d("AddRefresh", _devices.value.toString())
_allDevicesReady.value = allDevicesReady()
Log.d("AddRefresh", _allDevicesReady.value.toString())
}
private fun updateDevice(newDevice: Device) {
val currentDevices = _devices.value.toMutableList()
val index = currentDevices.indexOfFirst { it.address == newDevice.address }
if (index == -1) { return }
var oldDevice = currentDevices[index]
newDevice.deviceType = newDevice.deviceType ?: oldDevice.deviceType
newDevice.team = newDevice.team ?: oldDevice.team
newDevice.playerID = newDevice.playerID ?: oldDevice.playerID
currentDevices[index] = newDevice
_devices.value = currentDevices
_allDevicesReady.value = allDevicesReady()
}
fun updateDeviceTeam(deviceAddress: String, newTeam: Int) {
_devices.update { currentList ->
currentList.map { device ->
if (device.address == deviceAddress) {
device.copy(team = newTeam) // Creates a new Device instance
} else {
device
}
}
}
_allDevicesReady.value = allDevicesReady()
}
fun cycleDeviceTeam(device: Device) {
Log.d("STATEMACHINE", "cycling device team")
var newTeam = device.team ?: -1
newTeam++
if (newTeam > 2) {
newTeam = 0
}
updateDeviceTeam(device.address, newTeam)
}
private fun allDevicesReady(): Boolean {
if (_devices.value.isEmpty()) { return false }
for (device in _devices.value) {
if (device.deviceState != DeviceState.Ready) {
return false
}
}
return true
}
fun readyAllDevices() {
val eventPacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 2)
bleManager.startAdvertising(kTagPacketToByteArray(eventPacket), advertisingCallbackDefault)
bleManager.startScanning(scanCallbackDefault)
}
fun updateGameConfig(newGameConfig: GameConfig) {
_currentGameConfig.value = newGameConfig
}
/**
* Initiates the configuration of all devices using the MultiDeviceConfigurator.
*/
fun configureDevices() {
val currentDevicesToConfigure = _devices.value.filter {
// Refine this filter as needed. For example:
// it.deviceState == DeviceState.Configurable || it.needsReconfiguration
true // For now, assuming all devices in the list should be configured
}
val gameCfg = _currentGameConfig.value
if (currentDevicesToConfigure.isEmpty()) {
Log.i(TAG_STATE_MACHINE, "No devices eligible for configuration.")
_configurationProgress.value = ConfigurationProgress(0, 0, true)
return
}
// 1. Prepare the list of ParametersPacket (this part is similar to what you had,
// but now it collects all packets for all devices first)
val deviceConfigPackets = currentDevicesToConfigure.mapNotNull { device ->
val teamId = device.team ?: 0 // Default to team 0 if not set
// Create your ParametersPacket.
// If a device can have multiple configuration packets, generate them all here
// and return a List<ParametersPacket> from this lambda, then use flatMapNotNull.
// For now, assuming one packet per device for simplicity.
parameterPacketGenerator.generatePacket(
targetAddress = device.address,
subtype = 2, // Request Parameter Change
key1 = 1, value1 = teamId, // Key 1 is Team ID
key2 = 4, value2 = gameCfg.maxHealth // Key 2 is Max Health
)
// If a device for some reason can't be configured (e.g. missing address),
// you could return null here, and mapNotNull will filter it out.
}
if (deviceConfigPackets.isEmpty() && currentDevicesToConfigure.isNotEmpty()) {
Log.w(TAG_STATE_MACHINE, "Could not generate config packets for any selected device.")
_configurationProgress.value = ConfigurationProgress(0, currentDevicesToConfigure.size, true)
return
}
Log.i(TAG_STATE_MACHINE, "Starting configuration for ${deviceConfigPackets.size} devices.")
_deviceConfigStatus.value = emptyMap() // Reset status for the new session
_configurationProgress.value = ConfigurationProgress(0, deviceConfigPackets.size, false)
viewModelScope.launch {
// Optional: Stop general scanning if it might interfere with the
// specific scan/advertise sequence within bleManager.configureDevice()
// Log.d(TAG_STATE_MACHINE, "Stopping general BLE scan for configuration.")
// bleManager.stopScanning()
// 2. THIS IS THE REPLACEMENT: Call your MultiDeviceConfigurator
val results = multiDeviceConfigurator.configureMultipleDevices(
deviceConfigs = deviceConfigPackets,
onDeviceConfigured = { deviceAddress, success ->
// This callback is invoked by your MultiDeviceConfigurator for each device
Log.i(TAG_STATE_MACHINE, "Device $deviceAddress configured (from callback): $success")
_deviceConfigStatus.update { currentStatus ->
currentStatus + (deviceAddress to success)
}
// You could also update the Device object in _devices if needed:
// _devices.update { list ->
// list.map { if (it.address == deviceAddress) it.copy(isConfigured = success) else it }
// }
},
onProgressUpdate = { configuredCount, totalCount ->
// This callback is invoked by your MultiDeviceConfigurator on progress
Log.i(TAG_STATE_MACHINE, "Configuration progress (from callback): $configuredCount/$totalCount")
_configurationProgress.value = ConfigurationProgress(configuredCount, totalCount, configuredCount == totalCount)
}
)
// 3. Process overall results (optional, as callbacks already update state)
Log.i(TAG_STATE_MACHINE, "All device configurations attempted by MultiDeviceConfigurator. Final Results map: $results")
val allSucceeded = results.values.all { it }
if (allSucceeded) {
Log.i(TAG_STATE_MACHINE, "All devices configured successfully (overall check).")
} else {
Log.w(TAG_STATE_MACHINE, "One or more devices failed to configure (overall check).")
}
// Optional: Restart general scanning if it was stopped earlier
// Log.d(TAG_STATE_MACHINE, "Restarting general BLE scan after configuration.")
// bleManager.startScanning(scanCallbackDefault)
}
}
fun nextState() {
soundManager.playDoubleChirp()
when (_appState.value) {
is AppState.TitleScreen -> {
_appState.value = AppState.GameSettings
}
is AppState.GameSettings -> {
_appState.value = AppState.TeamSettings
bleManager.startScanning(scanCallbackDefault)
}
is AppState.TeamSettings -> {
_appState.value = AppState.PregameTimer
val instigatePacket = instigatePacketGenerator.generatePacket(
_currentGameConfig.value.gameLength,
_currentGameConfig.value.pregameLength
)
bleManager.stopScanning()
bleManager.startAdvertising(kTagPacketToByteArray(instigatePacket), advertisingCallbackDefault)
}
is AppState.PregameTimer -> {
_appState.value = AppState.Countdown
bleManager.stopAdvertising()
}
is AppState.Countdown -> {
_appState.value = AppState.GameTimer
}
is AppState.GameTimer -> {
_appState.value = AppState.GameOver
val gameOverPacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 4)
bleManager.startAdvertising(kTagPacketToByteArray(gameOverPacket), advertisingCallbackDefault)
}
AppState.GameOver -> {
_appState.value = AppState.GameSettings
val wrapupCompletePacket = eventPacketGenerator.generatePacket(targetAddress = "FF:FF:FF:FF:FF:FF", eventId = 3, eventData = 2)
bleManager.startAdvertising(kTagPacketToByteArray(wrapupCompletePacket), advertisingCallbackDefault)
}
AppState.WrapUp -> {
_appState.value = AppState.GameSettings
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/konfigurator_icon_background"/>
<foreground android:drawable="@mipmap/konfigurator_icon_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/konfigurator_icon_background"/>
<foreground android:drawable="@mipmap/konfigurator_icon_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="konfigurator_icon_background">#000000</color>
</resources>

BIN
konfigurator.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB