@devlas/capacitor-thermal-printer 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/android/build.gradle +47 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/app/devlas/plugins/thermalprinter/BluetoothTransport.kt +75 -0
- package/android/src/main/java/app/devlas/plugins/thermalprinter/PrinterException.kt +8 -0
- package/android/src/main/java/app/devlas/plugins/thermalprinter/TcpTransport.kt +41 -0
- package/android/src/main/java/app/devlas/plugins/thermalprinter/ThermalPrinterPlugin.kt +137 -0
- package/android/src/main/java/app/devlas/plugins/thermalprinter/UsbTransport.kt +146 -0
- package/dist/esm/definitions.d.ts +74 -0
- package/dist/esm/definitions.js +8 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/web.d.ts +20 -0
- package/dist/esm/web.js +17 -0
- package/docs/TRANSPORTES.md +52 -0
- package/package.json +71 -0
- package/src/definitions.ts +77 -0
- package/src/index.ts +22 -0
- package/src/web.ts +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devlas SpA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @devlas/capacitor-thermal-printer
|
|
2
|
+
|
|
3
|
+
Adaptador universal de impresión térmica **ESC/POS** para Capacitor (Android): **USB (OTG)**, **TCP/LAN/WiFi** y **Bluetooth SPP** con una sola API de bytes crudos.
|
|
4
|
+
|
|
5
|
+
- **Bytes crudos, no formateo**: la app codifica el ticket ESC/POS (con su propio encoder) y el plugin solo lo transporta. Así el mismo encoder sirve para Electron, Web Serial, WebUSB y esta app nativa.
|
|
6
|
+
- **Stateless**: cada `print()` conecta → escribe → cierra. Sin estado de conexión que se corrompa.
|
|
7
|
+
- **Sin dependencias nativas**: `UsbManager`, `Socket` y `BluetoothSocket` del framework Android, nada más.
|
|
8
|
+
- **Permisos modernos**: flujo USB compatible con Android 12/14+ (`FLAG_MUTABLE` + intent explícito + `RECEIVER_NOT_EXPORTED`), `BLUETOOTH_CONNECT` runtime en API 31+.
|
|
9
|
+
- **Base64 en el bridge**: los bytes viajan como base64 — sin corrupción UTF-8 (el bug clásico de los plugins BT de la comunidad).
|
|
10
|
+
|
|
11
|
+
> Matriz de transportes, librerías evaluadas y decisiones de diseño: [docs/TRANSPORTES.md](docs/TRANSPORTES.md)
|
|
12
|
+
|
|
13
|
+
## Instalación
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i @devlas/capacitor-thermal-printer
|
|
17
|
+
npx cap sync android
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Sin pasos extra: no requiere JitPack ni repositorios Maven adicionales.
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { ThermalPrinter, bytesToBase64 } from '@devlas/capacitor-thermal-printer'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### `list({ transport })`
|
|
29
|
+
|
|
30
|
+
Lista dispositivos disponibles. `transport: 'usb' | 'bluetooth'` (TCP no es listable — la IP la ingresa el usuario).
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const { devices } = await ThermalPrinter.list({ transport: 'usb' })
|
|
34
|
+
// [{ transport: 'usb', vendorId: 1155, productId: 22304, name: 'POS58 Printer',
|
|
35
|
+
// canPrint: true, hasPermission: false, deviceId: 1002 }]
|
|
36
|
+
|
|
37
|
+
const { devices } = await ThermalPrinter.list({ transport: 'bluetooth' })
|
|
38
|
+
// [{ transport: 'bluetooth', address: '66:22:...', name: 'PT-210' }]
|
|
39
|
+
// (solo emparejados — la impresora se empareja desde Ajustes del sistema)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `requestPermission(target)`
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// USB: diálogo del sistema por dispositivo. El permiso persiste hasta desconectar
|
|
46
|
+
// el cable (o para siempre si el usuario marca "usar por defecto").
|
|
47
|
+
const { granted } = await ThermalPrinter.requestPermission({
|
|
48
|
+
transport: 'usb', vendorId: 1155, productId: 22304,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Bluetooth: permiso runtime BLUETOOTH_CONNECT (API 31+)
|
|
52
|
+
await ThermalPrinter.requestPermission({ transport: 'bluetooth', address: '66:22:...' })
|
|
53
|
+
|
|
54
|
+
// TCP: siempre { granted: true }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `print(target & { data })`
|
|
58
|
+
|
|
59
|
+
`data` = bytes ESC/POS en **base64** (usa el helper `bytesToBase64`).
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const bytes: Uint8Array = miEncoderEscPos(ticket) // el encoder es de la app
|
|
63
|
+
|
|
64
|
+
// USB — si se omiten vendorId/productId usa la primera impresora detectada
|
|
65
|
+
await ThermalPrinter.print({ transport: 'usb', vendorId: 1155, productId: 22304, data: bytesToBase64(bytes) })
|
|
66
|
+
|
|
67
|
+
// TCP / LAN / WiFi — raw 9100 (JetDirect)
|
|
68
|
+
await ThermalPrinter.print({ transport: 'tcp', host: '192.168.1.50', port: 9100, data: bytesToBase64(bytes) })
|
|
69
|
+
|
|
70
|
+
// Bluetooth SPP
|
|
71
|
+
await ThermalPrinter.print({ transport: 'bluetooth', address: '66:22:...', data: bytesToBase64(bytes) })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Códigos de error
|
|
75
|
+
|
|
76
|
+
`error.code` estable para manejar en JS:
|
|
77
|
+
|
|
78
|
+
| Código | Significado |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `unavailable` | El transporte no existe en el equipo (sin USB host / sin adaptador BT) |
|
|
81
|
+
| `not_found` | Dispositivo no encontrado (desconectado, MAC inválida) |
|
|
82
|
+
| `permission_denied` | Permiso USB o BLUETOOTH_CONNECT denegado |
|
|
83
|
+
| `connect_failed` | No se pudo abrir la conexión (timeout TCP, socket BT, claim USB) |
|
|
84
|
+
| `write_failed` | La escritura falló a mitad de trabajo |
|
|
85
|
+
| `invalid_transport` / `invalid_data` | Parámetros mal formados |
|
|
86
|
+
|
|
87
|
+
## Plataformas
|
|
88
|
+
|
|
89
|
+
| Plataforma | Estado |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Android (Capacitor WebView) | ✅ usb / tcp / bluetooth |
|
|
92
|
+
| Web | ❌ stub — en navegador usa WebUSB / Web Serial directamente |
|
|
93
|
+
| iOS | ❌ sin USB host ni SPP; ver [docs/TRANSPORTES.md](docs/TRANSPORTES.md) |
|
|
94
|
+
|
|
95
|
+
## Publicar
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm publish --access public
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Licencia
|
|
102
|
+
|
|
103
|
+
MIT © [Devlas SpA](https://devlas.cl)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
ext {
|
|
2
|
+
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
|
3
|
+
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
|
|
4
|
+
kotlinVersion = '1.9.22'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
buildscript {
|
|
8
|
+
repositories {
|
|
9
|
+
google()
|
|
10
|
+
mavenCentral()
|
|
11
|
+
}
|
|
12
|
+
dependencies {
|
|
13
|
+
classpath 'com.android.tools.build:gradle:8.2.1'
|
|
14
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
apply plugin: 'com.android.library'
|
|
19
|
+
apply plugin: 'kotlin-android'
|
|
20
|
+
|
|
21
|
+
android {
|
|
22
|
+
namespace "app.devlas.plugins.thermalprinter"
|
|
23
|
+
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
|
24
|
+
defaultConfig {
|
|
25
|
+
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
|
26
|
+
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
27
|
+
}
|
|
28
|
+
compileOptions {
|
|
29
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
30
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
31
|
+
}
|
|
32
|
+
kotlinOptions {
|
|
33
|
+
jvmTarget = '17'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
repositories {
|
|
38
|
+
google()
|
|
39
|
+
mavenCentral()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
dependencies {
|
|
43
|
+
implementation project(':capacitor-android')
|
|
44
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
|
45
|
+
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
46
|
+
testImplementation "junit:junit:$junitVersion"
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
3
|
+
|
|
4
|
+
<!-- USB host (OTG) — required=false: el plugin también sirve por TCP/BT en equipos sin OTG -->
|
|
5
|
+
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
|
6
|
+
|
|
7
|
+
<!-- TCP/LAN/WiFi (raw 9100) -->
|
|
8
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
9
|
+
|
|
10
|
+
<!-- Bluetooth SPP: legacy hasta API 30, BLUETOOTH_CONNECT runtime desde API 31 -->
|
|
11
|
+
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
|
12
|
+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
|
13
|
+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
14
|
+
|
|
15
|
+
</manifest>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package app.devlas.plugins.thermalprinter
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.bluetooth.BluetoothManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import com.getcapacitor.JSArray
|
|
7
|
+
import com.getcapacitor.JSObject
|
|
8
|
+
import java.io.IOException
|
|
9
|
+
import java.util.UUID
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transporte Bluetooth clásico (SPP/RFCOMM) — el perfil de las térmicas BT
|
|
13
|
+
* (PT-210, MTP-II, Rongta, Xprinter…). La impresora debe estar emparejada
|
|
14
|
+
* desde Ajustes del sistema; aquí solo se listan los dispositivos vinculados.
|
|
15
|
+
*
|
|
16
|
+
* El permiso BLUETOOTH_CONNECT (API 31+) lo gestiona ThermalPrinterPlugin
|
|
17
|
+
* vía el alias de permisos de Capacitor antes de llegar acá.
|
|
18
|
+
*/
|
|
19
|
+
class BluetoothTransport(private val context: Context) {
|
|
20
|
+
|
|
21
|
+
companion object {
|
|
22
|
+
/** UUID estándar del Serial Port Profile. */
|
|
23
|
+
private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
|
|
24
|
+
private const val CHUNK_SIZE = 1024
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private val adapter
|
|
28
|
+
get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
|
29
|
+
|
|
30
|
+
@SuppressLint("MissingPermission") // verificado por el plugin antes de llamar
|
|
31
|
+
fun list(): JSArray {
|
|
32
|
+
val result = JSArray()
|
|
33
|
+
val a = adapter ?: throw PrinterException("unavailable", "Bluetooth no disponible en este dispositivo")
|
|
34
|
+
for (device in a.bondedDevices) {
|
|
35
|
+
val obj = JSObject()
|
|
36
|
+
obj.put("transport", "bluetooth")
|
|
37
|
+
obj.put("address", device.address)
|
|
38
|
+
obj.put("name", device.name ?: device.address)
|
|
39
|
+
result.put(obj)
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stateless: conecta → escribe chunked → flush → cierra. */
|
|
45
|
+
@SuppressLint("MissingPermission")
|
|
46
|
+
fun print(address: String, bytes: ByteArray) {
|
|
47
|
+
val a = adapter ?: throw PrinterException("unavailable", "Bluetooth no disponible en este dispositivo")
|
|
48
|
+
val device = try {
|
|
49
|
+
a.getRemoteDevice(address)
|
|
50
|
+
} catch (e: IllegalArgumentException) {
|
|
51
|
+
throw PrinterException("not_found", "Dirección Bluetooth inválida: $address")
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
a.cancelDiscovery() // discovery activo degrada la conexión SPP
|
|
55
|
+
device.createRfcommSocketToServiceRecord(SPP_UUID).use { socket ->
|
|
56
|
+
socket.connect()
|
|
57
|
+
val out = socket.outputStream
|
|
58
|
+
var offset = 0
|
|
59
|
+
while (offset < bytes.size) {
|
|
60
|
+
val len = minOf(CHUNK_SIZE, bytes.size - offset)
|
|
61
|
+
out.write(bytes, offset, len)
|
|
62
|
+
offset += len
|
|
63
|
+
}
|
|
64
|
+
out.flush()
|
|
65
|
+
// ponytail: conexión por trabajo (~1-2s de latencia BT por ticket);
|
|
66
|
+
// pool keep-alive si la latencia molesta en caja.
|
|
67
|
+
Thread.sleep(minOf(2000L, bytes.size / 16L))
|
|
68
|
+
}
|
|
69
|
+
} catch (e: SecurityException) {
|
|
70
|
+
throw PrinterException("permission_denied", "Sin permiso BLUETOOTH_CONNECT")
|
|
71
|
+
} catch (e: IOException) {
|
|
72
|
+
throw PrinterException("connect_failed", "No se pudo imprimir en $address: ${e.message}")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
package app.devlas.plugins.thermalprinter
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error con código estable que llega a JS como `error.code`:
|
|
5
|
+
* unavailable | not_found | permission_denied | connect_failed
|
|
6
|
+
* | write_failed | invalid_transport | invalid_data
|
|
7
|
+
*/
|
|
8
|
+
class PrinterException(val code: String, message: String) : Exception(message)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package app.devlas.plugins.thermalprinter
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.net.InetSocketAddress
|
|
5
|
+
import java.net.Socket
|
|
6
|
+
import java.net.SocketTimeoutException
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transporte TCP — impresión raw a puerto 9100 (JetDirect), el estándar de
|
|
10
|
+
* térmicas de red cableada y WiFi. Sin librerías: un Socket y ya.
|
|
11
|
+
*/
|
|
12
|
+
class TcpTransport {
|
|
13
|
+
|
|
14
|
+
companion object {
|
|
15
|
+
private const val CONNECT_TIMEOUT_MS = 4000
|
|
16
|
+
private const val CHUNK_SIZE = 8192
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fun print(host: String, port: Int, bytes: ByteArray) {
|
|
20
|
+
try {
|
|
21
|
+
Socket().use { socket ->
|
|
22
|
+
socket.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
|
|
23
|
+
val out = socket.getOutputStream()
|
|
24
|
+
var offset = 0
|
|
25
|
+
while (offset < bytes.size) {
|
|
26
|
+
val len = minOf(CHUNK_SIZE, bytes.size - offset)
|
|
27
|
+
out.write(bytes, offset, len)
|
|
28
|
+
offset += len
|
|
29
|
+
}
|
|
30
|
+
out.flush()
|
|
31
|
+
// Espera proporcional antes de cerrar — evita truncar el búfer de la
|
|
32
|
+
// impresora en trabajos grandes (patrón DantSu: ~1ms por 16 bytes).
|
|
33
|
+
Thread.sleep(minOf(2000L, bytes.size / 16L))
|
|
34
|
+
}
|
|
35
|
+
} catch (e: SocketTimeoutException) {
|
|
36
|
+
throw PrinterException("connect_failed", "Timeout conectando a $host:$port")
|
|
37
|
+
} catch (e: IOException) {
|
|
38
|
+
throw PrinterException("connect_failed", "No se pudo imprimir en $host:$port: ${e.message}")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package app.devlas.plugins.thermalprinter
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.util.Base64
|
|
5
|
+
import com.getcapacitor.JSObject
|
|
6
|
+
import com.getcapacitor.PermissionState
|
|
7
|
+
import com.getcapacitor.Plugin
|
|
8
|
+
import com.getcapacitor.PluginCall
|
|
9
|
+
import com.getcapacitor.PluginMethod
|
|
10
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
11
|
+
import com.getcapacitor.annotation.Permission
|
|
12
|
+
import com.getcapacitor.annotation.PermissionCallback
|
|
13
|
+
import java.util.concurrent.Executors
|
|
14
|
+
|
|
15
|
+
@CapacitorPlugin(
|
|
16
|
+
name = "ThermalPrinter",
|
|
17
|
+
permissions = [
|
|
18
|
+
Permission(alias = "bluetooth", strings = [Manifest.permission.BLUETOOTH_CONNECT])
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
class ThermalPrinterPlugin : Plugin() {
|
|
22
|
+
|
|
23
|
+
// Un solo hilo: serializa los trabajos (nunca dos impresiones concurrentes al
|
|
24
|
+
// mismo dispositivo) y saca el I/O bloqueante (sockets, bulkTransfer) del main.
|
|
25
|
+
private val executor = Executors.newSingleThreadExecutor()
|
|
26
|
+
|
|
27
|
+
private val usb by lazy { UsbTransport(context) }
|
|
28
|
+
private val tcp = TcpTransport()
|
|
29
|
+
private val bluetooth by lazy { BluetoothTransport(context) }
|
|
30
|
+
|
|
31
|
+
override fun handleOnDestroy() {
|
|
32
|
+
executor.shutdown()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── list ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@PluginMethod
|
|
38
|
+
fun list(call: PluginCall) {
|
|
39
|
+
when (call.getString("transport")) {
|
|
40
|
+
"usb" -> call.resolve(JSObject().put("devices", usb.list()))
|
|
41
|
+
"bluetooth" -> {
|
|
42
|
+
if (getPermissionState("bluetooth") != PermissionState.GRANTED) {
|
|
43
|
+
requestPermissionForAlias("bluetooth", call, "bluetoothListCallback")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
resolveBluetoothList(call)
|
|
47
|
+
}
|
|
48
|
+
else -> call.reject("transport debe ser 'usb' o 'bluetooth'", "invalid_transport")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@PermissionCallback
|
|
53
|
+
private fun bluetoothListCallback(call: PluginCall) {
|
|
54
|
+
if (getPermissionState("bluetooth") == PermissionState.GRANTED) {
|
|
55
|
+
resolveBluetoothList(call)
|
|
56
|
+
} else {
|
|
57
|
+
call.reject("Permiso Bluetooth denegado", "permission_denied")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private fun resolveBluetoothList(call: PluginCall) {
|
|
62
|
+
try {
|
|
63
|
+
call.resolve(JSObject().put("devices", bluetooth.list()))
|
|
64
|
+
} catch (e: PrinterException) {
|
|
65
|
+
call.reject(e.message, e.code)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── requestPermission ────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
@PluginMethod
|
|
72
|
+
fun requestPermission(call: PluginCall) {
|
|
73
|
+
when (call.getString("transport")) {
|
|
74
|
+
"usb" -> usb.requestPermission(
|
|
75
|
+
vendorId = call.getInt("vendorId"),
|
|
76
|
+
productId = call.getInt("productId"),
|
|
77
|
+
onResult = { granted -> call.resolve(JSObject().put("granted", granted)) },
|
|
78
|
+
onError = { code, msg -> call.reject(msg, code) },
|
|
79
|
+
)
|
|
80
|
+
"bluetooth" -> {
|
|
81
|
+
if (getPermissionState("bluetooth") == PermissionState.GRANTED) {
|
|
82
|
+
call.resolve(JSObject().put("granted", true))
|
|
83
|
+
} else {
|
|
84
|
+
requestPermissionForAlias("bluetooth", call, "bluetoothPermissionCallback")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
"tcp" -> call.resolve(JSObject().put("granted", true))
|
|
88
|
+
else -> call.reject("transport debe ser 'usb', 'tcp' o 'bluetooth'", "invalid_transport")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@PermissionCallback
|
|
93
|
+
private fun bluetoothPermissionCallback(call: PluginCall) {
|
|
94
|
+
call.resolve(JSObject().put("granted", getPermissionState("bluetooth") == PermissionState.GRANTED))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── print ────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
@PluginMethod
|
|
100
|
+
fun print(call: PluginCall) {
|
|
101
|
+
val data = call.getString("data")
|
|
102
|
+
if (data.isNullOrEmpty()) {
|
|
103
|
+
call.reject("Falta 'data' (bytes ESC/POS en base64)", "invalid_data")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
val bytes = try {
|
|
107
|
+
Base64.decode(data, Base64.DEFAULT)
|
|
108
|
+
} catch (e: IllegalArgumentException) {
|
|
109
|
+
call.reject("'data' no es base64 válido", "invalid_data")
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
val transport = call.getString("transport")
|
|
113
|
+
executor.execute {
|
|
114
|
+
try {
|
|
115
|
+
when (transport) {
|
|
116
|
+
"usb" -> usb.print(call.getInt("vendorId"), call.getInt("productId"), bytes)
|
|
117
|
+
"tcp" -> {
|
|
118
|
+
val host = call.getString("host")
|
|
119
|
+
?: throw PrinterException("connect_failed", "Falta 'host' para transporte tcp")
|
|
120
|
+
tcp.print(host, call.getInt("port") ?: 9100, bytes)
|
|
121
|
+
}
|
|
122
|
+
"bluetooth" -> {
|
|
123
|
+
val address = call.getString("address")
|
|
124
|
+
?: throw PrinterException("not_found", "Falta 'address' para transporte bluetooth")
|
|
125
|
+
bluetooth.print(address, bytes)
|
|
126
|
+
}
|
|
127
|
+
else -> throw PrinterException("invalid_transport", "transport debe ser 'usb', 'tcp' o 'bluetooth'")
|
|
128
|
+
}
|
|
129
|
+
call.resolve()
|
|
130
|
+
} catch (e: PrinterException) {
|
|
131
|
+
call.reject(e.message, e.code)
|
|
132
|
+
} catch (e: Exception) {
|
|
133
|
+
call.reject(e.message ?: "Error de impresión", "write_failed")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package app.devlas.plugins.thermalprinter
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.content.BroadcastReceiver
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.hardware.usb.UsbConstants
|
|
9
|
+
import android.hardware.usb.UsbDevice
|
|
10
|
+
import android.hardware.usb.UsbEndpoint
|
|
11
|
+
import android.hardware.usb.UsbInterface
|
|
12
|
+
import android.hardware.usb.UsbManager
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import androidx.core.content.ContextCompat
|
|
15
|
+
import com.getcapacitor.JSArray
|
|
16
|
+
import com.getcapacitor.JSObject
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Transporte USB host (OTG) — habla directo con UsbManager, sin librerías.
|
|
20
|
+
*
|
|
21
|
+
* Selección de interfaz: prefiere la de clase impresora (USB_CLASS_PRINTER);
|
|
22
|
+
* si el dispositivo no la declara (térmicas genéricas con clase vendor-specific),
|
|
23
|
+
* cae a la primera interfaz con endpoint bulk OUT — heurística equivalente a la
|
|
24
|
+
* usada por WebUSB en navegador, validada con hardware real.
|
|
25
|
+
*/
|
|
26
|
+
class UsbTransport(private val context: Context) {
|
|
27
|
+
|
|
28
|
+
companion object {
|
|
29
|
+
private const val ACTION_USB_PERMISSION = "app.devlas.plugins.thermalprinter.USB_PERMISSION"
|
|
30
|
+
private const val CHUNK_SIZE = 4096
|
|
31
|
+
private const val TRANSFER_TIMEOUT_MS = 3000
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private val usbManager: UsbManager?
|
|
35
|
+
get() = context.getSystemService(Context.USB_SERVICE) as? UsbManager
|
|
36
|
+
|
|
37
|
+
fun list(): JSArray {
|
|
38
|
+
val result = JSArray()
|
|
39
|
+
val manager = usbManager ?: return result
|
|
40
|
+
for (device in manager.deviceList.values) {
|
|
41
|
+
val obj = JSObject()
|
|
42
|
+
obj.put("transport", "usb")
|
|
43
|
+
obj.put("deviceId", device.deviceId)
|
|
44
|
+
obj.put("vendorId", device.vendorId)
|
|
45
|
+
obj.put("productId", device.productId)
|
|
46
|
+
obj.put("name", device.productName ?: device.deviceName)
|
|
47
|
+
obj.put("canPrint", findPrinterInterface(device) != null)
|
|
48
|
+
obj.put("hasPermission", manager.hasPermission(device))
|
|
49
|
+
result.put(obj)
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flujo de permiso compatible con Android 12/14+:
|
|
56
|
+
* PendingIntent MUTABLE (el sistema rellena EXTRA_PERMISSION_GRANTED),
|
|
57
|
+
* intent explícito (setPackage) y receiver RECEIVER_NOT_EXPORTED.
|
|
58
|
+
* Ninguno de los plugins de la comunidad revisados hace esto correctamente.
|
|
59
|
+
*/
|
|
60
|
+
fun requestPermission(
|
|
61
|
+
vendorId: Int?,
|
|
62
|
+
productId: Int?,
|
|
63
|
+
onResult: (granted: Boolean) -> Unit,
|
|
64
|
+
onError: (code: String, msg: String) -> Unit,
|
|
65
|
+
) {
|
|
66
|
+
val manager = usbManager ?: return onError("unavailable", "USB no disponible en este dispositivo")
|
|
67
|
+
val device = findDevice(manager, vendorId, productId)
|
|
68
|
+
?: return onError("not_found", "No se encontró una impresora USB conectada")
|
|
69
|
+
if (manager.hasPermission(device)) return onResult(true)
|
|
70
|
+
|
|
71
|
+
val intent = Intent(ACTION_USB_PERMISSION).setPackage(context.packageName)
|
|
72
|
+
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
|
73
|
+
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, flags)
|
|
74
|
+
|
|
75
|
+
val receiver = object : BroadcastReceiver() {
|
|
76
|
+
override fun onReceive(ctx: Context, received: Intent) {
|
|
77
|
+
if (received.action != ACTION_USB_PERMISSION) return
|
|
78
|
+
try { context.unregisterReceiver(this) } catch (_: Exception) { /* ya removido */ }
|
|
79
|
+
onResult(received.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ContextCompat.registerReceiver(
|
|
83
|
+
context, receiver, IntentFilter(ACTION_USB_PERMISSION), ContextCompat.RECEIVER_NOT_EXPORTED
|
|
84
|
+
)
|
|
85
|
+
manager.requestPermission(device, pendingIntent)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stateless: abre → reclama interfaz → escribe chunked → libera → cierra. */
|
|
89
|
+
fun print(vendorId: Int?, productId: Int?, bytes: ByteArray) {
|
|
90
|
+
val manager = usbManager
|
|
91
|
+
?: throw PrinterException("unavailable", "USB no disponible en este dispositivo")
|
|
92
|
+
val device = findDevice(manager, vendorId, productId)
|
|
93
|
+
?: throw PrinterException("not_found", "No se encontró una impresora USB conectada")
|
|
94
|
+
if (!manager.hasPermission(device)) {
|
|
95
|
+
throw PrinterException("permission_denied", "Sin permiso USB — llama a requestPermission() primero")
|
|
96
|
+
}
|
|
97
|
+
val (iface, endpoint) = findPrinterInterface(device)
|
|
98
|
+
?: throw PrinterException("not_found", "El dispositivo no tiene endpoint de salida (bulk OUT)")
|
|
99
|
+
val connection = manager.openDevice(device)
|
|
100
|
+
?: throw PrinterException("connect_failed", "No se pudo abrir el dispositivo USB")
|
|
101
|
+
try {
|
|
102
|
+
if (!connection.claimInterface(iface, true)) {
|
|
103
|
+
throw PrinterException("connect_failed", "No se pudo reclamar la interfaz USB")
|
|
104
|
+
}
|
|
105
|
+
var offset = 0
|
|
106
|
+
while (offset < bytes.size) {
|
|
107
|
+
val len = minOf(CHUNK_SIZE, bytes.size - offset)
|
|
108
|
+
val sent = connection.bulkTransfer(endpoint, bytes, offset, len, TRANSFER_TIMEOUT_MS)
|
|
109
|
+
if (sent < 0) throw PrinterException("write_failed", "bulkTransfer falló en offset $offset")
|
|
110
|
+
offset += sent
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
try { connection.releaseInterface(iface) } catch (_: Exception) { /* best effort */ }
|
|
114
|
+
connection.close()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun findDevice(manager: UsbManager, vendorId: Int?, productId: Int?): UsbDevice? {
|
|
119
|
+
val devices = manager.deviceList.values
|
|
120
|
+
if (vendorId != null && productId != null) {
|
|
121
|
+
return devices.firstOrNull { it.vendorId == vendorId && it.productId == productId }
|
|
122
|
+
}
|
|
123
|
+
return devices.firstOrNull { findPrinterInterface(it) != null }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private fun findPrinterInterface(device: UsbDevice): Pair<UsbInterface, UsbEndpoint>? {
|
|
127
|
+
var fallback: Pair<UsbInterface, UsbEndpoint>? = null
|
|
128
|
+
for (i in 0 until device.interfaceCount) {
|
|
129
|
+
val iface = device.getInterface(i)
|
|
130
|
+
val out = bulkOutEndpoint(iface) ?: continue
|
|
131
|
+
if (iface.interfaceClass == UsbConstants.USB_CLASS_PRINTER) return iface to out
|
|
132
|
+
if (fallback == null) fallback = iface to out
|
|
133
|
+
}
|
|
134
|
+
return fallback
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private fun bulkOutEndpoint(iface: UsbInterface): UsbEndpoint? {
|
|
138
|
+
for (j in 0 until iface.endpointCount) {
|
|
139
|
+
val ep = iface.getEndpoint(j)
|
|
140
|
+
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == UsbConstants.USB_DIR_OUT) {
|
|
141
|
+
return ep
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @devlas/capacitor-thermal-printer — definiciones públicas.
|
|
3
|
+
*
|
|
4
|
+
* Filosofía: el plugin transporta BYTES CRUDOS (ESC/POS ya codificado por la app).
|
|
5
|
+
* No formatea texto, no conoce comandos — eso permite reusar el mismo encoder
|
|
6
|
+
* en todas las plataformas (Electron, Web Serial, WebUSB y esta app nativa).
|
|
7
|
+
*/
|
|
8
|
+
export type PrinterTransport = 'usb' | 'tcp' | 'bluetooth';
|
|
9
|
+
export interface PrinterDevice {
|
|
10
|
+
transport: PrinterTransport;
|
|
11
|
+
/** Nombre legible del dispositivo (productName USB / nombre BT). */
|
|
12
|
+
name?: string;
|
|
13
|
+
vendorId?: number;
|
|
14
|
+
productId?: number;
|
|
15
|
+
/** ID de sesión Android — cambia al reconectar; usar vendorId+productId para persistir. */
|
|
16
|
+
deviceId?: number;
|
|
17
|
+
/** true si el dispositivo tiene un endpoint bulk OUT (puede recibir impresión). */
|
|
18
|
+
canPrint?: boolean;
|
|
19
|
+
/** true si Android ya otorgó permiso USB a este dispositivo. */
|
|
20
|
+
hasPermission?: boolean;
|
|
21
|
+
/** Dirección MAC del dispositivo emparejado. */
|
|
22
|
+
address?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface UsbTarget {
|
|
25
|
+
transport: 'usb';
|
|
26
|
+
/** Si se omiten vendorId/productId, usa la primera impresora USB detectada. */
|
|
27
|
+
vendorId?: number;
|
|
28
|
+
productId?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface TcpTarget {
|
|
31
|
+
transport: 'tcp';
|
|
32
|
+
host: string;
|
|
33
|
+
/** Puerto raw/JetDirect. Default: 9100. */
|
|
34
|
+
port?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface BluetoothTarget {
|
|
37
|
+
transport: 'bluetooth';
|
|
38
|
+
/** MAC del dispositivo (debe estar emparejado en Ajustes del sistema). */
|
|
39
|
+
address: string;
|
|
40
|
+
}
|
|
41
|
+
export type PrintTarget = UsbTarget | TcpTarget | BluetoothTarget;
|
|
42
|
+
/**
|
|
43
|
+
* Códigos de error estables (segundo argumento de reject, `error.code` en JS):
|
|
44
|
+
* `unavailable` | `not_found` | `permission_denied` | `connect_failed`
|
|
45
|
+
* | `write_failed` | `invalid_transport` | `invalid_data`
|
|
46
|
+
*/
|
|
47
|
+
export interface ThermalPrinterPlugin {
|
|
48
|
+
/**
|
|
49
|
+
* Lista dispositivos del transporte. USB: todos los conectados con metadata.
|
|
50
|
+
* Bluetooth: emparejados en el sistema (pide BLUETOOTH_CONNECT si falta).
|
|
51
|
+
* TCP no es listable — la IP la ingresa el usuario.
|
|
52
|
+
*/
|
|
53
|
+
list(options: {
|
|
54
|
+
transport: 'usb' | 'bluetooth';
|
|
55
|
+
}): Promise<{
|
|
56
|
+
devices: PrinterDevice[];
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Pide el permiso del transporte. USB: diálogo del sistema por dispositivo
|
|
60
|
+
* (persiste hasta desconectar, o siempre si el usuario marca "usar por defecto").
|
|
61
|
+
* Bluetooth: permiso runtime BLUETOOTH_CONNECT (API 31+). TCP: siempre granted.
|
|
62
|
+
*/
|
|
63
|
+
requestPermission(options: PrintTarget): Promise<{
|
|
64
|
+
granted: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Envía bytes ESC/POS crudos codificados en base64.
|
|
68
|
+
* Stateless: conecta → escribe (chunked) → cierra. Sin estado que corromper.
|
|
69
|
+
* Los trabajos se serializan en un solo hilo — nunca dos impresiones concurrentes.
|
|
70
|
+
*/
|
|
71
|
+
print(options: PrintTarget & {
|
|
72
|
+
data: string;
|
|
73
|
+
}): Promise<void>;
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @devlas/capacitor-thermal-printer — definiciones públicas.
|
|
3
|
+
*
|
|
4
|
+
* Filosofía: el plugin transporta BYTES CRUDOS (ESC/POS ya codificado por la app).
|
|
5
|
+
* No formatea texto, no conoce comandos — eso permite reusar el mismo encoder
|
|
6
|
+
* en todas las plataformas (Electron, Web Serial, WebUSB y esta app nativa).
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ThermalPrinterPlugin } from './definitions';
|
|
2
|
+
declare const ThermalPrinter: ThermalPrinterPlugin;
|
|
3
|
+
/**
|
|
4
|
+
* Convierte bytes ESC/POS a base64 para `print({ data })`.
|
|
5
|
+
* Seguro para arrays grandes (evita el límite de argumentos de String.fromCharCode).
|
|
6
|
+
*/
|
|
7
|
+
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
8
|
+
export * from './definitions';
|
|
9
|
+
export { ThermalPrinter };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { registerPlugin } from '@capacitor/core';
|
|
2
|
+
const ThermalPrinter = registerPlugin('ThermalPrinter', {
|
|
3
|
+
web: () => import('./web').then((m) => new m.ThermalPrinterWeb()),
|
|
4
|
+
});
|
|
5
|
+
/**
|
|
6
|
+
* Convierte bytes ESC/POS a base64 para `print({ data })`.
|
|
7
|
+
* Seguro para arrays grandes (evita el límite de argumentos de String.fromCharCode).
|
|
8
|
+
*/
|
|
9
|
+
export function bytesToBase64(bytes) {
|
|
10
|
+
let binary = '';
|
|
11
|
+
const CHUNK = 0x8000;
|
|
12
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
13
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
14
|
+
}
|
|
15
|
+
return btoa(binary);
|
|
16
|
+
}
|
|
17
|
+
export * from './definitions';
|
|
18
|
+
export { ThermalPrinter };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
import type { PrinterDevice, PrintTarget, ThermalPrinterPlugin } from './definitions';
|
|
3
|
+
/**
|
|
4
|
+
* Stub web: este plugin es Android-nativo. En navegador usa las APIs del
|
|
5
|
+
* browser directamente (WebUSB / Web Serial) — no las envolvemos aquí para
|
|
6
|
+
* no duplicar lo que la plataforma ya expone a la app web.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ThermalPrinterWeb extends WebPlugin implements ThermalPrinterPlugin {
|
|
9
|
+
list(_options: {
|
|
10
|
+
transport: 'usb' | 'bluetooth';
|
|
11
|
+
}): Promise<{
|
|
12
|
+
devices: PrinterDevice[];
|
|
13
|
+
}>;
|
|
14
|
+
requestPermission(_options: PrintTarget): Promise<{
|
|
15
|
+
granted: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
print(_options: PrintTarget & {
|
|
18
|
+
data: string;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
}
|
package/dist/esm/web.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
/**
|
|
3
|
+
* Stub web: este plugin es Android-nativo. En navegador usa las APIs del
|
|
4
|
+
* browser directamente (WebUSB / Web Serial) — no las envolvemos aquí para
|
|
5
|
+
* no duplicar lo que la plataforma ya expone a la app web.
|
|
6
|
+
*/
|
|
7
|
+
export class ThermalPrinterWeb extends WebPlugin {
|
|
8
|
+
async list(_options) {
|
|
9
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.');
|
|
10
|
+
}
|
|
11
|
+
async requestPermission(_options) {
|
|
12
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.');
|
|
13
|
+
}
|
|
14
|
+
async print(_options) {
|
|
15
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Transportes — matriz, investigación y decisiones de diseño
|
|
2
|
+
|
|
3
|
+
Documento de referencia del adapter. Actualizar cuando se agregue un transporte o cambie una decisión.
|
|
4
|
+
|
|
5
|
+
## Matriz de transportes
|
|
6
|
+
|
|
7
|
+
| Transporte | Estado | Implementación | Notas |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| **USB (OTG)** | ✅ Validado en hardware real (2026-07-06) | `UsbTransport.kt` — `UsbManager` puro | Requiere adaptador OTG real (no todos los USB-A→C lo son). Permiso por dispositivo vía diálogo del sistema — Android lo revoca al re-enchufar o reinstalar la app; el consumidor debe re-pedirlo y reintentar (ver useAndroidUsbPrinter del POS). |
|
|
10
|
+
| **TCP / LAN / WiFi** | ✅ Implementado (sin validar en hardware) | `TcpTransport.kt` — `Socket` puro | Raw 9100 (JetDirect). "WiFi" e "impresora de red" son el mismo transporte. |
|
|
11
|
+
| **Bluetooth SPP** | ✅ Validado en hardware real (2026-07-06) | `BluetoothTransport.kt` — `BluetoothSocket` RFCOMM | Solo dispositivos emparejados. Conexión por trabajo (~1-2 s de latencia); pool keep-alive si molesta en caja. |
|
|
12
|
+
| **Serial RS232** | ⏳ Roadmap | Requiere [mik3y/usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) | Solo aplica a dongles USB-serial (FTDI/CH340/PL2303). Las "impresoras serie" reales llegan por BT-SPP o USB, ya cubiertos. Agregar solo cuando un cliente tenga ese hardware. |
|
|
13
|
+
| **BLE** | ❌ Descartado | — | Las térmicas POS usan BT clásico (SPP), no BLE. Los pocos modelos BLE no justifican la complejidad GATT. |
|
|
14
|
+
| **iOS** | ❌ Sin soporte | — | iOS no tiene API de USB host ni SPP (solo MFi/BLE). TCP sería posible si algún día existe app iOS — el diseño del plugin lo permite sin romper la API. |
|
|
15
|
+
|
|
16
|
+
## Por qué existe este plugin (y no una librería de terceros)
|
|
17
|
+
|
|
18
|
+
Se evaluaron a fondo (código fuente leído, no solo READMEs) las opciones existentes:
|
|
19
|
+
|
|
20
|
+
| Librería / plugin | Qué es | Veredicto |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| [DantSu/ESCPOS-ThermalPrinter-Android](https://github.com/DantSu/ESCPOS-ThermalPrinter-Android) (1.5k ★, MIT) | Librería Android BT+TCP+USB | La más madura, pero su API es de **texto formateado** (`[C]<b>…`), no bytes crudos; su filtro USB exige clase 7 (USB_CLASS_PRINTER) y deja fuera térmicas genéricas vendor-specific. Se rescataron sus patrones (ver abajo) sin heredar la dependencia. |
|
|
23
|
+
| [paystory-de/thermal-printer-cordova-plugin](https://github.com/paystory-de/thermal-printer-cordova-plugin) (Apache-2.0) | Plugin Cordova sobre DantSu | Código limpio y un solo mecanismo de conexión, pero **solo API de texto formateado** — habría obligado a reescribir el encoder de tickets. Permiso USB sin los flags de Android 14+. |
|
|
24
|
+
| `@atomsolution/usb-printer-capacitor` (npm) | Plugin Capacitor USB | Descartado tras auditar el `.tgz`: depende de un SDK propietario (`omnidriver`, terminales Landi), doble mecanismo de conexión inconsistente (UsbManager para connect, DantSu para print) y código muerto. |
|
|
25
|
+
| [KhairoHumsi/Printer-ktx](https://github.com/KhairoHumsi/Printer-ktx) | Port Kotlin de DantSu | Abandonado (última release 2021). |
|
|
26
|
+
| `@e-is/capacitor-bluetooth-serial` | Plugin BT que usa el POS de Devlas hoy | Funciona, pero hubo que **parcharlo a mano en node_modules** (base64 para bytes crudos + pre-poblar scan con emparejados) — parche frágil que se pierde en cada `npm install`. Este adapter lo hace nativo desde el día 1; la migración del POS a este plugin elimina el parche. |
|
|
27
|
+
| [WebUSBReceiptPrinter](https://github.com/NielsLeenheer/WebUSBReceiptPrinter) / WebSerialReceiptPrinter | Librerías browser (no Capacitor) | No aplican al WebView (Chromium no expone WebUSB/Web Serial en Android WebView — [issue 41441927](https://issues.chromium.org/issues/41441927)). Sus ideas de identidad de dispositivo (vendorId+productId) sí se adoptaron. |
|
|
28
|
+
|
|
29
|
+
**Conclusión**: ninguna opción ofrecía bytes crudos + USB sin filtro de clase + permisos Android 14+ + cero dependencias. El costo de mantener ~400 líneas de Kotlin propio es menor que adoptar cualquiera de esos compromisos.
|
|
30
|
+
|
|
31
|
+
## Qué se rescató de cada una
|
|
32
|
+
|
|
33
|
+
- **DantSu**: preferencia por interfaz clase impresora (USB_CLASS_PRINTER) antes del fallback; espera proporcional al tamaño del trabajo antes de cerrar (~1 ms / 16 bytes) para no truncar el búfer de la impresora en TCP/BT.
|
|
34
|
+
- **WebUSB (validado con hardware real en este proyecto)**: fallback a "primera interfaz con endpoint bulk OUT" para térmicas genéricas que no declaran clase 7.
|
|
35
|
+
- **atomsolution**: escritura USB chunked (4096 bytes / timeout 3 s por chunk).
|
|
36
|
+
- **paystory**: metadata rica en el listado de dispositivos; flujo de permiso con BroadcastReceiver (corregido aquí para Android 12/14+: `FLAG_MUTABLE`, `setPackage`, `RECEIVER_NOT_EXPORTED` — ninguno de los plugins lo hace bien).
|
|
37
|
+
- **Parche de e-is en el POS**: bytes como base64 en el bridge JS↔nativo — aquí es el contrato de la API, no un parche.
|
|
38
|
+
|
|
39
|
+
## Decisiones de diseño
|
|
40
|
+
|
|
41
|
+
1. **Bytes crudos, encoder en la app.** El plugin no sabe qué es una boleta. `generarEscPosTicketBoleta()` (o cualquier encoder) vive en la app y se reusa en todas las plataformas.
|
|
42
|
+
2. **Stateless por trabajo.** Conectar→escribir→cerrar en cada `print()`. Elimina toda la clase de bugs de "conexión zombie" (la causa del doble mecanismo inconsistente de atomsolution). El costo es latencia de reconexión BT (~1-2 s); si molesta, el roadmap contempla keep-alive opcional.
|
|
43
|
+
3. **Un solo hilo ejecutor.** Serializa trabajos (nunca dos impresiones simultáneas al mismo dispositivo) y saca el I/O bloqueante del main thread (obligatorio para TCP: `NetworkOnMainThreadException`).
|
|
44
|
+
4. **Códigos de error estables** (`unavailable`, `not_found`, `permission_denied`, `connect_failed`, `write_failed`) — la app mapea a mensajes de usuario sin parsear strings.
|
|
45
|
+
5. **Sin auto-prompt de permiso dentro de `print()`.** Si falta permiso USB, rechaza con `permission_denied`; el flujo de configuración (Ajustes) llama a `requestPermission()` explícitamente. Una venta nunca se bloquea en un diálogo inesperado.
|
|
46
|
+
|
|
47
|
+
## Roadmap
|
|
48
|
+
|
|
49
|
+
- [ ] **Serial RS232** vía `usb-serial-for-android` — cuando exista un cliente con ese hardware.
|
|
50
|
+
- [ ] **Keep-alive opcional BT** — pool de sockets con TTL si la latencia por ticket molesta en caja.
|
|
51
|
+
- [ ] **Migrar el BT del POS Devlas** de `@e-is/capacitor-bluetooth-serial` (parchado) a este plugin — elimina el parche de node_modules.
|
|
52
|
+
- [ ] **iOS TCP** — solo si algún día hay app iOS.
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devlas/capacitor-thermal-printer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Adaptador universal de impresión térmica ESC/POS para Capacitor Android: USB (OTG), TCP/LAN/WiFi y Bluetooth SPP con una sola API de bytes crudos.",
|
|
5
|
+
"main": "dist/esm/index.js",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/esm/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/esm/index.d.ts",
|
|
11
|
+
"import": "./dist/esm/index.js",
|
|
12
|
+
"default": "./dist/esm/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"android/src/main/",
|
|
17
|
+
"android/build.gradle",
|
|
18
|
+
"dist/",
|
|
19
|
+
"src/",
|
|
20
|
+
"docs/"
|
|
21
|
+
],
|
|
22
|
+
"capacitor": {
|
|
23
|
+
"android": {
|
|
24
|
+
"src": "android"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"clean": "rimraf dist",
|
|
30
|
+
"prepack": "npm run build",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"author": "Devlas SpA <ti@devlas.cl> (https://devlas.cl)",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/devlas-cl/capacitor-thermal-printer.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/devlas-cl/capacitor-thermal-printer/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/devlas-cl/capacitor-thermal-printer#readme",
|
|
42
|
+
"keywords": [
|
|
43
|
+
"capacitor",
|
|
44
|
+
"capacitor-plugin",
|
|
45
|
+
"thermal-printer",
|
|
46
|
+
"impresora-termica",
|
|
47
|
+
"esc-pos",
|
|
48
|
+
"escpos",
|
|
49
|
+
"usb",
|
|
50
|
+
"otg",
|
|
51
|
+
"tcp",
|
|
52
|
+
"lan",
|
|
53
|
+
"wifi",
|
|
54
|
+
"bluetooth",
|
|
55
|
+
"spp",
|
|
56
|
+
"android",
|
|
57
|
+
"pos",
|
|
58
|
+
"point-of-sale",
|
|
59
|
+
"punto-de-venta",
|
|
60
|
+
"boleta",
|
|
61
|
+
"ticket"
|
|
62
|
+
],
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@capacitor/core": ">=5.0.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@capacitor/core": "^8.0.0",
|
|
68
|
+
"typescript": "~5.7.0"
|
|
69
|
+
},
|
|
70
|
+
"license": "MIT"
|
|
71
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @devlas/capacitor-thermal-printer — definiciones públicas.
|
|
3
|
+
*
|
|
4
|
+
* Filosofía: el plugin transporta BYTES CRUDOS (ESC/POS ya codificado por la app).
|
|
5
|
+
* No formatea texto, no conoce comandos — eso permite reusar el mismo encoder
|
|
6
|
+
* en todas las plataformas (Electron, Web Serial, WebUSB y esta app nativa).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PrinterTransport = 'usb' | 'tcp' | 'bluetooth'
|
|
10
|
+
|
|
11
|
+
export interface PrinterDevice {
|
|
12
|
+
transport: PrinterTransport
|
|
13
|
+
/** Nombre legible del dispositivo (productName USB / nombre BT). */
|
|
14
|
+
name?: string
|
|
15
|
+
// ── USB ──
|
|
16
|
+
vendorId?: number
|
|
17
|
+
productId?: number
|
|
18
|
+
/** ID de sesión Android — cambia al reconectar; usar vendorId+productId para persistir. */
|
|
19
|
+
deviceId?: number
|
|
20
|
+
/** true si el dispositivo tiene un endpoint bulk OUT (puede recibir impresión). */
|
|
21
|
+
canPrint?: boolean
|
|
22
|
+
/** true si Android ya otorgó permiso USB a este dispositivo. */
|
|
23
|
+
hasPermission?: boolean
|
|
24
|
+
// ── Bluetooth ──
|
|
25
|
+
/** Dirección MAC del dispositivo emparejado. */
|
|
26
|
+
address?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UsbTarget {
|
|
30
|
+
transport: 'usb'
|
|
31
|
+
/** Si se omiten vendorId/productId, usa la primera impresora USB detectada. */
|
|
32
|
+
vendorId?: number
|
|
33
|
+
productId?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TcpTarget {
|
|
37
|
+
transport: 'tcp'
|
|
38
|
+
host: string
|
|
39
|
+
/** Puerto raw/JetDirect. Default: 9100. */
|
|
40
|
+
port?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BluetoothTarget {
|
|
44
|
+
transport: 'bluetooth'
|
|
45
|
+
/** MAC del dispositivo (debe estar emparejado en Ajustes del sistema). */
|
|
46
|
+
address: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type PrintTarget = UsbTarget | TcpTarget | BluetoothTarget
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Códigos de error estables (segundo argumento de reject, `error.code` en JS):
|
|
53
|
+
* `unavailable` | `not_found` | `permission_denied` | `connect_failed`
|
|
54
|
+
* | `write_failed` | `invalid_transport` | `invalid_data`
|
|
55
|
+
*/
|
|
56
|
+
export interface ThermalPrinterPlugin {
|
|
57
|
+
/**
|
|
58
|
+
* Lista dispositivos del transporte. USB: todos los conectados con metadata.
|
|
59
|
+
* Bluetooth: emparejados en el sistema (pide BLUETOOTH_CONNECT si falta).
|
|
60
|
+
* TCP no es listable — la IP la ingresa el usuario.
|
|
61
|
+
*/
|
|
62
|
+
list(options: { transport: 'usb' | 'bluetooth' }): Promise<{ devices: PrinterDevice[] }>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pide el permiso del transporte. USB: diálogo del sistema por dispositivo
|
|
66
|
+
* (persiste hasta desconectar, o siempre si el usuario marca "usar por defecto").
|
|
67
|
+
* Bluetooth: permiso runtime BLUETOOTH_CONNECT (API 31+). TCP: siempre granted.
|
|
68
|
+
*/
|
|
69
|
+
requestPermission(options: PrintTarget): Promise<{ granted: boolean }>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Envía bytes ESC/POS crudos codificados en base64.
|
|
73
|
+
* Stateless: conecta → escribe (chunked) → cierra. Sin estado que corromper.
|
|
74
|
+
* Los trabajos se serializan en un solo hilo — nunca dos impresiones concurrentes.
|
|
75
|
+
*/
|
|
76
|
+
print(options: PrintTarget & { data: string }): Promise<void>
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { registerPlugin } from '@capacitor/core'
|
|
2
|
+
import type { ThermalPrinterPlugin } from './definitions'
|
|
3
|
+
|
|
4
|
+
const ThermalPrinter = registerPlugin<ThermalPrinterPlugin>('ThermalPrinter', {
|
|
5
|
+
web: () => import('./web').then((m) => new m.ThermalPrinterWeb()),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convierte bytes ESC/POS a base64 para `print({ data })`.
|
|
10
|
+
* Seguro para arrays grandes (evita el límite de argumentos de String.fromCharCode).
|
|
11
|
+
*/
|
|
12
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
13
|
+
let binary = ''
|
|
14
|
+
const CHUNK = 0x8000
|
|
15
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
16
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
|
|
17
|
+
}
|
|
18
|
+
return btoa(binary)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export * from './definitions'
|
|
22
|
+
export { ThermalPrinter }
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core'
|
|
2
|
+
import type { PrinterDevice, PrintTarget, ThermalPrinterPlugin } from './definitions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stub web: este plugin es Android-nativo. En navegador usa las APIs del
|
|
6
|
+
* browser directamente (WebUSB / Web Serial) — no las envolvemos aquí para
|
|
7
|
+
* no duplicar lo que la plataforma ya expone a la app web.
|
|
8
|
+
*/
|
|
9
|
+
export class ThermalPrinterWeb extends WebPlugin implements ThermalPrinterPlugin {
|
|
10
|
+
async list(_options: { transport: 'usb' | 'bluetooth' }): Promise<{ devices: PrinterDevice[] }> {
|
|
11
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async requestPermission(_options: PrintTarget): Promise<{ granted: boolean }> {
|
|
15
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async print(_options: PrintTarget & { data: string }): Promise<void> {
|
|
19
|
+
throw this.unimplemented('Solo Android. En navegador usa WebUSB o Web Serial.')
|
|
20
|
+
}
|
|
21
|
+
}
|