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) {