@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,528 @@
1
+ package com.delicity.thermalprinter
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothManager
5
+ import android.content.Context
6
+ import android.graphics.Bitmap
7
+ import com.delicity.thermalprinter.adapters.BleAdapter
8
+ import com.delicity.thermalprinter.adapters.BrotherAdapter
9
+ import com.delicity.thermalprinter.adapters.EpsonAdapter
10
+ import com.delicity.thermalprinter.adapters.EscPosAdapter
11
+ import com.delicity.thermalprinter.adapters.PrinterAdapter
12
+ import com.delicity.thermalprinter.adapters.RawTcpAdapter
13
+ import com.delicity.thermalprinter.adapters.StarAdapter
14
+ import com.delicity.thermalprinter.adapters.UsbAdapter
15
+ import com.delicity.thermalprinter.adapters.ZebraAdapter
16
+ import com.delicity.thermalprinter.discovery.DiscoveryManager
17
+ import com.delicity.thermalprinter.image.ImageCache
18
+ import com.delicity.thermalprinter.image.ImageProcessor
19
+ import com.delicity.thermalprinter.image.TextRasterizer
20
+ import com.delicity.thermalprinter.model.AdapterId
21
+ import com.delicity.thermalprinter.model.Capabilities
22
+ import com.delicity.thermalprinter.model.DiscoveredPrinter
23
+ import com.delicity.thermalprinter.model.ErrorCode
24
+ import com.delicity.thermalprinter.model.PrinterException
25
+ import com.delicity.thermalprinter.model.PrinterProfile
26
+ import com.delicity.thermalprinter.model.PrinterStatus
27
+ import com.delicity.thermalprinter.model.RenderOptions
28
+ import com.delicity.thermalprinter.model.Transport
29
+ import com.delicity.thermalprinter.store.PrinterStore
30
+ import java.util.concurrent.ConcurrentHashMap
31
+ import kotlinx.coroutines.CoroutineScope
32
+ import kotlinx.coroutines.Dispatchers
33
+ import kotlinx.coroutines.Job
34
+ import kotlinx.coroutines.SupervisorJob
35
+ import kotlinx.coroutines.delay
36
+ import kotlinx.coroutines.isActive
37
+ import kotlinx.coroutines.launch
38
+ import kotlinx.coroutines.withTimeout
39
+
40
+ /**
41
+ * Cœur applicatif côté Android. Indépendant de Capacitor (testable).
42
+ *
43
+ * Responsabilités :
44
+ * - tenir la registry d'adapters,
45
+ * - exposer la découverte agrégée,
46
+ * - gérer connexion / reconnexion / déconnexion,
47
+ * - exécuter le flux printImage (load -> resize -> mono -> dither -> adapter -> send),
48
+ * - persister les profils (PrinterStore) et l'imprimante par défaut.
49
+ */
50
+ class ThermalPrinterEngine(private val context: Context) {
51
+
52
+ private val btAdapter: BluetoothAdapter? by lazy {
53
+ (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
54
+ }
55
+
56
+ private val store = PrinterStore(context)
57
+ private val imageCache = ImageCache(context)
58
+
59
+ /** Registry d'adapters. L'ordre n'a pas d'importance (priorité gérée ailleurs). */
60
+ private val adapters: List<PrinterAdapter> by lazy {
61
+ listOf(
62
+ EpsonAdapter(context),
63
+ StarAdapter(context),
64
+ BrotherAdapter(context),
65
+ ZebraAdapter(context),
66
+ EscPosAdapter(btAdapter),
67
+ RawTcpAdapter(),
68
+ UsbAdapter(context),
69
+ BleAdapter(context),
70
+ )
71
+ }
72
+
73
+ // Dernière liste découverte (pour résoudre un printerId vers un profil ad hoc).
74
+ @Volatile private var lastDiscovered: List<DiscoveredPrinter> = emptyList()
75
+
76
+ /** Émetteur d'états de job (branché par le plugin sur notifyListeners). */
77
+ var onJobUpdate: ((JobUpdate) -> Unit)? = null
78
+
79
+ /** Émetteur de changement de statut (branché par le plugin sur 'statusChange'). */
80
+ var onStatusChange: ((PrinterStatus) -> Unit)? = null
81
+
82
+ /** Scope + registre des moniteurs de statut actifs (Phase 6). */
83
+ private val monitorScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
84
+ private val monitors = ConcurrentHashMap<String, Job>()
85
+
86
+ private companion object {
87
+ const val RECONNECT_ATTEMPTS = 3
88
+ }
89
+
90
+ /** Mise à jour d'état d'un job d'impression. */
91
+ data class JobUpdate(
92
+ val jobId: String,
93
+ val printerId: String,
94
+ val state: String, // pending|printing|hold|completed|failed|canceled
95
+ val holdReason: String? = null,
96
+ val progress: Double? = null,
97
+ val errorCode: ErrorCode? = null,
98
+ val message: String? = null,
99
+ )
100
+
101
+ private fun emitJob(
102
+ jobId: String,
103
+ printerId: String,
104
+ state: String,
105
+ holdReason: String? = null,
106
+ progress: Double? = null,
107
+ errorCode: ErrorCode? = null,
108
+ message: String? = null,
109
+ ) {
110
+ onJobUpdate?.invoke(JobUpdate(jobId, printerId, state, holdReason, progress, errorCode, message))
111
+ }
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Découverte
115
+ // -------------------------------------------------------------------------
116
+
117
+ suspend fun discover(
118
+ options: DiscoveryManager.Options,
119
+ emitPartial: (DiscoveredPrinter) -> Unit,
120
+ ): Pair<List<DiscoveredPrinter>, List<String>> {
121
+ Logger.log("discovery", "start", mapOf("sources" to (options.sources?.joinToString() ?: "all")))
122
+ val manager = DiscoveryManager(context, btAdapter, adapters)
123
+ val (printers, failed) = manager.discover(options, emitPartial)
124
+ // Marquer défaut / connecté connus.
125
+ val defaultId = store.getDefault()?.id
126
+ printers.forEach { p ->
127
+ p.isDefault = p.id == defaultId
128
+ val adapter = if (p.adapter == AdapterId.ESCPOS) escFamilyFor(p.transport) else adapterFor(p.adapter)
129
+ p.isConnected = adapter?.isConnected(p.id) == true
130
+ }
131
+ lastDiscovered = printers
132
+ Logger.log("discovery", "complete", mapOf("count" to printers.size, "failed" to failed.joinToString()))
133
+ return Pair(printers, failed)
134
+ }
135
+
136
+ // -------------------------------------------------------------------------
137
+ // Connexion / reconnexion
138
+ // -------------------------------------------------------------------------
139
+
140
+ suspend fun connect(printerId: String, timeoutMs: Long, forceAdapter: AdapterId?, setAsDefault: Boolean = false): Boolean {
141
+ val profile = resolveProfile(printerId, forceAdapter)
142
+ val adapter = adapterFor(profile)
143
+ ?: throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Aucun adapter pour ${profile.adapter.value}")
144
+ if (!adapter.isAvailable()) {
145
+ throw PrinterException(ErrorCode.SDK_NOT_AVAILABLE, "Adapter ${profile.adapter.value} indisponible")
146
+ }
147
+ Logger.log("connect", "connecting", mapOf("id" to printerId, "adapter" to profile.adapter.value))
148
+ withTimeout(timeoutMs + 1000) { adapter.connect(profile, timeoutMs) }
149
+ val connected = adapter.isConnected(printerId)
150
+ Logger.log("connect", "connected", mapOf("id" to printerId, "ok" to connected))
151
+ // setAsDefault UNIQUEMENT si la connexion a réussi.
152
+ if (connected && setAsDefault) {
153
+ store.upsert(profile)
154
+ store.setDefault(printerId)
155
+ Logger.log("connect", "set-default-after-connect", mapOf("id" to printerId))
156
+ }
157
+ return connected
158
+ }
159
+
160
+ suspend fun disconnect(printerId: String) {
161
+ val profile = store.get(printerId) ?: lastDiscovered.firstOrNull { it.id == printerId }?.let(::toEphemeralProfile)
162
+ val adapter = profile?.let { adapterFor(it) } ?: return
163
+ adapter.disconnect(printerId)
164
+ Logger.log("connect", "disconnected", mapOf("id" to printerId))
165
+ }
166
+
167
+ /**
168
+ * Assure une connexion avant impression (cœur de la reconnexion auto), avec
169
+ * **backoff exponentiel** : jusqu'à [RECONNECT_ATTEMPTS] tentatives espacées
170
+ * (300ms, 600ms, 1200ms…, plafonnées). Les erreurs non-retryables court-circuitent.
171
+ */
172
+ private suspend fun ensureConnected(profile: PrinterProfile, timeoutMs: Long) {
173
+ val adapter = adapterFor(profile)
174
+ ?: throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Adapter introuvable")
175
+ if (adapter.isConnected(profile.id)) return
176
+
177
+ var backoff = 300L
178
+ var lastError: PrinterException? = null
179
+ for (attempt in 1..RECONNECT_ATTEMPTS) {
180
+ try {
181
+ Logger.log("connect", "auto-reconnect", mapOf("id" to profile.id, "attempt" to attempt))
182
+ withTimeout(timeoutMs) { adapter.connect(profile, timeoutMs) }
183
+ if (adapter.isConnected(profile.id)) {
184
+ if (attempt > 1) Logger.log("connect", "reconnect-recovered", mapOf("id" to profile.id, "attempt" to attempt))
185
+ return
186
+ }
187
+ lastError = PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion non établie", retryable = true)
188
+ } catch (e: PrinterException) {
189
+ lastError = e
190
+ if (!e.retryable) throw e
191
+ } catch (e: Exception) {
192
+ lastError = PrinterException(ErrorCode.CONNECTION_FAILED, "Reconnexion échouée", e.message, retryable = true)
193
+ }
194
+ if (attempt < RECONNECT_ATTEMPTS) {
195
+ Logger.log("connect", "backoff", mapOf("id" to profile.id, "delayMs" to backoff))
196
+ delay(backoff)
197
+ backoff = (backoff * 2).coerceAtMost(3000L)
198
+ }
199
+ }
200
+ throw lastError ?: PrinterException(ErrorCode.CONNECTION_FAILED, "Reconnexion échouée ($RECONNECT_ATTEMPTS tentatives)", retryable = true)
201
+ }
202
+
203
+ // -------------------------------------------------------------------------
204
+ // Impression (flux complet)
205
+ // -------------------------------------------------------------------------
206
+
207
+ data class PrintRequest(
208
+ val printerId: String?,
209
+ val filePath: String?,
210
+ val url: String?,
211
+ val base64: String?,
212
+ val render: RenderOptions?,
213
+ val timeoutMs: Long = 15000,
214
+ val autoReconnect: Boolean = true,
215
+ )
216
+
217
+ data class PrintTextRequest(
218
+ val printerId: String?,
219
+ val items: List<com.delicity.thermalprinter.model.PrintItem>,
220
+ val defaultCodePage: String = "WPC1252",
221
+ val cut: Boolean = false,
222
+ val feedLines: Int = 3,
223
+ val timeoutMs: Long = 15000,
224
+ val autoReconnect: Boolean = true,
225
+ )
226
+
227
+ data class PrintOutcome(
228
+ val printerId: String,
229
+ val adapter: AdapterId,
230
+ val jobId: String,
231
+ val state: String,
232
+ val bytesSent: Int,
233
+ val durationMs: Long,
234
+ val status: PrinterStatus?,
235
+ )
236
+
237
+ suspend fun printImage(req: PrintRequest): PrintOutcome {
238
+ val started = System.currentTimeMillis()
239
+ val jobId = java.util.UUID.randomUUID().toString()
240
+
241
+ val profile = resolveTargetProfile(req.printerId)
242
+ emitJob(jobId, profile.id, "pending")
243
+ val adapter = adapterFor(profile)
244
+ ?: throw failJob(jobId, profile.id, PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Adapter introuvable"))
245
+
246
+ try {
247
+ // 2/3. Connexion ou reconnexion auto.
248
+ if (!adapter.isConnected(profile.id)) {
249
+ if (!req.autoReconnect) throw PrinterException(ErrorCode.CONNECTION_FAILED, "Imprimante non connectée")
250
+ ensureConnected(profile, req.timeoutMs)
251
+ }
252
+
253
+ // Pré-contrôle statut -> HOLD si problème connu (papier/capot).
254
+ preflightHold(adapter, profile, jobId)
255
+
256
+ // 4. Charger l'image.
257
+ val bitmap = loadImage(req)
258
+ val render = resolveRenderOptions(profile, req.render)
259
+ // 5/6. Resize (sauf si désactivé).
260
+ val resized = if (render.resize) ImageProcessor.resizeToWidth(bitmap, render.widthDots) else bitmap
261
+ if (render.resize && bitmap != resized) bitmap.recycle()
262
+
263
+ emitJob(jobId, profile.id, "printing", progress = 0.1)
264
+ val bytes = try {
265
+ withTimeout(req.timeoutMs) { adapter.printBitmap(profile, resized, render) }
266
+ } finally {
267
+ resized.recycle()
268
+ }
269
+
270
+ val status = runCatching { adapter.getStatus(profile) }.getOrNull()
271
+ val duration = System.currentTimeMillis() - started
272
+ emitJob(jobId, profile.id, "completed", progress = 1.0)
273
+ Logger.log("print", "done", mapOf("id" to profile.id, "bytes" to bytes, "ms" to duration))
274
+ return PrintOutcome(profile.id, profile.adapter, jobId, "completed", bytes, duration, status)
275
+ } catch (e: PrinterException) {
276
+ throw failJob(jobId, profile.id, e)
277
+ }
278
+ }
279
+
280
+ suspend fun printText(req: PrintTextRequest): PrintOutcome {
281
+ val started = System.currentTimeMillis()
282
+ val jobId = java.util.UUID.randomUUID().toString()
283
+
284
+ val profile = resolveTargetProfile(req.printerId)
285
+ emitJob(jobId, profile.id, "pending")
286
+ val adapter = adapterFor(profile)
287
+ ?: throw failJob(jobId, profile.id, PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Adapter introuvable"))
288
+
289
+ try {
290
+ if (!adapter.isConnected(profile.id)) {
291
+ if (!req.autoReconnect) throw PrinterException(ErrorCode.CONNECTION_FAILED, "Imprimante non connectée")
292
+ ensureConnected(profile, req.timeoutMs)
293
+ }
294
+ preflightHold(adapter, profile, jobId)
295
+
296
+ emitJob(jobId, profile.id, "printing", progress = 0.1)
297
+ val bytes = withTimeout(req.timeoutMs) {
298
+ if (adapter.supportsTextItems()) {
299
+ adapter.printItems(profile, req.items, req.defaultCodePage, req.cut, req.feedLines)
300
+ } else {
301
+ // Repli : rendre les items en image puis imprimer via le SDK image (Brother/Zebra).
302
+ val width = profile.capabilities.printableDots.takeIf { it > 0 } ?: 576
303
+ val bmp = TextRasterizer.render(req.items, width)
304
+ val render = RenderOptions(widthDots = width, resize = false, cut = req.cut, feedLines = req.feedLines)
305
+ try {
306
+ adapter.printBitmap(profile, bmp, render)
307
+ } finally {
308
+ bmp.recycle()
309
+ }
310
+ }
311
+ }
312
+ val status = runCatching { adapter.getStatus(profile) }.getOrNull()
313
+ val duration = System.currentTimeMillis() - started
314
+ emitJob(jobId, profile.id, "completed", progress = 1.0)
315
+ Logger.log("print", "text done", mapOf("id" to profile.id, "items" to req.items.size, "bytes" to bytes))
316
+ return PrintOutcome(profile.id, profile.adapter, jobId, "completed", bytes, duration, status)
317
+ } catch (e: PrinterException) {
318
+ throw failJob(jobId, profile.id, e)
319
+ }
320
+ }
321
+
322
+ /** Lit le statut avant impression ; émet HOLD + lève si papier/capot bloquant. */
323
+ private suspend fun preflightHold(adapter: PrinterAdapter, profile: PrinterProfile, jobId: String) {
324
+ if (!profile.capabilities.supportsStatus) return
325
+ val st = runCatching { adapter.getStatus(profile) }.getOrNull() ?: return
326
+ if (st.paper == "empty") {
327
+ emitJob(jobId, profile.id, "hold", holdReason = "paper_empty")
328
+ throw PrinterException(ErrorCode.PAPER_EMPTY, "Plus de papier", retryable = true)
329
+ }
330
+ if (st.coverOpen == true) {
331
+ emitJob(jobId, profile.id, "hold", holdReason = "cover_open")
332
+ throw PrinterException(ErrorCode.COVER_OPEN, "Capot ouvert", retryable = true)
333
+ }
334
+ }
335
+
336
+ private fun failJob(jobId: String, printerId: String, e: PrinterException): PrinterException {
337
+ emitJob(jobId, printerId, "failed", errorCode = e.code, message = e.message)
338
+ return e
339
+ }
340
+
341
+ // -------------------------------------------------------------------------
342
+ // Profils / défaut
343
+ // -------------------------------------------------------------------------
344
+
345
+ fun savedProfiles(): List<PrinterProfile> = store.all()
346
+ fun defaultProfile(): PrinterProfile? = store.getDefault()
347
+ fun removeProfile(id: String) = store.remove(id)
348
+
349
+ /** Enregistre/MAJ le profil depuis la dernière découverte et le marque par défaut. */
350
+ fun setDefault(printerId: String): PrinterProfile {
351
+ val existing = store.get(printerId)
352
+ if (existing != null) {
353
+ return store.setDefault(printerId)
354
+ ?: throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "Profil introuvable")
355
+ }
356
+ val discovered = lastDiscovered.firstOrNull { it.id == printerId }
357
+ ?: throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "Imprimante inconnue: $printerId")
358
+ val profile = toEphemeralProfile(discovered).copy(isDefault = true)
359
+ store.upsert(profile)
360
+ return store.setDefault(printerId) ?: profile
361
+ }
362
+
363
+ suspend fun getStatus(printerId: String?): PrinterStatus {
364
+ val profile = resolveTargetProfile(printerId)
365
+ val adapter = adapterFor(profile)
366
+ ?: throw PrinterException(ErrorCode.UNSUPPORTED_PRINTER, "Adapter introuvable")
367
+ return adapter.getStatus(profile)
368
+ }
369
+
370
+ // -------------------------------------------------------------------------
371
+ // Monitoring de statut (Phase 6)
372
+ // -------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Démarre un polling périodique du statut de [printerId] et émet `statusChange`
376
+ * uniquement quand l'état pertinent change (connexion/online/papier/capot).
377
+ * Idempotent : relance le moniteur si déjà actif.
378
+ */
379
+ fun startStatusMonitor(printerId: String, intervalMs: Long) {
380
+ stopStatusMonitor(printerId)
381
+ val interval = intervalMs.coerceIn(1000L, 300_000L)
382
+ monitors[printerId] = monitorScope.launch {
383
+ var lastKey: String? = null
384
+ var lastBlocked = false
385
+ while (isActive) {
386
+ val status = runCatching { getStatus(printerId) }.getOrElse { e ->
387
+ val code = (e as? PrinterException)?.code
388
+ PrinterStatus(printerId, "error", online = false, paper = "unknown", errorCode = code, rawStatus = e.message)
389
+ }
390
+ // "Bloqué" = condition qui mettrait un job en hold (papier/capot/offline).
391
+ val blocked = status.paper == "empty" || status.coverOpen == true || !status.online
392
+ val key = "${status.connection}|${status.online}|${status.paper}|${status.coverOpen}|${status.errorCode}"
393
+ if (key != lastKey) {
394
+ lastKey = key
395
+ onStatusChange?.invoke(status)
396
+ if (lastBlocked && !blocked) {
397
+ // Reprise après hold détectée (papier rechargé / capot fermé / retour online).
398
+ Logger.log("status", "recovered", mapOf("id" to printerId))
399
+ }
400
+ Logger.log("status", "change", mapOf("id" to printerId, "paper" to status.paper, "conn" to status.connection))
401
+ }
402
+ lastBlocked = blocked
403
+ delay(interval)
404
+ }
405
+ }
406
+ Logger.log("status", "monitor-start", mapOf("id" to printerId, "intervalMs" to interval))
407
+ }
408
+
409
+ /** Arrête le moniteur d'une imprimante (no-op si absent). */
410
+ fun stopStatusMonitor(printerId: String) {
411
+ monitors.remove(printerId)?.cancel()
412
+ }
413
+
414
+ /** Arrête tous les moniteurs (à l'arrêt du plugin). */
415
+ fun stopAllMonitors() {
416
+ monitors.values.forEach { it.cancel() }
417
+ monitors.clear()
418
+ }
419
+
420
+ // -------------------------------------------------------------------------
421
+ // État des SDK / adapters à l'instant présent
422
+ // -------------------------------------------------------------------------
423
+
424
+ data class SdkInfo(
425
+ val adapter: String,
426
+ val label: String,
427
+ val available: Boolean,
428
+ val requiresSdk: Boolean,
429
+ val transports: List<String>,
430
+ )
431
+
432
+ /** Retourne l'état courant de chaque adapter/SDK (cf. getActiveSdks). */
433
+ fun activeSdks(): List<SdkInfo> {
434
+ val star = adapters.firstOrNull { it is StarAdapter }
435
+ val epson = adapters.firstOrNull { it is EpsonAdapter }
436
+ val brother = adapters.firstOrNull { it is BrotherAdapter }
437
+ val zebra = adapters.firstOrNull { it is ZebraAdapter }
438
+ val ble = adapters.firstOrNull { it is BleAdapter }
439
+ val usb = adapters.firstOrNull { it is UsbAdapter }
440
+ val escTransports = buildList {
441
+ add("wifi"); add("ethernet"); add("bluetooth")
442
+ if (ble?.isAvailable() == true) add("ble")
443
+ if (usb?.isAvailable() == true) add("usb")
444
+ }
445
+ return listOf(
446
+ SdkInfo("escpos", "ESC/POS générique", true, false, escTransports),
447
+ SdkInfo("star", "Star StarXpand", star?.isAvailable() == true, true, listOf("wifi", "bluetooth", "ble", "usb")),
448
+ SdkInfo("epson", "Epson ePOS2", epson?.isAvailable() == true, true, listOf("wifi", "bluetooth", "usb")),
449
+ SdkInfo("brother", "Brother", brother?.isAvailable() == true, true, listOf("wifi", "bluetooth", "ble")),
450
+ SdkInfo("zebra", "Zebra Link-OS", zebra?.isAvailable() == true, true, listOf("wifi", "bluetooth")),
451
+ SdkInfo("rawTcp", "TCP brut", true, false, listOf("wifi", "ethernet")),
452
+ )
453
+ }
454
+
455
+ fun debugLog() = Logger.snapshot()
456
+
457
+ // -------------------------------------------------------------------------
458
+ // Helpers internes
459
+ // -------------------------------------------------------------------------
460
+
461
+ private fun adapterFor(id: AdapterId): PrinterAdapter? = when (id) {
462
+ AdapterId.ESCPOS -> adapters.firstOrNull { it is EscPosAdapter }
463
+ AdapterId.EPSON -> adapters.firstOrNull { it is EpsonAdapter }
464
+ AdapterId.STAR -> adapters.firstOrNull { it is StarAdapter }
465
+ AdapterId.BROTHER -> adapters.firstOrNull { it is BrotherAdapter }
466
+ AdapterId.ZEBRA -> adapters.firstOrNull { it is ZebraAdapter }
467
+ AdapterId.RAW_TCP -> adapters.firstOrNull { it is RawTcpAdapter }
468
+ }
469
+
470
+ /**
471
+ * Résolution transport-aware : la famille ESC/POS (escpos) regroupe 3 adapters
472
+ * distincts (TCP/SPP, USB, BLE) qui ne se différencient que par le transport.
473
+ */
474
+ private fun adapterFor(profile: PrinterProfile): PrinterAdapter? =
475
+ if (profile.adapter == AdapterId.ESCPOS) escFamilyFor(profile.transport) else adapterFor(profile.adapter)
476
+
477
+ private fun escFamilyFor(transport: Transport): PrinterAdapter? = when (transport) {
478
+ Transport.USB -> adapters.firstOrNull { it is UsbAdapter }
479
+ Transport.BLE -> adapters.firstOrNull { it is BleAdapter }
480
+ else -> adapters.firstOrNull { it is EscPosAdapter }
481
+ }
482
+
483
+ private fun resolveTargetProfile(printerId: String?): PrinterProfile {
484
+ if (printerId == null) {
485
+ return store.getDefault()
486
+ ?: throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "Aucune imprimante par défaut")
487
+ }
488
+ return resolveProfile(printerId, null)
489
+ }
490
+
491
+ /** Profil persistant si connu, sinon profil éphémère depuis la découverte. */
492
+ private fun resolveProfile(printerId: String, forceAdapter: AdapterId?): PrinterProfile {
493
+ store.get(printerId)?.let { p ->
494
+ return if (forceAdapter != null) p.copy(adapter = forceAdapter) else p
495
+ }
496
+ val d = lastDiscovered.firstOrNull { it.id == printerId }
497
+ ?: throw PrinterException(ErrorCode.PRINTER_NOT_FOUND, "Imprimante inconnue: $printerId")
498
+ val base = toEphemeralProfile(d)
499
+ return if (forceAdapter != null) base.copy(adapter = forceAdapter) else base
500
+ }
501
+
502
+ private fun toEphemeralProfile(d: DiscoveredPrinter): PrinterProfile = PrinterProfile(
503
+ id = d.id,
504
+ adapter = d.adapter,
505
+ transport = d.transport,
506
+ address = d.address,
507
+ brand = d.brand,
508
+ model = d.model,
509
+ name = d.name,
510
+ capabilities = d.capabilities?.let { mergeCaps(it) } ?: Capabilities(),
511
+ )
512
+
513
+ private fun mergeCaps(partial: Capabilities): Capabilities = partial
514
+
515
+ private fun loadImage(req: PrintRequest): Bitmap = when {
516
+ !req.filePath.isNullOrBlank() -> ImageProcessor.decodeFile(req.filePath)
517
+ !req.url.isNullOrBlank() -> ImageProcessor.decodeFile(imageCache.fetch(req.url).absolutePath)
518
+ !req.base64.isNullOrBlank() -> ImageProcessor.decodeBase64(req.base64)
519
+ else -> throw PrinterException(ErrorCode.IMAGE_INVALID, "Aucune source image fournie")
520
+ }
521
+
522
+ private fun resolveRenderOptions(profile: PrinterProfile, req: RenderOptions?): RenderOptions {
523
+ val width = req?.widthDots?.takeIf { it > 0 }
524
+ ?: profile.capabilities.printableDots.takeIf { it > 0 }
525
+ ?: when (profile.capabilities.paperWidthMm) { 58 -> 384; 112 -> 832; else -> 576 }
526
+ return (req ?: RenderOptions(widthDots = width)).copy(widthDots = width)
527
+ }
528
+ }