@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,305 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ #if canImport(StarIO10)
5
+ import StarIO10
6
+ #endif
7
+
8
+ /// Adapter Star iOS basé sur StarXpand SDK (StarIO10).
9
+ ///
10
+ /// ⭐ Star est le mieux supporté sur iOS (MFi) et le seul fabricant auto-installable :
11
+ /// via Swift Package Manager (`https://github.com/star-micronics/StarXpand-SDK-iOS`).
12
+ /// On utilise la compilation conditionnelle `#if canImport(StarIO10)` :
13
+ /// - si l'app a ajouté le package StarXpand -> code TYPÉ compilé et actif,
14
+ /// - sinon -> stub inerte, `isAvailable()` renvoie false, l'adapter est ignoré.
15
+ ///
16
+ /// INSTALLATION : voir docs/SDK_INTEGRATION.md (§ Star iOS).
17
+ final class StarAdapter: PrinterAdapter {
18
+
19
+ let id: AdapterId = .star
20
+
21
+ /// Connexions ouvertes indexées par printerId (StarPrinter est une classe -> AnyObject).
22
+ private var connections: [String: AnyObject] = [:]
23
+ private let lock = NSLock()
24
+
25
+ #if canImport(StarIO10)
26
+ /// Délégué de découverte retenu le temps du scan.
27
+ private var discoveryDelegate: StarDiscoveryDelegate?
28
+ private var discoveryManager: StarDeviceDiscoveryManager?
29
+ #endif
30
+
31
+ func isAvailable() -> Bool {
32
+ #if canImport(StarIO10)
33
+ return true
34
+ #else
35
+ return false
36
+ #endif
37
+ }
38
+
39
+ func canHandle(_ profile: PrinterProfile) -> Bool { isAvailable() && profile.adapter == .star }
40
+
41
+ func supportsTextItems() -> Bool { isAvailable() }
42
+
43
+ // MARK: Découverte
44
+
45
+ func discover(timeoutMs: Int, onFound: @escaping (DiscoveredPrinter) -> Void) async {
46
+ #if canImport(StarIO10)
47
+ guard let manager = try? StarDeviceDiscoveryManagerFactory.create(
48
+ interfaceTypes: [.lan, .bluetooth, .bluetoothLE, .usb]
49
+ ) else { return }
50
+ manager.discoveryTime = min(max(timeoutMs, 1000), 30000)
51
+
52
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
53
+ let delegate = StarDiscoveryDelegate(
54
+ onFound: { printer in
55
+ let settings = printer.connectionSettings
56
+ let transport = Self.transport(for: settings.interfaceType)
57
+ let model = printer.information?.model.map { String(describing: $0) }
58
+ onFound(DiscoveredPrinter(
59
+ id: "star:\(settings.interfaceType):\(settings.identifier)",
60
+ name: model ?? "Star Printer",
61
+ brand: "Star",
62
+ model: model,
63
+ transport: transport,
64
+ adapter: .star,
65
+ address: settings.identifier,
66
+ discoveredBy: ["star"]
67
+ ))
68
+ },
69
+ onFinish: { [weak self] in
70
+ self?.lock.lock(); let resumed = self?.discoveryDelegate != nil; self?.discoveryDelegate = nil; self?.lock.unlock()
71
+ if resumed { cont.resume() }
72
+ }
73
+ )
74
+ self.lock.lock(); self.discoveryDelegate = delegate; self.discoveryManager = manager; self.lock.unlock()
75
+ manager.delegate = delegate
76
+ do { try manager.startDiscovery() } catch {
77
+ self.lock.lock(); self.discoveryDelegate = nil; self.lock.unlock()
78
+ cont.resume()
79
+ }
80
+ }
81
+ manager.stopDiscovery()
82
+ #endif
83
+ }
84
+
85
+ // MARK: Connexion
86
+
87
+ func connect(_ profile: PrinterProfile, timeoutMs: Int) async throws {
88
+ #if canImport(StarIO10)
89
+ if isConnected(profile.id) { return }
90
+ let printer = StarPrinter(Self.connectionSettings(for: profile))
91
+ do {
92
+ try await printer.open()
93
+ } catch {
94
+ throw PrinterError(.CONNECTION_FAILED, "Connexion Star échouée: \(profile.address)", detail: "\(error)", retryable: true)
95
+ }
96
+ lock.lock(); connections[profile.id] = printer; lock.unlock()
97
+ #else
98
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Star (StarIO10) absent")
99
+ #endif
100
+ }
101
+
102
+ func isConnected(_ printerId: String) -> Bool {
103
+ lock.lock(); defer { lock.unlock() }
104
+ return connections[printerId] != nil
105
+ }
106
+
107
+ func disconnect(_ printerId: String) async {
108
+ #if canImport(StarIO10)
109
+ lock.lock(); let p = connections.removeValue(forKey: printerId) as? StarPrinter; lock.unlock()
110
+ if let p = p { try? await p.close() }
111
+ #endif
112
+ }
113
+
114
+ // MARK: Impression
115
+
116
+ func printImage(_ profile: PrinterProfile, image: UIImage, options: RenderOptions) async throws -> Int {
117
+ #if canImport(StarIO10)
118
+ let printer = try requireConnected(profile)
119
+ let printerBuilder = StarXpandCommand.PrinterBuilder()
120
+ .styleAlignment(Self.alignment(for: options.align))
121
+ .actionPrintImage(StarXpandCommand.Printer.ImageParameter(image: image, width: max(8, profile.capabilities.printableDots)))
122
+ if options.feedLines > 0 { _ = printerBuilder.actionFeedLine(options.feedLines) }
123
+ if options.cut && profile.capabilities.supportsCut { _ = printerBuilder.actionCut(.partial) }
124
+
125
+ let document = StarXpandCommand.DocumentBuilder().addPrinter(printerBuilder)
126
+ if options.openCashDrawer && profile.capabilities.supportsCashDrawer {
127
+ _ = document.addDrawer(StarXpandCommand.DrawerBuilder().actionOpen(StarXpandCommand.Drawer.OpenParameter()))
128
+ }
129
+ let commands = StarXpandCommand.StarXpandCommandBuilder().addDocument(document).getCommands()
130
+
131
+ var sent = 0
132
+ for _ in 0..<max(1, options.copies) {
133
+ try await send(printer, commands)
134
+ sent += commands.count
135
+ }
136
+ return sent
137
+ #else
138
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Star (StarIO10) absent")
139
+ #endif
140
+ }
141
+
142
+ func printItems(_ profile: PrinterProfile, items: [PrintItem], defaultCodePage: String, cut: Bool, feedLines: Int) async throws -> Int {
143
+ #if canImport(StarIO10)
144
+ let printer = try requireConnected(profile)
145
+ let pb = StarXpandCommand.PrinterBuilder()
146
+ for item in items { Self.map(item, into: pb, profile: profile) }
147
+ if feedLines > 0 { _ = pb.actionFeedLine(feedLines) }
148
+ if cut && profile.capabilities.supportsCut { _ = pb.actionCut(.partial) }
149
+ let commands = StarXpandCommand.StarXpandCommandBuilder()
150
+ .addDocument(StarXpandCommand.DocumentBuilder().addPrinter(pb))
151
+ .getCommands()
152
+ try await send(printer, commands)
153
+ return commands.count
154
+ #else
155
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Star (StarIO10) absent")
156
+ #endif
157
+ }
158
+
159
+ // MARK: Statut
160
+
161
+ func getStatus(_ profile: PrinterProfile) async throws -> PrinterStatus {
162
+ #if canImport(StarIO10)
163
+ lock.lock(); let p = connections[profile.id] as? StarPrinter; lock.unlock()
164
+ guard let printer = p else {
165
+ return PrinterStatus(id: profile.id, connection: "disconnected", online: false, paper: "unknown")
166
+ }
167
+ do {
168
+ let st = try await printer.getStatus()
169
+ let paperEmpty = st.paperEmpty
170
+ let coverOpen = st.coverOpen
171
+ return PrinterStatus(
172
+ id: profile.id, connection: "connected", online: !paperEmpty && !coverOpen,
173
+ paper: paperEmpty ? "empty" : (st.paperNearEmpty ? "near_end" : "ok"),
174
+ coverOpen: coverOpen,
175
+ errorCode: paperEmpty ? .PAPER_EMPTY : (coverOpen ? .COVER_OPEN : nil),
176
+ rawStatus: "\(st)"
177
+ )
178
+ } catch {
179
+ return PrinterStatus(id: profile.id, connection: "error", online: false, paper: "unknown", rawStatus: "\(error)")
180
+ }
181
+ #else
182
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Star (StarIO10) absent")
183
+ #endif
184
+ }
185
+
186
+ // MARK: Helpers (typés, sous canImport)
187
+
188
+ #if canImport(StarIO10)
189
+ private func requireConnected(_ profile: PrinterProfile) throws -> StarPrinter {
190
+ lock.lock(); let p = connections[profile.id] as? StarPrinter; lock.unlock()
191
+ guard let printer = p else { throw PrinterError(.CONNECTION_FAILED, "Star non connecté: \(profile.id)") }
192
+ return printer
193
+ }
194
+
195
+ private func send(_ printer: StarPrinter, _ commands: String) async throws {
196
+ do { try await printer.print(command: commands) }
197
+ catch { throw PrinterError(.PRINT_FAILED, "Impression Star échouée", detail: "\(error)", retryable: true) }
198
+ }
199
+
200
+ private static func connectionSettings(for profile: PrinterProfile) -> StarConnectionSettings {
201
+ let iface: InterfaceType
202
+ switch profile.transport {
203
+ case .wifi, .ethernet: iface = .lan
204
+ case .bluetooth: iface = .bluetooth
205
+ case .ble: iface = .bluetoothLE
206
+ case .usb: iface = .usb
207
+ }
208
+ let identifier = iface == .lan ? String(profile.address.split(separator: ":").first ?? "") : profile.address
209
+ return StarConnectionSettings(interfaceType: iface, identifier: identifier)
210
+ }
211
+
212
+ private static func transport(for iface: InterfaceType) -> Transport {
213
+ switch iface {
214
+ case .lan: return .wifi
215
+ case .bluetooth: return .bluetooth
216
+ case .bluetoothLE: return .ble
217
+ case .usb: return .usb
218
+ @unknown default: return .wifi
219
+ }
220
+ }
221
+
222
+ private static func alignment(for align: String) -> StarXpandCommand.Printer.Alignment {
223
+ switch align {
224
+ case "center": return .center
225
+ case "right": return .right
226
+ default: return .left
227
+ }
228
+ }
229
+
230
+ /// Mappe un PrintItem vers le PrinterBuilder StarXpand (best effort par type).
231
+ private static func map(_ item: PrintItem, into pb: StarXpandCommand.PrinterBuilder, profile: PrinterProfile) {
232
+ switch item {
233
+ case let .text(value, style):
234
+ if let a = style.align { _ = pb.styleAlignment(alignment(for: a)) }
235
+ _ = pb.styleBold(style.bold)
236
+ _ = pb.styleInvert(style.invert)
237
+ _ = pb.styleUnderLine(style.underline != "none")
238
+ _ = pb.styleMagnification(StarXpandCommand.MagnificationParameter(
239
+ width: min(max(style.widthMultiplier, 1), 6), height: min(max(style.heightMultiplier, 1), 6)))
240
+ _ = pb.actionPrintText(style.newline ? value + "\n" : value)
241
+ _ = pb.styleBold(false).styleInvert(false).styleUnderLine(false)
242
+ .styleMagnification(StarXpandCommand.MagnificationParameter(width: 1, height: 1))
243
+ case let .feed(lines):
244
+ _ = pb.actionFeedLine(max(1, lines))
245
+ case let .cut(mode, _):
246
+ _ = pb.actionCut(mode == "full" ? .full : .partial)
247
+ case let .divider(char, columns, align, bold):
248
+ let cols = columns ?? (profile.capabilities.printableDots <= 420 ? 32 : 48)
249
+ if let a = align { _ = pb.styleAlignment(alignment(for: a)) }
250
+ _ = pb.styleBold(bold)
251
+ _ = pb.actionPrintText(String(repeating: char, count: min(max(cols, 1), 96)) + "\n")
252
+ _ = pb.styleBold(false)
253
+ case let .qrcode(value, size, ec, align):
254
+ _ = pb.styleAlignment(alignment(for: align))
255
+ _ = pb.actionPrintQRCode(StarXpandCommand.Printer.QRCodeParameter(content: value)
256
+ .setLevel(qrLevel(for: ec))
257
+ .setCellSize(min(max(size, 1), 16)))
258
+ case let .barcode(value, symbology, height, _, hri, align):
259
+ _ = pb.styleAlignment(alignment(for: align))
260
+ _ = pb.actionPrintBarcode(StarXpandCommand.Printer.BarcodeParameter(content: value, symbology: barcodeSymbology(for: symbology))
261
+ .setHeight(Double(min(max(height, 1), 255)))
262
+ .setPrintHri(hri != "none"))
263
+ case .cashDrawer, .image, .raw:
264
+ // tiroir -> openCashDrawer ; image -> printImage ; raw -> non supporté par StarXpand.
265
+ break
266
+ }
267
+ }
268
+
269
+ private static func qrLevel(for ec: String) -> StarXpandCommand.Printer.QRCodeLevel {
270
+ switch ec.uppercased() {
271
+ case "L": return .l
272
+ case "Q": return .q
273
+ case "H": return .h
274
+ default: return .m
275
+ }
276
+ }
277
+
278
+ private static func barcodeSymbology(for s: String) -> StarXpandCommand.Printer.BarcodeSymbology {
279
+ switch s.uppercased() {
280
+ case "CODE39": return .code39
281
+ case "CODE93": return .code93
282
+ case "EAN13", "JAN13": return .jan13
283
+ case "EAN8", "JAN8": return .jan8
284
+ case "ITF": return .itf
285
+ case "UPCA": return .upcA
286
+ case "UPCE": return .upcE
287
+ case "NW7", "CODABAR": return .nw7
288
+ default: return .code128
289
+ }
290
+ }
291
+ #endif
292
+ }
293
+
294
+ #if canImport(StarIO10)
295
+ /// Délégué de découverte StarXpand (retenu par l'adapter le temps du scan).
296
+ private final class StarDiscoveryDelegate: NSObject, StarDeviceDiscoveryManagerDelegate {
297
+ private let onFound: (StarPrinter) -> Void
298
+ private let onFinish: () -> Void
299
+ init(onFound: @escaping (StarPrinter) -> Void, onFinish: @escaping () -> Void) {
300
+ self.onFound = onFound; self.onFinish = onFinish
301
+ }
302
+ func manager(_ manager: StarDeviceDiscoveryManager, didFind printer: StarPrinter) { onFound(printer) }
303
+ func managerDidFinishDiscovery(_ manager: StarDeviceDiscoveryManager) { onFinish() }
304
+ }
305
+ #endif
@@ -0,0 +1,119 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ // Le SDK Link-OS iOS est livré en xcframework manuel (licence Zebra, non
5
+ // redistribuable, pas de pod/SPM officiel). Nom de module possible `ZSDK_API`.
6
+ // Import absent -> stub inerte. Voir docs/SDK_INTEGRATION.md (§ Zebra iOS).
7
+ #if canImport(ZSDK_API)
8
+ import ZSDK_API
9
+ #endif
10
+
11
+ /// Adapter Zebra iOS (Link-OS SDK / ZSDK).
12
+ ///
13
+ /// ⚠️ Comme sur Android : Zebra = ZPL/CPCL, JAMAIS ESC/POS. Le SDK convertit un
14
+ /// CGImage en ZPL et l'imprime via GraphicsUtil. Transports : TCP + Bluetooth MFi.
15
+ /// API ObjC à VÉRIFIER sur device avec le xcframework réel.
16
+ final class ZebraAdapter: PrinterAdapter {
17
+
18
+ let id: AdapterId = .zebra
19
+ private var connections: [String: AnyObject] = [:]
20
+ private let lock = NSLock()
21
+
22
+ func isAvailable() -> Bool {
23
+ #if canImport(ZSDK_API)
24
+ return true
25
+ #else
26
+ return NSClassFromString("TcpPrinterConnection") != nil || NSClassFromString("ZebraPrinterFactory") != nil
27
+ #endif
28
+ }
29
+
30
+ func canHandle(_ profile: PrinterProfile) -> Bool { isAvailable() && profile.adapter == .zebra }
31
+
32
+ func discover(timeoutMs: Int, onFound: @escaping (DiscoveredPrinter) -> Void) async {
33
+ // NetworkDiscoverer.localBroadcast(...) disponible mais bloquant/ObjC ;
34
+ // les Zebra réseau restent trouvées par le scan TCP générique.
35
+ }
36
+
37
+ func connect(_ profile: PrinterProfile, timeoutMs: Int) async throws {
38
+ #if canImport(ZSDK_API)
39
+ if isConnected(profile.id) { return }
40
+ let (host, port) = Self.hostPort(profile.address)
41
+ guard let conn = TcpPrinterConnection(address: host, andWithPort: port) else {
42
+ throw PrinterError(.CONNECTION_FAILED, "Init connexion Zebra échouée")
43
+ }
44
+ conn.setMaxTimeoutForRead(Int32(timeoutMs))
45
+ if !conn.open() {
46
+ throw PrinterError(.CONNECTION_FAILED, "Connexion Zebra échouée: \(profile.address)", retryable: true)
47
+ }
48
+ lock.lock(); connections[profile.id] = conn; lock.unlock()
49
+ #else
50
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Zebra Link-OS absent")
51
+ #endif
52
+ }
53
+
54
+ func isConnected(_ printerId: String) -> Bool {
55
+ lock.lock(); defer { lock.unlock() }
56
+ return connections[printerId] != nil
57
+ }
58
+
59
+ func disconnect(_ printerId: String) async {
60
+ #if canImport(ZSDK_API)
61
+ lock.lock(); let c = connections.removeValue(forKey: printerId) as? ZebraPrinterConnection; lock.unlock()
62
+ c?.close()
63
+ #endif
64
+ }
65
+
66
+ func printImage(_ profile: PrinterProfile, image: UIImage, options: RenderOptions) async throws -> Int {
67
+ #if canImport(ZSDK_API)
68
+ lock.lock(); let c = connections[profile.id] as? ZebraPrinterConnection; lock.unlock()
69
+ guard let conn = c else { throw PrinterError(.CONNECTION_FAILED, "Zebra non connecté: \(profile.id)") }
70
+ guard let cg = image.cgImage else { throw PrinterError(.IMAGE_INVALID, "CGImage indisponible") }
71
+ do {
72
+ let printer = try ZebraPrinterFactory.getInstance(conn)
73
+ let tools = printer.getGraphicsUtil()
74
+ for _ in 0..<max(1, options.copies) {
75
+ try tools?.print(cg, atX: 0, atY: 0, withWidth: cg.width, withHeight: cg.height, andIsInsideFormat: false)
76
+ }
77
+ } catch {
78
+ throw PrinterError(.PRINT_FAILED, "Impression Zebra échouée", detail: "\(error)", retryable: true)
79
+ }
80
+ return cg.width * cg.height / 8
81
+ #else
82
+ throw PrinterError(.SDK_NOT_AVAILABLE, "SDK Zebra Link-OS absent")
83
+ #endif
84
+ }
85
+
86
+ func getStatus(_ profile: PrinterProfile) async throws -> PrinterStatus {
87
+ #if canImport(ZSDK_API)
88
+ lock.lock(); let c = connections[profile.id] as? ZebraPrinterConnection; lock.unlock()
89
+ guard let conn = c else {
90
+ return PrinterStatus(id: profile.id, connection: "disconnected", online: false, paper: "unknown")
91
+ }
92
+ do {
93
+ let printer = try ZebraPrinterFactory.getInstance(conn)
94
+ let st = try printer.getCurrentStatus()
95
+ let paperOut = st.isPaperOut
96
+ let headOpen = st.isHeadOpen
97
+ return PrinterStatus(
98
+ id: profile.id, connection: "connected", online: st.isReadyToPrint,
99
+ paper: paperOut ? "empty" : "ok", coverOpen: headOpen,
100
+ errorCode: paperOut ? .PAPER_EMPTY : (headOpen ? .COVER_OPEN : nil)
101
+ )
102
+ } catch {
103
+ return PrinterStatus(id: profile.id, connection: "error", online: false, paper: "unknown", rawStatus: "\(error)")
104
+ }
105
+ #else
106
+ return PrinterStatus(id: profile.id, connection: isConnected(profile.id) ? "connected" : "disconnected",
107
+ online: isConnected(profile.id), paper: "unknown")
108
+ #endif
109
+ }
110
+
111
+ // MARK: Helpers
112
+
113
+ private static func hostPort(_ address: String) -> (String, Int) {
114
+ let parts = address.split(separator: ":")
115
+ let host = parts.first.map(String.init) ?? address
116
+ let port = parts.count > 1 ? (Int(parts[1]) ?? 9100) : 9100
117
+ return (host, port)
118
+ }
119
+ }
@@ -0,0 +1,21 @@
1
+ import Foundation
2
+
3
+ /// Moteur de priorité d'adapter (miroir iOS de priority.ts / AdapterPriority.kt).
4
+ enum AdapterPriority {
5
+ static func score(_ p: DiscoveredPrinter) -> Int {
6
+ let brand = (p.brand ?? "").lowercased()
7
+ let isZebra = brand.contains("zebra") || p.adapter == .zebra
8
+ if isZebra { return p.adapter == .zebra ? 1000 : -1000 }
9
+
10
+ if p.adapter == .epson { return 900 }
11
+ if p.adapter == .star { return 890 }
12
+ if p.adapter == .brother { return 880 }
13
+
14
+ if p.adapter == .escpos {
15
+ return p.transport == .bluetooth ? 620 : 600
16
+ }
17
+ if p.transport == .ble { return 500 }
18
+ if p.adapter == .rawTcp { return 300 }
19
+ return 100
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// Découverte réseau iOS.
5
+ ///
6
+ /// Recommandé : Bonjour/mDNS via NWBrowser pour `_pdl-datastream._tcp` (port 9100)
7
+ /// et `_printer._tcp` / `_ipp._tcp`. C'est la méthode propre sur iOS (pas de scan
8
+ /// d'IP brute, qui est mal vu et lent). Les imprimantes réseau modernes publient
9
+ /// ces services.
10
+ ///
11
+ /// ⚠️ Info.plist : nécessite NSLocalNetworkUsageDescription + NSBonjourServices
12
+ /// listant les services recherchés.
13
+ final class BonjourScanner {
14
+
15
+ private var browsers: [NWBrowser] = []
16
+
17
+ func scan(timeoutMs: Int, onFound: @escaping (DiscoveredPrinter) -> Void) async {
18
+ let services = ["_pdl-datastream._tcp", "_printer._tcp", "_ipp._tcp"]
19
+ let queue = DispatchQueue(label: "thermalprinter.bonjour")
20
+
21
+ for service in services {
22
+ let params = NWParameters()
23
+ params.includePeerToPeer = false
24
+ let browser = NWBrowser(for: .bonjour(type: service, domain: nil), using: params)
25
+ browser.browseResultsChangedHandler = { results, _ in
26
+ for result in results {
27
+ if case let .service(name, _, _, _) = result.endpoint {
28
+ // La résolution IP se fait à la connexion via NWConnection(endpoint:).
29
+ let printer = DiscoveredPrinter(
30
+ id: "wifi:\(name)",
31
+ name: name,
32
+ brand: nil, model: nil,
33
+ transport: .wifi,
34
+ adapter: .escpos, // arbitré ensuite par la priorité
35
+ address: "\(name)._\(service)", // résolu via NWEndpoint à la connexion
36
+ capabilities: Capabilities(),
37
+ discoveredBy: ["escpos", "rawTcp"]
38
+ )
39
+ onFound(printer)
40
+ }
41
+ }
42
+ }
43
+ browser.start(queue: queue)
44
+ browsers.append(browser)
45
+ }
46
+
47
+ try? await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
48
+ browsers.forEach { $0.cancel() }
49
+ browsers.removeAll()
50
+ }
51
+ }
@@ -0,0 +1,86 @@
1
+ import Foundation
2
+
3
+ /// Orchestre la découverte agrégée iOS (miroir de DiscoveryManager.kt).
4
+ ///
5
+ /// Sources iOS :
6
+ /// - SDK Epson / Star / Brother / Zebra (via adapters, si liés)
7
+ /// - Bonjour/mDNS réseau (BonjourScanner)
8
+ /// - BLE (optionnel, allowlist)
9
+ ///
10
+ /// PAS de Bluetooth Classic générique sur iOS (voir README "Limites iOS").
11
+ final class DiscoveryManager {
12
+
13
+ struct Options {
14
+ let sources: Set<String>?
15
+ let timeoutMs: Int
16
+ let networkCidr: String?
17
+ let tcpPorts: [Int]
18
+ }
19
+
20
+ private let adapters: [PrinterAdapter]
21
+ init(adapters: [PrinterAdapter]) { self.adapters = adapters }
22
+
23
+ func discover(_ options: Options, emitPartial: @escaping (DiscoveredPrinter) -> Void) async -> (printers: [DiscoveredPrinter], failed: [String]) {
24
+ let buffer = SyncBuffer()
25
+ var failed: [String] = []
26
+
27
+ func enabled(_ src: String) -> Bool { options.sources == nil || options.sources!.contains(src) }
28
+
29
+ let collect: (DiscoveredPrinter) -> Void = { p in
30
+ buffer.add(p)
31
+ emitPartial(p)
32
+ }
33
+
34
+ await withTaskGroup(of: Void.self) { group in
35
+ // SDK fabricants
36
+ for adapter in adapters {
37
+ let src: String?
38
+ switch adapter.id {
39
+ case .epson: src = "epson"
40
+ case .star: src = "star"
41
+ case .brother: src = "brother"
42
+ case .zebra: src = "zebra"
43
+ default: src = nil
44
+ }
45
+ if let src = src, enabled(src), adapter.isAvailable() {
46
+ group.addTask { await adapter.discover(timeoutMs: options.timeoutMs, onFound: collect) }
47
+ }
48
+ }
49
+ // Bonjour réseau
50
+ if enabled("tcp") {
51
+ group.addTask { await BonjourScanner().scan(timeoutMs: options.timeoutMs, onFound: collect) }
52
+ }
53
+ // BLE (optionnel)
54
+ // if enabled("ble") { group.addTask { await BleScanner().scan(...) } }
55
+ }
56
+
57
+ return (merge(buffer.snapshot()), failed)
58
+ }
59
+
60
+ private func merge(_ incoming: [DiscoveredPrinter]) -> [DiscoveredPrinter] {
61
+ var byId: [String: DiscoveredPrinter] = [:]
62
+ for p in incoming {
63
+ if let existing = byId[p.id] {
64
+ var winner = AdapterPriority.score(p) > AdapterPriority.score(existing) ? p : existing
65
+ winner.discoveredBy = existing.discoveredBy.union(p.discoveredBy)
66
+ winner.lastSeenAt = max(existing.lastSeenAt, p.lastSeenAt)
67
+ winner.isConnected = existing.isConnected || p.isConnected
68
+ byId[p.id] = winner
69
+ } else {
70
+ byId[p.id] = p
71
+ }
72
+ }
73
+ return byId.values.sorted {
74
+ let sa = AdapterPriority.score($0), sb = AdapterPriority.score($1)
75
+ return sa != sb ? sa > sb : $0.name < $1.name
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Petit buffer thread-safe pour collecter les résultats concurrents.
81
+ final class SyncBuffer {
82
+ private var items: [DiscoveredPrinter] = []
83
+ private let lock = NSLock()
84
+ func add(_ p: DiscoveredPrinter) { lock.lock(); items.append(p); lock.unlock() }
85
+ func snapshot() -> [DiscoveredPrinter] { lock.lock(); defer { lock.unlock() }; return items }
86
+ }
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ /// Cache local des images à imprimer (miroir de ImageCache.kt).
5
+ /// Emplacement : caches/thermal-images/, clé = SHA-256(url), quota 32 Mo.
6
+ final class ImageCache {
7
+
8
+ private let dir: URL
9
+ private let maxBytes: Int64 = 32 * 1024 * 1024
10
+
11
+ init() {
12
+ let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
13
+ dir = caches.appendingPathComponent("thermal-images", isDirectory: true)
14
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
15
+ }
16
+
17
+ /// Télécharge l'URL (si absente) et renvoie le chemin local.
18
+ func fetch(_ urlString: String, timeoutMs: Int = 10000) async throws -> String {
19
+ guard let url = URL(string: urlString) else {
20
+ throw PrinterError(.IMAGE_INVALID, "URL invalide: \(urlString)")
21
+ }
22
+ let dest = dir.appendingPathComponent(sha256(urlString) + ".img")
23
+ if FileManager.default.fileExists(atPath: dest.path) {
24
+ Logger.shared.log("image", "cache hit", ["url": urlString])
25
+ return dest.path
26
+ }
27
+ var request = URLRequest(url: url)
28
+ request.timeoutInterval = Double(timeoutMs) / 1000.0
29
+ do {
30
+ let (data, response) = try await URLSession.shared.data(for: request)
31
+ if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
32
+ throw PrinterError(.IMAGE_INVALID, "HTTP \(http.statusCode) pour \(urlString)")
33
+ }
34
+ try data.write(to: dest)
35
+ Logger.shared.log("image", "downloaded", ["url": urlString, "bytes": data.count])
36
+ enforceQuota()
37
+ return dest.path
38
+ } catch let e as PrinterError {
39
+ throw e
40
+ } catch {
41
+ throw PrinterError(.IMAGE_INVALID, "Téléchargement image échoué", detail: error.localizedDescription, retryable: true)
42
+ }
43
+ }
44
+
45
+ private func enforceQuota() {
46
+ let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
47
+ guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: keys) else { return }
48
+ let sorted = files.sorted {
49
+ let d0 = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
50
+ let d1 = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
51
+ return d0 < d1
52
+ }
53
+ var total = sorted.reduce(Int64(0)) { acc, u in
54
+ acc + Int64((try? u.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
55
+ }
56
+ var i = 0
57
+ while total > maxBytes && i < sorted.count {
58
+ let size = Int64((try? sorted[i].resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
59
+ try? FileManager.default.removeItem(at: sorted[i])
60
+ total -= size
61
+ i += 1
62
+ }
63
+ }
64
+
65
+ func clear() {
66
+ try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
67
+ .forEach { try? FileManager.default.removeItem(at: $0) }
68
+ }
69
+
70
+ private func sha256(_ s: String) -> String {
71
+ SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined()
72
+ }
73
+ }