@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.
- package/DelicityThermalPrinter.podspec +28 -0
- package/LICENSE +21 -0
- package/README.md +649 -0
- package/android/build.gradle +122 -0
- package/android/src/main/AndroidManifest.xml +38 -0
- package/android/src/main/java/com/delicity/thermalprinter/Logger.kt +50 -0
- package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterEngine.kt +528 -0
- package/android/src/main/java/com/delicity/thermalprinter/ThermalPrinterPlugin.kt +334 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/BleAdapter.kt +125 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/BrotherAdapter.kt +206 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EpsonAdapter.kt +384 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosAdapter.kt +160 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosCommands.kt +42 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/EscPosTextEncoder.kt +138 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/PrinterAdapter.kt +95 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/RawTcpAdapter.kt +96 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkContract.kt +158 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/SdkReflect.kt +104 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/StarAdapter.kt +322 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/UsbAdapter.kt +248 -0
- package/android/src/main/java/com/delicity/thermalprinter/adapters/ZebraAdapter.kt +207 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/AdapterPriority.kt +39 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/BleScanner.kt +70 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt +112 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/DiscoveryManager.kt +136 -0
- package/android/src/main/java/com/delicity/thermalprinter/discovery/TcpScanner.kt +96 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/ImageCache.kt +88 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/ImageProcessor.kt +220 -0
- package/android/src/main/java/com/delicity/thermalprinter/image/TextRasterizer.kt +99 -0
- package/android/src/main/java/com/delicity/thermalprinter/model/Models.kt +206 -0
- package/android/src/main/java/com/delicity/thermalprinter/model/PrintItem.kt +100 -0
- package/android/src/main/java/com/delicity/thermalprinter/store/PrinterStore.kt +71 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/BleGattClient.kt +201 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/BluetoothSppTransport.kt +110 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/ByteTransport.kt +18 -0
- package/android/src/main/java/com/delicity/thermalprinter/transport/TcpTransport.kt +83 -0
- package/dist/esm/adapters/dedup.d.ts +26 -0
- package/dist/esm/adapters/dedup.js +66 -0
- package/dist/esm/adapters/dedup.js.map +1 -0
- package/dist/esm/adapters/priority.d.ts +29 -0
- package/dist/esm/adapters/priority.js +55 -0
- package/dist/esm/adapters/priority.js.map +1 -0
- package/dist/esm/core/enums.d.ts +61 -0
- package/dist/esm/core/enums.js +25 -0
- package/dist/esm/core/enums.js.map +1 -0
- package/dist/esm/core/errors.d.ts +16 -0
- package/dist/esm/core/errors.js +53 -0
- package/dist/esm/core/errors.js.map +1 -0
- package/dist/esm/core/escpos-text.d.ts +33 -0
- package/dist/esm/core/escpos-text.js +239 -0
- package/dist/esm/core/escpos-text.js.map +1 -0
- package/dist/esm/core/imaging.d.ts +91 -0
- package/dist/esm/core/imaging.js +184 -0
- package/dist/esm/core/imaging.js.map +1 -0
- package/dist/esm/core/models.d.ts +131 -0
- package/dist/esm/core/models.js +2 -0
- package/dist/esm/core/models.js.map +1 -0
- package/dist/esm/core/options.d.ts +154 -0
- package/dist/esm/core/options.js +2 -0
- package/dist/esm/core/options.js.map +1 -0
- package/dist/esm/core/text.d.ts +138 -0
- package/dist/esm/core/text.js +14 -0
- package/dist/esm/core/text.js.map +1 -0
- package/dist/esm/definitions.d.ts +155 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +63 -0
- package/dist/esm/web.js +112 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +224 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +227 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/Adapters/BrotherAdapter.swift +139 -0
- package/ios/Plugin/Adapters/EpsonAdapter.swift +131 -0
- package/ios/Plugin/Adapters/EscPosAdapter.swift +106 -0
- package/ios/Plugin/Adapters/EscPosCommands.swift +32 -0
- package/ios/Plugin/Adapters/EscPosTextEncoder.swift +115 -0
- package/ios/Plugin/Adapters/PrinterAdapter.swift +44 -0
- package/ios/Plugin/Adapters/RawTcpAdapter.swift +70 -0
- package/ios/Plugin/Adapters/StarAdapter.swift +305 -0
- package/ios/Plugin/Adapters/ZebraAdapter.swift +119 -0
- package/ios/Plugin/Discovery/AdapterPriority.swift +21 -0
- package/ios/Plugin/Discovery/BonjourScanner.swift +51 -0
- package/ios/Plugin/Discovery/DiscoveryManager.swift +86 -0
- package/ios/Plugin/Image/ImageCache.swift +73 -0
- package/ios/Plugin/Image/ImageProcessor.swift +168 -0
- package/ios/Plugin/Image/TextRasterizer.swift +81 -0
- package/ios/Plugin/Logger.swift +33 -0
- package/ios/Plugin/Model/Models.swift +174 -0
- package/ios/Plugin/Model/PrintItem.swift +111 -0
- package/ios/Plugin/Store/PrinterStore.swift +51 -0
- package/ios/Plugin/ThermalPrinterEngine.swift +395 -0
- package/ios/Plugin/ThermalPrinterPlugin.m +22 -0
- package/ios/Plugin/ThermalPrinterPlugin.swift +258 -0
- package/ios/Plugin/Transport/TcpTransport.swift +89 -0
- 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
|
+
}
|