@nitra/zebra 8.1.0 → 8.2.1
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.
|
@@ -131,6 +131,8 @@ public class Zebra {
|
|
|
131
131
|
out.write(data, offset, len);
|
|
132
132
|
}
|
|
133
133
|
out.flush();
|
|
134
|
+
// Затримка перед закриттям: дає Bluetooth-стеку та принтеру час відправити/прийняти дані (інакше перший друк іноді не виконується, лише другий).
|
|
135
|
+
Thread.sleep(400);
|
|
134
136
|
|
|
135
137
|
Logger.info("Zebra", "ZPL успішно відправлено: " + data.length + " байт");
|
|
136
138
|
callback.onSuccess(addrTrimmed, data.length);
|
|
@@ -23,9 +23,6 @@ import ExternalAccessory
|
|
|
23
23
|
/// Невелика затримка між черговими завданнями, щоб дати прошивці час стабілізуватися
|
|
24
24
|
private let cooldownInterval: TimeInterval = 1.0
|
|
25
25
|
|
|
26
|
-
/// Перемикач debug-логування
|
|
27
|
-
private static let debugLogging = true
|
|
28
|
-
|
|
29
26
|
/// Один раз реєструємося для нотифікацій EA — потрібно для коректного доступу до аксесуарів при холодному запуску (без Xcode).
|
|
30
27
|
private static var didRegisterForAccessoryNotifications = false
|
|
31
28
|
private static let registerLock = NSLock()
|
|
@@ -35,13 +32,7 @@ import ExternalAccessory
|
|
|
35
32
|
guard !Zebra.didRegisterForAccessoryNotifications else { return }
|
|
36
33
|
EAAccessoryManager.shared().registerForLocalNotifications()
|
|
37
34
|
Zebra.didRegisterForAccessoryNotifications = true
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/// Функція логування з префіксом [Zebra]
|
|
42
|
-
private func log(_ items: Any...) {
|
|
43
|
-
guard Zebra.debugLogging else { return }
|
|
44
|
-
print("[Zebra]", items.map { String(describing: $0) }.joined(separator: " "))
|
|
35
|
+
logDebug("EAAccessoryManager.registerForLocalNotifications() called")
|
|
45
36
|
}
|
|
46
37
|
|
|
47
38
|
/// Представляє інформацію про підключений аксесуар, яку використовує плагін
|
|
@@ -95,7 +86,8 @@ import ExternalAccessory
|
|
|
95
86
|
}
|
|
96
87
|
}
|
|
97
88
|
|
|
98
|
-
/// Відповідає за запис байтів у OutputStream з таймаутом та звітує через completion
|
|
89
|
+
/// Відповідає за запис байтів у OutputStream з таймаутом та звітує через completion.
|
|
90
|
+
/// Закриття потоків і сесії гарантовано виконується один раз (ідемпотентний close), у т.ч. по таймеру при відсутності подій.
|
|
99
91
|
private class StreamWriter: NSObject, StreamDelegate {
|
|
100
92
|
private let output: OutputStream
|
|
101
93
|
private let data: [UInt8]
|
|
@@ -103,12 +95,13 @@ import ExternalAccessory
|
|
|
103
95
|
private let timeout: TimeInterval
|
|
104
96
|
private var totalWritten = 0
|
|
105
97
|
private var completion: ((Result<PrintResult, ZebraError>) -> Void)?
|
|
106
|
-
private weak var owner: Zebra?
|
|
107
98
|
private var startTime = Date()
|
|
108
99
|
private let session: EASession
|
|
100
|
+
private var didClose = false
|
|
101
|
+
private let closeLock = NSLock()
|
|
102
|
+
private var timeoutTimer: Timer?
|
|
109
103
|
|
|
110
|
-
init(owner: Zebra, session: EASession, output: OutputStream, data: [UInt8], address: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<PrintResult, ZebraError>) -> Void) {
|
|
111
|
-
self.owner = owner
|
|
104
|
+
init(owner _: Zebra, session: EASession, output: OutputStream, data: [UInt8], address: String, timeout: TimeInterval = 5.0, completion: @escaping (Result<PrintResult, ZebraError>) -> Void) {
|
|
112
105
|
self.session = session
|
|
113
106
|
self.output = output
|
|
114
107
|
self.data = data
|
|
@@ -119,6 +112,7 @@ import ExternalAccessory
|
|
|
119
112
|
}
|
|
120
113
|
|
|
121
114
|
/// Запускає стріми — обидва (input і output) відкриваються й додаються в RunLoop, як у документації Apple; інакше сесія може не звільнятися коректно.
|
|
115
|
+
/// Таймер гарантує закриття при відсутності подій потоку (наприклад, зависання принтера).
|
|
122
116
|
func start() {
|
|
123
117
|
let runLoop = RunLoop.current
|
|
124
118
|
let mode: RunLoop.Mode = .default
|
|
@@ -130,24 +124,39 @@ import ExternalAccessory
|
|
|
130
124
|
output.delegate = self
|
|
131
125
|
output.schedule(in: runLoop, forMode: mode)
|
|
132
126
|
output.open()
|
|
133
|
-
|
|
127
|
+
logDebug("StreamWriter started, bytes= \(self.data.count)")
|
|
134
128
|
startTime = Date()
|
|
129
|
+
timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
|
|
130
|
+
logDebug("StreamWriter timeout (no events)")
|
|
131
|
+
self?.close(with: .failure(.writeFailed("Таймаут запису")))
|
|
132
|
+
}
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
/// Після запису всіх байтів дає час на флаш буфера (iOS може не встигнути відправити на пристрій при миттєвому
|
|
135
|
+
/// Після запису всіх байтів дає час на флаш буфера (iOS може не встигнути відправити на пристрій при миттєвому закритті; при першому друці принтер ще "прокидається" — потрібно більше часу).
|
|
138
136
|
private func flushThenClose() {
|
|
139
|
-
let flushDuration: TimeInterval =
|
|
137
|
+
let flushDuration: TimeInterval = 1.5
|
|
140
138
|
let deadline = Date().addingTimeInterval(flushDuration)
|
|
141
139
|
while Date() < deadline {
|
|
142
140
|
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.1))
|
|
143
141
|
}
|
|
144
|
-
|
|
142
|
+
logDebug("Flush done, closing stream")
|
|
145
143
|
close(with: .success(PrintResult(address: address)))
|
|
146
144
|
}
|
|
147
145
|
|
|
148
|
-
/// Закриває стріми та викликає completion один
|
|
146
|
+
/// Закриває стріми та викликає completion один раз (ідемпотентно).
|
|
149
147
|
/// Порядок важливий для коректного звільнення EASession у iOS — інакше повторне відкриття сесії до того ж аксесуара може повертати nil.
|
|
150
148
|
func close(with result: Result<PrintResult, ZebraError>) {
|
|
149
|
+
closeLock.lock()
|
|
150
|
+
guard !didClose else {
|
|
151
|
+
closeLock.unlock()
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
didClose = true
|
|
155
|
+
closeLock.unlock()
|
|
156
|
+
|
|
157
|
+
timeoutTimer?.invalidate()
|
|
158
|
+
timeoutTimer = nil
|
|
159
|
+
|
|
151
160
|
let runLoop = RunLoop.current
|
|
152
161
|
let mode: RunLoop.Mode = .default
|
|
153
162
|
// Даємо RunLoop обробити останні події перед закриттям
|
|
@@ -172,26 +181,26 @@ import ExternalAccessory
|
|
|
172
181
|
completion(result)
|
|
173
182
|
}
|
|
174
183
|
completion = nil
|
|
175
|
-
|
|
184
|
+
logDebug("StreamWriter finished with \(String(describing: result))")
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
|
|
179
188
|
// Події input-потоку ігноруємо (потрібен лише для коректного життєвого циклу сесії)
|
|
180
189
|
guard aStream === output else { return }
|
|
181
190
|
if Date().timeIntervalSince(startTime) > timeout {
|
|
182
|
-
|
|
191
|
+
logDebug("StreamWriter timeout")
|
|
183
192
|
close(with: .failure(.writeFailed("Таймаут запису")))
|
|
184
193
|
return
|
|
185
194
|
}
|
|
186
195
|
switch eventCode {
|
|
187
196
|
case .openCompleted:
|
|
188
197
|
// Потік відкрито
|
|
189
|
-
|
|
198
|
+
logDebug("Stream open completed")
|
|
190
199
|
case .hasSpaceAvailable:
|
|
191
200
|
// Є простір для запису, пишемо дані порціями
|
|
192
201
|
if totalWritten >= data.count {
|
|
193
202
|
// Усі байти записані — даємо час на флаш буфера перед закриттям (iOS часто не встигає відправити на пристрій)
|
|
194
|
-
|
|
203
|
+
logDebug("All bytes written, flushing before close...")
|
|
195
204
|
flushThenClose()
|
|
196
205
|
return
|
|
197
206
|
}
|
|
@@ -200,7 +209,7 @@ import ExternalAccessory
|
|
|
200
209
|
let wrote = output.write(Array(data[totalWritten..<(totalWritten + chunkSize)]), maxLength: chunkSize)
|
|
201
210
|
if wrote <= 0 {
|
|
202
211
|
let reason = output.streamError?.localizedDescription ?? "Невідома помилка потоку"
|
|
203
|
-
|
|
212
|
+
logDebug("StreamWriter write failed: \(reason)")
|
|
204
213
|
close(with: .failure(.writeFailed(reason)))
|
|
205
214
|
return
|
|
206
215
|
}
|
|
@@ -208,11 +217,11 @@ import ExternalAccessory
|
|
|
208
217
|
case .errorOccurred:
|
|
209
218
|
// Сталася помилка потоку
|
|
210
219
|
let reason = output.streamError?.localizedDescription ?? "Невідома помилка потоку"
|
|
211
|
-
|
|
220
|
+
logDebug("Stream error: \(reason)")
|
|
212
221
|
close(with: .failure(.writeFailed(reason)))
|
|
213
222
|
case .endEncountered:
|
|
214
223
|
// Потік закінчився, перевіряємо чи всі дані записані
|
|
215
|
-
|
|
224
|
+
logDebug("Stream end encountered, bytes= \(self.totalWritten)")
|
|
216
225
|
if totalWritten >= data.count {
|
|
217
226
|
close(with: .success(PrintResult(address: address)))
|
|
218
227
|
} else {
|
|
@@ -239,7 +248,18 @@ import ExternalAccessory
|
|
|
239
248
|
/// Повертає список підключених EA аксесуарів у вигляді DeviceInfo, вибираючи серійний номер або ім'я як адресу
|
|
240
249
|
public func getConnectedAccessories() -> [DeviceInfo] {
|
|
241
250
|
ensureRegisteredForAccessoryNotifications()
|
|
242
|
-
|
|
251
|
+
// Якщо аксесуарів немає, чекаємо 0.6 с і повторюємо спробу до 3 разів (щоб дати системі час підхопити нове підключення)
|
|
252
|
+
let maxAttempts = 3
|
|
253
|
+
let retryDelay: TimeInterval = 0.6
|
|
254
|
+
var accessories = EAAccessoryManager.shared().connectedAccessories
|
|
255
|
+
for attempt in 1...maxAttempts {
|
|
256
|
+
if !accessories.isEmpty { break }
|
|
257
|
+
if attempt < maxAttempts {
|
|
258
|
+
logDebug("connectedAccessories attempt: \(attempt)")
|
|
259
|
+
Thread.sleep(forTimeInterval: retryDelay)
|
|
260
|
+
accessories = EAAccessoryManager.shared().connectedAccessories
|
|
261
|
+
}
|
|
262
|
+
}
|
|
243
263
|
return accessories.compactMap { accessory in
|
|
244
264
|
let address = accessory.serialNumber.isEmpty ? accessory.name : accessory.serialNumber
|
|
245
265
|
if address.isEmpty {
|
|
@@ -252,14 +272,14 @@ import ExternalAccessory
|
|
|
252
272
|
/// Виводить діагностичну інформацію щодо підключених аксесуарів та протоколів у Info.plist
|
|
253
273
|
public func diagnoseEnvironment() {
|
|
254
274
|
let accessories = EAAccessoryManager.shared().connectedAccessories
|
|
255
|
-
|
|
275
|
+
logDebug("Connected accessories count: \(accessories.count)")
|
|
256
276
|
for a in accessories {
|
|
257
|
-
|
|
277
|
+
logDebug("Accessory: name=\(a.name) serial=\(a.serialNumber) protocols=\(a.protocolStrings)")
|
|
258
278
|
}
|
|
259
279
|
if let supported = Bundle.main.object(forInfoDictionaryKey: "UISupportedExternalAccessoryProtocols") as? [String] {
|
|
260
|
-
|
|
280
|
+
logDebug("Info.plist UISupportedExternalAccessoryProtocols: \(supported)")
|
|
261
281
|
} else {
|
|
262
|
-
|
|
282
|
+
logDebug("Info.plist UISupportedExternalAccessoryProtocols: MISSING or not an array")
|
|
263
283
|
}
|
|
264
284
|
}
|
|
265
285
|
|
|
@@ -270,10 +290,10 @@ import ExternalAccessory
|
|
|
270
290
|
for proto in protocolsToTry {
|
|
271
291
|
for attempt in 1...retries {
|
|
272
292
|
if let session = EASession(accessory: accessory, forProtocol: proto) {
|
|
273
|
-
|
|
293
|
+
logDebug("EASession open success on attempt \(attempt) protocol: \(proto)")
|
|
274
294
|
return (session, proto)
|
|
275
295
|
} else {
|
|
276
|
-
|
|
296
|
+
logDebug("EASession open failed attempt \(attempt) protocol: \(proto)")
|
|
277
297
|
Thread.sleep(forTimeInterval: delay)
|
|
278
298
|
}
|
|
279
299
|
}
|
|
@@ -299,18 +319,25 @@ import ExternalAccessory
|
|
|
299
319
|
payload += "\r\n"
|
|
300
320
|
}
|
|
301
321
|
let zplBytes = Array(payload.utf8)
|
|
302
|
-
|
|
322
|
+
logDebug("printZpl called with address=\(trimmedAddress) zplLength=\(zplBytes.count)")
|
|
303
323
|
|
|
304
324
|
DispatchQueue.main.async { [weak self] in
|
|
305
325
|
guard let self = self else { return }
|
|
306
326
|
self.ensureRegisteredForAccessoryNotifications()
|
|
307
327
|
let accessories = EAAccessoryManager.shared().connectedAccessories
|
|
308
|
-
|
|
328
|
+
logDebug("EA connectedAccessories count= \(accessories.count)")
|
|
309
329
|
|
|
310
330
|
// Шукаємо аксесуар, що відповідає адресі (serialNumber або name)
|
|
311
331
|
let availableList = self.getConnectedAccessories()
|
|
332
|
+
|
|
333
|
+
if availableList.isEmpty {
|
|
334
|
+
logDebug("No accessories found")
|
|
335
|
+
completion(.failure(.accessoryNotFound(requestedAddress: trimmedAddress, availableAccessories: availableList)))
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
312
339
|
guard let accessory = accessories.first(where: { self.accessoryMatches($0, address: trimmedAddress) }) else {
|
|
313
|
-
|
|
340
|
+
logDebug("No accessory matched address: \(trimmedAddress)")
|
|
314
341
|
// У failure передаємо не лише адресу, а й список доступних аксесуарів (назви та адреси) для показу в помилці
|
|
315
342
|
completion(.failure(.accessoryNotFound(requestedAddress: trimmedAddress, availableAccessories: availableList)))
|
|
316
343
|
return
|
|
@@ -318,30 +345,30 @@ import ExternalAccessory
|
|
|
318
345
|
|
|
319
346
|
// Обираємо протокол для з’єднання
|
|
320
347
|
let protocolString = self.preferredProtocol(for: accessory)
|
|
321
|
-
|
|
322
|
-
|
|
348
|
+
logDebug("Chosen accessory: \(accessory.name) serial=\(accessory.serialNumber)")
|
|
349
|
+
logDebug("Selected protocol: \(protocolString)")
|
|
323
350
|
if let supported = Bundle.main.object(forInfoDictionaryKey: "UISupportedExternalAccessoryProtocols") as? [String] {
|
|
324
351
|
if !supported.contains(protocolString) {
|
|
325
|
-
|
|
352
|
+
logDebug("WARNING: Selected protocol not present in Info.plist UISupportedExternalAccessoryProtocols")
|
|
326
353
|
}
|
|
327
354
|
} else {
|
|
328
|
-
|
|
355
|
+
logDebug("WARNING: UISupportedExternalAccessoryProtocols missing in Info.plist")
|
|
329
356
|
}
|
|
330
357
|
|
|
331
|
-
|
|
358
|
+
logDebug("Attempting to open EASession with retries and fallback protocols...")
|
|
332
359
|
Thread.sleep(forTimeInterval: 0.1)
|
|
333
360
|
let fallback = accessory.protocolStrings
|
|
334
361
|
// Відкриваємо сесію, пробуємо ретраї і fallback протоколи
|
|
335
362
|
guard let result = self.openSessionWithRetries(accessory: accessory, preferredProtocol: protocolString, fallbackProtocols: fallback) else {
|
|
336
|
-
|
|
363
|
+
logDebug("EASession failed after retries and fallback")
|
|
337
364
|
completion(.failure(.sessionFailed("EASession повернув nil")))
|
|
338
365
|
return
|
|
339
366
|
}
|
|
340
367
|
let session = result.session
|
|
341
368
|
let protocolUsed = result.protocolUsed
|
|
342
|
-
|
|
369
|
+
logDebug("Using protocol for session: \(protocolUsed)")
|
|
343
370
|
guard let outputStream = session.outputStream else {
|
|
344
|
-
|
|
371
|
+
logDebug("Session opened but outputStream is nil")
|
|
345
372
|
completion(.failure(.sessionFailed("Відсутній outputStream")))
|
|
346
373
|
return
|
|
347
374
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
/// Спільна функція дебаг-логування для модуля Zebra.
|
|
4
|
+
/// У Release не виконується й не обчислює повідомлення (zero cost).
|
|
5
|
+
#if DEBUG
|
|
6
|
+
func logDebug(_ message: @autoclosure @escaping () -> String) {
|
|
7
|
+
Logger(subsystem: "com.nitra.zebra", category: "Zebra").debug("\(message(), privacy: .public)")
|
|
8
|
+
}
|
|
9
|
+
#else
|
|
10
|
+
func logDebug(_ message: @autoclosure @escaping () -> String) {}
|
|
11
|
+
#endif
|
|
@@ -36,9 +36,8 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
36
36
|
return
|
|
37
37
|
}
|
|
38
38
|
Task {
|
|
39
|
-
// Зберігаємо адресу в UserDefaults
|
|
40
39
|
await implementation.setPrinterAddress(address)
|
|
41
|
-
|
|
40
|
+
logDebug("setPrinterAddress: \(address)")
|
|
42
41
|
await MainActor.run {
|
|
43
42
|
call.resolve(["address": address])
|
|
44
43
|
}
|
|
@@ -49,13 +48,10 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
49
48
|
/// - Parameter call: Виклик без параметрів
|
|
50
49
|
@objc func getPairedDevices(_ call: CAPPluginCall) {
|
|
51
50
|
Task {
|
|
52
|
-
// Отримуємо список підключених аксесуарів
|
|
53
51
|
let devices = await implementation.getConnectedAccessories()
|
|
54
|
-
|
|
55
|
-
// Перетворюємо масив DeviceInfo в масив словників
|
|
52
|
+
logDebug("getPairedDevices: \(devices.count) device(s)")
|
|
56
53
|
let list = devices.map { ["address": $0.address, "name": $0.name] }
|
|
57
54
|
await MainActor.run {
|
|
58
|
-
// Повертаємо результат
|
|
59
55
|
call.resolve(["devices": list])
|
|
60
56
|
}
|
|
61
57
|
}
|
|
@@ -71,13 +67,15 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
71
67
|
return
|
|
72
68
|
}
|
|
73
69
|
Task {
|
|
70
|
+
logDebug("print address=\(address) zplLength=\(zpl.utf8.count)")
|
|
74
71
|
do {
|
|
75
72
|
_ = try await implementation.printZpl(address: address, zpl: zpl)
|
|
73
|
+
logDebug("print success")
|
|
76
74
|
await MainActor.run {
|
|
77
|
-
// Успішний друк - повертаємо лише success
|
|
78
75
|
call.resolve(["success": true])
|
|
79
76
|
}
|
|
80
77
|
} catch let error as Zebra.ZebraError {
|
|
78
|
+
logDebug("print failed: \(error.localizedDescription)")
|
|
81
79
|
// Помилка друку - визначаємо код помилки
|
|
82
80
|
await MainActor.run {
|
|
83
81
|
let code: String
|
|
@@ -110,8 +108,8 @@ public class ZebraPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
110
108
|
call.reject(error.localizedDescription, code, error)
|
|
111
109
|
}
|
|
112
110
|
} catch {
|
|
111
|
+
logDebug("print failed (unknown): \(error.localizedDescription)")
|
|
113
112
|
await MainActor.run {
|
|
114
|
-
// Повертаємо помилку з UNKNOWN
|
|
115
113
|
call.reject(error.localizedDescription, "UNKNOWN", error)
|
|
116
114
|
}
|
|
117
115
|
}
|