mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
you know, p-chan, ive always really - *clank clank clank clank*
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"knownPackets": {
|
||||
"imageInfo": {
|
||||
"description": "Image info packet: {\"type\":6,\"number\":1}",
|
||||
"hex": "f10600000000001a7b2274797065223a362c226e756d626572223a317d77"
|
||||
},
|
||||
"deviceStatusResponse": {
|
||||
"description": "Device status response Type 13",
|
||||
"hex": "7b2274797065223a31332c22616c6c7370616365223a31363338342c22667265657370616365223a31333839322c226465766e616d65223a224265616d426f78222c2273697a65223a223634783332222c226272616e64223a317d",
|
||||
"json": {
|
||||
"type": 13,
|
||||
"allspace": 16384,
|
||||
"freespace": 13892,
|
||||
"devname": "BeamBox",
|
||||
"size": "64x32",
|
||||
"brand": 1
|
||||
}
|
||||
},
|
||||
"getPacketSuccess": {
|
||||
"description": "Success response from device",
|
||||
"text": "GetPacketSuccess"
|
||||
},
|
||||
"getPacketFail": {
|
||||
"description": "Failure response from device",
|
||||
"text": "GetPacketFail"
|
||||
},
|
||||
"errorResponse": {
|
||||
"description": "Error response from device",
|
||||
"text": "1111111111"
|
||||
}
|
||||
},
|
||||
"expectedHeaders": [
|
||||
{
|
||||
"description": "1KB JPEG, 64x32",
|
||||
"jpegSize": 1024,
|
||||
"width": 64,
|
||||
"height": 32,
|
||||
"hex": "494d4200000000240000041800000b0040002000000024000004000000000000000000"
|
||||
},
|
||||
{
|
||||
"description": "100 byte JPEG, 128x64",
|
||||
"jpegSize": 100,
|
||||
"width": 128,
|
||||
"height": 64,
|
||||
"hex": "494d4200000000240000008800000b0080004000000024000000640000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Test utilities and helpers for BeamBox tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compare buffer content as hex strings
|
||||
*/
|
||||
export function expectHex(actual: Buffer, expected: string): void {
|
||||
const actualHex = actual.toString("hex").toLowerCase();
|
||||
const expectedHex = expected.toLowerCase().replace(/\s/g, "");
|
||||
|
||||
if (actualHex !== expectedHex) {
|
||||
throw new Error(
|
||||
`Hex mismatch:\nExpected: ${expectedHex}\nActual: ${actualHex}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Buffer
|
||||
*/
|
||||
export function hexToBuffer(hex: string): Buffer {
|
||||
return Buffer.from(hex.replace(/\s/g, ""), "hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test JPEG buffer (minimal valid JPEG)
|
||||
* This is a 1x1 pixel red JPEG
|
||||
*/
|
||||
export function createTestJpeg(size: number = 631): Buffer {
|
||||
// Minimal valid JPEG header + data
|
||||
const minimalJpeg = Buffer.from([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
|
||||
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
|
||||
0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,
|
||||
0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,
|
||||
0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,
|
||||
0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
|
||||
0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
|
||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x03, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
|
||||
0x37, 0xff, 0xd9,
|
||||
]);
|
||||
|
||||
if (size <= minimalJpeg.length) {
|
||||
return minimalJpeg;
|
||||
}
|
||||
|
||||
// Pad to requested size by inserting data before EOI marker
|
||||
const padding = Buffer.alloc(size - minimalJpeg.length, 0xff);
|
||||
const withoutEOI = minimalJpeg.subarray(0, minimalJpeg.length - 2);
|
||||
const EOI = minimalJpeg.subarray(minimalJpeg.length - 2);
|
||||
return Buffer.concat([withoutEOI, padding, EOI]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test PNG buffer (minimal valid PNG)
|
||||
* This is a 1x1 pixel transparent PNG
|
||||
*/
|
||||
export function createTestPng(): Buffer {
|
||||
return Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
||||
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate checksum for protocol packet
|
||||
*/
|
||||
export function calculateChecksum(data: Buffer): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i]!;
|
||||
}
|
||||
return (-sum & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock logger that captures log calls
|
||||
*/
|
||||
export class MockLogger {
|
||||
public logs: Array<{ level: string; message: string; args: any[] }> = [];
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
this.logs.push({ level: "info", message, args });
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
this.logs.push({ level: "debug", message, args });
|
||||
}
|
||||
|
||||
warning(message: string, ...args: any[]) {
|
||||
this.logs.push({ level: "warning", message, args });
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
this.logs.push({ level: "error", message, args });
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
hasLog(level: string, messageFragment: string): boolean {
|
||||
return this.logs.some(
|
||||
(log) => log.level === level && log.message.includes(messageFragment)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Command } from "commander";
|
||||
import { logger, LogLevel } from "../lib/utils/logger.ts";
|
||||
import { statSync } from "node:fs";
|
||||
import { scanDirectoryForImages } from "../utils/app-utils.ts";
|
||||
import type { UploadOptions, StatusOptions } from "./types.ts";
|
||||
|
||||
export function setupCLI() {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("beamboxctl")
|
||||
.description("CLI tool for managing BeamBox e-Badge devices")
|
||||
.version("1.0.0")
|
||||
.option("-v, --verbose", "Show detailed logs including packet data", false);
|
||||
|
||||
program
|
||||
.command("upload [image]")
|
||||
.description(
|
||||
"Upload image(s) to BeamBox device (supports single file or directory for bulk upload)",
|
||||
)
|
||||
.option(
|
||||
"--image <path>",
|
||||
"Path to image file or directory (alternative to positional argument)",
|
||||
)
|
||||
.option("--address <address>", "BLE device address (optional)")
|
||||
.option("--size <size>", "Target size WxH", "368x368")
|
||||
.option("--test", "Upload 8x8 checkerboard test pattern", false)
|
||||
.option("--packet-delay <ms>", "Delay between packets in milliseconds", "20")
|
||||
.action(async (imageArg: string | undefined, options: UploadOptions) => {
|
||||
const globalOptions = program.opts() as { verbose: boolean };
|
||||
const verbose = globalOptions.verbose;
|
||||
|
||||
if (verbose) {
|
||||
logger.setLevel(LogLevel.DEBUG);
|
||||
}
|
||||
|
||||
const imagePath = imageArg || options.image;
|
||||
|
||||
if (imagePath) {
|
||||
options.image = imagePath;
|
||||
}
|
||||
|
||||
if (!options.test && !options.image) {
|
||||
console.error("Error: Either provide an image path or use --test flag");
|
||||
console.error(
|
||||
"Usage: beamboxctl upload <image> or beamboxctl upload --image <path> or beamboxctl upload --test",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.image) {
|
||||
try {
|
||||
const stat = statSync(options.image);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const images = scanDirectoryForImages(options.image);
|
||||
|
||||
if (images.length === 0) {
|
||||
console.error(
|
||||
`Error: No image files found in directory: ${options.image}`,
|
||||
);
|
||||
console.error(
|
||||
"Supported formats: .jpg, .jpeg, .png, .gif, .bmp, .webp",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found ${images.length} image(s) in directory`);
|
||||
options.images = images;
|
||||
options.isBulk = true;
|
||||
} else if (stat.isFile()) {
|
||||
options.isBulk = false;
|
||||
} else {
|
||||
console.error(
|
||||
`Error: Path is not a file or directory: ${options.image}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: Cannot access path: ${options.image}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const sizeRegex = /^\d+x\d+$/;
|
||||
if (!sizeRegex.test(options.size)) {
|
||||
console.error(
|
||||
"Error: Invalid size format. Use WIDTHxHEIGHT (e.g., 368x368)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { App } = await import("../components/App.tsx");
|
||||
const { render } = await import("ink");
|
||||
render(<App options={options} verbose={verbose} />);
|
||||
});
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Get device status and notifications")
|
||||
.option("--address <address>", "BLE device address (optional)")
|
||||
.action(async (options: StatusOptions) => {
|
||||
const globalOptions = program.opts() as { verbose: boolean };
|
||||
const verbose = globalOptions.verbose;
|
||||
|
||||
if (verbose) {
|
||||
logger.setLevel(LogLevel.DEBUG);
|
||||
}
|
||||
|
||||
const { StatusApp } = await import("../components/StatusApp.tsx");
|
||||
const { render } = await import("ink");
|
||||
render(<StatusApp options={options} verbose={verbose} />);
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface UploadOptions {
|
||||
image?: string;
|
||||
address?: string;
|
||||
size: string;
|
||||
test: boolean;
|
||||
packetDelay: number;
|
||||
images?: string[];
|
||||
isBulk?: boolean;
|
||||
}
|
||||
|
||||
export interface StatusOptions {
|
||||
address?: string;
|
||||
verbose: boolean;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import {
|
||||
Header,
|
||||
UploadProgress,
|
||||
ConnectionStatus,
|
||||
} from "./index.ts";
|
||||
import { useUpload } from "../hooks/useUpload.ts";
|
||||
import type { UploadOptions } from "../cli/types.ts";
|
||||
|
||||
export interface AppProps {
|
||||
options: UploadOptions;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({ options, verbose }) => {
|
||||
const {
|
||||
status,
|
||||
message,
|
||||
progress,
|
||||
currentFileIndex,
|
||||
totalFiles,
|
||||
uploadSteps,
|
||||
connectionSteps,
|
||||
} = useUpload(options, verbose);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Header />
|
||||
|
||||
{options.isBulk && totalFiles > 1 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color="blue" bold>
|
||||
Bulk Upload: {currentFileIndex}/{totalFiles} files
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === "connecting" && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="cyan" bold>
|
||||
{" "}
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
<ConnectionStatus steps={connectionSteps} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === "uploading" && (
|
||||
<Box flexDirection="column">
|
||||
<UploadProgress
|
||||
status={status}
|
||||
message={message}
|
||||
progress={progress}
|
||||
/>
|
||||
<ConnectionStatus steps={uploadSteps} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === "success" && (
|
||||
<>
|
||||
<UploadProgress
|
||||
status={status}
|
||||
message={message}
|
||||
progress={progress}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text color="green">Device is ready to use!</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<>
|
||||
<UploadProgress
|
||||
status={status}
|
||||
message={message}
|
||||
progress={progress}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">Please check your device and try again.</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
export interface ConnectionStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'active' | 'complete' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
steps: ConnectionStep[];
|
||||
}
|
||||
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({ steps }) => {
|
||||
const activeStep = steps.find(s => s.status === 'active');
|
||||
const lastCompletedStep = [...steps].reverse().find(s => s.status === 'complete');
|
||||
const errorStep = steps.find(s => s.status === 'error');
|
||||
|
||||
if (errorStep) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">✗ {errorStep.label} failed</Text>
|
||||
{errorStep.error && (
|
||||
<Text color="dim"> ({errorStep.error})</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeStep) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">{activeStep.label}</Text>
|
||||
<Text color="dim">...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastCompletedStep) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="green">✓ {lastCompletedStep.label}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const firstPending = steps.find(s => s.status === 'pending');
|
||||
if (firstPending) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">{firstPending.label}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import BigText from "ink-big-text";
|
||||
|
||||
const PASTEL_PINK = "#FFB6C1";
|
||||
const sponsorLink = "github.com/sponsors/YuzuZensai";
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<BigText text="BeamBox" font="tiny" />
|
||||
<Text color="white">CLI tool for managing BeamBox e-Badge devices</Text>
|
||||
<Box>
|
||||
<Text color={PASTEL_PINK}>Support my work ♡{" "}</Text>
|
||||
<Text color={PASTEL_PINK}>
|
||||
{`\x1b]8;;https://${sponsorLink}\x07${sponsorLink}\x1b]8;;\x07`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from "react";
|
||||
import { Box, Text, Newline } from "ink";
|
||||
import type { DeviceStatus, ParsedResponse } from "../lib/protocol/interfaces/index.ts";
|
||||
|
||||
interface StatusProps {
|
||||
status: DeviceStatus | null;
|
||||
notifications: Array<{ time: number; data: Buffer; parsed: ParsedResponse }>;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(2)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const StorageBar: React.FC<{ used: number; total: number }> = ({
|
||||
used,
|
||||
total,
|
||||
}) => {
|
||||
const percentage = Math.round((used / total) * 100);
|
||||
const barLength = 20;
|
||||
const filledLength = Math.round((used / total) * barLength);
|
||||
const filled = "█".repeat(filledLength);
|
||||
const empty = "░".repeat(barLength - filledLength);
|
||||
|
||||
const getBarColor = () => {
|
||||
if (percentage < 50) return "green";
|
||||
if (percentage < 75) return "yellow";
|
||||
return "red";
|
||||
};
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">[</Text>
|
||||
<Text color={getBarColor()}>{filled}</Text>
|
||||
<Text color="dim">{empty}]</Text>
|
||||
<Text color="dim"> {percentage}%</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const Status: React.FC<StatusProps> = ({
|
||||
status,
|
||||
notifications,
|
||||
verbose,
|
||||
}) => {
|
||||
const allspace = typeof status?.allspace === "number" ? status.allspace : 0;
|
||||
const freespace =
|
||||
typeof status?.freespace === "number" ? status.freespace : 0;
|
||||
const usedSpace = allspace - freespace;
|
||||
const freePercentage =
|
||||
allspace > 0 ? Math.round((freespace / allspace) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{status && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
Device Status
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} paddingLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
Storage Information
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Total:</Text>
|
||||
</Box>
|
||||
<Text color="white">{formatBytes(allspace)}</Text>
|
||||
</Box>
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Free:</Text>
|
||||
</Box>
|
||||
<Text color="green">{formatBytes(freespace)}</Text>
|
||||
<Text color="dim"> ({freePercentage}%)</Text>
|
||||
</Box>
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Used:</Text>
|
||||
</Box>
|
||||
<Text color="yellow">{formatBytes(usedSpace)}</Text>
|
||||
<Text color="dim"> ({100 - freePercentage}%)</Text>
|
||||
</Box>
|
||||
|
||||
<StorageBar used={usedSpace} total={allspace} />
|
||||
</Box>
|
||||
|
||||
<Box marginTop={2}>
|
||||
<Text color="cyan" bold>
|
||||
Device Information
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
{status.size !== undefined && status.size !== null && (
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Resolution:</Text>
|
||||
</Box>
|
||||
<Text color="white">
|
||||
{String(status.size).replace(",", "x")}
|
||||
</Text>
|
||||
<Text color="dim"> pixels</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.devname !== undefined && (
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Name:</Text>
|
||||
</Box>
|
||||
<Text>{String(status.devname) || "<not set>"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{typeof status.brand === "number" && (
|
||||
<Box marginBottom={0.5}>
|
||||
<Box width={12}>
|
||||
<Text color="gray">Brand:</Text>
|
||||
</Box>
|
||||
<Text>#{status.brand}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{verbose && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color="dim">Raw JSON:</Text>
|
||||
<Newline />
|
||||
<Text color="gray">{JSON.stringify(status, null, 2)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!status && (
|
||||
<Box>
|
||||
<Text color="yellow">⚠ No device status received</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{verbose && notifications.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Protocol Notifications: {notifications.length}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{notifications.slice(0, 20).map((notif, index) => {
|
||||
const timestamp = new Date(notif.time).toLocaleTimeString();
|
||||
return (
|
||||
<Box key={index} marginBottom={1} paddingLeft={2}>
|
||||
<Box>
|
||||
<Text color="dim">
|
||||
[{index + 1}] {timestamp}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={0.5}>
|
||||
<Text color="gray">{notif.data.length} bytes</Text>
|
||||
{verbose && (
|
||||
<Text color="dim">
|
||||
{" "}
|
||||
| Hex: {notif.data.toString("hex")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{notif.parsed.rawText && (
|
||||
<Box marginTop={0.5} paddingLeft={4}>
|
||||
<Text color="cyan">Text:</Text>
|
||||
<Text> {notif.parsed.rawText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{notifications.length > 20 && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
... and {notifications.length - 20} more notifications
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import { Header, Status, ConnectionStatus } from "./index.ts";
|
||||
import { useDeviceStatus } from "../hooks/useDeviceStatus.ts";
|
||||
import type { StatusOptions } from "../cli/types.ts";
|
||||
|
||||
export interface StatusAppProps {
|
||||
options: StatusOptions;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export const StatusApp: React.FC<StatusAppProps> = ({ options }) => {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
deviceStatus,
|
||||
notifications,
|
||||
connectionSteps,
|
||||
} = useDeviceStatus(options);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Header />
|
||||
|
||||
{loading && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text color="cyan" bold>
|
||||
{" "}
|
||||
Connecting to BeamBox device...
|
||||
</Text>
|
||||
</Box>
|
||||
<ConnectionStatus steps={connectionSteps} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="red" bold>
|
||||
✗ Connection Error
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">{error}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">
|
||||
Make sure your device is powered on and in range.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<Status
|
||||
status={deviceStatus}
|
||||
notifications={notifications}
|
||||
verbose={options.verbose}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
|
||||
interface UploadProgressProps {
|
||||
status: "connecting" | "uploading" | "success" | "error";
|
||||
message?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => {
|
||||
const percentage = Math.round(progress);
|
||||
const barLength = 30;
|
||||
const filledLength = Math.round((progress / 100) * barLength);
|
||||
const filled = "█".repeat(filledLength);
|
||||
const empty = "░".repeat(barLength - filledLength);
|
||||
|
||||
const getBarColor = () => {
|
||||
if (percentage < 100) return "cyan";
|
||||
return "green";
|
||||
};
|
||||
|
||||
const barContent = filled + empty;
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">[</Text>
|
||||
<Text color={getBarColor()}>{barContent}</Text>
|
||||
<Text color="dim">] {percentage}%</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const UploadProgress: React.FC<UploadProgressProps> = ({
|
||||
status,
|
||||
message,
|
||||
progress,
|
||||
}) => {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "connecting":
|
||||
return "cyan";
|
||||
case "uploading":
|
||||
return "blue";
|
||||
case "success":
|
||||
return "green";
|
||||
case "error":
|
||||
return "red";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "✓";
|
||||
case "error":
|
||||
return "✗";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = getStatusIcon();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box>
|
||||
{!statusIcon && (
|
||||
<Text color={getStatusColor()}>
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
)}
|
||||
{statusIcon && <Text color={getStatusColor()}>{statusIcon}</Text>}
|
||||
<Box marginLeft={1}>
|
||||
<Text color={getStatusColor()} bold>
|
||||
{message || status.toUpperCase()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{progress !== undefined && status === "uploading" && (
|
||||
<ProgressBar progress={progress} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Header } from './Header.tsx';
|
||||
export { UploadProgress } from './UploadProgress.tsx';
|
||||
export { Status } from './Status.tsx';
|
||||
export { ConnectionStatus, type ConnectionStep } from './ConnectionStatus.tsx';
|
||||
export { App } from './App.tsx';
|
||||
export { StatusApp } from './StatusApp.tsx';
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts";
|
||||
import { logger, LogEventType } from "../lib/utils/logger.ts";
|
||||
import type { DeviceStatus, ParsedResponse } from "../lib/protocol/interfaces/index.ts";
|
||||
import type { ConnectionStep } from "../components/index.ts";
|
||||
import { updateStepStatus } from "../utils/app-utils.ts";
|
||||
import type { StatusOptions } from "../cli/types.ts";
|
||||
|
||||
export function useDeviceStatus(options: StatusOptions) {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null);
|
||||
const [notifications, setNotifications] = useState<
|
||||
Array<{ time: number; data: Buffer; parsed: ParsedResponse }>
|
||||
>([]);
|
||||
const [connectionSteps, setConnectionSteps] = useState<ConnectionStep[]>([
|
||||
{ id: "scan", label: "Scanning for device", status: "pending" },
|
||||
{ id: "connect", label: "Connecting to device", status: "pending" },
|
||||
{ id: "discover", label: "Discovering characteristics", status: "pending" },
|
||||
{
|
||||
id: "wait-status",
|
||||
label: "Waiting for device status",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: "Collecting notifications",
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
const hasCalledGetStatus = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.onLog((entry) => {
|
||||
const eventType = entry.eventType;
|
||||
|
||||
switch (eventType) {
|
||||
case LogEventType.SCAN_START:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "scan", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DEVICE_FOUND:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "scan", "complete", "connect"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.CONNECT_START:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "connect", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.CONNECTED:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "connect", "complete", "discover"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DISCOVER_CHAR:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "discover", "complete", "wait-status"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.STATUS_WAIT:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "wait-status", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.STATUS_RECEIVED:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "wait-status", "complete", "notifications"),
|
||||
);
|
||||
setTimeout(() => {
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "notifications", "complete"),
|
||||
);
|
||||
}, 100);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const getStatus = useCallback(async () => {
|
||||
let uploader: BeamBoxUploader | null = null;
|
||||
|
||||
try {
|
||||
uploader = new BeamBoxUploader(
|
||||
options.address,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
options.verbose,
|
||||
);
|
||||
|
||||
const result = await uploader.getStatus(10000);
|
||||
|
||||
setDeviceStatus(result.status as DeviceStatus | null);
|
||||
setNotifications(result.notifications);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
|
||||
setConnectionSteps((prev) => {
|
||||
const firstPending = prev.find(
|
||||
(s) => s.status === "pending" || s.status === "active",
|
||||
);
|
||||
if (firstPending) {
|
||||
return prev.map((step) =>
|
||||
step.id === firstPending.id
|
||||
? { ...step, status: "error", error: "Failed" }
|
||||
: step,
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCalledGetStatus.current) {
|
||||
hasCalledGetStatus.current = true;
|
||||
getStatus();
|
||||
}
|
||||
}, [getStatus]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
deviceStatus,
|
||||
notifications,
|
||||
connectionSteps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { BeamBoxUploader } from "../lib/core/beambox-uploader.ts";
|
||||
import { logger, LogEventType } from "../lib/utils/logger.ts";
|
||||
import { BeamBoxError } from "../lib/utils/errors.ts";
|
||||
import type { ConnectionStep } from "../components/index.ts";
|
||||
import { basename } from "node:path";
|
||||
import { updateStepStatus } from "../utils/app-utils.ts";
|
||||
import type { UploadOptions } from "../cli/types.ts";
|
||||
|
||||
export function useUpload(options: UploadOptions, verbose: boolean) {
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "uploading" | "success" | "error"
|
||||
>("connecting");
|
||||
const [message, setMessage] = useState<string>("Initializing...");
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState<number>(0);
|
||||
const [totalFiles, setTotalFiles] = useState<number>(1);
|
||||
|
||||
const [uploadSteps, setUploadSteps] = useState<ConnectionStep[]>([
|
||||
{ id: "image-info", label: "Sending image info", status: "pending" },
|
||||
{ id: "data", label: "Transferring image data", status: "pending" },
|
||||
{ id: "complete", label: "Finalizing", status: "pending" },
|
||||
]);
|
||||
|
||||
const [connectionSteps, setConnectionSteps] = useState<ConnectionStep[]>([
|
||||
{ id: "scan", label: "Scanning for device", status: "pending" },
|
||||
{ id: "connect", label: "Connecting to device", status: "pending" },
|
||||
{ id: "discover", label: "Discovering characteristics", status: "pending" },
|
||||
{
|
||||
id: "wait-status",
|
||||
label: "Waiting for device status",
|
||||
status: "pending",
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.onLog((entry) => {
|
||||
const eventType = entry.eventType;
|
||||
|
||||
switch (eventType) {
|
||||
case LogEventType.SCAN_START:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "scan", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DEVICE_FOUND:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "scan", "complete", "connect"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.CONNECT_START:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "connect", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.CONNECTED:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "connect", "complete", "discover"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DISCOVER_CHAR:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "discover", "complete", "wait-status"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.STATUS_WAIT:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "wait-status", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.STATUS_RECEIVED:
|
||||
setConnectionSteps((prev) =>
|
||||
updateStepStatus(prev, "wait-status", "complete"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.IMAGE_INFO_SEND:
|
||||
setUploadSteps((prev) =>
|
||||
updateStepStatus(prev, "image-info", "active"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DATA_SEND_START:
|
||||
setUploadSteps((prev) =>
|
||||
updateStepStatus(prev, "image-info", "complete", "data"),
|
||||
);
|
||||
break;
|
||||
|
||||
case LogEventType.DATA_SEND_COMPLETE:
|
||||
setUploadSteps((prev) =>
|
||||
updateStepStatus(prev, "data", "complete", "complete"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runUpload = async () => {
|
||||
let uploader: BeamBoxUploader | null = null;
|
||||
|
||||
try {
|
||||
const [width, height] = options.size.split("x").map(Number);
|
||||
const targetSize: [number, number] = [width!, height!];
|
||||
const packetDelaySeconds = options.packetDelay / 1000.0;
|
||||
|
||||
const isBulk =
|
||||
options.isBulk && options.images && options.images.length > 1;
|
||||
const imagesToUpload = isBulk ? options.images! : [];
|
||||
|
||||
if (isBulk) {
|
||||
setTotalFiles(imagesToUpload.length);
|
||||
}
|
||||
|
||||
uploader = new BeamBoxUploader(
|
||||
options.address,
|
||||
packetDelaySeconds,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
verbose,
|
||||
);
|
||||
|
||||
setStatus("connecting");
|
||||
setMessage("Connecting to BeamBox device...");
|
||||
|
||||
const connected = await uploader.connect();
|
||||
if (!connected) {
|
||||
throw new Error("Failed to connect to device");
|
||||
}
|
||||
|
||||
setMessage("Connected successfully!");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setStatus("uploading");
|
||||
|
||||
if (isBulk) {
|
||||
for (let i = 0; i < imagesToUpload.length; i++) {
|
||||
const imagePath = imagesToUpload[i];
|
||||
if (!imagePath) continue;
|
||||
|
||||
const fileName = basename(imagePath);
|
||||
|
||||
setCurrentFileIndex(i + 1);
|
||||
setMessage(
|
||||
`Uploading ${i + 1}/${imagesToUpload.length}: ${fileName}`,
|
||||
);
|
||||
|
||||
setUploadSteps([
|
||||
{
|
||||
id: "image-info",
|
||||
label: "Sending image info",
|
||||
status: "pending",
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "Transferring image data",
|
||||
status: "pending",
|
||||
},
|
||||
{ id: "complete", label: "Finalizing", status: "pending" },
|
||||
]);
|
||||
setProgress(0);
|
||||
|
||||
const success = await uploader.uploadImageFromFile(
|
||||
imagePath,
|
||||
targetSize,
|
||||
(prog) => setProgress(prog),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
throw new Error(`Failed to upload ${fileName}`);
|
||||
}
|
||||
|
||||
setUploadSteps((prev) =>
|
||||
updateStepStatus(prev, "complete", "complete"),
|
||||
);
|
||||
|
||||
if (i < imagesToUpload.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setStatus("success");
|
||||
setMessage(
|
||||
`All ${imagesToUpload.length} images uploaded successfully!`,
|
||||
);
|
||||
} else {
|
||||
setMessage("Preparing upload...");
|
||||
|
||||
let success: boolean;
|
||||
if (options.test) {
|
||||
setMessage("Uploading image...");
|
||||
success = await uploader.uploadCheckerboard(targetSize, 8, (prog) =>
|
||||
setProgress(prog),
|
||||
);
|
||||
} else if (options.image) {
|
||||
setMessage("Uploading image...");
|
||||
success = await uploader.uploadImageFromFile(
|
||||
options.image,
|
||||
targetSize,
|
||||
(prog) => setProgress(prog),
|
||||
);
|
||||
} else {
|
||||
throw new Error("No image provided");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
if (success) {
|
||||
setUploadSteps((prev) =>
|
||||
updateStepStatus(prev, "complete", "complete"),
|
||||
);
|
||||
setStatus("success");
|
||||
setMessage("Upload completed successfully!");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setMessage("Upload failed");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
if (error instanceof BeamBoxError) {
|
||||
setMessage(`Upload failed: ${error.message}`);
|
||||
} else {
|
||||
setMessage(`Upload failed: ${error}`);
|
||||
}
|
||||
} finally {
|
||||
if (uploader) {
|
||||
await uploader.disconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runUpload();
|
||||
}, [options, verbose]);
|
||||
|
||||
return {
|
||||
status,
|
||||
message,
|
||||
progress,
|
||||
currentFileIndex,
|
||||
totalFiles,
|
||||
uploadSteps,
|
||||
connectionSteps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
import { setupCLI } from "./cli/index.tsx";
|
||||
|
||||
const program = setupCLI();
|
||||
program.parse();
|
||||
@@ -0,0 +1,569 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
EventEmitter.defaultMaxListeners = 20;
|
||||
|
||||
import { createBluetooth } from "node-ble";
|
||||
import type { BLEConfig, ProtocolConfig } from "../protocol/index.ts";
|
||||
import { PacketType } from "../protocol/index.ts";
|
||||
import { DeviceNotFoundError, ConnectionError } from "../utils/errors.ts";
|
||||
import { PayloadBuilder, ResponseParser } from "../protocol/index.ts";
|
||||
import { logger, LogEventType } from "../utils/logger.ts";
|
||||
|
||||
/**
|
||||
* Handles notifications from the BeamBox device
|
||||
*/
|
||||
class NotificationHandler {
|
||||
waitingForAck = false;
|
||||
packetSuccessCount = 0;
|
||||
errorFlag = false;
|
||||
lastNotification: Record<string, unknown> | null = null;
|
||||
|
||||
deviceStatus: Record<string, unknown> | null = null;
|
||||
deviceReady = false;
|
||||
deviceStatusReceived = false;
|
||||
allNotifications: Array<{ time: number; data: Buffer; parsed: any }> = [];
|
||||
|
||||
private notificationResolve: (() => void) | null = null;
|
||||
private statusResolve: (() => void) | null = null;
|
||||
|
||||
constructor(private verbose: boolean = false) {}
|
||||
|
||||
/**
|
||||
* Handle incoming notification data from device
|
||||
* @param data Notification data buffer
|
||||
*/
|
||||
public handleNotification(data: Buffer): void {
|
||||
try {
|
||||
const response = ResponseParser.parse(data);
|
||||
|
||||
// Verbose mode logging
|
||||
if (this.verbose) {
|
||||
const timestamp = new Date().toISOString();
|
||||
this.allNotifications.push({
|
||||
time: Date.now(),
|
||||
data,
|
||||
parsed: response,
|
||||
});
|
||||
logger.debug(
|
||||
`[RECV] ${timestamp} | Bytes: ${data.length} | Hex: ${data.toString("hex")} | Text: ${response.rawText || "(empty)"}`,
|
||||
);
|
||||
if (response.jsonData) {
|
||||
logger.debug(`[RECV] JSON: ${JSON.stringify(response.jsonData)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.rawText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ResponseParser.isSuccess(response)) {
|
||||
this.packetSuccessCount++;
|
||||
logger.info("Device ack received: GetPacketSuccess");
|
||||
if (this.waitingForAck && this.notificationResolve) {
|
||||
this.notificationResolve();
|
||||
this.notificationResolve = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ResponseParser.isFail(response)) {
|
||||
logger.warning(`Device reported packet fail: ${response.rawText}`);
|
||||
if (this.notificationResolve) {
|
||||
this.notificationResolve();
|
||||
this.notificationResolve = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ResponseParser.isError(response)) {
|
||||
this.errorFlag = true;
|
||||
logger.error("Device error flag reported: 1111111111");
|
||||
if (this.notificationResolve) {
|
||||
this.notificationResolve();
|
||||
this.notificationResolve = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle JSON responses
|
||||
if (response.jsonData) {
|
||||
this.lastNotification = response.jsonData;
|
||||
|
||||
// Handle PacketType.DEVICE_STATUS messages
|
||||
if (response.isStatus) {
|
||||
this.deviceStatus = response.jsonData;
|
||||
this.deviceReady = true;
|
||||
|
||||
// Only resolve the first time we get status
|
||||
if (!this.deviceStatusReceived) {
|
||||
this.deviceStatusReceived = true;
|
||||
logger.info(
|
||||
`Device status received: ${JSON.stringify(response.jsonData)}`,
|
||||
LogEventType.STATUS_RECEIVED,
|
||||
response.jsonData,
|
||||
);
|
||||
} else {
|
||||
logger.debug("Duplicate status notification, ignoring");
|
||||
}
|
||||
|
||||
if (this.statusResolve) {
|
||||
this.statusResolve();
|
||||
this.statusResolve = null;
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`Device notification payload: ${JSON.stringify(response.jsonData)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.notificationResolve) {
|
||||
this.notificationResolve();
|
||||
this.notificationResolve = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: print raw text if we couldn't parse anything
|
||||
logger.debug(`Device notification text: ${response.rawText}`);
|
||||
if (this.notificationResolve) {
|
||||
this.notificationResolve();
|
||||
this.notificationResolve = null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling notification: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sent packet in verbose mode
|
||||
* @param packet Sent packet buffer
|
||||
* @param description Description of the packet
|
||||
*/
|
||||
public logSentPacket(packet: Buffer, description: string): void {
|
||||
if (this.verbose) {
|
||||
const timestamp = new Date().toISOString();
|
||||
logger.debug(
|
||||
`[SEND] ${timestamp} | Bytes: ${packet.length} | Hex: ${packet.toString("hex")} | ${description}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all received notifications
|
||||
* @returns Array of notifications with timestamp, data, and parsed content
|
||||
*/
|
||||
public getNotifications(): Array<{ time: number; data: Buffer; parsed: any }> {
|
||||
return this.allNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for device status to be received
|
||||
* @returns Promise that resolves when status is received
|
||||
*/
|
||||
public waitForStatus(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.deviceStatusReceived) {
|
||||
resolve();
|
||||
} else {
|
||||
this.statusResolve = resolve;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for any notification from device
|
||||
* @returns Promise that resolves when a notification is received
|
||||
*/
|
||||
public waitForNotification(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.notificationResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state
|
||||
*/
|
||||
public reset(): void {
|
||||
this.notificationResolve = null;
|
||||
this.statusResolve = null;
|
||||
this.errorFlag = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages BLE connection and data transfer to BeamBox device
|
||||
*/
|
||||
export class BleUploader {
|
||||
private bluetooth: any;
|
||||
private adapter: any;
|
||||
private device: any;
|
||||
private gattServer: any;
|
||||
private writeCharacteristic: any;
|
||||
private notifyCharacteristic: any;
|
||||
private notificationHandler: NotificationHandler;
|
||||
private payloadBuilder: PayloadBuilder;
|
||||
private chunkDelay: number;
|
||||
private verbose: boolean = false;
|
||||
|
||||
constructor(
|
||||
private deviceAddress: string | null,
|
||||
chunkDelay: number | null,
|
||||
private bleConfig: BLEConfig,
|
||||
private protocolConfig: ProtocolConfig,
|
||||
verbose: boolean = false,
|
||||
) {
|
||||
this.chunkDelay = chunkDelay ?? protocolConfig.packetDelay;
|
||||
this.verbose = verbose;
|
||||
this.notificationHandler = new NotificationHandler(verbose);
|
||||
this.payloadBuilder = new PayloadBuilder(protocolConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Bluetooth adapter
|
||||
*/
|
||||
private async initBluetooth(): Promise<void> {
|
||||
const { bluetooth, destroy } = createBluetooth();
|
||||
this.bluetooth = { bluetooth, destroy };
|
||||
this.adapter = await bluetooth.defaultAdapter();
|
||||
|
||||
const isPowered = await this.adapter.isPowered();
|
||||
if (!isPowered) {
|
||||
throw new ConnectionError("Bluetooth adapter is not powered on");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for the BeamBox device
|
||||
* @returns Device address or null if not found
|
||||
*/
|
||||
public async findDevice(): Promise<string | null> {
|
||||
if (!this.adapter) {
|
||||
await this.initBluetooth();
|
||||
}
|
||||
|
||||
logger.info("Starting device scan...", LogEventType.SCAN_START);
|
||||
|
||||
// Start discovery
|
||||
if (!(await this.adapter.isDiscovering())) {
|
||||
await this.adapter.startDiscovery();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeout = this.bleConfig.scanTimeout * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const devices = await this.adapter.devices();
|
||||
|
||||
for (const deviceAddr of devices) {
|
||||
const device = await this.adapter.getDevice(deviceAddr);
|
||||
const name = await device.getName().catch(() => null);
|
||||
|
||||
if (
|
||||
name &&
|
||||
name.toLowerCase().includes(this.bleConfig.deviceName.toLowerCase())
|
||||
) {
|
||||
await this.adapter.stopDiscovery();
|
||||
logger.info(
|
||||
`Found device: ${name} (${deviceAddr})`,
|
||||
LogEventType.DEVICE_FOUND,
|
||||
{ name, address: deviceAddr },
|
||||
);
|
||||
return deviceAddr;
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(500);
|
||||
}
|
||||
|
||||
await this.adapter.stopDiscovery();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the BeamBox device and wait for device status
|
||||
* @returns True if connected successfully
|
||||
*/
|
||||
public async connect(): Promise<boolean> {
|
||||
try {
|
||||
await this.initBluetooth();
|
||||
|
||||
if (!this.deviceAddress) {
|
||||
logger.info("Scanning for device...", LogEventType.SCAN_START);
|
||||
this.deviceAddress = await this.findDevice();
|
||||
if (!this.deviceAddress) {
|
||||
throw new DeviceNotFoundError(
|
||||
`Could not find '${this.bleConfig.deviceName}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get device
|
||||
this.device = await this.adapter.getDevice(this.deviceAddress);
|
||||
|
||||
// Connect to device
|
||||
logger.info("Connecting to device...", LogEventType.CONNECT_START);
|
||||
await this.device.connect();
|
||||
logger.info("Connected to device", LogEventType.CONNECTED);
|
||||
|
||||
// Get GATT server
|
||||
this.gattServer = await this.device.gatt();
|
||||
|
||||
// Find the service and characteristics
|
||||
await this.discoverCharacteristics();
|
||||
|
||||
// Setup notifications
|
||||
if (this.notifyCharacteristic) {
|
||||
await this.notifyCharacteristic.startNotifications();
|
||||
|
||||
// Listen for value changes
|
||||
this.notifyCharacteristic.on("valuechanged", (buffer: Buffer) => {
|
||||
this.notificationHandler.handleNotification(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for device status (PacketType.DEVICE_STATUS) to be received
|
||||
logger.info("Waiting for device status...", LogEventType.STATUS_WAIT);
|
||||
const statusPromise = this.notificationHandler.waitForStatus();
|
||||
const timeoutPromise = this.sleep(5000);
|
||||
|
||||
await Promise.race([statusPromise, timeoutPromise]);
|
||||
|
||||
if (this.notificationHandler.deviceReady) {
|
||||
logger.info("Device status received", LogEventType.STATUS_RECEIVED);
|
||||
} else {
|
||||
logger.warning(
|
||||
"Device status not received within timeout, proceeding anyway",
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Connection error: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover required characteristics on the device
|
||||
*/
|
||||
private async discoverCharacteristics(): Promise<void> {
|
||||
// Get all services
|
||||
const services = await this.gattServer.services();
|
||||
|
||||
for (const serviceUuid of services) {
|
||||
const service = await this.gattServer.getPrimaryService(serviceUuid);
|
||||
const characteristics = await service.characteristics();
|
||||
|
||||
for (const charUuid of characteristics) {
|
||||
const char = await service.getCharacteristic(charUuid);
|
||||
|
||||
// Match normalized UUIDs
|
||||
const normalizedCharUuid = charUuid.replace(/-/g, "").toLowerCase();
|
||||
const normalizedWriteUuid = this.bleConfig.writeCharacteristicUUID
|
||||
.replace(/-/g, "")
|
||||
.toLowerCase();
|
||||
const normalizedNotifyUuid = this.bleConfig.notifyCharacteristicUUID
|
||||
.replace(/-/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
if (normalizedCharUuid === normalizedWriteUuid) {
|
||||
this.writeCharacteristic = char;
|
||||
logger.debug(
|
||||
`Found write characteristic: ${charUuid}`,
|
||||
LogEventType.DISCOVER_CHAR,
|
||||
{ type: "write", uuid: charUuid },
|
||||
);
|
||||
}
|
||||
if (normalizedCharUuid === normalizedNotifyUuid) {
|
||||
this.notifyCharacteristic = char;
|
||||
logger.debug(
|
||||
`Found notify characteristic: ${charUuid}`,
|
||||
LogEventType.DISCOVER_CHAR,
|
||||
{ type: "notify", uuid: charUuid },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.writeCharacteristic || !this.notifyCharacteristic) {
|
||||
throw new ConnectionError("Could not find required characteristics");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the device
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.device) {
|
||||
await this.device.disconnect();
|
||||
}
|
||||
if (this.bluetooth) {
|
||||
this.bluetooth.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warning(`Error during disconnect: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send image info packet to device
|
||||
* Tells device how many images to expect
|
||||
* @param payload Image info payload bytes (e.g., {"type":6,"number":1})
|
||||
*/
|
||||
public async sendImageInfo(payload: Buffer): Promise<void> {
|
||||
if (!this.writeCharacteristic) {
|
||||
throw new ConnectionError("BLE client not connected");
|
||||
}
|
||||
|
||||
if (!this.notificationHandler.deviceReady) {
|
||||
logger.warning("Device status not received, but proceeding with upload");
|
||||
}
|
||||
|
||||
const packet = this.payloadBuilder.createPacket(
|
||||
payload,
|
||||
0,
|
||||
0,
|
||||
PacketType.IMAGE,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Sending image info packet (type ${PacketType.IMAGE}): ${payload.length} bytes`,
|
||||
LogEventType.IMAGE_INFO_SEND,
|
||||
{ size: payload.length },
|
||||
);
|
||||
|
||||
if (this.verbose) {
|
||||
const imageInfoHex = packet.toString("hex");
|
||||
logger.debug(
|
||||
`Full image info packet hex (${packet.length} bytes): ${imageInfoHex}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.notificationHandler.logSentPacket(packet, "Image info packet");
|
||||
|
||||
await this.writeCharacteristic.writeValue(packet, { type: "command" });
|
||||
await this.sleep(this.protocolConfig.imageInfoDelay * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send image data packets to device
|
||||
* @param fullData Complete image data payload
|
||||
* @returns True if successful
|
||||
*/
|
||||
public async sendData(
|
||||
fullData: Buffer,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<boolean> {
|
||||
if (!this.writeCharacteristic) {
|
||||
throw new ConnectionError("BLE client not connected");
|
||||
}
|
||||
|
||||
const totalSize = fullData.length;
|
||||
const chunkSize = this.protocolConfig.chunkSize;
|
||||
const totalChunks = Math.ceil(totalSize / chunkSize);
|
||||
|
||||
logger.info(
|
||||
`Starting data transfer: ${totalChunks} packets`,
|
||||
LogEventType.DATA_SEND_START,
|
||||
{ totalChunks, totalSize },
|
||||
);
|
||||
|
||||
if (this.verbose) {
|
||||
const firstChunkPreview = fullData
|
||||
.subarray(0, Math.min(64, fullData.length))
|
||||
.toString("hex");
|
||||
logger.debug(`First 64 bytes of payload: ${firstChunkPreview}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, totalSize);
|
||||
const chunk = fullData.subarray(start, end);
|
||||
|
||||
const remainingPackets = totalChunks - 1 - i;
|
||||
|
||||
const packet = this.payloadBuilder.createPacket(
|
||||
chunk,
|
||||
totalChunks,
|
||||
remainingPackets,
|
||||
this.protocolConfig.cmdSubtype,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Sending packet ${i + 1}/${totalChunks} (remaining=${remainingPackets}, bytes=${chunk.length})`,
|
||||
LogEventType.DATA_SEND_PROGRESS,
|
||||
{ current: i + 1, total: totalChunks, remaining: remainingPackets },
|
||||
);
|
||||
|
||||
if (this.verbose) {
|
||||
this.notificationHandler.logSentPacket(
|
||||
packet,
|
||||
`Data packet ${i + 1}/${totalChunks}`,
|
||||
);
|
||||
|
||||
if (i === 0) {
|
||||
const headerHex = packet.subarray(0, 8).toString("hex");
|
||||
const checksumHex = packet[packet.length - 1]
|
||||
?.toString(16)
|
||||
.padStart(2, "0");
|
||||
logger.debug(`First packet header: ${headerHex}`);
|
||||
logger.debug(`First packet checksum: ${checksumHex}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeCharacteristic.writeValue(packet, { type: "command" });
|
||||
|
||||
if (this.notificationHandler.errorFlag) {
|
||||
logger.error("Device error flag set; aborting send.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
const progress = ((i + 1) / totalChunks) * 100;
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
await this.sleep(this.chunkDelay * 1000);
|
||||
}
|
||||
|
||||
logger.info("Data transfer complete", LogEventType.DATA_SEND_COMPLETE);
|
||||
|
||||
return !this.notificationHandler.errorFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for device response after upload
|
||||
* @param timeout Timeout in seconds
|
||||
* @returns True if response received without error
|
||||
*/
|
||||
public async waitForResponse(timeout: number = 5.0): Promise<boolean> {
|
||||
const responsePromise = this.notificationHandler.waitForNotification();
|
||||
const timeoutPromise = this.sleep(timeout * 1000);
|
||||
|
||||
await Promise.race([responsePromise, timeoutPromise]);
|
||||
this.notificationHandler.reset();
|
||||
|
||||
return !this.notificationHandler.errorFlag;
|
||||
}
|
||||
|
||||
public hasError(): boolean {
|
||||
return this.notificationHandler.errorFlag;
|
||||
}
|
||||
|
||||
public isDeviceReady(): boolean {
|
||||
return this.notificationHandler.deviceReady;
|
||||
}
|
||||
|
||||
public getDeviceStatus(): Record<string, unknown> | null {
|
||||
return this.notificationHandler.deviceStatus;
|
||||
}
|
||||
|
||||
public getNotifications(): Array<{ time: number; data: Buffer; parsed: any }> {
|
||||
return this.notificationHandler.getNotifications();
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BleUploader } from "./ble-client.ts";
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
BLEConfig,
|
||||
ProtocolConfig,
|
||||
ImageConfig,
|
||||
} from "../protocol/index.ts";
|
||||
import {
|
||||
DEFAULT_BLE_CONFIG,
|
||||
DEFAULT_PROTOCOL_CONFIG,
|
||||
DEFAULT_IMAGE_CONFIG,
|
||||
} from "../protocol/index.ts";
|
||||
import { BleUploader } from "../ble/ble-client.ts";
|
||||
import { ImageProcessor } from "../processing/image-processor.ts";
|
||||
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];
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image to the device
|
||||
*
|
||||
* @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;
|
||||
|
||||
// Prepare JPEG data
|
||||
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
|
||||
const imageInfoPayload = this.payloadBuilder.buildImageInfo();
|
||||
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,
|
||||
);
|
||||
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
|
||||
const ok = await this.ble.sendData(fullData, onProgress);
|
||||
|
||||
if (!ok) {
|
||||
logger.error("Upload reported error");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for final response
|
||||
if (!(await this.ble.waitForResponse(5.0))) {
|
||||
logger.error("Upload timeout waiting for response");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
onProgress?: (progress: number) => void,
|
||||
): 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,
|
||||
onProgress?: (progress: number) => void,
|
||||
): 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BeamBoxUploader } from "./beambox-uploader.ts";
|
||||
export type { UploadOptions } from "./beambox-uploader.ts";
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./core/index.ts";
|
||||
export * from "./protocol/index.ts";
|
||||
export * from "./utils/index.ts";
|
||||
export { ImageProcessor } from "./processing/index.ts";
|
||||
export { BleUploader } from "./ble/index.ts";
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { ImageProcessor } from "./image-processor.ts";
|
||||
import { ImageProcessingError } from "../utils/errors.ts";
|
||||
import { DEFAULT_IMAGE_CONFIG } from "../protocol/interfaces/defaults.ts";
|
||||
import sharp from "sharp";
|
||||
import path from "path";
|
||||
|
||||
const fixturesPath = path.join(__dirname, "../../__tests__/fixtures");
|
||||
const testImagePath = path.join(fixturesPath, "test-1x1.png");
|
||||
const invalidImagePath = path.join(fixturesPath, "invalid-image.txt");
|
||||
|
||||
describe("ImageProcessor", () => {
|
||||
const processor = new ImageProcessor(DEFAULT_IMAGE_CONFIG);
|
||||
|
||||
describe("loadFromFile()", () => {
|
||||
test("loads valid PNG file", async () => {
|
||||
const image = await processor.loadFromFile(testImagePath);
|
||||
expect(image).toBeDefined();
|
||||
|
||||
// Should be a Sharp instance
|
||||
const metadata = await image.metadata();
|
||||
expect(metadata.width).toBe(1);
|
||||
expect(metadata.height).toBe(1);
|
||||
});
|
||||
|
||||
test("Sharp doesn't throw immediately on non-existent file", async () => {
|
||||
// Sharp creates lazily, so loadFromFile won't throw
|
||||
// The error happens when you try to use it
|
||||
const image = await processor.loadFromFile("/non/existent/path.png");
|
||||
expect(image).toBeDefined();
|
||||
});
|
||||
|
||||
test("Sharp doesn't throw immediately on invalid image file", async () => {
|
||||
// Sharp loads lazily
|
||||
const image = await processor.loadFromFile(invalidImagePath);
|
||||
expect(image).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCheckerboard()", () => {
|
||||
test("generates PNG buffer with default size", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
|
||||
const metadata = await sharp(png).metadata();
|
||||
expect(metadata.format).toBe("png");
|
||||
expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]);
|
||||
expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]);
|
||||
});
|
||||
|
||||
test("generates PNG with custom size (128x64)", async () => {
|
||||
const png = await processor.generateCheckerboard([128, 64]);
|
||||
const metadata = await sharp(png).metadata();
|
||||
expect(metadata.width).toBe(128);
|
||||
expect(metadata.height).toBe(64);
|
||||
});
|
||||
|
||||
test("generates with default squares (from config)", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
// Just verify it doesn't throw
|
||||
});
|
||||
|
||||
test("generates with custom squares (4x4)", async () => {
|
||||
const png = await processor.generateCheckerboard([64, 32], 4);
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
const metadata = await sharp(png).metadata();
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("generates with custom squares (16x16)", async () => {
|
||||
const png = await processor.generateCheckerboard([128, 128], 16);
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
const metadata = await sharp(png).metadata();
|
||||
expect(metadata.width).toBe(128);
|
||||
expect(metadata.height).toBe(128);
|
||||
});
|
||||
|
||||
test("can be parsed by Sharp", async () => {
|
||||
const png = await processor.generateCheckerboard([64, 32]);
|
||||
const image = sharp(png);
|
||||
const metadata = await image.metadata();
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("edge case: 1x1 grid (all black)", async () => {
|
||||
const png = await processor.generateCheckerboard([10, 10], 1);
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
});
|
||||
|
||||
test("edge case: 2x2 grid", async () => {
|
||||
const png = await processor.generateCheckerboard([20, 20], 2);
|
||||
expect(Buffer.isBuffer(png)).toBe(true);
|
||||
});
|
||||
|
||||
test("throws ImageProcessingError on failure", async () => {
|
||||
// This is hard to trigger, but we verify the error type would be correct
|
||||
// by checking that normal generation doesn't throw
|
||||
await expect(
|
||||
processor.generateCheckerboard([64, 32], 8),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareImage()", () => {
|
||||
test("accepts Sharp instance input", async () => {
|
||||
const image = sharp(await processor.generateCheckerboard());
|
||||
const jpeg = await processor.prepareImage(image, [64, 32]);
|
||||
expect(Buffer.isBuffer(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts Buffer input (PNG)", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
expect(Buffer.isBuffer(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test("resizes to target size", async () => {
|
||||
const png = await processor.generateCheckerboard([100, 100]);
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("output is JPEG format", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
test("output has correct JPEG quality (from config)", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
// Can't directly test quality, but verify it's a valid JPEG
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
test("output dimensions match target", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png, [128, 64]);
|
||||
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.width).toBe(128);
|
||||
expect(metadata.height).toBe(64);
|
||||
});
|
||||
|
||||
test("uses default size when not specified", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png);
|
||||
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]);
|
||||
expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]);
|
||||
});
|
||||
|
||||
test("throws ImageProcessingError on invalid input", async () => {
|
||||
const invalidBuffer = Buffer.from("not an image");
|
||||
await expect(
|
||||
processor.prepareImage(invalidBuffer, [64, 32]),
|
||||
).rejects.toThrow(ImageProcessingError);
|
||||
});
|
||||
|
||||
test("error message includes context", async () => {
|
||||
try {
|
||||
await processor.prepareImage(Buffer.from("bad"), [64, 32]);
|
||||
throw new Error("Should have thrown");
|
||||
} catch (e) {
|
||||
expect((e as Error).message).toContain("prepare image");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareFromFile()", () => {
|
||||
test("loads and prepares in one call", async () => {
|
||||
const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]);
|
||||
expect(Buffer.isBuffer(jpeg)).toBe(true);
|
||||
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("returns JPEG Buffer", async () => {
|
||||
const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]);
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
});
|
||||
|
||||
test("dimensions match target", async () => {
|
||||
const jpeg = await processor.prepareFromFile(testImagePath, [128, 64]);
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.width).toBe(128);
|
||||
expect(metadata.height).toBe(64);
|
||||
});
|
||||
|
||||
test("throws on invalid path", async () => {
|
||||
await expect(
|
||||
processor.prepareFromFile("/bad/path.png", [64, 32]),
|
||||
).rejects.toThrow(ImageProcessingError);
|
||||
});
|
||||
|
||||
test("throws on invalid image", async () => {
|
||||
await expect(
|
||||
processor.prepareFromFile(invalidImagePath, [64, 32]),
|
||||
).rejects.toThrow(ImageProcessingError);
|
||||
});
|
||||
|
||||
test("uses default size when not specified", async () => {
|
||||
const jpeg = await processor.prepareFromFile(testImagePath);
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]);
|
||||
expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("output format verification", () => {
|
||||
test("JPEG has correct magic bytes", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
// JPEG starts with FF D8
|
||||
expect(jpeg[0]).toBe(0xff);
|
||||
expect(jpeg[1]).toBe(0xd8);
|
||||
// JPEG ends with FF D9
|
||||
expect(jpeg[jpeg.length - 2]).toBe(0xff);
|
||||
expect(jpeg[jpeg.length - 1]).toBe(0xd9);
|
||||
});
|
||||
|
||||
test("output is valid and can be processed again", async () => {
|
||||
const png = await processor.generateCheckerboard();
|
||||
const jpeg1 = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
// Process the JPEG again
|
||||
const jpeg2 = await processor.prepareImage(jpeg1, [32, 16]);
|
||||
|
||||
const metadata = await sharp(jpeg2).metadata();
|
||||
expect(metadata.width).toBe(32);
|
||||
expect(metadata.height).toBe(16);
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
test("full workflow: generate -> prepare", async () => {
|
||||
const png = await processor.generateCheckerboard([100, 100], 8);
|
||||
const jpeg = await processor.prepareImage(png, [64, 32]);
|
||||
|
||||
expect(Buffer.isBuffer(jpeg)).toBe(true);
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("full workflow: load file -> prepare", async () => {
|
||||
const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]);
|
||||
|
||||
expect(Buffer.isBuffer(jpeg)).toBe(true);
|
||||
const metadata = await sharp(jpeg).metadata();
|
||||
expect(metadata.format).toBe("jpeg");
|
||||
expect(metadata.width).toBe(64);
|
||||
expect(metadata.height).toBe(32);
|
||||
});
|
||||
|
||||
test("multiple operations in sequence", async () => {
|
||||
const png1 = await processor.generateCheckerboard([100, 100]);
|
||||
const jpeg1 = await processor.prepareImage(png1, [64, 32]);
|
||||
|
||||
const png2 = await processor.generateCheckerboard([50, 50]);
|
||||
const jpeg2 = await processor.prepareImage(png2, [128, 64]);
|
||||
|
||||
expect(jpeg1.length).toBeDefined();
|
||||
expect(jpeg2.length).toBeDefined();
|
||||
expect(jpeg1.length).not.toBe(jpeg2.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import sharp from "sharp";
|
||||
import type { ImageConfig } from "../protocol/index.ts";
|
||||
import { ImageProcessingError } from "../utils/errors.ts";
|
||||
|
||||
/**
|
||||
* Image processor for loading, generating, and preparing images.
|
||||
* Handles image operations using the Sharp library including resizing,
|
||||
* format conversion, and test pattern generation.
|
||||
*/
|
||||
export class ImageProcessor {
|
||||
/**
|
||||
* Creates a new ImageProcessor instance.
|
||||
* @param config Image configuration containing default size,
|
||||
* JPEG quality, and checkerboard settings
|
||||
*/
|
||||
constructor(private config: ImageConfig) {}
|
||||
|
||||
/**
|
||||
* Load an image from a file path
|
||||
* @param imagePath Path to the image file
|
||||
* @returns Sharp instance
|
||||
*/
|
||||
public async loadFromFile(imagePath: string): Promise<sharp.Sharp> {
|
||||
try {
|
||||
return sharp(imagePath);
|
||||
} catch (error) {
|
||||
throw new ImageProcessingError(
|
||||
`Failed to load image from ${imagePath}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a checkerboard test pattern
|
||||
* @param size Image size [width, height]
|
||||
* @param squares Number of squares per side
|
||||
* @returns Buffer containing PNG image data
|
||||
*/
|
||||
public async generateCheckerboard(
|
||||
size: [number, number] = this.config.defaultSize,
|
||||
squares: number = this.config.checkerboardSquares,
|
||||
): Promise<Buffer> {
|
||||
const [width, height] = size;
|
||||
const squareWidth = Math.floor(width / squares);
|
||||
const squareHeight = Math.floor(height / squares);
|
||||
|
||||
// Create SVG checkerboard pattern
|
||||
const svgPattern = this.createCheckerboardSVG(
|
||||
width,
|
||||
height,
|
||||
squareWidth,
|
||||
squareHeight,
|
||||
squares,
|
||||
);
|
||||
|
||||
try {
|
||||
return await sharp(Buffer.from(svgPattern)).png().toBuffer();
|
||||
} catch (error) {
|
||||
throw new ImageProcessingError(
|
||||
`Failed to generate checkerboard: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare an image as JPEG bytes
|
||||
* @param imageInput Sharp instance or buffer
|
||||
* @param targetSize Target size [width, height]
|
||||
* @returns JPEG image as Buffer
|
||||
*/
|
||||
public async prepareImage(
|
||||
imageInput: sharp.Sharp | Buffer,
|
||||
targetSize: [number, number] = this.config.defaultSize,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const pipeline = Buffer.isBuffer(imageInput)
|
||||
? sharp(imageInput)
|
||||
: imageInput;
|
||||
|
||||
return await pipeline
|
||||
.resize(targetSize[0], targetSize[1], {
|
||||
fit: "fill",
|
||||
kernel: "lanczos3",
|
||||
})
|
||||
.toColorspace("srgb")
|
||||
.jpeg({
|
||||
quality: this.config.jpegQuality,
|
||||
optimiseCoding: true,
|
||||
mozjpeg: false,
|
||||
chromaSubsampling: "4:2:0",
|
||||
})
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
throw new ImageProcessingError(`Failed to prepare image: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and prepare an image from file as JPEG bytes
|
||||
* @param imagePath Path to the image file
|
||||
* @param targetSize Target size [width, height]
|
||||
* @returns JPEG image as Buffer
|
||||
*/
|
||||
public async prepareFromFile(
|
||||
imagePath: string,
|
||||
targetSize: [number, number] = this.config.defaultSize,
|
||||
): Promise<Buffer> {
|
||||
const image = await this.loadFromFile(imagePath);
|
||||
return this.prepareImage(image, targetSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SVG markup for a checkerboard pattern.
|
||||
* Generates alternating black and white squares.
|
||||
* @private
|
||||
* @param width Total image width in pixels
|
||||
* @param height Total image height in pixels
|
||||
* @param squareWidth Width of each square in pixels
|
||||
* @param squareHeight Height of each square in pixels
|
||||
* @param squares Number of squares per row/column
|
||||
* @returns SVG string containing the checkerboard pattern
|
||||
*/
|
||||
private createCheckerboardSVG(
|
||||
width: number,
|
||||
height: number,
|
||||
squareWidth: number,
|
||||
squareHeight: number,
|
||||
squares: number,
|
||||
): string {
|
||||
let rects = "";
|
||||
|
||||
for (let row = 0; row < squares; row++) {
|
||||
for (let col = 0; col < squares; col++) {
|
||||
if ((row + col) % 2 === 0) {
|
||||
const x = col * squareWidth;
|
||||
const y = row * squareHeight;
|
||||
rects += `<rect x="${x}" y="${y}" width="${squareWidth}" height="${squareHeight}" fill="black"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${width}" height="${height}" fill="white"/>
|
||||
${rects}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ImageProcessor } from "./image-processor.ts";
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
import { expectHex } from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
describe("IMBHeaderBuilder", () => {
|
||||
describe("build()", () => {
|
||||
describe("structure validation", () => {
|
||||
test("creates exactly 36-byte buffer", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.length).toBe(36);
|
||||
});
|
||||
|
||||
test("signature is 'IMB' at bytes 0-2", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.toString("utf-8", 0, 3)).toBe("IMB");
|
||||
expect(header[0]).toBe(0x49); // I
|
||||
expect(header[1]).toBe(0x4d); // M
|
||||
expect(header[2]).toBe(0x42); // B
|
||||
});
|
||||
|
||||
test("null byte at position 3", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[3]).toBe(0x00);
|
||||
});
|
||||
|
||||
test("header size (36) at bytes 4-7 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(4)).toBe(36);
|
||||
});
|
||||
|
||||
test("total size (jpegSize + 36) at bytes 8-11 (little-endian)", () => {
|
||||
const jpegSize = 1024;
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(8)).toBe(jpegSize + 36);
|
||||
});
|
||||
|
||||
test("format value (11) at byte 12", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[12]).toBe(11);
|
||||
});
|
||||
|
||||
test("zero byte at byte 13", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[13]).toBe(0x00);
|
||||
});
|
||||
|
||||
test("reserved zeros (2 bytes) at bytes 14-15", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(14)).toBe(0);
|
||||
});
|
||||
|
||||
test("width at bytes 16-17 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(16)).toBe(64);
|
||||
});
|
||||
|
||||
test("height at bytes 18-19 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(18)).toBe(32);
|
||||
});
|
||||
|
||||
test("header size repeat (36) at bytes 20-23 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(20)).toBe(36);
|
||||
});
|
||||
|
||||
test("JPEG size at bytes 24-27 (little-endian)", () => {
|
||||
const jpegSize = 1024;
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(24)).toBe(jpegSize);
|
||||
});
|
||||
|
||||
test("reserved zeros (8 bytes) at bytes 28-35", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(28)).toBe(0);
|
||||
expect(header.readUInt32LE(32)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("zero JPEG size produces valid header", () => {
|
||||
const header = IMBHeaderBuilder.build(0, 64, 32);
|
||||
expect(header.length).toBe(36);
|
||||
expect(header.readUInt32LE(24)).toBe(0); // JPEG size
|
||||
expect(header.readUInt32LE(8)).toBe(36); // Total size = 0 + 36
|
||||
});
|
||||
|
||||
test("large JPEG size (5MB)", () => {
|
||||
const jpegSize = 5 * 1024 * 1024; // 5MB
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(24)).toBe(jpegSize);
|
||||
expect(header.readUInt32LE(8)).toBe(jpegSize + 36);
|
||||
});
|
||||
|
||||
test("maximum dimensions (uint16 max: 65535x65535)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 65535, 65535);
|
||||
expect(header.readUInt16LE(16)).toBe(65535);
|
||||
expect(header.readUInt16LE(18)).toBe(65535);
|
||||
});
|
||||
|
||||
test("minimum dimensions (1x1)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 1, 1);
|
||||
expect(header.readUInt16LE(16)).toBe(1);
|
||||
expect(header.readUInt16LE(18)).toBe(1);
|
||||
});
|
||||
|
||||
test("common device size (64x32)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(16)).toBe(64);
|
||||
expect(header.readUInt16LE(18)).toBe(32);
|
||||
});
|
||||
|
||||
test("common device size (128x64)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 128, 64);
|
||||
expect(header.readUInt16LE(16)).toBe(128);
|
||||
expect(header.readUInt16LE(18)).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("known header verification", () => {
|
||||
test("1KB JPEG, 64x32 matches expected hex", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
// IMB\x00 + 36(LE) + 1060(LE) + 11 + 0x00 + 0x0000 + 64(LE) + 32(LE) + 36(LE) + 1024(LE) + 0x00000000 + 0x00000000
|
||||
expectHex(
|
||||
header,
|
||||
"494d420024000000240400000b0000004000200024000000000400000000000000000000"
|
||||
);
|
||||
});
|
||||
|
||||
test("100 byte JPEG, 128x64 matches expected hex", () => {
|
||||
const header = IMBHeaderBuilder.build(100, 128, 64);
|
||||
// IMB\x00 + 36(LE) + 136(LE) + 11 + 0x00 + 0x0000 + 128(LE) + 64(LE) + 36(LE) + 100(LE) + 0x00000000 + 0x00000000
|
||||
expectHex(
|
||||
header,
|
||||
"494d420024000000880000000b0000008000400024000000640000000000000000000000"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consistency", () => {
|
||||
test("multiple calls with same params produce identical headers", () => {
|
||||
const header1 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
const header2 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header1.equals(header2)).toBe(true);
|
||||
});
|
||||
|
||||
test("different params produce different headers", () => {
|
||||
const header1 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
const header2 = IMBHeaderBuilder.build(1024, 128, 64);
|
||||
expect(header1.equals(header2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate()", () => {
|
||||
test("validates correct header from build()", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects wrong length (35 bytes)", () => {
|
||||
const header = Buffer.alloc(35);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong length (37 bytes)", () => {
|
||||
const header = Buffer.alloc(37);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong signature 'IMC'", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header[2] = 0x43; // Change B to C
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong signature 'ABC'", () => {
|
||||
const header = Buffer.alloc(36);
|
||||
header.write("ABC", 0);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty signature", () => {
|
||||
const header = Buffer.alloc(36);
|
||||
// All zeros
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects missing null byte at position 3", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header[3] = 0x01; // Change null to non-null
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mismatched header sizes (bytes 4-7 vs 20-23)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header.writeUInt32LE(40, 20); // Change second header size to 40
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects incorrect header size value", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header.writeUInt32LE(32, 4); // Change first header size to 32
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("validates headers with various JPEG sizes", () => {
|
||||
const sizes = [0, 100, 1024, 10000, 1000000];
|
||||
sizes.forEach((size) => {
|
||||
const header = IMBHeaderBuilder.build(size, 64, 32);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("validates headers with various dimensions", () => {
|
||||
const dimensions: [number, number][] = [
|
||||
[1, 1],
|
||||
[64, 32],
|
||||
[128, 64],
|
||||
[256, 256],
|
||||
[1920, 1080],
|
||||
];
|
||||
dimensions.forEach(([width, height]) => {
|
||||
const header = IMBHeaderBuilder.build(1024, width, height);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("build() and validate() integration", () => {
|
||||
test("all built headers pass validation", () => {
|
||||
const testCases: [number, number, number][] = [
|
||||
[0, 1, 1],
|
||||
[100, 64, 32],
|
||||
[1024, 128, 64],
|
||||
[5242880, 256, 256],
|
||||
[10000, 1920, 1080],
|
||||
];
|
||||
|
||||
testCases.forEach(([jpegSize, width, height]) => {
|
||||
const header = IMBHeaderBuilder.build(jpegSize, width, height);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* IMB (Image Binary) header builder
|
||||
*
|
||||
* The IMB header is exactly 36 bytes and contains information about the image:
|
||||
* - Signature: 'IMB' (3 bytes)
|
||||
* - Null byte: 0x00 (1 byte)
|
||||
* - Header size: 36 (4 bytes, little-endian)
|
||||
* - Total size: JPEG size + 36 (4 bytes, little-endian)
|
||||
* - Format: 11 (1 byte) + 0x00 (1 byte)
|
||||
* - Reserved: 0x0000 (2 bytes, little-endian)
|
||||
* - Width: image width (2 bytes, little-endian)
|
||||
* - Height: image height (2 bytes, little-endian)
|
||||
* - Header size repeat: 36 (4 bytes, little-endian)
|
||||
* - JPEG size: size of JPEG data (4 bytes, little-endian)
|
||||
* - Reserved: 0x00000000 0x00000000 (8 bytes, little-endian)
|
||||
*/
|
||||
|
||||
export class IMBHeaderBuilder {
|
||||
private static readonly HEADER_SIZE = 36;
|
||||
private static readonly FORMAT_VALUE = 11;
|
||||
private static readonly SIGNATURE = Buffer.from("IMB");
|
||||
|
||||
/**
|
||||
* Build a 36-byte IMB header for image data
|
||||
* @param jpegSize Size of the JPEG data in bytes
|
||||
* @param width Image width in pixels
|
||||
* @param height Image height in pixels
|
||||
* @returns 36 bytes of IMB header data
|
||||
*/
|
||||
static build(jpegSize: number, width: number, height: number): Buffer {
|
||||
const header = Buffer.alloc(this.HEADER_SIZE);
|
||||
let offset = 0;
|
||||
|
||||
// 1. IMB signature (3 bytes)
|
||||
this.SIGNATURE.copy(header, offset);
|
||||
offset += 3;
|
||||
|
||||
// 2. One zero byte (1 byte)
|
||||
header.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
|
||||
// 3. Header size: 36 as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(this.HEADER_SIZE, offset);
|
||||
offset += 4;
|
||||
|
||||
// 4. Total size: jpegSize + 36 as int32_LE (4 bytes)
|
||||
const totalSize = jpegSize + this.HEADER_SIZE;
|
||||
header.writeUInt32LE(totalSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// 5. Format: 11 (1 byte) + zero byte (1 byte)
|
||||
header.writeUInt8(this.FORMAT_VALUE, offset);
|
||||
offset += 1;
|
||||
header.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
|
||||
// 6. Reserved: zero as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(0, offset);
|
||||
offset += 2;
|
||||
|
||||
// 7. Width as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(width, offset);
|
||||
offset += 2;
|
||||
|
||||
// 8. Height as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(height, offset);
|
||||
offset += 2;
|
||||
|
||||
// 9. Header size repeat: 36 as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(this.HEADER_SIZE, offset);
|
||||
offset += 4;
|
||||
|
||||
// 10. JPEG size as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(jpegSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// 11. Reserved: two zero int32_LE values (8 bytes)
|
||||
header.writeUInt32LE(0, offset);
|
||||
offset += 4;
|
||||
header.writeUInt32LE(0, offset);
|
||||
offset += 4;
|
||||
|
||||
if (header.length !== this.HEADER_SIZE) {
|
||||
throw new Error(
|
||||
`IMB header must be exactly ${this.HEADER_SIZE} bytes, got ${header.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a header is a proper IMB header
|
||||
* @param header Header bytes to validate
|
||||
* @returns True if valid IMB header
|
||||
*/
|
||||
static validate(header: Buffer): boolean {
|
||||
if (header.length !== this.HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check signature
|
||||
if (!header.subarray(0, 3).equals(this.SIGNATURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check null byte
|
||||
if (header.readUInt8(3) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check header size fields
|
||||
const headerSize1 = header.readUInt32LE(4);
|
||||
const headerSize2 = header.readUInt32LE(20);
|
||||
|
||||
if (headerSize1 !== this.HEADER_SIZE || headerSize2 !== this.HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { PayloadBuilder } from "./payload-builder.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import {
|
||||
expectHex,
|
||||
createTestJpeg,
|
||||
calculateChecksum,
|
||||
} from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
const createBuilder = () => new PayloadBuilder(DEFAULT_PROTOCOL_CONFIG);
|
||||
|
||||
describe("PayloadBuilder", () => {
|
||||
describe("buildImageInfo()", () => {
|
||||
test("default creates {\"type\":6,\"number\":1}", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 6, number: 1 });
|
||||
});
|
||||
|
||||
test("output has no extra spaces", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
const text = payload.toString("utf-8");
|
||||
expect(text).toBe('{"type":6,"number":1}');
|
||||
});
|
||||
|
||||
test("custom type creates {\"type\":5,\"number\":1}", () => {
|
||||
const payload = createBuilder().buildImageInfo(PacketType.DYNAMIC_AMBIENCE);
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 5, number: 1 });
|
||||
});
|
||||
|
||||
test("custom number creates {\"type\":6,\"number\":3}", () => {
|
||||
const payload = createBuilder().buildImageInfo(PacketType.IMAGE, 3);
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 6, number: 3 });
|
||||
});
|
||||
|
||||
test("returns UTF-8 Buffer", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
expect(Buffer.isBuffer(payload)).toBe(true);
|
||||
const text = payload.toString("utf-8");
|
||||
expect(text).toMatch(/^\{.*\}$/);
|
||||
});
|
||||
|
||||
test("JSON.parse(output) matches input params", () => {
|
||||
const imageType = 6;
|
||||
const number = 1;
|
||||
const payload = createBuilder().buildImageInfo(imageType, number);
|
||||
const parsed = JSON.parse(payload.toString("utf-8"));
|
||||
expect(parsed.type).toBe(imageType);
|
||||
expect(parsed.number).toBe(number);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildImageData()", () => {
|
||||
test("format starts with {\"type\":6,\"data\"", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const text = payload.toString("utf-8", 0, 15);
|
||||
expect(text).toMatch(/^\{"type":6,"data/);
|
||||
});
|
||||
|
||||
test("has correct prefix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":6,"data":');
|
||||
});
|
||||
|
||||
test("suffix is '}'", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const lastByte = payload.toString("utf-8", payload.length - 1);
|
||||
expect(lastByte).toBe("}");
|
||||
});
|
||||
|
||||
test("IMB header (36 bytes) present after prefix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const imbSig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||
expect(imbSig).toBe("IMB");
|
||||
});
|
||||
|
||||
test("JPEG data follows IMB header", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const jpegStart = prefixLen + 36;
|
||||
const jpegInPayload = payload.subarray(jpegStart, jpegStart + jpeg.length);
|
||||
expect(jpegInPayload.equals(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test("total length = prefix + 36 + jpegSize + suffix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const suffixLen = 1;
|
||||
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
|
||||
expect(payload.length).toBe(expectedLen);
|
||||
});
|
||||
|
||||
test("custom type creates {\"type\":5,\"data\":...}", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(
|
||||
jpeg,
|
||||
[64, 32],
|
||||
PacketType.DYNAMIC_AMBIENCE
|
||||
);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":5,"data":');
|
||||
});
|
||||
|
||||
test("works with 1-byte JPEG", () => {
|
||||
const jpeg = Buffer.from([0xff]);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const jpegStart = prefixLen + 36;
|
||||
expect(payload[jpegStart]).toBe(0xff);
|
||||
});
|
||||
|
||||
test("works with large JPEG (1MB+)", () => {
|
||||
const jpeg = createTestJpeg(1024 * 1024);
|
||||
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const suffixLen = 1;
|
||||
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
|
||||
expect(payload.length).toBe(expectedLen);
|
||||
});
|
||||
|
||||
test("dimensions are embedded in IMB header", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const imbStart = prefixLen;
|
||||
const width = payload.readUInt16LE(imbStart + 16);
|
||||
const height = payload.readUInt16LE(imbStart + 18);
|
||||
expect(width).toBe(128);
|
||||
expect(height).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitPayload()", () => {
|
||||
test("throws error (not implemented)", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow();
|
||||
});
|
||||
|
||||
test("error message mentions Type 5/DYNAMIC_AMBIENCE", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(
|
||||
/DYNAMIC_AMBIENCE|Type 5/i
|
||||
);
|
||||
});
|
||||
|
||||
test("error message mentions not implemented", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(/not.*implemented/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - header structure", () => {
|
||||
test("byte 0 is cmdType (0xF1 by default)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet[0]).toBe(0xf1);
|
||||
});
|
||||
|
||||
test("byte 1 is cmdSubtype (from config)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet[1]).toBe(DEFAULT_PROTOCOL_CONFIG.cmdSubtype);
|
||||
});
|
||||
|
||||
test("byte 1 uses override packetType when provided", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 0, 0, PacketType.IMAGE);
|
||||
expect(packet[1]).toBe(PacketType.IMAGE);
|
||||
});
|
||||
|
||||
test("bytes 2-3 are totalPacketCount (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 10, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
});
|
||||
|
||||
test("bytes 4-5 are remainingPackets (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 10, 5);
|
||||
expect(packet.readUInt16BE(4)).toBe(5);
|
||||
});
|
||||
|
||||
test("bytes 6-7 are payload length (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - checksum", () => {
|
||||
test("last byte is checksum", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
const checksumByte = packet[packet.length - 1];
|
||||
expect(typeof checksumByte).toBe("number");
|
||||
});
|
||||
|
||||
test("checksum = (-sum(header + payload)) & 0xFF", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
|
||||
const headerAndPayload = packet.subarray(0, packet.length - 1);
|
||||
const expectedChecksum = calculateChecksum(headerAndPayload);
|
||||
const actualChecksum = packet[packet.length - 1];
|
||||
|
||||
expect(actualChecksum).toBe(expectedChecksum);
|
||||
});
|
||||
|
||||
test("different payloads produce different checksums", () => {
|
||||
const builder = createBuilder();
|
||||
const packet1 = builder.createPacket(Buffer.from("test1"));
|
||||
const packet2 = builder.createPacket(Buffer.from("test2"));
|
||||
const checksum1 = packet1[packet1.length - 1];
|
||||
const checksum2 = packet2[packet2.length - 1];
|
||||
expect(checksum1).not.toBe(checksum2);
|
||||
});
|
||||
|
||||
test("same payload produces same checksum", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const builder = createBuilder();
|
||||
const packet1 = builder.createPacket(payload);
|
||||
const packet2 = builder.createPacket(payload);
|
||||
const checksum1 = packet1[packet1.length - 1];
|
||||
const checksum2 = packet2[packet2.length - 1];
|
||||
expect(checksum1).toBe(checksum2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - parameters", () => {
|
||||
test("totalPacketCount=0, remainingPackets=0 (info packet)", () => {
|
||||
const payload = Buffer.from('{"type":6,"number":1}');
|
||||
const packet = createBuilder().createPacket(payload, 0, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(0);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("totalPacketCount=1, remainingPackets=0 (single data packet)", () => {
|
||||
const payload = Buffer.from("data");
|
||||
const packet = createBuilder().createPacket(payload, 1, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(1);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("totalPacketCount=10, remainingPackets=9 (first of 10)", () => {
|
||||
const payload = Buffer.from("chunk1");
|
||||
const packet = createBuilder().createPacket(payload, 10, 9);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
expect(packet.readUInt16BE(4)).toBe(9);
|
||||
});
|
||||
|
||||
test("totalPacketCount=10, remainingPackets=0 (last of 10)", () => {
|
||||
const payload = Buffer.from("chunk10");
|
||||
const packet = createBuilder().createPacket(payload, 10, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("override packetType changes byte 1", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(
|
||||
payload,
|
||||
0,
|
||||
0,
|
||||
PacketType.DEVICE_STATUS
|
||||
);
|
||||
expect(packet[1]).toBe(PacketType.DEVICE_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - edge cases", () => {
|
||||
test("empty payload (length=0)", () => {
|
||||
const payload = Buffer.alloc(0);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(0);
|
||||
expect(packet.length).toBe(9);
|
||||
});
|
||||
|
||||
test("1-byte payload", () => {
|
||||
const payload = Buffer.from([0x42]);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(1);
|
||||
expect(packet[8]).toBe(0x42);
|
||||
});
|
||||
|
||||
test("large payload (512 bytes)", () => {
|
||||
const payload = Buffer.alloc(512, 0xaa);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(512);
|
||||
expect(packet.length).toBe(8 + 512 + 1);
|
||||
});
|
||||
|
||||
test("values overflow handling (65535+ packets)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 70000, 70000);
|
||||
const totalPackets = packet.readUInt16BE(2);
|
||||
const remaining = packet.readUInt16BE(4);
|
||||
expect(totalPackets).toBe(70000 & 0xffff);
|
||||
expect(remaining).toBe(70000 & 0xffff);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - structure", () => {
|
||||
test("packet = header(8) + payload + checksum(1)", () => {
|
||||
const payload = Buffer.from("test data");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.length).toBe(8 + payload.length + 1);
|
||||
});
|
||||
|
||||
test("payload is correctly embedded", () => {
|
||||
const payload = Buffer.from("test data");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
const embeddedPayload = packet.subarray(8, 8 + payload.length);
|
||||
expect(embeddedPayload.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
test("image info packet structure", () => {
|
||||
const infoPayload = Buffer.from('{"type":6,"number":1}');
|
||||
const packet = createBuilder().createPacket(infoPayload, 0, 0, PacketType.IMAGE);
|
||||
|
||||
expect(packet[0]).toBe(0xf1);
|
||||
expect(packet[1]).toBe(PacketType.IMAGE);
|
||||
expect(packet.readUInt16BE(2)).toBe(0);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
expect(packet.readUInt16BE(6)).toBe(infoPayload.length);
|
||||
|
||||
const embeddedPayload = packet.subarray(8, 8 + infoPayload.length);
|
||||
expect(embeddedPayload.equals(infoPayload)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration - upload sequence", () => {
|
||||
test("build info + data packets for single image", () => {
|
||||
const builder = createBuilder();
|
||||
|
||||
const infoPayload = builder.buildImageInfo();
|
||||
const infoPacket = builder.createPacket(infoPayload, 0, 0, PacketType.IMAGE);
|
||||
|
||||
expect(infoPacket[0]).toBe(0xf1);
|
||||
expect(infoPacket[1]).toBe(PacketType.IMAGE);
|
||||
|
||||
const jpeg = createTestJpeg(200);
|
||||
const dataPayload = builder.buildImageData(jpeg, [64, 32]);
|
||||
const dataPacket = builder.createPacket(dataPayload, 1, 0);
|
||||
|
||||
expect(dataPacket[0]).toBe(0xf1);
|
||||
expect(dataPacket.readUInt16BE(2)).toBe(1);
|
||||
expect(dataPacket.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("build multi-packet data sequence", () => {
|
||||
const builder = createBuilder();
|
||||
const jpeg = createTestJpeg(1000);
|
||||
const fullData = builder.buildImageData(jpeg, [64, 32]);
|
||||
|
||||
const chunkSize = 512;
|
||||
const totalChunks = Math.ceil(fullData.length / chunkSize);
|
||||
|
||||
const packets = [];
|
||||
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 remaining = totalChunks - 1 - i;
|
||||
|
||||
const packet = builder.createPacket(chunk, totalChunks, remaining);
|
||||
packets.push(packet);
|
||||
|
||||
expect(packet.readUInt16BE(2)).toBe(totalChunks);
|
||||
expect(packet.readUInt16BE(4)).toBe(remaining);
|
||||
}
|
||||
|
||||
expect(packets.length).toBe(totalChunks);
|
||||
expect(packets[0]!.readUInt16BE(4)).toBe(totalChunks - 1);
|
||||
expect(packets[packets.length - 1]!.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
|
||||
/**
|
||||
* Builder for creating protocol payloads and packets for image uploads
|
||||
*
|
||||
* Handles construction of image info, data payloads, and complete protocol packets
|
||||
* with proper headers, checksums, and formatting.
|
||||
*/
|
||||
export class PayloadBuilder {
|
||||
/**
|
||||
* Creates a new PayloadBuilder instance.
|
||||
* @param config Protocol configuration containing command type and subtype values
|
||||
*/
|
||||
constructor(private config: ProtocolConfig) {}
|
||||
|
||||
/**
|
||||
* Build image info payload for image upload
|
||||
*
|
||||
* This announces to the device that one image is coming.
|
||||
* For batch uploads, call this once per image (always with number=1).
|
||||
*
|
||||
* NOTE: Dynamic ambience (Type 5) also uses this same info format
|
||||
* with imageType=PacketType.IMAGE, followed by Type 5 data packet.
|
||||
*
|
||||
* @param imageType Image type identifier (PacketType.IMAGE for still image or animation info)
|
||||
* @param number Number of images in this announcement (always 1)
|
||||
* @returns Image info payload bytes in format: {"type":6,"number":1}
|
||||
*/
|
||||
public buildImageInfo(
|
||||
imageType: number = PacketType.IMAGE,
|
||||
number: number = 1,
|
||||
): Buffer {
|
||||
const imageInfo = { type: imageType, number };
|
||||
// Real app format: {"type":IMAGE,"number":1} without extra spaces
|
||||
const jsonStr = JSON.stringify(imageInfo);
|
||||
return Buffer.from(jsonStr, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build initialization payload for dynamic ambience mode (animations, gallery)
|
||||
*
|
||||
* Type 5 (DYNAMIC_AMBIENCE) is used for:
|
||||
* - Video uploads (frames extracted via FFmpeg)
|
||||
* - GIF uploads (frames separated)
|
||||
* - Gallery/slideshow mode (multiple images converted to animation)
|
||||
*
|
||||
* Process:
|
||||
* 1. Extract/convert frames using FFmpeg
|
||||
* 2. Send info packet: {"type":6,"number":1} (uses buildImageInfo)
|
||||
* 3. Send data packet: {"type":5,"data":<xV4_ANIMATION>}
|
||||
*
|
||||
* Animation data format (needs implementation):
|
||||
* - Signature: "xV4" (0x78 0x56 0x34)
|
||||
* - Frame timing: "output/50ms" or similar
|
||||
* - Frame references: "frame_00001", "frame_00002", etc.
|
||||
* - Multiple JPEG frames embedded
|
||||
*
|
||||
* @throws {Error} Dynamic ambience feature is not yet implemented
|
||||
* @deprecated This feature is not implemented yet
|
||||
*/
|
||||
public buildInitPayload(): Buffer {
|
||||
throw new Error(
|
||||
"Dynamic ambience (PacketType.DYNAMIC_AMBIENCE) is not yet implemented. " +
|
||||
"This feature is required for video/GIF upload and gallery/slideshow mode. " +
|
||||
"Only static single image upload (PacketType.IMAGE) is currently supported.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build image data payload with binary header
|
||||
* @param jpegData JPEG image bytes
|
||||
* @param targetSize Image dimensions [width, height]
|
||||
* @param imageType Image type identifier (PacketType.IMAGE)
|
||||
* @returns Complete data payload with prefix, header, and JPEG data
|
||||
*/
|
||||
public buildImageData(
|
||||
jpegData: Buffer,
|
||||
targetSize: [number, number],
|
||||
imageType: number = PacketType.IMAGE,
|
||||
): Buffer {
|
||||
const dataPrefix = Buffer.from(`{"type":${imageType},"data":`, "utf-8");
|
||||
const dataSuffix = Buffer.from("}", "utf-8");
|
||||
|
||||
// Build IMB header
|
||||
const header = IMBHeaderBuilder.build(
|
||||
jpegData.length,
|
||||
targetSize[0],
|
||||
targetSize[1],
|
||||
);
|
||||
|
||||
return Buffer.concat([dataPrefix, header, jpegData, dataSuffix]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol packet with header, payload, and checksum
|
||||
*
|
||||
* - Bytes 0-1: Command type and subtype
|
||||
* - Bytes 2-3: Total packet count (CONSTANT across all packets)
|
||||
* - Bytes 4-5: Remaining packets (COUNTDOWN from total-1 to 0)
|
||||
* - Bytes 6-7: Payload length
|
||||
* - Payload data
|
||||
* - Checksum byte: (-sum(header + payload)) & 0xFF
|
||||
*
|
||||
* @param payload Packet payload data
|
||||
* @param totalPacketCount Total number of packets in this transmission (constant across all packets)
|
||||
* @param remainingPackets Number of packets remaining after this one (countdown: total-1 to 0)
|
||||
* @param packetType Override packet subtype
|
||||
* @returns Complete packet with header + payload + checksum
|
||||
*/
|
||||
public createPacket(
|
||||
payload: Buffer,
|
||||
totalPacketCount: number = 0,
|
||||
remainingPackets: number = 0,
|
||||
packetType?: PacketType,
|
||||
): Buffer {
|
||||
const payloadLength = payload.length;
|
||||
const type = packetType !== undefined ? packetType : this.config.cmdSubtype;
|
||||
|
||||
const headerLength = 8;
|
||||
const packet = Buffer.alloc(headerLength + payloadLength + 1);
|
||||
|
||||
// Build 8-byte header
|
||||
packet.writeUInt8(this.config.cmdType & 0xff, 0); // CMD_TYPE (0xF1)
|
||||
packet.writeUInt8(type & 0xff, 1); // CMD_SUBTYPE
|
||||
packet.writeUInt16BE(totalPacketCount & 0xffff, 2); // TOTAL_PACKETS (constant)
|
||||
packet.writeUInt16BE(remainingPackets & 0xffff, 4); // REMAINING_PACKETS (countdown)
|
||||
packet.writeUInt16BE(payloadLength & 0xffff, 6); // PAYLOAD_LENGTH
|
||||
|
||||
payload.copy(packet, headerLength);
|
||||
|
||||
// Calculate checksum: (-sum(header + payload)) & 0xFF
|
||||
let sum = 0;
|
||||
for (let i = 0; i < headerLength + payloadLength; i++) {
|
||||
sum += packet[i]!;
|
||||
}
|
||||
const checksum = -sum & 0xff;
|
||||
packet.writeUInt8(checksum, headerLength + payloadLength);
|
||||
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Command type byte used in packet headers (0xF1)
|
||||
*
|
||||
* This identifies the BeamBox protocol to the device.
|
||||
* As observed, all packets sent to the device must start with this command type.
|
||||
*/
|
||||
export const CMD_TYPE = 0xf1;
|
||||
|
||||
/**
|
||||
* Size of the packet header in bytes (8 bytes)
|
||||
*
|
||||
*/
|
||||
export const HEADER_SIZE = 8;
|
||||
|
||||
/**
|
||||
* Size of the checksum/trailer in bytes (1 byte)
|
||||
*
|
||||
* Appended to end of every packet for data integrity verification.
|
||||
*/
|
||||
export const CHECKSUM_SIZE = 1;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Constants
|
||||
export * from "./constants.ts";
|
||||
|
||||
// Enums
|
||||
export * from "./packet-types.ts";
|
||||
export * from "./response-types.ts";
|
||||
|
||||
// Interfaces and configs
|
||||
export * from "./interfaces/index.ts";
|
||||
|
||||
// Builders
|
||||
export { PayloadBuilder } from "./builders/payload-builder.ts";
|
||||
export { IMBHeaderBuilder } from "./builders/imb-header.ts";
|
||||
|
||||
// Parsers
|
||||
export { ResponseParser } from "./parsers/response-parser.ts";
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PacketType } from "../packet-types.ts";
|
||||
|
||||
/**
|
||||
* Bluetooth Low Energy (BLE) connection configuration
|
||||
*
|
||||
* Contains settings for discovering and connecting to the BeamBox device via BLE.
|
||||
*/
|
||||
export interface BLEConfig {
|
||||
/**
|
||||
* Expected name of the device during scanning
|
||||
*/
|
||||
deviceName: string;
|
||||
|
||||
/**
|
||||
* UUID of the BLE characteristic used for writing commands/data to the device
|
||||
*/
|
||||
writeCharacteristicUUID: string;
|
||||
|
||||
/**
|
||||
* UUID of the BLE characteristic used for receiving notifications/responses from the device
|
||||
*/
|
||||
notifyCharacteristicUUID: string;
|
||||
|
||||
/**
|
||||
* Maximum time (in seconds) to wait for device discovery before timing out
|
||||
*/
|
||||
scanTimeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol configuration for packet transmission
|
||||
*
|
||||
* Contains timing and size parameters for data transfer to the device.
|
||||
* These values control the flow of data to prevent overwhelming the device.
|
||||
*/
|
||||
export interface ProtocolConfig {
|
||||
/**
|
||||
* Command type byte (CMD_TYPE) sent in packet headers
|
||||
*
|
||||
* Always 0xF1 as observed in packet captures
|
||||
*/
|
||||
cmdType: number;
|
||||
|
||||
/**
|
||||
* Command subtype indicating the type of data being sent
|
||||
*/
|
||||
cmdSubtype: PacketType;
|
||||
|
||||
/**
|
||||
* Maximum size in bytes of each data chunk sent to the device
|
||||
*/
|
||||
chunkSize: number;
|
||||
|
||||
/**
|
||||
* Maximum size in bytes for image info JSON packets
|
||||
*/
|
||||
imageInfoChunkSize: number;
|
||||
|
||||
/**
|
||||
* Delay (in seconds) between sending consecutive data chunks
|
||||
*/
|
||||
packetDelay: number;
|
||||
|
||||
/**
|
||||
* Delay (in seconds) after sending the image info packet
|
||||
*/
|
||||
imageInfoDelay: number;
|
||||
|
||||
/**
|
||||
* Maximum time (in seconds) to wait for a packet acknowledgment from the device
|
||||
*/
|
||||
packetAckTimeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image processing and configuration settings
|
||||
*
|
||||
* Contains parameters for processing images before sending to the device,
|
||||
* including resizing, compression, and validation settings.
|
||||
*/
|
||||
export interface ImageConfig {
|
||||
/**
|
||||
* Default display dimensions [width, height] for images
|
||||
*
|
||||
* Images are resized to fit these dimensions
|
||||
*/
|
||||
defaultSize: [number, number];
|
||||
|
||||
/**
|
||||
* JPEG compression quality (0-100)
|
||||
*
|
||||
* - 0: Lowest quality, smallest file
|
||||
* - 100: Highest quality, largest file
|
||||
* - 70: Good balance (recommended)
|
||||
*/
|
||||
jpegQuality: number;
|
||||
|
||||
/**
|
||||
* Whether to enable JPEG optimization for smaller file sizes
|
||||
*
|
||||
* true: Apply optimization algorithms (slower but smaller)
|
||||
* false: Skip optimization (faster but larger)
|
||||
*/
|
||||
jpegOptimize: boolean;
|
||||
|
||||
/**
|
||||
* Number of squares per dimension for checkerboard transparency pattern
|
||||
*
|
||||
* Example: 8 = 8x8 checkerboard grid
|
||||
* Used for test pattern generation
|
||||
*/
|
||||
checkerboardSquares: number;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BLEConfig, ProtocolConfig, ImageConfig } from "./config.ts";
|
||||
import { CMD_TYPE } from "../constants.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
|
||||
/**
|
||||
* Default BLE configuration for BeamBox devices
|
||||
*
|
||||
* Standard settings for connecting to the beambox e-Badge Pulse device.
|
||||
* TODO: Support other BeamBox models in the future.
|
||||
*/
|
||||
export const DEFAULT_BLE_CONFIG: BLEConfig = {
|
||||
deviceName: "beambox e-Badge Pulse",
|
||||
writeCharacteristicUUID: "000001f1-0000-1000-8000-00805f9b34fb",
|
||||
notifyCharacteristicUUID: "000001f2-0000-1000-8000-00805f9b34fb",
|
||||
scanTimeout: 10.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default protocol configuration for data transmission
|
||||
*
|
||||
* Optimized timing and size values for reliable communication with the device.
|
||||
*
|
||||
* Values based on packet capture analysis:
|
||||
* - chunkSize: 0x1F0 (496 bytes) - max payload size observed
|
||||
* - imageInfoChunkSize: 0x14 (20 bytes) - size of `{"type":6,"number":1}`
|
||||
* - packetDelay: 0.1s - prevents overwhelming device buffer
|
||||
* - imageInfoDelay: 0.01s - gives device time to prepare
|
||||
* - packetAckTimeout: 2.0s - reasonable wait for response
|
||||
*/
|
||||
export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = {
|
||||
cmdType: CMD_TYPE,
|
||||
cmdSubtype: PacketType.IMAGE,
|
||||
chunkSize: 0x1f0,
|
||||
imageInfoChunkSize: 0x14,
|
||||
packetDelay: 0.1,
|
||||
imageInfoDelay: 0.01,
|
||||
packetAckTimeout: 2.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default image processing configuration
|
||||
*
|
||||
* Standard settings for processing images for the 368x368 BeamBox display.
|
||||
*
|
||||
* - defaultSize: [368, 368] - device native resolution
|
||||
* - jpegQuality: 70 - good balance between quality and size
|
||||
* - jpegOptimize: true - enable optimization for smaller files
|
||||
* - checkerboardSquares: 8 - for test pattern generation
|
||||
*/
|
||||
export const DEFAULT_IMAGE_CONFIG: ImageConfig = {
|
||||
defaultSize: [368, 368],
|
||||
jpegQuality: 70,
|
||||
jpegOptimize: true,
|
||||
checkerboardSquares: 8,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PacketType } from "../packet-types.ts";
|
||||
|
||||
/**
|
||||
* Device status information returned by the device
|
||||
*/
|
||||
export interface DeviceStatus {
|
||||
/** Packet type identifier (always 13 for DEVICE_STATUS) */
|
||||
type: PacketType.DEVICE_STATUS;
|
||||
|
||||
/**
|
||||
* Total storage capacity in kilobytes (KB)
|
||||
*/
|
||||
allspace: number;
|
||||
|
||||
/**
|
||||
* Available free storage in kilobytes (KB)
|
||||
*/
|
||||
freespace: number;
|
||||
|
||||
/**
|
||||
* Device name/identifier
|
||||
*
|
||||
* Usually empty string from observations
|
||||
*/
|
||||
devname: string;
|
||||
|
||||
/**
|
||||
* Display resolution as "width,height" string
|
||||
*
|
||||
* Example: "368,368" = 368x368 pixels
|
||||
*/
|
||||
size: string;
|
||||
|
||||
/**
|
||||
* Brand/device model identifier
|
||||
*
|
||||
* Usually empty string from observations
|
||||
*/
|
||||
brand: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./device-status.ts";
|
||||
export * from "./parsed-response.ts";
|
||||
export * from "./config.ts";
|
||||
export * from "./defaults.ts";
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ResponseStatus } from "../response-types.ts";
|
||||
import type { DeviceStatus } from "./device-status.ts";
|
||||
|
||||
/**
|
||||
* Parsed response from the device
|
||||
*
|
||||
* Contains the raw response text along with structured data extracted from it.
|
||||
* Responses can be status acknowledgments or actual device information.
|
||||
*/
|
||||
export interface ParsedResponse {
|
||||
/**
|
||||
* Raw string response received from the device
|
||||
*
|
||||
* May contain null bytes (0x00) and other control characters.
|
||||
*/
|
||||
rawText: string;
|
||||
|
||||
/**
|
||||
* Status code indicating success/failure of the operation
|
||||
*/
|
||||
status: ResponseStatus | null;
|
||||
|
||||
/**
|
||||
* Parsed JSON data if the response contains structured information
|
||||
*/
|
||||
jsonData: Record<string, unknown> | null;
|
||||
|
||||
/**
|
||||
* Whether this is a status packet (acknowledgment) rather than data
|
||||
*
|
||||
* TODO: Implement distinction between status and data packets, there is much better way to do this.
|
||||
* true: This is a DEVICE_STATUS response
|
||||
* false: This is a simple status acknowledgment
|
||||
*/
|
||||
isStatus: boolean;
|
||||
|
||||
/**
|
||||
* Device status information if this is a DEVICE_STATUS response
|
||||
*
|
||||
* TODO: Implement distinction between status and data packets, there is much better way to do this.
|
||||
* Only present when isStatus=true and jsonData contains type:13
|
||||
*/
|
||||
deviceStatus?: DeviceStatus;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Packet type identifiers
|
||||
*
|
||||
* These values appear in the "type" field of JSON payloads and indicate
|
||||
* the purpose/content of the packet.
|
||||
*
|
||||
* ## Overview
|
||||
*
|
||||
* The protocol uses a BLE-based communication system where:
|
||||
* - Client sends commands/data to device via write characteristic
|
||||
* - Device responds with status messages via notify characteristic
|
||||
* - All payloads are wrapped in 8-byte headers with checksums
|
||||
*
|
||||
*/
|
||||
export enum PacketType {
|
||||
/**
|
||||
* 0x05: DYNAMIC_AMBIENCE (Client to Device)
|
||||
*
|
||||
* Used for ALL animated content: videos, GIFs, and image gallery mode
|
||||
*
|
||||
* ## Upload Process (Two-Step)
|
||||
*
|
||||
* Step 1 - Send info packet (uses Type 6):
|
||||
* ```json
|
||||
* {"type":6,"number":1}
|
||||
* ```
|
||||
*
|
||||
* Step 2 - Send animation data:
|
||||
* ```json
|
||||
* {"type":5,"data":<xV4_ANIMATION_DATA>}
|
||||
* ```
|
||||
*
|
||||
* ## Animation Data Format
|
||||
*
|
||||
* The data payload contains:
|
||||
* TODO: Validate this format more thoroughly
|
||||
* - Signature: "xV4" (0x78 0x56 0x34), custom animation format?
|
||||
* - Frame timing: "output/50ms" interval between frames
|
||||
* - Frame references: "frame_00001", "frame_00002", etc.
|
||||
* - Multiple JPEG frames embedded in single payload
|
||||
*
|
||||
* ## How Content is Converted to Animation
|
||||
*
|
||||
* TODO: Implement using ffmpeg to extract frames and build xV4 format?
|
||||
*
|
||||
* ```bash
|
||||
* # Get video info
|
||||
* ffprobe -v quiet -show_entries format=duration -of csv=p=0 input.mp4
|
||||
* ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 input.mp4
|
||||
*
|
||||
* # Extract frames
|
||||
* ffmpeg -i input.mp4 output/frame_%05d.jpg
|
||||
* ```
|
||||
*
|
||||
* ## Use Cases
|
||||
*
|
||||
* - Video upload: Extract frames from video file
|
||||
* - GIF upload: Separate animated GIF into frames
|
||||
* - Gallery/slideshow Mode: Convert multiple images to animation with interval (the official app does this)
|
||||
*
|
||||
* TODO: Full implementation pending
|
||||
*/
|
||||
DYNAMIC_AMBIENCE = 0x05,
|
||||
|
||||
/**
|
||||
* 0x06: IMAGE (Client to Device)
|
||||
*
|
||||
* Used for uploading static SINGLE images to the device.
|
||||
*
|
||||
* ## Upload Process (Two-Step)
|
||||
*
|
||||
* Step 1 - Send image info:
|
||||
* ```json
|
||||
* {"type":6,"number":1}
|
||||
* ```
|
||||
* TODO: Validate if "number" means image count
|
||||
* - Announces that 1 image is coming? (Always observed as 1, even for multiple images, so might be something else)
|
||||
* - Device responds with DEVICE_STATUS (type 13)
|
||||
*
|
||||
* Step 2 - Send image data:
|
||||
* ```
|
||||
* {"type":6,"data":<IMB_HEADER><JPEG_BINARY>}
|
||||
* ```
|
||||
* - IMB header: 36 bytes (contains size, dimensions)
|
||||
* - JPEG data: Raw binary JPEG file
|
||||
* - Sent in chunks (0x1F0 (496) bytes per chunk)
|
||||
* - Device responds "GetPacketSuccess" for each chunk
|
||||
*
|
||||
* ## Important Notes
|
||||
* - For multiple images: repeat the 2-step process sequentially
|
||||
* - For IMB header format, see IMBHeaderBuilder in src/lib/protocol/imb-header.ts
|
||||
* - Gallery/slideshow mode on the official app does NOT use this, uses DYNAMIC_AMBIENCE (Type 5) and preprocessing instead
|
||||
*
|
||||
* The official app's "Image Gallery" feature works by:
|
||||
* 1. Converting multiple images into animation using FFmpeg
|
||||
* 2. Creating frame sequence: frame_00001.jpg, frame_00002.jpg, etc.
|
||||
* 3. Sending as Type 5 (DYNAMIC_AMBIENCE) with embedded timing
|
||||
*
|
||||
*/
|
||||
IMAGE = 0x06,
|
||||
|
||||
/**
|
||||
* 0x0C: PHOTO_ALBUM_COUNT (Client to Device)
|
||||
*
|
||||
* Would announce number of images in gallery/album mode??
|
||||
* TODO: Does not seem to be used or might be used on another model, needs further investigation
|
||||
*/
|
||||
PHOTO_ALBUM_COUNT = 0x0c,
|
||||
|
||||
/**
|
||||
* 0x0D: DEVICE_STATUS (Device to Client)
|
||||
*
|
||||
* Device response containing storage and capability information.
|
||||
*
|
||||
* ## Format
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "type": 13,
|
||||
* "allspace": 16384, // Total storage in KB
|
||||
* "freespace": 13892, // Free storage in KB
|
||||
* "devname": "", // Device name (usually empty)
|
||||
* "size": "368,368", // Display resolution (width,height)
|
||||
* "brand": 0 // Brand/model identifier
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## When Sent
|
||||
*
|
||||
* Device sends this in response to:
|
||||
* - Image info packet (Type)
|
||||
* - Used to check available storage before upload
|
||||
*
|
||||
*/
|
||||
DEVICE_STATUS = 0x0d,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { ResponseParser } from "./response-parser.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { ResponseStatus } from "../response-types.ts";
|
||||
import { hexToBuffer } from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
const knownPackets = {
|
||||
getPacketSuccess: { text: "GetPacketSuccess" },
|
||||
getPacketFail: { text: "GetPacketFail" },
|
||||
errorResponse: { text: "1111111111" },
|
||||
deviceStatusResponse: {
|
||||
hex: "7b2274797065223a31332c22616c6c7370616365223a31363338342c22667265657370616365223a31333839322c226465766e616d65223a224265616d426f78222c2273697a65223a223634783332222c226272616e64223a317d",
|
||||
json: {
|
||||
type: 13,
|
||||
allspace: 16384,
|
||||
freespace: 13892,
|
||||
devname: "BeamBox",
|
||||
size: "64x32",
|
||||
brand: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("ResponseParser", () => {
|
||||
describe("parse() - basic parsing", () => {
|
||||
test("empty buffer returns empty ParsedResponse", () => {
|
||||
const result = ResponseParser.parse(Buffer.alloc(0));
|
||||
expect(result.rawText).toBe("");
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.jsonData).toBeNull();
|
||||
expect(result.isStatus).toBe(false);
|
||||
});
|
||||
|
||||
test("cleans null bytes (\\x00)", () => {
|
||||
const data = Buffer.from("\x00hello\x00world\x00");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.rawText).toBe("helloworld");
|
||||
});
|
||||
|
||||
test("cleans \\xD1 bytes", () => {
|
||||
const data = Buffer.from([0xd1, 0x68, 0x69, 0xd1]); // \xD1hi\xD1
|
||||
const result = ResponseParser.parse(data);
|
||||
// Note: \xD1 might not be cleaned by current implementation
|
||||
expect(result.rawText.includes("hi")).toBe(true);
|
||||
});
|
||||
|
||||
test("trims whitespace", () => {
|
||||
const data = Buffer.from(" test ");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.rawText).toBe("test");
|
||||
});
|
||||
|
||||
test("combines cleaning and trimming", () => {
|
||||
const data = Buffer.from("\x00 \xD1 test \xD1 \x00");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.rawText).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse() - status detection", () => {
|
||||
test("'GetPacketSuccess' sets status to SUCCESS", () => {
|
||||
const data = Buffer.from("GetPacketSuccess");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBe(ResponseStatus.SUCCESS);
|
||||
});
|
||||
|
||||
test("'GetPacketFail' sets status to FAIL", () => {
|
||||
const data = Buffer.from("GetPacketFail");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBe(ResponseStatus.FAIL);
|
||||
});
|
||||
|
||||
test("'1111111111' sets status to ERROR", () => {
|
||||
const data = Buffer.from("1111111111");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBe(ResponseStatus.ERROR);
|
||||
});
|
||||
|
||||
test("no status text returns null status", () => {
|
||||
const data = Buffer.from("random text");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBeNull();
|
||||
});
|
||||
|
||||
test("status detection with null bytes", () => {
|
||||
const data = Buffer.from("\x00GetPacketSuccess\x00");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBe(ResponseStatus.SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse() - JSON extraction", () => {
|
||||
test("valid JSON string", () => {
|
||||
const data = Buffer.from('{"type":13,"value":42}');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toEqual({ type: 13, value: 42 });
|
||||
});
|
||||
|
||||
test("JSON with null bytes", () => {
|
||||
const data = Buffer.from('\x00{"type":13}\x00');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toEqual({ type: 13 });
|
||||
});
|
||||
|
||||
test("JSON with prefix/suffix", () => {
|
||||
const data = Buffer.from('abc{"type":13}xyz');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toEqual({ type: 13 });
|
||||
});
|
||||
|
||||
test("JSON with \\xD1 bytes", () => {
|
||||
const data = Buffer.from([0xd1, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x31, 0x33, 0x7d, 0xd1]);
|
||||
// \xD1{"type":13}\xD1
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toEqual({ type: 13 });
|
||||
});
|
||||
|
||||
test("invalid JSON returns null", () => {
|
||||
const data = Buffer.from("{invalid}");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toBeNull();
|
||||
});
|
||||
|
||||
test("no JSON returns null", () => {
|
||||
const data = Buffer.from("plain text");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toBeNull();
|
||||
});
|
||||
|
||||
test("incomplete JSON returns null", () => {
|
||||
const data = Buffer.from('{"type":13');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse() - device status detection", () => {
|
||||
test("type 13 (number) sets isStatus true", () => {
|
||||
const data = Buffer.from('{"type":13}');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.isStatus).toBe(true);
|
||||
});
|
||||
|
||||
test('type "13" (string) sets isStatus true', () => {
|
||||
const data = Buffer.from('{"type":"13"}');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.isStatus).toBe(true);
|
||||
});
|
||||
|
||||
test("type 6 sets isStatus false", () => {
|
||||
const data = Buffer.from('{"type":6}');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.isStatus).toBe(false);
|
||||
});
|
||||
|
||||
test("no type field sets isStatus false", () => {
|
||||
const data = Buffer.from('{"value":42}');
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.isStatus).toBe(false);
|
||||
});
|
||||
|
||||
test("null jsonData sets isStatus false", () => {
|
||||
const data = Buffer.from("not json");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.isStatus).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse() - device status parsing", () => {
|
||||
test("full status object", () => {
|
||||
const statusJson = {
|
||||
type: 13,
|
||||
allspace: 16384,
|
||||
freespace: 13892,
|
||||
devname: "BeamBox",
|
||||
size: "64x32",
|
||||
brand: 1,
|
||||
};
|
||||
const data = Buffer.from(JSON.stringify(statusJson));
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.isStatus).toBe(true);
|
||||
expect(result.deviceStatus).toBeDefined();
|
||||
expect(result.deviceStatus?.type).toBe(PacketType.DEVICE_STATUS);
|
||||
expect(result.deviceStatus?.allspace).toBe(16384);
|
||||
expect(result.deviceStatus?.freespace).toBe(13892);
|
||||
expect(result.deviceStatus?.devname).toBe("BeamBox");
|
||||
expect(result.deviceStatus?.size).toBe("64x32");
|
||||
expect(result.deviceStatus?.brand).toBe(1);
|
||||
});
|
||||
|
||||
test("missing fields default to 0 or empty string", () => {
|
||||
const data = Buffer.from('{"type":13}');
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.deviceStatus?.type).toBe(PacketType.DEVICE_STATUS);
|
||||
expect(result.deviceStatus?.allspace).toBe(0);
|
||||
expect(result.deviceStatus?.freespace).toBe(0);
|
||||
expect(result.deviceStatus?.devname).toBe("");
|
||||
expect(result.deviceStatus?.size).toBe("");
|
||||
expect(result.deviceStatus?.brand).toBe(0);
|
||||
});
|
||||
|
||||
test("string numbers converted to Number", () => {
|
||||
const data = Buffer.from('{"type":13,"allspace":"16384","freespace":"13892","brand":"1"}');
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.deviceStatus?.allspace).toBe(16384);
|
||||
expect(result.deviceStatus?.freespace).toBe(13892);
|
||||
expect(result.deviceStatus?.brand).toBe(1);
|
||||
expect(typeof result.deviceStatus?.allspace).toBe("number");
|
||||
});
|
||||
|
||||
test("invalid number values default to 0", () => {
|
||||
const data = Buffer.from('{"type":13,"allspace":"invalid","brand":"bad"}');
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.deviceStatus?.allspace).toBe(0);
|
||||
expect(result.deviceStatus?.brand).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helper methods", () => {
|
||||
test("isSuccess() checks ResponseStatus.SUCCESS", () => {
|
||||
const data = Buffer.from("GetPacketSuccess");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(ResponseParser.isSuccess(result)).toBe(true);
|
||||
expect(ResponseParser.isFail(result)).toBe(false);
|
||||
expect(ResponseParser.isError(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("isFail() checks ResponseStatus.FAIL", () => {
|
||||
const data = Buffer.from("GetPacketFail");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(ResponseParser.isSuccess(result)).toBe(false);
|
||||
expect(ResponseParser.isFail(result)).toBe(true);
|
||||
expect(ResponseParser.isError(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("isError() checks ResponseStatus.ERROR", () => {
|
||||
const data = Buffer.from("1111111111");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(ResponseParser.isSuccess(result)).toBe(false);
|
||||
expect(ResponseParser.isFail(result)).toBe(false);
|
||||
expect(ResponseParser.isError(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("all return false for null status", () => {
|
||||
const data = Buffer.from("random");
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(ResponseParser.isSuccess(result)).toBe(false);
|
||||
expect(ResponseParser.isFail(result)).toBe(false);
|
||||
expect(ResponseParser.isError(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real packet tests", () => {
|
||||
test("parse GetPacketSuccess response", () => {
|
||||
const data = Buffer.from(knownPackets.getPacketSuccess.text);
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.rawText).toBe("GetPacketSuccess");
|
||||
expect(result.status).toBe(ResponseStatus.SUCCESS);
|
||||
expect(ResponseParser.isSuccess(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("parse GetPacketFail response", () => {
|
||||
const data = Buffer.from(knownPackets.getPacketFail.text);
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.rawText).toBe("GetPacketFail");
|
||||
expect(result.status).toBe(ResponseStatus.FAIL);
|
||||
expect(ResponseParser.isFail(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("parse error response", () => {
|
||||
const data = Buffer.from(knownPackets.errorResponse.text);
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.rawText).toBe("1111111111");
|
||||
expect(result.status).toBe(ResponseStatus.ERROR);
|
||||
expect(ResponseParser.isError(result)).toBe(true);
|
||||
});
|
||||
|
||||
test("parse device status response", () => {
|
||||
const data = hexToBuffer(knownPackets.deviceStatusResponse.hex);
|
||||
const result = ResponseParser.parse(data);
|
||||
|
||||
expect(result.isStatus).toBe(true);
|
||||
expect(result.jsonData).toEqual(knownPackets.deviceStatusResponse.json);
|
||||
expect(result.deviceStatus?.type).toBe(13);
|
||||
expect(result.deviceStatus?.allspace).toBe(16384);
|
||||
expect(result.deviceStatus?.freespace).toBe(13892);
|
||||
expect(result.deviceStatus?.devname).toBe("BeamBox");
|
||||
expect(result.deviceStatus?.size).toBe("64x32");
|
||||
expect(result.deviceStatus?.brand).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("very long text", () => {
|
||||
const longText = "a".repeat(10000);
|
||||
const data = Buffer.from(longText);
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.rawText).toBe(longText);
|
||||
});
|
||||
|
||||
test("binary data (non-UTF8)", () => {
|
||||
const data = Buffer.from([0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.jsonData).toBeNull();
|
||||
});
|
||||
|
||||
test("nested JSON", () => {
|
||||
const nested = { type: 13, data: { nested: { value: 42 } } };
|
||||
const data = Buffer.from(JSON.stringify(nested));
|
||||
const result = ResponseParser.parse(data);
|
||||
expect(result.jsonData).toEqual(nested);
|
||||
expect(result.isStatus).toBe(true);
|
||||
});
|
||||
|
||||
test("JSON array is parsed", () => {
|
||||
const data = Buffer.from('[{"type":13}]');
|
||||
const result = ResponseParser.parse(data);
|
||||
// JSON.parse will parse arrays too
|
||||
expect(Array.isArray(result.jsonData)).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple JSON objects (first is parsed if valid JSON)", () => {
|
||||
const data = Buffer.from('{"type":13}{"type":6}');
|
||||
const result = ResponseParser.parse(data);
|
||||
// This is invalid JSON, so extraction will try to find {...}
|
||||
// It might find the first object
|
||||
expect(result.jsonData).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { ResponseStatus } from "../response-types.ts";
|
||||
import type { ParsedResponse, DeviceStatus } from "../interfaces/index.ts";
|
||||
|
||||
/**
|
||||
* Parser for responses received from the BeamBox device
|
||||
*
|
||||
* Handles parsing of raw Buffer data into structured response objects,
|
||||
* extracting status codes, JSON data, and device status information
|
||||
*/
|
||||
export class ResponseParser {
|
||||
/**
|
||||
* Parses raw response data from the device
|
||||
*
|
||||
* Cleans the raw text, extracts status codes, parses JSON data,
|
||||
* and identifies device status packets
|
||||
*
|
||||
* @param data - Raw Buffer received from the device
|
||||
* @returns Parsed response containing status, JSON data, and device info if applicable
|
||||
*/
|
||||
public static parse(data: Buffer): ParsedResponse {
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
rawText: "",
|
||||
status: null,
|
||||
jsonData: null,
|
||||
isStatus: false,
|
||||
};
|
||||
}
|
||||
|
||||
const rawText = data
|
||||
.toString("utf-8")
|
||||
.replace(/\x00/g, "")
|
||||
.replace(/\xd1/g, "")
|
||||
.trim();
|
||||
|
||||
let status: ResponseStatus | null = null;
|
||||
if (rawText.includes(ResponseStatus.SUCCESS)) {
|
||||
status = ResponseStatus.SUCCESS;
|
||||
} else if (rawText.includes(ResponseStatus.FAIL)) {
|
||||
status = ResponseStatus.FAIL;
|
||||
} else if (rawText.includes(ResponseStatus.ERROR)) {
|
||||
status = ResponseStatus.ERROR;
|
||||
}
|
||||
|
||||
const jsonData = this.extractJson(rawText);
|
||||
|
||||
const isStatus = this.isDeviceStatusPacket(jsonData);
|
||||
|
||||
const result: ParsedResponse = {
|
||||
rawText,
|
||||
status,
|
||||
jsonData,
|
||||
isStatus,
|
||||
};
|
||||
|
||||
if (isStatus && jsonData) {
|
||||
result.deviceStatus = this.parseDeviceStatus(jsonData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the parsed JSON data represents a device status packet
|
||||
*
|
||||
* @param jsonData - Parsed JSON object from the response
|
||||
* @returns True if this is a device status packet, false otherwise
|
||||
*/
|
||||
private static isDeviceStatusPacket(
|
||||
jsonData: Record<string, unknown> | null,
|
||||
): boolean {
|
||||
if (!jsonData) return false;
|
||||
|
||||
const type = jsonData.type;
|
||||
return (
|
||||
type === PacketType.DEVICE_STATUS ||
|
||||
type === String(PacketType.DEVICE_STATUS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses device status information from JSON data
|
||||
*
|
||||
* Extracts storage information, device name, display size, and brand ID
|
||||
*
|
||||
* @param jsonData - Parsed JSON object containing device status
|
||||
* @returns Device status with typed fields
|
||||
*/
|
||||
private static parseDeviceStatus(
|
||||
jsonData: Record<string, unknown>,
|
||||
): DeviceStatus {
|
||||
return {
|
||||
type: PacketType.DEVICE_STATUS,
|
||||
allspace: Number(jsonData.allspace) || 0,
|
||||
freespace: Number(jsonData.freespace) || 0,
|
||||
devname: String(jsonData.devname || ""),
|
||||
size: String(jsonData.size || ""),
|
||||
brand: Number(jsonData.brand) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts JSON data from response text
|
||||
*
|
||||
* Attempts to parse the entire text as JSON. If that fails,
|
||||
* attempts to extract JSON from within the text by finding
|
||||
* the first '{' and last '}' characters.
|
||||
*
|
||||
* @param text - Response text to parse
|
||||
* @returns Parsed JSON object or null if no valid JSON found
|
||||
*/
|
||||
private static extractJson(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Try extracting JSON from within text, it might come prefixed/suffixed
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const jsonPart = text.substring(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(jsonPart);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the response indicates successful operation
|
||||
*
|
||||
* @param response - Parsed response to check
|
||||
* @returns True if response status is SUCCESS
|
||||
*/
|
||||
public static isSuccess(response: ParsedResponse): boolean {
|
||||
return response.status === ResponseStatus.SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the response indicates a failed operation
|
||||
*
|
||||
* @param response - Parsed response to check
|
||||
* @returns True if response status is FAIL
|
||||
*/
|
||||
public static isFail(response: ParsedResponse): boolean {
|
||||
return response.status === ResponseStatus.FAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the response indicates an error
|
||||
*
|
||||
* @param response - Parsed response to check
|
||||
* @returns True if response status is ERROR
|
||||
*/
|
||||
public static isError(response: ParsedResponse): boolean {
|
||||
return response.status === ResponseStatus.ERROR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Status codes returned by the device in response to packet transfers
|
||||
*
|
||||
* These string values indicate whether the device successfully received and processed
|
||||
* a packet, or if an error occurred during transmission or processing.
|
||||
*/
|
||||
export enum ResponseStatus {
|
||||
/**
|
||||
* Packet was successfully received and processed by the device
|
||||
*
|
||||
* Device sends this after successfully receiving each data chunk.
|
||||
* Client should proceed to send the next chunk.
|
||||
*/
|
||||
SUCCESS = "GetPacketSuccess",
|
||||
|
||||
/**
|
||||
* Packet transmission or processing failed
|
||||
*
|
||||
* Device sends this when a chunk was corrupted or couldn't be processed.
|
||||
* Client should retry sending the failed chunk.
|
||||
*/
|
||||
FAIL = "PacketFail",
|
||||
|
||||
/**
|
||||
* Error occurred (represented by ten '1' characters)
|
||||
*
|
||||
* Likely occurs when an error happens during packet handling,
|
||||
* like malformed data or unexpected conditions.
|
||||
*
|
||||
* Client should abort the upload and reconnect.
|
||||
*/
|
||||
ERROR = "1111111111",
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
BeamBoxError,
|
||||
DeviceNotFoundError,
|
||||
ConnectionError,
|
||||
ImageProcessingError,
|
||||
UploadError,
|
||||
DeviceResponseError,
|
||||
} from "./errors.ts";
|
||||
|
||||
describe("Error Classes", () => {
|
||||
describe("error hierarchy", () => {
|
||||
test("BeamBoxError extends Error", () => {
|
||||
const error = new BeamBoxError("test");
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
test("all custom errors extend BeamBoxError", () => {
|
||||
expect(new DeviceNotFoundError() instanceof BeamBoxError).toBe(true);
|
||||
expect(new ConnectionError() instanceof BeamBoxError).toBe(true);
|
||||
expect(new ImageProcessingError("test") instanceof BeamBoxError).toBe(true);
|
||||
expect(new UploadError("test") instanceof BeamBoxError).toBe(true);
|
||||
expect(new DeviceResponseError("test") instanceof BeamBoxError).toBe(true);
|
||||
});
|
||||
|
||||
test("instanceof works correctly", () => {
|
||||
const error = new DeviceNotFoundError();
|
||||
expect(error instanceof DeviceNotFoundError).toBe(true);
|
||||
expect(error instanceof BeamBoxError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error names", () => {
|
||||
test("BeamBoxError.name === 'BeamBoxError'", () => {
|
||||
const error = new BeamBoxError("test");
|
||||
expect(error.name).toBe("BeamBoxError");
|
||||
});
|
||||
|
||||
test("DeviceNotFoundError.name === 'DeviceNotFoundError'", () => {
|
||||
const error = new DeviceNotFoundError();
|
||||
expect(error.name).toBe("DeviceNotFoundError");
|
||||
});
|
||||
|
||||
test("ConnectionError.name === 'ConnectionError'", () => {
|
||||
const error = new ConnectionError();
|
||||
expect(error.name).toBe("ConnectionError");
|
||||
});
|
||||
|
||||
test("ImageProcessingError.name === 'ImageProcessingError'", () => {
|
||||
const error = new ImageProcessingError("test");
|
||||
expect(error.name).toBe("ImageProcessingError");
|
||||
});
|
||||
|
||||
test("UploadError.name === 'UploadError'", () => {
|
||||
const error = new UploadError("test");
|
||||
expect(error.name).toBe("UploadError");
|
||||
});
|
||||
|
||||
test("DeviceResponseError.name === 'DeviceResponseError'", () => {
|
||||
const error = new DeviceResponseError("test");
|
||||
expect(error.name).toBe("DeviceResponseError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default messages", () => {
|
||||
test("DeviceNotFoundError default: 'Device not found'", () => {
|
||||
const error = new DeviceNotFoundError();
|
||||
expect(error.message).toBe("Device not found");
|
||||
});
|
||||
|
||||
test("ConnectionError default: 'Connection failed'", () => {
|
||||
const error = new ConnectionError();
|
||||
expect(error.message).toBe("Connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom messages", () => {
|
||||
test("BeamBoxError accepts custom message", () => {
|
||||
const error = new BeamBoxError("custom error");
|
||||
expect(error.message).toBe("custom error");
|
||||
});
|
||||
|
||||
test("DeviceNotFoundError accepts custom message", () => {
|
||||
const error = new DeviceNotFoundError("specific device not found");
|
||||
expect(error.message).toBe("specific device not found");
|
||||
});
|
||||
|
||||
test("ConnectionError accepts custom message", () => {
|
||||
const error = new ConnectionError("timeout connecting");
|
||||
expect(error.message).toBe("timeout connecting");
|
||||
});
|
||||
|
||||
test("ImageProcessingError requires message", () => {
|
||||
const error = new ImageProcessingError("failed to resize");
|
||||
expect(error.message).toBe("failed to resize");
|
||||
});
|
||||
|
||||
test("UploadError requires message", () => {
|
||||
const error = new UploadError("upload interrupted");
|
||||
expect(error.message).toBe("upload interrupted");
|
||||
});
|
||||
|
||||
test("DeviceResponseError requires message", () => {
|
||||
const error = new DeviceResponseError("invalid response");
|
||||
expect(error.message).toBe("invalid response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error throwing and catching", () => {
|
||||
test("can throw and catch BeamBoxError", () => {
|
||||
expect(() => {
|
||||
throw new BeamBoxError("test");
|
||||
}).toThrow(BeamBoxError);
|
||||
});
|
||||
|
||||
test("can catch specific error type", () => {
|
||||
try {
|
||||
throw new DeviceNotFoundError("custom");
|
||||
} catch (e) {
|
||||
expect(e instanceof DeviceNotFoundError).toBe(true);
|
||||
expect((e as DeviceNotFoundError).message).toBe("custom");
|
||||
}
|
||||
});
|
||||
|
||||
test("can catch as base BeamBoxError", () => {
|
||||
try {
|
||||
throw new ImageProcessingError("test");
|
||||
} catch (e) {
|
||||
if (e instanceof BeamBoxError) {
|
||||
expect(e.message).toBe("test");
|
||||
} else {
|
||||
throw new Error("Should be BeamBoxError");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("can catch as Error", () => {
|
||||
try {
|
||||
throw new UploadError("test");
|
||||
} catch (e) {
|
||||
expect(e instanceof Error).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Base error class for all BeamBox-related errors
|
||||
*/
|
||||
export class BeamBoxError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BeamBoxError";
|
||||
Object.setPrototypeOf(this, BeamBoxError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a device cannot be found or accessed
|
||||
*/
|
||||
export class DeviceNotFoundError extends BeamBoxError {
|
||||
constructor(message: string = "Device not found") {
|
||||
super(message);
|
||||
this.name = "DeviceNotFoundError";
|
||||
Object.setPrototypeOf(this, DeviceNotFoundError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a connection to the device fails
|
||||
*/
|
||||
export class ConnectionError extends BeamBoxError {
|
||||
constructor(message: string = "Connection failed") {
|
||||
super(message);
|
||||
this.name = "ConnectionError";
|
||||
Object.setPrototypeOf(this, ConnectionError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when image processing operations fail
|
||||
*/
|
||||
export class ImageProcessingError extends BeamBoxError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ImageProcessingError";
|
||||
Object.setPrototypeOf(this, ImageProcessingError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when uploading data to the device fails
|
||||
*/
|
||||
export class UploadError extends BeamBoxError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UploadError";
|
||||
Object.setPrototypeOf(this, UploadError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the device returns an unexpected or invalid response
|
||||
*/
|
||||
export class DeviceResponseError extends BeamBoxError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DeviceResponseError";
|
||||
Object.setPrototypeOf(this, DeviceResponseError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./errors.ts";
|
||||
export * from "./logger.ts";
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Log severity levels
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARNING = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of events that can be logged
|
||||
*/
|
||||
export enum LogEventType {
|
||||
SCAN_START = "scan_start",
|
||||
DEVICE_FOUND = "device_found",
|
||||
CONNECT_START = "connect_start",
|
||||
CONNECTED = "connected",
|
||||
DISCOVER_CHAR = "discover_char",
|
||||
STATUS_WAIT = "status_wait",
|
||||
STATUS_RECEIVED = "status_received",
|
||||
IMAGE_INFO_SEND = "image_info_send",
|
||||
DATA_SEND_START = "data_send_start",
|
||||
DATA_SEND_PROGRESS = "data_send_progress",
|
||||
DATA_SEND_COMPLETE = "data_send_complete",
|
||||
GENERIC = "generic",
|
||||
}
|
||||
|
||||
/**
|
||||
* A single log entry
|
||||
*/
|
||||
export type LogEntry = {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
eventType?: LogEventType;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logger class for handling application logging with severity levels and event tracking.
|
||||
* Supports console output at different levels (DEBUG, INFO, WARNING, ERROR) and
|
||||
* provides a listener system for external log processing.
|
||||
*/
|
||||
class Logger {
|
||||
private level: LogLevel = LogLevel.WARNING;
|
||||
private listeners: Set<(entry: LogEntry) => void> = new Set();
|
||||
|
||||
/**
|
||||
* Sets the minimum log level to output. Messages below this level will be ignored.
|
||||
* @param level Minimum log level (DEBUG, INFO, WARNING, or ERROR)
|
||||
*/
|
||||
public setLevel(level: LogLevel): void {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback to be invoked for each log entry that meets the minimum level.
|
||||
* @param listener Callback function that receives the log entry
|
||||
* @returns Unsubscribe function to remove the listener
|
||||
*/
|
||||
public onLog(listener: (entry: LogEntry) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logging method that processes log entries based on current level.
|
||||
* Outputs to appropriate console method and notifies all listeners.
|
||||
* @private
|
||||
* @param level Severity level of the log entry
|
||||
* @param message Log message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with the log entry
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
eventType?: LogEventType,
|
||||
data?: any,
|
||||
): void {
|
||||
if (this.level <= level) {
|
||||
const timestamp = Date.now();
|
||||
const entry: LogEntry = { level, message, timestamp, eventType, data };
|
||||
|
||||
const levelNames = ["DEBUG", "INFO", "WARNING", "ERROR"];
|
||||
const levelName = levelNames[level];
|
||||
const output = `[${levelName}] ${message}`;
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(output);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.log(output);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
console.warn(output);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(output);
|
||||
break;
|
||||
}
|
||||
|
||||
this.listeners.forEach((listener) => listener(entry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message. Only output if log level is DEBUG or lower.
|
||||
* @param message Debug message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public debug(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.DEBUG, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info message. Only output if log level is INFO or lower.
|
||||
* @param message Info message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public info(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.INFO, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message. Only output if log level is WARNING or lower.
|
||||
* @param message Warning message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public warning(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.WARNING, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message. Always output regardless of log level.
|
||||
* @param message Error message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public error(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.ERROR, message, eventType, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global logger instance for application-wide logging.
|
||||
*/
|
||||
export const logger = new Logger();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
import type { ConnectionStep } from "../components/index.ts";
|
||||
|
||||
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"];
|
||||
|
||||
export function scanDirectoryForImages(dirPath: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dirPath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
const ext = extname(entry).toLowerCase();
|
||||
if (IMAGE_EXTENSIONS.includes(ext)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function updateStepStatus(
|
||||
steps: ConnectionStep[],
|
||||
stepId: string,
|
||||
status: "pending" | "active" | "complete" | "error",
|
||||
nextStepId?: string,
|
||||
): ConnectionStep[] {
|
||||
return steps.map((step) => {
|
||||
if (step.id === stepId) return { ...step, status };
|
||||
if (nextStepId && step.id === nextStepId)
|
||||
return { ...step, status: "active" };
|
||||
return step;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user