Initial public release.

This commit is contained in:
Joe Kearney 2026-03-01 17:03:10 -06:00
parent ed31acd60f
commit 58d87b11b7
249 changed files with 15831 additions and 4 deletions

View file

@ -0,0 +1,264 @@
# KTag Konfigurator Subapp
A Jetpack Compose Android application for configuring KTag laser tag devices via BLE and coordinating game sessions.
## Overview
The Konfigurator manages the full lifecycle of a KTag game session: configuring game parameters, discovering and assigning devices to teams, broadcasting game start/stop events, and running game timers. It communicates with KTag devices over BLE using the KTag 27-byte packet protocol.
## Architecture
The app follows the **MVVM (Model-View-ViewModel)** pattern with a state machine driving screen transitions.
```
┌─────────────────────────────────────────────────────────┐
│ KonfiguratorActivity │
│ (Compose Host + Permissions) │
└─────────────────────┬───────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────┐
│ StateMachineViewModel │
│ • State machine (6 screens) │
│ • BLE scanning & advertising │
│ • Multi-device configuration │
│ • Game config management │
└──────┬──────────────┬───────────────────────────────────┘
│ │
┌──────▼──────┐ ┌─────▼──────────────────────────────────┐
│ BleManager │ │ MultiDeviceConfigurator │
│ (Singleton) │ │ (Sequential BLE config with mutex) │
└─────────────┘ └────────────────────────────────────────┘
```
## File Structure
```
src/main/java/club/clubk/ktag/apps/konfigurator/
├── KonfiguratorActivity.kt # Compose activity with BLE permissions
├── StateMachineViewModel.kt # State machine, BLE orchestration
├── BleManager.kt # BLE scan/advertise singleton
├── MultiDeviceConfigurator.kt # Sequential multi-device configuration
├── KTagPacket.kt # Packet types, parsing, generators
├── Device.kt # Device data class
├── DeviceState.kt # Sealed class: Configurable/Ready/Playing/WrapUp
├── AppState.kt # Sealed class: 6 screen states
├── GameConfig.kt # Game parameters data class
├── ConfigurationProgress.kt # Configuration progress tracking
├── Player.kt # Player data class
├── SoundManager.kt # Audio feedback (SoundPool)
├── KonfiguratorSubApp.kt # Subapp registration
├── KonfiguratorInitializer.kt # androidx.startup initializer
└── ui/theme/
├── Theme.kt # Material3 dynamic theming
├── Color.kt # Color palette
└── Type.kt # Typography
```
## State Machine
```
┌──────────────────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ │
│ GameSettings │ Configure duration, health, rounds │
└───────┬───────┘ │
│ → start BLE scanning │
▼ │
┌───────────────┐ │
│ TeamSettings │ Discover devices, assign teams, apply config │
└───────┬───────┘ │
│ [all devices ready] │
│ → stop scanning │
│ → advertise InstigatingGame packet │
▼ │
┌───────────────┐ │
│ PregameTimer │ Countdown before game │
└───────┬───────┘ │
│ [timer expires] │
│ → stop advertising │
▼ │
┌───────────────┐ │
│ Countdown │ Countdown lights sequence (5+1 s) │
└───────┬───────┘ │
│ [lights out] │
▼ │
┌───────────────┐ │
│ GameTimer │ Active game countdown │
└───────┬───────┘ │
│ [timer expires / End Game] │
│ → advertise EVENT_GAME_OVER │
▼ │
┌───────────────┐ │
│ WrapUp │ → advertise EVENT_WRAPUP_COMPLETE ──────────────────┘
└───────────────┘
```
| State | Description |
|----------------|----------------------------------------------------------------------------|
| `GameSettings` | Configure game duration, health, and rounds |
| `TeamSettings` | Scan for BLE devices, assign teams (Purple/Red/Blue), apply config |
| `PregameTimer` | Pregame countdown; InstigatingGame packet keeps devices in sync |
| `Countdown` | Countdown lights-out sequence (6 s) |
| `GameTimer` | Active game countdown with pie-chart timer; early-end available |
| `WrapUp` | Game complete; broadcasts EVENT_WRAPUP_COMPLETE, returns to GameSettings |
## Key Components
### StateMachineViewModel
Manages game state and BLE operations:
- **State Machine**: Transitions between 6 screen states, plays audio on each transition
- **Device Discovery**: BLE scanning with KTag manufacturer data filter (0xFFFF)
- **Team Assignment**: Cycle devices through teams (0=Purple, 1=Red, 2=Blue)
- **Configuration**: Sends Parameters packets to assign team and health to each device
- **Game Control**: Broadcasts Instigate Game, Event (ready/game over/wrapup) packets
### BleManager
Singleton managing BLE operations:
- **Advertising**: Broadcasts KTag packets as manufacturer-specific data
- **Scanning**: Filters for KTag magic bytes with scan holdoff (5s between scans)
- **Device Configuration**: Advertise config packet, scan for ACK, with 5s timeout
- **Thread Safety**: Coroutine-based with managed scan start jobs
### MultiDeviceConfigurator
Sequential device configuration:
- **Mutex-Protected**: Ensures one BLE config operation at a time
- **Progress Callbacks**: Reports per-device success/failure and overall progress
- **Error Handling**: Catches exceptions per-device, continues with remaining devices
### KTagPacket
Packet system with sealed class hierarchy:
- 7 packet types: InstigateGame, Event, Tag, Console, Status, Parameters, Hello
- `byteArrayToKTagPacket()`: Deserializes raw bytes to typed packets
- `kTagPacketToByteArray()`: Serializes packets to BLE advertisement bytes
- Packet generators with auto-incrementing event counters
## Game Configuration
| Parameter | Default | Description |
|---------------------------|---------|---------------------------------|
| `gameDurationMin` | 10 | Game duration in minutes |
| `timeUntilCountdownS` | 30 | Pregame countdown in seconds |
| `numRounds` | 2 | Number of rounds |
| `maxHealth` | 10 | Maximum player health |
| `specialWeaponsOnReentry` | 1 | Special weapons on game reentry |
## How to Add a New Configurable Parameter to a Device
Each configurable parameter follows the same "set then confirm" pattern: the app holds a *desired* value and a *broadcasted* (device-confirmed) value. The accent bar and checkmark on a device card turn green only when every parameter's desired value matches its broadcasted value.
The steps below use `fooBar` / `broadcastedFooBar` as the example parameter name.
### 1. Add the protocol key — `core/.../Packet.java`
Parameter keys are defined in the [KTag BLE Protocol Specification](https://ktag.clubk.club/Technology/BLE/). Check there first — the key you need may already exist as a `PARAMETER_KEY_*` constant in `Packet.java`. If it does not, add it:
```java
public static final int PARAMETER_KEY_FOO_BAR = <key_id_from_spec>;
```
Each Parameters BLE packet carries two key-value slots. If both slots in the last packet are already occupied, the new parameter needs its own packet (see step 6).
### 2. Add fields to the device model — `Device.kt`
```kotlin
var fooBar: Int? = null,
var broadcastedFooBar: Int? = null
```
`fooBar` is the value the operator wants to send. `broadcastedFooBar` is the value last acknowledged by the device.
### 3. Register the parameter for match-checking — `Device.kt`
Add one line to `allSettingsMatch()`:
```kotlin
fun allSettingsMatch(): Boolean =
paramMatches(team, broadcastedTeam) &&
paramMatches(maxHealth, broadcastedMaxHealth) &&
paramMatches(specialWeaponsOnReentry, broadcastedSpecialWeaponsOnReentry) &&
paramMatches(fooBar, broadcastedFooBar) // ← add this
```
### 4. Register the parameter for configure confirmation — `StateMachineViewModel.kt`
In `configureDevices()`, two `confirmOnSuccess` lambdas are built in parallel with the packet list. Each lambda is applied to the device immediately when its specific packet is ACK'd. Add `broadcastedFooBar` to whichever lambda corresponds to the packet that carries `PARAMETER_KEY_FOO_BAR`:
```kotlin
confirmOnSuccess += { d -> d.copy(
broadcastedTeam = d.team ?: d.broadcastedTeam,
broadcastedMaxHealth = d.maxHealth ?: d.broadcastedMaxHealth,
broadcastedFooBar = d.fooBar ?: d.broadcastedFooBar // ← add this
)}
```
This ensures a failed packet never blocks confirmation of packets that succeeded.
### 5. Preserve both fields when refreshing a device — `StateMachineViewModel.kt`
`addOrRefreshDevice()` merges incoming scan data with the existing device record. Add two lines following the same pattern as the other parameters:
```kotlin
newDevice.fooBar = oldDevice.fooBar ?: newDevice.fooBar
newDevice.broadcastedFooBar = newDevice.broadcastedFooBar ?: oldDevice.broadcastedFooBar
```
### 6. Initialize and send the parameter — `StateMachineViewModel.kt`
**Initialize** `fooBar` when a Hello packet is received (typically from game config defaults, same as `maxHealth`):
```kotlin
scannedDevice.fooBar = _currentGameConfig.value.fooBar
```
**Send** it during configure. In `configureDevices()`, the `flatMap` builds one `Pair<String, Packet.Parameters>` per BLE packet. Each Parameters packet has two key-value slots. Either fill an empty slot in an existing packet or add a new one:
```kotlin
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
Packet.PARAMETER_KEY_FOO_BAR, device.fooBar ?: gameCfg.fooBar,
Packet.PARAMETER_KEY_NONE, 0))
```
If the device also broadcasts `fooBar` in its Status packets, parse it in the Status handler and set `scannedDevice.broadcastedFooBar` there (same as `broadcastedTeam`). This provides an ongoing truth-check from the device in addition to the immediate confirmation from the ACK packet.
### 7. Add an update function — `StateMachineViewModel.kt`
```kotlin
fun updateDeviceFooBar(deviceAddress: String, value: Int) =
updateDeviceField(deviceAddress) { it.copy(fooBar = value) }
```
`updateDeviceField` handles the `_devices` update and sets `_pendingApply = true` automatically.
### 8. Add a UI field to the device detail dialog — `KonfiguratorActivity.kt`
In `DeviceDetailDialog`, add a state variable and an `IntegerTextField`:
```kotlin
var fooBarText by remember { mutableStateOf(device.fooBar?.toString() ?: "") }
var fooBarError by remember { mutableStateOf(false) }
IntegerTextField(
value = fooBarText,
onValueChange = { fooBarText = it; fooBarError = false },
label = "Foo Bar",
isError = fooBarError
)
```
Update the `onSave` lambda signature to include the new value, validate it in the Save button, and call `stateMachine.updateDeviceFooBar(device.address, fooBar)` alongside the other update calls.
## Dependencies
- Jetpack Compose (Material3)
- ViewModel + Compose integration
- BLE (BluetoothLeScanner, BluetoothLeAdvertiser)
- SoundPool (audio feedback)

View file

@ -0,0 +1,36 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "club.clubk.ktag.apps.konfigurator"
compileSdk = 36
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
}
dependencies {
implementation(project(":core"))
implementation(project(":shared-services"))
implementation("androidx.preference:preference:1.2.1")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.startup)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

View file

@ -0,0 +1,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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>
<activity
android:name=".KonfiguratorActivity"
android:exported="false"
android:label="Konfigurator" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="club.clubk.ktag.apps.konfigurator.KonfiguratorInitializer"
android:value="androidx.startup" />
</provider>
</application>
</manifest>

View file

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

View file

@ -0,0 +1,336 @@
package club.clubk.ktag.apps.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 club.clubk.ktag.apps.core.ble.Packet
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
}
@SuppressLint("MissingPermission")
suspend fun configureDevice(configPacket: Packet.Parameters, targetAddress: String): 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")
if (Packet.IsKTagParametersPacket(result)) {
val scannedParams = Packet.Parameters(result)
Log.d(TAG_BLE_SCAN, "Parameter packet scanned, checking for parameter match...")
if (result.device.address == targetAddress
&& scannedParams.subtype == Packet.PARAMETER_SUBTYPE_ACKNOWLEDGE_CHANGE
&& scannedParams.key1 == configPacket.key1
&& scannedParams.value1 == configPacket.value1
&& scannedParams.key2 == configPacket.key2
&& scannedParams.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)
}
}
}
// Stop any in-progress regular scan, then start the ACK scan
// directly — bypassing startScanning's holdoff, which is the same
// duration as the configure timeout and would always cause a miss.
scanStartJob?.cancel()
internalStopScan()
val ackFilter = ScanFilter.Builder()
.setManufacturerData(0xFFFF, byteArrayOf(0x4B, 0x54, 0x61, 0x67))
.build()
val ackSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
try {
bluetoothLeScanner?.startScan(listOf(ackFilter), ackSettings, tempScanCallback!!)
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for ACK scan", e)
if (!advertisementDetected.isCompleted) advertisementDetected.complete(false)
}
}
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 $targetAddress...")
this@BleManager.startAdvertising(configPacket.GetBytes(),
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 $targetAddress")
false
} finally {
Log.d(TAG_BLE_SCAN, "Cleaning up configureDevice resources for $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() }
// Stop the ACK scan directly so we don't stamp a new holdoff timestamp
// between consecutive configure packets.
tempScanCallback?.let {
try {
bluetoothLeScanner?.stopScan(it)
} catch (e: SecurityException) {
Log.e(TAG_BLE_SCAN, "Permission missing for stopping ACK scan", e)
}
}
}
}
}
}

View file

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

View file

@ -0,0 +1,46 @@
package club.clubk.ktag.apps.konfigurator
import club.clubk.ktag.apps.core.ble.Packet
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 broadcastedTeam: Int? = null,
var playerID : Int? = null,
var deviceState: DeviceState? = null,
var systemkMajorVersion: Int? = null,
var systemkMinorVersion: Int? = null,
var maxHealth: Int? = null,
var broadcastedMaxHealth: Int? = null,
var specialWeaponsOnReentry: Int? = null,
var broadcastedSpecialWeaponsOnReentry: Int? = null
) {
fun deviceTypeName(): String {
return Packet.getDeviceTypeName(deviceType ?: -1)
}
/** True when every configured parameter has been acknowledged by the device. */
fun allSettingsMatch(): Boolean =
paramMatches(team, broadcastedTeam) &&
paramMatches(maxHealth, broadcastedMaxHealth) &&
paramMatches(specialWeaponsOnReentry, broadcastedSpecialWeaponsOnReentry)
fun isOutdatedSystemK(): Boolean {
val major = systemkMajorVersion ?: return false
val minor = systemkMinorVersion ?: return false
return major < MinSystemKVersion.MAJOR || (major == MinSystemKVersion.MAJOR && minor < MinSystemKVersion.MINOR)
}
fun SystemKVersionString(): String {
val major = systemkMajorVersion ?: return "Unknown"
val minor = systemkMinorVersion ?: return "Unknown"
return "$major.${minor.toString().padStart(2, '0')}"
}
}
/** A device parameter matches when it has been acknowledged and equals the desired value. */
private fun paramMatches(desired: Int?, broadcasted: Int?) = broadcasted != null && desired == broadcasted

View file

@ -0,0 +1,8 @@
package club.clubk.ktag.apps.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.apps.konfigurator
data class GameConfig(
var gameDurationMin: Int = 10,
var timeUntilCountdownS: Int = 30,
var numRounds: Int = 2,
var maxHealth: Int = 10,
var specialWeaponsOnReentry: Int = 1
)

View file

@ -0,0 +1,13 @@
package club.clubk.ktag.apps.konfigurator
import android.content.Context
import androidx.startup.Initializer
import club.clubk.ktag.apps.core.SubAppRegistry
class KonfiguratorInitializer : Initializer<Unit> {
override fun create(context: Context) {
SubAppRegistry.register(KonfiguratorSubApp())
}
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

View file

@ -0,0 +1,15 @@
package club.clubk.ktag.apps.konfigurator
import android.content.Context
import android.content.Intent
import club.clubk.ktag.apps.core.SubApp
class KonfiguratorSubApp : SubApp {
override val id = "konfigurator"
override val name = "Konfigurator"
override val icon = R.drawable.konfigurator
override fun createIntent(context: Context): Intent {
return Intent(context, KonfiguratorActivity::class.java)
}
}

View file

@ -0,0 +1,72 @@
package club.clubk.ktag.apps.konfigurator
import android.util.Log
import club.clubk.ktag.apps.core.ble.Packet
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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 Pair<String, Packet.Parameters> 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<Pair<String, Packet.Parameters>>,
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 ((targetAddress, configPacket) in deviceConfigs) {
var success = false
try {
bleOperationMutex.withLock {
Log.i(
"MultiDeviceConfigurator",
"Attempting to configure $targetAddress"
)
success = bleManager.configureDevice(configPacket, targetAddress)
}
} catch (e: Exception) {
Log.e(
"MultiDeviceConfigurator",
"Exception configuring $targetAddress",
e
)
success = false
}
results[targetAddress] = success
configuredCount++
onDeviceConfigured(targetAddress, success)
onProgressUpdate(configuredCount, totalCount)
if (success) {
Log.i(
"MultiDeviceConfigurator",
"Successfully configured $targetAddress"
)
} else {
Log.w(
"MultiDeviceConfigurator",
"Failed to configure $targetAddress"
)
}
}
return results
}
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.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,60 @@
package club.clubk.ktag.apps.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
if (isLoaded) {
playDoubleChirp()
}
}
// 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,389 @@
package club.clubk.ktag.apps.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.preference.PreferenceManager
import club.clubk.ktag.apps.sharedservices.DeviceInfo
import club.clubk.ktag.apps.sharedservices.DeviceInfoMqttSync
import club.clubk.ktag.apps.sharedservices.DeviceInfoRepository
import club.clubk.ktag.apps.sharedservices.GamePreferenceKeys
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import club.clubk.ktag.apps.core.HexUtils
import club.clubk.ktag.apps.core.ble.Packet
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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.GameSettings)
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 _pendingApply = MutableStateFlow(true)
val pendingApply = _pendingApply.asStateFlow()
private val _isConfiguring = MutableStateFlow(false)
val isConfiguring = _isConfiguring.asStateFlow()
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context)
private val _currentGameConfig = MutableStateFlow(
GameConfig(
gameDurationMin = sharedPrefs.getString(GamePreferenceKeys.GAME_DURATION, GamePreferenceKeys.DEFAULT_GAME_DURATION)?.toIntOrNull()
?: GamePreferenceKeys.DEFAULT_GAME_DURATION.toInt(),
timeUntilCountdownS = sharedPrefs.getString(GamePreferenceKeys.TIME_UNTIL_COUNTDOWN, GamePreferenceKeys.DEFAULT_TIME_UNTIL_COUNTDOWN)?.toIntOrNull()
?: GamePreferenceKeys.DEFAULT_TIME_UNTIL_COUNTDOWN.toInt()
)
)
val currentGameConfig = _currentGameConfig.asStateFlow()
val gameLengthMs: Long get() = _currentGameConfig.value.gameDurationMin.toLong() * 60 * 1000
val pregameLengthMs: Long get() = _currentGameConfig.value.timeUntilCountdownS.toLong() * 1000
// 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 multiDeviceConfigurator = MultiDeviceConfigurator(bleManager)
private val soundManager = SoundManager(context)
private val deviceInfoRepository = DeviceInfoRepository.getInstance(context)
private val mqttSync = DeviceInfoMqttSync(deviceInfoRepository)
init {
mqttSync.connect()
}
override fun onCleared() {
mqttSync.cleanup()
soundManager.release()
super.onCleared()
}
@SuppressLint("MissingPermission")
private val scanCallbackDefault = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (Packet.IsKTagPacket(result)) {
val scannedDevice = Device(address = result.device.address)
when {
Packet.IsKTagHelloPacket(result) -> {
val hello = Packet.Hello(result)
scannedDevice.name = hello.device_name
scannedDevice.deviceType = hello.device_type
scannedDevice.team = hello.team_ID.toInt()
scannedDevice.broadcastedTeam = hello.team_ID.toInt()
scannedDevice.deviceState = DeviceState.Configurable
scannedDevice.systemkMajorVersion = hello.systemK_major_version
scannedDevice.systemkMinorVersion = hello.systemK_minor_version
scannedDevice.maxHealth = _currentGameConfig.value.maxHealth
scannedDevice.specialWeaponsOnReentry = _currentGameConfig.value.specialWeaponsOnReentry
addOrRefreshDevice(scannedDevice)
val info = DeviceInfo(
name = hello.device_name,
deviceType = hello.device_type,
deviceTypeName = Packet.getDeviceTypeName(hello.device_type),
majorVersion = hello.systemK_major_version,
minorVersion = hello.systemK_minor_version
)
if (deviceInfoRepository.setInfo(scannedDevice.address, info)) {
mqttSync.publishCurrentInfo()
}
}
Packet.IsKTagStatusPacket(result) -> {
val status = Packet.Status(result)
scannedDevice.broadcastedTeam = status.team_ID.toInt()
when (status.getSystemK_top_level_state()) {
3 -> scannedDevice.deviceState = DeviceState.Ready
7 -> scannedDevice.deviceState = DeviceState.Playing
9 -> scannedDevice.deviceState = DeviceState.WrapUp
}
addOrRefreshDevice(scannedDevice)
}
}
}
}
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 {
val oldDevice = currentDevices[index]
newDevice.name = oldDevice.name
newDevice.deviceType = oldDevice.deviceType ?: newDevice.deviceType
newDevice.team = oldDevice.team ?: newDevice.team
newDevice.broadcastedTeam = newDevice.broadcastedTeam ?: oldDevice.broadcastedTeam
newDevice.playerID = oldDevice.playerID ?: newDevice.playerID
newDevice.deviceState = newDevice.deviceState ?: oldDevice.deviceState
newDevice.systemkMajorVersion = newDevice.systemkMajorVersion ?: oldDevice.systemkMajorVersion
newDevice.systemkMinorVersion = newDevice.systemkMinorVersion ?: oldDevice.systemkMinorVersion
newDevice.maxHealth = oldDevice.maxHealth ?: newDevice.maxHealth
newDevice.broadcastedMaxHealth = newDevice.broadcastedMaxHealth ?: oldDevice.broadcastedMaxHealth
newDevice.specialWeaponsOnReentry = oldDevice.specialWeaponsOnReentry ?: newDevice.specialWeaponsOnReentry
newDevice.broadcastedSpecialWeaponsOnReentry = newDevice.broadcastedSpecialWeaponsOnReentry ?: oldDevice.broadcastedSpecialWeaponsOnReentry
currentDevices[index] = newDevice
}
_devices.value = currentDevices
Log.d("AddRefresh", _devices.value.toString())
_allDevicesReady.value = allDevicesReady()
Log.d("AddRefresh", _allDevicesReady.value.toString())
}
/** Applies [update] to the device with the given [address] and marks settings as pending. */
private fun updateDeviceField(address: String, update: (Device) -> Device) {
_devices.update { list -> list.map { if (it.address == address) update(it) else it } }
_pendingApply.value = true
}
fun updateDeviceTeam(deviceAddress: String, newTeam: Int) {
updateDeviceField(deviceAddress) { it.copy(team = newTeam) }
_allDevicesReady.value = allDevicesReady()
}
fun renameDevice(address: String, name: String) {
deviceInfoRepository.setName(address, name)
mqttSync.publishCurrentInfo()
}
fun getDeviceUserDefinedName(address: String): String {
return deviceInfoRepository.getInfo(address)?.userDefinedName ?: ""
}
fun updateDeviceMaxHealth(deviceAddress: String, value: Int) =
updateDeviceField(deviceAddress) { it.copy(maxHealth = value) }
fun updateDeviceSpecialWeapons(deviceAddress: String, value: Int) =
updateDeviceField(deviceAddress) { it.copy(specialWeaponsOnReentry = value) }
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
}
private var readyRetryJob: Job? = null
fun readyAllDevices() {
readyRetryJob?.cancel()
readyRetryJob = viewModelScope.launch {
while (_appState.value is AppState.TeamSettings && !_allDevicesReady.value) {
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
val eventPacket = Packet.Event(broadcastAddr, Packet.EVENT_CONFIGURED, 0)
bleManager.startAdvertising(eventPacket.GetBytes(), advertisingCallbackDefault)
delay(2000)
}
}
}
fun updateGameConfig(newGameConfig: GameConfig) {
_currentGameConfig.value = newGameConfig
sharedPrefs.edit()
.putString(GamePreferenceKeys.GAME_DURATION, newGameConfig.gameDurationMin.toString())
.putString(GamePreferenceKeys.TIME_UNTIL_COUNTDOWN, newGameConfig.timeUntilCountdownS.toString())
.apply()
}
/**
* Initiates the configuration of all devices using the MultiDeviceConfigurator.
*/
fun configureDevices() {
val currentDevicesToConfigure = _devices.value
val gameCfg = _currentGameConfig.value
if (currentDevicesToConfigure.isEmpty()) {
Log.i(TAG_STATE_MACHINE, "No devices eligible for configuration.")
_configurationProgress.value = ConfigurationProgress(0, 0, true)
return
}
// Build the packet list and a parallel list of confirmation lambdas.
// Each lambda is applied to the device when its specific packet is ACK'd,
// so a failed packet never blocks confirmation of packets that did succeed.
val confirmOnSuccess = mutableListOf<(Device) -> Device>()
val deviceConfigPackets = currentDevicesToConfigure.flatMap { device ->
val targetAddr = HexUtils.hexStringToByteArray(device.address, ':')
val teamId = device.team ?: 0
val health = device.maxHealth ?: gameCfg.maxHealth
val specWeapons = device.specialWeaponsOnReentry ?: gameCfg.specialWeaponsOnReentry
confirmOnSuccess += { d -> d.copy(
broadcastedTeam = d.team ?: d.broadcastedTeam,
broadcastedMaxHealth = d.maxHealth ?: d.broadcastedMaxHealth
)}
confirmOnSuccess += { d -> d.copy(
broadcastedSpecialWeaponsOnReentry = d.specialWeaponsOnReentry ?: d.broadcastedSpecialWeaponsOnReentry
)}
listOf(
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
Packet.PARAMETER_KEY_TEAM_ID, teamId,
Packet.PARAMETER_KEY_MAX_HEALTH, health)),
Pair(device.address, Packet.Parameters(targetAddr, Packet.PARAMETER_SUBTYPE_REQUEST_CHANGE,
Packet.PARAMETER_KEY_SPECIAL_WEAPONS_ON_REENTRY, specWeapons,
Packet.PARAMETER_KEY_NONE, 0))
)
}
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: ${deviceConfigPackets.size} packets for ${currentDevicesToConfigure.size} devices.")
readyRetryJob?.cancel()
_isConfiguring.value = true
_pendingApply.value = false
_deviceConfigStatus.value = emptyMap() // Reset status for the new session
_configurationProgress.value = ConfigurationProgress(0, deviceConfigPackets.size, false)
viewModelScope.launch {
var packetIndex = 0
val results = multiDeviceConfigurator.configureMultipleDevices(
deviceConfigs = deviceConfigPackets,
onDeviceConfigured = { deviceAddress, success ->
val confirm = confirmOnSuccess.getOrNull(packetIndex++)
Log.i(TAG_STATE_MACHINE, "Device $deviceAddress packet ${packetIndex - 1} configured: $success")
_deviceConfigStatus.update { it + (deviceAddress to success) }
if (success && confirm != null) {
_devices.update { list ->
list.map { device ->
if (device.address == deviceAddress) confirm(device) else device
}
}
}
},
onProgressUpdate = { configuredCount, totalCount ->
Log.i(TAG_STATE_MACHINE, "Configuration progress: $configuredCount/$totalCount")
_configurationProgress.value = ConfigurationProgress(configuredCount, totalCount, configuredCount == totalCount)
}
)
// 3. Process overall results
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).")
}
// Resume device discovery. configureDevice() stops scanning in its finally block.
bleManager.startScanning(scanCallbackDefault)
_isConfiguring.value = false
}
}
fun nextState() {
soundManager.playDoubleChirp()
when (_appState.value) {
is AppState.GameSettings -> {
_appState.value = AppState.TeamSettings
_pendingApply.value = true
bleManager.startScanning(scanCallbackDefault)
}
is AppState.TeamSettings -> {
readyRetryJob?.cancel()
_appState.value = AppState.PregameTimer
val instigatePacket = Packet.InstigatingGame(
gameLengthMs.toInt(),
pregameLengthMs.toInt()
)
bleManager.stopScanning()
bleManager.startAdvertising(instigatePacket.GetBytes(), advertisingCallbackDefault)
}
is AppState.PregameTimer -> {
_appState.value = AppState.Countdown
bleManager.stopAdvertising()
}
is AppState.Countdown -> {
_appState.value = AppState.GameTimer
}
is AppState.GameTimer -> {
_appState.value = AppState.WrapUp
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
val gameOverPacket = Packet.Event(broadcastAddr, Packet.EVENT_GAME_OVER, 0)
bleManager.startAdvertising(gameOverPacket.GetBytes(), advertisingCallbackDefault)
}
AppState.WrapUp -> {
_appState.value = AppState.GameSettings
val broadcastAddr = HexUtils.hexStringToByteArray("FF:FF:FF:FF:FF:FF", ':')
val wrapupCompletePacket = Packet.Event(broadcastAddr, Packet.EVENT_WRAPUP_COMPLETE, 2)
bleManager.startAdvertising(wrapupCompletePacket.GetBytes(), advertisingCallbackDefault)
}
}
}
}

View file

@ -0,0 +1,10 @@
package club.clubk.ktag.apps.konfigurator
/** Minimum SystemK firmware version required for full Konfigurator support. */
object MinSystemKVersion {
const val MAJOR = 1
const val MINOR = 2
/** Human-readable form, e.g. "1.02". */
val display: String get() = "$MAJOR.${MINOR.toString().padStart(2, '0')}"
}

View file

@ -0,0 +1,11 @@
package club.clubk.ktag.apps.konfigurator.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,29 @@
package club.clubk.ktag.apps.konfigurator.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
import club.clubk.ktag.apps.core.ui.theme.Typography
private val KTagColorScheme = darkColorScheme(
primary = KTagYellow,
onPrimary = Color.Black,
secondary = KTagGreen,
onSecondary = Color.Black,
background = Color.Black,
surface = Color.Black,
onBackground = Color.White,
onSurface = Color.White,
)
@Composable
fun KonfiguratorTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = KTagColorScheme,
typography = Typography,
content = content
)
}

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,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

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

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Konfigurator</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Konfigurator" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>