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;
+}