Files
beamboxctl/src/lib/core/beambox-uploader.ts
T

365 lines
10 KiB
TypeScript
Raw Normal View History

2026-02-02 18:23:31 +07:00
import type {
BLEConfig,
ProtocolConfig,
ImageConfig,
} from "../protocol/index.ts";
import {
DEFAULT_BLE_CONFIG,
DEFAULT_PROTOCOL_CONFIG,
DEFAULT_IMAGE_CONFIG,
2026-02-04 12:37:40 +07:00
PacketType,
2026-02-02 18:23:31 +07:00
} from "../protocol/index.ts";
import { BleUploader } from "../ble/ble-client.ts";
import { ImageProcessor } from "../processing/image-processor.ts";
2026-02-04 12:37:40 +07:00
import { MediaDetector } from "../processing/media-detector.ts";
import { FrameExtractor } from "../processing/frame-extractor.ts";
2026-02-02 18:23:31 +07:00
import { PayloadBuilder } from "../protocol/index.ts";
import { logger } from "../utils/logger.ts";
import { UploadError } from "../utils/errors.ts";
export interface UploadOptions {
imagePath?: string;
imageData?: Buffer;
targetSize?: [number, number];
2026-02-04 12:37:40 +07:00
onProgress?: (progress: number, status?: string) => void;
2026-02-02 18:23:31 +07:00
}
/**
* Main uploader class
*/
export class BeamBoxUploader {
private ble: BleUploader;
private imageProcessor: ImageProcessor;
private payloadBuilder: PayloadBuilder;
private imageConfig: ImageConfig;
constructor(
deviceAddress?: string,
chunkDelay?: number,
bleConfig?: BLEConfig,
protocolConfig?: ProtocolConfig,
imageConfig?: ImageConfig,
verbose: boolean = false,
) {
this.imageConfig = imageConfig ?? DEFAULT_IMAGE_CONFIG;
this.ble = new BleUploader(
deviceAddress ?? null,
chunkDelay ?? null,
bleConfig ?? DEFAULT_BLE_CONFIG,
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
verbose,
);
this.imageProcessor = new ImageProcessor(this.imageConfig);
this.payloadBuilder = new PayloadBuilder(
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
);
}
/**
* Connect to the device
* @returns True if connected successfully
*/
public async connect(): Promise<boolean> {
return await this.ble.connect();
}
/**
* Disconnect from the device
*/
public async disconnect(): Promise<void> {
await this.ble.disconnect();
}
/**
2026-02-04 12:37:40 +07:00
* Upload an image, GIF, or video to the device
*
* Automatically detects file type and uses appropriate upload method:
* - Static images: Type 6 (IMAGE) - single frame
* - Animated GIFs: Type 5 (DYNAMIC_AMBIENCE), frames extracted
* - Videos: Type 5 (DYNAMIC_AMBIENCE), frames extracted
2026-02-02 18:23:31 +07:00
*
* @param options Upload options
* @returns True if upload successful
*/
public async upload(options: UploadOptions): Promise<boolean> {
const { imagePath, imageData, targetSize, onProgress } = options;
if (!imageData && !imagePath) {
throw new UploadError("No image provided");
}
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
2026-02-04 12:37:40 +07:00
// Detect file type if path provided
let isAnimated = false;
if (imagePath) {
const mediaInfo = await MediaDetector.detectFromFile(imagePath);
isAnimated = mediaInfo.type === "gif" || mediaInfo.type === "video";
logger.info(
`Detected file type: ${mediaInfo.type} (${mediaInfo.mimeType})`,
);
if (isAnimated) {
logger.info("File is animated, using Type 5 (DYNAMIC_AMBIENCE)");
return await this.uploadAnimation(imagePath, effectiveSize, onProgress);
} else {
logger.info("File is static image, using Type 6 (IMAGE)");
}
}
// Handle static image upload (Type 6)
2026-02-02 18:23:31 +07:00
let jpegData: Buffer;
if (imageData) {
jpegData = imageData;
} else if (imagePath) {
jpegData = await this.imageProcessor.prepareFromFile(
imagePath,
effectiveSize,
);
} else {
throw new UploadError("No image provided");
}
// Wait a moment after connection to ensure device is fully ready
logger.info("Waiting for device to be fully ready...");
await this.sleep(1000);
// Step 1: Send image info packet to announce upload
2026-02-04 12:37:40 +07:00
const imageInfoPayload = this.payloadBuilder.buildImageInfo(
PacketType.IMAGE,
1,
);
2026-02-02 18:23:31 +07:00
await this.ble.sendImageInfo(imageInfoPayload);
logger.info("Sent image info packet, proceeding to data transfer");
// Step 2: Build and send image data payload
const fullData = this.payloadBuilder.buildImageData(
jpegData,
effectiveSize,
2026-02-04 12:37:40 +07:00
PacketType.IMAGE,
2026-02-02 18:23:31 +07:00
);
const prefixLen = fullData.length - jpegData.length;
logger.info(
`Payload bytes: total=${fullData.length}, jpeg=${jpegData.length}, header+prefix=${prefixLen}`,
);
// Send data in chunks with protocol packets
2026-02-04 12:37:40 +07:00
const ok = await this.ble.sendData(fullData, PacketType.IMAGE, onProgress);
2026-02-02 18:23:31 +07:00
if (!ok) {
logger.error("Upload reported error");
return false;
}
2026-02-04 12:37:40 +07:00
// Wait for all acknowledgments
if (!(await this.ble.waitForResponse(onProgress))) {
2026-02-02 18:23:31 +07:00
logger.error("Upload timeout waiting for response");
return false;
}
return true;
}
2026-02-04 12:37:40 +07:00
/**
* Upload an animated GIF or video as Type 5 (DYNAMIC_AMBIENCE)
*
* @param filePath Path to the GIF or video file
* @param targetSize Target size for frames
* @param onProgress Progress callback
* @returns True if upload successful
*/
private async uploadAnimation(
filePath: string,
targetSize: [number, number],
onProgress?: (progress: number) => void,
): Promise<boolean> {
// Extract frames from the file
logger.info("Extracting frames from animation...");
const frames = await FrameExtractor.extractFrames(filePath, {
maxFrames: 100,
targetSize,
});
logger.info(`Extracted ${frames.length} frames`);
// Calculate frame interval
const intervalMs = await FrameExtractor.calculateFrameInterval(
filePath,
frames.length,
);
logger.info(`Using frame interval: ${intervalMs}ms`);
// Wait for device to be ready
logger.info("Waiting for device to be fully ready...");
await this.sleep(1000);
// Step 1: Send image info packet (Type 6 for the info)
const imageInfoPayload = this.payloadBuilder.buildImageInfo(
PacketType.IMAGE,
1,
);
await this.ble.sendImageInfo(imageInfoPayload);
logger.info("Sent animation info packet, proceeding to data transfer");
// Step 2: Build and send animation data payload (Type 5)
const fullData = this.payloadBuilder.buildAnimationData(
frames,
intervalMs,
targetSize,
);
logger.info(
`Animation payload bytes: total=${fullData.length}, frames=${frames.length}`,
);
// Send data in chunks with protocol packets using DYNAMIC_AMBIENCE packet type
const ok = await this.ble.sendData(
fullData,
PacketType.DYNAMIC_AMBIENCE,
onProgress,
);
if (!ok) {
logger.error("Animation upload reported error");
return false;
}
// Wait for all acknowledgments
if (!(await this.ble.waitForResponse(onProgress))) {
logger.error("Animation upload timeout waiting for response");
return false;
}
logger.info("Animation upload completed successfully");
return true;
}
2026-02-02 18:23:31 +07:00
/**
* Upload an image from a file
* @param imagePath Path to the image file
* @param targetSize Target image size
* @param onProgress Progress callback
* @returns True if upload successful
*/
public async uploadImageFromFile(
imagePath: string,
targetSize?: [number, number],
2026-02-04 12:37:40 +07:00
onProgress?: (progress: number, status?: string) => void,
2026-02-02 18:23:31 +07:00
): Promise<boolean> {
return await this.upload({ imagePath, targetSize, onProgress });
}
/**
* Upload a checkerboard test pattern
* @param targetSize Target image size
* @param squares Number of squares per side
* @param onProgress Progress callback
* @returns True if upload successful
*/
public async uploadCheckerboard(
targetSize?: [number, number],
squares?: number,
2026-02-04 12:37:40 +07:00
onProgress?: (progress: number, status?: string) => void,
2026-02-02 18:23:31 +07:00
): Promise<boolean> {
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
const effectiveSquares = squares ?? this.imageConfig.checkerboardSquares;
logger.info(
`Generating ${effectiveSquares}x${effectiveSquares} checkerboard pattern...`,
);
const checkerboardPng = await this.imageProcessor.generateCheckerboard(
effectiveSize,
effectiveSquares,
);
const jpegData = await this.imageProcessor.prepareImage(
checkerboardPng,
effectiveSize,
);
return await this.upload({
imageData: jpegData,
targetSize: effectiveSize,
onProgress,
});
}
/**
* Check if device is ready
* @returns True if device is ready
*/
public isDeviceReady(): boolean {
return this.ble.isDeviceReady();
}
/**
* Get device status notifications
* @param timeoutMs Maximum time to wait for status in milliseconds
* @returns Device status object and all notifications
*/
public async getStatus(timeoutMs: number = 10000): Promise<{
status: Record<string, unknown> | null;
notifications: Array<{ time: number; data: Buffer; parsed: any }>;
}> {
try {
logger.info("Connecting to device to get status...");
const connected = await this.ble.connect();
if (!connected) {
throw new Error("Failed to connect to device");
}
logger.info("Waiting for device status notifications...");
// Wait for at least one status notification (PacketType.DEVICE_STATUS)
const startTime = Date.now();
const checkInterval = 100;
while (Date.now() - startTime < timeoutMs) {
const deviceStatus = this.ble.getDeviceStatus();
if (deviceStatus && deviceStatus.type === 13) {
logger.info(
`Device status received: ${JSON.stringify(deviceStatus)}`,
);
logger.info("Device is ready for upload");
break;
}
await this.sleep(checkInterval);
}
const notifications = this.ble.getNotifications();
logger.info(`Received ${notifications.length} notifications from device`);
return {
status: this.ble.getDeviceStatus(),
notifications,
};
} finally {
await this.disconnect();
}
}
/**
* Get device status
* @returns Device status object
*/
public getDeviceStatus(): Record<string, unknown> | null {
return this.ble.getDeviceStatus();
}
/**
* Check if an error occurred
* @returns True if error occurred
*/
public hasError(): boolean {
return this.ble.hasError();
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}