@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,322 @@
1
+ package com.delicity.thermalprinter.adapters
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import com.delicity.thermalprinter.model.AdapterId
6
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
7
+ import com.delicity.thermalprinter.model.ErrorCode
8
+ import com.delicity.thermalprinter.model.PrinterException
9
+ import com.delicity.thermalprinter.model.PrinterProfile
10
+ import com.delicity.thermalprinter.model.PrinterStatus
11
+ import com.delicity.thermalprinter.model.RenderOptions
12
+ import com.delicity.thermalprinter.model.Transport
13
+ import com.starmicronics.stario10.InterfaceType
14
+ import com.starmicronics.stario10.StarConnectionSettings
15
+ import com.starmicronics.stario10.StarDeviceDiscoveryManager
16
+ import com.starmicronics.stario10.StarDeviceDiscoveryManagerFactory
17
+ import com.starmicronics.stario10.StarPrinter
18
+ import com.starmicronics.stario10.starxpandcommand.DocumentBuilder
19
+ import com.starmicronics.stario10.starxpandcommand.DrawerBuilder
20
+ import com.starmicronics.stario10.starxpandcommand.PrinterBuilder
21
+ import com.starmicronics.stario10.starxpandcommand.StarXpandCommandBuilder
22
+ import com.starmicronics.stario10.starxpandcommand.drawer.Channel
23
+ import com.starmicronics.stario10.starxpandcommand.drawer.OpenParameter
24
+ import com.starmicronics.stario10.starxpandcommand.printer.Alignment
25
+ import com.starmicronics.stario10.starxpandcommand.printer.BarcodeParameter
26
+ import com.starmicronics.stario10.starxpandcommand.printer.BarcodeSymbology
27
+ import com.starmicronics.stario10.starxpandcommand.printer.CutType
28
+ import com.starmicronics.stario10.starxpandcommand.printer.ImageParameter
29
+ import com.starmicronics.stario10.starxpandcommand.printer.MagnificationParameter
30
+ import com.starmicronics.stario10.starxpandcommand.printer.QRCodeLevel
31
+ import com.starmicronics.stario10.starxpandcommand.printer.QRCodeParameter
32
+ import java.util.concurrent.ConcurrentHashMap
33
+ import kotlin.coroutines.resume
34
+ import kotlinx.coroutines.suspendCancellableCoroutine
35
+
36
+ /**
37
+ * Adapter Star basé sur le StarXpand SDK (`com.starmicronics:stario10`).
38
+ *
39
+ * ⭐ Star est le SEUL fabricant dont le SDK est 100 % auto-téléchargé (Maven
40
+ * Central) : cet adapter utilise donc des appels TYPÉS directs (pas de réflexion).
41
+ * Si la dépendance Maven est retirée du build, `isAvailable()` renvoie false par
42
+ * réflexion et l'adapter est ignoré.
43
+ *
44
+ * StarXpand expose :
45
+ * - StarDeviceDiscoveryManager (LAN, Bluetooth, BLE, USB),
46
+ * - StarXpandCommandBuilder + PrinterBuilder.actionPrintImage(...) pour l'image,
47
+ * - PrinterBuilder.actionPrintText / QRCode / Barcode pour le texte stylé,
48
+ * - getStatusAsync() pour papier/capot/massicot.
49
+ */
50
+ class StarAdapter(private val context: Context) : PrinterAdapter {
51
+
52
+ override val id = AdapterId.STAR
53
+
54
+ /** Connexions ouvertes indexées par printerId. */
55
+ private val connections = ConcurrentHashMap<String, StarPrinter>()
56
+
57
+ override fun isAvailable(): Boolean =
58
+ EpsonAdapter.classExists("com.starmicronics.stario10.StarPrinter")
59
+
60
+ override fun supportsTextItems(): Boolean = true
61
+
62
+ // -------------------------------------------------------------------------
63
+ // Découverte
64
+ // -------------------------------------------------------------------------
65
+
66
+ override suspend fun discover(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
67
+ if (!isAvailable()) return
68
+ val manager = StarDeviceDiscoveryManagerFactory.create(
69
+ listOf(
70
+ InterfaceType.Lan,
71
+ InterfaceType.Bluetooth,
72
+ InterfaceType.BluetoothLE,
73
+ InterfaceType.Usb,
74
+ ),
75
+ context,
76
+ )
77
+ manager.discoveryTime = timeoutMs.toInt().coerceIn(1000, 30000)
78
+
79
+ suspendCancellableCoroutine<Unit> { cont ->
80
+ manager.callback = object : StarDeviceDiscoveryManager.Callback {
81
+ override fun onPrinterFound(printer: StarPrinter) {
82
+ val settings = printer.connectionSettings
83
+ val transport = transportFor(settings.interfaceType)
84
+ val model = printer.information?.model?.name
85
+ onFound(
86
+ DiscoveredPrinter(
87
+ id = "star:${settings.interfaceType}:${settings.identifier}",
88
+ name = model ?: "Star Printer",
89
+ brand = "Star",
90
+ model = model,
91
+ transport = transport,
92
+ adapter = AdapterId.STAR,
93
+ address = settings.identifier,
94
+ discoveredBy = mutableSetOf(AdapterId.STAR),
95
+ ),
96
+ )
97
+ }
98
+
99
+ override fun onDiscoveryFinished() {
100
+ if (cont.isActive) cont.resume(Unit)
101
+ }
102
+ }
103
+ cont.invokeOnCancellation { runCatching { manager.stopDiscovery() } }
104
+ runCatching { manager.startDiscovery() }
105
+ .onFailure { if (cont.isActive) cont.resume(Unit) }
106
+ }
107
+ runCatching { manager.stopDiscovery() }
108
+ }
109
+
110
+ override fun canHandle(profile: PrinterProfile): Boolean =
111
+ isAvailable() && profile.adapter == AdapterId.STAR
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Connexion
115
+ // -------------------------------------------------------------------------
116
+
117
+ override suspend fun connect(profile: PrinterProfile, timeoutMs: Long) {
118
+ ensureSdk()
119
+ if (isConnected(profile.id)) return
120
+ val printer = StarPrinter(connectionSettingsFor(profile), context)
121
+ try {
122
+ printer.openAsync().await()
123
+ } catch (e: Exception) {
124
+ throw PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion Star échouée: ${profile.address}", e.message, retryable = true)
125
+ }
126
+ connections[profile.id] = printer
127
+ }
128
+
129
+ override fun isConnected(printerId: String): Boolean = connections.containsKey(printerId)
130
+
131
+ override suspend fun disconnect(printerId: String) {
132
+ connections.remove(printerId)?.let { runCatching { it.closeAsync().await() } }
133
+ }
134
+
135
+ // -------------------------------------------------------------------------
136
+ // Impression
137
+ // -------------------------------------------------------------------------
138
+
139
+ override suspend fun printBitmap(profile: PrinterProfile, bitmap: Bitmap, options: RenderOptions): Int {
140
+ val printer = requireConnected(profile)
141
+ val printerDoc = PrinterBuilder()
142
+ .styleAlignment(alignmentFor(options.align))
143
+ .actionPrintImage(ImageParameter(bitmap, profile.capabilities.printableDots.coerceAtLeast(8)))
144
+ if (options.feedLines > 0) printerDoc.actionFeedLine(options.feedLines)
145
+ if (options.cut && profile.capabilities.supportsCut) printerDoc.actionCut(CutType.Partial)
146
+
147
+ val document = DocumentBuilder().addPrinter(printerDoc)
148
+ if (options.openCashDrawer && profile.capabilities.supportsCashDrawer) {
149
+ document.addDrawer(DrawerBuilder().actionOpen(OpenParameter().setChannel(Channel.No1)))
150
+ }
151
+ val commands = StarXpandCommandBuilder().addDocument(document).getCommands()
152
+
153
+ var sent = 0
154
+ repeat(options.copies.coerceAtLeast(1)) {
155
+ sendCommands(printer, commands, profile)
156
+ sent += commands.length
157
+ }
158
+ return sent
159
+ }
160
+
161
+ override suspend fun printItems(
162
+ profile: PrinterProfile,
163
+ items: List<com.delicity.thermalprinter.model.PrintItem>,
164
+ defaultCodePage: String,
165
+ cut: Boolean,
166
+ feedLines: Int,
167
+ ): Int {
168
+ val printer = requireConnected(profile)
169
+ val pb = PrinterBuilder()
170
+ for (item in items) mapItem(pb, item, profile)
171
+ if (feedLines > 0) pb.actionFeedLine(feedLines)
172
+ if (cut && profile.capabilities.supportsCut) pb.actionCut(CutType.Partial)
173
+ val commands = StarXpandCommandBuilder()
174
+ .addDocument(DocumentBuilder().addPrinter(pb))
175
+ .getCommands()
176
+ sendCommands(printer, commands, profile)
177
+ return commands.length
178
+ }
179
+
180
+ /** Mappe un PrintItem vers le builder StarXpand (best effort par type). */
181
+ private fun mapItem(pb: PrinterBuilder, item: com.delicity.thermalprinter.model.PrintItem, profile: PrinterProfile) {
182
+ when (item) {
183
+ is com.delicity.thermalprinter.model.PrintItem.Text -> {
184
+ item.style.align?.let { pb.styleAlignment(alignmentFor(it)) }
185
+ pb.styleBold(item.style.bold)
186
+ pb.styleInvert(item.style.invert)
187
+ pb.styleUnderLine(item.style.underline != "none")
188
+ pb.styleMagnification(MagnificationParameter(item.style.widthMultiplier.coerceIn(1, 6), item.style.heightMultiplier.coerceIn(1, 6)))
189
+ if (item.style.newline) pb.actionPrintText(item.value + "\n") else pb.actionPrintText(item.value)
190
+ // reset styles pour ne pas contaminer les items suivants
191
+ pb.styleBold(false).styleInvert(false).styleUnderLine(false)
192
+ .styleMagnification(MagnificationParameter(1, 1))
193
+ }
194
+ is com.delicity.thermalprinter.model.PrintItem.Feed -> pb.actionFeedLine(item.lines.coerceAtLeast(1))
195
+ is com.delicity.thermalprinter.model.PrintItem.Cut ->
196
+ pb.actionCut(if (item.mode == "full") CutType.Full else CutType.Partial)
197
+ is com.delicity.thermalprinter.model.PrintItem.Divider -> {
198
+ val cols = item.columns ?: if (profile.capabilities.printableDots <= 420) 32 else 48
199
+ item.align?.let { pb.styleAlignment(alignmentFor(it)) }
200
+ pb.styleBold(item.bold)
201
+ pb.actionPrintText(item.char.repeat(cols.coerceIn(1, 96)) + "\n")
202
+ pb.styleBold(false)
203
+ }
204
+ is com.delicity.thermalprinter.model.PrintItem.QrCode -> {
205
+ pb.styleAlignment(alignmentFor(item.align))
206
+ pb.actionPrintQRCode(
207
+ QRCodeParameter(item.value)
208
+ .setLevel(qrLevelFor(item.ec))
209
+ .setCellSize(item.size.coerceIn(1, 16)),
210
+ )
211
+ }
212
+ is com.delicity.thermalprinter.model.PrintItem.Barcode -> {
213
+ pb.styleAlignment(alignmentFor(item.align))
214
+ pb.actionPrintBarcode(
215
+ BarcodeParameter(item.value, barcodeSymbologyFor(item.symbology))
216
+ .setHeight(item.height.coerceIn(1, 255).toDouble())
217
+ .setPrintHri(item.hri != "none"),
218
+ )
219
+ }
220
+ is com.delicity.thermalprinter.model.PrintItem.CashDrawer -> {
221
+ // Le tiroir est géré au niveau document ; ignoré ici (voir openCashDrawer).
222
+ }
223
+ is com.delicity.thermalprinter.model.PrintItem.Image -> {
224
+ // Les images inline dans printText ne sont pas pré-rendues ici ;
225
+ // l'app doit utiliser printImage pour un rendu maîtrisé.
226
+ }
227
+ is com.delicity.thermalprinter.model.PrintItem.Raw -> {
228
+ // StarXpand n'expose pas d'injection ESC/POS brute fiable -> ignoré.
229
+ }
230
+ }
231
+ }
232
+
233
+ private suspend fun sendCommands(printer: StarPrinter, commands: String, profile: PrinterProfile) {
234
+ try {
235
+ printer.printAsync(commands).await()
236
+ } catch (e: Exception) {
237
+ throw PrinterException(ErrorCode.PRINT_FAILED, "Impression Star échouée", e.message, retryable = true)
238
+ }
239
+ }
240
+
241
+ // -------------------------------------------------------------------------
242
+ // Statut
243
+ // -------------------------------------------------------------------------
244
+
245
+ override suspend fun getStatus(profile: PrinterProfile): PrinterStatus {
246
+ val printer = connections[profile.id]
247
+ ?: return PrinterStatus(profile.id, "disconnected", online = false, paper = "unknown")
248
+ return try {
249
+ val st = printer.getStatusAsync().await()
250
+ val paperEmpty = runCatching { st.paperEmpty }.getOrDefault(false)
251
+ val paperNear = runCatching { st.paperNearEmpty }.getOrDefault(false)
252
+ val coverOpen = runCatching { st.coverOpen }.getOrDefault(false)
253
+ PrinterStatus(
254
+ id = profile.id,
255
+ connection = "connected",
256
+ online = !paperEmpty && !coverOpen,
257
+ paper = if (paperEmpty) "empty" else if (paperNear) "near_end" else "ok",
258
+ coverOpen = coverOpen,
259
+ errorCode = if (paperEmpty) ErrorCode.PAPER_EMPTY else if (coverOpen) ErrorCode.COVER_OPEN else null,
260
+ rawStatus = st.toString(),
261
+ )
262
+ } catch (e: Exception) {
263
+ PrinterStatus(profile.id, "error", online = false, paper = "unknown", rawStatus = e.message)
264
+ }
265
+ }
266
+
267
+ // -------------------------------------------------------------------------
268
+ // Helpers
269
+ // -------------------------------------------------------------------------
270
+
271
+ private fun requireConnected(profile: PrinterProfile): StarPrinter =
272
+ connections[profile.id] ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Star non connecté: ${profile.id}")
273
+
274
+ private fun connectionSettingsFor(profile: PrinterProfile): StarConnectionSettings {
275
+ val iface = when (profile.transport) {
276
+ Transport.WIFI, Transport.ETHERNET -> InterfaceType.Lan
277
+ Transport.BLUETOOTH -> InterfaceType.Bluetooth
278
+ Transport.BLE -> InterfaceType.BluetoothLE
279
+ Transport.USB -> InterfaceType.Usb
280
+ }
281
+ // Pour le LAN, l'identifier Star est l'IP (sans port).
282
+ val identifier = if (iface == InterfaceType.Lan) profile.address.substringBefore(":") else profile.address
283
+ return StarConnectionSettings(iface, identifier)
284
+ }
285
+
286
+ private fun transportFor(iface: InterfaceType): Transport = when (iface) {
287
+ InterfaceType.Lan -> Transport.WIFI
288
+ InterfaceType.Bluetooth -> Transport.BLUETOOTH
289
+ InterfaceType.BluetoothLE -> Transport.BLE
290
+ InterfaceType.Usb -> Transport.USB
291
+ else -> Transport.WIFI
292
+ }
293
+
294
+ private fun alignmentFor(align: String?): Alignment = when (align) {
295
+ "center" -> Alignment.Center
296
+ "right" -> Alignment.Right
297
+ else -> Alignment.Left
298
+ }
299
+
300
+ private fun qrLevelFor(ec: String): QRCodeLevel = when (ec.uppercase()) {
301
+ "L" -> QRCodeLevel.L
302
+ "Q" -> QRCodeLevel.Q
303
+ "H" -> QRCodeLevel.H
304
+ else -> QRCodeLevel.M
305
+ }
306
+
307
+ private fun barcodeSymbologyFor(symbology: String): BarcodeSymbology = when (symbology.uppercase()) {
308
+ "CODE39" -> BarcodeSymbology.Code39
309
+ "CODE93" -> BarcodeSymbology.Code93
310
+ "EAN13", "JAN13" -> BarcodeSymbology.Jan13
311
+ "EAN8", "JAN8" -> BarcodeSymbology.Jan8
312
+ "ITF" -> BarcodeSymbology.Itf
313
+ "UPCA" -> BarcodeSymbology.UpcA
314
+ "UPCE" -> BarcodeSymbology.UpcE
315
+ "NW7", "CODABAR" -> BarcodeSymbology.Nw7
316
+ else -> BarcodeSymbology.Code128
317
+ }
318
+
319
+ private fun ensureSdk() {
320
+ if (!isAvailable()) throw PrinterException(ErrorCode.SDK_NOT_AVAILABLE, "SDK Star absent")
321
+ }
322
+ }
@@ -0,0 +1,248 @@
1
+ package com.delicity.thermalprinter.adapters
2
+
3
+ import android.app.PendingIntent
4
+ import android.content.BroadcastReceiver
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.IntentFilter
8
+ import android.graphics.Bitmap
9
+ import android.hardware.usb.UsbConstants
10
+ import android.hardware.usb.UsbDevice
11
+ import android.hardware.usb.UsbDeviceConnection
12
+ import android.hardware.usb.UsbEndpoint
13
+ import android.hardware.usb.UsbInterface
14
+ import android.hardware.usb.UsbManager
15
+ import android.os.Build
16
+ import com.delicity.thermalprinter.image.ImageProcessor
17
+ import com.delicity.thermalprinter.model.AdapterId
18
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
19
+ import com.delicity.thermalprinter.model.ErrorCode
20
+ import com.delicity.thermalprinter.model.PrinterException
21
+ import com.delicity.thermalprinter.model.PrinterProfile
22
+ import com.delicity.thermalprinter.model.PrinterStatus
23
+ import com.delicity.thermalprinter.model.RenderOptions
24
+ import com.delicity.thermalprinter.model.Transport
25
+ import java.util.concurrent.ConcurrentHashMap
26
+ import kotlin.coroutines.resume
27
+ import kotlinx.coroutines.suspendCancellableCoroutine
28
+
29
+ /**
30
+ * Adapter USB (Android host) pour imprimantes ESC/POS branchées en USB.
31
+ *
32
+ * Détecte les périphériques de classe imprimante (USB class 7), demande la
33
+ * permission runtime, réclame l'interface et écrit le raster ESC/POS sur
34
+ * l'endpoint bulk OUT via bulkTransfer (par paquets).
35
+ *
36
+ * ⚠️ ANDROID UNIQUEMENT (iOS n'expose pas l'USB host pour ce cas).
37
+ */
38
+ class UsbAdapter(private val context: Context) : PrinterAdapter {
39
+
40
+ override val id = AdapterId.ESCPOS
41
+ private val usbManager get() = context.getSystemService(Context.USB_SERVICE) as UsbManager
42
+
43
+ /** Connexions ouvertes indexées par printerId ("usb:vendorId:productId"). */
44
+ private val connections = ConcurrentHashMap<String, UsbLink>()
45
+
46
+ private data class UsbLink(
47
+ val connection: UsbDeviceConnection,
48
+ val iface: UsbInterface,
49
+ val endpointOut: UsbEndpoint,
50
+ val endpointIn: UsbEndpoint?,
51
+ )
52
+
53
+ override fun isAvailable(): Boolean =
54
+ context.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_USB_HOST)
55
+
56
+ override fun supportsTextItems(): Boolean = true
57
+
58
+ override suspend fun discover(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
59
+ if (!isAvailable()) return
60
+ usbManager.deviceList.values.forEach { device ->
61
+ val isPrinter = (0 until device.interfaceCount).any { device.getInterface(it).interfaceClass == UsbConstants.USB_CLASS_PRINTER }
62
+ if (isPrinter) {
63
+ onFound(
64
+ DiscoveredPrinter(
65
+ id = "usb:${device.vendorId}:${device.productId}",
66
+ name = device.productName ?: "USB Printer",
67
+ brand = device.manufacturerName,
68
+ transport = Transport.USB,
69
+ adapter = AdapterId.ESCPOS,
70
+ address = "${device.vendorId}:${device.productId}",
71
+ discoveredBy = mutableSetOf(AdapterId.ESCPOS),
72
+ ),
73
+ )
74
+ }
75
+ }
76
+ }
77
+
78
+ override fun canHandle(profile: PrinterProfile): Boolean = profile.transport == Transport.USB
79
+
80
+ override suspend fun connect(profile: PrinterProfile, timeoutMs: Long) {
81
+ ensureUsb()
82
+ if (isConnected(profile.id)) return
83
+
84
+ val device = findDevice(profile.address)
85
+ ?: throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "Périphérique USB introuvable: ${profile.address}")
86
+
87
+ if (!usbManager.hasPermission(device)) {
88
+ val granted = requestPermission(device, timeoutMs)
89
+ if (!granted) throw PrinterException(ErrorCode.PERMISSION_DENIED, "Permission USB refusée", retryable = true)
90
+ }
91
+
92
+ val iface = (0 until device.interfaceCount)
93
+ .map { device.getInterface(it) }
94
+ .firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_PRINTER }
95
+ ?: throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Aucune interface imprimante USB")
96
+
97
+ var endpointOut: UsbEndpoint? = null
98
+ var endpointIn: UsbEndpoint? = null
99
+ for (i in 0 until iface.endpointCount) {
100
+ val ep = iface.getEndpoint(i)
101
+ if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) {
102
+ if (ep.direction == UsbConstants.USB_DIR_OUT) endpointOut = ep
103
+ if (ep.direction == UsbConstants.USB_DIR_IN) endpointIn = ep
104
+ }
105
+ }
106
+ if (endpointOut == null) throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Endpoint USB bulk OUT introuvable")
107
+
108
+ val connection = usbManager.openDevice(device)
109
+ ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Ouverture USB échouée", retryable = true)
110
+ if (!connection.claimInterface(iface, true)) {
111
+ connection.close()
112
+ throw PrinterException(ErrorCode.CONNECTION_FAILED, "claimInterface USB échoué", retryable = true)
113
+ }
114
+ connections[profile.id] = UsbLink(connection, iface, endpointOut, endpointIn)
115
+ }
116
+
117
+ override fun isConnected(printerId: String): Boolean = connections.containsKey(printerId)
118
+
119
+ override suspend fun disconnect(printerId: String) {
120
+ connections.remove(printerId)?.let { link ->
121
+ runCatching { link.connection.releaseInterface(link.iface) }
122
+ runCatching { link.connection.close() }
123
+ }
124
+ }
125
+
126
+ override suspend fun printBitmap(profile: PrinterProfile, bitmap: Bitmap, options: RenderOptions): Int {
127
+ val link = requireLink(profile)
128
+ val mono = ImageProcessor.toMono(bitmap, options)
129
+ val raster = ImageProcessor.encodeEscPosRaster(mono)
130
+ val job = EscPosCommands.buildJob(
131
+ rasterData = raster,
132
+ align = options.align,
133
+ feedLines = options.feedLines,
134
+ cut = options.cut && profile.capabilities.supportsCut,
135
+ openDrawer = options.openCashDrawer && profile.capabilities.supportsCashDrawer,
136
+ )
137
+ var sent = 0
138
+ repeat(options.copies.coerceAtLeast(1)) { sent += writeBulk(link, job) }
139
+ return sent
140
+ }
141
+
142
+ override suspend fun printItems(
143
+ profile: PrinterProfile,
144
+ items: List<com.delicity.thermalprinter.model.PrintItem>,
145
+ defaultCodePage: String,
146
+ cut: Boolean,
147
+ feedLines: Int,
148
+ ): Int {
149
+ val link = requireLink(profile)
150
+ val columns = if (profile.capabilities.printableDots <= 420) 32 else 48
151
+ val encoded = EscPosTextEncoder.encode(items, defaultCodePage, columns)
152
+ val out = java.io.ByteArrayOutputStream()
153
+ out.write(encoded.bytes)
154
+ if (feedLines > 0) out.write(EscPosCommands.feed(feedLines))
155
+ if (cut && profile.capabilities.supportsCut) out.write(EscPosCommands.CUT_PARTIAL)
156
+ return writeBulk(link, out.toByteArray())
157
+ }
158
+
159
+ override suspend fun getStatus(profile: PrinterProfile): PrinterStatus {
160
+ val link = connections[profile.id]
161
+ ?: return PrinterStatus(profile.id, "disconnected", online = false, paper = "unknown")
162
+ // DLE EOT 4 -> statut papier si un endpoint IN est exposé (rare en USB).
163
+ val epIn = link.endpointIn
164
+ ?: return PrinterStatus(profile.id, "connected", online = true, paper = "unknown", rawStatus = "no-in-endpoint")
165
+ return try {
166
+ writeBulk(link, EscPosCommands.realtimeStatus(4))
167
+ val buf = ByteArray(8)
168
+ val n = link.connection.bulkTransfer(epIn, buf, buf.size, 1500)
169
+ if (n <= 0) {
170
+ PrinterStatus(profile.id, "connected", online = true, paper = "unknown", rawStatus = "no-response")
171
+ } else {
172
+ val b = buf[0].toInt()
173
+ val paperEmpty = (b and 0x60) != 0
174
+ PrinterStatus(
175
+ profile.id, "connected", online = true,
176
+ paper = if (paperEmpty) "empty" else "ok",
177
+ errorCode = if (paperEmpty) ErrorCode.PAPER_EMPTY else null,
178
+ rawStatus = "0x%02X".format(b),
179
+ )
180
+ }
181
+ } catch (e: Exception) {
182
+ PrinterStatus(profile.id, "error", online = false, paper = "unknown", rawStatus = e.message)
183
+ }
184
+ }
185
+
186
+ // -------------------------------------------------------------------------
187
+ // Helpers
188
+ // -------------------------------------------------------------------------
189
+
190
+ private fun requireLink(profile: PrinterProfile): UsbLink =
191
+ connections[profile.id] ?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "USB non connecté: ${profile.id}")
192
+
193
+ /** Écrit un buffer sur l'endpoint bulk OUT par paquets (taille = maxPacketSize). */
194
+ private fun writeBulk(link: UsbLink, data: ByteArray): Int {
195
+ val ep = link.endpointOut
196
+ val chunk = if (ep.maxPacketSize > 0) ep.maxPacketSize else 16384
197
+ var offset = 0
198
+ while (offset < data.size) {
199
+ val len = minOf(chunk, data.size - offset)
200
+ val slice = if (offset == 0 && len == data.size) data else data.copyOfRange(offset, offset + len)
201
+ val n = link.connection.bulkTransfer(ep, slice, len, 5000)
202
+ if (n < 0) throw PrinterException(ErrorCode.PRINT_FAILED, "Écriture USB échouée (bulkTransfer=$n)", retryable = true)
203
+ offset += len
204
+ }
205
+ return data.size
206
+ }
207
+
208
+ private fun findDevice(address: String): UsbDevice? {
209
+ val parts = address.split(":")
210
+ if (parts.size < 2) return null
211
+ val vid = parts[0].toIntOrNull() ?: return null
212
+ val pid = parts[1].toIntOrNull() ?: return null
213
+ return usbManager.deviceList.values.firstOrNull { it.vendorId == vid && it.productId == pid }
214
+ }
215
+
216
+ /** Demande la permission USB runtime et suspend jusqu'à la réponse (ou timeout). */
217
+ private suspend fun requestPermission(device: UsbDevice, timeoutMs: Long): Boolean =
218
+ suspendCancellableCoroutine { cont ->
219
+ val action = "$ACTION_USB_PERMISSION.${device.deviceId}"
220
+ val receiver = object : BroadcastReceiver() {
221
+ override fun onReceive(ctx: Context, intent: Intent) {
222
+ if (intent.action != action) return
223
+ runCatching { context.unregisterReceiver(this) }
224
+ val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
225
+ if (cont.isActive) cont.resume(granted)
226
+ }
227
+ }
228
+ val filter = IntentFilter(action)
229
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
230
+ context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
231
+ } else {
232
+ @Suppress("UnspecifiedRegisterReceiverFlag")
233
+ context.registerReceiver(receiver, filter)
234
+ }
235
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
236
+ val pi = PendingIntent.getBroadcast(context, 0, Intent(action).setPackage(context.packageName), flags)
237
+ cont.invokeOnCancellation { runCatching { context.unregisterReceiver(receiver) } }
238
+ usbManager.requestPermission(device, pi)
239
+ }
240
+
241
+ private fun ensureUsb() {
242
+ if (!isAvailable()) throw PrinterException(ErrorCode.UNSUPPORTED_TRANSPORT, "USB host indisponible")
243
+ }
244
+
245
+ companion object {
246
+ private const val ACTION_USB_PERMISSION = "com.delicity.thermalprinter.USB_PERMISSION"
247
+ }
248
+ }