diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f971b58b..dca1d178 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -43,6 +43,12 @@ jobs: with: submodules: recursive + - name: Setup TinyGo Bluetooth + shell: bash + run: | + chmod +x ./scripts/setup-tinygo-bluetooth.sh + ./scripts/setup-tinygo-bluetooth.sh + - name: Build wails uses: ./.github/actions id: build diff --git a/.gitignore b/.gitignore index 0d9656f4..2c3be77b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ frontend/dist /frontend/wailsjs/ .vscode/ package.json.md5 + +# Cloned from upstream by scripts/setup-tinygo-bluetooth.sh — not tracked in git +tinygo-bluetooth/ diff --git a/app.go b/app.go index 8932ebe0..21cc1484 100644 --- a/app.go +++ b/app.go @@ -7,6 +7,7 @@ import ( "runtime" "time" + "epos-proxy/bluetooth" "epos-proxy/config" "epos-proxy/logger" "epos-proxy/printer" @@ -55,6 +56,7 @@ func (a *App) startup(ctx context.Context) { a.config = cfg a.printerManager = printer.NewManager() + bluetooth.InitBluetoothManager(cfg) port, err := cfg.ResolvePort() if err != nil { @@ -78,6 +80,8 @@ type Printer struct { Id string `json:"id"` IsLAN bool `json:"isLAN"` LANIp string `json:"lanIp,omitempty"` + IsBT bool `json:"isBT"` + BTMac string `json:"btMac,omitempty"` Online bool `json:"online"` Type string `json:"type"` } @@ -154,6 +158,25 @@ func (a *App) Status() Status { }) } + // Bluetooth printers from config + btPrinters := a.config.GetBluetoothPrinters() + for _, btCfg := range btPrinters { + id := printer.EncodeBluetoothPrinterID(btCfg.Address) + name := btCfg.Name + if name == "" { + name = "Bluetooth - " + btCfg.Address + + } + printers = append(printers, Printer{ + Id: id, + Name: name, + Ip: a.GetPrinterIp(id), + IsBT: true, + BTMac: btCfg.Address, + Type: string(printer.PrinterTypeReceipt), + }) + } + return Status{ ServerRunning: a.webserver.Running(), DefaultIp: fmt.Sprintf("127.0.0.1:%d", a.webserver.Port), @@ -272,3 +295,76 @@ func (a *App) DisableAutostart() error { return nil } + +// --- Bluetooth printer methods --- +func (a *App) ScanBluetoothPrinters() ([]bluetooth.BluetoothPrinterInfo, error) { + logger.Debug("Scanning for Bluetooth devices") + devices, err := bluetooth.ScanBluetoothPrinters() + if err != nil { + logger.Errorf("Bluetooth scan failed: %v", err) + return nil, err + } + return devices, nil +} + +func (a *App) CheckBluetoothDependencies() []bluetooth.DependencyStatus { + return bluetooth.CheckDependencies() +} + +func (a *App) AddBluetoothPrinter(address, name string) error { + logger.Debugf("Adding Bluetooth printer: %s (%s)", address, name) + address = bluetooth.NormalizeAddress(address) + if err := bluetooth.ValidateAddress(address); err != nil { + logger.Errorf("Invalid MAC address: %v", err) + return err + } + + if err := bluetooth.BTManager.CheckBluetoothPrinter(address); err != nil { + logger.Errorf("Errow while check printer %v", err) + return err + } + + if err := a.config.AddBluetoothPrinter(address, name); err != nil { + logger.Errorf("Failed to save Bluetooth printer: %v", err) + return fmt.Errorf("failed to save Bluetooth printer: %w", err) + } + + logger.Debugf("Bluetooth printer added: %s (%s)", address, name) + return nil +} + +func (a *App) ConfirmRemoveBluetoothPrinter(address string) (bool, error) { + logger.Debugf("Remove Bluetooth printer requested: %s", address) + + result, err := wailsruntime.MessageDialog(a.ctx, wailsruntime.MessageDialogOptions{ + Type: wailsruntime.QuestionDialog, + Title: "Remove Printer", + Message: fmt.Sprintf("Are you sure you want to remove the Bluetooth printer %s?", address), + Buttons: []string{"Cancel", "Confirm"}, + DefaultButton: "Cancel", + CancelButton: "Cancel", + }) + if err != nil { + logger.Errorf("failed to show confirmation dialog: %v", err) + return false, fmt.Errorf("failed to show confirmation dialog: %w", err) + } + if result == "Confirm" || result == "Yes" { + if err := a.config.RemoveBluetoothPrinter(address); err != nil { + logger.Errorf("Failed to remove Bluetooth printer: %v", err) + return false, fmt.Errorf("failed to remove Bluetooth printer: %v", err) + } + logger.Debugf("Bluetooth printer removed successfully") + return true, nil + } + logger.Debugf("Remove Bluetooth printer cancelled") + return false, nil +} + +func (a *App) CheckBluetoothPrinterStatus(address string) bool { + logger.Debugf("Checking Bluetooth printer status: %s", address) + if err := bluetooth.BTManager.CheckBluetoothPrinter(address); err != nil { + logger.Errorf("Errow while check printer %v", err) + return false + } + return true +} diff --git a/bluetooth/ble.go b/bluetooth/ble.go new file mode 100644 index 00000000..4b8f6466 --- /dev/null +++ b/bluetooth/ble.go @@ -0,0 +1,291 @@ +//go:build !darwin || cgo + +package bluetooth + +import ( + "context" + "fmt" + "io" + "net" + "runtime" + "strings" + "sync" + "time" + + "epos-proxy/logger" + "tinygo.org/x/bluetooth" +) + +var adapter = bluetooth.DefaultAdapter +var adapterOnce sync.Once +var adapterEnableErr error + +const defaultBLEWriteChunk = 180 + +func enableAdapter() error { + adapterOnce.Do(func() { + adapterEnableErr = adapter.Enable() + if adapterEnableErr != nil { + logger.Errorf("BT/ble: failed to enable bluetooth adapter: %v", adapterEnableErr) + } else { + logger.Debugf("BT/ble: bluetooth adapter enabled successfully") + } + }) + return adapterEnableErr +} + +type BLETransport struct{} + +func (t *BLETransport) Name() string { + return "BLE" +} + +func (t *BLETransport) IsAvailable() bool { + return enableAdapter() == nil +} + +func (t *BLETransport) Dial(ctx context.Context, address string) (net.Conn, error) { + address = NormalizeAddress(address) + // macOS CoreBluetooth identifies BLE peripherals by UUID rather than exposing + // their Bluetooth MAC address. Linux and Windows expose a Bluetooth address for + // BLE devices, but connections are still established through BLE peripheral + // discovery rather than directly dialing an address. + + dialAddress := address + if !UuidRegexp.MatchString(address) && runtime.GOOS == "darwin" { + resolved, ok := resolveMACToBLEUUID(address) + if ok { + logger.Debugf("BT/ble: resolved MAC %s to BLE UUID %s", address, resolved) + dialAddress = resolved + } else { + return nil, fmt.Errorf("BT/ble: cannot dial MAC address %s directly on macOS without UUID resolution", address) + } + } + + return dialBLE(ctx, dialAddress) +} + +func (t *BLETransport) Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) { + return scanLiveBLEPrinters(ctx, 3*time.Second) +} + +// Scans for BLE devices and returns a list of discovered printers. +func scanLiveBLEPrinters(ctx context.Context, timeout time.Duration) ([]BluetoothPrinterInfo, error) { + logger.Debugf("BT/ble: starting live BLE scan for %v", timeout) + + var mu sync.Mutex + var devices []BluetoothPrinterInfo + seen := make(map[string]bool) + + scanDone := make(chan error, 1) + go func() { + err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) { + name := result.LocalName() + if name == "" { + return + } + addrStr := result.Address.String() + + mu.Lock() + defer mu.Unlock() + if seen[addrStr] { + return + } + + seen[addrStr] = true + devices = append(devices, BluetoothPrinterInfo{ + Address: addrStr, + Name: name, + Device: getDeviceType(name), + }) + }) + scanDone <- err + }() + + select { + case <-ctx.Done(): + _ = adapter.StopScan() + return nil, ctx.Err() + case <-time.After(timeout): + _ = adapter.StopScan() + } + + select { + case err := <-scanDone: + if err != nil { + logger.Errorf("BT/ble: scan failed: %v", err) + } + case <-time.After(3 * time.Second): + logger.Warnf("BT/ble: scan goroutine did not exit within backstop") + } + + mu.Lock() + defer mu.Unlock() + return devices, nil +} + +type bleConn struct { + device bluetooth.Device + char *bluetooth.DeviceCharacteristic + address string + writeTimeout time.Duration + readDeadline time.Time + connected bool +} + +func (c *bleConn) Read(b []byte) (int, error) { + return 0, io.EOF +} + +func (c *bleConn) Write(b []byte) (int, error) { + if c.char == nil { + return 0, fmt.Errorf("BT/ble: connection closed") + } + + logger.Debugf("BT/ble: writing %d bytes to %s", len(b), c.address) + + // Split write into chunks because BLE has MTU limit. + // Safe MTU chunk size is 180 bytes. + totalWritten := 0 + + for totalWritten < len(b) { + end := totalWritten + defaultBLEWriteChunk + if end > len(b) { + end = len(b) + } + chunk := b[totalWritten:end] + + n, err := c.char.Write(chunk) + if err != nil { + n, err = c.char.WriteWithoutResponse(chunk) + if err != nil { + return totalWritten, fmt.Errorf("BT/ble: write failed: %w", err) + } + time.Sleep(15 * time.Millisecond) + } + totalWritten += n + } + + return totalWritten, nil +} + +func (c *bleConn) Close() error { + if c.connected { + err := c.device.Disconnect() + c.connected = false + c.char = nil + return err + } + return nil +} + +func (c *bleConn) LocalAddr() net.Addr { + return netAddrPlaceholder{net: "ble", addr: "local-ble"} +} + +func (c *bleConn) RemoteAddr() net.Addr { + return netAddrPlaceholder{net: "ble", addr: c.address} +} + +func (c *bleConn) SetDeadline(t time.Time) error { + return nil +} + +func (c *bleConn) SetReadDeadline(t time.Time) error { + c.readDeadline = t + return nil +} + +func (c *bleConn) SetWriteDeadline(t time.Time) error { + if t.IsZero() { + c.writeTimeout = 0 + } else { + c.writeTimeout = time.Until(t) + } + return nil +} + +func dialBLE(ctx context.Context, address string) (net.Conn, error) { + type result struct { + conn net.Conn + err error + } + ch := make(chan result, 1) + go func() { + conn, err := dialBLEInternal(address) + ch <- result{conn, err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case r := <-ch: + return r.conn, r.err + case <-time.After(15 * time.Second): + return nil, fmt.Errorf("BT/ble: connect to %s timed out after 15s", address) + } +} + +func dialBLEInternal(address string) (conn net.Conn, err error) { + logger.Debugf("BT/ble: connecting to %s", address) + + var addr bluetooth.Address + addr.Set(address) + + device, err := adapter.Connect(addr, bluetooth.ConnectionParams{}) + if err != nil { + return nil, fmt.Errorf("BT/ble: failed to connect to %s: %w", address, err) + } + + success := false + defer func() { + if !success { + _ = device.Disconnect() + } + }() + + services, err := device.DiscoverServices(nil) + if err != nil { + return nil, fmt.Errorf("BT/ble: failed to discover services: %w", err) + } + + var char *bluetooth.DeviceCharacteristic + for _, service := range services { + char, err = discoverPrinterCharacteristic(service) + if err != nil { + logger.Debugf("BT/ble: skipping service %s: %v", service.UUID(), err) + continue + } + if char != nil { + break + } + } + + if char == nil { + return nil, fmt.Errorf("BT/ble: no writable characteristic found") + } + + logger.Debugf("BT/ble: connected to %s using characteristic %s", address, char.UUID()) + + success = true + return &bleConn{ + device: device, + char: char, + address: address, + writeTimeout: 10 * time.Second, + connected: true, + }, nil +} + +var printerKeywords = []string{"print", "printer", "pos", "epson", "star", "thermal", "58", "80"} + +func getDeviceType(name string) string { + name = strings.ToLower(name) + + for _, keyword := range printerKeywords { + if strings.Contains(name, keyword) { + return "printer" + } + } + + return "other" +} diff --git a/bluetooth/ble_darwin.go b/bluetooth/ble_darwin.go new file mode 100644 index 00000000..b0dd9882 --- /dev/null +++ b/bluetooth/ble_darwin.go @@ -0,0 +1,150 @@ +//go:build darwin && cgo + +package bluetooth + +import ( + "context" + "encoding/json" + "epos-proxy/logger" + "fmt" + "os/exec" + "strings" + "time" + + cbgo "github.com/tinygo-org/cbgo" + tinygoBT "tinygo.org/x/bluetooth" +) + +// On Darwin+CGO,it uses the Properties() method +// exposed by our local fork of tinygo.org/x/bluetooth to pick the first +// characteristic that supports Write or WriteWithoutResponse. +func discoverPrinterCharacteristic(service tinygoBT.DeviceService) (*tinygoBT.DeviceCharacteristic, error) { + chars, err := service.DiscoverCharacteristics(nil) + if err != nil { + return nil, err + } + if len(chars) == 0 { + return nil, fmt.Errorf("BT/ble: service %s exposes no characteristics", service.UUID()) + } + + writeProps := cbgo.CharacteristicPropertyWrite | cbgo.CharacteristicPropertyWriteWithoutResponse + + var fallback *tinygoBT.DeviceCharacteristic + for i := range chars { + c := &chars[i] + props := c.Properties() + logger.Debugf("BT/ble: characteristic %s props=0x%x", c.UUID(), int(props)) + if props&writeProps != 0 { + logger.Debugf("BT/ble: selected writable characteristic %s", c.UUID()) + return c, nil + } + if fallback == nil { + fallback = c + } + } + + logger.Debugf("BT/ble: no writable characteristic found, using fallback %s", fallback.UUID()) + return fallback, nil +} + +// Resolves a Classic MAC address to a BLE UUID on macOS by +// querying system_profiler for the device's Bluetooth name, then scanning for a +// BLE device with a matching or similar name. +func resolveMACToBLEUUID(mac string) (string, bool) { + btName := lookupBluetoothName(mac) + if btName == "" { + return "", false + } + sanitizedTarget := sanitizeForCUName(btName) + if sanitizedTarget == "" { + return "", false + } + + logger.Debugf("BT/darwin/classic: attempting to resolve MAC %s (%q) via BLE scan name-matching", mac, btName) + + ble := &BLETransport{} + if !ble.IsAvailable() { + return "", false + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + devices, err := ble.Scan(ctx) + if err != nil { + return "", false + } + + for _, dev := range devices { + if strings.Contains(strings.ToLower(sanitizeForCUName(dev.Name)), sanitizedTarget) { + logger.Debugf("BT/darwin/classic: matched BLE device %s (%q) for classic printer %s", dev.Address, dev.Name, mac) + return dev.Address, true + } + } + + return "", false +} + +func lookupBluetoothName(mac string) string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "system_profiler", "SPBluetoothDataType", "-json").Output() + if ctx.Err() == context.DeadlineExceeded { + logger.Warnf("BT/darwin: system_profiler timed out resolving BT name for %s", mac) + return "" + } + if err != nil { + logger.Warnf("BT/darwin: system_profiler failed, cannot resolve BT name for %s: %v", mac, err) + return "" + } + + var generic map[string]any + if err := json.Unmarshal(out, &generic); err != nil { + logger.Warnf("BT/darwin: failed to parse system_profiler JSON: %v", err) + return "" + } + + target := strings.ToLower(mac) + var found string + var walk func(v any) + walk = func(v any) { + if found != "" { + return + } + switch t := v.(type) { + case map[string]any: + if addr, ok := t["device_address"].(string); ok && strings.ToLower(addr) == target { + if name, ok := t["_name"].(string); ok { + found = name + return + } + } + for _, val := range t { + walk(val) + } + case []any: + for _, item := range t { + walk(item) + } + } + } + walk(generic) + + if found == "" { + logger.Warnf("BT/darwin: no system_profiler entry matched MAC %s", mac) + } else { + logger.Debugf("BT/darwin: resolved MAC %s -> Bluetooth name %q", mac, found) + } + return found +} + +func sanitizeForCUName(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + } + } + return strings.ToLower(b.String()) +} diff --git a/bluetooth/ble_darwin_nocgo.go b/bluetooth/ble_darwin_nocgo.go new file mode 100644 index 00000000..f30df4a4 --- /dev/null +++ b/bluetooth/ble_darwin_nocgo.go @@ -0,0 +1,27 @@ +//go:build darwin && !cgo + +package bluetooth + +import ( + "context" + "errors" + "net" +) + +type BLETransport struct{} + +func (t *BLETransport) Name() string { + return "BLE" +} + +func (t *BLETransport) IsAvailable() bool { + return false +} + +func (t *BLETransport) Dial(ctx context.Context, address string) (net.Conn, error) { + return nil, errors.New("BLE on macOS requires CGO_ENABLED=1") +} + +func (t *BLETransport) Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) { + return nil, errors.New("BLE on macOS requires CGO_ENABLED=1") +} diff --git a/bluetooth/ble_other.go b/bluetooth/ble_other.go new file mode 100644 index 00000000..3c3bdac3 --- /dev/null +++ b/bluetooth/ble_other.go @@ -0,0 +1,22 @@ +//go:build !darwin + +package bluetooth + +import ( + "errors" + + tinygoBT "tinygo.org/x/bluetooth" +) + +func discoverPrinterCharacteristic(service tinygoBT.DeviceService) (*tinygoBT.DeviceCharacteristic, error) { + return nil, errors.New("Not implemented for this platform") +} + +func resolveMACToBLEUUID(mac string) (string, bool) { + // On non-darwin platforms, we cannot resolve the MAC address to a BLE UUID. + // macOS CoreBluetooth identifies BLE peripherals by UUID rather than exposing + // their Bluetooth MAC address. Linux and Windows expose a Bluetooth address for + // BLE devices, but connections are still established through BLE peripheral + // discovery rather than directly dialing an address. + return "", false +} diff --git a/bluetooth/classic.go b/bluetooth/classic.go new file mode 100644 index 00000000..0b6c0022 --- /dev/null +++ b/bluetooth/classic.go @@ -0,0 +1,85 @@ +package bluetooth + +import ( + "net" + "os" + "sync" + "time" +) + +// btConnectTimeout is the maximum time allowed for a single RFCOMM connect attempt. +const btConnectTimeout = 3 * time.Second + +// rfcommBinding records the state of a bound (or candidate) RFCOMM device. +// On Linux the DevPath refers to an actual /dev/rfcommX node; +// on Darwin/Windows it is used only as a value holder. +type rfcommBinding struct { + DevPath string // e.g. "/dev/rfcomm0" + Channel int // RFCOMM channel number + Index int // numeric index (0 = rfcomm0, 1 = rfcomm1, …) +} + +type rfcommCache struct { + mu sync.RWMutex + entries map[string]*rfcommBinding +} + +func (c *rfcommCache) get(address string) (*rfcommBinding, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + b, ok := c.entries[address] + return b, ok +} + +func (c *rfcommCache) set(address string, b *rfcommBinding) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[address] = b +} + +func (c *rfcommCache) delete(address string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, address) +} + +func (bm *BluetoothManager) setBinding(address string, b *rfcommBinding) { + oldBinding, ok := bm.cache.get(address) + if ok && oldBinding.Channel == b.Channel && oldBinding.DevPath == b.DevPath { + return + } + bm.cache.set(address, b) +} + +type serialConn struct { + f *os.File + path string +} + +type serialAddr struct{ path string } + +func (a serialAddr) Network() string { return "rfcomm-serial" } +func (a serialAddr) String() string { return a.path } + +func (c *serialConn) Read(b []byte) (int, error) { return c.f.Read(b) } +func (c *serialConn) Write(b []byte) (int, error) { return c.f.Write(b) } +func (c *serialConn) Close() error { return c.f.Close() } + +func (c *serialConn) LocalAddr() net.Addr { + return netAddrPlaceholder{net: "rfcomm-serial", addr: c.path} +} +func (c *serialConn) RemoteAddr() net.Addr { + return netAddrPlaceholder{net: "rfcomm-serial", addr: c.path} +} + +func (c *serialConn) SetDeadline(t time.Time) error { return nil } +func (c *serialConn) SetReadDeadline(t time.Time) error { return nil } +func (c *serialConn) SetWriteDeadline(t time.Time) error { return nil } + +type netAddrPlaceholder struct { + net string + addr string +} + +func (a netAddrPlaceholder) Network() string { return a.net } +func (a netAddrPlaceholder) String() string { return a.addr } diff --git a/bluetooth/classic_darwin.go b/bluetooth/classic_darwin.go new file mode 100644 index 00000000..15769c6c --- /dev/null +++ b/bluetooth/classic_darwin.go @@ -0,0 +1,31 @@ +//go:build darwin + +package bluetooth + +import ( + "context" + "errors" + "net" +) + +type ClassicTransport struct{} + +func (t *ClassicTransport) Name() string { + return "Classic" +} + +func (t *ClassicTransport) IsAvailable() bool { + return false +} + +func (t *ClassicTransport) Dial(ctx context.Context, address string) (net.Conn, error) { + return nil, errors.New("not implemented for darwin") +} + +func (t *ClassicTransport) Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) { + return nil, errors.New("not implemented for darwin") +} + +func CheckDependencies() []DependencyStatus { + return []DependencyStatus{} +} diff --git a/bluetooth/classic_linux.go b/bluetooth/classic_linux.go new file mode 100644 index 00000000..4f141d9e --- /dev/null +++ b/bluetooth/classic_linux.go @@ -0,0 +1,487 @@ +//go:build linux + +package bluetooth + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "epos-proxy/logger" + + "golang.org/x/sys/unix" +) + +var rfcommBindDisabled = false +var rfcommBindDisabledMu sync.RWMutex + +func isRFCOMMBindDisabled() bool { + rfcommBindDisabledMu.RLock() + defer rfcommBindDisabledMu.RUnlock() + return rfcommBindDisabled +} + +func disableRFCOMMBind() { + rfcommBindDisabledMu.Lock() + rfcommBindDisabled = true + rfcommBindDisabledMu.Unlock() +} + +type ClassicTransport struct{} + +func (t *ClassicTransport) Name() string { + return "Classic" +} + +func (t *ClassicTransport) IsAvailable() bool { + return isBluetoothAdapterActive() +} + +func (t *ClassicTransport) Dial(ctx context.Context, address string) (net.Conn, error) { + channel := BTManager.GetCachedRFCOMMChannel(address) + return dialRFCOMMPlatform(address, channel) +} + +func (t *ClassicTransport) Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) { + return scanPairedPrinters() +} + +// scanPairedPrinters lists paired Bluetooth printers via bluetoothctl. +func scanPairedPrinters() ([]BluetoothPrinterInfo, error) { + logger.Debug("BT: scanning for Bluetooth printers on Linux") + + out, err := exec.Command("bluetoothctl", "devices").Output() + if err != nil { + return nil, fmt.Errorf("bluetoothctl devices failed: %w — is bluez installed?", err) + } + + var devices []BluetoothPrinterInfo + seen := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + // Format: "Device AA:BB:CC:DD:EE:FF Device Name" + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "Device ") { + continue + } + + parts := strings.SplitN(line, " ", 3) + if len(parts) < 2 { + continue + } + + mac := NormalizeAddress(parts[1]) + if seen[mac] { + continue + } + + name := "Unknown" + if len(parts) == 3 { + name = strings.TrimSpace(parts[2]) + } + + infoOut, err := exec.Command("bluetoothctl", "info", mac).Output() + if err != nil { + logger.Warnf("BT: failed to get info for %s: %v", mac, err) + continue + } + + info := strings.ToLower(string(infoOut)) + hasPrinterIcon := strings.Contains(info, "icon: printer") + hasSPP := strings.Contains(info, "uuid: serial port") + if !hasPrinterIcon || !hasSPP { + continue + } + + seen[mac] = true + devices = append(devices, BluetoothPrinterInfo{Address: NormalizeAddress(mac), Name: name, Device: "printer"}) + } + + logger.Debugf("BT: found %d Bluetooth printers", len(devices)) + return devices, nil +} + +func isBluetoothAdapterActive() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "bluetoothctl", "show").Output() + if err != nil { + return false + } + return strings.Contains(string(out), "Powered: yes") +} + +func CheckDependencies() []DependencyStatus { + deps := []DependencyStatus{} + + // Check bluetoothctl (bluez) + _, err := exec.LookPath("bluetoothctl") + if err != nil { + deps = append(deps, DependencyStatus{ + Name: "bluez", + InstallCmd: "sudo apt-get install bluez", + Description: "Required to scan for Bluetooth devices and manage connections", + }) + } + + return deps +} + +// Strategy: Try cached channel → SDP → probe channels 1–8 → raw socket fallback. +func dialRFCOMMPlatform(mac string, cachedChannel int) (net.Conn, error) { + mac = NormalizeAddress(mac) + + // Step 0: Try cached channel first if it exists. + if cachedChannel > 0 { + logger.Debugf("BT/RFCOMM: trying cached channel %d for %s", cachedChannel, mac) + if conn, err := tryRFCOMMDevice(mac, cachedChannel); err == nil { + logger.Debugf("BT/RFCOMM: connected successfully on cached channel %d for %s", cachedChannel, mac) + return conn, nil + } + logger.Warnf("BT/RFCOMM: connection on cached channel %d failed for %s", cachedChannel, mac) + } + + // Step 1: Channel probing. + for _, ch := range []int{1, 2, 3, 4, 5, 6, 7, 8} { + if ch == cachedChannel { + continue + } + + logger.Debugf("BT/RFCOMM: probing channel %d for %s", ch, mac) + if conn, err := tryRFCOMMDevice(mac, ch); err == nil { + logger.Debugf("BT/RFCOMM: channel probe succeeded on channel %d for %s", ch, mac) + return conn, nil + } else { + logger.Errorf("BT/RFCOMM: channel %d probe failed for %s: %v", ch, mac, err) + BTManager.cache.delete(mac) + } + } + + // Step 2: Raw socket fallback. + logger.Warnf("BT/RFCOMM: /dev/rfcommX approach exhausted for %s; falling back to raw RFCOMM socket", mac) + fallbackCh := cachedChannel + if fallbackCh == 0 { + fallbackCh = 1 + } + + conn, err := dialRFCOMM(mac, fallbackCh) + if err != nil { + return nil, fmt.Errorf("BT/RFCOMM: all connection strategies failed for %s: %w", mac, err) + } + + logger.Debugf("BT/RFCOMM: raw socket fallback succeeded for %s on channel %d", mac, fallbackCh) + BTManager.setBinding(mac, &rfcommBinding{DevPath: "raw", Channel: fallbackCh, Index: -1}) + return conn, nil +} + +func dialRFCOMM(mac string, channel int) (net.Conn, error) { + mac = NormalizeAddress(mac) + + addr, err := ParseMACToBytes(mac) + if err != nil { + return nil, fmt.Errorf("invalid bluetooth MAC %q: %w", mac, err) + } + + fd, err := unix.Socket(unix.AF_BLUETOOTH, unix.SOCK_STREAM, unix.BTPROTO_RFCOMM) + if err != nil { + return nil, fmt.Errorf("create RFCOMM socket failed: %w", err) + } + cleanup := func() { _ = unix.Close(fd) } + + unix.CloseOnExec(fd) + if err := unix.SetNonblock(fd, true); err != nil { + cleanup() + return nil, fmt.Errorf("set nonblocking mode failed: %w", err) + } + + _ = unix.SetsockoptLinger(fd, unix.SOL_SOCKET, unix.SO_LINGER, &unix.Linger{Onoff: 1, Linger: 1}) + + sa := &unix.SockaddrRFCOMM{Addr: addr, Channel: uint8(channel)} + err = unix.Connect(fd, sa) + if err != nil && err != unix.EINPROGRESS && err != unix.EAGAIN { + cleanup() + return nil, fmt.Errorf("RFCOMM connect to %s channel %d failed: %w", mac, channel, err) + } + + pollFds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}} + n, err := unix.Poll(pollFds, int(btConnectTimeout.Milliseconds())) + if err != nil || n == 0 { + cleanup() + return nil, fmt.Errorf("RFCOMM poll failed or timeout to %s channel %d: %w", mac, channel, err) + } + + if pollFds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 { + soErr, _ := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR) + cleanup() + if soErr != 0 { + return nil, fmt.Errorf("RFCOMM connect to %s channel %d failed: errno=%d (%s)", + mac, channel, soErr, syscall.Errno(soErr).Error()) + } + return nil, fmt.Errorf("RFCOMM connect to %s channel %d failed", mac, channel) + } + + soErr, err := unix.GetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_ERROR) + if err != nil { + return nil, fmt.Errorf("SO_ERROR check failed: %w", err) + } + if soErr != 0 { + cleanup() + return nil, fmt.Errorf("RFCOMM connect to %s channel %d failed: errno=%d (%s)", mac, channel, soErr, syscall.Errno(soErr).Error()) + } + + file := os.NewFile(uintptr(fd), fmt.Sprintf("rfcomm-%s-%d", mac, channel)) + if file == nil { + cleanup() + return nil, fmt.Errorf("failed to create os.File from RFCOMM socket") + } + + return &serialConn{f: file, path: fmt.Sprintf("rfcomm-%s-%d", mac, channel)}, nil +} + +func tryRFCOMMDevice(mac string, channel int) (net.Conn, error) { + if b, ok := BTManager.cache.get(mac); ok { + if b.Channel != channel { + BTManager.cache.delete(mac) + _ = releaseRFCOMM(b.Index) + } else if b.DevPath == "raw" { + logger.Debugf("BT/RFCOMM: cache hit for raw socket connection to %s on channel %d", mac, channel) + conn, err := dialRFCOMM(mac, channel) + if err != nil { + logger.Warnf("BT/RFCOMM: cached raw socket connection failed: %v; clearing cache", err) + BTManager.cache.delete(mac) + return nil, err + } + return conn, nil + } + } + + b, err := ensureRFCOMMDevice(mac, channel) + if err == nil { + conn, err := openRFCOMMDevice(b) + if err == nil { + return conn, nil + } + logger.Warnf("BT/RFCOMM: failed to open bound device %s: %v", b.DevPath, err) + _ = releaseRFCOMM(b.Index) + BTManager.cache.delete(mac) + } else { + logger.Warnf("BT/RFCOMM: binding device failed: %v", err) + } + + logger.Debugf("BT/RFCOMM: falling back to raw RFCOMM socket for channel %d", channel) + conn, err := dialRFCOMM(mac, channel) + if err == nil { + BTManager.setBinding(mac, &rfcommBinding{DevPath: "raw", Channel: channel, Index: -1}) + return conn, nil + } + return nil, err +} + +func releaseRFCOMM(index int) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "rfcomm", "release", strconv.Itoa(index)).CombinedOutput() + if err != nil { + logger.Debugf("BT/RFCOMM: rfcomm release for index %d returned: %v; output: %s", index, err, strings.TrimSpace(string(out))) + return err + } + logger.Debugf("BT/RFCOMM: released rfcomm%d", index) + return nil +} + +func ensureRFCOMMDevice(mac string, channel int) (*rfcommBinding, error) { + mac = NormalizeAddress(mac) + + if isRFCOMMBindDisabled() { + return nil, fmt.Errorf("BT/RFCOMM: rfcomm bind is disabled on this system") + } + + if b, ok := BTManager.cache.get(mac); ok { + logger.Debugf("BT/RFCOMM: cache hit for %s → %s (channel %d)", mac, b.DevPath, b.Channel) + if b.DevPath == "raw" { + return nil, fmt.Errorf("BT/RFCOMM: raw cache hit") + } + if _, err := os.Stat(b.DevPath); err == nil { + return b, nil + } + logger.Warnf("BT/RFCOMM: cached device %s no longer exists; re-binding", b.DevPath) + } + + if existing, ok := findExistingRFCOMMDevice(mac); ok { + if channel > 0 { + existing.Channel = channel + } + if existing.Channel == 0 { + existing.Channel = 1 + } + logger.Debugf("BT/RFCOMM: reusing existing device %s for %s (channel %d)", existing.DevPath, mac, existing.Channel) + BTManager.setBinding(mac, existing) + return existing, nil + } + + if channel <= 0 { + channel = 1 + } + + b, err := bindRFCOMM(mac, channel) + if err != nil { + return nil, err + } + + BTManager.setBinding(mac, b) + logger.Debugf("BT/RFCOMM: bound %s → %s (channel %d)", mac, b.DevPath, b.Channel) + return b, nil +} + +func openRFCOMMDevice(b *rfcommBinding) (net.Conn, error) { + f, err := os.OpenFile(b.DevPath, os.O_RDWR, 0) + if err != nil { + if os.IsPermission(err) { + return nil, fmt.Errorf("BT/RFCOMM: cannot open %s — add user to 'dialout' or 'bluetooth' group: %w", b.DevPath, err) + } + return nil, fmt.Errorf("BT/RFCOMM: failed to open %s: %w", b.DevPath, err) + } + logger.Debugf("BT/RFCOMM: opened %s as serial connection", b.DevPath) + return &serialConn{f: f, path: b.DevPath}, nil +} + +func findExistingRFCOMMDevice(mac string) (*rfcommBinding, bool) { + bindings, err := listRFCOMMBindings() + if err != nil { + logger.Warnf("BT/RFCOMM: could not list RFCOMM bindings: %v", err) + return nil, false + } + + mac = NormalizeAddress(mac) + for idx, boundMAC := range bindings { + if boundMAC == mac { + devPath := fmt.Sprintf("/dev/rfcomm%d", idx) + if _, err := os.Stat(devPath); err != nil { + logger.Warnf("BT/RFCOMM: binding for %s found at index %d but %s does not exist: %v", + mac, idx, devPath, err) + } + logger.Debugf("BT/RFCOMM: existing binding found for %s → %s", mac, devPath) + return &rfcommBinding{DevPath: devPath, Index: idx, Channel: 0}, true + } + } + + logger.Debugf("BT/RFCOMM: no existing RFCOMM binding for %s", mac) + return nil, false +} + +func listRFCOMMBindings() (map[int]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "rfcomm", "-a").CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("rfcomm -a timed out") + } + if err != nil { + return nil, fmt.Errorf("rfcomm -a failed: %w: %s", err, strings.TrimSpace(string(out))) + } + + bindings := make(map[int]string) + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + colonIdx := strings.Index(line, ":") + if colonIdx < 0 { + continue + } + devName := strings.TrimSpace(line[:colonIdx]) + if !strings.HasPrefix(devName, "rfcomm") { + continue + } + idx, err := strconv.Atoi(strings.TrimPrefix(devName, "rfcomm")) + if err != nil { + continue + } + fields := strings.Fields(strings.TrimSpace(line[colonIdx+1:])) + if len(fields) < 1 { + continue + } + bindings[idx] = NormalizeAddress(fields[0]) + } + + logger.Debugf("BT/RFCOMM: listRFCOMMBindings found %d bound device(s): %v", len(bindings), bindings) + return bindings, nil +} + +func bindRFCOMM(mac string, channel int) (*rfcommBinding, error) { + mac = NormalizeAddress(mac) + + existing, err := listRFCOMMBindings() + if err != nil { + return nil, fmt.Errorf("BT/RFCOMM: cannot list bindings (is rfcomm installed?): %w", err) + } + + for idx, boundMAC := range existing { + if boundMAC == mac { + devPath := fmt.Sprintf("/dev/rfcomm%d", idx) + logger.Debugf("BT/RFCOMM: bind skipped — %s already bound as %s", mac, devPath) + return &rfcommBinding{DevPath: devPath, Index: idx, Channel: channel}, nil + } + } + + idx := findFreeRFCOMMIndex(existing) + if idx < 0 { + return nil, fmt.Errorf("BT/RFCOMM: no free RFCOMM index available (all 0..31 are occupied)") + } + + devPath := fmt.Sprintf("/dev/rfcomm%d", idx) + args := []string{"bind", strconv.Itoa(idx), mac, strconv.Itoa(channel)} + logger.Debugf("BT/RFCOMM: running: rfcomm %s", strings.Join(args, " ")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "rfcomm", args...).CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("BT/RFCOMM: rfcomm bind timed out") + } + if err != nil { + outStr := strings.TrimSpace(string(out)) + if strings.Contains(outStr, "Operation not permitted") || + strings.Contains(outStr, "permission denied") || + strings.Contains(err.Error(), "permission denied") { + return nil, fmt.Errorf( + "BT/RFCOMM: rfcomm bind failed — insufficient privileges "+ + "(run as root or add user to 'bluetooth'/'dialout' group): %w; output: %s", + err, outStr) + } + return nil, fmt.Errorf("BT/RFCOMM: rfcomm bind failed: %w; output: %s", err, outStr) + } + + logger.Debugf("BT/RFCOMM: rfcomm bind succeeded, waiting for %s to appear…", devPath) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(devPath); err == nil { + logger.Debugf("BT/RFCOMM: device %s is ready", devPath) + return &rfcommBinding{DevPath: devPath, Index: idx, Channel: channel}, nil + } + time.Sleep(100 * time.Millisecond) + } + + disableRFCOMMBind() + return nil, fmt.Errorf("BT/RFCOMM: %s did not appear after rfcomm bind", devPath) +} + +func findFreeRFCOMMIndex(existing map[int]string) int { + for i := 0; i <= 31; i++ { + if _, used := existing[i]; !used { + return i + } + } + return -1 +} diff --git a/bluetooth/classic_windows.go b/bluetooth/classic_windows.go new file mode 100644 index 00000000..b6955b3d --- /dev/null +++ b/bluetooth/classic_windows.go @@ -0,0 +1,388 @@ +//go:build windows + +package bluetooth + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "strings" + "syscall" + "time" + "unsafe" + + "epos-proxy/logger" + + "golang.org/x/sys/windows" +) + +const ( + afBTH = 32 // AF_BTH + bthProtoRFCOMM = 3 // BTHPROTO_RFCOMM + + soSndtimeo = 0x1005 // SO_SNDTIMEO + soRcvtimeo = 0x1006 // SO_RCVTIMEO + + sockaddrBTHSize = 30 +) + +var ( + ws2_32 = windows.NewLazySystemDLL("ws2_32.dll") + procSocket = ws2_32.NewProc("socket") + procConnect = ws2_32.NewProc("connect") + procSend = ws2_32.NewProc("send") + procRecv = ws2_32.NewProc("recv") + procCloseSocket = ws2_32.NewProc("closesocket") + procSetsockopt = ws2_32.NewProc("setsockopt") + + bluetoothAPIs = windows.NewLazySystemDLL("BluetoothAPIs.dll") + procBTFindFirst = bluetoothAPIs.NewProc("BluetoothFindFirstDevice") + procBTFindNext = bluetoothAPIs.NewProc("BluetoothFindNextDevice") + procBTFindClose = bluetoothAPIs.NewProc("BluetoothFindDeviceClose") + procBTFindFirstRadio = bluetoothAPIs.NewProc("BluetoothFindFirstRadio") + procBTFindRadioClose = bluetoothAPIs.NewProc("BluetoothFindRadioClose") +) + +type btDeviceSearchParams struct { + dwSize uint32 + fReturnAuthenticated uint32 + fReturnRemembered uint32 + fReturnUnknown uint32 + fReturnConnected uint32 + fIssueInquiry uint32 + cTimeoutMultiplier uint8 + _ [3]byte + hRadio uintptr +} + +type winSYSTEMTIME struct { + Year, Month, DayOfWeek, Day uint16 + Hour, Minute, Second, Ms uint16 +} + +type btDeviceInfo struct { + dwSize uint32 + _ [4]byte + Address uint64 + ulClassOfDevice uint32 + fConnected uint32 + fRemembered uint32 + fAuthenticated uint32 + stLastSeen winSYSTEMTIME + stLastUsed winSYSTEMTIME + szName [248]uint16 +} + +type ClassicTransport struct{} + +func (t *ClassicTransport) Name() string { + return "Classic" +} + +func (t *ClassicTransport) IsAvailable() bool { + return isBluetoothAdapterActive() +} + +func (t *ClassicTransport) Dial(ctx context.Context, address string) (net.Conn, error) { + channel := BTManager.GetCachedRFCOMMChannel(address) + return dialRFCOMMPlatform(address, channel) +} + +func (t *ClassicTransport) Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) { + return scanPairedPrinters() +} + +func scanPairedPrinters() ([]BluetoothPrinterInfo, error) { + logger.Debug("BT: scanning for Bluetooth printers on windows") + if err := bluetoothAPIs.Load(); err != nil { + return nil, fmt.Errorf("BluetoothAPIs.dll unavailable: %w", err) + } + + params := btDeviceSearchParams{} + params.dwSize = uint32(unsafe.Sizeof(params)) + params.fReturnAuthenticated = 1 + params.fReturnRemembered = 1 + params.fReturnConnected = 1 + + var info btDeviceInfo + info.dwSize = uint32(unsafe.Sizeof(info)) + + handle, _, e := procBTFindFirst.Call( + uintptr(unsafe.Pointer(¶ms)), + uintptr(unsafe.Pointer(&info)), + ) + const invalidHandle = ^uintptr(0) + if handle == invalidHandle || handle == 0 { + if e == windows.ERROR_NO_MORE_ITEMS { + return nil, nil + } + return nil, fmt.Errorf("BluetoothFindFirstDevice failed: %w", e) + } + defer procBTFindClose.Call(handle) + + var devices []BluetoothPrinterInfo + + for { + mac := btAddrToMAC(info.Address) + name := utf16ToString(info.szName[:]) + if name == "" { + name = mac + } + + logger.Debugf("BT/Windows: found device %s (%s)", name, mac) + devices = append(devices, BluetoothPrinterInfo{Address: NormalizeAddress(mac), Name: name, Device: "printer"}) + + info = btDeviceInfo{} + info.dwSize = uint32(unsafe.Sizeof(info)) + r, _, _ := procBTFindNext.Call(handle, uintptr(unsafe.Pointer(&info))) + if r == 0 { + break + } + } + + return devices, nil +} + +func isBluetoothAdapterActive() bool { + if err := bluetoothAPIs.Load(); err != nil { + return false + } + + var params struct { + dwSize uint32 + } + params.dwSize = 4 + var hRadio syscall.Handle + hFind, _, _ := procBTFindFirstRadio.Call( + uintptr(unsafe.Pointer(¶ms)), + uintptr(unsafe.Pointer(&hRadio)), + ) + + const invalidHandle = ^uintptr(0) + if hFind == 0 || hFind == invalidHandle { + return false + } + + _ = syscall.CloseHandle(hRadio) + _, _, _ = procBTFindRadioClose.Call(hFind) + return true +} + +func CheckDependencies() []DependencyStatus { + return []DependencyStatus{} +} + +func macToWindowsBTHAddr(mac string) (uint64, error) { + parts := strings.Split(strings.ToUpper(mac), ":") + if len(parts) != 6 { + return 0, fmt.Errorf("invalid MAC: %s", mac) + } + var addr uint64 + for _, p := range parts { + v, err := parseHexByte(p) + if err != nil { + return 0, fmt.Errorf("invalid MAC octet %q: %w", p, err) + } + addr = (addr << 8) | uint64(v) + } + return addr, nil +} + +func parseHexByte(s string) (byte, error) { + var v uint64 + for _, c := range s { + v <<= 4 + switch { + case c >= '0' && c <= '9': + v |= uint64(c - '0') + case c >= 'A' && c <= 'F': + v |= uint64(c-'A') + 10 + case c >= 'a' && c <= 'f': + v |= uint64(c-'a') + 10 + default: + return 0, fmt.Errorf("invalid hex char %q", c) + } + } + return byte(v), nil +} + +func makeSockaddrBTH(btAddr uint64, channel uint32) [sockaddrBTHSize]byte { + var sa [sockaddrBTHSize]byte + binary.LittleEndian.PutUint16(sa[0:2], afBTH) + binary.LittleEndian.PutUint64(sa[2:10], btAddr) + binary.LittleEndian.PutUint32(sa[26:30], channel) + return sa +} + +func btAddrToMAC(addr uint64) string { + b := make([]byte, 6) + for i := 5; i >= 0; i-- { + b[i] = byte(addr & 0xFF) + addr >>= 8 + } + return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[0], b[1], b[2], b[3], b[4], b[5]) +} + +func utf16ToString(s []uint16) string { + for i, v := range s { + if v == 0 { + return windows.UTF16ToString(s[:i]) + } + } + return windows.UTF16ToString(s) +} + +type windowsBTConn struct { + sock syscall.Handle + mac string + ch int +} + +func (c *windowsBTConn) LocalAddr() net.Addr { + return netAddrPlaceholder{net: "rfcomm", addr: fmt.Sprintf("%s/%d", c.mac, c.ch)} +} +func (c *windowsBTConn) RemoteAddr() net.Addr { + return netAddrPlaceholder{net: "rfcomm", addr: fmt.Sprintf("%s/%d", c.mac, c.ch)} +} + +func (c *windowsBTConn) Read(b []byte) (int, error) { + r, _, err := procRecv.Call( + uintptr(c.sock), + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + 0, + ) + if int32(r) < 0 { + return 0, fmt.Errorf("recv failed: %w", err) + } + return int(r), nil +} + +func (c *windowsBTConn) Write(b []byte) (int, error) { + total := 0 + for len(b) > 0 { + r, _, err := procSend.Call( + uintptr(c.sock), + uintptr(unsafe.Pointer(&b[0])), + uintptr(len(b)), + 0, + ) + if int32(r) < 0 { + return total, fmt.Errorf("send failed: %w", err) + } + n := int(r) + total += n + b = b[n:] + } + return total, nil +} + +func (c *windowsBTConn) Close() error { + r, _, err := procCloseSocket.Call(uintptr(c.sock)) + if r != 0 { + return fmt.Errorf("closesocket failed: %w", err) + } + return nil +} + +func (c *windowsBTConn) setTimeoutMS(optname int32, ms int32) error { + r, _, err := procSetsockopt.Call( + uintptr(c.sock), + uintptr(windows.SOL_SOCKET), + uintptr(optname), + uintptr(unsafe.Pointer(&ms)), + uintptr(4), + ) + if r != 0 { + return fmt.Errorf("setsockopt failed: %w", err) + } + return nil +} + +func (c *windowsBTConn) SetDeadline(t time.Time) error { + _ = c.SetReadDeadline(t) + return c.SetWriteDeadline(t) +} + +func (c *windowsBTConn) SetReadDeadline(t time.Time) error { + ms := int32(time.Until(t).Milliseconds()) + if ms < 0 { + ms = 1 + } + return c.setTimeoutMS(soRcvtimeo, ms) +} + +func (c *windowsBTConn) SetWriteDeadline(t time.Time) error { + ms := int32(time.Until(t).Milliseconds()) + if ms < 0 { + ms = 1 + } + return c.setTimeoutMS(soSndtimeo, ms) +} + +func dialRFCOMM(mac string, channel int) (net.Conn, error) { + mac = NormalizeAddress(mac) + + btAddr, err := macToWindowsBTHAddr(mac) + if err != nil { + return nil, fmt.Errorf("invalid bluetooth MAC %q: %w", mac, err) + } + + r, _, e := procSocket.Call(afBTH, windows.SOCK_STREAM, bthProtoRFCOMM) + const invalidSocket = ^uintptr(0) + if r == invalidSocket { + return nil, fmt.Errorf("BT socket() failed: %w", e) + } + sock := syscall.Handle(r) + + cleanup := func() { procCloseSocket.Call(uintptr(sock)) } + + sa := makeSockaddrBTH(btAddr, uint32(channel)) + + timeoutMS := int32(btConnectTimeout.Milliseconds()) + procSetsockopt.Call( + uintptr(sock), + uintptr(windows.SOL_SOCKET), + uintptr(soSndtimeo), + uintptr(unsafe.Pointer(&timeoutMS)), + 4, + ) + + rc, _, e := procConnect.Call( + uintptr(sock), + uintptr(unsafe.Pointer(&sa[0])), + sockaddrBTHSize, + ) + if rc != 0 { + cleanup() + return nil, fmt.Errorf("RFCOMM connect to %s channel %d failed: %w", mac, channel, e) + } + + return &windowsBTConn{sock: sock, mac: mac, ch: channel}, nil +} + +func dialRFCOMMPlatform(mac string, cachedChannel int) (net.Conn, error) { + mac = NormalizeAddress(mac) + logger.Debugf("BT/Windows: dialling %s (cached channel %d)", mac, cachedChannel) + + if cachedChannel > 0 { + if conn, err := dialRFCOMM(mac, cachedChannel); err == nil { + return conn, nil + } + } + + for _, ch := range []int{1, 2, 3, 4, 5, 6, 7, 8} { + if ch == cachedChannel { + continue + } + logger.Debugf("BT/Windows: probing channel %d for %s", ch, mac) + if conn, err := dialRFCOMM(mac, ch); err == nil { + logger.Debugf("BT/Windows: channel %d succeeded for %s", ch, mac) + BTManager.setBinding(mac, &rfcommBinding{DevPath: "", Channel: ch, Index: -1}) + return conn, nil + } + } + + return nil, fmt.Errorf("BT/Windows: no working RFCOMM channel found for %s", mac) +} diff --git a/bluetooth/manager.go b/bluetooth/manager.go new file mode 100644 index 00000000..2a6d8991 --- /dev/null +++ b/bluetooth/manager.go @@ -0,0 +1,161 @@ +package bluetooth + +import ( + "context" + "epos-proxy/config" + "epos-proxy/logger" + "fmt" + "net" + "runtime" + "sync" + "time" +) + +type BluetoothPrinterInfo struct { + Address string `json:"address"` + Name string `json:"name"` + Device string `json:"device"` +} + +type DependencyStatus struct { + Name string `json:"name"` + InstallCmd string `json:"installCmd"` + Description string `json:"description"` +} + +type BluetoothManager struct { + Cfg *config.Manager + cache *rfcommCache +} + +var BTManager *BluetoothManager + +func InitBluetoothManager(cfg *config.Manager) { + BTManager = &BluetoothManager{ + Cfg: cfg, + cache: &rfcommCache{ + entries: make(map[string]*rfcommBinding), + }, + } +} + +func (bm *BluetoothManager) GetCachedRFCOMMChannel(address string) int { + address = NormalizeAddress(address) + if b, ok := bm.cache.get(address); ok { + return b.Channel + } + return 0 +} + +func (bm *BluetoothManager) CheckBluetoothPrinter(address string) error { + if !IsBluetoothAdapterActive() { + return fmt.Errorf("BT/manager: Bluetooth adapter is not available") + } + + conn, err := bm.Dial(address) + if err != nil { + return fmt.Errorf("bluetooth printer %s is unreachable: %w", address, err) + } + _ = conn.Close() + return nil +} + +func (bm *BluetoothManager) GetCachedBinding(address string) (string, int, bool) { + address = NormalizeAddress(address) + if b, ok := bm.cache.get(address); ok { + return b.DevPath, b.Channel, true + } + return "", 0, false +} + +func supportedTransportsByOS() []Transport { + if runtime.GOOS == "darwin" { + return []Transport{ + &BLETransport{}, + } + } + + return []Transport{ + &ClassicTransport{}, + } +} + +// Dial attempts to connect to the Bluetooth device at address using the platform's +// preferred transports in order, automatically falling back if a connection fails. +func (bm *BluetoothManager) Dial(address string) (net.Conn, error) { + var lastErr error + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + for _, t := range supportedTransportsByOS() { + if !t.IsAvailable() { + logger.Debugf("BT/manager: transport %s is not available, skipping", t.Name()) + continue + } + + logger.Debugf("BT/manager: attempting connection via %s to %s", t.Name(), address) + conn, err := t.Dial(ctx, address) + if err == nil { + logger.Debugf("BT/manager: connected via %s to %s", t.Name(), address) + return conn, nil + } + logger.Warnf("BT/manager: connection via %s to %s failed: %v", t.Name(), address, err) + lastErr = err + } + + if lastErr == nil { + return nil, fmt.Errorf("bluetooth/manager: no available Bluetooth transports") + } + return nil, fmt.Errorf("bluetooth/manager: all connection strategies failed: %w", lastErr) +} + +// ScanBluetoothPrinters queries all available transports for devices and merges the results. +func ScanBluetoothPrinters() ([]BluetoothPrinterInfo, error) { + logger.Debug("BT/manager: starting Bluetooth printer scan across all available transports") + + var allDevices []BluetoothPrinterInfo + seen := make(map[string]bool) + var mu sync.Mutex + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var wg sync.WaitGroup + for _, t := range supportedTransportsByOS() { + if !t.IsAvailable() { + continue + } + wg.Add(1) + go func(trans Transport) { + defer wg.Done() + devices, err := trans.Scan(ctx) + if err != nil { + logger.Warnf("BT/manager: transport %s scan failed: %v", trans.Name(), err) + return + } + mu.Lock() + defer mu.Unlock() + for _, d := range devices { + norm := NormalizeAddress(d.Address) + if !seen[norm] { + seen[norm] = true + d.Address = norm + allDevices = append(allDevices, d) + } + } + }(t) + } + + wg.Wait() + logger.Debugf("BT/manager: scan complete, found %d unique printer(s)", len(allDevices)) + return allDevices, nil +} + +func IsBluetoothAdapterActive() bool { + for _, t := range supportedTransportsByOS() { + if t.IsAvailable() { + return true + } + } + return false +} diff --git a/bluetooth/transport.go b/bluetooth/transport.go new file mode 100644 index 00000000..734fdddf --- /dev/null +++ b/bluetooth/transport.go @@ -0,0 +1,21 @@ +package bluetooth + +import ( + "context" + "net" +) + +// Transport defines the interface that all Bluetooth connection types (Classic/RFCOMM, BLE) must implement. +type Transport interface { + // Name returns the display name of the transport. + Name() string + + // Dial opens a connection to the given address. + Dial(ctx context.Context, address string) (net.Conn, error) + + // Scan performs a scan for devices matching this transport. + Scan(ctx context.Context) ([]BluetoothPrinterInfo, error) + + // IsAvailable returns true if the adapter for this transport is active and available. + IsAvailable() bool +} diff --git a/bluetooth/util.go b/bluetooth/util.go new file mode 100644 index 00000000..898a9642 --- /dev/null +++ b/bluetooth/util.go @@ -0,0 +1,50 @@ +package bluetooth + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var UuidRegexp = regexp.MustCompile(`^(?i)[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$`) +var MacRegexp = regexp.MustCompile(`^(?i)([0-9A-F]{2}[:\-]){5}[0-9A-F]{2}$`) + +// parseMACToBytes converts "AA:BB:CC:DD:EE:FF" → [6]byte in reversed order +// (little-endian as required by the BlueZ sockaddr_rc). +func ParseMACToBytes(macAddress string) ([6]byte, error) { + parts := strings.Split(strings.ToUpper(macAddress), ":") + if len(parts) != 6 { + return [6]byte{}, fmt.Errorf("invalid MAC: %s", macAddress) + } + var b [6]byte + for i, p := range parts { + v, err := strconv.ParseUint(p, 16, 8) + if err != nil { + return [6]byte{}, fmt.Errorf("invalid MAC octet %q: %w", p, err) + } + b[5-i] = byte(v) + } + return b, nil +} + +func NormalizeAddress(address string) string { + address = strings.ToUpper(strings.TrimSpace(address)) + // If it is a UUID, preserve it as-is + if UuidRegexp.MatchString(address) { + return address + } + address = strings.ReplaceAll(address, "-", ":") + return address +} + +func ValidateAddress(address string) error { + address = strings.TrimSpace(address) + if MacRegexp.MatchString(address) { + return nil + } + if UuidRegexp.MatchString(address) { + return nil + } + return fmt.Errorf("invalid MAC address format: %s (expected format: AA:BB:CC:DD:EE:FF or UUID)", address) +} diff --git a/build/build-darwin.sh b/build/build-darwin.sh index f905ec14..eb5e874f 100755 --- a/build/build-darwin.sh +++ b/build/build-darwin.sh @@ -61,6 +61,10 @@ cat > "${ENTITLEMENTS}" << EOF EOF +# ── Prepare tinygo-bluetooth ────────────────────────────────────────────────── +echo "▶ Setting up tinygo-bluetooth..." +"$(dirname "$0")/../scripts/setup-tinygo-bluetooth.sh" + # ── Build ───────────────────────────────────────────────────────────────────── echo "▶ Building..." wails build -clean diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 14121ef7..72de5346 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -64,5 +64,9 @@ NSAllowsLocalNetworking + NSBluetoothAlwaysUsageDescription + This app requires Bluetooth access to connect to and print on Bluetooth receipt printers. + NSBluetoothPeripheralUsageDescription + This app requires Bluetooth access to connect to and print on Bluetooth receipt printers. diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index d17a7475..b7f7cb48 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -59,5 +59,9 @@ {{end}} {{end}} + NSBluetoothAlwaysUsageDescription + This app requires Bluetooth access to connect to and print on Bluetooth receipt printers. + NSBluetoothPeripheralUsageDescription + This app requires Bluetooth access to connect to and print on Bluetooth receipt printers. diff --git a/config/bluetooth.go b/config/bluetooth.go new file mode 100644 index 00000000..59260407 --- /dev/null +++ b/config/bluetooth.go @@ -0,0 +1,43 @@ +package config + +func (cm *Manager) AddBluetoothPrinter(address, name string) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + for i, existing := range cm.Data.BluetoothPrinters { + if existing.Address == address { + cm.Data.BluetoothPrinters[i].Name = name + return cm.saveLocked() + } + } + cm.Data.BluetoothPrinters = append(cm.Data.BluetoothPrinters, BluetoothPrinterConfig{ + Address: address, + Name: name, + }) + return cm.saveLocked() +} + +func (cm *Manager) RemoveBluetoothPrinter(address string) error { + cm.mu.Lock() + defer cm.mu.Unlock() + + for i, existing := range cm.Data.BluetoothPrinters { + if existing.Address == address { + cm.Data.BluetoothPrinters = append(cm.Data.BluetoothPrinters[:i], cm.Data.BluetoothPrinters[i+1:]...) + return cm.saveLocked() + } + } + return nil +} + +func (cm *Manager) GetBluetoothPrinters() []BluetoothPrinterConfig { + cm.mu.RLock() + defer cm.mu.RUnlock() + + if cm.Data.BluetoothPrinters == nil { + return []BluetoothPrinterConfig{} + } + result := make([]BluetoothPrinterConfig, len(cm.Data.BluetoothPrinters)) + copy(result, cm.Data.BluetoothPrinters) + return result +} diff --git a/config/config.go b/config/config.go index 34bfdd73..3599e413 100644 --- a/config/config.go +++ b/config/config.go @@ -17,9 +17,15 @@ const ( PortRangeEnd = 4555 ) +type BluetoothPrinterConfig struct { + Address string `json:"address"` + Name string `json:"name"` +} + type AppConfig struct { - Port int `json:"port"` - LANPrinters []string `json:"lan_printers,omitempty"` + Port int `json:"port"` + LANPrinters []string `json:"lan_printers,omitempty"` + BluetoothPrinters []BluetoothPrinterConfig `json:"bluetooth_printers,omitempty"` } func defaults() AppConfig { diff --git a/frontend/src/components/printer-actions.js b/frontend/src/components/printer-actions.js index 19745749..6d49e38f 100644 --- a/frontend/src/components/printer-actions.js +++ b/frontend/src/components/printer-actions.js @@ -8,10 +8,9 @@ async function sendEposPrint(printer, openCashDrawer = false) { const content = openCashDrawer ? '' : ` - This is a test receipt ${printer.name} - + ` diff --git a/frontend/src/components/printer-actions.vue b/frontend/src/components/printer-actions.vue index 29073a63..df670f91 100644 --- a/frontend/src/components/printer-actions.vue +++ b/frontend/src/components/printer-actions.vue @@ -21,13 +21,14 @@ diff --git a/frontend/src/hooks/useToast.js b/frontend/src/hooks/useToast.js new file mode 100644 index 00000000..d457411f --- /dev/null +++ b/frontend/src/hooks/useToast.js @@ -0,0 +1,39 @@ +import { createApp, reactive } from 'vue' +import ToastNotification from '../components/toast-notification.vue' + +// Singleton reactive object — reactive() (not ref()) so it can be passed as a +// createApp prop and still be reactive in the child app without auto-unwrapping. +const toast = reactive({ show: false, message: '', type: 'success' }) +let timer = null + +function notify(message, type = 'success') { + if (timer) clearTimeout(timer) + toast.show = true + toast.message = message + toast.type = type + timer = setTimeout(() => { + toast.show = false + }, type === 'success' ? 2000 : 3000) +} + +/** + * Vue plugin — register with `app.use(Toast)`. + * Mounts into its own DOM node (always on top) and + * makes notify() available to every component via useToast(). + */ +export const Toast = { + install(app) { + const toastHost = document.createElement('div') + document.body.appendChild(toastHost) + // Pass the reactive object directly — its reference is stable so the + // child app will observe property mutations correctly. + createApp(ToastNotification, { toast }).mount(toastHost) + }, +} + +/** + * Call in any component to obtain the notify(message, type) function. + */ +export function useToast() { + return { notify } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 1034d904..5350349f 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,8 @@ -import './app.css'; -import PrinterList from "./printer-list.vue"; -import {createApp} from 'vue' +import './app.css' +import { createApp } from 'vue' +import PrinterList from './printer-list.vue' +import { Toast } from './hooks/useToast.js' const app = createApp(PrinterList) -app.mount('#app') \ No newline at end of file +app.use(Toast) +app.mount('#app') diff --git a/frontend/src/modal/bluetooth-dialog.vue b/frontend/src/modal/bluetooth-dialog.vue new file mode 100644 index 00000000..f2654d8b --- /dev/null +++ b/frontend/src/modal/bluetooth-dialog.vue @@ -0,0 +1,194 @@ + + + diff --git a/frontend/src/modal/network-ip-dialog.vue b/frontend/src/modal/network-ip-dialog.vue index 3c93c4a5..a9d2fd91 100644 --- a/frontend/src/modal/network-ip-dialog.vue +++ b/frontend/src/modal/network-ip-dialog.vue @@ -41,12 +41,15 @@ import {ref, watch, nextTick} from 'vue' import CloseButton from './close-button.vue' import {AddLANPrinter} from '../../wailsjs/go/main/App' +import { useToast } from '../hooks/useToast.js' const props = defineProps({ show: {type: Boolean, default: false}, }) -const emit = defineEmits(['close', 'notify']) +const emit = defineEmits(['close']) + +const { notify } = useToast() const ipInput = ref('') const error = ref(null) @@ -78,11 +81,11 @@ async function submit() { try { await AddLANPrinter(ip) - emit('notify', 'Printer added successfully', 'success') + notify('Printer added successfully', 'success') close(true) } catch (err) { console.log(err) - emit('notify', err || 'Failed to add printer', 'danger') + notify(err || 'Failed to add printer', 'danger') error.value = err || 'Failed to add printer' } finally { loading.value = false diff --git a/frontend/src/printer-list.vue b/frontend/src/printer-list.vue index 087a8873..ea701a9a 100644 --- a/frontend/src/printer-list.vue +++ b/frontend/src/printer-list.vue @@ -9,23 +9,29 @@
  • - + {{ printer.name }} × + ×
    {{ printer.ip }}
    - +
  • - + {{ printer.name }}
    Unable to communicate with this printer: {{ @@ -49,7 +55,7 @@
    No printers found
    -
    Make sure your printer is powered on and connected via USB.
    +
    Make sure your printer is powered on and connected via USB, Network, or Bluetooth.
    @@ -60,43 +66,32 @@
    -
    +
    + 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) {