feat: size limits and storage checks

This commit is contained in:
2026-02-10 13:53:46 +07:00
parent 6b109af42e
commit 5c33891968
3 changed files with 147 additions and 28 deletions
+131 -28
View File
@@ -33,6 +33,7 @@ export class BeamBoxUploader {
private imageProcessor: ImageProcessor;
private payloadBuilder: PayloadBuilder;
private imageConfig: ImageConfig;
private protocolConfig: ProtocolConfig;
private verbose: boolean = false;
constructor(
@@ -45,17 +46,16 @@ export class BeamBoxUploader {
) {
this.verbose = verbose;
this.imageConfig = imageConfig ?? DEFAULT_IMAGE_CONFIG;
this.protocolConfig = protocolConfig ?? DEFAULT_PROTOCOL_CONFIG;
this.ble = new BleUploader(
deviceAddress ?? null,
chunkDelay ?? null,
bleConfig ?? DEFAULT_BLE_CONFIG,
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
this.protocolConfig,
verbose,
);
this.imageProcessor = new ImageProcessor(this.imageConfig);
this.payloadBuilder = new PayloadBuilder(
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
);
this.payloadBuilder = new PayloadBuilder(this.protocolConfig);
}
/**
@@ -135,15 +135,7 @@ export class BeamBoxUploader {
logger.info("Waiting for device to be fully ready...");
await this.sleep(1000);
// 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");
// Step 2: Build and send image data payload
// Build image data payload
const fullData = this.payloadBuilder.buildImageData(
jpegData,
effectiveSize,
@@ -154,6 +146,26 @@ export class BeamBoxUploader {
`Payload bytes: total=${fullData.length}, jpeg=${jpegData.length}, header+prefix=${prefixLen}`,
);
// Validate against protocol limits
this.validatePayloadLimits(fullData.length);
// Check device storage before upload
if (!this.checkStorageCapacity(fullData.length)) {
throw new UploadError(
`Insufficient device storage. Image requires ${Math.ceil(fullData.length / 1024)}KB. ` +
`Try reducing image size or quality.`,
);
}
// 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");
// Step 2: Send image data payload
// Send data in chunks with protocol packets
const ok = await this.ble.sendData(fullData, PacketType.IMAGE, onProgress);
@@ -171,6 +183,74 @@ export class BeamBoxUploader {
return true;
}
/**
* Check if device has enough free storage for the upload
* @param payloadSizeBytes Size of the payload in bytes
* @returns True if enough space available, false otherwise
*/
private checkStorageCapacity(payloadSizeBytes: number): boolean {
const deviceStatus = this.ble.getDeviceStatus();
if (!deviceStatus) {
logger.warning("Device status not available, cannot check storage");
return false;
}
const freespaceKB = Number(deviceStatus.freespace) || 0;
const freespaceBytes = freespaceKB * 1024;
const payloadKB = Math.ceil(payloadSizeBytes / 1024);
logger.info(
`Storage check: payload=${payloadKB}KB, available=${freespaceKB}KB`,
);
// Add 10% safety margin to avoid filling device completely
const requiredBytes = Math.ceil(payloadSizeBytes * 1.1);
if (freespaceBytes < requiredBytes) {
logger.error(
`Insufficient storage: need ${Math.ceil(requiredBytes / 1024)}KB (with 10% margin), have ${freespaceKB}KB`,
);
return false;
}
return true;
}
/**
* Validate payload against protocol limits
* @param payloadSizeBytes Size of the payload in bytes
* @throws UploadError if payload exceeds safety limits
*/
private validatePayloadLimits(payloadSizeBytes: number): void {
// Check 2MB payload size limit (from iOS app)
if (payloadSizeBytes > this.protocolConfig.maxPayloadSize) {
const sizeMB = (payloadSizeBytes / (1024 * 1024)).toFixed(2);
const limitMB = (
this.protocolConfig.maxPayloadSize /
(1024 * 1024)
).toFixed(2);
throw new UploadError(
`Payload too large: ${sizeMB}MB exceeds ${limitMB}MB limit. ` +
`Reduce image quality, frame count, or dimensions.`,
);
}
// Check packet count limit (from iOS app: 20000 packets max)
const packetCount = Math.ceil(
payloadSizeBytes / this.protocolConfig.chunkSize,
);
if (packetCount > this.protocolConfig.maxPacketCount) {
throw new UploadError(
`Too many packets: ${packetCount} exceeds ${this.protocolConfig.maxPacketCount} limit. ` +
`Reduce payload size or increase chunk size.`,
);
}
logger.info(
`Payload validation passed: ${(payloadSizeBytes / 1024).toFixed(2)}KB, ${packetCount} packets`,
);
}
/**
* Upload an animated GIF or video as Type 5 (DYNAMIC_AMBIENCE)
*
@@ -185,7 +265,7 @@ export class BeamBoxUploader {
onProgress?: (progress: number) => void,
): Promise<boolean> {
const animationSize: [number, number] = targetSize;
// Extract frames from the file
logger.info(
`Extracting frames from animation at ${animationSize[0]}x${animationSize[1]}...`,
@@ -200,11 +280,13 @@ export class BeamBoxUploader {
// The xV4 format requires at least 2 frames
const MIN_FRAMES = 2;
if (frames.length < MIN_FRAMES) {
logger.info(`Padding from ${frames.length} to ${MIN_FRAMES} frames by duplicating last frame`);
logger.info(
`Padding from ${frames.length} to ${MIN_FRAMES} frames by duplicating last frame`,
);
while (frames.length < MIN_FRAMES) {
const lastFrame = frames[frames.length - 1]!;
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
});
}
@@ -223,15 +305,7 @@ export class BeamBoxUploader {
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)
// Build animation payload to check size
const fullData = this.payloadBuilder.buildAnimationData(
frames,
intervalMs,
@@ -242,6 +316,26 @@ 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
if (!this.checkStorageCapacity(fullData.length)) {
throw new UploadError(
`Insufficient device storage. Animation requires ${Math.ceil(fullData.length / 1024)}KB. ` +
`Try reducing frames or image quality.`,
);
}
// 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: Send animation data payload (Type 5)
// Send data in chunks with protocol packets using DYNAMIC_AMBIENCE packet type
const ok = await this.ble.sendData(
fullData,
@@ -277,7 +371,12 @@ export class BeamBoxUploader {
animationSize?: [number, number],
onProgress?: (progress: number, status?: string) => void,
): Promise<boolean> {
return await this.upload({ imagePath, targetSize, animationSize, onProgress });
return await this.upload({
imagePath,
targetSize,
animationSize,
onProgress,
});
}
/**
@@ -354,7 +453,9 @@ export class BeamBoxUploader {
// Log progress if we got new notifications
if (notifications.length > lastNotificationCount) {
logger.debug(`Received ${notifications.length} total notifications so far...`);
logger.debug(
`Received ${notifications.length} total notifications so far...`,
);
lastNotificationCount = notifications.length;
}
@@ -380,7 +481,9 @@ export class BeamBoxUploader {
if (notifications.length > 0 && this.verbose) {
logger.debug("Received notification types:");
notifications.forEach((n, i) => {
logger.debug(` [${i}] type: ${n.parsed?.jsonData?.type || 'unknown'}, data: ${JSON.stringify(n.parsed?.jsonData || {})}`);
logger.debug(
` [${i}] type: ${n.parsed?.jsonData?.type || "unknown"}, data: ${JSON.stringify(n.parsed?.jsonData || {})}`,
);
});
}
}
+12
View File
@@ -70,6 +70,18 @@ export interface ProtocolConfig {
* Maximum time (in seconds) to wait for a packet acknowledgment from the device
*/
packetAckTimeout: number;
/**
* Maximum number of packets allowed per upload
* Value: 20000 packets max
*/
maxPacketCount: number;
/**
* Maximum payload size in bytes allowed per upload
* Value: 2MB (2 * 1024 * 1024 bytes)
*/
maxPayloadSize: number;
}
/**
+4
View File
@@ -26,6 +26,8 @@ export const DEFAULT_BLE_CONFIG: BLEConfig = {
* - packetDelay: 0.1s - prevents overwhelming device buffer
* - imageInfoDelay: 0.01s - gives device time to prepare
* - packetAckTimeout: 2.0s - reasonable wait for response
* - maxPacketCount: 20000 - seem to be the app limit
* - maxPayloadSize: 2MB - seem to be the app limit
*/
export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = {
cmdType: CMD_TYPE,
@@ -35,6 +37,8 @@ export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = {
packetDelay: 0.1,
imageInfoDelay: 0.01,
packetAckTimeout: 2.0,
maxPacketCount: 20000,
maxPayloadSize: 2 * 1024 * 1024,
};
/**