+
+ Add Network Printer
+ class="flex-1 border-2 border-dashed border-gray-300 bg-gray-50 rounded-lg px-4 py-3 text-gray-600 hover:border-gray-400 hover:bg-gray-100 cursor-pointer flex items-center justify-center gap-2 transition-colors"
+ >
+
+ Add Network Printer
+
-
-
-
-
-
- {{ toast.message }}
-
-
-
+
diff --git a/go.mod b/go.mod
index df3cc761..6d7098fa 100644
--- a/go.mod
+++ b/go.mod
@@ -7,10 +7,12 @@ require (
github.com/gofiber/fiber/v3 v3.1.0
github.com/google/gousb v1.1.3
github.com/sirupsen/logrus v1.9.4
+ github.com/tinygo-org/cbgo v0.0.4
github.com/wailsapp/wails/v2 v2.12.0
github.com/yusufpapurcu/wmi v1.2.4
golang.org/x/sys v0.41.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
+ tinygo.org/x/bluetooth v0.15.0
)
require (
@@ -37,7 +39,12 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96 // indirect
github.com/samber/lo v1.52.0 // indirect
+ github.com/soypat/cyw43439 v0.1.0 // indirect
+ github.com/soypat/lneto v0.1.0 // indirect
+ github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect
+ github.com/tinygo-org/pio v0.3.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -46,8 +53,11 @@ require (
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.11.0 => /Users/steph/go/pkg/mod
+
+replace tinygo.org/x/bluetooth v0.15.0 => ./tinygo-bluetooth
diff --git a/go.sum b/go.sum
index 0dc5638c..a9047d0b 100644
--- a/go.sum
+++ b/go.sum
@@ -31,6 +31,7 @@ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -63,14 +64,28 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96 h1:IXxzj3yjfDNXZJ35foY+RpFShqPsZZ81hhCckgfh5PI=
+github.com/saltosystems/winrt-go v0.0.0-20260317170058-9c2fec580d96/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
+github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/soypat/cyw43439 v0.1.0 h1:3Nyqg2LSndhCYgCr2VXuL2nn73vyaJXAnD02veMoLvA=
+github.com/soypat/cyw43439 v0.1.0/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc=
+github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I=
+github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U=
+github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA=
+github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU=
+github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk=
+github.com/tinygo-org/pio v0.3.0 h1:opEnOtw58KGB4RJD3/n/Rd0/djYGX3DeJiXLI6y/yDI=
+github.com/tinygo-org/pio v0.3.0/go.mod h1:wf6c6lKZp+pQOzKKcpzchmRuhiMc27ABRuo7KVnaMFU=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -95,9 +110,12 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
+golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/printer/model.go b/printer/model.go
index da070a64..07e6956a 100644
--- a/printer/model.go
+++ b/printer/model.go
@@ -14,6 +14,7 @@ type PrinterConnectionType int
const (
PrinterTypeUSB PrinterConnectionType = iota
PrinterTypeLAN
+ PrinterTypeBluetooth
)
const (
@@ -51,6 +52,10 @@ type Printer struct {
// LAN fields
tcpConn net.Conn
jobs chan Job
+ // Bluetooth fields
+ bluetoothAddress string
+ btDevPath string // cached /dev/rfcommX path (Linux only; empty on other platforms)
+ btConn net.Conn
}
type PrinterType string
diff --git a/printer/printer.go b/printer/printer.go
index ca2e78e8..dfcd1a7d 100644
--- a/printer/printer.go
+++ b/printer/printer.go
@@ -12,6 +12,11 @@ import (
)
func newPrinter(id string) *Printer {
+ // Check if this is a Bluetooth printer
+ if address, ok := DecodeBluetoothPrinterID(id); ok {
+ return newBlueToothPrinter(address)
+ }
+
// Check if this is a LAN printer
if lanIP, ok := DecodeLANPrinterID(id); ok {
p := &Printer{
@@ -61,6 +66,10 @@ func (p *Printer) Write(data []byte) error {
logger.Debugf("Writing %d bytes to printer %s", len(data), p.idToString())
+ if p.connectionType == PrinterTypeBluetooth {
+ return p.writeBluetooth(data)
+ }
+
if p.connectionType == PrinterTypeLAN {
if err := p.tcpConn.SetWriteDeadline(time.Now().Add(WriteTimeout)); err != nil {
p.closeDeviceLocked()
@@ -107,7 +116,10 @@ func (p *Printer) loop() {
}
}
func (p *Printer) ensureOpen() error {
- if p.connectionType == PrinterTypeLAN {
+ switch p.connectionType {
+ case PrinterTypeBluetooth:
+ return p.ensureOpenBluetoothLocked()
+ case PrinterTypeLAN:
return p.ensureOpenLANLocked()
}
return p.ensureOpenUSBLocked()
@@ -244,6 +256,15 @@ func (p *Printer) close() {
}
func (p *Printer) closeDeviceLocked() {
+ if p.connectionType == PrinterTypeBluetooth {
+ if p.btConn != nil {
+ _ = p.btConn.Close()
+ p.btConn = nil
+ logger.Debugf("BT printer %s connection closed", p.idToString())
+ }
+ return
+ }
+
if p.connectionType == PrinterTypeLAN {
if p.tcpConn != nil {
_ = p.tcpConn.Close()
@@ -270,8 +291,11 @@ func (p *Printer) closeDeviceLocked() {
}
func (p *Printer) idToString() string {
- if p.connectionType == PrinterTypeLAN {
- return fmt.Sprintf("LAN:%s", p.lanIP)
+ switch p.connectionType {
+ case PrinterTypeBluetooth:
+ return fmt.Sprintf("BT:%s", p.bluetoothAddress)
+ case PrinterTypeLAN:
+ return fmt.Sprintf(" LAN:%s", p.lanIP)
}
if p.id != nil {
return fmt.Sprintf("USB:%s, %v", p.id.Serial, p.id)
diff --git a/printer/printer_bluetooth.go b/printer/printer_bluetooth.go
new file mode 100644
index 00000000..3f400c50
--- /dev/null
+++ b/printer/printer_bluetooth.go
@@ -0,0 +1,56 @@
+package printer
+
+import (
+ "epos-proxy/bluetooth"
+ "epos-proxy/logger"
+ "fmt"
+ "time"
+)
+
+func newBlueToothPrinter(address string) *Printer {
+ p := &Printer{
+ connectionType: PrinterTypeBluetooth,
+ bluetoothAddress: address,
+ jobs: make(chan Job, QueueSize),
+ }
+ logger.Debugf("Created new Bluetooth printer instance for address: %s", address)
+ go p.loop()
+ return p
+}
+
+func (p *Printer) ensureOpenBluetoothLocked() error {
+ if p.btConn != nil {
+ logger.Debugf("BT printer %s already connected", p.idToString())
+ return nil
+ }
+
+ conn, err := bluetooth.BTManager.Dial(p.bluetoothAddress)
+ if err != nil {
+ return fmt.Errorf("failed to connect to BT printer %s: %w", p.bluetoothAddress, err)
+ }
+
+ p.btConn = conn
+
+ if devPath, channel, ok := bluetooth.BTManager.GetCachedBinding(p.bluetoothAddress); ok {
+ p.btDevPath = devPath
+ logger.Infof("BT printer %s connected via %s (channel %d)",
+ p.bluetoothAddress, devPath, channel)
+ } else {
+ logger.Infof("BT printer %s connected (raw socket)", p.bluetoothAddress)
+ }
+
+ return nil
+}
+
+func (p *Printer) writeBluetooth(data []byte) error {
+ if err := p.btConn.SetWriteDeadline(time.Now().Add(WriteTimeout)); err != nil {
+ p.closeDeviceLocked()
+ return fmt.Errorf("failed to set write deadline for BT printer %s: %w", p.idToString(), err)
+ }
+ if _, err := p.btConn.Write(data); err != nil {
+ p.closeDeviceLocked()
+ return fmt.Errorf("failed to write to BT printer %s: %w", p.idToString(), err)
+ }
+ logger.Debugf("Successfully wrote to BT printer %s", p.idToString())
+ return nil
+}
diff --git a/printer/printer_id.go b/printer/printer_id.go
index 0309789b..5b74a141 100644
--- a/printer/printer_id.go
+++ b/printer/printer_id.go
@@ -93,3 +93,18 @@ func DecodeLANPrinterID(id string) (string, bool) {
return string(decoded[2:]), true
}
+
+func EncodeBluetoothPrinterID(mac string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte("b:" + mac))
+}
+
+func DecodeBluetoothPrinterID(id string) (string, bool) {
+ decoded, err := base64.RawURLEncoding.DecodeString(id)
+ if err != nil {
+ return "", false
+ }
+ if len(decoded) < 3 || decoded[1] != ':' || decoded[0] != 'b' {
+ return "", false
+ }
+ return string(decoded[2:]), true
+}
diff --git a/scripts/setup-tinygo-bluetooth.sh b/scripts/setup-tinygo-bluetooth.sh
new file mode 100755
index 00000000..4163544b
--- /dev/null
+++ b/scripts/setup-tinygo-bluetooth.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# scripts/setup-tinygo-bluetooth.sh
+#
+# Clones tinygo.org/x/bluetooth v0.15.0 into ./tinygo-bluetooth/ and applies
+# all patch files from ./tinygo-bluetooth-patches/.
+#
+# Usage:
+# ./scripts/setup-tinygo-bluetooth.sh # skip if already present
+# ./scripts/setup-tinygo-bluetooth.sh --force # re-clone and re-apply
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TARGET_DIR="${REPO_ROOT}/tinygo-bluetooth"
+PATCHES_DIR="${REPO_ROOT}/tinygo-bluetooth-patches"
+UPSTREAM_URL="https://github.com/tinygo-org/bluetooth.git"
+UPSTREAM_TAG="v0.15.0"
+
+FORCE=false
+if [[ "${1:-}" == "--force" ]]; then
+ FORCE=true
+fi
+
+# ── Clone upstream if needed ──────────────────────────────────────────────────
+if [[ -f "${TARGET_DIR}/go.mod" ]] && [[ "${FORCE}" == "false" ]]; then
+ echo "✓ tinygo-bluetooth already present (use --force to re-clone)"
+else
+ echo "▶ Cloning tinygo-org/bluetooth ${UPSTREAM_TAG}..."
+
+ # Remove existing content so we get a clean clone
+ rm -rf "${TARGET_DIR}"
+ mkdir -p "${TARGET_DIR}"
+
+ git clone \
+ --depth 1 \
+ --branch "${UPSTREAM_TAG}" \
+ "${UPSTREAM_URL}" \
+ "${TARGET_DIR}" \
+ --quiet
+
+ # Remove the upstream .git — this is just a plain source directory
+ rm -rf "${TARGET_DIR}/.git"
+
+ echo " ✓ Cloned ${UPSTREAM_TAG}"
+fi
+
+# ── Apply patches ─────────────────────────────────────────────────────────────
+if [[ ! -d "${PATCHES_DIR}" ]]; then
+ echo "⚠ No patches directory found at ${PATCHES_DIR}, skipping"
+ exit 0
+fi
+
+PATCH_FILES=("${PATCHES_DIR}"/*.patch)
+if [[ ${#PATCH_FILES[@]} -eq 0 ]] || [[ ! -f "${PATCH_FILES[0]}" ]]; then
+ echo "⚠ No .patch files found in ${PATCHES_DIR}"
+ exit 0
+fi
+
+echo "▶ Applying ${#PATCH_FILES[@]} patch(es)..."
+for patch_file in "${PATCH_FILES[@]}"; do
+ echo " ✎ $(basename "${patch_file}")"
+
+ if patch \
+ --directory="${TARGET_DIR}" \
+ --strip=1 \
+ --forward \
+ --dry-run \
+ < "${patch_file}" >/dev/null 2>&1; then
+
+ patch \
+ --directory="${TARGET_DIR}" \
+ --strip=1 \
+ --forward \
+ < "${patch_file}"
+
+ else
+ echo " ✓ Already applied (or cannot be applied), skipping"
+ fi
+done
+
+echo "✅ tinygo-bluetooth is ready (${UPSTREAM_TAG} + patches)"
diff --git a/tinygo-bluetooth-patches/gattc_darwin.patch b/tinygo-bluetooth-patches/gattc_darwin.patch
new file mode 100644
index 00000000..604f1c83
--- /dev/null
+++ b/tinygo-bluetooth-patches/gattc_darwin.patch
@@ -0,0 +1,16 @@
+--- a/gattc_darwin.go
++++ b/gattc_darwin.go
+@@ -219,6 +219,13 @@ func (c DeviceCharacteristic) UUID() UUID {
+ return c.uuidWrapper
+ }
+
++// Properties returns the CBCharacteristicProperties bitmask for this characteristic.
++// Use cbgo.CharacteristicPropertyWrite and cbgo.CharacteristicPropertyWriteWithoutResponse
++// to test for write capability.
++func (c DeviceCharacteristic) Properties() cbgo.CharacteristicProperties {
++ return c.characteristic.Properties()
++}
++
+ // Write replaces the characteristic value with a new value. The
+ // call will return after all data has been written.
+ func (c DeviceCharacteristic) Write(p []byte) (n int, err error) {