@delicity/capacitor-thermal-printer 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/DelicityThermalPrinter.podspec +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +649 -0
  4. package/android/build.gradle +122 -0
  5. package/android/src/main/AndroidManifest.xml +38 -0
  6. package/android/src/main/java/com/delicity/thermalprinter/Logger.kt +50 -0
  7. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterEngine.kt +528 -0
  8. package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterPlugin.kt +334 -0
  9. package/android/src/main/java/com/delicity/thermalprinter/adapters/BleAdapter.kt +125 -0
  10. package/android/src/main/java/com/delicity/thermalprinter/adapters/BrotherAdapter.kt +206 -0
  11. package/android/src/main/java/com/delicity/thermalprinter/adapters/EpsonAdapter.kt +384 -0
  12. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosAdapter.kt +160 -0
  13. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosCommands.kt +42 -0
  14. package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosTextEncoder.kt +138 -0
  15. package/android/src/main/java/com/delicity/thermalprinter/adapters/PrinterAdapter.kt +95 -0
  16. package/android/src/main/java/com/delicity/thermalprinter/adapters/RawTcpAdapter.kt +96 -0
  17. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkContract.kt +158 -0
  18. package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkReflect.kt +104 -0
  19. package/android/src/main/java/com/delicity/thermalprinter/adapters/StarAdapter.kt +322 -0
  20. package/android/src/main/java/com/delicity/thermalprinter/adapters/UsbAdapter.kt +248 -0
  21. package/android/src/main/java/com/delicity/thermalprinter/adapters/ZebraAdapter.kt +207 -0
  22. package/android/src/main/java/com/delicity/thermalprinter/discovery/AdapterPriority.kt +39 -0
  23. package/android/src/main/java/com/delicity/thermalprinter/discovery/BleScanner.kt +70 -0
  24. package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt +112 -0
  25. package/android/src/main/java/com/delicity/thermalprinter/discovery/DiscoveryManager.kt +136 -0
  26. package/android/src/main/java/com/delicity/thermalprinter/discovery/TcpScanner.kt +96 -0
  27. package/android/src/main/java/com/delicity/thermalprinter/image/ImageCache.kt +88 -0
  28. package/android/src/main/java/com/delicity/thermalprinter/image/ImageProcessor.kt +220 -0
  29. package/android/src/main/java/com/delicity/thermalprinter/image/TextRasterizer.kt +99 -0
  30. package/android/src/main/java/com/delicity/thermalprinter/model/Models.kt +206 -0
  31. package/android/src/main/java/com/delicity/thermalprinter/model/PrintItem.kt +100 -0
  32. package/android/src/main/java/com/delicity/thermalprinter/store/PrinterStore.kt +71 -0
  33. package/android/src/main/java/com/delicity/thermalprinter/transport/BleGattClient.kt +201 -0
  34. package/android/src/main/java/com/delicity/thermalprinter/transport/BluetoothSppTransport.kt +110 -0
  35. package/android/src/main/java/com/delicity/thermalprinter/transport/ByteTransport.kt +18 -0
  36. package/android/src/main/java/com/delicity/thermalprinter/transport/TcpTransport.kt +83 -0
  37. package/dist/esm/adapters/dedup.d.ts +26 -0
  38. package/dist/esm/adapters/dedup.js +66 -0
  39. package/dist/esm/adapters/dedup.js.map +1 -0
  40. package/dist/esm/adapters/priority.d.ts +29 -0
  41. package/dist/esm/adapters/priority.js +55 -0
  42. package/dist/esm/adapters/priority.js.map +1 -0
  43. package/dist/esm/core/enums.d.ts +61 -0
  44. package/dist/esm/core/enums.js +25 -0
  45. package/dist/esm/core/enums.js.map +1 -0
  46. package/dist/esm/core/errors.d.ts +16 -0
  47. package/dist/esm/core/errors.js +53 -0
  48. package/dist/esm/core/errors.js.map +1 -0
  49. package/dist/esm/core/escpos-text.d.ts +33 -0
  50. package/dist/esm/core/escpos-text.js +239 -0
  51. package/dist/esm/core/escpos-text.js.map +1 -0
  52. package/dist/esm/core/imaging.d.ts +91 -0
  53. package/dist/esm/core/imaging.js +184 -0
  54. package/dist/esm/core/imaging.js.map +1 -0
  55. package/dist/esm/core/models.d.ts +131 -0
  56. package/dist/esm/core/models.js +2 -0
  57. package/dist/esm/core/models.js.map +1 -0
  58. package/dist/esm/core/options.d.ts +154 -0
  59. package/dist/esm/core/options.js +2 -0
  60. package/dist/esm/core/options.js.map +1 -0
  61. package/dist/esm/core/text.d.ts +138 -0
  62. package/dist/esm/core/text.js +14 -0
  63. package/dist/esm/core/text.js.map +1 -0
  64. package/dist/esm/definitions.d.ts +155 -0
  65. package/dist/esm/definitions.js +2 -0
  66. package/dist/esm/definitions.js.map +1 -0
  67. package/dist/esm/index.d.ts +15 -0
  68. package/dist/esm/index.js +18 -0
  69. package/dist/esm/index.js.map +1 -0
  70. package/dist/esm/web.d.ts +63 -0
  71. package/dist/esm/web.js +112 -0
  72. package/dist/esm/web.js.map +1 -0
  73. package/dist/plugin.cjs.js +224 -0
  74. package/dist/plugin.cjs.js.map +1 -0
  75. package/dist/plugin.js +227 -0
  76. package/dist/plugin.js.map +1 -0
  77. package/ios/Plugin/Adapters/BrotherAdapter.swift +139 -0
  78. package/ios/Plugin/Adapters/EpsonAdapter.swift +131 -0
  79. package/ios/Plugin/Adapters/EscPosAdapter.swift +106 -0
  80. package/ios/Plugin/Adapters/EscPosCommands.swift +32 -0
  81. package/ios/Plugin/Adapters/EscPosTextEncoder.swift +115 -0
  82. package/ios/Plugin/Adapters/PrinterAdapter.swift +44 -0
  83. package/ios/Plugin/Adapters/RawTcpAdapter.swift +70 -0
  84. package/ios/Plugin/Adapters/StarAdapter.swift +305 -0
  85. package/ios/Plugin/Adapters/ZebraAdapter.swift +119 -0
  86. package/ios/Plugin/Discovery/AdapterPriority.swift +21 -0
  87. package/ios/Plugin/Discovery/BonjourScanner.swift +51 -0
  88. package/ios/Plugin/Discovery/DiscoveryManager.swift +86 -0
  89. package/ios/Plugin/Image/ImageCache.swift +73 -0
  90. package/ios/Plugin/Image/ImageProcessor.swift +168 -0
  91. package/ios/Plugin/Image/TextRasterizer.swift +81 -0
  92. package/ios/Plugin/Logger.swift +33 -0
  93. package/ios/Plugin/Model/Models.swift +174 -0
  94. package/ios/Plugin/Model/PrintItem.swift +111 -0
  95. package/ios/Plugin/Store/PrinterStore.swift +51 -0
  96. package/ios/Plugin/ThermalPrinterEngine.swift +395 -0
  97. package/ios/Plugin/ThermalPrinterPlugin.m +22 -0
  98. package/ios/Plugin/ThermalPrinterPlugin.swift +258 -0
  99. package/ios/Plugin/Transport/TcpTransport.swift +89 -0
  100. package/package.json +96 -0
@@ -0,0 +1,100 @@
1
+ package com.delicity.thermalprinter.model
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ /**
7
+ * Modèle d'items texte (miroir Kotlin de src/core/text.ts).
8
+ * Parsé depuis le tableau JSON envoyé par printText([...]).
9
+ */
10
+ data class TextStyle(
11
+ val align: String? = null,
12
+ val bold: Boolean = false,
13
+ val underline: String = "none",
14
+ val font: String = "A",
15
+ val widthMultiplier: Int = 1,
16
+ val heightMultiplier: Int = 1,
17
+ val doubleStrike: Boolean = false,
18
+ val invert: Boolean = false,
19
+ val upsideDown: Boolean = false,
20
+ val rotate90: Boolean = false,
21
+ val letterSpacing: Int? = null,
22
+ val lineSpacing: Int? = null,
23
+ val codePage: String? = null,
24
+ val codePageId: Int? = null,
25
+ val newline: Boolean = true,
26
+ ) {
27
+ companion object {
28
+ fun fromJson(o: JSONObject?): TextStyle {
29
+ if (o == null) return TextStyle()
30
+ return TextStyle(
31
+ align = o.optString("align").ifEmpty { null },
32
+ bold = o.optBoolean("bold", false),
33
+ underline = o.optString("underline", "none"),
34
+ font = o.optString("font", "A"),
35
+ widthMultiplier = o.optInt("widthMultiplier", 1),
36
+ heightMultiplier = o.optInt("heightMultiplier", 1),
37
+ doubleStrike = o.optBoolean("doubleStrike", false),
38
+ invert = o.optBoolean("invert", false),
39
+ upsideDown = o.optBoolean("upsideDown", false),
40
+ rotate90 = o.optBoolean("rotate90", false),
41
+ letterSpacing = if (o.has("letterSpacing")) o.optInt("letterSpacing") else null,
42
+ lineSpacing = if (o.has("lineSpacing")) o.optInt("lineSpacing") else null,
43
+ codePage = o.optString("codePage").ifEmpty { null },
44
+ codePageId = if (o.has("codePageId")) o.optInt("codePageId") else null,
45
+ newline = o.optBoolean("newline", true),
46
+ )
47
+ }
48
+ }
49
+ }
50
+
51
+ sealed class PrintItem {
52
+ data class Text(val value: String, val style: TextStyle) : PrintItem()
53
+ data class Feed(val lines: Int) : PrintItem()
54
+ data class Cut(val mode: String, val feedBefore: Int) : PrintItem()
55
+ data class Divider(val char: String, val columns: Int?, val align: String?, val bold: Boolean) : PrintItem()
56
+ data class QrCode(val value: String, val size: Int, val ec: String, val align: String) : PrintItem()
57
+ data class Barcode(
58
+ val value: String, val symbology: String, val height: Int, val width: Int, val hri: String, val align: String,
59
+ ) : PrintItem()
60
+ data class CashDrawer(val pin: Int) : PrintItem()
61
+ data class Image(val filePath: String?, val url: String?, val base64: String?, val render: JSONObject?) : PrintItem()
62
+ data class Raw(val bytesBase64: String) : PrintItem()
63
+
64
+ companion object {
65
+ fun parseList(arr: JSONArray): List<PrintItem> =
66
+ (0 until arr.length()).mapNotNull { parse(arr.optJSONObject(it)) }
67
+
68
+ private fun parse(o: JSONObject?): PrintItem? {
69
+ if (o == null) return null
70
+ return when (o.optString("type")) {
71
+ "text" -> Text(o.optString("value"), TextStyle.fromJson(o.optJSONObject("style")))
72
+ "feed" -> Feed(o.optInt("lines", 1))
73
+ "cut" -> Cut(o.optString("mode", "partial"), o.optInt("feedBefore", 0))
74
+ "divider" -> Divider(
75
+ o.optString("char", "-"),
76
+ if (o.has("columns")) o.optInt("columns") else null,
77
+ o.optJSONObject("style")?.optString("align")?.ifEmpty { null },
78
+ o.optJSONObject("style")?.optBoolean("bold", false) ?: false,
79
+ )
80
+ "qrcode" -> QrCode(o.optString("value"), o.optInt("size", 6), o.optString("errorCorrection", "M"), o.optString("align", "center"))
81
+ "barcode" -> Barcode(
82
+ o.optString("value"), o.optString("symbology", "CODE128"),
83
+ o.optInt("height", 80), o.optInt("width", 3),
84
+ o.optString("hri", "below"), o.optString("align", "center"),
85
+ )
86
+ "cashDrawer" -> CashDrawer(o.optInt("pin", 2))
87
+ "image" -> o.optJSONObject("image").let { img ->
88
+ Image(
89
+ img?.optString("filePath")?.ifEmpty { null },
90
+ img?.optString("url")?.ifEmpty { null },
91
+ img?.optString("base64")?.ifEmpty { null },
92
+ o.optJSONObject("render"),
93
+ )
94
+ }
95
+ "raw" -> Raw(o.optString("bytesBase64"))
96
+ else -> null
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,71 @@
1
+ package com.delicity.thermalprinter.store
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import com.delicity.thermalprinter.model.PrinterProfile
6
+ import org.json.JSONArray
7
+ import org.json.JSONObject
8
+
9
+ /**
10
+ * Persistance des profils d'imprimantes (SharedPreferences + JSON).
11
+ *
12
+ * Conçu pour la reconnexion automatique : tout le nécessaire pour rejoindre une
13
+ * imprimante sans re-découverte est stocké dans le PrinterProfile.
14
+ *
15
+ * NB : les profils ne contiennent pas de secret. Si un jour des credentials
16
+ * réseau sont stockés, migrer vers EncryptedSharedPreferences.
17
+ */
18
+ class PrinterStore(context: Context) {
19
+
20
+ private val prefs: SharedPreferences =
21
+ context.getSharedPreferences("delicity.thermalprinter", Context.MODE_PRIVATE)
22
+
23
+ @Synchronized
24
+ fun all(): List<PrinterProfile> {
25
+ val raw = prefs.getString(KEY_PROFILES, "[]") ?: "[]"
26
+ val arr = JSONArray(raw)
27
+ return (0 until arr.length()).map { PrinterProfile.fromJson(arr.getJSONObject(it)) }
28
+ }
29
+
30
+ fun get(id: String): PrinterProfile? = all().firstOrNull { it.id == id }
31
+
32
+ fun getDefault(): PrinterProfile? = all().firstOrNull { it.isDefault }
33
+
34
+ @Synchronized
35
+ fun upsert(profile: PrinterProfile) {
36
+ val list = all().toMutableList()
37
+ val idx = list.indexOfFirst { it.id == profile.id }
38
+ profile.updatedAt = System.currentTimeMillis()
39
+ if (idx >= 0) list[idx] = profile else list.add(profile)
40
+ persist(list)
41
+ }
42
+
43
+ @Synchronized
44
+ fun setDefault(id: String): PrinterProfile? {
45
+ val list = all().toMutableList()
46
+ var target: PrinterProfile? = null
47
+ list.forEach {
48
+ it.isDefault = it.id == id
49
+ if (it.isDefault) target = it
50
+ }
51
+ persist(list)
52
+ return target
53
+ }
54
+
55
+ @Synchronized
56
+ fun remove(id: String) {
57
+ persist(all().filterNot { it.id == id })
58
+ }
59
+
60
+ private fun persist(list: List<PrinterProfile>) {
61
+ val arr = JSONArray()
62
+ list.forEach { arr.put(it.toJson()) }
63
+ prefs.edit().putString(KEY_PROFILES, arr.toString()).apply()
64
+ }
65
+
66
+ companion object {
67
+ private const val KEY_PROFILES = "profiles_v1"
68
+
69
+ fun emptyJson(): JSONObject = JSONObject()
70
+ }
71
+ }
@@ -0,0 +1,201 @@
1
+ package com.delicity.thermalprinter.transport
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.bluetooth.BluetoothDevice
5
+ import android.bluetooth.BluetoothGatt
6
+ import android.bluetooth.BluetoothGattCallback
7
+ import android.bluetooth.BluetoothGattCharacteristic
8
+ import android.bluetooth.BluetoothProfile
9
+ import android.content.Context
10
+ import android.os.Build
11
+ import com.delicity.thermalprinter.model.ErrorCode
12
+ import com.delicity.thermalprinter.model.PrinterException
13
+ import java.util.UUID
14
+ import java.util.concurrent.atomic.AtomicReference
15
+ import kotlin.coroutines.Continuation
16
+ import kotlin.coroutines.resume
17
+ import kotlin.coroutines.resumeWithException
18
+ import kotlinx.coroutines.suspendCancellableCoroutine
19
+ import kotlinx.coroutines.withTimeout
20
+
21
+ /**
22
+ * Client GATT BLE suspend pour imprimantes ESC/POS exposant une "UART série" BLE.
23
+ *
24
+ * Flux : connectGatt -> (MTU) -> discoverServices -> localiser la characteristic
25
+ * d'écriture (allowlist d'UUID connus, sinon 1re characteristic WRITE/WRITE_NO_RESPONSE)
26
+ * -> écriture du raster ESC/POS par paquets <= (MTU-3).
27
+ *
28
+ * ⚠️ Il n'existe pas de profil BLE standard d'impression : on s'appuie sur une
29
+ * allowlist de services/characteristics validés (cf. [WRITE_TARGETS]). Pour un
30
+ * modèle non listé, on tente la 1re characteristic inscriptible trouvée.
31
+ */
32
+ @SuppressLint("MissingPermission")
33
+ class BleGattClient(
34
+ private val context: Context,
35
+ private val device: BluetoothDevice,
36
+ ) {
37
+ private var gatt: BluetoothGatt? = null
38
+ private var writeChar: BluetoothGattCharacteristic? = null
39
+ @Volatile private var mtu: Int = 23
40
+ @Volatile var isOpen: Boolean = false
41
+ private set
42
+
43
+ // Continuations one-shot par opération (le GATT sérialise les callbacks).
44
+ private val connectCont = AtomicReference<Continuation<Unit>?>()
45
+ private val mtuCont = AtomicReference<Continuation<Unit>?>()
46
+ private val servicesCont = AtomicReference<Continuation<Unit>?>()
47
+ private val writeCont = AtomicReference<Continuation<Unit>?>()
48
+
49
+ private val callback = object : BluetoothGattCallback() {
50
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
51
+ if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
52
+ connectCont.getAndSet(null)?.resume(Unit)
53
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
54
+ isOpen = false
55
+ val e = PrinterException(ErrorCode.CONNECTION_FAILED, "BLE déconnecté (status=$status)", retryable = true)
56
+ connectCont.getAndSet(null)?.resumeWithException(e)
57
+ writeCont.getAndSet(null)?.resumeWithException(e)
58
+ }
59
+ }
60
+
61
+ override fun onMtuChanged(g: BluetoothGatt, newMtu: Int, status: Int) {
62
+ mtu = if (status == BluetoothGatt.GATT_SUCCESS) newMtu else mtu
63
+ mtuCont.getAndSet(null)?.resume(Unit)
64
+ }
65
+
66
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
67
+ servicesCont.getAndSet(null)?.let { cont ->
68
+ if (status == BluetoothGatt.GATT_SUCCESS) cont.resume(Unit)
69
+ else cont.resumeWithException(PrinterException(ErrorCode.CONNECTION_FAILED, "discoverServices BLE échoué"))
70
+ }
71
+ }
72
+
73
+ @Deprecated("Deprecated in Java")
74
+ override fun onCharacteristicWrite(g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int) {
75
+ writeCont.getAndSet(null)?.let { cont ->
76
+ if (status == BluetoothGatt.GATT_SUCCESS) cont.resume(Unit)
77
+ else cont.resumeWithException(PrinterException(ErrorCode.PRINT_FAILED, "Écriture BLE échouée (status=$status)", retryable = true))
78
+ }
79
+ }
80
+ }
81
+
82
+ suspend fun open(timeoutMs: Long) {
83
+ withTimeout(timeoutMs.coerceAtLeast(3000)) {
84
+ suspendCancellableCoroutine<Unit> { cont ->
85
+ connectCont.set(cont)
86
+ cont.invokeOnCancellation { runCatching { gatt?.close() } }
87
+ gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
88
+ device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
89
+ } else {
90
+ device.connectGatt(context, false, callback)
91
+ }
92
+ }
93
+ // MTU max (512) pour réduire le nombre de paquets ; best effort.
94
+ runCatching {
95
+ suspendCancellableCoroutine<Unit> { cont ->
96
+ mtuCont.set(cont)
97
+ if (gatt?.requestMtu(512) != true) mtuCont.getAndSet(null)?.resume(Unit)
98
+ }
99
+ }
100
+ suspendCancellableCoroutine<Unit> { cont ->
101
+ servicesCont.set(cont)
102
+ if (gatt?.discoverServices() != true) {
103
+ servicesCont.getAndSet(null)?.resumeWithException(
104
+ PrinterException(ErrorCode.CONNECTION_FAILED, "discoverServices BLE non démarré"),
105
+ )
106
+ }
107
+ }
108
+ writeChar = locateWriteCharacteristic()
109
+ ?: throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Aucune characteristic BLE inscriptible (allowlist UUID requise)")
110
+ isOpen = true
111
+ }
112
+ }
113
+
114
+ /** Écrit [data] par paquets <= (MTU-3), en attendant l'ACK entre chaque paquet. */
115
+ suspend fun write(data: ByteArray) {
116
+ val g = gatt ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "GATT non ouvert")
117
+ val ch = writeChar ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Characteristic d'écriture absente")
118
+ val packet = (mtu - 3).coerceIn(20, 512)
119
+ val noResponse = (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE) == 0
120
+ val writeType = if (noResponse) {
121
+ BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
122
+ } else {
123
+ BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
124
+ }
125
+ var offset = 0
126
+ while (offset < data.size) {
127
+ val len = minOf(packet, data.size - offset)
128
+ val slice = data.copyOfRange(offset, offset + len)
129
+ suspendCancellableCoroutine<Unit> { cont ->
130
+ writeCont.set(cont)
131
+ val ok = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
132
+ g.writeCharacteristic(ch, slice, writeType) == BluetoothGatt.GATT_SUCCESS
133
+ } else {
134
+ @Suppress("DEPRECATION")
135
+ run {
136
+ ch.writeType = writeType
137
+ ch.value = slice
138
+ g.writeCharacteristic(ch)
139
+ }
140
+ }
141
+ if (!ok) writeCont.getAndSet(null)?.resumeWithException(
142
+ PrinterException(ErrorCode.PRINT_FAILED, "writeCharacteristic BLE refusé", retryable = true),
143
+ )
144
+ }
145
+ offset += len
146
+ }
147
+ }
148
+
149
+ fun close() {
150
+ isOpen = false
151
+ runCatching { gatt?.disconnect() }
152
+ runCatching { gatt?.close() }
153
+ gatt = null
154
+ writeChar = null
155
+ }
156
+
157
+ private fun locateWriteCharacteristic(): BluetoothGattCharacteristic? {
158
+ val g = gatt ?: return null
159
+ // 1) Allowlist : services/characteristics connus.
160
+ for ((svc, chr) in WRITE_TARGETS) {
161
+ g.getService(svc)?.getCharacteristic(chr)?.let { return it }
162
+ }
163
+ // 2) Fallback : 1re characteristic inscriptible trouvée.
164
+ for (service in g.services) {
165
+ for (c in service.characteristics) {
166
+ val p = c.properties
167
+ if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 ||
168
+ (p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0
169
+ ) {
170
+ return c
171
+ }
172
+ }
173
+ }
174
+ return null
175
+ }
176
+
177
+ companion object {
178
+ private fun u(s: String) = UUID.fromString(s)
179
+
180
+ /**
181
+ * Allowlist (service -> characteristic d'écriture) des "UART série" BLE
182
+ * couramment exposées par les imprimantes thermiques. À enrichir au fil des
183
+ * modèles validés.
184
+ */
185
+ val WRITE_TARGETS: List<Pair<UUID, UUID>> = listOf(
186
+ // Nordic UART Service (NUS)
187
+ u("6e400001-b5a3-f393-e0a9-e50e24dcca9e") to u("6e400002-b5a3-f393-e0a9-e50e24dcca9e"),
188
+ // Microchip / ISSC Transparent UART
189
+ u("49535343-fe7d-4ae5-8fa9-9fafd205e455") to u("49535343-8841-43f4-a8d4-ecbe34729bb3"),
190
+ // Générique "FFE0/FFE1" (modules HM-10 et clones)
191
+ u("0000ffe0-0000-1000-8000-00805f9b34fb") to u("0000ffe1-0000-1000-8000-00805f9b34fb"),
192
+ // Générique "FF00/FF02"
193
+ u("0000ff00-0000-1000-8000-00805f9b34fb") to u("0000ff02-0000-1000-8000-00805f9b34fb"),
194
+ // Imprimantes type "18F0/2AF1"
195
+ u("000018f0-0000-1000-8000-00805f9b34fb") to u("00002af1-0000-1000-8000-00805f9b34fb"),
196
+ )
197
+
198
+ /** Services BLE à mettre en avant lors du scan (filtre de découverte). */
199
+ val ADVERTISED_SERVICES: List<UUID> = WRITE_TARGETS.map { it.first }
200
+ }
201
+ }
@@ -0,0 +1,110 @@
1
+ package com.delicity.thermalprinter.transport
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.bluetooth.BluetoothAdapter
5
+ import android.bluetooth.BluetoothDevice
6
+ import android.bluetooth.BluetoothSocket
7
+ import com.delicity.thermalprinter.model.ErrorCode
8
+ import com.delicity.thermalprinter.model.PrinterException
9
+ import java.io.InputStream
10
+ import java.io.OutputStream
11
+ import java.util.UUID
12
+
13
+ /**
14
+ * Transport Bluetooth Classic / SPP (Serial Port Profile).
15
+ *
16
+ * ⚠️ ANDROID UNIQUEMENT. iOS n'expose pas le SPP générique (voir limites README).
17
+ *
18
+ * Le SPP est le canal des imprimantes ESC/POS Bluetooth "génériques" très répandues
19
+ * en restauration (modèles chinois à bas coût). UUID SPP standard :
20
+ * 00001101-0000-1000-8000-00805F9B34FB
21
+ *
22
+ * Requiert BLUETOOTH_CONNECT (API 31+) accordée AVANT l'appel.
23
+ */
24
+ @SuppressLint("MissingPermission")
25
+ class BluetoothSppTransport(
26
+ private val adapter: BluetoothAdapter?,
27
+ private val macAddress: String,
28
+ ) : ByteTransport {
29
+
30
+ private var socket: BluetoothSocket? = null
31
+ private var out: OutputStream? = null
32
+ private var input: InputStream? = null
33
+
34
+ override val isOpen: Boolean
35
+ get() = socket?.isConnected == true
36
+
37
+ override fun open(timeoutMs: Long) {
38
+ if (isOpen) return
39
+ val ad = adapter ?: throw PrinterException(ErrorCode.BLUETOOTH_DISABLED, "Bluetooth indisponible")
40
+ if (!ad.isEnabled) throw PrinterException(ErrorCode.BLUETOOTH_DISABLED, "Bluetooth désactivé")
41
+
42
+ val device: BluetoothDevice = try {
43
+ ad.getRemoteDevice(macAddress)
44
+ } catch (e: IllegalArgumentException) {
45
+ throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "MAC invalide: $macAddress", e.message)
46
+ }
47
+
48
+ // Si non appairé, l'app doit déclencher l'appairage en amont.
49
+ if (device.bondState != BluetoothDevice.BOND_BONDED) {
50
+ throw PrinterException(ErrorCode.PAIRING_REQUIRED, "Appareil non appairé: $macAddress", retryable = false)
51
+ }
52
+
53
+ try {
54
+ ad.cancelDiscovery() // la découverte ralentit/échoue la connexion
55
+ val sock = device.createRfcommSocketToServiceRecord(SPP_UUID)
56
+ sock.connect() // bloquant ; pas de vrai timeout natif -> orchestré par coroutine
57
+ socket = sock
58
+ out = sock.outputStream
59
+ input = sock.inputStream
60
+ } catch (e: SecurityException) {
61
+ throw PrinterException(ErrorCode.PERMISSION_DENIED, "Permission BLUETOOTH_CONNECT manquante", e.message)
62
+ } catch (e: Exception) {
63
+ // Fallback "insecure" pour certaines imprimantes capricieuses.
64
+ try {
65
+ val sock = device.createInsecureRfcommSocketToServiceRecord(SPP_UUID)
66
+ sock.connect()
67
+ socket = sock
68
+ out = sock.outputStream
69
+ input = sock.inputStream
70
+ } catch (e2: Exception) {
71
+ throw PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion SPP échouée $macAddress", e2.message, retryable = true)
72
+ }
73
+ }
74
+ }
75
+
76
+ override fun write(bytes: ByteArray) {
77
+ val o = out ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Socket SPP non ouvert")
78
+ try {
79
+ var offset = 0
80
+ val chunk = 2048 // les buffers SPP sont petits
81
+ while (offset < bytes.size) {
82
+ val len = minOf(chunk, bytes.size - offset)
83
+ o.write(bytes, offset, len)
84
+ o.flush()
85
+ offset += len
86
+ // micro-pause anti-overflow sur imprimantes lentes
87
+ if (bytes.size > 16_384) Thread.sleep(8)
88
+ }
89
+ } catch (e: Exception) {
90
+ throw PrinterException(ErrorCode.PRINT_FAILED, "Écriture SPP échouée", e.message, retryable = true)
91
+ }
92
+ }
93
+
94
+ override fun read(buffer: ByteArray, timeoutMs: Long): Int {
95
+ val i = input ?: return -1
96
+ return try { i.read(buffer) } catch (e: Exception) { -1 }
97
+ }
98
+
99
+ override fun close() {
100
+ try { out?.flush() } catch (_: Exception) {}
101
+ try { input?.close() } catch (_: Exception) {}
102
+ try { out?.close() } catch (_: Exception) {}
103
+ try { socket?.close() } catch (_: Exception) {}
104
+ socket = null; out = null; input = null
105
+ }
106
+
107
+ companion object {
108
+ val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
109
+ }
110
+ }
@@ -0,0 +1,18 @@
1
+ package com.delicity.thermalprinter.transport
2
+
3
+ /**
4
+ * Canal d'octets bas niveau vers une imprimante (TCP, SPP, BLE).
5
+ * Les adapters ESC/POS écrivent des commandes brutes via ce contrat.
6
+ */
7
+ interface ByteTransport {
8
+ val isOpen: Boolean
9
+ fun open(timeoutMs: Long)
10
+ fun write(bytes: ByteArray)
11
+
12
+ /**
13
+ * Lecture optionnelle (statut temps réel ESC/POS DLE EOT).
14
+ * Retourne le nombre d'octets lus, ou -1 si non supporté/aucune donnée.
15
+ */
16
+ fun read(buffer: ByteArray, timeoutMs: Long): Int = -1
17
+ fun close()
18
+ }
@@ -0,0 +1,83 @@
1
+ package com.delicity.thermalprinter.transport
2
+
3
+ import com.delicity.thermalprinter.model.ErrorCode
4
+ import com.delicity.thermalprinter.model.PrinterException
5
+ import java.io.InputStream
6
+ import java.io.OutputStream
7
+ import java.net.InetSocketAddress
8
+ import java.net.Socket
9
+ import java.net.SocketTimeoutException
10
+
11
+ /**
12
+ * Transport TCP brut (RAW / port 9100). Utilisé par EscPosAdapter et RawTcpAdapter
13
+ * en Wi-Fi/Ethernet.
14
+ *
15
+ * @param host adresse IP/hostname
16
+ * @param port port (défaut 9100)
17
+ */
18
+ class TcpTransport(
19
+ private val host: String,
20
+ private val port: Int = 9100,
21
+ ) : ByteTransport {
22
+
23
+ private var socket: Socket? = null
24
+ private var out: OutputStream? = null
25
+ private var input: InputStream? = null
26
+
27
+ override val isOpen: Boolean
28
+ get() = socket?.isConnected == true && socket?.isClosed == false
29
+
30
+ override fun open(timeoutMs: Long) {
31
+ if (isOpen) return
32
+ try {
33
+ val s = Socket()
34
+ s.tcpNoDelay = true
35
+ s.connect(InetSocketAddress(host, port), timeoutMs.toInt())
36
+ s.soTimeout = timeoutMs.toInt()
37
+ socket = s
38
+ out = s.getOutputStream()
39
+ input = s.getInputStream()
40
+ } catch (e: SocketTimeoutException) {
41
+ throw PrinterException(ErrorCode.TIMEOUT, "Timeout connexion TCP $host:$port", e.message, retryable = true)
42
+ } catch (e: Exception) {
43
+ throw PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion TCP échouée $host:$port", e.message, retryable = true)
44
+ }
45
+ }
46
+
47
+ override fun write(bytes: ByteArray) {
48
+ val o = out ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Socket TCP non ouvert")
49
+ try {
50
+ // Envoi par chunks pour ménager les petits buffers d'imprimante.
51
+ var offset = 0
52
+ val chunk = 4096
53
+ while (offset < bytes.size) {
54
+ val len = minOf(chunk, bytes.size - offset)
55
+ o.write(bytes, offset, len)
56
+ o.flush()
57
+ offset += len
58
+ }
59
+ } catch (e: Exception) {
60
+ throw PrinterException(ErrorCode.PRINT_FAILED, "Écriture TCP échouée", e.message, retryable = true)
61
+ }
62
+ }
63
+
64
+ override fun read(buffer: ByteArray, timeoutMs: Long): Int {
65
+ val i = input ?: return -1
66
+ return try {
67
+ socket?.soTimeout = timeoutMs.toInt()
68
+ i.read(buffer)
69
+ } catch (e: SocketTimeoutException) {
70
+ -1
71
+ } catch (e: Exception) {
72
+ -1
73
+ }
74
+ }
75
+
76
+ override fun close() {
77
+ try { out?.flush() } catch (_: Exception) {}
78
+ try { input?.close() } catch (_: Exception) {}
79
+ try { out?.close() } catch (_: Exception) {}
80
+ try { socket?.close() } catch (_: Exception) {}
81
+ socket = null; out = null; input = null
82
+ }
83
+ }
@@ -0,0 +1,26 @@
1
+ import type { PrinterTransport } from '../core/enums';
2
+ import type { DiscoveredPrinter } from '../core/models';
3
+ /**
4
+ * Construit un identifiant interne stable pour une imprimante.
5
+ *
6
+ * On veut le MÊME id quelle que soit la source de découverte afin de fusionner
7
+ * les doublons (ex: une TM-m30 vue à la fois par le SDK Epson et par le scan TCP).
8
+ *
9
+ * Clé de stabilité par transport :
10
+ * - réseau (wifi/ethernet) : adresse IP normalisée (sans port) — l'imprimante a
11
+ * une seule IP même si plusieurs ports répondent.
12
+ * - bluetooth/ble : adresse MAC (Android) ou UUID périphérique (iOS).
13
+ * - usb : vendorId:productId.
14
+ *
15
+ * Le préfixe transport évite les collisions improbables entre familles.
16
+ */
17
+ export declare function buildStableId(transport: PrinterTransport, rawAddress: string): string;
18
+ /**
19
+ * Fusionne une liste de découvertes brutes en une liste dédoublonnée.
20
+ * En cas de doublon (même id), on conserve l'entrée au meilleur adapter
21
+ * (déjà résolu par `resolveBestAdapter`) et on fusionne `discoveredBy`.
22
+ *
23
+ * @param incoming découvertes brutes (chaque source peut produire des doublons)
24
+ * @param adapterRank fonction de classement (score) déjà appliquée au champ adapter
25
+ */
26
+ export declare function mergeDiscoveries(incoming: DiscoveredPrinter[], adapterRank: (p: DiscoveredPrinter) => number): DiscoveredPrinter[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Construit un identifiant interne stable pour une imprimante.
3
+ *
4
+ * On veut le MÊME id quelle que soit la source de découverte afin de fusionner
5
+ * les doublons (ex: une TM-m30 vue à la fois par le SDK Epson et par le scan TCP).
6
+ *
7
+ * Clé de stabilité par transport :
8
+ * - réseau (wifi/ethernet) : adresse IP normalisée (sans port) — l'imprimante a
9
+ * une seule IP même si plusieurs ports répondent.
10
+ * - bluetooth/ble : adresse MAC (Android) ou UUID périphérique (iOS).
11
+ * - usb : vendorId:productId.
12
+ *
13
+ * Le préfixe transport évite les collisions improbables entre familles.
14
+ */
15
+ export function buildStableId(transport, rawAddress) {
16
+ const norm = normalizeAddress(transport, rawAddress);
17
+ return `${transport}:${norm}`;
18
+ }
19
+ function normalizeAddress(transport, address) {
20
+ switch (transport) {
21
+ case 'wifi':
22
+ case 'ethernet':
23
+ // retire un éventuel :port
24
+ return address.replace(/:\d+$/, '').trim().toLowerCase();
25
+ case 'bluetooth':
26
+ case 'ble':
27
+ return address.trim().toUpperCase();
28
+ case 'usb':
29
+ return address.trim().toLowerCase();
30
+ default:
31
+ return address.trim().toLowerCase();
32
+ }
33
+ }
34
+ /**
35
+ * Fusionne une liste de découvertes brutes en une liste dédoublonnée.
36
+ * En cas de doublon (même id), on conserve l'entrée au meilleur adapter
37
+ * (déjà résolu par `resolveBestAdapter`) et on fusionne `discoveredBy`.
38
+ *
39
+ * @param incoming découvertes brutes (chaque source peut produire des doublons)
40
+ * @param adapterRank fonction de classement (score) déjà appliquée au champ adapter
41
+ */
42
+ export function mergeDiscoveries(incoming, adapterRank) {
43
+ var _a, _b;
44
+ const byId = new Map();
45
+ for (const printer of incoming) {
46
+ const existing = byId.get(printer.id);
47
+ if (!existing) {
48
+ byId.set(printer.id, Object.assign(Object.assign({}, printer), { discoveredBy: dedupeSources(printer) }));
49
+ continue;
50
+ }
51
+ // Fusionner les sources de découverte.
52
+ const mergedSources = Array.from(new Set([...((_a = existing.discoveredBy) !== null && _a !== void 0 ? _a : []), ...((_b = printer.discoveredBy) !== null && _b !== void 0 ? _b : []), printer.adapter, existing.adapter]));
53
+ // Garder l'entrée au meilleur adapter.
54
+ const winner = adapterRank(printer) > adapterRank(existing) ? printer : existing;
55
+ byId.set(printer.id, Object.assign(Object.assign({}, winner), {
56
+ // capacités: union (on garde le plus d'infos possible)
57
+ capabilities: Object.assign(Object.assign(Object.assign({}, existing.capabilities), printer.capabilities), winner.capabilities), lastSeenAt: Math.max(existing.lastSeenAt, printer.lastSeenAt), discoveredBy: mergedSources, isDefault: existing.isDefault || printer.isDefault, isConnected: existing.isConnected || printer.isConnected }));
58
+ }
59
+ return Array.from(byId.values());
60
+ }
61
+ function dedupeSources(p) {
62
+ var _a;
63
+ const base = (_a = p.discoveredBy) !== null && _a !== void 0 ? _a : [];
64
+ return Array.from(new Set([...base, p.adapter]));
65
+ }
66
+ //# sourceMappingURL=dedup.js.map