From 09f593dcbb263f55ebad05df19553aeea9d40697 Mon Sep 17 00:00:00 2001 From: i12bp8 Date: Tue, 7 Apr 2026 19:58:34 +0200 Subject: [PATCH] TagTinker V1.3 Android Companion, Extra Settings, ESL Type Detection, Bug Fixes --- .gitignore | 7 + android-companion/.gitignore | 4 + android-companion/app/build.gradle.kts | 50 + android-companion/app/proguard-rules.pro | 2 + .../app/src/main/AndroidManifest.xml | 23 + .../tagtinker/companion/MainActivity.kt | 1649 +++++++++++++++++ android-companion/build.gradle.kts | 4 + android-companion/gradle.properties | 5 + android-companion/settings.gradle.kts | 18 + application.fam | 4 +- ir/tagtinker_ir.c | 171 +- ir/tagtinker_ir.h | 36 +- protocol/tagtinker_proto.c | 44 +- protocol/tagtinker_proto.h | 36 +- scenes/tagtinker_scene.c | 6 +- scenes/tagtinker_scene.h | 11 +- scenes/tagtinker_scene_about.c | 762 +++++++- scenes/tagtinker_scene_barcode_input.c | 30 +- scenes/tagtinker_scene_broadcast_menu.c | 6 +- scenes/tagtinker_scene_image_upload.c | 104 -- scenes/tagtinker_scene_main_menu.c | 6 +- scenes/tagtinker_scene_preset_list.c | 12 +- scenes/tagtinker_scene_size_picker.c | 7 +- scenes/tagtinker_scene_synced_image_list.c | 155 ++ scenes/tagtinker_scene_target_actions.c | 100 +- scenes/tagtinker_scene_target_menu.c | 4 +- scenes/tagtinker_scene_text_input.c | 58 +- tagtinker_app.c | 235 ++- tagtinker_app.h | 142 +- views/numlock_input.c | 36 +- views/numlock_input.h | 11 +- views/tagtinker_font.h | 2 +- 32 files changed, 3243 insertions(+), 497 deletions(-) create mode 100644 android-companion/.gitignore create mode 100644 android-companion/app/build.gradle.kts create mode 100644 android-companion/app/proguard-rules.pro create mode 100644 android-companion/app/src/main/AndroidManifest.xml create mode 100644 android-companion/app/src/main/java/com/i12bp8/tagtinker/companion/MainActivity.kt create mode 100644 android-companion/build.gradle.kts create mode 100644 android-companion/gradle.properties create mode 100644 android-companion/settings.gradle.kts delete mode 100644 scenes/tagtinker_scene_image_upload.c create mode 100644 scenes/tagtinker_scene_synced_image_list.c diff --git a/.gitignore b/.gitignore index f22ef0f..bab5ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,17 @@ *.fap *.map dist/ +build/ # ufbt .ufbt/ +# Android companion +android-companion/.gradle/ +android-companion/build/ +android-companion/app/build/ +android-companion/local.properties + # IDE .vscode/ .idea/ diff --git a/android-companion/.gitignore b/android-companion/.gitignore new file mode 100644 index 0000000..a7e04a2 --- /dev/null +++ b/android-companion/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +app/build/ +local.properties diff --git a/android-companion/app/build.gradle.kts b/android-companion/app/build.gradle.kts new file mode 100644 index 0000000..2eb59f4 --- /dev/null +++ b/android-companion/app/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.i12bp8.tagtinker.companion" + compileSdk = 35 + + defaultConfig { + applicationId = "com.i12bp8.tagtinker.companion" + minSdk = 26 + targetSdk = 35 + versionCode = 13 + versionName = "1.3.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.2") + implementation(platform("androidx.compose:compose-bom:2024.09.02")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") +} diff --git a/android-companion/app/proguard-rules.pro b/android-companion/app/proguard-rules.pro new file mode 100644 index 0000000..e7292c1 --- /dev/null +++ b/android-companion/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# no custom rules +# Intentionally minimal for first prototype build. diff --git a/android-companion/app/src/main/AndroidManifest.xml b/android-companion/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1d6461f --- /dev/null +++ b/android-companion/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/android-companion/app/src/main/java/com/i12bp8/tagtinker/companion/MainActivity.kt b/android-companion/app/src/main/java/com/i12bp8/tagtinker/companion/MainActivity.kt new file mode 100644 index 0000000..44f60cf --- /dev/null +++ b/android-companion/app/src/main/java/com/i12bp8/tagtinker/companion/MainActivity.kt @@ -0,0 +1,1649 @@ +package com.i12bp8.tagtinker.companion + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +data class EslSize(val width: Int, val height: Int) + +@Composable +private fun CompactSliderRow( + label: String, + valueText: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodySmall) + Text(valueText, style = MaterialTheme.typography.bodySmall) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier.fillMaxWidth().heightIn(min = 20.dp), + ) + } +} + +class MainActivity : ComponentActivity() { + private data class ScanCandidate( + val device: BluetoothDevice, + var bestRssi: Int, + var hasService: Boolean, + var looksLikeFlipper: Boolean, + val bonded: Boolean, + ) + + private data class PendingLine(val line: String, val ackToken: String?, var attempts: Int = 0) + + private data class ImageUploadJob( + val jobId: String, + val barcode: String, + val width: Int, + val height: Int, + val page: Int, + val bmpBytes: ByteArray, + ) + + private data class SavedTarget( + val barcode: String, + val name: String, + val width: Int, + val height: Int, + ) + + private val serialServiceUuid = UUID.fromString("8FE5B3D5-2E7F-4A98-2A48-7ACC60FE0000") + private val serialWriteUuid = UUID.fromString("19ED82AE-ED21-4C9D-4145-228E62FE0000") + private val serialNotifyUuid = UUID.fromString("19ED82AE-ED21-4C9D-4145-228E61FE0000") + private val serialFlowUuid = UUID.fromString("19ED82AE-ED21-4C9D-4145-228E63FE0000") + private val serialStatusUuid = UUID.fromString("19ED82AE-ED21-4C9D-4145-228E64FE0000") + private val serialFlowWindow = 8192 + private val maxCompactChunkBytes = 381 + private val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private val logTag = "TagTinkerBle" + + private val bleHandler = Handler(Looper.getMainLooper()) + private val pickedImageUri = mutableStateOf(null) + private val txQueue = ArrayDeque() + + private var gatt: BluetoothGatt? = null + private var connectedAddress: String = "" + private var writeChars: List = emptyList() + private var notifyChars: List = emptyList() + private var writeChar: BluetoothGattCharacteristic? = null + private var flowChar: BluetoothGattCharacteristic? = null + private var lastBleStatus = "Connecting..." + private val bleRxAsciiBuffer = StringBuilder() + + private var bleStatusSink: (String) -> Unit = {} + private var bleReadySink: (Boolean) -> Unit = {} + private var targetListSink: (List) -> Unit = {} + private var selectedBarcodeSink: (String) -> Unit = {} + + private var bleReadyState = false + private var candidateSearchInProgress = false + private var pendingCandidates = mutableListOf() + private var notifySubscribeIndex = 0 + private var txInFlight: PendingLine? = null + private var txDoneCallback: ((Boolean, String) -> Unit)? = null + private var negotiatedMtu = 23 + @Volatile private var flowCredits = 0 + + @Volatile private var writeLatch: CountDownLatch? = null + @Volatile private var writeStatusOk: AtomicBoolean? = null + @Volatile private var mtuLatch: CountDownLatch? = null + @Volatile private var helloSeen = AtomicBoolean(false) + @Volatile private var pongSeen = AtomicBoolean(false) + private val remoteTargetsCache = mutableListOf() + private val remoteTargetsBuffer = mutableListOf() + private var targetSyncRequested = false + private var lastSelectedBarcode = "" + + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {} + private val imagePicker = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + pickedImageUri.value = uri + } + + private fun ensurePermissions() { + permissionLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_FINE_LOCATION, + ), + ) + } + + private fun inferSizeFromBarcode(barcode: String): EslSize { + if(barcode.length != 17) return EslSize(296, 128) + val typeCode = barcode.substring(12, 16).toIntOrNull() ?: return EslSize(296, 128) + return when(typeCode) { + 1300 -> EslSize(172, 72) + 1276 -> EslSize(320, 140) + 1275 -> EslSize(320, 192) + 1317, 1322, 1339, 1639 -> EslSize(152, 152) + 1318, 1327, 1324 -> EslSize(208, 112) + 1315, 1328, 1370, 1627, 1628, 1344 -> EslSize(296, 128) + 1348, 1349 -> EslSize(264, 176) + 1314, 1336 -> EslSize(400, 300) + 1351, 1353, 1354, 1371 -> EslSize(648, 480) + 1319, 1340, 1346 -> EslSize(800, 480) + else -> EslSize(296, 128) + } + } + + private fun isValidBarcode(barcode: String): Boolean = + barcode.length == 17 && + barcode.firstOrNull()?.isLetter() == true && + barcode.drop(1).all { it.isDigit() } + + private fun normalizeBarcode(raw: String): String { + val compact = raw.filter { it.isLetterOrDigit() }.uppercase() + return when { + compact.length >= 17 && + compact.first().isLetter() && + compact.substring(1).all { it.isDigit() } -> compact.take(17) + compact.length == 16 && compact.all { it.isDigit() } -> "N$compact" + else -> compact.take(17) + } + } + + private fun eslSizeForBarcode(barcode: String): EslSize { + val saved = remoteTargetsCache.firstOrNull { it.barcode == barcode } + if(saved != null && saved.width > 0 && saved.height > 0) { + return EslSize(saved.width, saved.height) + } + return inferSizeFromBarcode(barcode) + } + + private fun applyRemoteTargets(targets: List) { + remoteTargetsCache.clear() + remoteTargetsCache.addAll(targets) + runOnUiThread { targetListSink(targets) } + + val preferred = + when { + lastSelectedBarcode.isNotBlank() && + targets.any { it.barcode == lastSelectedBarcode } -> lastSelectedBarcode + targets.isNotEmpty() -> targets.first().barcode + else -> "" + } + if(preferred.isNotEmpty()) { + lastSelectedBarcode = preferred + runOnUiThread { selectedBarcodeSink(preferred) } + } + } + + private fun requestTargetsSync(force: Boolean = false) { + if(!bleReadyState || gatt == null) return + if(txInFlight != null || txQueue.isNotEmpty()) return + if(!force && targetSyncRequested && remoteTargetsCache.isNotEmpty()) return + + remoteTargetsBuffer.clear() + if(writeLine("TT_LIST_TARGETS")) { + targetSyncRequested = true + } + } + + private fun parseTargetLine(msg: String) { + val parts = msg.split('|', limit = 5) + if(parts.size != 5) return + + val barcode = normalizeBarcode(parts[1]) + if(!isValidBarcode(barcode)) return + + val fallbackSize = inferSizeFromBarcode(barcode) + val target = + SavedTarget( + barcode = barcode, + name = parts[2].trim().ifBlank { barcode }, + width = parts[3].toIntOrNull() ?: fallbackSize.width, + height = parts[4].toIntOrNull() ?: fallbackSize.height, + ) + + remoteTargetsBuffer.removeAll { it.barcode == barcode } + remoteTargetsBuffer.add(target) + } + + private fun loadBitmap(uri: Uri): Bitmap? = + contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + + private fun pricehaxThreshold(detail: Float): Int = + (128 + ((detail - 50f) * 1.2f).toInt()).coerceIn(0, 255) + + private fun transformBitmapForFrame( + bitmap: Bitmap, + target: EslSize, + detail: Float, + scale: Float, + offsetX: Float, + offsetY: Float, + ): Bitmap { + val frame = Bitmap.createBitmap(target.width, target.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(frame) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { isFilterBitmap = true } + canvas.drawColor(0xFFFFFFFF.toInt()) + + val fitScale = + minOf( + target.width.toFloat() / bitmap.width.toFloat(), + target.height.toFloat() / bitmap.height.toFloat(), + ) + val appliedScale = (fitScale * scale).coerceAtLeast(0.05f) + val drawWidth = (bitmap.width * appliedScale).toInt().coerceAtLeast(1) + val drawHeight = (bitmap.height * appliedScale).toInt().coerceAtLeast(1) + val left = ((target.width - drawWidth) / 2f) + (offsetX * target.width * 0.5f) + val top = ((target.height - drawHeight) / 2f) + (offsetY * target.height * 0.5f) + + canvas.drawBitmap( + bitmap, + Rect(0, 0, bitmap.width, bitmap.height), + RectF(left, top, left + drawWidth, top + drawHeight), + paint, + ) + + val threshold = pricehaxThreshold(detail) + val output = Bitmap.createBitmap(target.width, target.height, Bitmap.Config.ARGB_8888) + for(y in 0 until target.height) { + for(x in 0 until target.width) { + val color = frame.getPixel(x, y) + val gray = + ((0.299f * ((color shr 16) and 0xFF)) + + (0.587f * ((color shr 8) and 0xFF)) + + (0.114f * (color and 0xFF))).toInt() + output.setPixel( + x, + y, + if(gray > threshold) 0xFFFFFFFF.toInt() else 0xFF000000.toInt(), + ) + } + } + return output + } + + private fun toBwPricehax( + bitmap: Bitmap, + target: EslSize, + detail: Float, + scale: Float, + offsetX: Float, + offsetY: Float, + ): Bitmap = transformBitmapForFrame(bitmap, target, detail, scale, offsetX, offsetY) + + private fun writeIntLe(buffer: ByteArray, offset: Int, value: Int) { + buffer[offset + 0] = (value and 0xFF).toByte() + buffer[offset + 1] = ((value ushr 8) and 0xFF).toByte() + buffer[offset + 2] = ((value ushr 16) and 0xFF).toByte() + buffer[offset + 3] = ((value ushr 24) and 0xFF).toByte() + } + + private fun writeShortLe(buffer: ByteArray, offset: Int, value: Int) { + buffer[offset + 0] = (value and 0xFF).toByte() + buffer[offset + 1] = ((value ushr 8) and 0xFF).toByte() + } + + private fun buildMonochromeBmp(bitmap: Bitmap): ByteArray { + val width = bitmap.width + val height = bitmap.height + val rowStride = ((width + 31) / 32) * 4 + val pixelBytes = rowStride * height + val dataOffset = 14 + 40 + 8 + val fileSize = dataOffset + pixelBytes + val output = ByteArray(fileSize) + + output[0] = 'B'.code.toByte() + output[1] = 'M'.code.toByte() + writeIntLe(output, 2, fileSize) + writeIntLe(output, 10, dataOffset) + writeIntLe(output, 14, 40) + writeIntLe(output, 18, width) + writeIntLe(output, 22, height) + writeShortLe(output, 26, 1) + writeShortLe(output, 28, 1) + writeIntLe(output, 34, pixelBytes) + + output[54] = 0x00 + output[55] = 0x00 + output[56] = 0x00 + output[57] = 0x00 + output[58] = 0xFF.toByte() + output[59] = 0xFF.toByte() + output[60] = 0xFF.toByte() + output[61] = 0x00 + + var dst = dataOffset + for(y in height - 1 downTo 0) { + val rowStart = dst + var packedByte = 0 + var bitIndex = 0 + + for(x in 0 until width) { + val isWhite = (bitmap.getPixel(x, y) and 0x00FFFFFF) != 0 + if(isWhite) { + packedByte = packedByte or (1 shl (7 - bitIndex)) + } + bitIndex++ + if(bitIndex == 8) { + output[dst++] = packedByte.toByte() + packedByte = 0 + bitIndex = 0 + } + } + + if(bitIndex != 0) { + output[dst++] = packedByte.toByte() + } + + while(dst - rowStart < rowStride) { + output[dst++] = 0 + } + } + + return output + } + + private fun gattStatusString(status: Int): String = + when(status) { + BluetoothGatt.GATT_SUCCESS -> "GATT_SUCCESS" + BluetoothGatt.GATT_FAILURE -> "GATT_FAILURE" + else -> "status=$status" + } + + private fun canWrite(ch: BluetoothGattCharacteristic): Boolean = + (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 || + (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0 + + private fun canNotify(ch: BluetoothGattCharacteristic): Boolean = + (ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 || + (ch.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 + + private fun characteristicProps(ch: BluetoothGattCharacteristic): String { + val props = mutableListOf() + val value = ch.properties + if((value and BluetoothGattCharacteristic.PROPERTY_READ) != 0) props.add("READ") + if((value and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) props.add("WRITE") + if((value and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) { + props.add("WRITE_NR") + } + if((value and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) props.add("NOTIFY") + if((value and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) props.add("INDICATE") + return props.joinToString("|").ifEmpty { "none" } + } + + private fun setBleStatus(status: String) { + lastBleStatus = status + Log.d(logTag, status) + runOnUiThread { bleStatusSink(status) } + } + + private fun selectPreferredWriteChar(force: Boolean = false): BluetoothGattCharacteristic? { + if(!force && writeChar != null) return writeChar + val selected = writeChars.firstOrNull() + if(selected != null && selected != writeChar) { + Log.d( + logTag, + "Selected write ${selected.uuid} props=${characteristicProps(selected)}", + ) + } + writeChar = selected + return selected + } + + private fun findSerialWriteChars(service: BluetoothGattService): List { + service.getCharacteristic(serialWriteUuid)?.takeIf(::canWrite)?.let { exact -> + return listOf(exact) + } + return emptyList() + } + + private fun findSerialNotifyChars( + service: BluetoothGattService, + ): List { + val chars = mutableListOf() + service.getCharacteristic(serialNotifyUuid)?.takeIf(::canNotify)?.let { chars.add(it) } + service.getCharacteristic(serialFlowUuid)?.takeIf(::canNotify)?.let { + if(!chars.contains(it)) chars.add(it) + } + return chars + } + + private fun decodeFlowCredits(bytes: ByteArray?): Int? { + if(bytes == null || bytes.size < 4) return null + return ((bytes[0].toInt() and 0xFF) shl 24) or + ((bytes[1].toInt() and 0xFF) shl 16) or + ((bytes[2].toInt() and 0xFF) shl 8) or + (bytes[3].toInt() and 0xFF) + } + + private fun setReady(ready: Boolean) { + bleReadyState = ready + runOnUiThread { bleReadySink(ready) } + } + + private fun failSend(message: String) { + txQueue.clear() + txInFlight = null + val callback = txDoneCallback + txDoneCallback = null + callback?.invoke(false, message) + } + + @SuppressLint("MissingPermission") + private fun disconnectGatt() { + try { + gatt?.disconnect() + } catch(_: Throwable) { + } + try { + gatt?.close() + } catch(_: Throwable) { + } + gatt = null + negotiatedMtu = 23 + writeChars = emptyList() + notifyChars = emptyList() + notifySubscribeIndex = 0 + writeChar = null + flowChar = null + flowCredits = 0 + targetSyncRequested = false + remoteTargetsBuffer.clear() + bleRxAsciiBuffer.setLength(0) + txQueue.clear() + txInFlight = null + txDoneCallback = null + } + + private fun onBleLine(msg: String) { + if(msg == "TT_HELLO") { + helloSeen.set(true) + setBleStatus("BLE ready") + setReady(true) + return + } + + if(msg == "TT_PONG") { + pongSeen.set(true) + setBleStatus("BLE ready") + setReady(true) + return + } + + if(msg.startsWith("TT_TARGETS_BEGIN|")) { + remoteTargetsBuffer.clear() + return + } + + if(msg.startsWith("TT_TARGET|")) { + parseTargetLine(msg) + return + } + + if(msg == "TT_TARGETS_END") { + targetSyncRequested = false + applyRemoteTargets(remoteTargetsBuffer.toList()) + return + } + + if(msg == "AB" || msg == "AT" || msg == "AE" || + (msg.length == 5 && msg.startsWith("A"))) { + val current = txInFlight + if(current != null && current.ackToken == msg) { + txInFlight = null + pumpTxQueue() + setBleStatus( + when(msg) { + "AB" -> "Upload started" + "AT" -> "Target linked" + "AE" -> "Saved on Flipper" + else -> "Uploading..." + }, + ) + return + } + } + + if(msg.startsWith("TT_ACK|")) { + val token = msg.removePrefix("TT_ACK|").split('|').firstOrNull().orEmpty() + val current = txInFlight + if(current != null && current.ackToken == token) { + txInFlight = null + pumpTxQueue() + setBleStatus( + when(token) { + "BEGIN" -> "Upload started" + "END" -> "Saved on Flipper" + else -> "Uploading..." + }, + ) + return + } + } + + setBleStatus(msg) + } + + private fun onBlePayload(bytes: ByteArray?) { + val payload = bytes ?: return + var ignoredControl = false + + for(byte in payload) { + val value = byte.toInt() and 0xFF + when(value) { + '\n'.code, '\r'.code -> { + if(bleRxAsciiBuffer.isNotEmpty()) { + val line = bleRxAsciiBuffer.toString().trim() + bleRxAsciiBuffer.setLength(0) + if(line.isNotEmpty()) onBleLine(line) + } + } + in 0x20..0x7E -> bleRxAsciiBuffer.append(value.toChar()) + else -> ignoredControl = true + } + } + + if(ignoredControl) { + Log.d( + logTag, + "Ignored control bytes ${payload.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}", + ) + } + } + + private fun subscribeOne(g: BluetoothGatt, notify: BluetoothGattCharacteristic): Boolean { + if(!g.setCharacteristicNotification(notify, true)) return false + val cccd = notify.getDescriptor(cccdUuid) ?: return false + cccd.value = + if((notify.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 && + (notify.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0) { + BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + } else { + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } + return g.writeDescriptor(cccd) + } + + @SuppressLint("MissingPermission") + private fun tryNextCandidate() { + if(pendingCandidates.isEmpty()) { + candidateSearchInProgress = false + setBleStatus("No Flipper serial device found") + return + } + + val next = pendingCandidates.removeAt(0) + setBleStatus("Trying ${next.name ?: next.address}") + connectToDevice(next) + } + + @SuppressLint("MissingPermission") + private fun enableNotifications(g: BluetoothGatt, notifies: List) { + if(notifies.isEmpty()) { + setBleStatus("No notify characteristic") + setReady(false) + return + } + + notifySubscribeIndex = 0 + if(!subscribeOne(g, notifies.first())) { + setBleStatus("Notification subscribe failed") + setReady(false) + } + } + + private fun maxWritePayload(): Int = (negotiatedMtu - 3).coerceAtLeast(20) + + private fun consumeFlowCredits(count: Int) { + if(count <= 0) return + flowCredits = (flowCredits - count).coerceAtLeast(0) + Log.d(logTag, "Flow credits -> $flowCredits") + } + + private fun writeLineToCharacteristic(ch: BluetoothGattCharacteristic, line: String): Boolean { + val g = gatt ?: return false + val payload = (line + "\n").toByteArray(Charsets.UTF_8) + if(payload.size > maxWritePayload()) { + setBleStatus("BLE packet too large") + return false + } + if(flowCredits in 1 until payload.size) { + setBleStatus("Waiting for flow credit") + return false + } + if(flowCredits == 0) { + setBleStatus("Waiting for flow credit") + return false + } + + val supportsWrite = (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 + val supportsWriteNr = + (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0 + if(!supportsWrite && !supportsWriteNr) return false + + ch.value = payload + ch.writeType = + if(supportsWrite) { + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } else { + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + } + + if(ch.writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) { + val started = g.writeCharacteristic(ch) + if(started) consumeFlowCredits(payload.size) + return started + } + + val latch = CountDownLatch(1) + val ok = AtomicBoolean(false) + writeLatch = latch + writeStatusOk = ok + val started = g.writeCharacteristic(ch) + if(!started) { + writeLatch = null + writeStatusOk = null + setBleStatus("Write start failed") + return false + } + + val completed = latch.await(1500, TimeUnit.MILLISECONDS) + writeLatch = null + writeStatusOk = null + val success = completed && ok.get() + if(success) consumeFlowCredits(payload.size) + return success + } + + private fun orderedWriteChars(): List { + val ordered = mutableListOf() + writeChar?.let { ordered.add(it) } + writeChars.forEach { ch -> + if(!ordered.contains(ch)) ordered.add(ch) + } + return ordered + } + + private fun waitForPong(timeoutMs: Long): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while(System.currentTimeMillis() < deadline) { + if(pongSeen.get()) return true + Thread.sleep(80) + } + return pongSeen.get() + } + + private fun probeWriteCharacteristic(force: Boolean): Boolean { + val chars = orderedWriteChars() + if(chars.isEmpty()) return false + if(writeChar != null && !force) return true + + for(ch in chars) { + pongSeen.set(false) + if(!writeLineToCharacteristic(ch, "TT_PING")) continue + if(waitForPong(1800)) { + writeChar = ch + return true + } + } + + return false + } + + private fun writeLine(line: String): Boolean { + val selected = selectPreferredWriteChar() ?: return false + return writeLineToCharacteristic(selected, line) + } + + private fun verifyTransport() { + val candidates = writeChars.toList() + if(candidates.isEmpty()) { + setBleStatus("No write characteristic") + setReady(false) + return + } + + Thread { + setBleStatus("Finalizing connection...") + + repeat(12) { + if(helloSeen.get() || pongSeen.get()) { + setBleStatus("BLE ready") + setReady(true) + return@Thread + } + Thread.sleep(80) + } + + for(candidate in candidates) { + pongSeen.set(false) + val sent = writeLineToCharacteristic(candidate, "TT_PING") + if(!sent) continue + + repeat(16) { + if(pongSeen.get()) { + writeChar = candidate + setBleStatus("BLE ready") + setReady(true) + return@Thread + } + Thread.sleep(120) + } + } + + if(helloSeen.get()) { + setBleStatus("BLE ready") + setReady(true) + } else { + setBleStatus("Waiting for Phone Sync...") + try { + gatt?.disconnect() + } catch(_: Throwable) { + } + setReady(false) + } + }.start() + } + + private fun pumpTxQueue() { + if(txInFlight != null) return + if(txQueue.isEmpty()) { + val callback = txDoneCallback + txDoneCallback = null + callback?.invoke(true, "Saved on Flipper") + return + } + + val pending = txQueue.removeFirst() + txInFlight = pending + + fun attemptSend() { + val current = txInFlight ?: return + val ok = writeLine(current.line) + if(!ok) { + if(lastBleStatus == "Waiting for flow credit") { + bleHandler.postDelayed({ if(txInFlight == current) attemptSend() }, 250) + return + } + + current.attempts++ + if(current.attempts >= 6) { + failSend("Send timeout") + return + } + bleHandler.postDelayed({ if(txInFlight == current) attemptSend() }, 250) + return + } + + current.attempts++ + if(current.ackToken == null) { + txInFlight = null + pumpTxQueue() + return + } + + bleHandler.postDelayed({ + if(txInFlight == current) { + attemptSend() + } + }, 1200) + } + + attemptSend() + } + + private fun buildJobId(): String { + return ((System.currentTimeMillis() / 1000L) and 0xFFFFFFL) + .toString(16) + .uppercase() + .padStart(6, '0') + } + + private fun maxChunkBytesForMtu(mtu: Int): Int { + val lineBudget = (mtu - 4).coerceAtLeast(9) + val base64Chars = (lineBudget - 5).coerceAtLeast(4) + val alignedChars = base64Chars - (base64Chars % 4) + return ((alignedChars / 4) * 3).coerceAtLeast(3).coerceAtMost(maxCompactChunkBytes) + } + + private fun buildUploadQueue(job: ImageUploadJob, mtu: Int): List { + val lines = mutableListOf() + val begin = + "B%06X%03X%03X%01X%04X".format( + job.jobId.toInt(16), + job.width, + job.height, + job.page, + job.bmpBytes.size, + ) + lines.add(PendingLine(begin, "AB")) + lines.add(PendingLine("C${job.barcode}", "AT")) + + val chunkSize = maxChunkBytesForMtu(mtu) + val flags = Base64.NO_WRAP or Base64.URL_SAFE + var offset = 0 + var seq = 1 + while(offset < job.bmpBytes.size) { + val length = minOf(chunkSize, job.bmpBytes.size - offset) + val encoded = Base64.encodeToString(job.bmpBytes, offset, length, flags) + lines.add(PendingLine("D%04X%s".format(seq, encoded), "A%04X".format(seq))) + offset += length + seq++ + } + + lines.add(PendingLine("E${job.jobId}", "AE")) + return lines + } + + private fun startProtocolSend(job: ImageUploadJob, mtu: Int, onDone: (Boolean, String) -> Unit) { + txQueue.clear() + txInFlight = null + txDoneCallback = onDone + buildUploadQueue(job, mtu).forEach(txQueue::add) + pumpTxQueue() + } + + @SuppressLint("MissingPermission") + private fun requestPreferredMtu(preferred: Int): Int { + val currentGatt = gatt ?: return negotiatedMtu + if(negotiatedMtu >= preferred) return negotiatedMtu + + val latch = CountDownLatch(1) + mtuLatch = latch + val started = currentGatt.requestMtu(preferred) + if(started) { + latch.await(2500, TimeUnit.MILLISECONDS) + } + mtuLatch = null + return negotiatedMtu + } + + private fun sendSyncOverBle(job: ImageUploadJob): Boolean { + if(selectPreferredWriteChar() == null) { + setBleStatus("No write characteristic") + return false + } + + val mtu = requestPreferredMtu(247).coerceAtLeast(23) + repeat(2) { attempt -> + val okRef = AtomicBoolean(false) + val done = CountDownLatch(1) + runOnUiThread { + startProtocolSend(job, mtu) { ok, status -> + okRef.set(ok) + setBleStatus(status) + done.countDown() + } + } + + val timeoutSeconds = maxOf(45L, (job.bmpBytes.size / 300L) + 30L) + val waited = done.await(timeoutSeconds, TimeUnit.SECONDS) + if(waited && okRef.get()) return true + if(waited && !okRef.get()) return false + if(gatt == null) return false + + if(attempt == 0) { + probeWriteCharacteristic(true) + selectPreferredWriteChar() + setBleStatus("Retrying upload...") + } else { + setBleStatus("Upload timed out") + return false + } + } + + return false + } + + @SuppressLint("MissingPermission") + private fun connectToDevice(dev: BluetoothDevice) { + disconnectGatt() + helloSeen.set(false) + pongSeen.set(false) + connectedAddress = dev.address + setBleStatus("Connecting to ${dev.name ?: connectedAddress}") + setReady(false) + + gatt = + dev.connectGatt( + this, + false, + object : BluetoothGattCallback() { + override fun onConnectionStateChange( + g: BluetoothGatt, + status: Int, + newState: Int, + ) { + if(newState == BluetoothProfile.STATE_CONNECTED) { + connectedAddress = g.device?.address ?: connectedAddress + negotiatedMtu = 23 + try { + g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + } catch(_: Throwable) { + } + setBleStatus("Connected, discovering...") + g.discoverServices() + } else if(newState == BluetoothProfile.STATE_DISCONNECTED) { + val reason = "Disconnected (${gattStatusString(status)})" + val reconnecting = txDoneCallback == null && txInFlight == null && txQueue.isEmpty() + setBleStatus(if(reconnecting) "Waiting for Phone Sync..." else reason) + setReady(false) + if(txDoneCallback != null || txInFlight != null || txQueue.isNotEmpty()) { + failSend(reason) + } + if(gatt === g) disconnectGatt() + if(candidateSearchInProgress && pendingCandidates.isNotEmpty()) { + bleHandler.postDelayed({ tryNextCandidate() }, 400) + } else { + bleHandler.postDelayed({ + if(gatt == null) connectFlipper() + }, 1200) + } + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if(status != BluetoothGatt.GATT_SUCCESS) { + setBleStatus("Service discovery failed") + setReady(false) + return + } + + val service = g.getService(serialServiceUuid) + val writes = service?.let(::findSerialWriteChars).orEmpty() + val notifies = service?.let(::findSerialNotifyChars).orEmpty() + if(service == null || writes.isEmpty() || notifies.isEmpty()) { + setBleStatus("Serial UUIDs missing") + setReady(false) + if(candidateSearchInProgress) { + try { + g.disconnect() + } catch(_: Throwable) { + } + } + return + } + + writeChars = writes + notifyChars = notifies + writeChar = writes.firstOrNull() + flowChar = service.getCharacteristic(serialFlowUuid) + flowCredits = serialFlowWindow + targetSyncRequested = false + remoteTargetsBuffer.clear() + service.characteristics.forEach { ch -> + val role = + when(ch.uuid) { + serialWriteUuid -> "RX" + serialNotifyUuid -> "TX" + serialFlowUuid -> "FLOW" + serialStatusUuid -> "STATUS" + else -> "OTHER" + } + Log.d( + logTag, + "Service char $role ${ch.uuid} props=${characteristicProps(ch)}", + ) + } + Log.d( + logTag, + "Write chars: ${writes.joinToString { "${it.uuid}(${characteristicProps(it)})" }}", + ) + Log.d( + logTag, + "Notify chars: ${notifies.joinToString { "${it.uuid}(${characteristicProps(it)})" }}", + ) + candidateSearchInProgress = false + pendingCandidates.clear() + setBleStatus("Enabling notifications...") + enableNotifications(g, notifies) + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + if(descriptor.uuid != cccdUuid) return + if(status == BluetoothGatt.GATT_SUCCESS && + notifySubscribeIndex + 1 < notifyChars.size) { + notifySubscribeIndex += 1 + val nextNotify = notifyChars[notifySubscribeIndex] + if(!subscribeOne(g, nextNotify)) { + setBleStatus("Notification subscribe failed") + setReady(false) + } + return + } + + if(status == BluetoothGatt.GATT_SUCCESS) { + verifyTransport() + } else { + setBleStatus("Notification setup failed") + setReady(false) + } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + val payload = characteristic.value + when(characteristic.uuid) { + serialNotifyUuid -> runOnUiThread { onBlePayload(payload) } + serialFlowUuid -> { + val credits = decodeFlowCredits(payload) + if(credits != null) { + flowCredits = credits + Log.d(logTag, "Flow credits <- $credits") + } else { + Log.d( + logTag, + "Flow ctrl decode failed ${payload?.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}", + ) + } + } + serialStatusUuid -> Log.d( + logTag, + "Ignoring status notify ${payload?.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}", + ) + else -> Log.d(logTag, "Ignoring notify from ${characteristic.uuid}") + } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + when(characteristic.uuid) { + serialNotifyUuid -> runOnUiThread { onBlePayload(value) } + serialFlowUuid -> { + val credits = decodeFlowCredits(value) + if(credits != null) { + flowCredits = credits + Log.d(logTag, "Flow credits <- $credits") + } else { + Log.d( + logTag, + "Flow ctrl decode failed ${value.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}", + ) + } + } + serialStatusUuid -> Log.d( + logTag, + "Ignoring status notify ${value.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}", + ) + else -> Log.d(logTag, "Ignoring notify from ${characteristic.uuid}") + } + } + + override fun onCharacteristicWrite( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + writeStatusOk?.set(status == BluetoothGatt.GATT_SUCCESS) + writeLatch?.countDown() + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + if(status == BluetoothGatt.GATT_SUCCESS) { + negotiatedMtu = mtu + } + mtuLatch?.countDown() + } + }, + ) + } + + private fun scanRecordHasSerialService(result: ScanResult): Boolean = + result.scanRecord?.serviceUuids?.any { it.uuid == serialServiceUuid } == true + + @SuppressLint("MissingPermission") + private fun connectFlipper() { + val adapter = + (getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter ?: run { + setBleStatus("Bluetooth adapter unavailable") + return + } + + val hasScan = + checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED + val hasConnect = + checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + if(!hasScan || !hasConnect) { + setBleStatus("Requesting BLE permissions...") + ensurePermissions() + bleHandler.postDelayed({ connectFlipper() }, 1200) + return + } + + if(!adapter.isEnabled) { + setBleStatus("Bluetooth is off") + setReady(false) + return + } + + disconnectGatt() + bleHandler.removeCallbacksAndMessages(null) + candidateSearchInProgress = false + pendingCandidates.clear() + setBleStatus("Scanning...") + setReady(false) + + val scanner = adapter.bluetoothLeScanner ?: run { + setBleStatus("BLE scanner unavailable") + return + } + + val settings = + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + val serviceFilters = emptyList() + val candidates = linkedMapOf() + + adapter.bondedDevices?.forEach { dev -> + val address = dev.address + candidates[address] = + ScanCandidate( + device = dev, + bestRssi = Int.MIN_VALUE, + hasService = false, + looksLikeFlipper = dev.name?.contains("Flipper", ignoreCase = true) == true, + bonded = true, + ) + } + + val callback = + object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val dev = result.device ?: return + val address = dev.address + val hasService = scanRecordHasSerialService(result) + val looksLikeFlipper = dev.name?.contains("Flipper", ignoreCase = true) == true + if(hasService) { + scanner.stopScan(this) + bleHandler.removeCallbacksAndMessages(null) + candidateSearchInProgress = false + pendingCandidates.clear() + setBleStatus("Found Flipper service") + connectToDevice(dev) + return + } + + val current = candidates[address] + if(current == null) { + candidates[address] = + ScanCandidate( + device = dev, + bestRssi = result.rssi, + hasService = false, + looksLikeFlipper = looksLikeFlipper, + bonded = false, + ) + } else { + if(result.rssi > current.bestRssi) current.bestRssi = result.rssi + current.hasService = current.hasService || hasService + current.looksLikeFlipper = current.looksLikeFlipper || looksLikeFlipper + } + } + + override fun onScanFailed(errorCode: Int) { + setBleStatus("Scan failed ($errorCode)") + } + } + + scanner.startScan(serviceFilters, settings, callback) + bleHandler.postDelayed({ + try { + scanner.stopScan(callback) + } catch(_: Throwable) { + } + + val ranked = + candidates + .values + .sortedWith( + compareByDescending { it.hasService } + .thenByDescending { it.bonded } + .thenByDescending { it.looksLikeFlipper } + .thenByDescending { it.bestRssi }, + ).take(6) + .map { it.device } + + if(ranked.isNotEmpty()) { + pendingCandidates = ranked.toMutableList() + candidateSearchInProgress = true + setBleStatus("Trying ${ranked.size} BLE candidates...") + tryNextCandidate() + } else { + setBleStatus("No Flipper found") + } + }, 3000) + } + + override fun onDestroy() { + super.onDestroy() + disconnectGatt() + } + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + ensurePermissions() + + val orange = Color(0xFFFF7A00) + val scheme = + darkColorScheme( + primary = orange, + onPrimary = Color.Black, + surface = Color(0xFF0A0A0A), + background = Color.Black, + onBackground = Color(0xFFF2F2F2), + onSurface = Color(0xFFF2F2F2), + ) + + setContent { + var bleState by remember { mutableStateOf("Connecting...") } + var bleReady by remember { mutableStateOf(false) } + var barcode by remember { mutableStateOf(lastSelectedBarcode) } + var savedTargets by remember { mutableStateOf>(emptyList()) } + var eslSize by remember { mutableStateOf(EslSize(296, 128)) } + var detail by remember { mutableStateOf(50f) } + var scale by remember { mutableStateOf(1.0f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var sending by remember { mutableStateOf(false) } + var original by remember { mutableStateOf(null) } + var preview by remember { mutableStateOf(null) } + var uploadBytes by remember { mutableStateOf(null) } + var stats by remember { mutableStateOf("No image") } + val scanner = remember { + val options = GmsBarcodeScannerOptions.Builder().enableAutoZoom().build() + GmsBarcodeScanning.getClient(this, options) + } + + MaterialTheme(colorScheme = scheme) { + LaunchedEffect(Unit) { + bleStatusSink = { bleState = it } + bleReadySink = { bleReady = it } + targetListSink = { savedTargets = it } + selectedBarcodeSink = { nextBarcode -> + barcode = nextBarcode + lastSelectedBarcode = nextBarcode + eslSize = eslSizeForBarcode(nextBarcode) + } + bleState = lastBleStatus + connectFlipper() + } + + LaunchedEffect(pickedImageUri.value) { + original = pickedImageUri.value?.let { loadBitmap(it) } + if(original == null) { + preview = null + uploadBytes = null + stats = "No image" + } + } + + LaunchedEffect(original, detail, scale, offsetX, offsetY, eslSize) { + val nextPreview = + original?.let { + toBwPricehax( + it, + eslSize, + detail, + scale, + offsetX, + offsetY, + ) + } + preview = nextPreview + uploadBytes = nextPreview?.let(::buildMonochromeBmp) + stats = + if(nextPreview != null && uploadBytes != null) { + "Upload ${uploadBytes!!.size} B | ${nextPreview.width}x${nextPreview.height}" + } else { + "No image" + } + } + + LaunchedEffect(barcode, savedTargets) { + if(barcode.isNotBlank()) { + eslSize = eslSizeForBarcode(barcode) + } + } + + val selectedTarget = savedTargets.firstOrNull { it.barcode == barcode } + + if(!bleReady) { + Scaffold( + containerColor = Color.Black, + ) { inner -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .padding(inner) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Connecting to Flipper...") + Text(bleState, color = orange, modifier = Modifier.padding(top = 8.dp)) + } + } + } else { + Scaffold( + containerColor = Color.Black, + ) { inner -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black) + .padding(inner) + .padding(12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFF101010)), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + scanner.startScan().addOnSuccessListener { code -> + barcode = normalizeBarcode(code.rawValue.orEmpty()) + lastSelectedBarcode = barcode + eslSize = eslSizeForBarcode(barcode) + } + }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = 10.dp), + ) { + Text("Scan") + } + Button( + onClick = { imagePicker.launch("image/*") }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = 10.dp), + ) { + Text("Photo") + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + when { + selectedTarget != null -> selectedTarget.name + barcode.isBlank() -> "No target selected" + else -> "New target" + }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + if(barcode.isBlank()) { + "Choose an existing target or scan a new one" + } else { + barcode + }, + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "${eslSize.width} x ${eslSize.height}", + color = orange, + style = MaterialTheme.typography.bodySmall, + ) + } + Text(stats, color = orange) + Card( + colors = CardDefaults.cardColors(containerColor = Color.Black), + shape = RoundedCornerShape(12.dp), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio( + eslSize.width.toFloat() / + eslSize.height.toFloat().coerceAtLeast(1f), + ) + .padding(6.dp), + contentAlignment = Alignment.Center, + ) { + if(preview != null) { + Image( + bitmap = preview!!.asImageBitmap(), + contentDescription = "Preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } else { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("No image selected") + } + } + } + } + CompactSliderRow( + label = "Detail", + valueText = detail.toInt().toString(), + value = detail, + onValueChange = { detail = it }, + valueRange = 0f..100f, + ) + CompactSliderRow( + label = "Zoom", + valueText = "${(scale * 100f).toInt()}%", + value = scale, + onValueChange = { scale = it }, + valueRange = 0.5f..2.5f, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + CompactSliderRow( + label = "Move X", + valueText = "${(offsetX * 100f).toInt()}%", + value = offsetX, + onValueChange = { offsetX = it }, + valueRange = -1f..1f, + ) + } + Column(modifier = Modifier.weight(1f)) { + CompactSliderRow( + label = "Move Y", + valueText = "${(offsetY * 100f).toInt()}%", + value = offsetY, + onValueChange = { offsetY = it }, + valueRange = -1f..1f, + ) + } + } + } + } + + Button( + onClick = { + if(!isValidBarcode(barcode)) { + Toast + .makeText( + this@MainActivity, + "Scan a valid barcode: Letter + 16 digits", + Toast.LENGTH_SHORT, + ).show() + return@Button + } + + val bytes = uploadBytes + if(bytes == null) { + Toast + .makeText( + this@MainActivity, + "Choose an image first", + Toast.LENGTH_SHORT, + ).show() + return@Button + } + + sending = true + Thread { + val job = + ImageUploadJob( + jobId = buildJobId(), + barcode = barcode, + width = eslSize.width, + height = eslSize.height, + page = 1, + bmpBytes = bytes, + ) + val ok = sendSyncOverBle(job) + runOnUiThread { + sending = false + if(ok && savedTargets.none { it.barcode == barcode }) { + val target = + SavedTarget( + barcode = barcode, + name = barcode, + width = eslSize.width, + height = eslSize.height, + ) + remoteTargetsCache.removeAll { it.barcode == barcode } + remoteTargetsCache.add(target) + savedTargets = savedTargets + listOf(target) + } + Toast + .makeText( + this@MainActivity, + if(ok) { + "Saved on Flipper" + } else { + "BLE send failed: $lastBleStatus" + }, + Toast.LENGTH_SHORT, + ).show() + } + }.start() + }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + Text(if(sending) "Uploading..." else "Send to Flipper") + } + Text( + bleState, + color = orange, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } +} diff --git a/android-companion/build.gradle.kts b/android-companion/build.gradle.kts new file mode 100644 index 0000000..017d909 --- /dev/null +++ b/android-companion/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android-companion/gradle.properties b/android-companion/gradle.properties new file mode 100644 index 0000000..8679d5b --- /dev/null +++ b/android-companion/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 diff --git a/android-companion/settings.gradle.kts b/android-companion/settings.gradle.kts new file mode 100644 index 0000000..d9230f6 --- /dev/null +++ b/android-companion/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TagTinkerBadgeCreator" +include(":app") diff --git a/application.fam b/application.fam index 5710438..6f9413f 100644 --- a/application.fam +++ b/application.fam @@ -9,7 +9,7 @@ App( fap_category="Infrared", fap_author="i12bp8", fap_description="Educational ESL study tool for owned hardware", - fap_version="1.1", + fap_version="1.3", sources=[ "tagtinker_app.c", "ir/tagtinker_ir.c", @@ -19,10 +19,10 @@ App( "scenes/tagtinker_scene_barcode_input.c", "scenes/tagtinker_scene_broadcast.c", "scenes/tagtinker_scene_broadcast_menu.c", - "scenes/tagtinker_scene_image_upload.c", "scenes/tagtinker_scene_image_options.c", "scenes/tagtinker_scene_main_menu.c", "scenes/tagtinker_scene_preset_list.c", + "scenes/tagtinker_scene_synced_image_list.c", "scenes/tagtinker_scene_settings.c", "scenes/tagtinker_scene_size_picker.c", "scenes/tagtinker_scene_target_actions.c", diff --git a/ir/tagtinker_ir.c b/ir/tagtinker_ir.c index be92d64..c392aa9 100644 --- a/ir/tagtinker_ir.c +++ b/ir/tagtinker_ir.c @@ -1,23 +1,8 @@ -/** - * TagTinker IR transmitter - implementation (v2) +/* + * IR transmitter. * - * Drives the Flipper Zero's built-in IR LEDs at ~1.255 MHz carrier - * for the ESL pulse-position modulation (PPM) protocol used here. - * - * v2 changes from v1: - * - Removed TIM2 ISR approach (TIM2 conflicts with Flipper's IR RX subsystem!) - * - Uses DWT cycle counter for precise timing instead (zero timer conflicts) - * - Properly handles TIM1 bus state (was crashing if already enabled) - * - Non-blocking repeat loop with cancellation support - * - * Architecture: - * TIM1 Channel 3N: 1.255 MHz PWM carrier on built-in IR LED (PB9) - * DWT->CYCCNT: Cycle-accurate timing for PPM symbol encoding - * - * CRITICAL: The built-in IR LED is on PB9 = TIM1_CH3N (complementary output). - * NOT CH3! The carrier is gated by toggling OC3M bits in TIM1->CCMR2 - * (which control both CH3 and CH3N). The firmware uses PWM2 mode. - * For CH3N (complementary), Force Inactive = LED off, PWM2 = carrier on. + * TIM1 CH3N drives the built-in IR LED carrier. + * DWT->CYCCNT handles the symbol timing so we do not need another timer. */ #include "tagtinker_ir.h" @@ -31,109 +16,68 @@ #include -/* ─── Carrier configuration ─── - * Target: 1.25 MHz carrier for ESL signaling - * Flipper: 64 MHz system clock, TIM1 PSC=0 - * ARR = 51-1 = 50 → 64MHz/51 = 1,254,901 Hz (+4.9 kHz off, within ±10kHz) - */ +/* Carrier setup for the built-in IR LED on TIM1 CH3N. */ #define CARRIER_TIM TIM1 #define CARRIER_ARR (51 - 1) -#define CARRIER_CCR 25 /* ~50% duty cycle */ +#define CARRIER_CCR 25 -/* ─── PP4 timing in CPU cycles (64 MHz) ─── - * - * Protocol reference: ESL Blaster FW03 ir.c, furrtek.org ESL page - * Base period t = 1/32768 Hz = 30.518 µs - * - * Burst duration: ~40 µs = 50 carrier cycles at 1.25 MHz - * ESL Blaster: 4 ticks × 10.08µs = 40.32 µs - * Our value: 40 µs × 64 = 2560 cycles - * - * Symbol gap durations (index = 2-bit symbol value): - * From ir.c pp4_steps ordering: - * pp4_steps[0] = 5 ticks → sym 00: 6 × 10.08 = 60.48 µs - * pp4_steps[1] = 23 ticks → sym 01: 24 × 10.08 = 241.92 µs - * pp4_steps[2] = 11 ticks → sym 10: 12 × 10.08 = 120.96 µs - * pp4_steps[3] = 17 ticks → sym 11: 18 × 10.08 = 181.44 µs - * - * Converting to 64 MHz cycles: - */ -#define PP4_BURST_CYCLES 2581 /* 40.33 µs */ +/* PP4 sends two bits per symbol. The gap selects the symbol value. */ +#define PP4_BURST_CYCLES 2581 static const uint32_t pp4_gap_cycles[4] = { - 3871, /* Symbol 00: 60.48 µs */ - 15483, /* Symbol 01: 241.92 µs */ - 7741, /* Symbol 10: 120.96 µs */ - 11612, /* Symbol 11: 181.44 µs */ + 3871, + 15483, + 7741, + 11612, }; -/* ─── PP16 timing in CPU cycles (64 MHz) ─── - * Derived from esl_blaster ir.c: TIM16 steps of 4us. - * Gap values (27us to 107us) mapped to 16 symbols. - * Burst: 21us * 64 = 1344 cycles. - */ +/* PP16 sends four bits per symbol. */ #define PP16_BURST_CYCLES 1344 static const uint32_t pp16_gap_cycles[16] = { - 1728, // 0000: 27µs - 3264, // 0001: 51µs - 2240, // 0010: 35µs - 2752, // 0011: 43µs - 9408, // 0100: 147µs - 7872, // 0101: 123µs - 8896, // 0110: 139µs - 8384, // 0111: 131µs - 5312, // 1000: 83µs - 3776, // 1001: 59µs - 4800, // 1010: 75µs - 4288, // 1011: 67µs - 5824, // 1100: 91µs - 7360, // 1101: 115µs - 6336, // 1110: 99µs - 6848 // 1111: 107µs + 1728, + 3264, + 2240, + 2752, + 9408, + 7872, + 8896, + 8384, + 5312, + 3776, + 4800, + 4288, + 5824, + 7360, + 6336, + 6848 }; -/* ─── Module state ─── */ static bool ir_initialized = false; static volatile bool ir_stop_requested = false; -/* ─── Carrier control (TIM1 OC3M register manipulation) ─── */ - static inline void carrier_on(void) { - /* OC3M = PWM Mode 2 (111) — matching Flipper firmware. - * For CH3N (complementary): PWM2 inverts → CH3N gets active-low PWM = carrier burst. - * This matches INFRARED_TX_CCMR_HIGH in furi_hal_infrared.c */ + /* PWM2 on CH3N gives us the carrier burst on the built-in LED. */ uint32_t ccmr2 = CARRIER_TIM->CCMR2; ccmr2 &= ~(TIM_CCMR2_OC3M); - ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM mode 2 */ + ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); CARRIER_TIM->CCMR2 = ccmr2; } static inline void carrier_off(void) { - /* OC3M = Force Inactive (100) — CH3N goes idle = LED off. - * This matches INFRARED_TX_CCMR_LOW in furi_hal_infrared.c */ + /* Force-inactive stops the carrier between symbols. */ uint32_t ccmr2 = CARRIER_TIM->CCMR2; ccmr2 &= ~(TIM_CCMR2_OC3M); ccmr2 |= TIM_CCMR2_OC3M_2; CARRIER_TIM->CCMR2 = ccmr2; } -/* ─── Cycle-accurate delay using DWT ─── */ - static inline void delay_cycles(uint32_t cycles) { uint32_t start = DWT->CYCCNT; while((DWT->CYCCNT - start) < cycles) { - /* Busy wait — handles wraparound via unsigned subtraction */ } } -/* ─── Send a single PP4-encoded frame ─── - * - * PPM on-air sequence for N symbols: - * [burst][gap₀][burst][gap₁]...[burst][gapₙ₋₁][burst] - * = N+1 bursts with N gaps, each gap encoding one 2-bit symbol. - * - * Data is sent LSB first, 2 bits at a time per byte (matching ir.c). - */ static void send_frame_pp4(const uint8_t* data, size_t len) { + /* PP4 walks each byte from least-significant bits upward, two bits at a time. */ for(size_t byte_idx = 0; byte_idx < len; byte_idx++) { uint8_t current_byte = data[byte_idx]; @@ -141,26 +85,21 @@ static void send_frame_pp4(const uint8_t* data, size_t len) { uint8_t symbol = current_byte & 0x03; current_byte >>= 2; - /* Burst (carrier ON) */ carrier_on(); delay_cycles(PP4_BURST_CYCLES); - /* Gap (carrier OFF) — duration encodes the symbol */ carrier_off(); delay_cycles(pp4_gap_cycles[symbol]); } } - /* Final burst (N+1th burst, required by PPM protocol) */ carrier_on(); delay_cycles(PP4_BURST_CYCLES); carrier_off(); } -/* ─── Send a single PP16-encoded frame ─── - * Encodes 4 bits per symbol, resulting in half the pulses of PP4. - */ static void send_frame_pp16(const uint8_t* data, size_t len) { + /* PP16 uses the same pattern, but four bits per symbol. */ for(size_t byte_idx = 0; byte_idx < len; byte_idx++) { uint8_t current_byte = data[byte_idx]; @@ -168,35 +107,28 @@ static void send_frame_pp16(const uint8_t* data, size_t len) { uint8_t symbol = current_byte & 0x0F; current_byte >>= 4; - /* Burst (carrier ON) */ carrier_on(); delay_cycles(PP16_BURST_CYCLES); - /* Gap (carrier OFF) */ carrier_off(); delay_cycles(pp16_gap_cycles[symbol]); } } - /* Final burst */ carrier_on(); delay_cycles(PP16_BURST_CYCLES); carrier_off(); } -/* ─── Public API ─── */ - void tagtinker_ir_init(void) { if(ir_initialized) return; - /* Safely claim TIM1: must handle case where it's already enabled - * by the firmware's IR subsystem. Bus enable asserts if already on! */ + /* Claim TIM1 from the stock IR stack before configuring our own carrier. */ if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { furi_hal_bus_disable(FuriHalBusTIM1); } furi_hal_bus_enable(FuriHalBusTIM1); - /* Configure GPIO for IR TX (built-in IR LEDs) */ furi_hal_gpio_init_ex( &gpio_infrared_tx, GpioModeAltFunctionPushPull, @@ -204,26 +136,17 @@ void tagtinker_ir_init(void) { GpioSpeedVeryHigh, GpioAltFn1TIM1); - /* Configure TIM1 as 1.255 MHz carrier */ LL_TIM_SetPrescaler(CARRIER_TIM, 0); LL_TIM_SetAutoReload(CARRIER_TIM, CARRIER_ARR); LL_TIM_SetCounter(CARRIER_TIM, 0); - /* Channel 3 config — controls both CH3 and CH3N outputs. - * The IR LED is on CH3N (PB9), so we must enable CC3NE. - * PWM2 mode with preload, matching the firmware. */ LL_TIM_OC_SetMode(CARRIER_TIM, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_PWM2); LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR); LL_TIM_OC_EnablePreload(CARRIER_TIM, LL_TIM_CHANNEL_CH3); - /* CRITICAL: Enable CH3N (complementary output), NOT CH3! - * IR LED is on PB9 = TIM1_CH3N. Without this, no IR output at all. */ LL_TIM_CC_EnableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); - - /* Main output enable (required for TIM1 advanced timer) */ LL_TIM_EnableAllOutputs(CARRIER_TIM); - /* Start timer but force carrier OFF initially */ carrier_off(); LL_TIM_EnableCounter(CARRIER_TIM); LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM); @@ -240,18 +163,15 @@ void tagtinker_ir_deinit(void) { tagtinker_ir_stop(); - /* Force carrier off and disable TIM1 */ carrier_off(); LL_TIM_DisableAllOutputs(CARRIER_TIM); LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N); LL_TIM_DisableCounter(CARRIER_TIM); - /* Reset TIM1 bus so firmware can reclaim it for normal IR */ if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) { furi_hal_bus_disable(FuriHalBusTIM1); } - /* Restore GPIO to safe state */ furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow); ir_initialized = false; @@ -263,17 +183,13 @@ bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw if(len == 0 || len > 255) return false; ir_stop_requested = false; - - // MSB of repeats indicates PP16 protocol! + bool is_pp16 = (repeats_raw & 0x8000) != 0; uint32_t repeats = repeats_raw & 0x7FFF; FURI_LOG_I("TagTinker", "TX start: %zu bytes, %lu repeats (PP%d), %u delay", len, repeats, is_pp16 ? 16 : 4, delay); - /* Transmit frame with repeats. - * Between repeats we yield briefly so FreeRTOS stays happy - * and the user can cancel via tagtinker_ir_stop(). */ for(uint32_t rep = 0; rep <= repeats; rep++) { if(ir_stop_requested) { FURI_LOG_I("TagTinker", "TX cancelled at repeat %lu", rep); @@ -281,34 +197,29 @@ bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw return false; } - /* Send one frame (interrupts stay enabled — DWT handles timing) */ if(is_pp16) { send_frame_pp16(data, len); } else { send_frame_pp4(data, len); } - /* Delay between repeats: delay × 500µs (matching ESL Blaster). - * ESL Blaster: TickCounter = RepeatDelay * 50 ticks, each ~10µs = 500µs per unit */ if(rep < repeats) { + /* Delay units are 500 us to match the ESL timing tools. */ uint32_t delay_us = (uint32_t)delay * 500; if(delay_us > 0) { uint32_t delay_ms_yield = delay_us / 1000; uint32_t delay_us_busy = delay_us % 1000; - - /* Yield to FreeRTOS for the bulk of the delay to allow the GUI to animate! */ + if(delay_ms_yield > 0) { furi_delay_ms(delay_ms_yield); } - - /* Busy loop only for the sub-millisecond remainder */ + if(delay_us_busy > 0) { - delay_cycles(delay_us_busy * 64); /* 64 cycles per µs */ + delay_cycles(delay_us_busy * 64); } } } - /* Fallback yield to FreeRTOS every 10 repeats if the delay parameter was 0 or 1 */ if(((uint32_t)delay * 500) < 1000 && (rep % 10) == 9) { furi_delay_ms(1); } @@ -319,7 +230,7 @@ bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw } bool tagtinker_ir_is_busy(void) { - return false; /* Transmit is blocking in this version */ + return false; } void tagtinker_ir_stop(void) { diff --git a/ir/tagtinker_ir.h b/ir/tagtinker_ir.h index a54644c..60c6cc5 100644 --- a/ir/tagtinker_ir.h +++ b/ir/tagtinker_ir.h @@ -1,12 +1,7 @@ -/** - * TagTinker IR transmitter - header +/* + * IR transmitter API. * - * Low-level IR transmitter for the ESL protocol used by this project. - * Drives the Flipper Zero's built-in IR LEDs at 1.255 MHz carrier - * by directly programming TIM1 registers, bypassing furi_hal_infrared. - * - * Uses DWT cycle counter for symbol timing (no timer conflicts). - * PP4 mode (2-bit symbols) for this POC. + * Sends already-built ESL frames over the built-in IR LED. */ #pragma once @@ -15,36 +10,13 @@ #include #include -/** - * Initialize the IR transmitter. - * Claims TIM1 for carrier, configures GPIO for IR output. - */ void tagtinker_ir_init(void); -/** - * Deinitialize the IR transmitter. - * Releases TIM1, restores GPIO state. - */ void tagtinker_ir_deinit(void); -/** - * Transmit a frame using PP4 modulation. - * Blocking call — returns when all repeats complete or cancelled. - * - * @param data frame bytes - * @param len byte count - * @param repeats times to repeat (0 = send once) - * @param delay inter-repeat delay (×500µs, 10 = 5ms like ESL Blaster) - * @return true if completed, false if cancelled/error - */ +/* The high bit of repeats selects PP16. The low 15 bits are the repeat count. */ bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay); -/** - * Check if transmitting. - */ bool tagtinker_ir_is_busy(void); -/** - * Stop any ongoing transmission. - */ void tagtinker_ir_stop(void); diff --git a/protocol/tagtinker_proto.c b/protocol/tagtinker_proto.c index 6298737..f97a0b5 100644 --- a/protocol/tagtinker_proto.c +++ b/protocol/tagtinker_proto.c @@ -1,7 +1,10 @@ /* - * TagTinker - ESL protocol helpers (implementation) + * ESL protocol helpers. * - * Ported from furrtek/TagTinker tools_python/pr.py and img2dm.py + * This file covers three jobs: + * 1. Decode a barcode into the tag address and known display profile. + * 2. Pack pixels into the tag's raw or RLE bitmap format. + * 3. Wrap those bytes into the IR frames that the tag understands. * * SPDX-License-Identifier: MIT */ @@ -11,8 +14,6 @@ #include #include -/* ── Helpers ────────────────────────────────────────────────── */ - typedef struct { uint16_t type_code; uint16_t width; @@ -21,6 +22,7 @@ typedef struct { TagTinkerTagColor color; } TagTinkerProfileEntry; +/* Known type codes seen in ESL barcodes. */ static const TagTinkerProfileEntry profile_table[] = { {1206, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono}, {1207, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono}, @@ -86,6 +88,7 @@ static size_t terminate(uint8_t* buf, size_t len) { static size_t raw_frame(uint8_t* buf, uint8_t proto, const uint8_t plid[4], uint8_t cmd) { + /* Every addressed frame starts with protocol byte, PLID, then command. */ buf[0] = proto; buf[1] = plid[3]; buf[2] = plid[2]; buf[3] = plid[1]; buf[4] = plid[0]; @@ -94,6 +97,7 @@ static size_t raw_frame(uint8_t* buf, uint8_t proto, } static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) { + /* Image upload is tunneled through command 0x34 with an inner MCU opcode. */ size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34); buf[p++] = 0x00; buf[p++] = 0x00; @@ -102,8 +106,6 @@ static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) { return p; } -/* ── CRC-16 (poly 0x8408, init 0x8408) ─────────────────────── */ - uint16_t tagtinker_crc16(const uint8_t* data, size_t len) { uint16_t crc = 0x8408; for(size_t i = 0; i < len; i++) { @@ -114,8 +116,6 @@ uint16_t tagtinker_crc16(const uint8_t* data, size_t len) { return crc; } -/* ── Barcode → PLID ─────────────────────────────────────────── */ - bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]) { if(!barcode || strlen(barcode) != 17) return false; for(int i = 2; i < 12; i++) @@ -166,8 +166,6 @@ bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* prof return true; } -/* ── Frame builders ─────────────────────────────────────────── */ - size_t tagtinker_build_broadcast_page_frame( uint8_t* buf, uint8_t page, bool forever, uint16_t duration) { @@ -231,10 +229,9 @@ static void record_run(uint8_t* out, size_t* pos, size_t cap, uint32_t run_count for(int i = 0; i < n / 2; i++) { uint8_t t = bits[i]; bits[i] = bits[n - 1 - i]; bits[n - 1 - i] = t; } - /* Prefix zeros (n-1 of them, skip leading 1) */ + /* Runs are unary-prefixed: zeros mark bit-length, then the count bits follow. */ for(int i = 1; i < n; i++) if(*pos < cap) out[(*pos)++] = 0; - /* The bits themselves */ for(int i = 0; i < n; i++) if(*pos < cap) out[(*pos)++] = bits[i]; } @@ -263,10 +260,10 @@ size_t tagtinker_rle_compress( if(run_count > 1) record_run(out, &pos, out_cap, run_count); if(pos < count) { - *comp_type = 2; /* RLE */ + *comp_type = 2; return pos; } - /* Compression didn't help — use raw */ + memcpy(out, pixels, count < out_cap ? count : out_cap); *comp_type = 0; return count < out_cap ? count : out_cap; @@ -411,6 +408,7 @@ bool tagtinker_encode_planes_payload( if(mode == TagTinkerCompressionRle) { use_compressed = true; } else if(mode == TagTinkerCompressionAuto) { + /* Auto mode picks RLE only when it is smaller than the raw bitstream. */ use_compressed = (comp_len > 0U) && (comp_len < total_pixels); } size_t src_len = use_compressed ? comp_len : total_pixels; @@ -474,6 +472,7 @@ size_t tagtinker_make_image_param_frame( uint16_t height, uint16_t pos_x, uint16_t pos_y) { + /* Command 0x05 tells the tag how many bytes are coming and where to place them. */ size_t p = mcu_frame(buf, plid, 0x05); append_word(buf, &p, byte_count); buf[p++] = 0x00; @@ -495,6 +494,7 @@ size_t tagtinker_make_image_data_frame( const uint8_t plid[4], uint16_t frame_index, const uint8_t data_bytes[20]) { + /* Command 0x20 carries one fixed 20-byte image block. */ size_t p = mcu_frame(buf, plid, 0x20); append_word(buf, &p, frame_index); memcpy(&buf[p], data_bytes, DATA_BYTES_PER_FRAME); @@ -502,8 +502,6 @@ size_t tagtinker_make_image_data_frame( return terminate(buf, p); } -/* ── Image upload sequence builder ──────────────────────────── */ - void tagtinker_build_image_sequence( TagTinkerApp* app, const uint8_t plid[4], @@ -517,6 +515,8 @@ void tagtinker_build_image_sequence( if(!tagtinker_encode_image_payload( pixels, width, height, app->color_clear, app->compression_mode, &payload)) return; + + /* The tag expects 20 data bytes per frame, so pad the payload to that boundary. */ size_t frame_count = payload.byte_count / DATA_BYTES_PER_FRAME; FURI_LOG_I("TagTinker", "IMG %ux%u pg=%u comp=%u %zu->%zu frames=%zu", @@ -528,7 +528,6 @@ void tagtinker_build_image_sequence( payload.byte_count, frame_count); - /* Total: ping + params + N data + refresh */ size_t total = 2 + frame_count + 1; app->frame_seq_count = total; @@ -544,13 +543,13 @@ void tagtinker_build_image_sequence( size_t idx = 0; - /* 1. Ping */ + /* Wake the tag before sending the upload. */ app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); app->frame_lengths[idx] = tagtinker_make_ping_frame(app->frame_sequence[idx], plid); app->frame_repeats[idx] = wake_repeats; idx++; - /* 2. Parameters (cmd 0x05) */ + /* The parameter frame describes size, page, compression mode, and placement. */ app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); app->frame_lengths[idx] = tagtinker_make_image_param_frame( app->frame_sequence[idx], @@ -565,22 +564,21 @@ void tagtinker_build_image_sequence( app->frame_repeats[idx] = 1; idx++; - /* 3..N+2. Data frames (cmd 0x20) */ + /* Data frames follow in order and carry the packed bitmap bytes. */ for(size_t fi = 0; fi < frame_count; fi++) { app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); size_t start = fi * DATA_BYTES_PER_FRAME; app->frame_lengths[idx] = tagtinker_make_image_data_frame( app->frame_sequence[idx], plid, (uint16_t)fi, &payload.data[start]); - app->frame_repeats[idx] = 3; /* 3 repeats per data frame for reliability */ + app->frame_repeats[idx] = 3; idx++; } - /* N+3. Refresh */ + /* Refresh asks the tag to display the uploaded image. */ app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE); app->frame_lengths[idx] = tagtinker_make_refresh_frame(app->frame_sequence[idx], plid); app->frame_repeats[idx] = 1; - /* Copy first data frame for display in TX scene */ if(app->frame_seq_count > 1) { memcpy(app->frame_buf, app->frame_sequence[1], app->frame_lengths[1] < TAGTINKER_MAX_FRAME_SIZE diff --git a/protocol/tagtinker_proto.h b/protocol/tagtinker_proto.h index 8bf27fe..aa27d53 100644 --- a/protocol/tagtinker_proto.h +++ b/protocol/tagtinker_proto.h @@ -1,8 +1,8 @@ /* - * TagTinker - ESL protocol helpers + * ESL protocol helpers. * - * Frame construction, CRC, and encoding for the infrared - * ESL protocol used by this project. Ported from furrtek/TagTinker. + * This layer turns barcodes, pixels, and payload bytes into the frames + * that the Flipper sends over IR to the tag. * * SPDX-License-Identifier: MIT */ @@ -13,19 +13,15 @@ #include #include -#define TAGTINKER_PROTO_DM 0x85 /* Dot-matrix / graphic ESLs */ -#define TAGTINKER_PROTO_SEG 0x84 /* 7-segment ESLs */ +#define TAGTINKER_PROTO_DM 0x85 +#define TAGTINKER_PROTO_SEG 0x84 #define TAGTINKER_MAX_FRAME_SIZE 96 -/* Forward declaration for TagTinkerApp (avoids circular include) */ typedef struct TagTinkerApp TagTinkerApp; -/* ── CRC ────────────────────────────────────────────────────── */ - +/* CRC used by the ESL wire format. */ uint16_t tagtinker_crc16(const uint8_t* data, size_t len); -/* ── Barcode / PLID ─────────────────────────────────────────── */ - typedef enum { TagTinkerTagKindUnknown = 0, TagTinkerTagKindDotMatrix, @@ -93,40 +89,32 @@ size_t tagtinker_make_image_data_frame( uint16_t frame_index, const uint8_t data_bytes[20]); -/* ── Frame builders ─────────────────────────────────────────── */ - -/* Broadcast page-change (no barcode needed). */ +/* Broadcast frames address every listening tag. */ size_t tagtinker_build_broadcast_page_frame( uint8_t* buf, uint8_t page, bool forever, uint16_t duration); -/* Broadcast diagnostic page. */ size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf); -/* Addressed DM frame: wraps raw payload with protocol + PLID + CRC. */ +/* Addressed frames use the PLID decoded from the barcode. */ size_t tagtinker_make_addressed_frame( uint8_t* buf, const uint8_t plid[4], const uint8_t* payload, size_t payload_len); -/* Wake-up ping (must be sent before most addressed commands). */ +/* Tags need a wake ping before most addressed commands. */ size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]); -/* Display refresh request. */ size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]); -/* MCU-level frame (used for image upload protocol). */ +/* Image upload uses MCU frames: one parameter frame followed by data frames. */ size_t tagtinker_make_mcu_frame( uint8_t* buf, const uint8_t plid[4], uint8_t cmd); -/* ── Image upload helpers ───────────────────────────────────── */ - -/* RLE-compress a pixel array (0/1 values). - * Returns compressed bitstream length. comp_type is set to 0 (raw) or 2 (RLE). */ +/* RLE is the tag's compact bitmap format. Raw mode keeps one bit per pixel. */ size_t tagtinker_rle_compress( const uint8_t* pixels, size_t count, uint8_t* out, size_t out_cap, uint8_t* comp_type); -/* Build a complete image-upload frame sequence and store it in app state. - * Allocates memory that the transmit scene frees on exit. */ +/* Builds the full IR sequence: wake, image params, data chunks, refresh. */ void tagtinker_build_image_sequence( TagTinkerApp* app, const uint8_t plid[4], diff --git a/scenes/tagtinker_scene.c b/scenes/tagtinker_scene.c index 130117f..fe60501 100644 --- a/scenes/tagtinker_scene.c +++ b/scenes/tagtinker_scene.c @@ -15,8 +15,8 @@ void(*const tagtinker_scene_on_enter_handlers[])(void*) = { tagtinker_scene_barcode_input_on_enter, tagtinker_scene_text_input_on_enter, tagtinker_scene_preset_list_on_enter, + tagtinker_scene_synced_image_list_on_enter, tagtinker_scene_size_picker_on_enter, - tagtinker_scene_image_upload_on_enter, tagtinker_scene_image_options_on_enter, tagtinker_scene_transmit_on_enter, tagtinker_scene_about_on_enter, @@ -33,8 +33,8 @@ bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = { tagtinker_scene_barcode_input_on_event, tagtinker_scene_text_input_on_event, tagtinker_scene_preset_list_on_event, + tagtinker_scene_synced_image_list_on_event, tagtinker_scene_size_picker_on_event, - tagtinker_scene_image_upload_on_event, tagtinker_scene_image_options_on_event, tagtinker_scene_transmit_on_event, tagtinker_scene_about_on_event, @@ -51,8 +51,8 @@ void(*const tagtinker_scene_on_exit_handlers[])(void*) = { tagtinker_scene_barcode_input_on_exit, tagtinker_scene_text_input_on_exit, tagtinker_scene_preset_list_on_exit, + tagtinker_scene_synced_image_list_on_exit, tagtinker_scene_size_picker_on_exit, - tagtinker_scene_image_upload_on_exit, tagtinker_scene_image_options_on_exit, tagtinker_scene_transmit_on_exit, tagtinker_scene_about_on_exit, diff --git a/scenes/tagtinker_scene.h b/scenes/tagtinker_scene.h index 50a3fa4..0da5d8e 100644 --- a/scenes/tagtinker_scene.h +++ b/scenes/tagtinker_scene.h @@ -1,5 +1,5 @@ /* - * TagTinker — Scene Definitions + * Scene definitions. */ #pragma once @@ -17,15 +17,14 @@ typedef enum { TagTinkerSceneBarcodeInput, TagTinkerSceneTextInput, TagTinkerScenePresetList, + TagTinkerSceneSyncedImageList, TagTinkerSceneSizePicker, - TagTinkerSceneImageUpload, TagTinkerSceneImageOptions, TagTinkerSceneTransmit, TagTinkerSceneAbout, TagTinkerSceneCount, } TagTinkerScene; -/* Scene handler declarations */ void tagtinker_scene_warning_on_enter(void* ctx); bool tagtinker_scene_warning_on_event(void* ctx, SceneManagerEvent event); void tagtinker_scene_warning_on_exit(void* ctx); @@ -70,9 +69,9 @@ void tagtinker_scene_preset_list_on_enter(void* ctx); bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event); void tagtinker_scene_preset_list_on_exit(void* ctx); -void tagtinker_scene_image_upload_on_enter(void* ctx); -bool tagtinker_scene_image_upload_on_event(void* ctx, SceneManagerEvent event); -void tagtinker_scene_image_upload_on_exit(void* ctx); +void tagtinker_scene_synced_image_list_on_enter(void* ctx); +bool tagtinker_scene_synced_image_list_on_event(void* ctx, SceneManagerEvent event); +void tagtinker_scene_synced_image_list_on_exit(void* ctx); void tagtinker_scene_image_options_on_enter(void* ctx); bool tagtinker_scene_image_options_on_event(void* ctx, SceneManagerEvent event); diff --git a/scenes/tagtinker_scene_about.c b/scenes/tagtinker_scene_about.c index fb2f3f8..9184e64 100644 --- a/scenes/tagtinker_scene_about.c +++ b/scenes/tagtinker_scene_about.c @@ -1,25 +1,714 @@ /* - * About — credits (state=0) or Android teaser (state=1) + * About and phone sync scene. */ #include "../tagtinker_app.h" +#include + +#define TAGTINKER_SYNC_DIR APP_DATA_PATH("sync") +#define TAGTINKER_SYNC_INDEX_PATH APP_DATA_PATH("synced_images.txt") +#define TAGTINKER_BLE_FLOW_WINDOW 8192U +#define TAGTINKER_SYNC_MAX_CHUNK_BYTES 384U + +enum { + AboutEventSendLatestPhone = 1, +}; typedef struct { uint32_t mode; uint32_t tick; + char status_text[32]; + bool can_send_latest; + char target_name[TAGTINKER_TARGET_NAME_LEN + 1]; } AboutViewModel; +static void ble_set_status(TagTinkerApp* app, const char* text) { + if(!app || !text) return; + snprintf(app->ble_status_text, sizeof(app->ble_status_text), "%s", text); +} + +static void ble_send_line(TagTinkerApp* app, const char* line) { + if(!app || !app->ble_serial || !line) return; + + uint8_t buf[256]; + size_t n = strlen(line); + if(n > sizeof(buf) - 2U) n = sizeof(buf) - 2U; + memcpy(buf, line, n); + buf[n++] = '\n'; + ble_profile_serial_tx(app->ble_serial, buf, (uint16_t)n); +} + +static void ble_set_rx_status(TagTinkerApp* app, const char* line) { + if(!app || !line) return; + snprintf(app->ble_status_text, sizeof(app->ble_status_text), "RX %.24s", line); +} + +static char* sync_next_token(char** cursor) { + if(!cursor || !*cursor) return NULL; + + char* token = *cursor; + char* sep = strchr(token, '|'); + if(sep) { + *sep = '\0'; + *cursor = sep + 1; + } else { + *cursor = NULL; + } + + return token; +} + +static bool sync_safe_token(const char* value, size_t max_len) { + if(!value || !*value) return false; + + size_t len = strlen(value); + if(len == 0U || len > max_len) return false; + + for(size_t i = 0; i < len; i++) { + char c = value[i]; + if((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + c == '_' || c == '-') { + continue; + } + return false; + } + + return true; +} + +static void sync_send_targets(TagTinkerApp* app) { + if(!app) return; + + char line[96]; + snprintf(line, sizeof(line), "TT_TARGETS_BEGIN|%u", app->target_count); + ble_send_line(app, line); + + for(uint8_t i = 0; i < app->target_count; i++) { + const TagTinkerTarget* target = &app->targets[i]; + snprintf( + line, + sizeof(line), + "TT_TARGET|%s|%s|%u|%u", + target->barcode, + target->name, + target->profile.width, + target->profile.height); + ble_send_line(app, line); + } + + ble_send_line(app, "TT_TARGETS_END"); +} + +static void sync_clear_active_job(TagTinkerApp* app) { + if(!app) return; + + app->ble_sync_job_active = false; + app->ble_sync_compact_protocol = false; + app->ble_sync_job_id[0] = '\0'; + app->ble_sync_barcode[0] = '\0'; + app->ble_sync_temp_path[0] = '\0'; + app->ble_sync_final_path[0] = '\0'; + app->ble_sync_expected_bytes = 0; + app->ble_sync_received_bytes = 0; + app->ble_sync_last_chunk = 0; +} + +static void sync_abort_active_job(TagTinkerApp* app) { + if(!app) return; + + if(app->ble_sync_temp_path[0] != '\0') { + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_common_remove(storage, app->ble_sync_temp_path); + furi_record_close(RECORD_STORAGE); + } + + sync_clear_active_job(app); +} + +static bool sync_append_index_record( + const char* job_id, + const char* barcode, + uint16_t width, + uint16_t height, + uint8_t page, + const char* image_path) { + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_common_mkdir(storage, APP_DATA_PATH("")); + + File* file = storage_file_alloc(storage); + bool ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_OPEN_APPEND); + if(!ok) { + ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_CREATE_ALWAYS); + } + + if(ok) { + char line[384]; + int len = snprintf( + line, + sizeof(line), + "%s|%s|%u|%u|%u|%s\n", + job_id, + barcode, + width, + height, + page, + image_path); + ok = (len > 0) && ((size_t)len < sizeof(line)) && + (storage_file_write(file, line, (uint16_t)len) == (uint16_t)len); + storage_file_close(file); + } + + storage_file_free(file); + furi_record_close(RECORD_STORAGE); + return ok; +} + +static int8_t sync_base64_value(char c) { + if(c >= 'A' && c <= 'Z') return (int8_t)(c - 'A'); + if(c >= 'a' && c <= 'z') return (int8_t)(c - 'a' + 26); + if(c >= '0' && c <= '9') return (int8_t)(c - '0' + 52); + if(c == '+' || c == '-') return 62; + if(c == '/' || c == '_') return 63; + if(c == '=') return -2; + return -1; +} + +static bool sync_decode_base64( + const char* input, + uint8_t* output, + size_t output_size, + size_t* output_len) { + if(!input || !output || !output_len) return false; + + size_t out_len = 0; + uint8_t quartet[4]; + uint8_t quartet_len = 0; + uint8_t padding = 0; + + for(const char* p = input; *p; p++) { + int8_t value = sync_base64_value(*p); + if(value == -1) return false; + + if(value == -2) { + value = 0; + padding++; + } + + quartet[quartet_len++] = (uint8_t)value; + if(quartet_len != 4U) continue; + + if(out_len + 3U > output_size) return false; + output[out_len++] = (uint8_t)((quartet[0] << 2U) | (quartet[1] >> 4U)); + if(padding < 2U) output[out_len++] = (uint8_t)((quartet[1] << 4U) | (quartet[2] >> 2U)); + if(padding == 0U) output[out_len++] = (uint8_t)((quartet[2] << 6U) | quartet[3]); + + quartet_len = 0; + padding = 0; + } + + if(quartet_len != 0U) return false; + + *output_len = out_len; + return true; +} + +static bool sync_begin_job( + TagTinkerApp* app, + const char* job_id, + const char* barcode, + uint16_t width, + uint16_t height, + uint8_t page, + uint32_t byte_count, + bool compact_protocol) { + if(!app || !sync_safe_token(job_id, TAGTINKER_SYNC_JOB_ID_LEN) || + (barcode && *barcode && !sync_safe_token(barcode, TAGTINKER_BC_LEN)) || width == 0U || height == 0U || + page > 7U || byte_count == 0U) { + return false; + } + + sync_abort_active_job(app); + + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_common_mkdir(storage, APP_DATA_PATH("")); + storage_common_mkdir(storage, TAGTINKER_SYNC_DIR); + + snprintf( + app->ble_sync_temp_path, + sizeof(app->ble_sync_temp_path), + "%s/%s.part", + TAGTINKER_SYNC_DIR, + job_id); + snprintf( + app->ble_sync_final_path, + sizeof(app->ble_sync_final_path), + "%s/%s.bmp", + TAGTINKER_SYNC_DIR, + job_id); + + storage_common_remove(storage, app->ble_sync_temp_path); + storage_common_remove(storage, app->ble_sync_final_path); + + File* file = storage_file_alloc(storage); + bool ok = storage_file_open(file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_CREATE_ALWAYS); + if(ok) { + storage_file_close(file); + } + + storage_file_free(file); + furi_record_close(RECORD_STORAGE); + if(!ok) return false; + + strncpy(app->ble_sync_job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN); + app->ble_sync_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0'; + if(barcode && *barcode) { + strncpy(app->ble_sync_barcode, barcode, TAGTINKER_BC_LEN); + app->ble_sync_barcode[TAGTINKER_BC_LEN] = '\0'; + } else { + app->ble_sync_barcode[0] = '\0'; + } + app->ble_sync_expected_bytes = byte_count; + app->ble_sync_received_bytes = 0; + app->ble_sync_last_chunk = 0; + app->ble_synced_lines = 0; + app->img_page = page; + app->esl_width = width; + app->esl_height = height; + app->ble_sync_job_active = true; + app->ble_sync_compact_protocol = compact_protocol; + app->ble_sync_ready_target = -1; + app->ble_status_text[0] = '\0'; + ble_set_status(app, "Upload started"); + ble_send_line(app, compact_protocol ? "AB" : "TT_ACK|BEGIN"); + return true; +} + +static bool sync_set_job_barcode(TagTinkerApp* app, const char* barcode) { + if(!app || !app->ble_sync_job_active || !barcode || !sync_safe_token(barcode, TAGTINKER_BC_LEN)) { + return false; + } + + if(app->ble_sync_barcode[0] != '\0' && strcmp(app->ble_sync_barcode, barcode) != 0) { + return false; + } + + strncpy(app->ble_sync_barcode, barcode, TAGTINKER_BC_LEN); + app->ble_sync_barcode[TAGTINKER_BC_LEN] = '\0'; + ble_send_line(app, app->ble_sync_compact_protocol ? "AT" : "TT_ACK|TARGET"); + return true; +} + +static bool sync_append_chunk(TagTinkerApp* app, uint16_t sequence, const char* payload) { + if(!app || !app->ble_sync_job_active || !payload || sequence == 0U) return false; + + if(sequence == app->ble_sync_last_chunk) { + char ack[32]; + if(app->ble_sync_compact_protocol) { + snprintf(ack, sizeof(ack), "A%04X", sequence); + } else { + snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence); + } + ble_send_line(app, ack); + return true; + } + + if(sequence != (uint16_t)(app->ble_sync_last_chunk + 1U)) return false; + + uint8_t decoded[TAGTINKER_SYNC_MAX_CHUNK_BYTES]; + size_t decoded_len = 0; + if(!sync_decode_base64(payload, decoded, sizeof(decoded), &decoded_len)) return false; + if((app->ble_sync_received_bytes + decoded_len) > app->ble_sync_expected_bytes) return false; + + Storage* storage = furi_record_open(RECORD_STORAGE); + File* file = storage_file_alloc(storage); + bool ok = storage_file_open(file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_OPEN_APPEND); + if(ok) { + ok = storage_file_write(file, decoded, decoded_len) == decoded_len; + storage_file_close(file); + } + storage_file_free(file); + furi_record_close(RECORD_STORAGE); + if(!ok) return false; + + app->ble_sync_received_bytes += decoded_len; + app->ble_sync_last_chunk = sequence; + app->ble_synced_lines = sequence; + + snprintf(app->ble_status_text, sizeof(app->ble_status_text), "RX %u chunks", sequence); + + char ack[32]; + if(app->ble_sync_compact_protocol) { + snprintf(ack, sizeof(ack), "A%04X", sequence); + } else { + snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence); + } + ble_send_line(app, ack); + return true; +} + +static bool sync_finish_job(TagTinkerApp* app, const char* job_id) { + if(!app || !sync_safe_token(job_id, TAGTINKER_SYNC_JOB_ID_LEN)) return false; + + if(!app->ble_sync_job_active && strcmp(job_id, app->ble_sync_last_job_id) == 0) { + char ack[32]; + if(app->ble_sync_last_compact_protocol) { + snprintf(ack, sizeof(ack), "AE"); + } else { + snprintf(ack, sizeof(ack), "TT_ACK|END|%u", app->ble_sync_last_completed_chunks); + } + ble_send_line(app, ack); + return true; + } + + if(!app->ble_sync_job_active || strcmp(job_id, app->ble_sync_job_id) != 0) return false; + if(app->ble_sync_barcode[0] == '\0') { + ble_set_status(app, "No target"); + return false; + } + if(app->ble_sync_received_bytes != app->ble_sync_expected_bytes) { + ble_set_status(app, "Size mismatch"); + return false; + } + + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_common_remove(storage, app->ble_sync_final_path); + bool ok = + storage_common_rename(storage, app->ble_sync_temp_path, app->ble_sync_final_path) == + FSE_OK; + furi_record_close(RECORD_STORAGE); + if(!ok) { + ble_set_status(app, "Save failed"); + return false; + } + + ok = sync_append_index_record( + app->ble_sync_job_id, + app->ble_sync_barcode, + app->esl_width, + app->esl_height, + app->img_page, + app->ble_sync_final_path); + if(!ok) { + ble_set_status(app, "Index failed"); + return false; + } + + strncpy(app->ble_sync_last_job_id, app->ble_sync_job_id, TAGTINKER_SYNC_JOB_ID_LEN); + app->ble_sync_last_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0'; + app->ble_sync_last_completed_chunks = app->ble_sync_last_chunk; + app->ble_sync_last_compact_protocol = app->ble_sync_compact_protocol; + + char ack[32]; + if(app->ble_sync_compact_protocol) { + snprintf(ack, sizeof(ack), "AE"); + } else { + snprintf(ack, sizeof(ack), "TT_ACK|END|%u", app->ble_sync_last_chunk); + } + int8_t target_index = tagtinker_ensure_target(app, app->ble_sync_barcode); + if(target_index >= 0) { + tagtinker_select_target(app, (uint8_t)target_index); + app->ble_sync_ready_target = target_index; + snprintf( + app->ble_status_text, + sizeof(app->ble_status_text), + "Saved for %.20s", + app->targets[target_index].name); + } else { + app->ble_sync_ready_target = -1; + ble_set_status(app, "Saved on Flipper"); + } + ble_send_line(app, ack); + sync_clear_active_job(app); + return true; +} + +static void sync_apply_line(TagTinkerApp* app, const char* line) { + if(!app || !line) return; + + /* + * Compact upload protocol: + * B begin upload + * C bind upload to a target + * D append one chunk + * E finish upload + * + * Acks are AB, AT, A, and AE. + */ + if(strcmp(line, "TT_PING") == 0) { + ble_set_status(app, "RX ping"); + ble_send_line(app, "TT_PONG"); + return; + } + + if(strcmp(line, "TT_LIST_TARGETS") == 0) { + sync_send_targets(app); + return; + } + + if(strncmp(line, "TT_BEGIN|", 9) == 0) { + char temp[160]; + strncpy(temp, line, sizeof(temp) - 1U); + temp[sizeof(temp) - 1U] = '\0'; + + char* cursor = temp; + sync_next_token(&cursor); + char* job_id = sync_next_token(&cursor); + char* barcode = sync_next_token(&cursor); + char* width = sync_next_token(&cursor); + char* height = sync_next_token(&cursor); + char* page = sync_next_token(&cursor); + char* bytes = sync_next_token(&cursor); + + if(job_id && barcode && width && height && page && bytes && + sync_begin_job( + app, + job_id, + barcode, + (uint16_t)atoi(width), + (uint16_t)atoi(height), + (uint8_t)atoi(page), + (uint32_t)strtoul(bytes, NULL, 10), + false)) { + return; + } + + ble_set_status(app, "BEGIN failed"); + return; + } + + if(strncmp(line, "TT_DATA|", 8) == 0) { + char temp[1024]; + strncpy(temp, line, sizeof(temp) - 1U); + temp[sizeof(temp) - 1U] = '\0'; + + char* cursor = temp; + sync_next_token(&cursor); + char* seq = sync_next_token(&cursor); + char* payload = sync_next_token(&cursor); + + if(seq && payload && sync_append_chunk(app, (uint16_t)atoi(seq), payload)) { + return; + } + + ble_set_status(app, "DATA failed"); + return; + } + + size_t compact_len = strlen(line); + if(line[0] == 'B' && compact_len >= 18U && compact_len <= 20U) { + char job_id[7]; + char width_hex[4]; + char height_hex[4]; + char page_hex[2]; + char size_hex[7]; + + memcpy(job_id, line + 1, 6); + job_id[6] = '\0'; + memcpy(width_hex, line + 7, 3); + width_hex[3] = '\0'; + memcpy(height_hex, line + 10, 3); + height_hex[3] = '\0'; + memcpy(page_hex, line + 13, 1); + page_hex[1] = '\0'; + size_t size_hex_len = compact_len - 14U; + memcpy(size_hex, line + 14, size_hex_len); + size_hex[size_hex_len] = '\0'; + + if(sync_begin_job( + app, + job_id, + NULL, + (uint16_t)strtoul(width_hex, NULL, 16), + (uint16_t)strtoul(height_hex, NULL, 16), + (uint8_t)strtoul(page_hex, NULL, 16), + (uint32_t)strtoul(size_hex, NULL, 16), + true)) { + return; + } + + ble_set_status(app, "BEGIN failed"); + return; + } + + if(line[0] == 'C' && strlen(line) == 18U) { + if(sync_set_job_barcode(app, line + 1)) { + return; + } + + ble_set_status(app, "TARGET failed"); + return; + } + + if(line[0] == 'D' && strlen(line) > 5U) { + char seq_hex[5]; + memcpy(seq_hex, line + 1, 4); + seq_hex[4] = '\0'; + + if(sync_append_chunk(app, (uint16_t)strtoul(seq_hex, NULL, 16), line + 5)) { + return; + } + + ble_set_status(app, "DATA failed"); + return; + } + + if(strncmp(line, "TT_END|", 7) == 0) { + const char* job_id = line + 7; + if(sync_finish_job(app, job_id)) { + return; + } + + ble_set_status(app, "END failed"); + return; + } + + if(line[0] == 'E' && strlen(line) == 7U) { + if(sync_finish_job(app, line + 1)) { + return; + } + + ble_set_status(app, "END failed"); + return; + } + + ble_set_rx_status(app, line); +} + +static uint16_t ble_rx_callback(SerialServiceEvent event, void* context) { + TagTinkerApp* app = context; + if(event.event == SerialServiceEventTypeDataReceived) { + for(uint16_t i = 0; i < event.data.size; i++) { + char c = (char)event.data.buffer[i]; + if(c == '\n' || c == '\r') { + if(app->ble_rx_len > 0U && !app->ble_rx_pending_ready) { + app->ble_rx_line[app->ble_rx_len] = '\0'; + strncpy( + app->ble_rx_pending_line, + app->ble_rx_line, + sizeof(app->ble_rx_pending_line) - 1U); + app->ble_rx_pending_line[sizeof(app->ble_rx_pending_line) - 1U] = '\0'; + app->ble_rx_pending_ready = true; + app->ble_rx_len = 0; + } + } else if(!app->ble_rx_pending_ready && app->ble_rx_len < (sizeof(app->ble_rx_line) - 1U)) { + app->ble_rx_line[app->ble_rx_len++] = c; + } + } + } + + if(app->ble_rx_pending_ready) return 0U; + return (uint16_t)((sizeof(app->ble_rx_line) - 1U) - app->ble_rx_len); +} + +static void bt_status_cb(BtStatus status, void* context) { + TagTinkerApp* app = context; + app->ble_status = status; + + switch(status) { + case BtStatusConnected: + if(app->ble_serial) { + ble_profile_serial_set_event_callback( + app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app); + ble_profile_serial_set_rpc_active(app->ble_serial, false); + } + ble_set_status(app, "Connected"); + ble_send_line(app, "TT_HELLO"); + break; + case BtStatusAdvertising: + ble_set_status(app, "Waiting phone"); + break; + case BtStatusOff: + ble_set_status(app, "Bluetooth off"); + break; + default: + ble_set_status(app, "BLE idle"); + break; + } +} + +static void ble_sync_start(TagTinkerApp* app) { + if(!app || !app->bt || app->ble_sync_active) return; + + bt_disconnect(app->bt); + bt_set_status_changed_callback(app->bt, bt_status_cb, app); + app->ble_serial = bt_profile_start(app->bt, ble_profile_serial, NULL); + if(!app->ble_serial) { + ble_set_status(app, "BLE start fail"); + return; + } + + app->ble_synced_lines = 0; + app->ble_rx_len = 0; + app->ble_rx_line[0] = '\0'; + app->ble_rx_pending_line[0] = '\0'; + app->ble_rx_pending_ready = false; + sync_clear_active_job(app); + app->ble_sync_last_job_id[0] = '\0'; + app->ble_sync_last_completed_chunks = 0; + app->ble_sync_last_compact_protocol = false; + app->ble_sync_ready_target = -1; + ble_profile_serial_set_event_callback(app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app); + ble_profile_serial_set_rpc_active(app->ble_serial, false); + ble_set_status(app, "Waiting phone"); + app->ble_sync_active = true; +} + +static void ble_sync_stop(TagTinkerApp* app) { + if(!app || !app->ble_sync_active) return; + + if(app->ble_sync_job_active) { + sync_abort_active_job(app); + } + + bt_profile_restore_default(app->bt); + app->ble_serial = NULL; + app->ble_sync_active = false; + app->ble_sync_ready_target = -1; +} + +static bool about_input_cb(InputEvent* event, void* context) { + TagTinkerApp* app = context; + if(event->type != InputTypeShort || event->key != InputKeyOk) { + return false; + } + + if(app->ble_sync_ready_target < 0 || app->ble_sync_ready_target >= app->target_count) { + return false; + } + + view_dispatcher_send_custom_event(app->view_dispatcher, AboutEventSendLatestPhone); + return true; +} + static void about_draw_cb(Canvas* canvas, void* _model) { AboutViewModel* model = _model; canvas_set_font(canvas, FontPrimary); - if(model->mode == 1) { - canvas_set_font(canvas, FontPrimary); - canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignCenter, "In Progress"); + if(model->mode == 1U) { + canvas_draw_str_aligned(canvas, 64, 8, AlignCenter, AlignTop, "Phone Sync"); + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned( + canvas, + 64, + 20, + AlignCenter, + AlignTop, + model->can_send_latest ? model->target_name : "Pick target on phone"); + canvas_draw_str_aligned( + canvas, + 64, + 30, + AlignCenter, + AlignTop, + model->can_send_latest ? "Press OK to send latest" : "Upload image to Flipper"); + canvas_draw_str_aligned(canvas, 64, 44, AlignCenter, AlignTop, "Status:"); + canvas_draw_str_aligned(canvas, 64, 54, AlignCenter, AlignTop, model->status_text); + if(model->can_send_latest) { + elements_button_center(canvas, "Send"); + } } else { canvas_draw_str_aligned( canvas, 64, 10, AlignCenter, AlignTop, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION); - canvas_set_font(canvas, FontSecondary); canvas_draw_str_aligned(canvas, 64, 24, AlignCenter, AlignTop, "Ported by I12BP8"); canvas_draw_str_aligned(canvas, 64, 34, AlignCenter, AlignTop, "Research by furrtek"); @@ -34,28 +723,89 @@ void tagtinker_scene_about_on_enter(void* ctx) { view_allocate_model(app->about_view, ViewModelTypeLockFree, sizeof(AboutViewModel)); view_set_context(app->about_view, app); view_set_draw_callback(app->about_view, about_draw_cb); + view_set_input_callback(app->about_view, about_input_cb); app->about_view_allocated = true; } AboutViewModel* model = view_get_model(app->about_view); + if(app->ble_status_text[0] == '\0') { + ble_set_status(app, mode == 1U ? "Waiting phone" : "Idle"); + } model->mode = mode; model->tick = 0; + model->can_send_latest = false; + model->target_name[0] = '\0'; + strncpy(model->status_text, app->ble_status_text, sizeof(model->status_text) - 1U); + model->status_text[sizeof(model->status_text) - 1U] = '\0'; view_commit_model(app->about_view, true); + if(mode == 1U) { + ble_sync_start(app); + } + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewAbout); } bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event) { TagTinkerApp* app = ctx; + if(event.type == SceneManagerEventTypeCustom && event.event == AboutEventSendLatestPhone) { + if(app->ble_sync_ready_target >= 0 && app->ble_sync_ready_target < app->target_count) { + TagTinkerTarget* target = &app->targets[app->ble_sync_ready_target]; + TagTinkerSyncedImage image; + if(tagtinker_find_latest_synced_image(app, target->barcode, &image)) { + tagtinker_select_target(app, (uint8_t)app->ble_sync_ready_target); + app->img_page = image.page; + app->draw_x = 0; + app->draw_y = 0; + app->color_clear = false; + tagtinker_prepare_bmp_tx( + app, + target->plid, + image.image_path, + image.width, + image.height, + image.page); + app->tx_spam = false; + app->ble_sync_ready_target = -1; + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); + return true; + } + } + return true; + } + if(event.type == SceneManagerEventTypeTick) { AboutViewModel* model = view_get_model(app->about_view); model->tick++; + if(app->ble_rx_pending_ready) { + sync_apply_line(app, app->ble_rx_pending_line); + app->ble_rx_pending_line[0] = '\0'; + app->ble_rx_pending_ready = false; + if(app->ble_serial) { + ble_profile_serial_notify_buffer_is_empty(app->ble_serial); + } + } + model->can_send_latest = + (app->ble_sync_ready_target >= 0 && app->ble_sync_ready_target < app->target_count); + if(model->can_send_latest) { + strncpy( + model->target_name, + app->targets[app->ble_sync_ready_target].name, + sizeof(model->target_name) - 1U); + model->target_name[sizeof(model->target_name) - 1U] = '\0'; + } else { + model->target_name[0] = '\0'; + } + strncpy(model->status_text, app->ble_status_text, sizeof(model->status_text) - 1U); + model->status_text[sizeof(model->status_text) - 1U] = '\0'; view_commit_model(app->about_view, true); return true; } + return false; } void tagtinker_scene_about_on_exit(void* ctx) { - UNUSED(ctx); + TagTinkerApp* app = ctx; + ble_sync_stop(app); } diff --git a/scenes/tagtinker_scene_barcode_input.c b/scenes/tagtinker_scene_barcode_input.c index 2708604..cf223c4 100644 --- a/scenes/tagtinker_scene_barcode_input.c +++ b/scenes/tagtinker_scene_barcode_input.c @@ -1,13 +1,11 @@ /* - * Barcode Input — custom barcode selector - * User enters 1 letter + 16 digits with arrow keys. + * Barcode input scene. */ #include "../tagtinker_app.h" static void numlock_done(void* ctx, const char* barcode) { TagTinkerApp* app = ctx; - /* Copy result to app barcode buffer */ strncpy(app->barcode, barcode, TAGTINKER_BC_LEN); app->barcode[TAGTINKER_BC_LEN] = '\0'; view_dispatcher_send_custom_event(app->view_dispatcher, 0); @@ -45,31 +43,7 @@ bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event) FURI_LOG_I(TAGTINKER_TAG, "Barcode: %s -> PLID %02X%02X%02X%02X", app->barcode, app->plid[3], app->plid[2], app->plid[1], app->plid[0]); - /* Auto-save target */ - bool exists = false; - for(uint8_t i = 0; i < app->target_count; i++) { - if(strcmp(app->targets[i].barcode, app->barcode) == 0) { - exists = true; - app->selected_target = i; - break; - } - } - if(!exists && app->target_count < TAGTINKER_MAX_TARGETS) { - TagTinkerTarget* t = &app->targets[app->target_count]; - memcpy(t->barcode, app->barcode, TAGTINKER_BC_LEN + 1); - memcpy(t->plid, app->plid, 4); - char suffix[7]; - memcpy(suffix, app->barcode + TAGTINKER_BC_LEN - 6, 6); - suffix[6] = '\0'; - snprintf(t->name, TAGTINKER_TARGET_NAME_LEN, "Tag ...%s", suffix); - tagtinker_target_refresh_profile(t); - app->selected_target = app->target_count; - app->target_count++; - - if(!tagtinker_targets_save(app)) { - FURI_LOG_W(TAGTINKER_TAG, "Failed to save targets"); - } - } + app->selected_target = tagtinker_ensure_target(app, app->barcode); if(app->selected_target >= 0) { tagtinker_select_target(app, (uint8_t)app->selected_target); diff --git a/scenes/tagtinker_scene_broadcast_menu.c b/scenes/tagtinker_scene_broadcast_menu.c index 53c2058..4eb11cd 100644 --- a/scenes/tagtinker_scene_broadcast_menu.c +++ b/scenes/tagtinker_scene_broadcast_menu.c @@ -13,10 +13,10 @@ void tagtinker_scene_broadcast_menu_on_enter(void* ctx) { TagTinkerApp* app = ctx; submenu_reset(app->submenu); - submenu_set_header(app->submenu, "Broadcast Tag"); + submenu_set_header(app->submenu, "Broadcast Payloads"); - submenu_add_item(app->submenu, "Change Page", TagTinkerBroadcastFlipPage, broadcast_menu_cb, app); - submenu_add_item(app->submenu, "Show Debug Page", TagTinkerBroadcastDebugScreen, broadcast_menu_cb, app); + submenu_add_item(app->submenu, "Change Page", TagTinkerBroadcastFlipPage, broadcast_menu_cb, app); + submenu_add_item(app->submenu, "Diagnostic Page", TagTinkerBroadcastDebugScreen, broadcast_menu_cb, app); submenu_set_selected_item( app->submenu, diff --git a/scenes/tagtinker_scene_image_upload.c b/scenes/tagtinker_scene_image_upload.c deleted file mode 100644 index 4c03dc3..0000000 --- a/scenes/tagtinker_scene_image_upload.c +++ /dev/null @@ -1,104 +0,0 @@ -#include "../tagtinker_app.h" -#include -#include -#include - -static void show_error_dialog(TagTinkerApp* app, const char* text) { - DialogMessage* message = dialog_message_alloc(); - dialog_message_set_header(message, "Load Error", 64, 0, AlignCenter, AlignTop); - dialog_message_set_text(message, text, 64, 26, AlignCenter, AlignCenter); - dialog_message_set_buttons(message, "OK", NULL, NULL); - dialog_message_show(app->dialogs, message); - dialog_message_free(message); -} - -void tagtinker_scene_image_upload_on_enter(void* ctx) { - TagTinkerApp* app = ctx; - TagTinkerTarget* target = &app->targets[app->selected_target]; - - FuriString* file_path = furi_string_alloc(); - furi_string_set(file_path, "/ext/apps_data/tagtinker"); - - Storage* storage = furi_record_open(RECORD_STORAGE); - storage_simply_mkdir(storage, furi_string_get_cstr(file_path)); - - DialogsFileBrowserOptions browser_options; - memset(&browser_options, 0, sizeof(browser_options)); - dialog_file_browser_set_basic_options(&browser_options, ".bmp", NULL); - browser_options.hide_dot_files = true; - - if(dialog_file_browser_show(app->dialogs, file_path, file_path, &browser_options)) { - File* file = storage_file_alloc(storage); - if(storage_file_open(file, furi_string_get_cstr(file_path), FSAM_READ, FSOM_OPEN_EXISTING)) { - uint8_t header[54]; - if(storage_file_read(file, header, sizeof(header)) == sizeof(header)) { - if(header[0] == 'B' && header[1] == 'M') { - uint32_t data_offset = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24); - int32_t bmp_w = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); - int32_t bmp_h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); - uint16_t bpp = header[28] | (header[29] << 8); - - bool top_down = false; - if(bmp_h < 0) { - top_down = true; - bmp_h = -bmp_h; - } - - if(bpp == 1) { - size_t w = (size_t)bmp_w; - size_t h = (size_t)bmp_h; - UNUSED(data_offset); - UNUSED(top_down); - tagtinker_prepare_bmp_tx( - app, - target->plid, - furi_string_get_cstr(file_path), - (uint16_t)w, - (uint16_t)h, - app->img_page); - scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageOptions); - } else if(bpp == 24 || bpp == 32) { - size_t w = (size_t)bmp_w; - size_t h = (size_t)bmp_h; - UNUSED(data_offset); - UNUSED(top_down); - tagtinker_prepare_bmp_tx( - app, - target->plid, - furi_string_get_cstr(file_path), - (uint16_t)w, - (uint16_t)h, - app->img_page); - scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageOptions); - } else { - show_error_dialog(app, "Use 1/24/32-bit BMP"); - } - } else { - show_error_dialog(app, "Invalid BMP magic"); - } - } else { - show_error_dialog(app, "File too small"); - } - storage_file_close(file); - } else { - show_error_dialog(app, "Could not open file"); - } - storage_file_free(file); - } else { - /* User cancelled browser */ - scene_manager_previous_scene(app->scene_manager); - } - - furi_record_close(RECORD_STORAGE); - furi_string_free(file_path); -} - -bool tagtinker_scene_image_upload_on_event(void* ctx, SceneManagerEvent event) { - UNUSED(ctx); - UNUSED(event); - return false; -} - -void tagtinker_scene_image_upload_on_exit(void* ctx) { - UNUSED(ctx); -} diff --git a/scenes/tagtinker_scene_main_menu.c b/scenes/tagtinker_scene_main_menu.c index 64142eb..3922be5 100644 --- a/scenes/tagtinker_scene_main_menu.c +++ b/scenes/tagtinker_scene_main_menu.c @@ -23,10 +23,10 @@ void tagtinker_scene_main_menu_on_enter(void* ctx) { submenu_reset(app->submenu); submenu_set_header(app->submenu, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION); - submenu_add_item(app->submenu, "Broadcast Tag", MainMenuBroadcast, main_menu_cb, app); - submenu_add_item(app->submenu, "Target Tag", MainMenuTargetESL, main_menu_cb, app); + submenu_add_item(app->submenu, "Broadcast Payloads", MainMenuBroadcast, main_menu_cb, app); + submenu_add_item(app->submenu, "Targeted Payloads", MainMenuTargetESL, main_menu_cb, app); + submenu_add_item(app->submenu, "Phone Sync", MainMenuAndroid, main_menu_cb, app); submenu_add_item(app->submenu, "Settings", MainMenuSettings, main_menu_cb, app); - submenu_add_item(app->submenu, "Android App", MainMenuAndroid, main_menu_cb, app); submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app); submenu_set_selected_item( diff --git a/scenes/tagtinker_scene_preset_list.c b/scenes/tagtinker_scene_preset_list.c index d757582..a075f8b 100644 --- a/scenes/tagtinker_scene_preset_list.c +++ b/scenes/tagtinker_scene_preset_list.c @@ -1,15 +1,11 @@ /* - * Preset List — first screen after "Push Text". - * - * Shows saved presets (click = instant transmit with saved text + settings). - * "[+] Add New Preset" goes to text input → size picker → save. + * Text preset list. */ #include "../tagtinker_app.h" #define EVT_ADD_NEW 200 #define EVT_PRESET 0 -/* Load presets from SD card */ static void presets_load(TagTinkerApp* app) { app->preset_count = 0; @@ -57,7 +53,6 @@ static void preset_list_cb(void* ctx, uint32_t index) { view_dispatcher_send_custom_event(app->view_dispatcher, index); } -/* Static label storage */ static char preset_labels[TAGTINKER_MAX_PRESETS][48]; void tagtinker_scene_preset_list_on_enter(void* ctx) { @@ -68,11 +63,9 @@ void tagtinker_scene_preset_list_on_enter(void* ctx) { submenu_reset(app->submenu); submenu_set_header(app->submenu, "Text Presets"); - /* Add New Preset option first */ submenu_add_item(app->submenu, "[+] New Preset", EVT_ADD_NEW, preset_list_cb, app); - /* Saved presets */ for(uint8_t i = 0; i < app->preset_count; i++) { snprintf(preset_labels[i], sizeof(preset_labels[i]), "%ux%u \"%s\"", @@ -91,17 +84,14 @@ bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event) { if(event.type != SceneManagerEventTypeCustom) return false; if(event.event == EVT_ADD_NEW) { - /* Clear text buffer and go to text input */ memset(app->text_input_buf, 0, sizeof(app->text_input_buf)); scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneTextInput, 0); scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextInput); return true; } - /* Preset selected — load text + settings and transmit */ uint32_t idx = event.event - EVT_PRESET; if(idx < app->preset_count) { - /* Load preset into app state */ app->esl_width = app->presets[idx].width; app->esl_height = app->presets[idx].height; app->img_page = app->presets[idx].page; diff --git a/scenes/tagtinker_scene_size_picker.c b/scenes/tagtinker_scene_size_picker.c index b29c0da..352acb4 100644 --- a/scenes/tagtinker_scene_size_picker.c +++ b/scenes/tagtinker_scene_size_picker.c @@ -1,5 +1,5 @@ /* - * Size Picker — exact native sizes plus a useful custom range. + * Size picker scene. */ #include "../tagtinker_app.h" @@ -31,7 +31,6 @@ static const char* compression_labels[] = {"Auto", "Raw", "RLE"}; #define H_COUNT COUNT_OF(height_values) #define COORD_COUNT COUNT_OF(coord_values) -/* Setting indices */ enum { SettingWidth, SettingHeight, @@ -46,8 +45,6 @@ enum { SettingTransmit, }; -/* ── Callbacks ── */ - static void clamp_current_offsets(TagTinkerApp* app); static void width_changed(VariableItem* item) { @@ -236,8 +233,6 @@ static void setting_cb(void* ctx, uint32_t index) { scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); } -/* ── Scene handlers ── */ - void tagtinker_scene_size_picker_on_enter(void* ctx) { TagTinkerApp* app = ctx; diff --git a/scenes/tagtinker_scene_synced_image_list.c b/scenes/tagtinker_scene_synced_image_list.c new file mode 100644 index 0000000..3b3fc50 --- /dev/null +++ b/scenes/tagtinker_scene_synced_image_list.c @@ -0,0 +1,155 @@ +#include "../tagtinker_app.h" + +#define EVT_SYNCED_IMAGE_BASE 300 + +static uint8_t synced_image_menu_map[TAGTINKER_MAX_SYNCED_IMAGES]; +static char synced_image_labels[TAGTINKER_MAX_SYNCED_IMAGES][48]; + +static void synced_image_list_cb(void* ctx, uint32_t index) { + TagTinkerApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +static char* synced_image_next_token(char** cursor) { + if(!cursor || !*cursor) return NULL; + + char* token = *cursor; + char* sep = strchr(token, '|'); + if(sep) { + *sep = '\0'; + *cursor = sep + 1; + } else { + *cursor = NULL; + } + + return token; +} + +static void synced_images_load(TagTinkerApp* app) { + app->synced_image_count = 0; + + if(app->selected_target < 0 || app->selected_target >= app->target_count) return; + + Storage* storage = furi_record_open(RECORD_STORAGE); + File* file = storage_file_alloc(storage); + + if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) { + uint64_t size = storage_file_size(file); + if(size > 0 && size < 8192U) { + char* buf = malloc((size_t)size + 1U); + if(buf) { + uint16_t read = storage_file_read(file, buf, (uint16_t)size); + buf[read] = '\0'; + + char* line = buf; + while(line && *line && app->synced_image_count < TAGTINKER_MAX_SYNCED_IMAGES) { + char* nl = strchr(line, '\n'); + if(nl) *nl = '\0'; + + if(*line) { + char* cursor = line; + char* job_id = synced_image_next_token(&cursor); + char* barcode = synced_image_next_token(&cursor); + char* width = synced_image_next_token(&cursor); + char* height = synced_image_next_token(&cursor); + char* page = synced_image_next_token(&cursor); + char* path = synced_image_next_token(&cursor); + + if(job_id && barcode && width && height && page && path && + strcmp(barcode, app->targets[app->selected_target].barcode) == 0 && + storage_common_exists(storage, path)) { + TagTinkerSyncedImage* image = + &app->synced_images[app->synced_image_count++]; + strncpy(image->job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN); + image->job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0'; + strncpy(image->barcode, barcode, TAGTINKER_BC_LEN); + image->barcode[TAGTINKER_BC_LEN] = '\0'; + image->width = (uint16_t)atoi(width); + image->height = (uint16_t)atoi(height); + image->page = (uint8_t)atoi(page); + strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN); + image->image_path[TAGTINKER_IMAGE_PATH_LEN] = '\0'; + } + } + + line = nl ? (nl + 1) : NULL; + } + + free(buf); + } + } + + storage_file_close(file); + } + + storage_file_free(file); + furi_record_close(RECORD_STORAGE); +} + +void tagtinker_scene_synced_image_list_on_enter(void* ctx) { + TagTinkerApp* app = ctx; + + synced_images_load(app); + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Uploads"); + + if(app->synced_image_count == 0) { + submenu_add_item(app->submenu, "No synced images", 0, synced_image_list_cb, app); + } else { + uint8_t menu_idx = 0; + for(int16_t i = (int16_t)app->synced_image_count - 1; i >= 0; i--) { + const TagTinkerSyncedImage* image = &app->synced_images[i]; + const char* suffix = image->job_id; + size_t suffix_len = strlen(image->job_id); + if(suffix_len > 6U) suffix += suffix_len - 6U; + + snprintf( + synced_image_labels[menu_idx], + sizeof(synced_image_labels[menu_idx]), + "P%u %ux%u #%s", + image->page, + image->width, + image->height, + suffix); + synced_image_menu_map[menu_idx] = (uint8_t)i; + submenu_add_item( + app->submenu, + synced_image_labels[menu_idx], + EVT_SYNCED_IMAGE_BASE + menu_idx, + synced_image_list_cb, + app); + menu_idx++; + } + } + + view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); +} + +bool tagtinker_scene_synced_image_list_on_event(void* ctx, SceneManagerEvent event) { + TagTinkerApp* app = ctx; + if(event.type != SceneManagerEventTypeCustom) return false; + + if(event.event < EVT_SYNCED_IMAGE_BASE) return true; + + uint32_t menu_idx = event.event - EVT_SYNCED_IMAGE_BASE; + if(menu_idx >= app->synced_image_count) return true; + if(app->selected_target < 0 || app->selected_target >= app->target_count) return true; + + TagTinkerSyncedImage* image = &app->synced_images[synced_image_menu_map[menu_idx]]; + TagTinkerTarget* target = &app->targets[app->selected_target]; + + app->img_page = image->page; + app->draw_x = 0; + app->draw_y = 0; + app->color_clear = false; + tagtinker_prepare_bmp_tx( + app, target->plid, image->image_path, image->width, image->height, image->page); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageOptions); + return true; +} + +void tagtinker_scene_synced_image_list_on_exit(void* ctx) { + TagTinkerApp* app = ctx; + submenu_reset(app->submenu); +} diff --git a/scenes/tagtinker_scene_target_actions.c b/scenes/tagtinker_scene_target_actions.c index 0ba03e8..d2ec865 100644 --- a/scenes/tagtinker_scene_target_actions.c +++ b/scenes/tagtinker_scene_target_actions.c @@ -1,5 +1,5 @@ /* - * Target Actions — what to do with a selected ESL + * Target actions scene. */ #include "../tagtinker_app.h" @@ -10,6 +10,29 @@ static void target_actions_cb(void* ctx, uint32_t index) { view_dispatcher_send_custom_event(app->view_dispatcher, index); } +static bool confirm_target_action(TagTinkerApp* app, const char* header, const char* body, const char* action) { + if(!app || !header || !body || !action) return false; + + DialogMessage* message = dialog_message_alloc(); + dialog_message_set_header(message, header, 64, 2, AlignCenter, AlignTop); + dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop); + dialog_message_set_buttons(message, "Back", NULL, action); + DialogMessageButton button = dialog_message_show(app->dialogs, message); + dialog_message_free(message); + return button == DialogMessageButtonRight; +} + +static void show_target_action_result(TagTinkerApp* app, const char* header, const char* body) { + if(!app || !header || !body) return; + + DialogMessage* message = dialog_message_alloc(); + dialog_message_set_header(message, header, 64, 2, AlignCenter, AlignTop); + dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop); + dialog_message_set_buttons(message, "OK", NULL, NULL); + dialog_message_show(app->dialogs, message); + dialog_message_free(message); +} + static void show_target_details(TagTinkerApp* app, const TagTinkerTarget* target) { if(!target) return; @@ -37,7 +60,7 @@ static void show_target_details(TagTinkerApp* app, const TagTinkerTarget* target } DialogMessage* message = dialog_message_alloc(); - dialog_message_set_header(message, "Tag Details", 64, 2, AlignCenter, AlignTop); + dialog_message_set_header(message, "Tag Info", 64, 2, AlignCenter, AlignTop); dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop); dialog_message_set_buttons(message, "OK", NULL, NULL); dialog_message_show(app->dialogs, message); @@ -52,16 +75,24 @@ void tagtinker_scene_target_actions_on_enter(void* ctx) { submenu_reset(app->submenu); char header[24]; - snprintf(header, sizeof(header), "Tag: %.8s...", app->barcode); + snprintf( + header, + sizeof(header), + "%s", + (target && target->name[0]) ? target->name : "Target"); submenu_set_header(app->submenu, header); - submenu_add_item(app->submenu, "Tag Details", TagTinkerTargetDetails, target_actions_cb, app); + submenu_add_item(app->submenu, "Show Tag Info", TagTinkerTargetDetails, target_actions_cb, app); + submenu_add_item(app->submenu, "Rename Tag", TagTinkerTargetRename, target_actions_cb, app); if(allow_graphics) { - submenu_add_item(app->submenu, "Show Text Preset", TagTinkerTargetPushText, target_actions_cb, app); - submenu_add_item(app->submenu, "Show Custom Image", TagTinkerTargetPushImage, target_actions_cb, app); + submenu_add_item(app->submenu, "Set Text", TagTinkerTargetPushText, target_actions_cb, app); + submenu_add_item(app->submenu, "Set Image", TagTinkerTargetPushSyncedImage, target_actions_cb, app); } - submenu_add_item(app->submenu, "LED Response Check", TagTinkerTargetPingFlash, target_actions_cb, app); + submenu_add_item(app->submenu, "LED Test", TagTinkerTargetPingFlash, target_actions_cb, app); + submenu_add_item( + app->submenu, "Delete Saved Images", TagTinkerTargetDeleteSyncedImages, target_actions_cb, app); + submenu_add_item(app->submenu, "Delete Tag", TagTinkerTargetDeleteTag, target_actions_cb, app); view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu); } @@ -74,47 +105,74 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event) case TagTinkerTargetDetails: show_target_details(app, &app->targets[app->selected_target]); return true; + case TagTinkerTargetRename: + scene_manager_set_scene_state( + app->scene_manager, TagTinkerSceneTextInput, TagTinkerTextInputRenameTarget); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextInput); + return true; case TagTinkerTargetPushText: if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true; scene_manager_next_scene(app->scene_manager, TagTinkerScenePresetList); return true; - case TagTinkerTargetPushImage: + case TagTinkerTargetPushSyncedImage: if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true; - scene_manager_next_scene(app->scene_manager, TagTinkerSceneImageUpload); + scene_manager_next_scene(app->scene_manager, TagTinkerSceneSyncedImageList); + return true; + case TagTinkerTargetDeleteSyncedImages: + { + TagTinkerTarget* target = &app->targets[app->selected_target]; + char body[96]; + snprintf(body, sizeof(body), "Remove saved images for\n%s?", target->name); + if(!confirm_target_action(app, "Delete Images", body, "Delete")) { + return true; + } + + size_t removed = tagtinker_delete_synced_images_for_barcode(app, target->barcode); + char result[96]; + snprintf(result, sizeof(result), "Removed %u saved image%s", (unsigned)removed, removed == 1U ? "" : "s"); + show_target_action_result(app, "Delete Images", result); + } return true; case TagTinkerTargetPingFlash: { TagTinkerTarget* target = &app->targets[app->selected_target]; - - /* The blink command actually requires two frames: - * 1. A wake ping (lots of repeats) - * 2. The green LED command payload (0x06 0xC9 0x00 0x00 0x00 0x00) - */ + app->frame_seq_count = 2; app->frame_sequence = malloc(sizeof(uint8_t*) * 2); app->frame_lengths = malloc(sizeof(size_t) * 2); app->frame_repeats = malloc(sizeof(uint16_t) * 2); - - /* 1. Wake ping */ + app->frame_sequence[0] = malloc(TAGTINKER_MAX_FRAME_SIZE); app->frame_lengths[0] = tagtinker_make_ping_frame(app->frame_sequence[0], target->plid); app->frame_repeats[0] = 500; - - /* 2. LED command */ + app->frame_sequence[1] = malloc(TAGTINKER_MAX_FRAME_SIZE); const uint8_t blink_payload[6] = {0x06, 0xC9, 0x00, 0x00, 0x00, 0x00}; app->frame_lengths[1] = tagtinker_make_addressed_frame( app->frame_sequence[1], target->plid, blink_payload, 6); app->frame_repeats[1] = 100; - - /* Put the first frame in the preview buffer */ + memcpy(app->frame_buf, app->frame_sequence[0], app->frame_lengths[0]); app->frame_len = app->frame_lengths[0]; - + app->tx_spam = false; scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); } return true; + case TagTinkerTargetDeleteTag: + { + TagTinkerTarget* target = &app->targets[app->selected_target]; + char body[96]; + snprintf(body, sizeof(body), "Delete %s and its\nsaved images?", target->name); + if(!confirm_target_action(app, "Delete Tag", body, "Delete")) { + return true; + } + + tagtinker_delete_target(app, (uint8_t)app->selected_target); + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, TagTinkerSceneTargetMenu); + } + return true; } return false; } diff --git a/scenes/tagtinker_scene_target_menu.c b/scenes/tagtinker_scene_target_menu.c index a46a2a1..017e108 100644 --- a/scenes/tagtinker_scene_target_menu.c +++ b/scenes/tagtinker_scene_target_menu.c @@ -17,10 +17,10 @@ void tagtinker_scene_target_menu_on_enter(void* ctx) { TagTinkerApp* app = ctx; submenu_reset(app->submenu); - submenu_set_header(app->submenu, "Target Tag"); + submenu_set_header(app->submenu, "Targeted Payloads"); /* Add new target */ - submenu_add_item(app->submenu, "+ Add Tag", TargetMenuAddNew, target_menu_cb, app); + submenu_add_item(app->submenu, "+ New Target", TargetMenuAddNew, target_menu_cb, app); /* List saved targets */ for(uint8_t i = 0; i < app->target_count; i++) { diff --git a/scenes/tagtinker_scene_text_input.c b/scenes/tagtinker_scene_text_input.c index c2e3691..925d600 100644 --- a/scenes/tagtinker_scene_text_input.c +++ b/scenes/tagtinker_scene_text_input.c @@ -1,7 +1,5 @@ /* - * TagTinker — Text Input Scene - * - * Prompts user for a text string, then goes to Size Picker. + * Text input scene. */ #include "../tagtinker_app.h" @@ -11,24 +9,47 @@ static void text_input_done_cb(void* ctx) { view_dispatcher_send_custom_event(app->view_dispatcher, 0); } +static void text_input_sanitize_name(char* value) { + if(!value) return; + + for(char* p = value; *p; p++) { + if(*p == '|' || *p == '\r' || *p == '\n') { + *p = ' '; + } + } +} + void tagtinker_scene_text_input_on_enter(void* ctx) { TagTinkerApp* app = ctx; - bool clear = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput) == 0; - - if(clear) { + uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput); + bool rename_target = mode == TagTinkerTextInputRenameTarget; + bool clear = mode == TagTinkerTextInputNewText; + + if(rename_target) { + if(app->selected_target >= 0 && app->selected_target < app->target_count) { + strncpy( + app->text_input_buf, + app->targets[app->selected_target].name, + sizeof(app->text_input_buf) - 1U); + app->text_input_buf[sizeof(app->text_input_buf) - 1U] = '\0'; + } else { + memset(app->text_input_buf, 0, sizeof(app->text_input_buf)); + } + } else if(clear) { memset(app->text_input_buf, 0, sizeof(app->text_input_buf)); - scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneTextInput, 1); + scene_manager_set_scene_state( + app->scene_manager, TagTinkerSceneTextInput, TagTinkerTextInputKeepText); } text_input_reset(app->text_input); - text_input_set_header_text(app->text_input, "Text to display:"); + text_input_set_header_text(app->text_input, rename_target ? "Target name:" : "Text to display:"); text_input_set_result_callback( app->text_input, text_input_done_cb, app, app->text_input_buf, sizeof(app->text_input_buf), - clear); + clear && !rename_target); view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput); } @@ -37,6 +58,25 @@ bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event) { TagTinkerApp* app = ctx; if(event.type != SceneManagerEventTypeCustom) return false; + uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneTextInput); + if(mode == TagTinkerTextInputRenameTarget) { + if(app->selected_target >= 0 && app->selected_target < app->target_count) { + TagTinkerTarget* target = &app->targets[app->selected_target]; + text_input_sanitize_name(app->text_input_buf); + if(strlen(app->text_input_buf) == 0U) { + tagtinker_target_set_default_name(target); + } else { + strncpy(target->name, app->text_input_buf, TAGTINKER_TARGET_NAME_LEN); + target->name[TAGTINKER_TARGET_NAME_LEN] = '\0'; + } + tagtinker_targets_save(app); + } + + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, TagTinkerSceneTargetActions); + return true; + } + if(strlen(app->text_input_buf) == 0) { scene_manager_search_and_switch_to_previous_scene( app->scene_manager, TagTinkerSceneTargetActions); diff --git a/tagtinker_app.c b/tagtinker_app.c index 0f011f5..7388d3b 100644 --- a/tagtinker_app.c +++ b/tagtinker_app.c @@ -59,6 +59,231 @@ void tagtinker_target_refresh_profile(TagTinkerTarget* target) { tagtinker_barcode_to_profile(target->barcode, &target->profile); } +void tagtinker_target_set_default_name(TagTinkerTarget* target) { + if(!target) return; + + if(strlen(target->barcode) < 6U) { + snprintf(target->name, sizeof(target->name), "Tag"); + return; + } + + char suffix[7]; + memcpy(suffix, target->barcode + TAGTINKER_BC_LEN - 6, 6); + suffix[6] = '\0'; + snprintf(target->name, sizeof(target->name), "Tag ...%s", suffix); +} + +int8_t tagtinker_find_target_by_barcode(const TagTinkerApp* app, const char* barcode) { + if(!app || !barcode || !*barcode) return -1; + + for(uint8_t i = 0; i < app->target_count; i++) { + if(strcmp(app->targets[i].barcode, barcode) == 0) { + return (int8_t)i; + } + } + + return -1; +} + +int8_t tagtinker_ensure_target(TagTinkerApp* app, const char* barcode) { + if(!app || !barcode) return -1; + + int8_t existing = tagtinker_find_target_by_barcode(app, barcode); + if(existing >= 0) return existing; + if(app->target_count >= TAGTINKER_MAX_TARGETS) return -1; + + TagTinkerTarget* target = &app->targets[app->target_count]; + memset(target, 0, sizeof(*target)); + strncpy(target->barcode, barcode, TAGTINKER_BC_LEN); + target->barcode[TAGTINKER_BC_LEN] = '\0'; + + if(!tagtinker_barcode_to_plid(target->barcode, target->plid)) { + memset(target, 0, sizeof(*target)); + return -1; + } + + tagtinker_target_set_default_name(target); + tagtinker_target_refresh_profile(target); + app->target_count++; + tagtinker_targets_save(app); + return (int8_t)(app->target_count - 1U); +} + +bool tagtinker_find_latest_synced_image( + const TagTinkerApp* app, + const char* barcode, + TagTinkerSyncedImage* image) { + if(!app || !barcode || !*barcode || !image) return false; + + bool found = false; + Storage* storage = furi_record_open(RECORD_STORAGE); + File* file = storage_file_alloc(storage); + + if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) { + uint64_t size = storage_file_size(file); + if(size > 0U && size < 8192U) { + char* buf = malloc((size_t)size + 1U); + if(buf) { + uint16_t read = storage_file_read(file, buf, (uint16_t)size); + buf[read] = '\0'; + + char* line = buf; + while(line && *line) { + char* nl = strchr(line, '\n'); + if(nl) *nl = '\0'; + + if(*line) { + char* cursor = line; + char* job_id = cursor; + char* current_barcode = strchr(cursor, '|'); + if(current_barcode) *current_barcode++ = '\0'; + char* width = current_barcode ? strchr(current_barcode, '|') : NULL; + if(width) *width++ = '\0'; + char* height = width ? strchr(width, '|') : NULL; + if(height) *height++ = '\0'; + char* page = height ? strchr(height, '|') : NULL; + if(page) *page++ = '\0'; + char* path = page ? strchr(page, '|') : NULL; + if(path) *path++ = '\0'; + + if(job_id && current_barcode && width && height && page && path && + strcmp(current_barcode, barcode) == 0 && storage_common_exists(storage, path)) { + strncpy(image->job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN); + image->job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0'; + strncpy(image->barcode, current_barcode, TAGTINKER_BC_LEN); + image->barcode[TAGTINKER_BC_LEN] = '\0'; + image->width = (uint16_t)atoi(width); + image->height = (uint16_t)atoi(height); + image->page = (uint8_t)atoi(page); + strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN); + image->image_path[TAGTINKER_IMAGE_PATH_LEN] = '\0'; + found = true; + } + } + + line = nl ? (nl + 1) : NULL; + } + + free(buf); + } + } + + storage_file_close(file); + } + + storage_file_free(file); + furi_record_close(RECORD_STORAGE); + return found; +} + +size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char* barcode) { + UNUSED(app); + + if(!barcode || !*barcode) return 0U; + + Storage* storage = furi_record_open(RECORD_STORAGE); + File* file = storage_file_alloc(storage); + size_t removed_count = 0U; + + if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) { + uint64_t size = storage_file_size(file); + if(size > 0U && size < 8192U) { + char* input = malloc((size_t)size + 1U); + char* output = malloc((size_t)size + 1U); + if(input && output) { + uint16_t read = storage_file_read(file, input, (uint16_t)size); + input[read] = '\0'; + output[0] = '\0'; + size_t output_len = 0U; + + char* line = input; + while(line && *line) { + char* nl = strchr(line, '\n'); + if(nl) *nl = '\0'; + + if(*line) { + char line_copy[384]; + snprintf(line_copy, sizeof(line_copy), "%s", line); + + char* cursor = line; + char* job_id = cursor; + char* current_barcode = strchr(cursor, '|'); + if(current_barcode) *current_barcode++ = '\0'; + char* width = current_barcode ? strchr(current_barcode, '|') : NULL; + if(width) *width++ = '\0'; + char* height = width ? strchr(width, '|') : NULL; + if(height) *height++ = '\0'; + char* page = height ? strchr(height, '|') : NULL; + if(page) *page++ = '\0'; + char* path = page ? strchr(page, '|') : NULL; + if(path) *path++ = '\0'; + + bool matches = + job_id && current_barcode && width && height && page && path && + strcmp(current_barcode, barcode) == 0; + if(matches) { + storage_common_remove(storage, path); + removed_count++; + } else { + size_t line_len = strlen(line_copy); + if((output_len + line_len + 1U) < ((size_t)size + 1U)) { + memcpy(output + output_len, line_copy, line_len); + output_len += line_len; + output[output_len++] = '\n'; + output[output_len] = '\0'; + } + } + } + + line = nl ? (nl + 1) : NULL; + } + + storage_file_close(file); + if(output_len == 0U) { + storage_common_remove(storage, APP_DATA_PATH("synced_images.txt")); + } else if( + storage_file_open( + file, APP_DATA_PATH("synced_images.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) { + storage_file_write(file, output, (uint16_t)output_len); + storage_file_close(file); + } + } else { + storage_file_close(file); + } + + free(output); + free(input); + } else { + storage_file_close(file); + } + } + + storage_file_free(file); + furi_record_close(RECORD_STORAGE); + return removed_count; +} + +bool tagtinker_delete_target(TagTinkerApp* app, uint8_t index) { + if(!app || index >= app->target_count) return false; + + tagtinker_delete_synced_images_for_barcode(app, app->targets[index].barcode); + + if(index + 1U < app->target_count) { + memmove( + &app->targets[index], + &app->targets[index + 1U], + sizeof(TagTinkerTarget) * (size_t)(app->target_count - index - 1U)); + } + memset(&app->targets[app->target_count - 1U], 0, sizeof(TagTinkerTarget)); + app->target_count--; + app->selected_target = -1; + app->barcode[0] = '\0'; + memset(app->plid, 0, sizeof(app->plid)); + app->barcode_valid = false; + + return tagtinker_targets_save(app); +} + bool tagtinker_target_supports_graphics(const TagTinkerTarget* target) { if(!target) return false; @@ -299,10 +524,7 @@ void tagtinker_targets_load(TagTinkerApp* app) { strncpy(target->name, sep + 1, TAGTINKER_TARGET_NAME_LEN); target->name[TAGTINKER_TARGET_NAME_LEN] = '\0'; } else { - char suffix[7]; - memcpy(suffix, target->barcode + TAGTINKER_BC_LEN - 6, 6); - suffix[6] = '\0'; - snprintf(target->name, TAGTINKER_TARGET_NAME_LEN + 1, "Tag ...%s", suffix); + tagtinker_target_set_default_name(target); } tagtinker_target_refresh_profile(target); @@ -367,6 +589,7 @@ static TagTinkerApp* app_alloc(void) { app->invert_text = false; strcpy(app->text_input_buf, "TagTinker"); app->selected_target = -1; + app->ble_sync_ready_target = -1; tagtinker_settings_load(app); tagtinker_targets_load(app); @@ -386,6 +609,7 @@ static TagTinkerApp* app_alloc(void) { /* Notifications */ app->notifications = furi_record_open(RECORD_NOTIFICATION); + app->bt = furi_record_open(RECORD_BT); /* Views */ app->submenu = submenu_alloc(); @@ -465,6 +689,9 @@ static void app_free(TagTinkerApp* app) { furi_record_close(RECORD_GUI); furi_record_close(RECORD_NOTIFICATION); furi_record_close(RECORD_DIALOGS); + if(app->bt) { + furi_record_close(RECORD_BT); + } view_free(app->warning_view); view_free(app->transmit_view); diff --git a/tagtinker_app.h b/tagtinker_app.h index 887dd98..f5d43ce 100644 --- a/tagtinker_app.h +++ b/tagtinker_app.h @@ -1,5 +1,5 @@ /* - * TagTinker — App State + * App state. */ #pragma once @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include "views/numlock_input.h" @@ -26,39 +28,19 @@ #define TAGTINKER_TAG "TagTinker" #define TAGTINKER_DISPLAY_NAME "TagTinker" -#define TAGTINKER_VERSION "1.1" +#define TAGTINKER_VERSION "1.3" #define TAGTINKER_BC_LEN 17 #define TAGTINKER_HEX_LEN 64 #define TAGTINKER_MAX_TARGETS 16 #define TAGTINKER_TARGET_NAME_LEN 16 #define TAGTINKER_MAX_PRESETS 6 +#define TAGTINKER_MAX_SYNCED_IMAGES 24 #define TAGTINKER_PRESET_TEXT_LEN 32 #define TAGTINKER_IMAGE_PATH_LEN 255 - -/* Views */ -typedef enum { - TagTinkerViewWarning, - TagTinkerViewSubmenu, - TagTinkerViewVarItemList, - TagTinkerViewTextInput, - TagTinkerViewPopup, - TagTinkerViewWidget, - TagTinkerViewNumlock, - TagTinkerViewTargetActions, - TagTinkerViewTransmit, - TagTinkerViewAbout, -} TagTinkerView; - -/* Saved ESL target */ -typedef struct { - char name[TAGTINKER_TARGET_NAME_LEN + 1]; - char barcode[TAGTINKER_BC_LEN + 1]; - uint8_t plid[4]; - TagTinkerTagProfile profile; -} TagTinkerTarget; +#define TAGTINKER_SYNC_JOB_ID_LEN 32 typedef enum { - TagTinkerTxModeDirect = 0, + TagTinkerTxModeNone = 0, TagTinkerTxModeTextImage, TagTinkerTxModeBmpImage, } TagTinkerTxMode; @@ -79,6 +61,43 @@ typedef struct { char image_path[TAGTINKER_IMAGE_PATH_LEN + 1]; } TagTinkerImageTxJob; +typedef struct { + char job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1]; + char barcode[TAGTINKER_BC_LEN + 1]; + uint16_t width; + uint16_t height; + uint8_t page; + char image_path[TAGTINKER_IMAGE_PATH_LEN + 1]; +} TagTinkerSyncedImage; + +typedef enum { + TagTinkerTextInputNewText = 0, + TagTinkerTextInputKeepText = 1, + TagTinkerTextInputRenameTarget = 2, +} TagTinkerTextInputMode; + +/* Views */ +typedef enum { + TagTinkerViewSubmenu, + TagTinkerViewVarItemList, + TagTinkerViewTextInput, + TagTinkerViewPopup, + TagTinkerViewWidget, + TagTinkerViewNumlock, + TagTinkerViewTargetActions, + TagTinkerViewWarning, + TagTinkerViewTransmit, + TagTinkerViewAbout, +} TagTinkerView; + +/* Saved ESL target */ +typedef struct { + char name[TAGTINKER_TARGET_NAME_LEN + 1]; + char barcode[TAGTINKER_BC_LEN + 1]; + uint8_t plid[4]; + TagTinkerTagProfile profile; +} TagTinkerTarget; + struct TagTinkerApp { /* GUI */ Gui* gui; @@ -94,8 +113,8 @@ struct TagTinkerApp { Popup* popup; Widget* widget; NumlockInput* numlock; - View* warning_view; View* target_actions_view; + View* warning_view; View* transmit_view; View* about_view; bool warning_view_allocated; @@ -113,10 +132,6 @@ struct TagTinkerApp { uint16_t repeats; bool forever; bool tx_spam; - bool show_startup_warning; - TagTinkerSignalMode signal_mode; - TagTinkerCompressionMode compression_mode; - uint8_t data_frame_repeats; /* Current target */ char barcode[TAGTINKER_BC_LEN + 1]; @@ -158,24 +173,54 @@ struct TagTinkerApp { } presets[TAGTINKER_MAX_PRESETS]; uint8_t preset_count; + TagTinkerSyncedImage synced_images[TAGTINKER_MAX_SYNCED_IMAGES]; + uint8_t synced_image_count; + /* Image settings */ uint8_t img_page; uint16_t draw_x; uint16_t draw_y; + uint16_t draw_width; + uint16_t draw_height; + TagTinkerImageTxJob image_tx_job; + TagTinkerCompressionMode compression_mode; + uint8_t data_frame_repeats; + TagTinkerSignalMode signal_mode; + bool show_startup_warning; /* Indicates which mode triggered raw cmd (0=broadcast, 1=targeted) */ uint8_t raw_mode; - /* Chunked image/text TX */ - TagTinkerImageTxJob image_tx_job; + /* Android BLE sync state */ + Bt* bt; + FuriHalBleProfileBase* ble_serial; + BtStatus ble_status; + bool ble_sync_active; + uint16_t ble_synced_lines; + char ble_status_text[32]; + char ble_rx_line[1024]; + char ble_rx_pending_line[1024]; + uint16_t ble_rx_len; + bool ble_rx_pending_ready; + bool ble_sync_job_active; + char ble_sync_job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1]; + char ble_sync_barcode[TAGTINKER_BC_LEN + 1]; + char ble_sync_temp_path[TAGTINKER_IMAGE_PATH_LEN + 1]; + char ble_sync_final_path[TAGTINKER_IMAGE_PATH_LEN + 1]; + char ble_sync_last_job_id[TAGTINKER_SYNC_JOB_ID_LEN + 1]; + uint32_t ble_sync_expected_bytes; + uint32_t ble_sync_received_bytes; + uint16_t ble_sync_last_chunk; + uint16_t ble_sync_last_completed_chunks; + bool ble_sync_compact_protocol; + bool ble_sync_last_compact_protocol; + int8_t ble_sync_ready_target; }; /* Main menu items */ typedef enum { TagTinkerMenuBroadcast, TagTinkerMenuTargetESL, - TagTinkerMenuSettings, - TagTinkerMenuAndroid, TagTinkerMenuAbout, } TagTinkerMainMenuItem; @@ -188,19 +233,29 @@ typedef enum { /* Target action items */ typedef enum { TagTinkerTargetDetails, + TagTinkerTargetRename, TagTinkerTargetPushText, - TagTinkerTargetPushImage, + TagTinkerTargetPushSyncedImage, + TagTinkerTargetDeleteSyncedImages, TagTinkerTargetPingFlash, + TagTinkerTargetDeleteTag, } TagTinkerTargetActionItem; -void tagtinker_settings_load(TagTinkerApp* app); -bool tagtinker_settings_save(const TagTinkerApp* app); -void tagtinker_targets_load(TagTinkerApp* app); -bool tagtinker_targets_save(const TagTinkerApp* app); void tagtinker_target_refresh_profile(TagTinkerTarget* target); -void tagtinker_select_target(TagTinkerApp* app, uint8_t index); +void tagtinker_target_set_default_name(TagTinkerTarget* target); bool tagtinker_target_supports_graphics(const TagTinkerTarget* target); bool tagtinker_target_supports_accent(const TagTinkerTarget* target); +const char* tagtinker_profile_kind_label(TagTinkerTagKind kind); +const char* tagtinker_profile_color_label(TagTinkerTagColor color); +int8_t tagtinker_find_target_by_barcode(const TagTinkerApp* app, const char* barcode); +int8_t tagtinker_ensure_target(TagTinkerApp* app, const char* barcode); +bool tagtinker_find_latest_synced_image( + const TagTinkerApp* app, + const char* barcode, + TagTinkerSyncedImage* image); +size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char* barcode); +bool tagtinker_delete_target(TagTinkerApp* app, uint8_t index); + void tagtinker_free_frame_sequence(TagTinkerApp* app); uint16_t tagtinker_pick_chunk_height(uint16_t width, bool color_clear); void tagtinker_prepare_text_tx(TagTinkerApp* app, const uint8_t plid[4]); @@ -211,5 +266,8 @@ void tagtinker_prepare_bmp_tx( uint16_t width, uint16_t height, uint8_t page); -const char* tagtinker_profile_kind_label(TagTinkerTagKind kind); -const char* tagtinker_profile_color_label(TagTinkerTagColor color); +void tagtinker_select_target(TagTinkerApp* app, uint8_t index); +void tagtinker_settings_load(TagTinkerApp* app); +bool tagtinker_settings_save(const TagTinkerApp* app); +void tagtinker_targets_load(TagTinkerApp* app); +bool tagtinker_targets_save(const TagTinkerApp* app); diff --git a/views/numlock_input.c b/views/numlock_input.c index 9596a01..c46a38d 100644 --- a/views/numlock_input.c +++ b/views/numlock_input.c @@ -1,8 +1,5 @@ /* - * TagTinker — Number Lock Barcode Input - * - * Clean centered barcode entry: 1 letter + 16 digits in groups of 4. - * UP/DOWN cycle, LEFT/RIGHT move, OK confirm, BACK cancel. + * Barcode input view. */ #include "numlock_input.h" @@ -28,7 +25,7 @@ static uint8_t prefix_x(void) { static uint8_t digit_x(uint8_t i) { uint8_t groups = i / GROUP_SIZE; - /* Spread across horizontal: Prefix(8) + 16 chars(6) + 3 gaps(5) = 119. Left margin = 4. */ + /* Keep the full code centered across the 128 px canvas. */ return 4 + 8 + i * CHAR_W + groups * GROUP_GAP; } @@ -36,7 +33,6 @@ static void numlock_draw(Canvas* canvas, void* model_v) { NumlockModel* m = model_v; canvas_clear(canvas); - /* 1. Header Bar — Inverted Tech Banner */ canvas_set_color(canvas, ColorBlack); canvas_draw_box(canvas, 0, 0, 128, 12); canvas_set_color(canvas, ColorWhite); @@ -44,17 +40,14 @@ static void numlock_draw(Canvas* canvas, void* model_v) { canvas_draw_str_aligned(canvas, 64, 6, AlignCenter, AlignCenter, "SET BARCODE"); canvas_set_color(canvas, ColorBlack); - /* 2. Main Input Frame — Heavy industrial border */ int frame_y = 19; int frame_h = 24; canvas_draw_rframe(canvas, 1, frame_y, 126, frame_h, 2); - /* Inner shadow/border detail */ canvas_draw_line(canvas, 2, frame_y+1, 125, frame_y+1); const uint8_t baseline = 36; canvas_set_font(canvas, FontPrimary); - /* Editable letter prefix */ char prefix[2] = {m->prefix, '\0'}; if(m->cursor == 0) { uint8_t x = prefix_x(); @@ -75,45 +68,34 @@ static void numlock_draw(Canvas* canvas, void* model_v) { canvas_draw_str(canvas, prefix_x(), baseline, prefix); } - /* Iterating Digits */ for(uint8_t i = 0; i < NUM_DIGITS; i++) { uint8_t x = digit_x(i); char ch[2] = {'0' + m->digits[i], '\0'}; if((i + 1) == m->cursor) { - /* Selected digit bounding block */ canvas_draw_box(canvas, x - 1, frame_y + 3, CHAR_W + 2, frame_h - 6); canvas_set_color(canvas, ColorWhite); canvas_draw_str(canvas, x, baseline, ch); canvas_set_color(canvas, ColorBlack); - - /* Sharp Navigator Arrows pointing at selection */ - uint8_t cx = x + CHAR_W / 2 - 1; /* Center of char */ - - /* Up pointer */ + + uint8_t cx = x + CHAR_W / 2 - 1; canvas_draw_line(canvas, cx, frame_y - 4, cx - 2, frame_y - 2); canvas_draw_line(canvas, cx, frame_y - 4, cx + 2, frame_y - 2); - canvas_draw_line(canvas, cx, frame_y - 4, cx, frame_y - 1); - - /* Down pointer */ + canvas_draw_line(canvas, cx, frame_y - 4, cx, frame_y - 1); + canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx - 2, frame_y + frame_h + 1); canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx + 2, frame_y + frame_h + 1); - canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx, frame_y + frame_h); - + canvas_draw_line(canvas, cx, frame_y + frame_h + 3, cx, frame_y + frame_h); } else { - /* Standard unselected digit */ canvas_draw_str(canvas, x, baseline, ch); } } - /* 3. Footer UI with Clean Button Callouts */ canvas_set_font(canvas, FontSecondary); - - /* D-Pad Hint Icons */ - canvas_draw_str(canvas, 2, 59, "<\x12\x13> Sel"); + + canvas_draw_str(canvas, 2, 59, "<\x12\x13> Sel"); canvas_draw_str(canvas, 45, 59, "^\x18\x19v Set"); - /* Thick OK Button */ canvas_draw_rbox(canvas, 92, 48, 34, 14, 2); canvas_set_color(canvas, ColorWhite); canvas_draw_str_aligned(canvas, 109, 55, AlignCenter, AlignCenter, "Hold OK"); diff --git a/views/numlock_input.h b/views/numlock_input.h index 76a6790..fd4f52a 100644 --- a/views/numlock_input.h +++ b/views/numlock_input.h @@ -1,14 +1,5 @@ /* - * TagTinker - number lock barcode input view - * - * Custom view for entering a 17-character ESL barcode. - * Format: Letter + 16 digits. - * - * Controls: - * UP/DOWN - cycle digit 0-9 - * LEFT/RIGHT - move cursor position - * OK - confirm barcode - * BACK - cancel + * Barcode input view. */ #pragma once diff --git a/views/tagtinker_font.h b/views/tagtinker_font.h index 298fc84..d0c1e61 100644 --- a/views/tagtinker_font.h +++ b/views/tagtinker_font.h @@ -134,7 +134,7 @@ static inline void set_region_pixel( * bg_val/fg_val control polarity: * Normal: bg=1 (white), fg=0 (black) * Inverted: bg=0 (black), fg=1 (white) - * Zero margin — text fills edge to edge. + * No outer margin is added. */ static inline void render_text_ex(uint8_t* buf, uint16_t w, uint16_t h, const char* text, uint8_t bg_val, uint8_t fg_val) {