feat: initial commit

This commit is contained in:
2026-02-03 00:29:31 +07:00
commit 3dfa7acb03
50 changed files with 5369 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
you know, p-chan, ive always really - *clank clank clank clank*
+48
View File
@@ -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

+117
View File
@@ -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)
);
}
}
+116
View File
@@ -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;
}
+14
View File
@@ -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;
}
+93
View File
@@ -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>
);
};
+58
View File
@@ -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;
};
+21
View File
@@ -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>
);
};
+202
View File
@@ -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>
);
};
+68
View File
@@ -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>
);
};
+85
View File
@@ -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>
);
};
+6
View File
@@ -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';
+144
View File
@@ -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,
};
}
+255
View File
@@ -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,
};
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bun
import { setupCLI } from "./cli/index.tsx";
const program = setupCLI();
program.parse();
+569
View File
@@ -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));
}
}
+1
View File
@@ -0,0 +1 @@
export { BleUploader } from "./ble-client.ts";
+260
View File
@@ -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));
}
}
+2
View File
@@ -0,0 +1,2 @@
export { BeamBoxUploader } from "./beambox-uploader.ts";
export type { UploadOptions } from "./beambox-uploader.ts";
+5
View File
@@ -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";
+286
View File
@@ -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);
});
});
});
+149
View File
@@ -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>
`;
}
}
+1
View File
@@ -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);
});
});
});
});
+122
View File
@@ -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;
}
}
+20
View File
@@ -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;
+16
View File
@@ -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";
+113
View File
@@ -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;
}
+55
View File
@@ -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;
}
+4
View File
@@ -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;
}
+136
View File
@@ -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();
});
});
});
+163
View File
@@ -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;
}
}
+33
View File
@@ -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",
}
+146
View File
@@ -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);
}
});
});
});
+65
View File
@@ -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);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./errors.ts";
export * from "./logger.ts";
+153
View File
@@ -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();
+44
View File
@@ -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;
});
}