mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: Implement interactive device selection
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Vendored
+49
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user