mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: dry run / dump mode
This commit is contained in:
@@ -1,12 +1,80 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { logger, LogLevel } from "../lib/utils/logger.ts";
|
import { logger, LogLevel } from "../lib/utils/logger.ts";
|
||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
|
import { join, basename } from "node:path";
|
||||||
import { scanDirectoryForImages } from "../utils/app-utils.ts";
|
import { scanDirectoryForImages } from "../utils/app-utils.ts";
|
||||||
import type { UploadOptions, StatusOptions } from "./types.ts";
|
import type { UploadOptions, StatusOptions } from "./types.ts";
|
||||||
import { render } from "ink";
|
import { render } from "ink";
|
||||||
import { App } from "../components/App.tsx";
|
import { App } from "../components/App.tsx";
|
||||||
import { StatusApp } from "../components/StatusApp.tsx";
|
import { StatusApp } from "../components/StatusApp.tsx";
|
||||||
import { MediaDetector } from "../lib/processing/media-detector.ts";
|
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() {
|
export function setupCLI() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -31,6 +99,10 @@ export function setupCLI() {
|
|||||||
.option("--animation-size <size>", "Animation size WxH", "360x360")
|
.option("--animation-size <size>", "Animation size WxH", "360x360")
|
||||||
.option("--test", "Upload 8x8 checkerboard test pattern", false)
|
.option("--test", "Upload 8x8 checkerboard test pattern", false)
|
||||||
.option("--packet-delay <ms>", "Delay between packets in milliseconds", "20")
|
.option("--packet-delay <ms>", "Delay between packets in milliseconds", "20")
|
||||||
|
.option(
|
||||||
|
"--dump <dir>",
|
||||||
|
"Dry run: build the payload and write it to <dir> instead of uploading (no device connection made)",
|
||||||
|
)
|
||||||
.action(async (imageArg: string | undefined, options: UploadOptions) => {
|
.action(async (imageArg: string | undefined, options: UploadOptions) => {
|
||||||
const globalOptions = program.opts() as { verbose: boolean };
|
const globalOptions = program.opts() as { verbose: boolean };
|
||||||
const verbose = globalOptions.verbose;
|
const verbose = globalOptions.verbose;
|
||||||
@@ -102,6 +174,11 @@ export function setupCLI() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.dump) {
|
||||||
|
await runDump(options, verbose);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let confirmMediaType: "gif" | "video" | null = null;
|
let confirmMediaType: "gif" | "video" | null = null;
|
||||||
if (options.image && !options.test) {
|
if (options.image && !options.test) {
|
||||||
const filesToCheck = options.isBulk
|
const filesToCheck = options.isBulk
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface UploadOptions {
|
|||||||
packetDelay: number;
|
packetDelay: number;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
isBulk?: boolean;
|
isBulk?: boolean;
|
||||||
|
dump?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusOptions {
|
export interface StatusOptions {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { FrameExtractor } from "../processing/frame-extractor.ts";
|
|||||||
import { PayloadBuilder } from "../protocol/index.ts";
|
import { PayloadBuilder } from "../protocol/index.ts";
|
||||||
import { logger } from "../utils/logger.ts";
|
import { logger } from "../utils/logger.ts";
|
||||||
import { UploadError } from "../utils/errors.ts";
|
import { UploadError } from "../utils/errors.ts";
|
||||||
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
export interface UploadOptions {
|
export interface UploadOptions {
|
||||||
imagePath?: string;
|
imagePath?: string;
|
||||||
@@ -23,6 +25,7 @@ export interface UploadOptions {
|
|||||||
targetSize?: [number, number];
|
targetSize?: [number, number];
|
||||||
animationSize?: [number, number];
|
animationSize?: [number, number];
|
||||||
onProgress?: (progress: number, status?: string) => void;
|
onProgress?: (progress: number, status?: string) => void;
|
||||||
|
dumpDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,8 +88,14 @@ export class BeamBoxUploader {
|
|||||||
* @returns True if upload successful
|
* @returns True if upload successful
|
||||||
*/
|
*/
|
||||||
public async upload(options: UploadOptions): Promise<boolean> {
|
public async upload(options: UploadOptions): Promise<boolean> {
|
||||||
const { imagePath, imageData, targetSize, animationSize, onProgress } =
|
const {
|
||||||
options;
|
imagePath,
|
||||||
|
imageData,
|
||||||
|
targetSize,
|
||||||
|
animationSize,
|
||||||
|
onProgress,
|
||||||
|
dumpDir,
|
||||||
|
} = options;
|
||||||
|
|
||||||
if (!imageData && !imagePath) {
|
if (!imageData && !imagePath) {
|
||||||
throw new UploadError("No image provided");
|
throw new UploadError("No image provided");
|
||||||
@@ -112,6 +121,7 @@ export class BeamBoxUploader {
|
|||||||
imagePath,
|
imagePath,
|
||||||
effectiveAnimationSize,
|
effectiveAnimationSize,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
dumpDir,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info("File is static image, using Type 6 (IMAGE)");
|
logger.info("File is static image, using Type 6 (IMAGE)");
|
||||||
@@ -131,10 +141,6 @@ export class BeamBoxUploader {
|
|||||||
throw new UploadError("No image provided");
|
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
|
// Build image data payload
|
||||||
const fullData = this.payloadBuilder.buildImageData(
|
const fullData = this.payloadBuilder.buildImageData(
|
||||||
jpegData,
|
jpegData,
|
||||||
@@ -149,6 +155,31 @@ export class BeamBoxUploader {
|
|||||||
// Validate against protocol limits
|
// Validate against protocol limits
|
||||||
this.validatePayloadLimits(fullData.length);
|
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
|
// Check device storage before upload
|
||||||
if (!this.checkStorageCapacity(fullData.length)) {
|
if (!this.checkStorageCapacity(fullData.length)) {
|
||||||
throw new UploadError(
|
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);
|
await this.ble.sendImageInfo(imageInfoPayload);
|
||||||
logger.info("Sent image info packet, proceeding to data transfer");
|
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<string, number>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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)
|
* Upload an animated GIF or video as Type 5 (DYNAMIC_AMBIENCE)
|
||||||
*
|
*
|
||||||
@@ -263,6 +359,7 @@ export class BeamBoxUploader {
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
targetSize: [number, number],
|
targetSize: [number, number],
|
||||||
onProgress?: (progress: number) => void,
|
onProgress?: (progress: number) => void,
|
||||||
|
dumpDir?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const animationSize: [number, number] = targetSize;
|
const animationSize: [number, number] = targetSize;
|
||||||
|
|
||||||
@@ -271,7 +368,6 @@ export class BeamBoxUploader {
|
|||||||
const MAX_ANIMATION_DURATION_SECS = 3;
|
const MAX_ANIMATION_DURATION_SECS = 3;
|
||||||
const ANIMATION_FPS = 20;
|
const ANIMATION_FPS = 20;
|
||||||
|
|
||||||
// Extract frames from the file
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`,
|
`Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`,
|
||||||
);
|
);
|
||||||
@@ -283,8 +379,6 @@ export class BeamBoxUploader {
|
|||||||
|
|
||||||
logger.info(`Extracted ${frames.length} frames`);
|
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;
|
const MIN_FRAMES = 2;
|
||||||
if (frames.length < MIN_FRAMES) {
|
if (frames.length < MIN_FRAMES) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -294,7 +388,7 @@ export class BeamBoxUploader {
|
|||||||
const lastFrame = frames[frames.length - 1]!;
|
const lastFrame = frames[frames.length - 1]!;
|
||||||
frames.push({
|
frames.push({
|
||||||
name: `frame_${String(frames.length + 1).padStart(5, "0")}`,
|
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`);
|
logger.info(`Padded to ${frames.length} frames`);
|
||||||
@@ -303,11 +397,6 @@ export class BeamBoxUploader {
|
|||||||
const intervalMs = Math.round(1000 / ANIMATION_FPS);
|
const intervalMs = Math.round(1000 / ANIMATION_FPS);
|
||||||
logger.info(`Using frame interval: ${intervalMs}ms`);
|
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(
|
const fullData = this.payloadBuilder.buildAnimationData(
|
||||||
frames,
|
frames,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
@@ -318,10 +407,31 @@ export class BeamBoxUploader {
|
|||||||
`Animation payload bytes: total=${fullData.length}, frames=${frames.length}`,
|
`Animation payload bytes: total=${fullData.length}, frames=${frames.length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate against protocol limits
|
|
||||||
this.validatePayloadLimits(fullData.length);
|
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)) {
|
if (!this.checkStorageCapacity(fullData.length)) {
|
||||||
throw new UploadError(
|
throw new UploadError(
|
||||||
`Insufficient device storage. Animation requires ${Math.ceil(fullData.length / 1024)}KB. ` +
|
`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);
|
await this.ble.sendImageInfo(imageInfoPayload);
|
||||||
logger.info("Sent animation info packet, proceeding to data transfer");
|
logger.info("Sent animation info packet, proceeding to data transfer");
|
||||||
|
|
||||||
@@ -372,12 +477,14 @@ export class BeamBoxUploader {
|
|||||||
targetSize?: [number, number],
|
targetSize?: [number, number],
|
||||||
animationSize?: [number, number],
|
animationSize?: [number, number],
|
||||||
onProgress?: (progress: number, status?: string) => void,
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
|
dumpDir?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await this.upload({
|
return await this.upload({
|
||||||
imagePath,
|
imagePath,
|
||||||
targetSize,
|
targetSize,
|
||||||
animationSize,
|
animationSize,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
dumpDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +499,7 @@ export class BeamBoxUploader {
|
|||||||
targetSize?: [number, number],
|
targetSize?: [number, number],
|
||||||
squares?: number,
|
squares?: number,
|
||||||
onProgress?: (progress: number, status?: string) => void,
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
|
dumpDir?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
|
const effectiveSize = targetSize ?? this.imageConfig.defaultSize;
|
||||||
const effectiveSquares = squares ?? this.imageConfig.checkerboardSquares;
|
const effectiveSquares = squares ?? this.imageConfig.checkerboardSquares;
|
||||||
@@ -413,6 +521,7 @@ export class BeamBoxUploader {
|
|||||||
imageData: jpegData,
|
imageData: jpegData,
|
||||||
targetSize: effectiveSize,
|
targetSize: effectiveSize,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
dumpDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user