@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,207 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.adapters
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import com.delicity.thermalprinter.model.AdapterId
|
|
6
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
7
|
+
import com.delicity.thermalprinter.model.ErrorCode
|
|
8
|
+
import com.delicity.thermalprinter.model.PrinterException
|
|
9
|
+
import com.delicity.thermalprinter.model.PrinterProfile
|
|
10
|
+
import com.delicity.thermalprinter.model.PrinterStatus
|
|
11
|
+
import com.delicity.thermalprinter.model.RenderOptions
|
|
12
|
+
import com.delicity.thermalprinter.model.Transport
|
|
13
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Adapter Zebra basé sur le SDK Link-OS (`com.zebra.sdk`), piloté par RÉFLEXION.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ Zebra N'EST PAS de l'ESC/POS : le langage est ZPL/CPCL. Le SDK convertit le
|
|
19
|
+
* Bitmap en ZPL et l'imprime via GraphicsUtil.printImage(...). On ne route JAMAIS
|
|
20
|
+
* une Zebra vers EscPosAdapter (priority.ts attribue un score négatif).
|
|
21
|
+
*
|
|
22
|
+
* Le SDK Link-OS n'est pas redistribuable (licence Zebra) : déposer
|
|
23
|
+
* `ZSDK_ANDROID_API.jar` (portail Zebra) — ou activer le dépôt Maven privé Zebra
|
|
24
|
+
* (credentials). Voir docs/SDK_INTEGRATION.md (§ Zebra).
|
|
25
|
+
*/
|
|
26
|
+
class ZebraAdapter(private val context: Context) : PrinterAdapter {
|
|
27
|
+
|
|
28
|
+
override val id = AdapterId.ZEBRA
|
|
29
|
+
|
|
30
|
+
private val cache = ConcurrentHashMap<String, Any>() // printerId -> com.zebra.sdk.comm.Connection
|
|
31
|
+
|
|
32
|
+
override fun isAvailable(): Boolean = EpsonAdapter.classExists(CONNECTION)
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// Découverte (NetworkDiscoverer / BluetoothDiscoverer + DiscoveryHandler proxy)
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
override suspend fun discover(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
|
|
39
|
+
if (!isAvailable()) return
|
|
40
|
+
val handler = SdkReflect.proxy(DISCOVERY_HANDLER, mapOf(
|
|
41
|
+
"foundPrinter" to { args ->
|
|
42
|
+
val dp = args.getOrNull(0)
|
|
43
|
+
if (dp != null) {
|
|
44
|
+
val address = SdkReflect.call(dp, "getAddress") as? String
|
|
45
|
+
?: SdkReflect.field(dp, "address") as? String ?: ""
|
|
46
|
+
if (address.isNotEmpty()) {
|
|
47
|
+
onFound(
|
|
48
|
+
DiscoveredPrinter(
|
|
49
|
+
id = "zebra:$address",
|
|
50
|
+
name = "Zebra $address",
|
|
51
|
+
brand = "Zebra",
|
|
52
|
+
transport = if (address.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) Transport.BLUETOOTH else Transport.WIFI,
|
|
53
|
+
adapter = AdapterId.ZEBRA,
|
|
54
|
+
address = address,
|
|
55
|
+
discoveredBy = mutableSetOf(AdapterId.ZEBRA),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
null
|
|
61
|
+
},
|
|
62
|
+
"discoveryFinished" to { null },
|
|
63
|
+
"discoveryError" to { null },
|
|
64
|
+
))
|
|
65
|
+
// NetworkDiscoverer.findPrinters(DiscoveryHandler) — bloquant jusqu'à fin.
|
|
66
|
+
runCatching {
|
|
67
|
+
SdkReflect.callStatic(
|
|
68
|
+
NETWORK_DISCOVERER, "findPrinters",
|
|
69
|
+
arrayOf(SdkReflect.classOrNull(DISCOVERY_HANDLER)!!),
|
|
70
|
+
arrayOf(handler),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
// BluetoothDiscoverer.findPrinters(Context, DiscoveryHandler)
|
|
74
|
+
runCatching {
|
|
75
|
+
SdkReflect.callStatic(
|
|
76
|
+
BLUETOOTH_DISCOVERER, "findPrinters",
|
|
77
|
+
arrayOf(Context::class.java, SdkReflect.classOrNull(DISCOVERY_HANDLER)!!),
|
|
78
|
+
arrayOf(context, handler),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override fun canHandle(profile: PrinterProfile): Boolean =
|
|
84
|
+
isAvailable() && profile.adapter == AdapterId.ZEBRA
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// Connexion
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
override suspend fun connect(profile: PrinterProfile, timeoutMs: Long) {
|
|
91
|
+
ensureSdk()
|
|
92
|
+
if (isConnected(profile.id)) return
|
|
93
|
+
val connection = buildConnection(profile)
|
|
94
|
+
try {
|
|
95
|
+
SdkReflect.call(connection, "open")
|
|
96
|
+
} catch (e: Throwable) {
|
|
97
|
+
throw PrinterException(ErrorCode.CONNECTION_FAILED, "Connexion Zebra échouée: ${profile.address}", e.message, retryable = true)
|
|
98
|
+
}
|
|
99
|
+
cache[profile.id] = connection
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun isConnected(printerId: String): Boolean {
|
|
103
|
+
val c = cache[printerId] ?: return false
|
|
104
|
+
return (SdkReflect.call(c, "isConnected") as? Boolean) ?: true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override suspend fun disconnect(printerId: String) {
|
|
108
|
+
cache.remove(printerId)?.let { runCatching { SdkReflect.call(it, "close") } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
// Impression image (-> ZPL via GraphicsUtil)
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
override suspend fun printBitmap(profile: PrinterProfile, bitmap: Bitmap, options: RenderOptions): Int {
|
|
116
|
+
val connection = cache[profile.id]
|
|
117
|
+
?: throw PrinterException(ErrorCode.CONNECTION_FAILED, "Zebra non connecté: ${profile.id}")
|
|
118
|
+
try {
|
|
119
|
+
val printer = SdkReflect.callStatic(
|
|
120
|
+
PRINTER_FACTORY, "getInstance",
|
|
121
|
+
arrayOf(SdkReflect.classOrNull(CONNECTION)!!), arrayOf(connection),
|
|
122
|
+
) ?: error("ZebraPrinterFactory.getInstance null")
|
|
123
|
+
val graphics = SdkReflect.call(printer, "getGraphicsUtil") ?: error("getGraphicsUtil null")
|
|
124
|
+
val zebraImage = SdkReflect.callStatic(
|
|
125
|
+
IMAGE_FACTORY, "getImage",
|
|
126
|
+
arrayOf(Bitmap::class.java), arrayOf(bitmap),
|
|
127
|
+
) ?: error("ZebraImageFactory.getImage null")
|
|
128
|
+
val intT = Int::class.javaPrimitiveType!!
|
|
129
|
+
val boolT = Boolean::class.javaPrimitiveType!!
|
|
130
|
+
repeat(options.copies.coerceAtLeast(1)) {
|
|
131
|
+
SdkReflect.call(
|
|
132
|
+
graphics, "printImage",
|
|
133
|
+
arrayOf(SdkReflect.classOrNull(IMAGE_I)!!, intT, intT, intT, intT, boolT),
|
|
134
|
+
arrayOf(zebraImage, 0, 0, bitmap.width, bitmap.height, false),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
} catch (e: Throwable) {
|
|
138
|
+
throw PrinterException(ErrorCode.PRINT_FAILED, "Impression Zebra échouée", e.message, retryable = true)
|
|
139
|
+
}
|
|
140
|
+
return bitmap.width * bitmap.height / 8
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
// Statut
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
override suspend fun getStatus(profile: PrinterProfile): PrinterStatus {
|
|
148
|
+
val connection = cache[profile.id]
|
|
149
|
+
?: return PrinterStatus(profile.id, "disconnected", online = false, paper = "unknown")
|
|
150
|
+
return try {
|
|
151
|
+
val printer = SdkReflect.callStatic(
|
|
152
|
+
PRINTER_FACTORY, "getInstance",
|
|
153
|
+
arrayOf(SdkReflect.classOrNull(CONNECTION)!!), arrayOf(connection),
|
|
154
|
+
) ?: error("getInstance null")
|
|
155
|
+
val status = SdkReflect.call(printer, "getCurrentStatus") ?: error("getCurrentStatus null")
|
|
156
|
+
val ready = (SdkReflect.field(status, "isReadyToPrint") as? Boolean) ?: false
|
|
157
|
+
val paperOut = (SdkReflect.field(status, "isPaperOut") as? Boolean) ?: false
|
|
158
|
+
val headOpen = (SdkReflect.field(status, "isHeadOpen") as? Boolean) ?: false
|
|
159
|
+
PrinterStatus(
|
|
160
|
+
id = profile.id,
|
|
161
|
+
connection = "connected",
|
|
162
|
+
online = ready,
|
|
163
|
+
paper = if (paperOut) "empty" else "ok",
|
|
164
|
+
coverOpen = headOpen,
|
|
165
|
+
errorCode = if (paperOut) ErrorCode.PAPER_EMPTY else if (headOpen) ErrorCode.COVER_OPEN else null,
|
|
166
|
+
)
|
|
167
|
+
} catch (e: Throwable) {
|
|
168
|
+
PrinterStatus(profile.id, "error", online = false, paper = "unknown", rawStatus = e.message)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -------------------------------------------------------------------------
|
|
173
|
+
// Helpers
|
|
174
|
+
// -------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
private fun buildConnection(profile: PrinterProfile): Any = when (profile.transport) {
|
|
177
|
+
Transport.WIFI, Transport.ETHERNET -> {
|
|
178
|
+
val host = profile.address.substringBefore(":")
|
|
179
|
+
val port = profile.address.substringAfter(":", "9100").toIntOrNull() ?: 9100
|
|
180
|
+
SdkReflect.newInstance(
|
|
181
|
+
TCP_CONNECTION,
|
|
182
|
+
arrayOf(String::class.java, Int::class.javaPrimitiveType!!),
|
|
183
|
+
arrayOf(host, port),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
Transport.BLUETOOTH -> SdkReflect.newInstance(
|
|
187
|
+
BT_CONNECTION, arrayOf(String::class.java), arrayOf(profile.address),
|
|
188
|
+
)
|
|
189
|
+
else -> throw PrinterException(ErrorCode.UNSUPPORTED_TRANSPORT, "Transport Zebra non supporté: ${profile.transport.value}")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun ensureSdk() {
|
|
193
|
+
if (!isAvailable()) throw PrinterException(ErrorCode.SDK_NOT_AVAILABLE, "SDK Zebra Link-OS absent")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
companion object {
|
|
197
|
+
private const val CONNECTION = "com.zebra.sdk.comm.Connection"
|
|
198
|
+
private const val TCP_CONNECTION = "com.zebra.sdk.comm.TcpConnection"
|
|
199
|
+
private const val BT_CONNECTION = "com.zebra.sdk.comm.BluetoothConnection"
|
|
200
|
+
private const val PRINTER_FACTORY = "com.zebra.sdk.printer.ZebraPrinterFactory"
|
|
201
|
+
private const val IMAGE_FACTORY = "com.zebra.sdk.graphics.ZebraImageFactory"
|
|
202
|
+
private const val IMAGE_I = "com.zebra.sdk.graphics.ZebraImageI"
|
|
203
|
+
private const val NETWORK_DISCOVERER = "com.zebra.sdk.printer.discovery.NetworkDiscoverer"
|
|
204
|
+
private const val BLUETOOTH_DISCOVERER = "com.zebra.sdk.printer.discovery.BluetoothDiscoverer"
|
|
205
|
+
private const val DISCOVERY_HANDLER = "com.zebra.sdk.printer.discovery.DiscoveryHandler"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.discovery
|
|
2
|
+
|
|
3
|
+
import com.delicity.thermalprinter.model.AdapterId
|
|
4
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
5
|
+
import com.delicity.thermalprinter.model.Transport
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Moteur de priorité d'adapter (miroir Kotlin de src/adapters/priority.ts).
|
|
9
|
+
*
|
|
10
|
+
* Règles :
|
|
11
|
+
* 1. SDK officiel reconnaissant l'imprimante -> priorité max.
|
|
12
|
+
* 2. Zebra -> ZebraAdapter UNIQUEMENT (escpos/rawTcp bannis).
|
|
13
|
+
* 3. ESC/POS confirmé > BLE > rawTcp.
|
|
14
|
+
*/
|
|
15
|
+
object AdapterPriority {
|
|
16
|
+
|
|
17
|
+
fun score(p: DiscoveredPrinter): Int {
|
|
18
|
+
val brand = p.brand?.lowercase() ?: ""
|
|
19
|
+
val isZebra = brand.contains("zebra") || p.adapter == AdapterId.ZEBRA
|
|
20
|
+
if (isZebra) return if (p.adapter == AdapterId.ZEBRA) 1000 else -1000
|
|
21
|
+
|
|
22
|
+
val fromVendorSdk = p.adapter in setOf(AdapterId.EPSON, AdapterId.STAR, AdapterId.BROTHER)
|
|
23
|
+
if (fromVendorSdk) {
|
|
24
|
+
return when (p.adapter) {
|
|
25
|
+
AdapterId.EPSON -> 900
|
|
26
|
+
AdapterId.STAR -> 890
|
|
27
|
+
AdapterId.BROTHER -> 880
|
|
28
|
+
else -> 850
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (p.adapter == AdapterId.ESCPOS) {
|
|
33
|
+
return if (p.transport == Transport.BLUETOOTH) 620 else 600
|
|
34
|
+
}
|
|
35
|
+
if (p.transport == Transport.BLE) return 500
|
|
36
|
+
if (p.adapter == AdapterId.RAW_TCP) return 300
|
|
37
|
+
return 100
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.discovery
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.bluetooth.BluetoothAdapter
|
|
5
|
+
import android.bluetooth.le.ScanCallback
|
|
6
|
+
import android.bluetooth.le.ScanFilter
|
|
7
|
+
import android.bluetooth.le.ScanResult
|
|
8
|
+
import android.bluetooth.le.ScanSettings
|
|
9
|
+
import android.content.Context
|
|
10
|
+
import android.os.ParcelUuid
|
|
11
|
+
import com.delicity.thermalprinter.model.AdapterId
|
|
12
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
13
|
+
import com.delicity.thermalprinter.model.Transport
|
|
14
|
+
import com.delicity.thermalprinter.transport.BleGattClient
|
|
15
|
+
import java.util.Collections
|
|
16
|
+
import kotlinx.coroutines.delay
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scanner BLE : repère les imprimantes thermiques exposant un service "UART série"
|
|
20
|
+
* connu (allowlist [BleGattClient.ADVERTISED_SERVICES]). On filtre sur ces services
|
|
21
|
+
* pour éviter le bruit (la plupart des appareils BLE ne sont pas des imprimantes).
|
|
22
|
+
*
|
|
23
|
+
* Les résultats sont produits avec adapter=ESCPOS / transport=BLE : la connexion +
|
|
24
|
+
* l'écriture sont ensuite gérées par BleAdapter / BleGattClient.
|
|
25
|
+
*/
|
|
26
|
+
@SuppressLint("MissingPermission")
|
|
27
|
+
class BleScanner(
|
|
28
|
+
private val context: Context,
|
|
29
|
+
private val btAdapter: BluetoothAdapter,
|
|
30
|
+
) {
|
|
31
|
+
|
|
32
|
+
suspend fun scan(timeoutMs: Long, onFound: (DiscoveredPrinter) -> Unit) {
|
|
33
|
+
val scanner = btAdapter.bluetoothLeScanner ?: return
|
|
34
|
+
if (!btAdapter.isEnabled) return
|
|
35
|
+
|
|
36
|
+
val seen = Collections.synchronizedSet(mutableSetOf<String>())
|
|
37
|
+
val callback = object : ScanCallback() {
|
|
38
|
+
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
39
|
+
val device = result.device ?: return
|
|
40
|
+
val address = device.address ?: return
|
|
41
|
+
if (!seen.add(address)) return
|
|
42
|
+
val name = result.scanRecord?.deviceName ?: runCatching { device.name }.getOrNull() ?: "BLE Printer"
|
|
43
|
+
onFound(
|
|
44
|
+
DiscoveredPrinter(
|
|
45
|
+
id = "ble:$address",
|
|
46
|
+
name = name,
|
|
47
|
+
transport = Transport.BLE,
|
|
48
|
+
adapter = AdapterId.ESCPOS,
|
|
49
|
+
address = address,
|
|
50
|
+
discoveredBy = mutableSetOf(AdapterId.ESCPOS),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
val filters = BleGattClient.ADVERTISED_SERVICES.map {
|
|
57
|
+
ScanFilter.Builder().setServiceUuid(ParcelUuid(it)).build()
|
|
58
|
+
}
|
|
59
|
+
val settings = ScanSettings.Builder()
|
|
60
|
+
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
61
|
+
.build()
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
scanner.startScan(filters, settings, callback)
|
|
65
|
+
delay(timeoutMs)
|
|
66
|
+
} finally {
|
|
67
|
+
runCatching { scanner.stopScan(callback) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/android/src/main/java/com/delicity/thermalprinter/discovery/BluetoothClassicScanner.kt
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.discovery
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.bluetooth.BluetoothAdapter
|
|
5
|
+
import android.bluetooth.BluetoothClass
|
|
6
|
+
import android.bluetooth.BluetoothDevice
|
|
7
|
+
import android.content.BroadcastReceiver
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.content.IntentFilter
|
|
11
|
+
import com.delicity.thermalprinter.model.AdapterId
|
|
12
|
+
import com.delicity.thermalprinter.model.Capabilities
|
|
13
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
14
|
+
import com.delicity.thermalprinter.model.Transport
|
|
15
|
+
import kotlinx.coroutines.delay
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scanner Bluetooth Classic (Android).
|
|
19
|
+
*
|
|
20
|
+
* Deux sources :
|
|
21
|
+
* 1. Appareils DÉJÀ APPAIRÉS (instantané, sans scan) -> idéal en restauration
|
|
22
|
+
* car l'imprimante est souvent appairée une fois pour toutes.
|
|
23
|
+
* 2. Découverte active (startDiscovery) pour les nouveaux appareils.
|
|
24
|
+
*
|
|
25
|
+
* Tous les résultats sont taggés adapter=ESCPOS (BT classic = SPP générique).
|
|
26
|
+
* Les imprimantes Epson/Star BT seront, elles, mieux remontées par leurs SDK
|
|
27
|
+
* respectifs et prendront la priorité via le dédoublonnage.
|
|
28
|
+
*
|
|
29
|
+
* Permissions requises (API 31+) : BLUETOOTH_SCAN + BLUETOOTH_CONNECT.
|
|
30
|
+
*/
|
|
31
|
+
@SuppressLint("MissingPermission")
|
|
32
|
+
class BluetoothClassicScanner(
|
|
33
|
+
private val context: Context,
|
|
34
|
+
private val adapter: BluetoothAdapter?,
|
|
35
|
+
) {
|
|
36
|
+
|
|
37
|
+
suspend fun scan(
|
|
38
|
+
timeoutMs: Long,
|
|
39
|
+
includePaired: Boolean,
|
|
40
|
+
onFound: (DiscoveredPrinter) -> Unit,
|
|
41
|
+
) {
|
|
42
|
+
val ad = adapter ?: return
|
|
43
|
+
if (!ad.isEnabled) return
|
|
44
|
+
|
|
45
|
+
// 1) Appareils appairés
|
|
46
|
+
if (includePaired) {
|
|
47
|
+
ad.bondedDevices?.forEach { device ->
|
|
48
|
+
if (looksLikePrinter(device)) onFound(toPrinter(device, paired = true))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2) Découverte active
|
|
53
|
+
val discovered = mutableSetOf<String>()
|
|
54
|
+
val receiver = object : BroadcastReceiver() {
|
|
55
|
+
override fun onReceive(ctx: Context?, intent: Intent?) {
|
|
56
|
+
if (intent?.action == BluetoothDevice.ACTION_FOUND) {
|
|
57
|
+
val device: BluetoothDevice? =
|
|
58
|
+
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
|
59
|
+
if (device != null && discovered.add(device.address) && looksLikePrinter(device)) {
|
|
60
|
+
onFound(toPrinter(device, paired = false))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
|
|
66
|
+
try {
|
|
67
|
+
ad.startDiscovery()
|
|
68
|
+
delay(timeoutMs)
|
|
69
|
+
} finally {
|
|
70
|
+
try { ad.cancelDiscovery() } catch (_: Exception) {}
|
|
71
|
+
try { context.unregisterReceiver(receiver) } catch (_: Exception) {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Heuristique : classe IMAGING/printer, ou nom contenant des mots-clés imprimante. */
|
|
76
|
+
private fun looksLikePrinter(device: BluetoothDevice): Boolean {
|
|
77
|
+
val cls = device.bluetoothClass?.majorDeviceClass
|
|
78
|
+
if (cls == BluetoothClass.Device.Major.IMAGING) return true
|
|
79
|
+
val name = (device.name ?: "").lowercase()
|
|
80
|
+
return PRINTER_HINTS.any { name.contains(it) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun toPrinter(device: BluetoothDevice, paired: Boolean): DiscoveredPrinter =
|
|
84
|
+
DiscoveredPrinter(
|
|
85
|
+
id = "bluetooth:${device.address}",
|
|
86
|
+
name = device.name ?: device.address,
|
|
87
|
+
brand = guessBrand(device.name),
|
|
88
|
+
transport = Transport.BLUETOOTH,
|
|
89
|
+
adapter = AdapterId.ESCPOS,
|
|
90
|
+
address = device.address,
|
|
91
|
+
capabilities = Capabilities(supportsRasterImage = true, supportsStatus = false),
|
|
92
|
+
discoveredBy = mutableSetOf(AdapterId.ESCPOS),
|
|
93
|
+
).also { it.isConnected = false; if (paired) it.lastSeenAt = System.currentTimeMillis() }
|
|
94
|
+
|
|
95
|
+
private fun guessBrand(name: String?): String? {
|
|
96
|
+
val n = (name ?: "").lowercase()
|
|
97
|
+
return when {
|
|
98
|
+
n.contains("epson") -> "Epson"
|
|
99
|
+
n.contains("star") -> "Star"
|
|
100
|
+
n.contains("zebra") -> "Zebra"
|
|
101
|
+
n.contains("brother") -> "Brother"
|
|
102
|
+
else -> null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
companion object {
|
|
107
|
+
private val PRINTER_HINTS = listOf(
|
|
108
|
+
"print", "printer", "pos", "escpos", "esc/pos", "thermal", "receipt",
|
|
109
|
+
"tm-", "mpt", "rpp", "mtp", "bluetooth printer",
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.discovery
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothAdapter
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import com.delicity.thermalprinter.adapters.PrinterAdapter
|
|
6
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
7
|
+
import kotlinx.coroutines.async
|
|
8
|
+
import kotlinx.coroutines.awaitAll
|
|
9
|
+
import kotlinx.coroutines.coroutineScope
|
|
10
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
11
|
+
import java.util.Collections
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Orchestre la découverte AGRÉGÉE multi-sources en parallèle puis fusionne.
|
|
15
|
+
*
|
|
16
|
+
* Sources lancées (selon options + disponibilité plateforme) :
|
|
17
|
+
* - SDK Epson / Star / Brother / Zebra (via PrinterAdapter.discover)
|
|
18
|
+
* - TcpScanner (réseau 9100)
|
|
19
|
+
* - BluetoothClassicScanner (Android)
|
|
20
|
+
* - BleScanner (optionnel)
|
|
21
|
+
* - UsbAdapter.discover (optionnel)
|
|
22
|
+
*
|
|
23
|
+
* Chaque source pousse ses résultats dans un buffer thread-safe ; `emitPartial`
|
|
24
|
+
* est invoqué au fil de l'eau (event printerFound). À la fin : fusion +
|
|
25
|
+
* dédoublonnage + arbitrage d'adapter via AdapterPriority.
|
|
26
|
+
*/
|
|
27
|
+
class DiscoveryManager(
|
|
28
|
+
private val context: Context,
|
|
29
|
+
private val btAdapter: BluetoothAdapter?,
|
|
30
|
+
private val adapters: List<PrinterAdapter>,
|
|
31
|
+
) {
|
|
32
|
+
|
|
33
|
+
data class Options(
|
|
34
|
+
val sources: Set<String>?, // null = toutes
|
|
35
|
+
val timeoutMs: Long = 8000,
|
|
36
|
+
val includePaired: Boolean = true,
|
|
37
|
+
val networkCidr: String? = null,
|
|
38
|
+
val tcpPorts: List<Int> = listOf(9100),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
suspend fun discover(
|
|
42
|
+
options: Options,
|
|
43
|
+
emitPartial: (DiscoveredPrinter) -> Unit,
|
|
44
|
+
): Pair<List<DiscoveredPrinter>, List<String>> = coroutineScope {
|
|
45
|
+
val buffer = Collections.synchronizedList(mutableListOf<DiscoveredPrinter>())
|
|
46
|
+
val failed = Collections.synchronizedList(mutableListOf<String>())
|
|
47
|
+
|
|
48
|
+
// Collecte thread-safe + émission partielle (callback synchrone).
|
|
49
|
+
val collect: (DiscoveredPrinter) -> Unit = { p ->
|
|
50
|
+
buffer.add(p)
|
|
51
|
+
runCatching { emitPartial(p) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fun enabled(src: String) = options.sources == null || options.sources.contains(src)
|
|
55
|
+
|
|
56
|
+
val jobs = mutableListOf<kotlinx.coroutines.Deferred<Unit>>()
|
|
57
|
+
|
|
58
|
+
// --- Sources SDK fabricants (via adapters) ---
|
|
59
|
+
for (adapter in adapters) {
|
|
60
|
+
val discoverySource = when (adapter.id.value) {
|
|
61
|
+
"epson" -> "epson"
|
|
62
|
+
"star" -> "star"
|
|
63
|
+
"brother" -> "brother"
|
|
64
|
+
"zebra" -> "zebra"
|
|
65
|
+
else -> null
|
|
66
|
+
} ?: continue
|
|
67
|
+
if (enabled(discoverySource) && adapter.isAvailable()) {
|
|
68
|
+
jobs += async {
|
|
69
|
+
runCatching {
|
|
70
|
+
withTimeoutOrNull(options.timeoutMs + 1000) {
|
|
71
|
+
adapter.discover(options.timeoutMs, collect)
|
|
72
|
+
}
|
|
73
|
+
}.onFailure { failed.add(discoverySource) }
|
|
74
|
+
Unit
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Source TCP réseau ---
|
|
80
|
+
if (enabled("tcp")) {
|
|
81
|
+
jobs += async {
|
|
82
|
+
runCatching {
|
|
83
|
+
TcpScanner(context).scan(options.timeoutMs, options.tcpPorts, options.networkCidr, collect)
|
|
84
|
+
}.onFailure { failed.add("tcp") }
|
|
85
|
+
Unit
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Source Bluetooth classique (Android) ---
|
|
90
|
+
if (enabled("bluetooth") && btAdapter != null) {
|
|
91
|
+
jobs += async {
|
|
92
|
+
runCatching {
|
|
93
|
+
BluetoothClassicScanner(context, btAdapter)
|
|
94
|
+
.scan(options.timeoutMs, options.includePaired, collect)
|
|
95
|
+
}.onFailure { failed.add("bluetooth") }
|
|
96
|
+
Unit
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Source BLE (allowlist de services "UART série") ---
|
|
101
|
+
if (enabled("ble") && btAdapter != null) {
|
|
102
|
+
jobs += async {
|
|
103
|
+
runCatching {
|
|
104
|
+
BleScanner(context, btAdapter).scan(options.timeoutMs, collect)
|
|
105
|
+
}.onFailure { failed.add("ble") }
|
|
106
|
+
Unit
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
jobs.awaitAll()
|
|
111
|
+
|
|
112
|
+
Pair(merge(buffer.toList()), failed.distinct())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Fusion + dédoublonnage par id stable, en conservant le meilleur adapter. */
|
|
116
|
+
private fun merge(incoming: List<DiscoveredPrinter>): List<DiscoveredPrinter> {
|
|
117
|
+
val byId = LinkedHashMap<String, DiscoveredPrinter>()
|
|
118
|
+
for (p in incoming) {
|
|
119
|
+
val existing = byId[p.id]
|
|
120
|
+
if (existing == null) {
|
|
121
|
+
byId[p.id] = p
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
val mergedSources = (existing.discoveredBy + p.discoveredBy).toMutableSet()
|
|
125
|
+
val winner = if (AdapterPriority.score(p) > AdapterPriority.score(existing)) p else existing
|
|
126
|
+
winner.discoveredBy.clear()
|
|
127
|
+
winner.discoveredBy.addAll(mergedSources)
|
|
128
|
+
winner.lastSeenAt = maxOf(existing.lastSeenAt, p.lastSeenAt)
|
|
129
|
+
winner.isConnected = existing.isConnected || p.isConnected
|
|
130
|
+
byId[p.id] = winner
|
|
131
|
+
}
|
|
132
|
+
return byId.values.sortedWith(
|
|
133
|
+
compareByDescending<DiscoveredPrinter> { AdapterPriority.score(it) }.thenBy { it.name },
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package com.delicity.thermalprinter.discovery
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.LinkProperties
|
|
6
|
+
import com.delicity.thermalprinter.model.AdapterId
|
|
7
|
+
import com.delicity.thermalprinter.model.Capabilities
|
|
8
|
+
import com.delicity.thermalprinter.model.DiscoveredPrinter
|
|
9
|
+
import com.delicity.thermalprinter.model.Transport
|
|
10
|
+
import kotlinx.coroutines.Dispatchers
|
|
11
|
+
import kotlinx.coroutines.coroutineScope
|
|
12
|
+
import kotlinx.coroutines.launch
|
|
13
|
+
import kotlinx.coroutines.sync.Semaphore
|
|
14
|
+
import kotlinx.coroutines.sync.withPermit
|
|
15
|
+
import kotlinx.coroutines.withContext
|
|
16
|
+
import java.net.InetSocketAddress
|
|
17
|
+
import java.net.Socket
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scanner réseau : sonde le /24 courant sur les ports d'impression (9100 par défaut)
|
|
21
|
+
* pour détecter des imprimantes RAW.
|
|
22
|
+
*
|
|
23
|
+
* Stratégie :
|
|
24
|
+
* - déduire le préfixe réseau via ConnectivityManager (ou networkCidr fourni),
|
|
25
|
+
* - tester 1..254 en parallèle (semaphore borné) avec un connect TCP court,
|
|
26
|
+
* - un port ouvert = candidat ESC/POS (adapter ESCPOS), faute d'identification SDK.
|
|
27
|
+
*
|
|
28
|
+
* Limites : ne distingue pas la marque. Les SDK (Epson/Star/...) tournent en
|
|
29
|
+
* parallèle et, via le dédoublonnage, prennent la priorité sur ce résultat brut.
|
|
30
|
+
*/
|
|
31
|
+
class TcpScanner(private val context: Context) {
|
|
32
|
+
|
|
33
|
+
suspend fun scan(
|
|
34
|
+
timeoutMs: Long,
|
|
35
|
+
ports: List<Int>,
|
|
36
|
+
networkCidr: String?,
|
|
37
|
+
onFound: (DiscoveredPrinter) -> Unit,
|
|
38
|
+
) = coroutineScope {
|
|
39
|
+
val prefix = networkCidr?.let { cidrToPrefix(it) } ?: detectPrefix() ?: return@coroutineScope
|
|
40
|
+
val portList = if (ports.isEmpty()) listOf(9100) else ports
|
|
41
|
+
val perHostTimeout = 300 // ms : connect court, le scan global est borné par timeoutMs
|
|
42
|
+
val gate = Semaphore(64) // limiter le parallélisme pour ne pas saturer le Wi-Fi
|
|
43
|
+
|
|
44
|
+
val deadline = System.currentTimeMillis() + timeoutMs
|
|
45
|
+
for (i in 1..254) {
|
|
46
|
+
val host = "$prefix$i"
|
|
47
|
+
for (port in portList) {
|
|
48
|
+
if (System.currentTimeMillis() > deadline) return@coroutineScope
|
|
49
|
+
launch(Dispatchers.IO) {
|
|
50
|
+
gate.withPermit {
|
|
51
|
+
if (isOpen(host, port, perHostTimeout)) {
|
|
52
|
+
onFound(
|
|
53
|
+
DiscoveredPrinter(
|
|
54
|
+
id = "wifi:$host",
|
|
55
|
+
name = host,
|
|
56
|
+
transport = Transport.WIFI,
|
|
57
|
+
adapter = AdapterId.ESCPOS, // sera arbitré par la priorité
|
|
58
|
+
address = "$host:$port",
|
|
59
|
+
capabilities = Capabilities(supportsRasterImage = true),
|
|
60
|
+
discoveredBy = mutableSetOf(AdapterId.ESCPOS, AdapterId.RAW_TCP),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private suspend fun isOpen(host: String, port: Int, timeout: Int): Boolean = withContext(Dispatchers.IO) {
|
|
71
|
+
try {
|
|
72
|
+
Socket().use { s ->
|
|
73
|
+
s.connect(InetSocketAddress(host, port), timeout)
|
|
74
|
+
true
|
|
75
|
+
}
|
|
76
|
+
} catch (e: Exception) {
|
|
77
|
+
false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Renvoie le préfixe "192.168.1." du réseau courant, ou null. */
|
|
82
|
+
private fun detectPrefix(): String? {
|
|
83
|
+
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
84
|
+
val active = cm.activeNetwork ?: return null
|
|
85
|
+
val lp: LinkProperties = cm.getLinkProperties(active) ?: return null
|
|
86
|
+
val addr = lp.linkAddresses.firstOrNull { it.address.address.size == 4 } ?: return null
|
|
87
|
+
val ip = addr.address.hostAddress ?: return null
|
|
88
|
+
return ip.substringBeforeLast('.') + "."
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun cidrToPrefix(cidr: String): String? {
|
|
92
|
+
// Support simple /24 : "192.168.1.0/24" -> "192.168.1."
|
|
93
|
+
val ip = cidr.substringBefore('/')
|
|
94
|
+
return if (ip.count { it == '.' } == 3) ip.substringBeforeLast('.') + "." else null
|
|
95
|
+
}
|
|
96
|
+
}
|