diff --git a/src/cli/index.tsx b/src/cli/index.tsx index a72db2a..c89e82d 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1,12 +1,80 @@ import { Command } from "commander"; import { logger, LogLevel } from "../lib/utils/logger.ts"; import { statSync } from "node:fs"; +import { join, basename } from "node:path"; import { scanDirectoryForImages } from "../utils/app-utils.ts"; import type { UploadOptions, StatusOptions } from "./types.ts"; import { render } from "ink"; import { App } from "../components/App.tsx"; import { StatusApp } from "../components/StatusApp.tsx"; import { MediaDetector } from "../lib/processing/media-detector.ts"; +import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts"; + +async function runDump(options: UploadOptions, verbose: boolean) { + if (verbose) { + logger.setLevel(LogLevel.DEBUG); + } + + const [width, height] = options.size.split("x").map(Number); + const targetSize: [number, number] = [width!, height!]; + const [animationWidth, animationHeight] = options.animationSize + .split("x") + .map(Number); + const animationSize: [number, number] = [animationWidth!, animationHeight!]; + + const uploader = new BeamBoxUploader( + options.address, + undefined, + undefined, + undefined, + undefined, + verbose, + ); + + const filesToDump = options.isBulk ? (options.images ?? []) : []; + const dumpRoot = options.dump!; + + try { + if (options.test) { + console.log(`Dumping checkerboard test pattern to ${dumpRoot}`); + await uploader.uploadCheckerboard(targetSize, 8, undefined, dumpRoot); + console.log("Dump complete. No device connection was made."); + return; + } + + if (filesToDump.length > 0) { + for (const imagePath of filesToDump) { + const fileName = basename(imagePath); + const dumpDir = join(dumpRoot, fileName); + console.log(`Dumping ${fileName} -> ${dumpDir}`); + await uploader.uploadImageFromFile( + imagePath, + targetSize, + animationSize, + undefined, + dumpDir, + ); + } + } else if (options.image) { + console.log(`Dumping ${options.image} -> ${dumpRoot}`); + await uploader.uploadImageFromFile( + options.image, + targetSize, + animationSize, + undefined, + dumpRoot, + ); + } else { + console.error("Error: Either provide an image path or use --test flag"); + process.exit(1); + } + + console.log("Dump complete. No device connection was made."); + } catch (error) { + console.error(`Dump failed: ${error}`); + process.exit(1); + } +} export function setupCLI() { const program = new Command(); @@ -31,6 +99,10 @@ export function setupCLI() { .option("--animation-size ", "Animation size WxH", "360x360") .option("--test", "Upload 8x8 checkerboard test pattern", false) .option("--packet-delay ", "Delay between packets in milliseconds", "20") + .option( + "--dump ", + "Dry run: build the payload and write it to instead of uploading (no device connection made)", + ) .action(async (imageArg: string | undefined, options: UploadOptions) => { const globalOptions = program.opts() as { verbose: boolean }; const verbose = globalOptions.verbose; @@ -102,6 +174,11 @@ export function setupCLI() { process.exit(1); } + if (options.dump) { + await runDump(options, verbose); + return; + } + let confirmMediaType: "gif" | "video" | null = null; if (options.image && !options.test) { const filesToCheck = options.isBulk diff --git a/src/cli/types.ts b/src/cli/types.ts index 30ae43c..a56ce44 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -7,6 +7,7 @@ export interface UploadOptions { packetDelay: number; images?: string[]; isBulk?: boolean; + dump?: string; } export interface StatusOptions { diff --git a/src/lib/core/beambox-uploader.ts b/src/lib/core/beambox-uploader.ts index 2a820c4..75c7460 100644 --- a/src/lib/core/beambox-uploader.ts +++ b/src/lib/core/beambox-uploader.ts @@ -16,6 +16,8 @@ import { FrameExtractor } from "../processing/frame-extractor.ts"; import { PayloadBuilder } from "../protocol/index.ts"; import { logger } from "../utils/logger.ts"; import { UploadError } from "../utils/errors.ts"; +import { mkdir, writeFile } from "fs/promises"; +import { join } from "path"; export interface UploadOptions { imagePath?: string; @@ -23,6 +25,7 @@ export interface UploadOptions { targetSize?: [number, number]; animationSize?: [number, number]; onProgress?: (progress: number, status?: string) => void; + dumpDir?: string; } /** @@ -85,8 +88,14 @@ export class BeamBoxUploader { * @returns True if upload successful */ public async upload(options: UploadOptions): Promise { - const { imagePath, imageData, targetSize, animationSize, onProgress } = - options; + const { + imagePath, + imageData, + targetSize, + animationSize, + onProgress, + dumpDir, + } = options; if (!imageData && !imagePath) { throw new UploadError("No image provided"); @@ -112,6 +121,7 @@ export class BeamBoxUploader { imagePath, effectiveAnimationSize, onProgress, + dumpDir, ); } else { logger.info("File is static image, using Type 6 (IMAGE)"); @@ -131,10 +141,6 @@ export class BeamBoxUploader { 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); - // Build image data payload const fullData = this.payloadBuilder.buildImageData( jpegData, @@ -149,6 +155,31 @@ export class BeamBoxUploader { // Validate against protocol limits this.validatePayloadLimits(fullData.length); + // Step 1: Build image info packet to announce upload + const imageInfoPayload = this.payloadBuilder.buildImageInfo( + PacketType.IMAGE, + 1, + ); + + if (dumpDir) { + await this.dumpPayload(dumpDir, { + kind: "image", + packetType: PacketType.IMAGE, + infoPayload: imageInfoPayload, + fullData, + targetSize: effectiveSize, + extra: { + jpegBytes: jpegData.length, + headerAndPrefixBytes: prefixLen, + }, + }); + return true; + } + + // Wait a moment after connection to ensure device is fully ready + logger.info("Waiting for device to be fully ready..."); + await this.sleep(1000); + // Check device storage before upload if (!this.checkStorageCapacity(fullData.length)) { throw new UploadError( @@ -157,11 +188,6 @@ export class BeamBoxUploader { ); } - // Step 1: Send image info packet to announce upload - const imageInfoPayload = this.payloadBuilder.buildImageInfo( - PacketType.IMAGE, - 1, - ); await this.ble.sendImageInfo(imageInfoPayload); logger.info("Sent image info packet, proceeding to data transfer"); @@ -251,6 +277,76 @@ export class BeamBoxUploader { ); } + private async dumpPayload( + dumpDir: string, + info: { + kind: "image" | "animation"; + packetType: PacketType; + infoPayload: Buffer; + fullData: Buffer; + targetSize: [number, number]; + extra: Record; + }, + ): Promise { + const { kind, packetType, infoPayload, fullData, targetSize, extra } = + info; + + await mkdir(dumpDir, { recursive: true }); + + const infoPacket = this.payloadBuilder.createPacket( + infoPayload, + 1, + 0, + PacketType.IMAGE, + ); + + const chunkSize = this.protocolConfig.chunkSize; + const totalChunks = Math.ceil(fullData.length / chunkSize); + const dataPackets: Buffer[] = []; + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, fullData.length); + const chunk = fullData.subarray(start, end); + const remainingPackets = totalChunks - 1 - i; + dataPackets.push( + this.payloadBuilder.createPacket( + chunk, + totalChunks, + remainingPackets, + packetType, + ), + ); + } + + await writeFile(join(dumpDir, "info_packet.bin"), infoPacket); + await writeFile(join(dumpDir, "payload.bin"), fullData); + await writeFile( + join(dumpDir, "data_packets.bin"), + Buffer.concat(dataPackets), + ); + + const manifest = { + kind, + packetType, + targetSize, + payloadBytes: fullData.length, + chunkSize, + totalDataPackets: totalChunks, + infoPacketHex: infoPacket.toString("hex"), + payloadHexPreview: fullData.subarray(0, 64).toString("hex"), + ...extra, + }; + await writeFile( + join(dumpDir, "manifest.json"), + JSON.stringify(manifest, null, 2), + ); + + logger.info(`Dumped ${kind} upload to ${dumpDir}`); + logger.info( + ` payload.bin: ${fullData.length} bytes | data_packets.bin: ${totalChunks} packets | info_packet.bin: ${infoPacket.length} bytes`, + ); + } + /** * Upload an animated GIF or video as Type 5 (DYNAMIC_AMBIENCE) * @@ -263,6 +359,7 @@ export class BeamBoxUploader { filePath: string, targetSize: [number, number], onProgress?: (progress: number) => void, + dumpDir?: string, ): Promise { const animationSize: [number, number] = targetSize; @@ -271,7 +368,6 @@ export class BeamBoxUploader { const MAX_ANIMATION_DURATION_SECS = 3; const ANIMATION_FPS = 20; - // Extract frames from the file logger.info( `Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`, ); @@ -283,8 +379,6 @@ export class BeamBoxUploader { logger.info(`Extracted ${frames.length} frames`); - // Ensure minimum 2 frames for device compatibility - // The xV4 format requires at least 2 frames const MIN_FRAMES = 2; if (frames.length < MIN_FRAMES) { logger.info( @@ -294,7 +388,7 @@ export class BeamBoxUploader { const lastFrame = frames[frames.length - 1]!; frames.push({ name: `frame_${String(frames.length + 1).padStart(5, "0")}`, - data: lastFrame.data, // Reuse the same buffer + data: lastFrame.data, }); } logger.info(`Padded to ${frames.length} frames`); @@ -303,11 +397,6 @@ export class BeamBoxUploader { const intervalMs = Math.round(1000 / ANIMATION_FPS); 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); - - // Build animation payload to check size const fullData = this.payloadBuilder.buildAnimationData( frames, intervalMs, @@ -318,10 +407,31 @@ export class BeamBoxUploader { `Animation payload bytes: total=${fullData.length}, frames=${frames.length}`, ); - // Validate against protocol limits this.validatePayloadLimits(fullData.length); - // Check device storage before upload + const imageInfoPayload = this.payloadBuilder.buildImageInfo( + PacketType.IMAGE, + 1, + ); + + if (dumpDir) { + await this.dumpPayload(dumpDir, { + kind: "animation", + packetType: PacketType.DYNAMIC_AMBIENCE, + infoPayload: imageInfoPayload, + fullData, + targetSize: animationSize, + extra: { + frameCount: frames.length, + intervalMs, + }, + }); + return true; + } + + logger.info("Waiting for device to be fully ready..."); + await this.sleep(1000); + if (!this.checkStorageCapacity(fullData.length)) { throw new UploadError( `Insufficient device storage. Animation requires ${Math.ceil(fullData.length / 1024)}KB. ` + @@ -329,11 +439,6 @@ export class BeamBoxUploader { ); } - // 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"); @@ -372,12 +477,14 @@ export class BeamBoxUploader { targetSize?: [number, number], animationSize?: [number, number], onProgress?: (progress: number, status?: string) => void, + dumpDir?: string, ): Promise { return await this.upload({ imagePath, targetSize, animationSize, onProgress, + dumpDir, }); } @@ -392,6 +499,7 @@ export class BeamBoxUploader { targetSize?: [number, number], squares?: number, onProgress?: (progress: number, status?: string) => void, + dumpDir?: string, ): Promise { const effectiveSize = targetSize ?? this.imageConfig.defaultSize; const effectiveSquares = squares ?? this.imageConfig.checkerboardSquares; @@ -413,6 +521,7 @@ export class BeamBoxUploader { imageData: jpegData, targetSize: effectiveSize, onProgress, + dumpDir, }); }