From 301a6eaad033eb25f4f75b38e4ebec0b6e44b5a9 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 9 Jun 2026 05:04:02 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20interactive=20d?= =?UTF-8?q?evice=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/App.tsx | 12 +++ src/components/DeviceSelector.tsx | 102 +++++++++++++++++++ src/components/Header.tsx | 9 +- src/components/StatusApp.tsx | 14 ++- src/components/index.ts | 1 + src/hooks/useDeviceScanner.ts | 62 +++++++++++ src/hooks/useDeviceStatus.ts | 20 +++- src/hooks/useUpload.ts | 23 ++++- src/lib/ble/backend.ts | 7 ++ src/lib/ble/backends/dbus-backend.ts | 140 ++++++++++++++++++++++++- src/lib/ble/backends/noble-backend.ts | 56 ++++++++++ src/lib/ble/ble-client.ts | 141 ++++++++++++++------------ src/lib/core/beambox-uploader.ts | 14 +++ src/lib/utils/logger.ts | 1 + src/types/node-ble.d.ts | 49 +++++++++ 15 files changed, 572 insertions(+), 79 deletions(-) create mode 100644 src/components/DeviceSelector.tsx create mode 100644 src/hooks/useDeviceScanner.ts create mode 100644 src/types/node-ble.d.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index 1ebed77..c9c5c6e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -6,6 +6,7 @@ import { UploadProgress, ConnectionStatus, ConfirmAnimatedUpload, + DeviceSelector, } from "./index.ts"; import { useUpload } from "../hooks/useUpload.ts"; import type { UploadOptions } from "../cli/types.ts"; @@ -50,6 +51,9 @@ const UploadFlow: React.FC<{ options: UploadOptions; verbose: boolean }> = ({ totalFiles, uploadSteps, connectionSteps, + scannedDevices, + scanning, + onDeviceSelected, } = useUpload(options, verbose); // Exit the app when done @@ -77,6 +81,14 @@ const UploadFlow: React.FC<{ options: UploadOptions; verbose: boolean }> = ({ )} + {(status === "scanning" || status === "selecting") && ( + + )} + {status === "connecting" && ( diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx new file mode 100644 index 0000000..ae645ed --- /dev/null +++ b/src/components/DeviceSelector.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import Spinner from "ink-spinner"; + +export interface DiscoveredDevice { + name: string | null; + address: string; +} + +export interface DeviceSelectorProps { + devices: DiscoveredDevice[]; + scanning: boolean; + onSelect: (device: DiscoveredDevice) => void; +} + +export const DeviceSelector: React.FC = ({ + devices, + scanning, + onSelect, +}) => { + const { exit } = useApp(); + const [cursor, setCursor] = useState(0); + + useInput((char, key) => { + if (key.escape || (key.ctrl && char === "c")) { + exit(); + process.exit(1); + } + + if (devices.length === 0) return; + + if (key.upArrow) { + setCursor((prev) => (prev - 1 + devices.length) % devices.length); + } + + if (key.downArrow) { + setCursor((prev) => (prev + 1) % devices.length); + } + + if (key.return) { + const selected = devices[cursor]; + if (selected) { + onSelect(selected); + } + } + }); + + return ( + + + {scanning ? ( + + + + + Scanning for devices... + {devices.length > 0 && ( + ({devices.length} found so far) + )} + + ) : ( + + Found {devices.length} device{devices.length !== 1 ? "s" : ""} + + )} + + + {devices.length > 0 && ( + + {devices.map((device, i) => { + const isSelected = i === cursor; + return ( + + + {isSelected ? "▶" : " "} + + + + {device.name ?? "(unnamed)"} + + + {device.address} + + + + ); + })} + + )} + + {!scanning && devices.length > 0 && ( + + ↑↓ navigate • Enter select • Esc cancel + + )} + + {!scanning && devices.length === 0 && ( + No devices found. Make sure your BeamBox is nearby and on. + )} + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 64f1675..c08293b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,15 +1,22 @@ import React from "react"; import { Box, Text } from "ink"; import BigText from "ink-big-text"; +import { createRequire } from "node:module"; const PASTEL_PINK = "#FFB6C1"; const sponsorLink = "github.com/sponsors/YuzuZensai"; +const require = createRequire(import.meta.url); +const { version } = require("../../package.json") as { version: string }; + export const Header: React.FC = () => { return ( - CLI tool for managing BeamBox e-Badge devices + + CLI tool for managing BeamBox e-Badge devices + v{version} + Support my work ♡{" "} diff --git a/src/components/StatusApp.tsx b/src/components/StatusApp.tsx index 0d83172..e5b8d4d 100644 --- a/src/components/StatusApp.tsx +++ b/src/components/StatusApp.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { Box, Text, useApp } from "ink"; import Spinner from "ink-spinner"; -import { Header, Status, ConnectionStatus } from "./index.ts"; +import { Header, Status, ConnectionStatus, DeviceSelector } from "./index.ts"; import { useDeviceStatus } from "../hooks/useDeviceStatus.ts"; import type { StatusOptions } from "../cli/types.ts"; @@ -12,7 +12,7 @@ export interface StatusAppProps { export const StatusApp: React.FC = ({ options }) => { const { exit } = useApp(); - const { loading, error, deviceStatus, notifications, connectionSteps } = + const { selecting, loading, error, deviceStatus, notifications, connectionSteps, scannedDevices, scanning, onDeviceSelected } = useDeviceStatus(options); // Exit the app when done @@ -32,7 +32,15 @@ export const StatusApp: React.FC = ({ options }) => {
- {loading && ( + {selecting && ( + + )} + + {!selecting && loading && ( diff --git a/src/components/index.ts b/src/components/index.ts index f15c4e2..5d13295 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export { Header } from './Header.tsx'; +export { DeviceSelector, type DiscoveredDevice } from './DeviceSelector.tsx'; export { ConfirmAnimatedUpload } from './ConfirmAnimatedUpload.tsx'; export { UploadProgress } from './UploadProgress.tsx'; export { Status } from './Status.tsx'; diff --git a/src/hooks/useDeviceScanner.ts b/src/hooks/useDeviceScanner.ts new file mode 100644 index 0000000..004b377 --- /dev/null +++ b/src/hooks/useDeviceScanner.ts @@ -0,0 +1,62 @@ +import { useState, useRef } from "react"; +import type { DiscoveredDevice } from "../components/index.ts"; + +export function useDeviceScanner() { + const [scannedDevices, setScannedDevices] = useState([]); + const [scanning, setScanning] = useState(false); + const selectResolveRef = useRef<((device: DiscoveredDevice) => void) | null>( + null, + ); + + const onDeviceSelected = (device: DiscoveredDevice) => { + selectResolveRef.current?.(device); + selectResolveRef.current = null; + }; + + const scanAndSelect = async (uploader: { + scanForDevices: ( + cb: (d: DiscoveredDevice) => void, + signal: AbortSignal, + ) => Promise; + setDeviceAddress: (a: string) => void; + }): Promise => { + const scanAbort = new AbortController(); + + const selectionPromise = new Promise((resolve) => { + selectResolveRef.current = resolve; + }); + + setScanning(true); + + const scanPromise = uploader.scanForDevices((device) => { + setScannedDevices((prev) => { + if (prev.some((d) => d.address === device.address)) return prev; + return [...prev, device]; + }); + }, scanAbort.signal); + + const chosen = await Promise.race([ + selectionPromise.then((device) => { + scanAbort.abort(); + return device; + }), + scanPromise.then((devices) => { + setScanning(false); + if (devices.length === 0) return null; + if (devices.length === 1) return devices[0]!; + return selectionPromise; + }), + ]); + + setScanning(false); + selectResolveRef.current = null; + + if (chosen) { + uploader.setDeviceAddress(chosen.address); + } + + return chosen; + }; + + return { scannedDevices, scanning, onDeviceSelected, scanAndSelect }; +} diff --git a/src/hooks/useDeviceStatus.ts b/src/hooks/useDeviceStatus.ts index 2aacd46..70f49d2 100644 --- a/src/hooks/useDeviceStatus.ts +++ b/src/hooks/useDeviceStatus.ts @@ -5,10 +5,13 @@ import type { DeviceStatus, ParsedResponse } from "../lib/protocol/interfaces/in import type { ConnectionStep } from "../components/index.ts"; import { updateStepStatus } from "../utils/app-utils.ts"; import type { StatusOptions } from "../cli/types.ts"; +import { useDeviceScanner } from "./useDeviceScanner.ts"; export function useDeviceStatus(options: StatusOptions) { + const [selecting, setSelecting] = useState(!options.address); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { scannedDevices, scanning, onDeviceSelected, scanAndSelect } = useDeviceScanner(); const [deviceStatus, setDeviceStatus] = useState(null); const [notifications, setNotifications] = useState< Array<{ time: number; data: Buffer; parsed: ParsedResponse }> @@ -92,7 +95,7 @@ export function useDeviceStatus(options: StatusOptions) { try { uploader = new BeamBoxUploader( - options.address, + options.address ?? undefined, undefined, undefined, undefined, @@ -100,6 +103,17 @@ export function useDeviceStatus(options: StatusOptions) { options.verbose, ); + if (!options.address) { + setSelecting(true); + const chosen = await scanAndSelect(uploader); + setSelecting(false); + if (!chosen) { + setError("No BeamBox devices found."); + setLoading(false); + return; + } + } + const result = await uploader.getStatus(10000); setDeviceStatus(result.status as DeviceStatus | null); @@ -135,10 +149,14 @@ export function useDeviceStatus(options: StatusOptions) { }, [getStatus]); return { + selecting, loading, error, deviceStatus, notifications, connectionSteps, + scannedDevices, + scanning, + onDeviceSelected, }; } diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts index fb97d30..d5716e6 100644 --- a/src/hooks/useUpload.ts +++ b/src/hooks/useUpload.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts"; import { logger, LogEventType } from "../lib/utils/logger.ts"; import { BeamBoxError } from "../lib/utils/errors.ts"; @@ -6,16 +6,18 @@ import type { ConnectionStep } from "../components/index.ts"; import { basename } from "node:path"; import { updateStepStatus } from "../utils/app-utils.ts"; import type { UploadOptions } from "../cli/types.ts"; +import { useDeviceScanner } from "./useDeviceScanner.ts"; export function useUpload(options: UploadOptions, verbose: boolean) { const [status, setStatus] = useState< - "connecting" | "uploading" | "success" | "error" - >("connecting"); + "scanning" | "selecting" | "connecting" | "uploading" | "success" | "error" + >("scanning"); const [message, setMessage] = useState("Initializing..."); const [sendProgress, setSendProgress] = useState(0); const [confirmProgress, setConfirmProgress] = useState(0); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [totalFiles, setTotalFiles] = useState(1); + const { scannedDevices, scanning, onDeviceSelected, scanAndSelect } = useDeviceScanner(); const [uploadSteps, setUploadSteps] = useState([ { id: "image-info", label: "Sending image info", status: "pending" }, @@ -129,7 +131,7 @@ export function useUpload(options: UploadOptions, verbose: boolean) { } uploader = new BeamBoxUploader( - options.address, + options.address ?? undefined, packetDelaySeconds, undefined, undefined, @@ -137,6 +139,16 @@ export function useUpload(options: UploadOptions, verbose: boolean) { verbose, ); + if (!options.address) { + setStatus("selecting"); + const chosen = await scanAndSelect(uploader); + if (!chosen) { + setStatus("error"); + setMessage("No BeamBox devices found. Make sure your device is nearby and powered on."); + return; + } + } + setStatus("connecting"); setMessage("Connecting to BeamBox device..."); @@ -282,5 +294,8 @@ export function useUpload(options: UploadOptions, verbose: boolean) { totalFiles, uploadSteps, connectionSteps, + scannedDevices, + scanning, + onDeviceSelected, }; } diff --git a/src/lib/ble/backend.ts b/src/lib/ble/backend.ts index a629495..d355f14 100644 --- a/src/lib/ble/backend.ts +++ b/src/lib/ble/backend.ts @@ -20,6 +20,13 @@ export interface BleBackend { timeoutMs: number, ): Promise; + scanForAll( + matcher: (device: DiscoveredDevice) => boolean, + timeoutMs: number, + onDeviceFound?: (device: DiscoveredDevice) => void, + signal?: AbortSignal, + ): Promise; + stopScan(): Promise; connect(address: string): Promise; diff --git a/src/lib/ble/backends/dbus-backend.ts b/src/lib/ble/backends/dbus-backend.ts index 74c961f..8acbbb2 100644 --- a/src/lib/ble/backends/dbus-backend.ts +++ b/src/lib/ble/backends/dbus-backend.ts @@ -7,6 +7,11 @@ import type { BleCharacteristic, DiscoveredDevice, } from "../backend.ts"; +import type { + AdapterInternal, + BluezInterfacesMap, + DbusObjectManager, +} from "../../../types/node-ble.d.ts"; const POLL_INTERVAL_MS = 1000; @@ -96,10 +101,6 @@ export class DBusBackend implements BleBackend { await this.ensureDiscovering(); const adapter = this.adapter; - - // Keep re-checking unnamed devices on every poll instead of giving up on first sight. - // Not sure if this is the best way to handle this. - const named = new Map(); const deadline = Date.now() + timeoutMs; @@ -141,6 +142,127 @@ export class DBusBackend implements BleBackend { return null; } + async scanForAll( + matcher: (device: DiscoveredDevice) => boolean, + timeoutMs: number, + onDeviceFound?: (device: DiscoveredDevice) => void, + signal?: AbortSignal, + ): Promise { + if (!this.adapter) { + throw new ConnectionError("Bluetooth adapter not initialized"); + } + + await this.ensureDiscovering(); + + const adapter = this.adapter; + const found = new Map(); + + return new Promise((resolve) => { + const dbus = (adapter as AdapterInternal).dbus; + + let objectManager: DbusObjectManager | null = null; + + const finish = async () => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", finish); + if (objectManager) { + objectManager.removeAllListeners("InterfacesAdded"); + } + await this.stopScan(); + resolve(Array.from(found.values())); + }; + + const timeoutId = setTimeout(finish, timeoutMs); + signal?.addEventListener("abort", finish, { once: true }); + + const handleDevice = async ( + objectPath: string, + interfaces: BluezInterfacesMap, + ) => { + if (!objectPath.includes("/dev_")) return; + const iface = interfaces["org.bluez.Device1"]; + if (!iface) return; + + const address: string = + iface.Address?.value ?? + objectPath.split("/dev_")[1]?.replace(/_/g, ":") ?? + ""; + if (!address || found.has(address)) return; + + const name: string | null = + iface.Name?.value ?? iface.Alias?.value ?? null; + + if (name) { + logger.debug(`Discovered device: ${name} (${address})`); + } + + const candidate: DiscoveredDevice = { address, name }; + if (matcher(candidate)) { + found.set(address, candidate); + if (!this.device) { + this.device = await adapter.getDevice(address).catch(() => null); + } + onDeviceFound?.(candidate); + } + }; + + dbus + .getProxyObject("org.bluez", "/") + .then((rootObj) => { + objectManager = rootObj.getInterface( + "org.freedesktop.DBus.ObjectManager", + ); + objectManager.on("InterfacesAdded", handleDevice); + + objectManager + .GetManagedObjects() + .then((objects) => { + for (const [objectPath, interfaces] of Object.entries(objects)) { + handleDevice(objectPath, interfaces); + } + }) + .catch(() => {}); + }) + .catch(() => { + const poll = async () => { + if (signal?.aborted) return; + try { + const addresses = await adapter.devices(); + for (const address of addresses) { + if (found.has(address)) continue; + let name: string | null = null; + try { + const remoteDevice = await adapter.getDevice(address); + name = await remoteDevice + .getName() + .catch(async () => + remoteDevice.getAlias().catch(() => null), + ); + } catch { + /* ignore */ + } + if (name) + logger.debug(`Discovered device: ${name} (${address})`); + const candidate: DiscoveredDevice = { address, name }; + if (matcher(candidate)) { + found.set(address, candidate); + if (!this.device) + this.device = await adapter + .getDevice(address) + .catch(() => null); + onDeviceFound?.(candidate); + } + } + } catch { + /* ignore */ + } + if (!signal?.aborted) setTimeout(poll, POLL_INTERVAL_MS); + }; + poll(); + }); + }); + } + async stopScan(): Promise { if (this.adapter && this.scanning) { await this.adapter.stopDiscovery().catch(() => {}); @@ -227,9 +349,19 @@ export class DBusBackend implements BleBackend { if (this.device) { this.device.removeAllListeners(); + + const address = await this.device.getAddress().catch(() => null); + if (await this.device.isConnected().catch(() => false)) { await this.device.disconnect().catch(() => {}); } + + if (address && this.adapter) { + const internal = this.adapter as AdapterInternal; + const devicePath = `/org/bluez/${internal.adapter}/dev_${address.replace(/:/g, "_").toUpperCase()}`; + await internal.helper.callMethod("RemoveDevice", devicePath).catch(() => {}); + } + this.device = null; } diff --git a/src/lib/ble/backends/noble-backend.ts b/src/lib/ble/backends/noble-backend.ts index 419f73c..9ee899a 100644 --- a/src/lib/ble/backends/noble-backend.ts +++ b/src/lib/ble/backends/noble-backend.ts @@ -34,6 +34,7 @@ export class NobleBackend implements BleBackend { readonly name = "noble"; private peripheral: Peripheral | null = null; + private peripheralsByAddress = new Map(); private initialized = false; async init(): Promise { @@ -107,6 +108,7 @@ export class NobleBackend implements BleBackend { await this.stopScan(); noble.removeListener("discover", onDiscover); this.peripheral = peripheral; + this.peripheralsByAddress.set(device.address, peripheral); resolve(device); } }; @@ -121,11 +123,65 @@ export class NobleBackend implements BleBackend { }); } + async scanForAll( + matcher: (device: DiscoveredDevice) => boolean, + timeoutMs: number, + onDeviceFound?: (device: DiscoveredDevice) => void, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve) => { + const found = new Map(); + + const finish = async () => { + clearTimeout(timeoutId); + await this.stopScan(); + noble.removeListener("discover", onDiscover); + resolve(Array.from(found.values())); + }; + + const timeoutId = setTimeout(finish, timeoutMs); + + signal?.addEventListener("abort", finish, { once: true }); + + const onDiscover = async (peripheral: Peripheral) => { + const device: DiscoveredDevice = { + address: peripheral.address || peripheral.id, + name: peripheral.advertisement.localName ?? null, + }; + + logger.debug( + `Discovered device: ${device.name ?? "(unnamed)"} (${device.address})`, + ); + + if (matcher(device) && !found.has(device.address)) { + found.set(device.address, device); + this.peripheralsByAddress.set(device.address, peripheral); + if (!this.peripheral) { + this.peripheral = peripheral; + } + onDeviceFound?.(device); + } + }; + + noble.on("discover", onDiscover); + + noble.startScanningAsync([], false).catch(() => { + clearTimeout(timeoutId); + noble.removeListener("discover", onDiscover); + resolve([]); + }); + }); + } + async stopScan(): Promise { await noble.stopScanningAsync().catch(() => {}); } async connect(address: string): Promise { + const byAddress = this.peripheralsByAddress.get(address); + if (byAddress) { + this.peripheral = byAddress; + } if (!this.peripheral) { throw new ConnectionError(`No discovered peripheral for ${address}`); } diff --git a/src/lib/ble/ble-client.ts b/src/lib/ble/ble-client.ts index 79021c1..6ba89fc 100644 --- a/src/lib/ble/ble-client.ts +++ b/src/lib/ble/ble-client.ts @@ -148,11 +148,6 @@ class NotificationHandler { } } - /** - * Log sent packet in verbose mode - * @param packet Sent packet buffer - * @param description Description of the packet - */ public logSentPacket(packet: Buffer, description: string): void { if (this.verbose) { const timestamp = new Date().toISOString(); @@ -162,10 +157,6 @@ class NotificationHandler { } } - /** - * Get all received notifications - * @returns Array of notifications with timestamp, data, and parsed content - */ public getNotifications(): Array<{ time: number; data: Buffer; @@ -174,10 +165,6 @@ class NotificationHandler { return this.allNotifications; } - /** - * Wait for device status to be received - * @returns Promise that resolves when status is received - */ public waitForStatus(): Promise { return new Promise((resolve) => { if (this.deviceStatusReceived) { @@ -188,38 +175,24 @@ class NotificationHandler { }); } - /** - * Wait for any notification from device - * @returns Promise that resolves when a notification is received - */ public waitForNotification(): Promise { return new Promise((resolve) => { this.notificationResolve = resolve; }); } - /** - * Reset internal state - */ public reset(): void { this.notificationResolve = null; this.statusResolve = null; this.errorFlag = false; } - /** - * Set the expected number of acknowledgments to wait for - * @param count Number of packets that will be sent - */ public setExpectedAckCount(count: number): void { this.expectedAckCount = count; this.packetSuccessCount = 0; } } -/** - * Manages BLE connection and data transfer to BeamBox device - */ export class BleUploader { private backend: BleBackend; private writeCharacteristic: BleCharacteristic | null = null; @@ -230,6 +203,7 @@ export class BleUploader { private verbose: boolean = false; private isInitialized: boolean = false; private connectedAddress: string | null = null; + private alreadyScanned: boolean = false; constructor( private deviceAddress: string | null, @@ -246,9 +220,6 @@ export class BleUploader { logger.debug(`Using BLE backend: ${this.backend.name}`); } - /** - * Initialize Bluetooth adapter and wait for it to be ready - */ private async initBluetooth(): Promise { if (this.isInitialized) { return; @@ -258,36 +229,48 @@ export class BleUploader { this.isInitialized = true; } - /** - * Normalize UUID for comparison - * Handles both full 128-bit UUIDs and short 16/32-bit UUIDs - * Short UUIDs use the Bluetooth Base UUID: 00000000-0000-1000-8000-00805f9b34fb - */ private normalizeUUID(uuid: string): string { - // Remove dashes and lowercase const cleaned = uuid.replace(/-/g, "").toLowerCase(); - - // If it's a full 128-bit UUID using Bluetooth Base UUID, extract the short form - // Bluetooth Base UUID pattern: 0000XXXX-0000-1000-8000-00805f9b34fb - const bluetoothBasePattern = /^0000([0-9a-f]{4})00001000800000805f9b34fb$/; - const match = cleaned.match(bluetoothBasePattern); - if (match && match[1]) { - return match[1]; // Return short UUID part - } - - // For short UUIDs (3-4 hex chars), pad with zeros to 4 chars for consistent comparison - // This handles cases like "1f1" vs "01f1" - if (cleaned.length <= 4) { - return cleaned.padStart(4, "0"); - } - + // Strip Bluetooth Base UUID wrapper: 0000XXXX-0000-1000-8000-00805f9b34fb → XXXX + const match = cleaned.match(/^0000([0-9a-f]{4})00001000800000805f9b34fb$/); + if (match && match[1]) return match[1]; + if (cleaned.length <= 4) return cleaned.padStart(4, "0"); return cleaned; } - /** - * Scan for the BeamBox device - * @returns Device address or null if not found - */ + public setDeviceAddress(address: string): void { + this.deviceAddress = address; + this.alreadyScanned = true; + } + + public async scanForDevices( + onDeviceFound?: (device: { name: string | null; address: string }) => void, + signal?: AbortSignal, + ): Promise> { + await this.initBluetooth(); + + logger.info("Starting device scan...", LogEventType.SCAN_START); + + const targetName = this.bleConfig.deviceName.toLowerCase(); + const timeout = this.bleConfig.scanTimeout * 1000; + + const devices = await this.backend.scanForAll( + (device) => + !!device.name && device.name.toLowerCase().includes(targetName), + timeout, + onDeviceFound, + signal, + ); + + logger.info( + `Scan complete, found ${devices.length} device(s)`, + LogEventType.DEVICES_FOUND, + { devices }, + ); + + return devices; + } + public async findDevice(): Promise { await this.initBluetooth(); @@ -297,9 +280,7 @@ export class BleUploader { const timeout = this.bleConfig.scanTimeout * 1000; const found = await this.backend.scanFor((device) => { - return ( - !!device.name && device.name.toLowerCase().includes(targetName) - ); + return !!device.name && device.name.toLowerCase().includes(targetName); }, timeout); if (!found) { @@ -324,7 +305,13 @@ export class BleUploader { await this.initBluetooth(); if (!this.connectedAddress) { - if (!this.deviceAddress) { + if (this.alreadyScanned && this.deviceAddress) { + logger.info( + `Using previously scanned device: ${this.deviceAddress}`, + LogEventType.DEVICE_FOUND, + { address: this.deviceAddress }, + ); + } else if (!this.deviceAddress) { logger.info("Scanning for device...", LogEventType.SCAN_START); const address = await this.findDevice(); if (!address) { @@ -516,7 +503,11 @@ export class BleUploader { public async sendData( fullData: Buffer, packetType: PacketType, - onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void, + onProgress?: ( + sendProgress: number, + confirmProgress: number, + status?: string, + ) => void, ): Promise { if (!this.writeCharacteristic) { throw new ConnectionError("BLE client not connected"); @@ -590,8 +581,13 @@ export class BleUploader { // Report progress if (onProgress) { const sendProgress = ((i + 1) / totalChunks) * 100; - const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100; - onProgress(sendProgress, confirmProgress, `Sending: (${i + 1}/${totalChunks} packets)`); + const confirmProgress = + (this.notificationHandler.packetSuccessCount / totalChunks) * 100; + onProgress( + sendProgress, + confirmProgress, + `Sending: (${i + 1}/${totalChunks} packets)`, + ); } await this.sleep(this.chunkDelay * 1000); @@ -616,8 +612,13 @@ export class BleUploader { if (onProgress) { const sendProgress = ((i + 1) / totalChunks) * 100; - const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100; - onProgress(sendProgress, confirmProgress, `Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`); + const confirmProgress = + (this.notificationHandler.packetSuccessCount / totalChunks) * 100; + onProgress( + sendProgress, + confirmProgress, + `Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`, + ); } await this.sleep(100); @@ -636,7 +637,11 @@ export class BleUploader { * @returns True if all acks received without error */ public async waitForResponse( - onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void, + onProgress?: ( + sendProgress: number, + confirmProgress: number, + status?: string, + ) => void, ): Promise { this.notificationHandler.waitingForAck = true; @@ -668,7 +673,11 @@ export class BleUploader { (this.notificationHandler.packetSuccessCount / this.notificationHandler.expectedAckCount) * 100; - onProgress(100, confirmProgress, `Confirming: (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount} packets)`); + onProgress( + 100, + confirmProgress, + `Confirming: (${this.notificationHandler.packetSuccessCount}/${this.notificationHandler.expectedAckCount} packets)`, + ); } await this.sleep(100); // Check every 100ms diff --git a/src/lib/core/beambox-uploader.ts b/src/lib/core/beambox-uploader.ts index 56fbb6c..7578b47 100644 --- a/src/lib/core/beambox-uploader.ts +++ b/src/lib/core/beambox-uploader.ts @@ -61,6 +61,20 @@ export class BeamBoxUploader { this.payloadBuilder = new PayloadBuilder(this.protocolConfig); } + public setDeviceAddress(address: string): void { + this.ble.setDeviceAddress(address); + } + + /** + * Scan for all matching devices and return them so a UI can offer selection. + */ + public async scanForDevices( + onDeviceFound?: (device: { name: string | null; address: string }) => void, + signal?: AbortSignal, + ): Promise> { + return await this.ble.scanForDevices(onDeviceFound, signal); + } + /** * Connect to the device * @returns True if connected successfully diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts index d680469..b8950a3 100644 --- a/src/lib/utils/logger.ts +++ b/src/lib/utils/logger.ts @@ -14,6 +14,7 @@ export enum LogLevel { export enum LogEventType { SCAN_START = "scan_start", DEVICE_FOUND = "device_found", + DEVICES_FOUND = "devices_found", CONNECT_START = "connect_start", CONNECTED = "connected", DISCOVER_CHAR = "discover_char", diff --git a/src/types/node-ble.d.ts b/src/types/node-ble.d.ts new file mode 100644 index 0000000..14a6dc8 --- /dev/null +++ b/src/types/node-ble.d.ts @@ -0,0 +1,49 @@ +import type NodeBle from "node-ble"; + +/** Raw D-Bus variant value as returned by dbus-next */ +export interface DbusVariant { + value: T; +} + +/** org.bluez.Device1 interface properties from ObjectManager */ +export interface BluezDevice1Props { + Address?: DbusVariant; + Name?: DbusVariant; + Alias?: DbusVariant; + [key: string]: DbusVariant | undefined; +} + +/** Interfaces map from ObjectManager.GetManagedObjects / InterfacesAdded */ +export type BluezInterfacesMap = Record; + +/** Minimal subset of the dbus-next ObjectManager proxy */ +export interface DbusObjectManager { + GetManagedObjects(): Promise>; + on( + event: "InterfacesAdded", + listener: (objectPath: string, interfaces: BluezInterfacesMap) => void, + ): this; + removeAllListeners(event: "InterfacesAdded"): this; +} + +/** Minimal subset of the dbus-next MessageBus */ +export interface DbusBus { + getProxyObject( + service: string, + path: string, + ): Promise<{ + getInterface(iface: string): DbusObjectManager; + }>; +} + +/** node-ble BusHelper internal class */ +export interface NodeBleBusHelper { + callMethod(method: string, ...args: unknown[]): Promise; +} + +/** node-ble Adapter at runtime has these extra properties not in the public interface */ +export interface AdapterInternal extends NodeBle.Adapter { + dbus: DbusBus; + adapter: string; + helper: NodeBleBusHelper; +}