@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,395 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// Cœur applicatif iOS (miroir de ThermalPrinterEngine.kt). Indépendant de Capacitor.
5
+ final class ThermalPrinterEngine {
6
+
7
+ private let store = PrinterStore()
8
+ private let imageCache = ImageCache()
9
+
10
+ /// Registry d'adapters (l'ordre n'importe pas, priorité gérée ailleurs).
11
+ private lazy var adapters: [PrinterAdapter] = [
12
+ EpsonAdapter(), StarAdapter(), BrotherAdapter(), ZebraAdapter(),
13
+ EscPosAdapter(), RawTcpAdapter(),
14
+ ]
15
+
16
+ private var lastDiscovered: [DiscoveredPrinter] = []
17
+ private let lock = NSLock()
18
+
19
+ /// Émetteur d'états de job (branché par le plugin sur notifyListeners).
20
+ var onJobUpdate: ((JobUpdate) -> Void)?
21
+
22
+ /// Émetteur de changement de statut (branché par le plugin sur 'statusChange').
23
+ var onStatusChange: ((PrinterStatus) -> Void)?
24
+
25
+ /// Registre des moniteurs de statut actifs (Phase 6).
26
+ private var monitors: [String: Task<Void, Never>] = [:]
27
+ private let monitorLock = NSLock()
28
+
29
+ private func emitJob(_ jobId: String, _ printerId: String, _ state: String,
30
+ holdReason: String? = nil, progress: Double? = nil,
31
+ errorCode: ErrorCode? = nil, message: String? = nil) {
32
+ onJobUpdate?(JobUpdate(jobId: jobId, printerId: printerId, state: state,
33
+ holdReason: holdReason, progress: progress, errorCode: errorCode, message: message))
34
+ }
35
+
36
+ // MARK: Découverte
37
+
38
+ func discover(_ options: DiscoveryManager.Options, emitPartial: @escaping (DiscoveredPrinter) -> Void) async -> (printers: [DiscoveredPrinter], failed: [String]) {
39
+ Logger.shared.log("discovery", "start")
40
+ let manager = DiscoveryManager(adapters: adapters)
41
+ var (printers, failed) = await manager.discover(options, emitPartial: emitPartial)
42
+ let defaultId = store.getDefault()?.id
43
+ for i in printers.indices {
44
+ printers[i].isDefault = printers[i].id == defaultId
45
+ printers[i].isConnected = adapterFor(printers[i].adapter)?.isConnected(printers[i].id) ?? false
46
+ }
47
+ lock.lock(); lastDiscovered = printers; lock.unlock()
48
+ Logger.shared.log("discovery", "complete", ["count": printers.count])
49
+ return (printers, failed)
50
+ }
51
+
52
+ // MARK: Connexion
53
+
54
+ func connect(_ printerId: String, timeoutMs: Int, forceAdapter: AdapterId?, setAsDefault: Bool = false) async throws -> Bool {
55
+ let profile = try resolveProfile(printerId, forceAdapter: forceAdapter)
56
+ guard let adapter = adapterFor(profile.adapter) else {
57
+ throw PrinterError(.UNSUPPORTED_PRINTER, "Aucun adapter pour \(profile.adapter.rawValue)")
58
+ }
59
+ guard adapter.isAvailable() else {
60
+ throw PrinterError(.SDK_NOT_AVAILABLE, "Adapter \(profile.adapter.rawValue) indisponible")
61
+ }
62
+ try await adapter.connect(profile, timeoutMs: timeoutMs)
63
+ let connected = adapter.isConnected(printerId)
64
+ // setAsDefault UNIQUEMENT si la connexion a réussi.
65
+ if connected && setAsDefault {
66
+ store.upsert(profile)
67
+ store.setDefault(printerId)
68
+ Logger.shared.log("connect", "set-default-after-connect", ["id": printerId])
69
+ }
70
+ return connected
71
+ }
72
+
73
+ func disconnect(_ printerId: String) async {
74
+ guard let profile = store.get(printerId) ?? ephemeral(for: printerId),
75
+ let adapter = adapterFor(profile.adapter) else { return }
76
+ await adapter.disconnect(printerId)
77
+ }
78
+
79
+ private static let reconnectAttempts = 3
80
+
81
+ /// Reconnexion auto avec **backoff exponentiel** (300ms, 600ms, 1200ms…, plafonné).
82
+ /// Les erreurs non-retryables court-circuitent.
83
+ private func ensureConnected(_ profile: PrinterProfile, timeoutMs: Int) async throws {
84
+ guard let adapter = adapterFor(profile.adapter) else {
85
+ throw PrinterError(.UNSUPPORTED_PRINTER, "Adapter introuvable")
86
+ }
87
+ if adapter.isConnected(profile.id) { return }
88
+
89
+ var backoff: UInt64 = 300
90
+ var lastError: Error?
91
+ for attempt in 1...Self.reconnectAttempts {
92
+ do {
93
+ Logger.shared.log("connect", "auto-reconnect", ["id": profile.id, "attempt": attempt])
94
+ try await adapter.connect(profile, timeoutMs: timeoutMs)
95
+ if adapter.isConnected(profile.id) {
96
+ if attempt > 1 { Logger.shared.log("connect", "reconnect-recovered", ["id": profile.id, "attempt": attempt]) }
97
+ return
98
+ }
99
+ lastError = PrinterError(.CONNECTION_FAILED, "Connexion non établie", retryable: true)
100
+ } catch let e as PrinterError {
101
+ lastError = e
102
+ if !e.retryable { throw e }
103
+ } catch {
104
+ lastError = error
105
+ }
106
+ if attempt < Self.reconnectAttempts {
107
+ Logger.shared.log("connect", "backoff", ["id": profile.id, "delayMs": backoff])
108
+ try? await Task.sleep(nanoseconds: backoff * 1_000_000)
109
+ backoff = min(backoff * 2, 3000)
110
+ }
111
+ }
112
+ throw lastError ?? PrinterError(.CONNECTION_FAILED, "Reconnexion échouée (\(Self.reconnectAttempts) tentatives)", retryable: true)
113
+ }
114
+
115
+ // MARK: Impression
116
+
117
+ struct PrintRequest {
118
+ let printerId: String?
119
+ let filePath: String?
120
+ let url: String?
121
+ let base64: String?
122
+ let render: RenderOptions?
123
+ let timeoutMs: Int
124
+ let autoReconnect: Bool
125
+ }
126
+
127
+ struct PrintTextRequest {
128
+ let printerId: String?
129
+ let items: [PrintItem]
130
+ let defaultCodePage: String
131
+ let cut: Bool
132
+ let feedLines: Int
133
+ let timeoutMs: Int
134
+ let autoReconnect: Bool
135
+ }
136
+
137
+ struct PrintOutcome {
138
+ let printerId: String
139
+ let adapter: AdapterId
140
+ let jobId: String
141
+ let state: String
142
+ let bytesSent: Int
143
+ let durationMs: Int
144
+ let status: PrinterStatus?
145
+ }
146
+
147
+ func printImage(_ req: PrintRequest) async throws -> PrintOutcome {
148
+ let started = Date()
149
+ let jobId = UUID().uuidString
150
+ let profile = try resolveTargetProfile(req.printerId)
151
+ emitJob(jobId, profile.id, "pending")
152
+ guard let adapter = adapterFor(profile.adapter) else {
153
+ let e = PrinterError(.UNSUPPORTED_PRINTER, "Adapter introuvable")
154
+ emitJob(jobId, profile.id, "failed", errorCode: e.code, message: e.message); throw e
155
+ }
156
+ do {
157
+ if !adapter.isConnected(profile.id) {
158
+ guard req.autoReconnect else { throw PrinterError(.CONNECTION_FAILED, "Imprimante non connectée") }
159
+ try await ensureConnected(profile, timeoutMs: req.timeoutMs)
160
+ }
161
+ try await preflightHold(adapter, profile, jobId)
162
+
163
+ let image = try await loadImage(req)
164
+ let render = resolveRenderOptions(profile, req.render)
165
+ let resized = render.resize ? try ImageProcessor.resizeToWidth(image, targetWidth: render.widthDots) : image
166
+
167
+ emitJob(jobId, profile.id, "printing", progress: 0.1)
168
+ let bytes = try await adapter.printImage(profile, image: resized, options: render)
169
+ let status = try? await adapter.getStatus(profile)
170
+ let duration = Int(Date().timeIntervalSince(started) * 1000)
171
+ emitJob(jobId, profile.id, "completed", progress: 1.0)
172
+ return PrintOutcome(printerId: profile.id, adapter: profile.adapter, jobId: jobId, state: "completed", bytesSent: bytes, durationMs: duration, status: status)
173
+ } catch let e as PrinterError {
174
+ emitJob(jobId, profile.id, "failed", errorCode: e.code, message: e.message); throw e
175
+ }
176
+ }
177
+
178
+ func printText(_ req: PrintTextRequest) async throws -> PrintOutcome {
179
+ let started = Date()
180
+ let jobId = UUID().uuidString
181
+ let profile = try resolveTargetProfile(req.printerId)
182
+ emitJob(jobId, profile.id, "pending")
183
+ guard let adapter = adapterFor(profile.adapter) else {
184
+ let e = PrinterError(.UNSUPPORTED_PRINTER, "Adapter introuvable")
185
+ emitJob(jobId, profile.id, "failed", errorCode: e.code, message: e.message); throw e
186
+ }
187
+ do {
188
+ if !adapter.isConnected(profile.id) {
189
+ guard req.autoReconnect else { throw PrinterError(.CONNECTION_FAILED, "Imprimante non connectée") }
190
+ try await ensureConnected(profile, timeoutMs: req.timeoutMs)
191
+ }
192
+ try await preflightHold(adapter, profile, jobId)
193
+
194
+ emitJob(jobId, profile.id, "printing", progress: 0.1)
195
+ let bytes: Int
196
+ if adapter.supportsTextItems() {
197
+ bytes = try await adapter.printItems(profile, items: req.items, defaultCodePage: req.defaultCodePage, cut: req.cut, feedLines: req.feedLines)
198
+ } else {
199
+ // Repli : rendre les items en image puis imprimer via le SDK image (Brother/Zebra).
200
+ let width = profile.capabilities.printableDots > 0 ? profile.capabilities.printableDots : 576
201
+ let image = TextRasterizer.render(req.items, widthDots: width)
202
+ var render = RenderOptions(widthDots: width)
203
+ render.resize = false
204
+ render.cut = req.cut
205
+ render.feedLines = req.feedLines
206
+ bytes = try await adapter.printImage(profile, image: image, options: render)
207
+ }
208
+ let status = try? await adapter.getStatus(profile)
209
+ let duration = Int(Date().timeIntervalSince(started) * 1000)
210
+ emitJob(jobId, profile.id, "completed", progress: 1.0)
211
+ return PrintOutcome(printerId: profile.id, adapter: profile.adapter, jobId: jobId, state: "completed", bytesSent: bytes, durationMs: duration, status: status)
212
+ } catch let e as PrinterError {
213
+ emitJob(jobId, profile.id, "failed", errorCode: e.code, message: e.message); throw e
214
+ }
215
+ }
216
+
217
+ /// Lit le statut avant impression ; émet HOLD + lève si papier/capot bloquant.
218
+ private func preflightHold(_ adapter: PrinterAdapter, _ profile: PrinterProfile, _ jobId: String) async throws {
219
+ guard profile.capabilities.supportsStatus else { return }
220
+ guard let st = try? await adapter.getStatus(profile) else { return }
221
+ if st.paper == "empty" {
222
+ emitJob(jobId, profile.id, "hold", holdReason: "paper_empty")
223
+ throw PrinterError(.PAPER_EMPTY, "Plus de papier", retryable: true)
224
+ }
225
+ if st.coverOpen == true {
226
+ emitJob(jobId, profile.id, "hold", holdReason: "cover_open")
227
+ throw PrinterError(.COVER_OPEN, "Capot ouvert", retryable: true)
228
+ }
229
+ }
230
+
231
+ // MARK: Profils
232
+
233
+ func savedProfiles() -> [PrinterProfile] { store.all() }
234
+ func defaultProfile() -> PrinterProfile? { store.getDefault() }
235
+ func removeProfile(_ id: String) { store.remove(id) }
236
+
237
+ func setDefault(_ printerId: String) throws -> PrinterProfile {
238
+ if store.get(printerId) != nil {
239
+ guard let updated = store.setDefault(printerId) else {
240
+ throw PrinterError(.PRINTER_NOT_FOUND, "Profil introuvable")
241
+ }
242
+ return updated
243
+ }
244
+ guard let d = ephemeralDiscovered(printerId) else {
245
+ throw PrinterError(.PRINTER_NOT_FOUND, "Imprimante inconnue: \(printerId)")
246
+ }
247
+ var profile = toEphemeralProfile(d)
248
+ profile.isDefault = true
249
+ store.upsert(profile)
250
+ return store.setDefault(printerId) ?? profile
251
+ }
252
+
253
+ func getStatus(_ printerId: String?) async throws -> PrinterStatus {
254
+ let profile = try resolveTargetProfile(printerId)
255
+ guard let adapter = adapterFor(profile.adapter) else {
256
+ throw PrinterError(.UNSUPPORTED_PRINTER, "Adapter introuvable")
257
+ }
258
+ return try await adapter.getStatus(profile)
259
+ }
260
+
261
+ // MARK: Monitoring de statut (Phase 6)
262
+
263
+ /// Démarre un polling périodique du statut et émet `statusChange` uniquement
264
+ /// quand l'état pertinent change (connexion/online/papier/capot). Idempotent.
265
+ func startStatusMonitor(_ printerId: String, intervalMs: Int) {
266
+ stopStatusMonitor(printerId)
267
+ let interval = min(max(intervalMs, 1000), 300_000)
268
+ let task = Task { [weak self] in
269
+ var lastKey: String?
270
+ var lastBlocked = false
271
+ while !Task.isCancelled {
272
+ guard let self = self else { return }
273
+ let status: PrinterStatus
274
+ do {
275
+ status = try await self.getStatus(printerId)
276
+ } catch let e as PrinterError {
277
+ status = PrinterStatus(id: printerId, connection: "error", online: false, paper: "unknown", errorCode: e.code, rawStatus: e.message)
278
+ } catch {
279
+ status = PrinterStatus(id: printerId, connection: "error", online: false, paper: "unknown", rawStatus: "\(error)")
280
+ }
281
+ // "Bloqué" = condition qui mettrait un job en hold (papier/capot/offline).
282
+ let blocked = status.paper == "empty" || status.coverOpen == true || !status.online
283
+ let key = "\(status.connection)|\(status.online)|\(status.paper)|\(String(describing: status.coverOpen))|\(String(describing: status.errorCode))"
284
+ if key != lastKey {
285
+ lastKey = key
286
+ self.onStatusChange?(status)
287
+ if lastBlocked && !blocked {
288
+ // Reprise après hold (papier rechargé / capot fermé / retour online).
289
+ Logger.shared.log("status", "recovered", ["id": printerId])
290
+ }
291
+ Logger.shared.log("status", "change", ["id": printerId, "paper": status.paper])
292
+ }
293
+ lastBlocked = blocked
294
+ try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000)
295
+ }
296
+ }
297
+ monitorLock.lock(); monitors[printerId] = task; monitorLock.unlock()
298
+ Logger.shared.log("status", "monitor-start", ["id": printerId, "intervalMs": interval])
299
+ }
300
+
301
+ /// Arrête le moniteur d'une imprimante (no-op si absent).
302
+ func stopStatusMonitor(_ printerId: String) {
303
+ monitorLock.lock(); let t = monitors.removeValue(forKey: printerId); monitorLock.unlock()
304
+ t?.cancel()
305
+ }
306
+
307
+ /// Arrête tous les moniteurs.
308
+ func stopAllMonitors() {
309
+ monitorLock.lock(); let all = monitors; monitors.removeAll(); monitorLock.unlock()
310
+ all.values.forEach { $0.cancel() }
311
+ }
312
+
313
+ /// État courant de chaque adapter/SDK (cf. getActiveSdks).
314
+ func activeSdks() -> [[String: Any]] {
315
+ let star = adapters.first { $0 is StarAdapter }?.isAvailable() ?? false
316
+ let epson = adapters.first { $0 is EpsonAdapter }?.isAvailable() ?? false
317
+ let brother = adapters.first { $0 is BrotherAdapter }?.isAvailable() ?? false
318
+ let zebra = adapters.first { $0 is ZebraAdapter }?.isAvailable() ?? false
319
+ return [
320
+ ["adapter": "escpos", "label": "ESC/POS générique", "available": true, "requiresSdk": false, "transports": ["wifi", "ethernet"]],
321
+ ["adapter": "star", "label": "Star StarXpand", "available": star, "requiresSdk": true, "transports": ["wifi", "bluetooth", "ble"]],
322
+ ["adapter": "epson", "label": "Epson ePOS2", "available": epson, "requiresSdk": true, "transports": ["wifi", "bluetooth"]],
323
+ ["adapter": "brother", "label": "Brother", "available": brother, "requiresSdk": true, "transports": ["wifi", "bluetooth", "ble"]],
324
+ ["adapter": "zebra", "label": "Zebra Link-OS", "available": zebra, "requiresSdk": true, "transports": ["wifi", "bluetooth"]],
325
+ ["adapter": "rawTcp", "label": "TCP brut", "available": true, "requiresSdk": false, "transports": ["wifi", "ethernet"]],
326
+ ]
327
+ }
328
+
329
+ func debugLog() -> [[String: Any]] { Logger.shared.snapshot() }
330
+
331
+ // MARK: Helpers
332
+
333
+ private func adapterFor(_ id: AdapterId) -> PrinterAdapter? { adapters.first { $0.id == id } }
334
+
335
+ private func ephemeralDiscovered(_ id: String) -> DiscoveredPrinter? {
336
+ lock.lock(); defer { lock.unlock() }
337
+ return lastDiscovered.first { $0.id == id }
338
+ }
339
+
340
+ private func ephemeral(for id: String) -> PrinterProfile? {
341
+ ephemeralDiscovered(id).map(toEphemeralProfile)
342
+ }
343
+
344
+ private func resolveTargetProfile(_ printerId: String?) throws -> PrinterProfile {
345
+ guard let id = printerId else {
346
+ guard let def = store.getDefault() else {
347
+ throw PrinterError(.PRINTER_NOT_FOUND, "Aucune imprimante par défaut")
348
+ }
349
+ return def
350
+ }
351
+ return try resolveProfile(id, forceAdapter: nil)
352
+ }
353
+
354
+ private func resolveProfile(_ printerId: String, forceAdapter: AdapterId?) throws -> PrinterProfile {
355
+ if var p = store.get(printerId) {
356
+ if let f = forceAdapter { p.adapter = f }
357
+ return p
358
+ }
359
+ guard let d = ephemeralDiscovered(printerId) else {
360
+ throw PrinterError(.PRINTER_NOT_FOUND, "Imprimante inconnue: \(printerId)")
361
+ }
362
+ var base = toEphemeralProfile(d)
363
+ if let f = forceAdapter { base.adapter = f }
364
+ return base
365
+ }
366
+
367
+ private func toEphemeralProfile(_ d: DiscoveredPrinter) -> PrinterProfile {
368
+ PrinterProfile(
369
+ id: d.id, adapter: d.adapter, transport: d.transport, address: d.address,
370
+ brand: d.brand, model: d.model, name: d.name,
371
+ capabilities: d.capabilities ?? Capabilities()
372
+ )
373
+ }
374
+
375
+ private func loadImage(_ req: PrintRequest) async throws -> UIImage {
376
+ if let path = req.filePath, !path.isEmpty { return try ImageProcessor.decodeFile(path) }
377
+ if let url = req.url, !url.isEmpty {
378
+ let local = try await imageCache.fetch(url)
379
+ return try ImageProcessor.decodeFile(local)
380
+ }
381
+ if let b64 = req.base64, !b64.isEmpty { return try ImageProcessor.decodeBase64(b64) }
382
+ throw PrinterError(.IMAGE_INVALID, "Aucune source image fournie")
383
+ }
384
+
385
+ private func resolveRenderOptions(_ profile: PrinterProfile, _ req: RenderOptions?) -> RenderOptions {
386
+ var width = req?.widthDots ?? 0
387
+ if width <= 0 { width = profile.capabilities.printableDots }
388
+ if width <= 0 {
389
+ width = profile.capabilities.paperWidthMm == 58 ? 384 : (profile.capabilities.paperWidthMm == 112 ? 832 : 576)
390
+ }
391
+ var opts = req ?? RenderOptions(widthDots: width)
392
+ opts.widthDots = width
393
+ return opts
394
+ }
395
+ }
@@ -0,0 +1,22 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <Capacitor/Capacitor.h>
3
+
4
+ // Enregistrement du plugin auprès du runtime Capacitor (Objective-C bridge).
5
+ // Doit refléter les méthodes déclarées dans pluginMethods (ThermalPrinterPlugin.swift).
6
+ CAP_PLUGIN(ThermalPrinterPlugin, "ThermalPrinter",
7
+ CAP_PLUGIN_METHOD(discoverPrinters, CAPPluginReturnPromise);
8
+ CAP_PLUGIN_METHOD(connectPrinter, CAPPluginReturnPromise);
9
+ CAP_PLUGIN_METHOD(disconnectPrinter, CAPPluginReturnPromise);
10
+ CAP_PLUGIN_METHOD(setDefaultPrinter, CAPPluginReturnPromise);
11
+ CAP_PLUGIN_METHOD(getDefaultPrinter, CAPPluginReturnPromise);
12
+ CAP_PLUGIN_METHOD(getSavedPrinters, CAPPluginReturnPromise);
13
+ CAP_PLUGIN_METHOD(removePrinter, CAPPluginReturnPromise);
14
+ CAP_PLUGIN_METHOD(printImage, CAPPluginReturnPromise);
15
+ CAP_PLUGIN_METHOD(printText, CAPPluginReturnPromise);
16
+ CAP_PLUGIN_METHOD(getPrinterStatus, CAPPluginReturnPromise);
17
+ CAP_PLUGIN_METHOD(requestPermissions, CAPPluginReturnPromise);
18
+ CAP_PLUGIN_METHOD(checkPermissions, CAPPluginReturnPromise);
19
+ CAP_PLUGIN_METHOD(startStatusMonitor, CAPPluginReturnPromise);
20
+ CAP_PLUGIN_METHOD(stopStatusMonitor, CAPPluginReturnPromise);
21
+ CAP_PLUGIN_METHOD(getDebugLog, CAPPluginReturnPromise);
22
+ )
@@ -0,0 +1,258 @@
1
+ import Foundation
2
+ import Capacitor
3
+
4
+ /// Pont Capacitor iOS (bridge JS <-> Swift). Conforme Capacitor 7 (CAPBridgedPlugin).
5
+ ///
6
+ /// Mappe l'API publique (definitions.ts) vers ThermalPrinterEngine. Les opérations
7
+ /// async tournent dans des Task ; les PrinterError sont converties en rejets
8
+ /// avec code normalisé.
9
+ @objc(ThermalPrinterPlugin)
10
+ public class ThermalPrinterPlugin: CAPPlugin, CAPBridgedPlugin {
11
+
12
+ public let identifier = "ThermalPrinterPlugin"
13
+ public let jsName = "ThermalPrinter"
14
+ public let pluginMethods: [CAPPluginMethod] = [
15
+ CAPPluginMethod(name: "discoverPrinters", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "connectPrinter", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "disconnectPrinter", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "setDefaultPrinter", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "getDefaultPrinter", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "getSavedPrinters", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "removePrinter", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "printImage", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "printText", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "getPrinterStatus", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "startStatusMonitor", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "stopStatusMonitor", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "getActiveSdks", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "getDebugLog", returnType: CAPPluginReturnPromise),
31
+ ]
32
+
33
+ private let engine = ThermalPrinterEngine()
34
+
35
+ override public func load() {
36
+ // Relaye les états de job vers le JS (event printJobStatus).
37
+ engine.onJobUpdate = { [weak self] update in
38
+ self?.notifyListeners("printJobStatus", data: ["job": update.toDict()])
39
+ }
40
+ // Relaye les changements de statut vers le JS (event statusChange).
41
+ engine.onStatusChange = { [weak self] status in
42
+ self?.notifyListeners("statusChange", data: ["status": status.toDict()])
43
+ }
44
+ }
45
+
46
+ // MARK: Permissions
47
+ //
48
+ // iOS n'a pas de permission runtime pour le Bluetooth Classic (inexistant) ni
49
+ // pour le réseau local au sens "demande explicite" : la pop-up Local Network
50
+ // apparaît à la première connexion. Le BLE déclenche une autorisation gérée par
51
+ // CoreBluetooth si on instancie un CBCentralManager (à activer avec BleAdapter).
52
+
53
+ @objc func checkPermissions(_ call: CAPPluginCall) {
54
+ call.resolve(permissionStatus())
55
+ }
56
+
57
+ @objc override public func requestPermissions(_ call: CAPPluginCall) {
58
+ // Rien à demander explicitement ici (voir note ci-dessus).
59
+ call.resolve(permissionStatus())
60
+ }
61
+
62
+ private func permissionStatus() -> [String: Any] {
63
+ [
64
+ "bluetooth": "unavailable", // BT Classic générique impossible sur iOS
65
+ "bluetoothScan": "unavailable",
66
+ "bluetoothConnect": "unavailable",
67
+ "location": "granted",
68
+ "localNetwork": "prompt", // pop-up système à la 1re connexion locale
69
+ ]
70
+ }
71
+
72
+ // MARK: Découverte
73
+
74
+ @objc func discoverPrinters(_ call: CAPPluginCall) {
75
+ let sources: Set<String>? = (call.getArray("sources") as? [String]).map { Set($0) }
76
+ let options = DiscoveryManager.Options(
77
+ sources: sources,
78
+ timeoutMs: call.getInt("timeoutMs") ?? 8000,
79
+ networkCidr: call.getString("networkCidr"),
80
+ tcpPorts: (call.getArray("tcpPorts") as? [Int]) ?? [9100]
81
+ )
82
+ let emitPartial = call.getBool("emitPartialResults") ?? true
83
+
84
+ Task {
85
+ let result = await engine.discover(options) { [weak self] p in
86
+ if emitPartial { self?.notifyListeners("printerFound", data: ["printer": p.toDict()]) }
87
+ }
88
+ let printersDict = result.printers.map { $0.toDict() }
89
+ self.notifyListeners("discoveryComplete", data: ["printers": printersDict, "failedSources": result.failed])
90
+ call.resolve(["printers": printersDict])
91
+ }
92
+ }
93
+
94
+ // MARK: Connexion
95
+
96
+ @objc func connectPrinter(_ call: CAPPluginCall) {
97
+ guard let printerId = call.getString("printerId") else {
98
+ return reject(call, .PRINTER_NOT_FOUND, "printerId requis")
99
+ }
100
+ let timeout = call.getInt("timeoutMs") ?? 10000
101
+ let force = call.getString("forceAdapter").map { AdapterId.from($0) }
102
+ let setAsDefault = call.getBool("setAsDefault") ?? false
103
+ Task { await self.guarded(call) {
104
+ let connected = try await self.engine.connect(printerId, timeoutMs: timeout, forceAdapter: force, setAsDefault: setAsDefault)
105
+ call.resolve(["connected": connected])
106
+ } }
107
+ }
108
+
109
+ @objc func disconnectPrinter(_ call: CAPPluginCall) {
110
+ let id = call.getString("printerId") ?? ""
111
+ Task { await self.engine.disconnect(id); call.resolve() }
112
+ }
113
+
114
+ // MARK: Profils
115
+
116
+ @objc func setDefaultPrinter(_ call: CAPPluginCall) {
117
+ guard let id = call.getString("printerId") else { return reject(call, .PRINTER_NOT_FOUND, "printerId requis") }
118
+ do {
119
+ let profile = try engine.setDefault(id)
120
+ call.resolve(["profile": profile.toDict()])
121
+ } catch { rejectError(call, error) }
122
+ }
123
+
124
+ @objc func getDefaultPrinter(_ call: CAPPluginCall) {
125
+ if let p = engine.defaultProfile() { call.resolve(["profile": p.toDict()]) }
126
+ else { call.resolve(["profile": NSNull()]) }
127
+ }
128
+
129
+ @objc func getSavedPrinters(_ call: CAPPluginCall) {
130
+ call.resolve(["profiles": engine.savedProfiles().map { $0.toDict() }])
131
+ }
132
+
133
+ @objc func removePrinter(_ call: CAPPluginCall) {
134
+ engine.removeProfile(call.getString("printerId") ?? "")
135
+ call.resolve()
136
+ }
137
+
138
+ // MARK: Impression / statut
139
+
140
+ @objc func printImage(_ call: CAPPluginCall) {
141
+ guard let image = call.getObject("image") else { return reject(call, .IMAGE_INVALID, "image requise") }
142
+ var render: RenderOptions?
143
+ if let r = call.getObject("render") {
144
+ render = RenderOptions(
145
+ widthDots: r["widthDots"] as? Int ?? 0,
146
+ resize: r["resize"] as? Bool ?? true,
147
+ grayscale: r["grayscale"] as? Bool ?? true,
148
+ threshold: r["threshold"] as? Int ?? 128,
149
+ dithering: r["dithering"] as? String ?? "floyd_steinberg",
150
+ align: r["align"] as? String ?? "center",
151
+ invert: r["invert"] as? Bool ?? false,
152
+ cut: r["cut"] as? Bool ?? true,
153
+ feedLines: r["feedLines"] as? Int ?? 3,
154
+ openCashDrawer: r["openCashDrawer"] as? Bool ?? false,
155
+ copies: r["copies"] as? Int ?? 1
156
+ )
157
+ }
158
+ let req = ThermalPrinterEngine.PrintRequest(
159
+ printerId: call.getString("printerId"),
160
+ filePath: image["filePath"] as? String,
161
+ url: image["url"] as? String,
162
+ base64: image["base64"] as? String,
163
+ render: render,
164
+ timeoutMs: call.getInt("timeoutMs") ?? 15000,
165
+ autoReconnect: call.getBool("autoReconnect") ?? true
166
+ )
167
+ Task { await self.guarded(call) {
168
+ let out = try await self.engine.printImage(req)
169
+ call.resolve(self.printResultDict(out))
170
+ } }
171
+ }
172
+
173
+ @objc func printText(_ call: CAPPluginCall) {
174
+ guard let rawItems = call.getArray("items") as? [[String: Any]] else {
175
+ return reject(call, .IMAGE_INVALID, "items requis")
176
+ }
177
+ let req = ThermalPrinterEngine.PrintTextRequest(
178
+ printerId: call.getString("printerId"),
179
+ items: PrintItem.parseList(rawItems),
180
+ defaultCodePage: call.getString("defaultCodePage") ?? "WPC1252",
181
+ cut: call.getBool("cut") ?? false,
182
+ feedLines: call.getInt("feedLines") ?? 3,
183
+ timeoutMs: call.getInt("timeoutMs") ?? 15000,
184
+ autoReconnect: call.getBool("autoReconnect") ?? true
185
+ )
186
+ Task { await self.guarded(call) {
187
+ let out = try await self.engine.printText(req)
188
+ call.resolve(self.printResultDict(out))
189
+ } }
190
+ }
191
+
192
+ private func printResultDict(_ out: ThermalPrinterEngine.PrintOutcome) -> [String: Any] {
193
+ var result: [String: Any] = [
194
+ "success": out.state == "completed",
195
+ "printerId": out.printerId,
196
+ "adapter": out.adapter.rawValue,
197
+ "jobId": out.jobId,
198
+ "state": out.state,
199
+ "bytesSent": out.bytesSent,
200
+ "durationMs": out.durationMs,
201
+ ]
202
+ if let status = out.status { result["status"] = status.toDict() }
203
+ return result
204
+ }
205
+
206
+ @objc func getPrinterStatus(_ call: CAPPluginCall) {
207
+ Task { await self.guarded(call) {
208
+ let status = try await self.engine.getStatus(call.getString("printerId"))
209
+ call.resolve(status.toDict())
210
+ } }
211
+ }
212
+
213
+ // MARK: Monitoring (Phase 6)
214
+
215
+ @objc func startStatusMonitor(_ call: CAPPluginCall) {
216
+ guard let printerId = call.getString("printerId") else {
217
+ return reject(call, .PRINTER_NOT_FOUND, "printerId requis")
218
+ }
219
+ engine.startStatusMonitor(printerId, intervalMs: call.getInt("intervalMs") ?? 5000)
220
+ call.resolve()
221
+ }
222
+
223
+ @objc func stopStatusMonitor(_ call: CAPPluginCall) {
224
+ guard let printerId = call.getString("printerId") else {
225
+ return reject(call, .PRINTER_NOT_FOUND, "printerId requis")
226
+ }
227
+ engine.stopStatusMonitor(printerId)
228
+ call.resolve()
229
+ }
230
+
231
+ @objc func getActiveSdks(_ call: CAPPluginCall) {
232
+ call.resolve(["sdks": engine.activeSdks()])
233
+ }
234
+
235
+ @objc func getDebugLog(_ call: CAPPluginCall) {
236
+ call.resolve(["log": engine.debugLog()])
237
+ }
238
+
239
+ // MARK: Plomberie erreurs
240
+
241
+ private func guarded(_ call: CAPPluginCall, _ block: () async throws -> Void) async {
242
+ do { try await block() } catch { rejectError(call, error) }
243
+ }
244
+
245
+ private func reject(_ call: CAPPluginCall, _ code: ErrorCode, _ message: String) {
246
+ call.reject(message, code.rawValue, nil, ["code": code.rawValue, "retryable": false])
247
+ }
248
+
249
+ private func rejectError(_ call: CAPPluginCall, _ error: Error) {
250
+ if let pe = error as? PrinterError {
251
+ Logger.shared.error("plugin", "\(pe.code.rawValue): \(pe.message)")
252
+ call.reject(pe.message, pe.code.rawValue, nil, ["code": pe.code.rawValue, "detail": pe.detail ?? "", "retryable": pe.retryable])
253
+ } else {
254
+ Logger.shared.error("plugin", "UNKNOWN: \(error.localizedDescription)")
255
+ call.reject(error.localizedDescription, ErrorCode.UNKNOWN.rawValue, error)
256
+ }
257
+ }
258
+ }