feat: Implement interactive device selection

This commit is contained in:
2026-06-09 05:04:02 +07:00
parent ee2f3df464
commit 301a6eaad0
15 changed files with 572 additions and 79 deletions
+12
View File
@@ -6,6 +6,7 @@ import {
UploadProgress, UploadProgress,
ConnectionStatus, ConnectionStatus,
ConfirmAnimatedUpload, ConfirmAnimatedUpload,
DeviceSelector,
} from "./index.ts"; } from "./index.ts";
import { useUpload } from "../hooks/useUpload.ts"; import { useUpload } from "../hooks/useUpload.ts";
import type { UploadOptions } from "../cli/types.ts"; import type { UploadOptions } from "../cli/types.ts";
@@ -50,6 +51,9 @@ const UploadFlow: React.FC<{ options: UploadOptions; verbose: boolean }> = ({
totalFiles, totalFiles,
uploadSteps, uploadSteps,
connectionSteps, connectionSteps,
scannedDevices,
scanning,
onDeviceSelected,
} = useUpload(options, verbose); } = useUpload(options, verbose);
// Exit the app when done // Exit the app when done
@@ -77,6 +81,14 @@ const UploadFlow: React.FC<{ options: UploadOptions; verbose: boolean }> = ({
</Box> </Box>
)} )}
{(status === "scanning" || status === "selecting") && (
<DeviceSelector
devices={scannedDevices}
scanning={scanning}
onSelect={onDeviceSelected}
/>
)}
{status === "connecting" && ( {status === "connecting" && (
<Box flexDirection="column"> <Box flexDirection="column">
<Box> <Box>
+102
View File
@@ -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<DeviceSelectorProps> = ({
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 (
<Box flexDirection="column">
<Box marginBottom={1}>
{scanning ? (
<Box gap={1}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text color="cyan">Scanning for devices...</Text>
{devices.length > 0 && (
<Text color="gray">({devices.length} found so far)</Text>
)}
</Box>
) : (
<Text color="green" bold>
Found {devices.length} device{devices.length !== 1 ? "s" : ""}
</Text>
)}
</Box>
{devices.length > 0 && (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
{devices.map((device, i) => {
const isSelected = i === cursor;
return (
<Box key={device.address} gap={1}>
<Text color={isSelected ? "cyan" : "gray"}>
{isSelected ? "▶" : " "}
</Text>
<Box flexDirection="column">
<Text color={isSelected ? "white" : "gray"} bold={isSelected}>
{device.name ?? "(unnamed)"}
</Text>
<Text color={isSelected ? "cyan" : "gray"} dimColor={!isSelected}>
{device.address}
</Text>
</Box>
</Box>
);
})}
</Box>
)}
{!scanning && devices.length > 0 && (
<Box marginTop={1}>
<Text color="gray"> navigate Enter select Esc cancel</Text>
</Box>
)}
{!scanning && devices.length === 0 && (
<Text color="red">No devices found. Make sure your BeamBox is nearby and on.</Text>
)}
</Box>
);
};
+8 -1
View File
@@ -1,15 +1,22 @@
import React from "react"; import React from "react";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import BigText from "ink-big-text"; import BigText from "ink-big-text";
import { createRequire } from "node:module";
const PASTEL_PINK = "#FFB6C1"; const PASTEL_PINK = "#FFB6C1";
const sponsorLink = "github.com/sponsors/YuzuZensai"; 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 = () => { export const Header: React.FC = () => {
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<BigText text="BeamBox" font="tiny" /> <BigText text="BeamBox" font="tiny" />
<Text color="white">CLI tool for managing BeamBox e-Badge devices</Text> <Box gap={1}>
<Text color="white">CLI tool for managing BeamBox e-Badge devices</Text>
<Text color="gray">v{version}</Text>
</Box>
<Box> <Box>
<Text color={PASTEL_PINK}>Support my work {" "}</Text> <Text color={PASTEL_PINK}>Support my work {" "}</Text>
<Text color={PASTEL_PINK}> <Text color={PASTEL_PINK}>
+11 -3
View File
@@ -1,7 +1,7 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Box, Text, useApp } from "ink"; import { Box, Text, useApp } from "ink";
import Spinner from "ink-spinner"; 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 { useDeviceStatus } from "../hooks/useDeviceStatus.ts";
import type { StatusOptions } from "../cli/types.ts"; import type { StatusOptions } from "../cli/types.ts";
@@ -12,7 +12,7 @@ export interface StatusAppProps {
export const StatusApp: React.FC<StatusAppProps> = ({ options }) => { export const StatusApp: React.FC<StatusAppProps> = ({ options }) => {
const { exit } = useApp(); const { exit } = useApp();
const { loading, error, deviceStatus, notifications, connectionSteps } = const { selecting, loading, error, deviceStatus, notifications, connectionSteps, scannedDevices, scanning, onDeviceSelected } =
useDeviceStatus(options); useDeviceStatus(options);
// Exit the app when done // Exit the app when done
@@ -32,7 +32,15 @@ export const StatusApp: React.FC<StatusAppProps> = ({ options }) => {
<Box flexDirection="column" padding={1}> <Box flexDirection="column" padding={1}>
<Header /> <Header />
{loading && ( {selecting && (
<DeviceSelector
devices={scannedDevices}
scanning={scanning}
onSelect={onDeviceSelected}
/>
)}
{!selecting && loading && (
<Box flexDirection="column"> <Box flexDirection="column">
<Box> <Box>
<Text color="cyan"> <Text color="cyan">
+1
View File
@@ -1,4 +1,5 @@
export { Header } from './Header.tsx'; export { Header } from './Header.tsx';
export { DeviceSelector, type DiscoveredDevice } from './DeviceSelector.tsx';
export { ConfirmAnimatedUpload } from './ConfirmAnimatedUpload.tsx'; export { ConfirmAnimatedUpload } from './ConfirmAnimatedUpload.tsx';
export { UploadProgress } from './UploadProgress.tsx'; export { UploadProgress } from './UploadProgress.tsx';
export { Status } from './Status.tsx'; export { Status } from './Status.tsx';
+62
View File
@@ -0,0 +1,62 @@
import { useState, useRef } from "react";
import type { DiscoveredDevice } from "../components/index.ts";
export function useDeviceScanner() {
const [scannedDevices, setScannedDevices] = useState<DiscoveredDevice[]>([]);
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<DiscoveredDevice[]>;
setDeviceAddress: (a: string) => void;
}): Promise<DiscoveredDevice | null> => {
const scanAbort = new AbortController();
const selectionPromise = new Promise<DiscoveredDevice>((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 };
}
+19 -1
View File
@@ -5,10 +5,13 @@ import type { DeviceStatus, ParsedResponse } from "../lib/protocol/interfaces/in
import type { ConnectionStep } from "../components/index.ts"; import type { ConnectionStep } from "../components/index.ts";
import { updateStepStatus } from "../utils/app-utils.ts"; import { updateStepStatus } from "../utils/app-utils.ts";
import type { StatusOptions } from "../cli/types.ts"; import type { StatusOptions } from "../cli/types.ts";
import { useDeviceScanner } from "./useDeviceScanner.ts";
export function useDeviceStatus(options: StatusOptions) { export function useDeviceStatus(options: StatusOptions) {
const [selecting, setSelecting] = useState(!options.address);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { scannedDevices, scanning, onDeviceSelected, scanAndSelect } = useDeviceScanner();
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null); const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null);
const [notifications, setNotifications] = useState< const [notifications, setNotifications] = useState<
Array<{ time: number; data: Buffer; parsed: ParsedResponse }> Array<{ time: number; data: Buffer; parsed: ParsedResponse }>
@@ -92,7 +95,7 @@ export function useDeviceStatus(options: StatusOptions) {
try { try {
uploader = new BeamBoxUploader( uploader = new BeamBoxUploader(
options.address, options.address ?? undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
@@ -100,6 +103,17 @@ export function useDeviceStatus(options: StatusOptions) {
options.verbose, 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); const result = await uploader.getStatus(10000);
setDeviceStatus(result.status as DeviceStatus | null); setDeviceStatus(result.status as DeviceStatus | null);
@@ -135,10 +149,14 @@ export function useDeviceStatus(options: StatusOptions) {
}, [getStatus]); }, [getStatus]);
return { return {
selecting,
loading, loading,
error, error,
deviceStatus, deviceStatus,
notifications, notifications,
connectionSteps, connectionSteps,
scannedDevices,
scanning,
onDeviceSelected,
}; };
} }
+19 -4
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts"; import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts";
import { logger, LogEventType } from "../lib/utils/logger.ts"; import { logger, LogEventType } from "../lib/utils/logger.ts";
import { BeamBoxError } from "../lib/utils/errors.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 { basename } from "node:path";
import { updateStepStatus } from "../utils/app-utils.ts"; import { updateStepStatus } from "../utils/app-utils.ts";
import type { UploadOptions } from "../cli/types.ts"; import type { UploadOptions } from "../cli/types.ts";
import { useDeviceScanner } from "./useDeviceScanner.ts";
export function useUpload(options: UploadOptions, verbose: boolean) { export function useUpload(options: UploadOptions, verbose: boolean) {
const [status, setStatus] = useState< const [status, setStatus] = useState<
"connecting" | "uploading" | "success" | "error" "scanning" | "selecting" | "connecting" | "uploading" | "success" | "error"
>("connecting"); >("scanning");
const [message, setMessage] = useState<string>("Initializing..."); const [message, setMessage] = useState<string>("Initializing...");
const [sendProgress, setSendProgress] = useState<number>(0); const [sendProgress, setSendProgress] = useState<number>(0);
const [confirmProgress, setConfirmProgress] = useState<number>(0); const [confirmProgress, setConfirmProgress] = useState<number>(0);
const [currentFileIndex, setCurrentFileIndex] = useState<number>(0); const [currentFileIndex, setCurrentFileIndex] = useState<number>(0);
const [totalFiles, setTotalFiles] = useState<number>(1); const [totalFiles, setTotalFiles] = useState<number>(1);
const { scannedDevices, scanning, onDeviceSelected, scanAndSelect } = useDeviceScanner();
const [uploadSteps, setUploadSteps] = useState<ConnectionStep[]>([ const [uploadSteps, setUploadSteps] = useState<ConnectionStep[]>([
{ id: "image-info", label: "Sending image info", status: "pending" }, { id: "image-info", label: "Sending image info", status: "pending" },
@@ -129,7 +131,7 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
} }
uploader = new BeamBoxUploader( uploader = new BeamBoxUploader(
options.address, options.address ?? undefined,
packetDelaySeconds, packetDelaySeconds,
undefined, undefined,
undefined, undefined,
@@ -137,6 +139,16 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
verbose, 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"); setStatus("connecting");
setMessage("Connecting to BeamBox device..."); setMessage("Connecting to BeamBox device...");
@@ -282,5 +294,8 @@ export function useUpload(options: UploadOptions, verbose: boolean) {
totalFiles, totalFiles,
uploadSteps, uploadSteps,
connectionSteps, connectionSteps,
scannedDevices,
scanning,
onDeviceSelected,
}; };
} }
+7
View File
@@ -20,6 +20,13 @@ export interface BleBackend {
timeoutMs: number, timeoutMs: number,
): Promise<DiscoveredDevice | null>; ): Promise<DiscoveredDevice | null>;
scanForAll(
matcher: (device: DiscoveredDevice) => boolean,
timeoutMs: number,
onDeviceFound?: (device: DiscoveredDevice) => void,
signal?: AbortSignal,
): Promise<DiscoveredDevice[]>;
stopScan(): Promise<void>; stopScan(): Promise<void>;
connect(address: string): Promise<void>; connect(address: string): Promise<void>;
+136 -4
View File
@@ -7,6 +7,11 @@ import type {
BleCharacteristic, BleCharacteristic,
DiscoveredDevice, DiscoveredDevice,
} from "../backend.ts"; } from "../backend.ts";
import type {
AdapterInternal,
BluezInterfacesMap,
DbusObjectManager,
} from "../../../types/node-ble.d.ts";
const POLL_INTERVAL_MS = 1000; const POLL_INTERVAL_MS = 1000;
@@ -96,10 +101,6 @@ export class DBusBackend implements BleBackend {
await this.ensureDiscovering(); await this.ensureDiscovering();
const adapter = this.adapter; 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<string, string | null>(); const named = new Map<string, string | null>();
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -141,6 +142,127 @@ export class DBusBackend implements BleBackend {
return null; return null;
} }
async scanForAll(
matcher: (device: DiscoveredDevice) => boolean,
timeoutMs: number,
onDeviceFound?: (device: DiscoveredDevice) => void,
signal?: AbortSignal,
): Promise<DiscoveredDevice[]> {
if (!this.adapter) {
throw new ConnectionError("Bluetooth adapter not initialized");
}
await this.ensureDiscovering();
const adapter = this.adapter;
const found = new Map<string, DiscoveredDevice>();
return new Promise<DiscoveredDevice[]>((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<void> { async stopScan(): Promise<void> {
if (this.adapter && this.scanning) { if (this.adapter && this.scanning) {
await this.adapter.stopDiscovery().catch(() => {}); await this.adapter.stopDiscovery().catch(() => {});
@@ -227,9 +349,19 @@ export class DBusBackend implements BleBackend {
if (this.device) { if (this.device) {
this.device.removeAllListeners(); this.device.removeAllListeners();
const address = await this.device.getAddress().catch(() => null);
if (await this.device.isConnected().catch(() => false)) { if (await this.device.isConnected().catch(() => false)) {
await this.device.disconnect().catch(() => {}); 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; this.device = null;
} }
+56
View File
@@ -34,6 +34,7 @@ export class NobleBackend implements BleBackend {
readonly name = "noble"; readonly name = "noble";
private peripheral: Peripheral | null = null; private peripheral: Peripheral | null = null;
private peripheralsByAddress = new Map<string, Peripheral>();
private initialized = false; private initialized = false;
async init(): Promise<void> { async init(): Promise<void> {
@@ -107,6 +108,7 @@ export class NobleBackend implements BleBackend {
await this.stopScan(); await this.stopScan();
noble.removeListener("discover", onDiscover); noble.removeListener("discover", onDiscover);
this.peripheral = peripheral; this.peripheral = peripheral;
this.peripheralsByAddress.set(device.address, peripheral);
resolve(device); 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<DiscoveredDevice[]> {
return new Promise((resolve) => {
const found = new Map<string, DiscoveredDevice>();
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<void> { async stopScan(): Promise<void> {
await noble.stopScanningAsync().catch(() => {}); await noble.stopScanningAsync().catch(() => {});
} }
async connect(address: string): Promise<void> { async connect(address: string): Promise<void> {
const byAddress = this.peripheralsByAddress.get(address);
if (byAddress) {
this.peripheral = byAddress;
}
if (!this.peripheral) { if (!this.peripheral) {
throw new ConnectionError(`No discovered peripheral for ${address}`); throw new ConnectionError(`No discovered peripheral for ${address}`);
} }
+75 -66
View File
@@ -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 { public logSentPacket(packet: Buffer, description: string): void {
if (this.verbose) { if (this.verbose) {
const timestamp = new Date().toISOString(); 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<{ public getNotifications(): Array<{
time: number; time: number;
data: Buffer; data: Buffer;
@@ -174,10 +165,6 @@ class NotificationHandler {
return this.allNotifications; return this.allNotifications;
} }
/**
* Wait for device status to be received
* @returns Promise that resolves when status is received
*/
public waitForStatus(): Promise<void> { public waitForStatus(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.deviceStatusReceived) { 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<void> { public waitForNotification(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
this.notificationResolve = resolve; this.notificationResolve = resolve;
}); });
} }
/**
* Reset internal state
*/
public reset(): void { public reset(): void {
this.notificationResolve = null; this.notificationResolve = null;
this.statusResolve = null; this.statusResolve = null;
this.errorFlag = false; 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 { public setExpectedAckCount(count: number): void {
this.expectedAckCount = count; this.expectedAckCount = count;
this.packetSuccessCount = 0; this.packetSuccessCount = 0;
} }
} }
/**
* Manages BLE connection and data transfer to BeamBox device
*/
export class BleUploader { export class BleUploader {
private backend: BleBackend; private backend: BleBackend;
private writeCharacteristic: BleCharacteristic | null = null; private writeCharacteristic: BleCharacteristic | null = null;
@@ -230,6 +203,7 @@ export class BleUploader {
private verbose: boolean = false; private verbose: boolean = false;
private isInitialized: boolean = false; private isInitialized: boolean = false;
private connectedAddress: string | null = null; private connectedAddress: string | null = null;
private alreadyScanned: boolean = false;
constructor( constructor(
private deviceAddress: string | null, private deviceAddress: string | null,
@@ -246,9 +220,6 @@ export class BleUploader {
logger.debug(`Using BLE backend: ${this.backend.name}`); logger.debug(`Using BLE backend: ${this.backend.name}`);
} }
/**
* Initialize Bluetooth adapter and wait for it to be ready
*/
private async initBluetooth(): Promise<void> { private async initBluetooth(): Promise<void> {
if (this.isInitialized) { if (this.isInitialized) {
return; return;
@@ -258,36 +229,48 @@ export class BleUploader {
this.isInitialized = true; 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 { private normalizeUUID(uuid: string): string {
// Remove dashes and lowercase
const cleaned = uuid.replace(/-/g, "").toLowerCase(); const cleaned = uuid.replace(/-/g, "").toLowerCase();
// Strip Bluetooth Base UUID wrapper: 0000XXXX-0000-1000-8000-00805f9b34fb → XXXX
// If it's a full 128-bit UUID using Bluetooth Base UUID, extract the short form const match = cleaned.match(/^0000([0-9a-f]{4})00001000800000805f9b34fb$/);
// Bluetooth Base UUID pattern: 0000XXXX-0000-1000-8000-00805f9b34fb if (match && match[1]) return match[1];
const bluetoothBasePattern = /^0000([0-9a-f]{4})00001000800000805f9b34fb$/; if (cleaned.length <= 4) return cleaned.padStart(4, "0");
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");
}
return cleaned; return cleaned;
} }
/** public setDeviceAddress(address: string): void {
* Scan for the BeamBox device this.deviceAddress = address;
* @returns Device address or null if not found this.alreadyScanned = true;
*/ }
public async scanForDevices(
onDeviceFound?: (device: { name: string | null; address: string }) => void,
signal?: AbortSignal,
): Promise<Array<{ name: string | null; address: string }>> {
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<string | null> { public async findDevice(): Promise<string | null> {
await this.initBluetooth(); await this.initBluetooth();
@@ -297,9 +280,7 @@ export class BleUploader {
const timeout = this.bleConfig.scanTimeout * 1000; const timeout = this.bleConfig.scanTimeout * 1000;
const found = await this.backend.scanFor((device) => { const found = await this.backend.scanFor((device) => {
return ( return !!device.name && device.name.toLowerCase().includes(targetName);
!!device.name && device.name.toLowerCase().includes(targetName)
);
}, timeout); }, timeout);
if (!found) { if (!found) {
@@ -324,7 +305,13 @@ export class BleUploader {
await this.initBluetooth(); await this.initBluetooth();
if (!this.connectedAddress) { 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); logger.info("Scanning for device...", LogEventType.SCAN_START);
const address = await this.findDevice(); const address = await this.findDevice();
if (!address) { if (!address) {
@@ -516,7 +503,11 @@ export class BleUploader {
public async sendData( public async sendData(
fullData: Buffer, fullData: Buffer,
packetType: PacketType, packetType: PacketType,
onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void, onProgress?: (
sendProgress: number,
confirmProgress: number,
status?: string,
) => void,
): Promise<boolean> { ): Promise<boolean> {
if (!this.writeCharacteristic) { if (!this.writeCharacteristic) {
throw new ConnectionError("BLE client not connected"); throw new ConnectionError("BLE client not connected");
@@ -590,8 +581,13 @@ export class BleUploader {
// Report progress // Report progress
if (onProgress) { if (onProgress) {
const sendProgress = ((i + 1) / totalChunks) * 100; const sendProgress = ((i + 1) / totalChunks) * 100;
const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100; const confirmProgress =
onProgress(sendProgress, confirmProgress, `Sending: (${i + 1}/${totalChunks} packets)`); (this.notificationHandler.packetSuccessCount / totalChunks) * 100;
onProgress(
sendProgress,
confirmProgress,
`Sending: (${i + 1}/${totalChunks} packets)`,
);
} }
await this.sleep(this.chunkDelay * 1000); await this.sleep(this.chunkDelay * 1000);
@@ -616,8 +612,13 @@ export class BleUploader {
if (onProgress) { if (onProgress) {
const sendProgress = ((i + 1) / totalChunks) * 100; const sendProgress = ((i + 1) / totalChunks) * 100;
const confirmProgress = (this.notificationHandler.packetSuccessCount / totalChunks) * 100; const confirmProgress =
onProgress(sendProgress, confirmProgress, `Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`); (this.notificationHandler.packetSuccessCount / totalChunks) * 100;
onProgress(
sendProgress,
confirmProgress,
`Sending: (${this.notificationHandler.packetSuccessCount}/${totalChunks} packets)`,
);
} }
await this.sleep(100); await this.sleep(100);
@@ -636,7 +637,11 @@ export class BleUploader {
* @returns True if all acks received without error * @returns True if all acks received without error
*/ */
public async waitForResponse( public async waitForResponse(
onProgress?: (sendProgress: number, confirmProgress: number, status?: string) => void, onProgress?: (
sendProgress: number,
confirmProgress: number,
status?: string,
) => void,
): Promise<boolean> { ): Promise<boolean> {
this.notificationHandler.waitingForAck = true; this.notificationHandler.waitingForAck = true;
@@ -668,7 +673,11 @@ export class BleUploader {
(this.notificationHandler.packetSuccessCount / (this.notificationHandler.packetSuccessCount /
this.notificationHandler.expectedAckCount) * this.notificationHandler.expectedAckCount) *
100; 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 await this.sleep(100); // Check every 100ms
+14
View File
@@ -61,6 +61,20 @@ export class BeamBoxUploader {
this.payloadBuilder = new PayloadBuilder(this.protocolConfig); 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<Array<{ name: string | null; address: string }>> {
return await this.ble.scanForDevices(onDeviceFound, signal);
}
/** /**
* Connect to the device * Connect to the device
* @returns True if connected successfully * @returns True if connected successfully
+1
View File
@@ -14,6 +14,7 @@ export enum LogLevel {
export enum LogEventType { export enum LogEventType {
SCAN_START = "scan_start", SCAN_START = "scan_start",
DEVICE_FOUND = "device_found", DEVICE_FOUND = "device_found",
DEVICES_FOUND = "devices_found",
CONNECT_START = "connect_start", CONNECT_START = "connect_start",
CONNECTED = "connected", CONNECTED = "connected",
DISCOVER_CHAR = "discover_char", DISCOVER_CHAR = "discover_char",
+49
View File
@@ -0,0 +1,49 @@
import type NodeBle from "node-ble";
/** Raw D-Bus variant value as returned by dbus-next */
export interface DbusVariant<T = unknown> {
value: T;
}
/** org.bluez.Device1 interface properties from ObjectManager */
export interface BluezDevice1Props {
Address?: DbusVariant<string>;
Name?: DbusVariant<string>;
Alias?: DbusVariant<string>;
[key: string]: DbusVariant | undefined;
}
/** Interfaces map from ObjectManager.GetManagedObjects / InterfacesAdded */
export type BluezInterfacesMap = Record<string, BluezDevice1Props>;
/** Minimal subset of the dbus-next ObjectManager proxy */
export interface DbusObjectManager {
GetManagedObjects(): Promise<Record<string, BluezInterfacesMap>>;
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<unknown>;
}
/** 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;
}