mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: initial commit
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
import { expectHex } from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
describe("IMBHeaderBuilder", () => {
|
||||
describe("build()", () => {
|
||||
describe("structure validation", () => {
|
||||
test("creates exactly 36-byte buffer", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.length).toBe(36);
|
||||
});
|
||||
|
||||
test("signature is 'IMB' at bytes 0-2", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.toString("utf-8", 0, 3)).toBe("IMB");
|
||||
expect(header[0]).toBe(0x49); // I
|
||||
expect(header[1]).toBe(0x4d); // M
|
||||
expect(header[2]).toBe(0x42); // B
|
||||
});
|
||||
|
||||
test("null byte at position 3", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[3]).toBe(0x00);
|
||||
});
|
||||
|
||||
test("header size (36) at bytes 4-7 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(4)).toBe(36);
|
||||
});
|
||||
|
||||
test("total size (jpegSize + 36) at bytes 8-11 (little-endian)", () => {
|
||||
const jpegSize = 1024;
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(8)).toBe(jpegSize + 36);
|
||||
});
|
||||
|
||||
test("format value (11) at byte 12", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[12]).toBe(11);
|
||||
});
|
||||
|
||||
test("zero byte at byte 13", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header[13]).toBe(0x00);
|
||||
});
|
||||
|
||||
test("reserved zeros (2 bytes) at bytes 14-15", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(14)).toBe(0);
|
||||
});
|
||||
|
||||
test("width at bytes 16-17 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(16)).toBe(64);
|
||||
});
|
||||
|
||||
test("height at bytes 18-19 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(18)).toBe(32);
|
||||
});
|
||||
|
||||
test("header size repeat (36) at bytes 20-23 (little-endian)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(20)).toBe(36);
|
||||
});
|
||||
|
||||
test("JPEG size at bytes 24-27 (little-endian)", () => {
|
||||
const jpegSize = 1024;
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(24)).toBe(jpegSize);
|
||||
});
|
||||
|
||||
test("reserved zeros (8 bytes) at bytes 28-35", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt32LE(28)).toBe(0);
|
||||
expect(header.readUInt32LE(32)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("zero JPEG size produces valid header", () => {
|
||||
const header = IMBHeaderBuilder.build(0, 64, 32);
|
||||
expect(header.length).toBe(36);
|
||||
expect(header.readUInt32LE(24)).toBe(0); // JPEG size
|
||||
expect(header.readUInt32LE(8)).toBe(36); // Total size = 0 + 36
|
||||
});
|
||||
|
||||
test("large JPEG size (5MB)", () => {
|
||||
const jpegSize = 5 * 1024 * 1024; // 5MB
|
||||
const header = IMBHeaderBuilder.build(jpegSize, 64, 32);
|
||||
expect(header.readUInt32LE(24)).toBe(jpegSize);
|
||||
expect(header.readUInt32LE(8)).toBe(jpegSize + 36);
|
||||
});
|
||||
|
||||
test("maximum dimensions (uint16 max: 65535x65535)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 65535, 65535);
|
||||
expect(header.readUInt16LE(16)).toBe(65535);
|
||||
expect(header.readUInt16LE(18)).toBe(65535);
|
||||
});
|
||||
|
||||
test("minimum dimensions (1x1)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 1, 1);
|
||||
expect(header.readUInt16LE(16)).toBe(1);
|
||||
expect(header.readUInt16LE(18)).toBe(1);
|
||||
});
|
||||
|
||||
test("common device size (64x32)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header.readUInt16LE(16)).toBe(64);
|
||||
expect(header.readUInt16LE(18)).toBe(32);
|
||||
});
|
||||
|
||||
test("common device size (128x64)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 128, 64);
|
||||
expect(header.readUInt16LE(16)).toBe(128);
|
||||
expect(header.readUInt16LE(18)).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("known header verification", () => {
|
||||
test("1KB JPEG, 64x32 matches expected hex", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
// IMB\x00 + 36(LE) + 1060(LE) + 11 + 0x00 + 0x0000 + 64(LE) + 32(LE) + 36(LE) + 1024(LE) + 0x00000000 + 0x00000000
|
||||
expectHex(
|
||||
header,
|
||||
"494d420024000000240400000b0000004000200024000000000400000000000000000000"
|
||||
);
|
||||
});
|
||||
|
||||
test("100 byte JPEG, 128x64 matches expected hex", () => {
|
||||
const header = IMBHeaderBuilder.build(100, 128, 64);
|
||||
// IMB\x00 + 36(LE) + 136(LE) + 11 + 0x00 + 0x0000 + 128(LE) + 64(LE) + 36(LE) + 100(LE) + 0x00000000 + 0x00000000
|
||||
expectHex(
|
||||
header,
|
||||
"494d420024000000880000000b0000008000400024000000640000000000000000000000"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consistency", () => {
|
||||
test("multiple calls with same params produce identical headers", () => {
|
||||
const header1 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
const header2 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(header1.equals(header2)).toBe(true);
|
||||
});
|
||||
|
||||
test("different params produce different headers", () => {
|
||||
const header1 = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
const header2 = IMBHeaderBuilder.build(1024, 128, 64);
|
||||
expect(header1.equals(header2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate()", () => {
|
||||
test("validates correct header from build()", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects wrong length (35 bytes)", () => {
|
||||
const header = Buffer.alloc(35);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong length (37 bytes)", () => {
|
||||
const header = Buffer.alloc(37);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong signature 'IMC'", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header[2] = 0x43; // Change B to C
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong signature 'ABC'", () => {
|
||||
const header = Buffer.alloc(36);
|
||||
header.write("ABC", 0);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty signature", () => {
|
||||
const header = Buffer.alloc(36);
|
||||
// All zeros
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects missing null byte at position 3", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header[3] = 0x01; // Change null to non-null
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mismatched header sizes (bytes 4-7 vs 20-23)", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header.writeUInt32LE(40, 20); // Change second header size to 40
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects incorrect header size value", () => {
|
||||
const header = IMBHeaderBuilder.build(1024, 64, 32);
|
||||
header.writeUInt32LE(32, 4); // Change first header size to 32
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(false);
|
||||
});
|
||||
|
||||
test("validates headers with various JPEG sizes", () => {
|
||||
const sizes = [0, 100, 1024, 10000, 1000000];
|
||||
sizes.forEach((size) => {
|
||||
const header = IMBHeaderBuilder.build(size, 64, 32);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("validates headers with various dimensions", () => {
|
||||
const dimensions: [number, number][] = [
|
||||
[1, 1],
|
||||
[64, 32],
|
||||
[128, 64],
|
||||
[256, 256],
|
||||
[1920, 1080],
|
||||
];
|
||||
dimensions.forEach(([width, height]) => {
|
||||
const header = IMBHeaderBuilder.build(1024, width, height);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("build() and validate() integration", () => {
|
||||
test("all built headers pass validation", () => {
|
||||
const testCases: [number, number, number][] = [
|
||||
[0, 1, 1],
|
||||
[100, 64, 32],
|
||||
[1024, 128, 64],
|
||||
[5242880, 256, 256],
|
||||
[10000, 1920, 1080],
|
||||
];
|
||||
|
||||
testCases.forEach(([jpegSize, width, height]) => {
|
||||
const header = IMBHeaderBuilder.build(jpegSize, width, height);
|
||||
expect(IMBHeaderBuilder.validate(header)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* IMB (Image Binary) header builder
|
||||
*
|
||||
* The IMB header is exactly 36 bytes and contains information about the image:
|
||||
* - Signature: 'IMB' (3 bytes)
|
||||
* - Null byte: 0x00 (1 byte)
|
||||
* - Header size: 36 (4 bytes, little-endian)
|
||||
* - Total size: JPEG size + 36 (4 bytes, little-endian)
|
||||
* - Format: 11 (1 byte) + 0x00 (1 byte)
|
||||
* - Reserved: 0x0000 (2 bytes, little-endian)
|
||||
* - Width: image width (2 bytes, little-endian)
|
||||
* - Height: image height (2 bytes, little-endian)
|
||||
* - Header size repeat: 36 (4 bytes, little-endian)
|
||||
* - JPEG size: size of JPEG data (4 bytes, little-endian)
|
||||
* - Reserved: 0x00000000 0x00000000 (8 bytes, little-endian)
|
||||
*/
|
||||
|
||||
export class IMBHeaderBuilder {
|
||||
private static readonly HEADER_SIZE = 36;
|
||||
private static readonly FORMAT_VALUE = 11;
|
||||
private static readonly SIGNATURE = Buffer.from("IMB");
|
||||
|
||||
/**
|
||||
* Build a 36-byte IMB header for image data
|
||||
* @param jpegSize Size of the JPEG data in bytes
|
||||
* @param width Image width in pixels
|
||||
* @param height Image height in pixels
|
||||
* @returns 36 bytes of IMB header data
|
||||
*/
|
||||
static build(jpegSize: number, width: number, height: number): Buffer {
|
||||
const header = Buffer.alloc(this.HEADER_SIZE);
|
||||
let offset = 0;
|
||||
|
||||
// 1. IMB signature (3 bytes)
|
||||
this.SIGNATURE.copy(header, offset);
|
||||
offset += 3;
|
||||
|
||||
// 2. One zero byte (1 byte)
|
||||
header.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
|
||||
// 3. Header size: 36 as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(this.HEADER_SIZE, offset);
|
||||
offset += 4;
|
||||
|
||||
// 4. Total size: jpegSize + 36 as int32_LE (4 bytes)
|
||||
const totalSize = jpegSize + this.HEADER_SIZE;
|
||||
header.writeUInt32LE(totalSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// 5. Format: 11 (1 byte) + zero byte (1 byte)
|
||||
header.writeUInt8(this.FORMAT_VALUE, offset);
|
||||
offset += 1;
|
||||
header.writeUInt8(0x00, offset);
|
||||
offset += 1;
|
||||
|
||||
// 6. Reserved: zero as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(0, offset);
|
||||
offset += 2;
|
||||
|
||||
// 7. Width as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(width, offset);
|
||||
offset += 2;
|
||||
|
||||
// 8. Height as int16_LE (2 bytes)
|
||||
header.writeUInt16LE(height, offset);
|
||||
offset += 2;
|
||||
|
||||
// 9. Header size repeat: 36 as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(this.HEADER_SIZE, offset);
|
||||
offset += 4;
|
||||
|
||||
// 10. JPEG size as int32_LE (4 bytes)
|
||||
header.writeUInt32LE(jpegSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// 11. Reserved: two zero int32_LE values (8 bytes)
|
||||
header.writeUInt32LE(0, offset);
|
||||
offset += 4;
|
||||
header.writeUInt32LE(0, offset);
|
||||
offset += 4;
|
||||
|
||||
if (header.length !== this.HEADER_SIZE) {
|
||||
throw new Error(
|
||||
`IMB header must be exactly ${this.HEADER_SIZE} bytes, got ${header.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a header is a proper IMB header
|
||||
* @param header Header bytes to validate
|
||||
* @returns True if valid IMB header
|
||||
*/
|
||||
static validate(header: Buffer): boolean {
|
||||
if (header.length !== this.HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check signature
|
||||
if (!header.subarray(0, 3).equals(this.SIGNATURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check null byte
|
||||
if (header.readUInt8(3) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check header size fields
|
||||
const headerSize1 = header.readUInt32LE(4);
|
||||
const headerSize2 = header.readUInt32LE(20);
|
||||
|
||||
if (headerSize1 !== this.HEADER_SIZE || headerSize2 !== this.HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { PayloadBuilder } from "./payload-builder.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import {
|
||||
expectHex,
|
||||
createTestJpeg,
|
||||
calculateChecksum,
|
||||
} from "../../../__tests__/utils/test-helpers.ts";
|
||||
|
||||
const createBuilder = () => new PayloadBuilder(DEFAULT_PROTOCOL_CONFIG);
|
||||
|
||||
describe("PayloadBuilder", () => {
|
||||
describe("buildImageInfo()", () => {
|
||||
test("default creates {\"type\":6,\"number\":1}", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 6, number: 1 });
|
||||
});
|
||||
|
||||
test("output has no extra spaces", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
const text = payload.toString("utf-8");
|
||||
expect(text).toBe('{"type":6,"number":1}');
|
||||
});
|
||||
|
||||
test("custom type creates {\"type\":5,\"number\":1}", () => {
|
||||
const payload = createBuilder().buildImageInfo(PacketType.DYNAMIC_AMBIENCE);
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 5, number: 1 });
|
||||
});
|
||||
|
||||
test("custom number creates {\"type\":6,\"number\":3}", () => {
|
||||
const payload = createBuilder().buildImageInfo(PacketType.IMAGE, 3);
|
||||
const json = JSON.parse(payload.toString("utf-8"));
|
||||
expect(json).toEqual({ type: 6, number: 3 });
|
||||
});
|
||||
|
||||
test("returns UTF-8 Buffer", () => {
|
||||
const payload = createBuilder().buildImageInfo();
|
||||
expect(Buffer.isBuffer(payload)).toBe(true);
|
||||
const text = payload.toString("utf-8");
|
||||
expect(text).toMatch(/^\{.*\}$/);
|
||||
});
|
||||
|
||||
test("JSON.parse(output) matches input params", () => {
|
||||
const imageType = 6;
|
||||
const number = 1;
|
||||
const payload = createBuilder().buildImageInfo(imageType, number);
|
||||
const parsed = JSON.parse(payload.toString("utf-8"));
|
||||
expect(parsed.type).toBe(imageType);
|
||||
expect(parsed.number).toBe(number);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildImageData()", () => {
|
||||
test("format starts with {\"type\":6,\"data\"", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const text = payload.toString("utf-8", 0, 15);
|
||||
expect(text).toMatch(/^\{"type":6,"data/);
|
||||
});
|
||||
|
||||
test("has correct prefix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":6,"data":');
|
||||
});
|
||||
|
||||
test("suffix is '}'", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const lastByte = payload.toString("utf-8", payload.length - 1);
|
||||
expect(lastByte).toBe("}");
|
||||
});
|
||||
|
||||
test("IMB header (36 bytes) present after prefix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const imbSig = payload.toString("utf-8", prefixLen, prefixLen + 3);
|
||||
expect(imbSig).toBe("IMB");
|
||||
});
|
||||
|
||||
test("JPEG data follows IMB header", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const jpegStart = prefixLen + 36;
|
||||
const jpegInPayload = payload.subarray(jpegStart, jpegStart + jpeg.length);
|
||||
expect(jpegInPayload.equals(jpeg)).toBe(true);
|
||||
});
|
||||
|
||||
test("total length = prefix + 36 + jpegSize + suffix", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const suffixLen = 1;
|
||||
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
|
||||
expect(payload.length).toBe(expectedLen);
|
||||
});
|
||||
|
||||
test("custom type creates {\"type\":5,\"data\":...}", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(
|
||||
jpeg,
|
||||
[64, 32],
|
||||
PacketType.DYNAMIC_AMBIENCE
|
||||
);
|
||||
const prefix = payload.toString("utf-8", 0, 17);
|
||||
expect(prefix).toBe('{"type":5,"data":');
|
||||
});
|
||||
|
||||
test("works with 1-byte JPEG", () => {
|
||||
const jpeg = Buffer.from([0xff]);
|
||||
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const jpegStart = prefixLen + 36;
|
||||
expect(payload[jpegStart]).toBe(0xff);
|
||||
});
|
||||
|
||||
test("works with large JPEG (1MB+)", () => {
|
||||
const jpeg = createTestJpeg(1024 * 1024);
|
||||
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const suffixLen = 1;
|
||||
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
|
||||
expect(payload.length).toBe(expectedLen);
|
||||
});
|
||||
|
||||
test("dimensions are embedded in IMB header", () => {
|
||||
const jpeg = createTestJpeg(100);
|
||||
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
|
||||
const prefixLen = '{"type":6,"data":'.length;
|
||||
const imbStart = prefixLen;
|
||||
const width = payload.readUInt16LE(imbStart + 16);
|
||||
const height = payload.readUInt16LE(imbStart + 18);
|
||||
expect(width).toBe(128);
|
||||
expect(height).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitPayload()", () => {
|
||||
test("throws error (not implemented)", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow();
|
||||
});
|
||||
|
||||
test("error message mentions Type 5/DYNAMIC_AMBIENCE", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(
|
||||
/DYNAMIC_AMBIENCE|Type 5/i
|
||||
);
|
||||
});
|
||||
|
||||
test("error message mentions not implemented", () => {
|
||||
expect(() => createBuilder().buildInitPayload()).toThrow(/not.*implemented/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - header structure", () => {
|
||||
test("byte 0 is cmdType (0xF1 by default)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet[0]).toBe(0xf1);
|
||||
});
|
||||
|
||||
test("byte 1 is cmdSubtype (from config)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet[1]).toBe(DEFAULT_PROTOCOL_CONFIG.cmdSubtype);
|
||||
});
|
||||
|
||||
test("byte 1 uses override packetType when provided", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 0, 0, PacketType.IMAGE);
|
||||
expect(packet[1]).toBe(PacketType.IMAGE);
|
||||
});
|
||||
|
||||
test("bytes 2-3 are totalPacketCount (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 10, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
});
|
||||
|
||||
test("bytes 4-5 are remainingPackets (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 10, 5);
|
||||
expect(packet.readUInt16BE(4)).toBe(5);
|
||||
});
|
||||
|
||||
test("bytes 6-7 are payload length (big-endian)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - checksum", () => {
|
||||
test("last byte is checksum", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
const checksumByte = packet[packet.length - 1];
|
||||
expect(typeof checksumByte).toBe("number");
|
||||
});
|
||||
|
||||
test("checksum = (-sum(header + payload)) & 0xFF", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
|
||||
const headerAndPayload = packet.subarray(0, packet.length - 1);
|
||||
const expectedChecksum = calculateChecksum(headerAndPayload);
|
||||
const actualChecksum = packet[packet.length - 1];
|
||||
|
||||
expect(actualChecksum).toBe(expectedChecksum);
|
||||
});
|
||||
|
||||
test("different payloads produce different checksums", () => {
|
||||
const builder = createBuilder();
|
||||
const packet1 = builder.createPacket(Buffer.from("test1"));
|
||||
const packet2 = builder.createPacket(Buffer.from("test2"));
|
||||
const checksum1 = packet1[packet1.length - 1];
|
||||
const checksum2 = packet2[packet2.length - 1];
|
||||
expect(checksum1).not.toBe(checksum2);
|
||||
});
|
||||
|
||||
test("same payload produces same checksum", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const builder = createBuilder();
|
||||
const packet1 = builder.createPacket(payload);
|
||||
const packet2 = builder.createPacket(payload);
|
||||
const checksum1 = packet1[packet1.length - 1];
|
||||
const checksum2 = packet2[packet2.length - 1];
|
||||
expect(checksum1).toBe(checksum2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - parameters", () => {
|
||||
test("totalPacketCount=0, remainingPackets=0 (info packet)", () => {
|
||||
const payload = Buffer.from('{"type":6,"number":1}');
|
||||
const packet = createBuilder().createPacket(payload, 0, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(0);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("totalPacketCount=1, remainingPackets=0 (single data packet)", () => {
|
||||
const payload = Buffer.from("data");
|
||||
const packet = createBuilder().createPacket(payload, 1, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(1);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("totalPacketCount=10, remainingPackets=9 (first of 10)", () => {
|
||||
const payload = Buffer.from("chunk1");
|
||||
const packet = createBuilder().createPacket(payload, 10, 9);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
expect(packet.readUInt16BE(4)).toBe(9);
|
||||
});
|
||||
|
||||
test("totalPacketCount=10, remainingPackets=0 (last of 10)", () => {
|
||||
const payload = Buffer.from("chunk10");
|
||||
const packet = createBuilder().createPacket(payload, 10, 0);
|
||||
expect(packet.readUInt16BE(2)).toBe(10);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("override packetType changes byte 1", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(
|
||||
payload,
|
||||
0,
|
||||
0,
|
||||
PacketType.DEVICE_STATUS
|
||||
);
|
||||
expect(packet[1]).toBe(PacketType.DEVICE_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - edge cases", () => {
|
||||
test("empty payload (length=0)", () => {
|
||||
const payload = Buffer.alloc(0);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(0);
|
||||
expect(packet.length).toBe(9);
|
||||
});
|
||||
|
||||
test("1-byte payload", () => {
|
||||
const payload = Buffer.from([0x42]);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(1);
|
||||
expect(packet[8]).toBe(0x42);
|
||||
});
|
||||
|
||||
test("large payload (512 bytes)", () => {
|
||||
const payload = Buffer.alloc(512, 0xaa);
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.readUInt16BE(6)).toBe(512);
|
||||
expect(packet.length).toBe(8 + 512 + 1);
|
||||
});
|
||||
|
||||
test("values overflow handling (65535+ packets)", () => {
|
||||
const payload = Buffer.from("test");
|
||||
const packet = createBuilder().createPacket(payload, 70000, 70000);
|
||||
const totalPackets = packet.readUInt16BE(2);
|
||||
const remaining = packet.readUInt16BE(4);
|
||||
expect(totalPackets).toBe(70000 & 0xffff);
|
||||
expect(remaining).toBe(70000 & 0xffff);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPacket() - structure", () => {
|
||||
test("packet = header(8) + payload + checksum(1)", () => {
|
||||
const payload = Buffer.from("test data");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
expect(packet.length).toBe(8 + payload.length + 1);
|
||||
});
|
||||
|
||||
test("payload is correctly embedded", () => {
|
||||
const payload = Buffer.from("test data");
|
||||
const packet = createBuilder().createPacket(payload);
|
||||
const embeddedPayload = packet.subarray(8, 8 + payload.length);
|
||||
expect(embeddedPayload.equals(payload)).toBe(true);
|
||||
});
|
||||
|
||||
test("image info packet structure", () => {
|
||||
const infoPayload = Buffer.from('{"type":6,"number":1}');
|
||||
const packet = createBuilder().createPacket(infoPayload, 0, 0, PacketType.IMAGE);
|
||||
|
||||
expect(packet[0]).toBe(0xf1);
|
||||
expect(packet[1]).toBe(PacketType.IMAGE);
|
||||
expect(packet.readUInt16BE(2)).toBe(0);
|
||||
expect(packet.readUInt16BE(4)).toBe(0);
|
||||
expect(packet.readUInt16BE(6)).toBe(infoPayload.length);
|
||||
|
||||
const embeddedPayload = packet.subarray(8, 8 + infoPayload.length);
|
||||
expect(embeddedPayload.equals(infoPayload)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration - upload sequence", () => {
|
||||
test("build info + data packets for single image", () => {
|
||||
const builder = createBuilder();
|
||||
|
||||
const infoPayload = builder.buildImageInfo();
|
||||
const infoPacket = builder.createPacket(infoPayload, 0, 0, PacketType.IMAGE);
|
||||
|
||||
expect(infoPacket[0]).toBe(0xf1);
|
||||
expect(infoPacket[1]).toBe(PacketType.IMAGE);
|
||||
|
||||
const jpeg = createTestJpeg(200);
|
||||
const dataPayload = builder.buildImageData(jpeg, [64, 32]);
|
||||
const dataPacket = builder.createPacket(dataPayload, 1, 0);
|
||||
|
||||
expect(dataPacket[0]).toBe(0xf1);
|
||||
expect(dataPacket.readUInt16BE(2)).toBe(1);
|
||||
expect(dataPacket.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
|
||||
test("build multi-packet data sequence", () => {
|
||||
const builder = createBuilder();
|
||||
const jpeg = createTestJpeg(1000);
|
||||
const fullData = builder.buildImageData(jpeg, [64, 32]);
|
||||
|
||||
const chunkSize = 512;
|
||||
const totalChunks = Math.ceil(fullData.length / chunkSize);
|
||||
|
||||
const packets = [];
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, fullData.length);
|
||||
const chunk = fullData.subarray(start, end);
|
||||
const remaining = totalChunks - 1 - i;
|
||||
|
||||
const packet = builder.createPacket(chunk, totalChunks, remaining);
|
||||
packets.push(packet);
|
||||
|
||||
expect(packet.readUInt16BE(2)).toBe(totalChunks);
|
||||
expect(packet.readUInt16BE(4)).toBe(remaining);
|
||||
}
|
||||
|
||||
expect(packets.length).toBe(totalChunks);
|
||||
expect(packets[0]!.readUInt16BE(4)).toBe(totalChunks - 1);
|
||||
expect(packets[packets.length - 1]!.readUInt16BE(4)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ProtocolConfig } from "../interfaces/config.ts";
|
||||
import { PacketType } from "../packet-types.ts";
|
||||
import { IMBHeaderBuilder } from "./imb-header.ts";
|
||||
|
||||
/**
|
||||
* Builder for creating protocol payloads and packets for image uploads
|
||||
*
|
||||
* Handles construction of image info, data payloads, and complete protocol packets
|
||||
* with proper headers, checksums, and formatting.
|
||||
*/
|
||||
export class PayloadBuilder {
|
||||
/**
|
||||
* Creates a new PayloadBuilder instance.
|
||||
* @param config Protocol configuration containing command type and subtype values
|
||||
*/
|
||||
constructor(private config: ProtocolConfig) {}
|
||||
|
||||
/**
|
||||
* Build image info payload for image upload
|
||||
*
|
||||
* This announces to the device that one image is coming.
|
||||
* For batch uploads, call this once per image (always with number=1).
|
||||
*
|
||||
* NOTE: Dynamic ambience (Type 5) also uses this same info format
|
||||
* with imageType=PacketType.IMAGE, followed by Type 5 data packet.
|
||||
*
|
||||
* @param imageType Image type identifier (PacketType.IMAGE for still image or animation info)
|
||||
* @param number Number of images in this announcement (always 1)
|
||||
* @returns Image info payload bytes in format: {"type":6,"number":1}
|
||||
*/
|
||||
public buildImageInfo(
|
||||
imageType: number = PacketType.IMAGE,
|
||||
number: number = 1,
|
||||
): Buffer {
|
||||
const imageInfo = { type: imageType, number };
|
||||
// Real app format: {"type":IMAGE,"number":1} without extra spaces
|
||||
const jsonStr = JSON.stringify(imageInfo);
|
||||
return Buffer.from(jsonStr, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build initialization payload for dynamic ambience mode (animations, gallery)
|
||||
*
|
||||
* Type 5 (DYNAMIC_AMBIENCE) is used for:
|
||||
* - Video uploads (frames extracted via FFmpeg)
|
||||
* - GIF uploads (frames separated)
|
||||
* - Gallery/slideshow mode (multiple images converted to animation)
|
||||
*
|
||||
* Process:
|
||||
* 1. Extract/convert frames using FFmpeg
|
||||
* 2. Send info packet: {"type":6,"number":1} (uses buildImageInfo)
|
||||
* 3. Send data packet: {"type":5,"data":<xV4_ANIMATION>}
|
||||
*
|
||||
* Animation data format (needs implementation):
|
||||
* - Signature: "xV4" (0x78 0x56 0x34)
|
||||
* - Frame timing: "output/50ms" or similar
|
||||
* - Frame references: "frame_00001", "frame_00002", etc.
|
||||
* - Multiple JPEG frames embedded
|
||||
*
|
||||
* @throws {Error} Dynamic ambience feature is not yet implemented
|
||||
* @deprecated This feature is not implemented yet
|
||||
*/
|
||||
public buildInitPayload(): Buffer {
|
||||
throw new Error(
|
||||
"Dynamic ambience (PacketType.DYNAMIC_AMBIENCE) is not yet implemented. " +
|
||||
"This feature is required for video/GIF upload and gallery/slideshow mode. " +
|
||||
"Only static single image upload (PacketType.IMAGE) is currently supported.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build image data payload with binary header
|
||||
* @param jpegData JPEG image bytes
|
||||
* @param targetSize Image dimensions [width, height]
|
||||
* @param imageType Image type identifier (PacketType.IMAGE)
|
||||
* @returns Complete data payload with prefix, header, and JPEG data
|
||||
*/
|
||||
public buildImageData(
|
||||
jpegData: Buffer,
|
||||
targetSize: [number, number],
|
||||
imageType: number = PacketType.IMAGE,
|
||||
): Buffer {
|
||||
const dataPrefix = Buffer.from(`{"type":${imageType},"data":`, "utf-8");
|
||||
const dataSuffix = Buffer.from("}", "utf-8");
|
||||
|
||||
// Build IMB header
|
||||
const header = IMBHeaderBuilder.build(
|
||||
jpegData.length,
|
||||
targetSize[0],
|
||||
targetSize[1],
|
||||
);
|
||||
|
||||
return Buffer.concat([dataPrefix, header, jpegData, dataSuffix]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol packet with header, payload, and checksum
|
||||
*
|
||||
* - Bytes 0-1: Command type and subtype
|
||||
* - Bytes 2-3: Total packet count (CONSTANT across all packets)
|
||||
* - Bytes 4-5: Remaining packets (COUNTDOWN from total-1 to 0)
|
||||
* - Bytes 6-7: Payload length
|
||||
* - Payload data
|
||||
* - Checksum byte: (-sum(header + payload)) & 0xFF
|
||||
*
|
||||
* @param payload Packet payload data
|
||||
* @param totalPacketCount Total number of packets in this transmission (constant across all packets)
|
||||
* @param remainingPackets Number of packets remaining after this one (countdown: total-1 to 0)
|
||||
* @param packetType Override packet subtype
|
||||
* @returns Complete packet with header + payload + checksum
|
||||
*/
|
||||
public createPacket(
|
||||
payload: Buffer,
|
||||
totalPacketCount: number = 0,
|
||||
remainingPackets: number = 0,
|
||||
packetType?: PacketType,
|
||||
): Buffer {
|
||||
const payloadLength = payload.length;
|
||||
const type = packetType !== undefined ? packetType : this.config.cmdSubtype;
|
||||
|
||||
const headerLength = 8;
|
||||
const packet = Buffer.alloc(headerLength + payloadLength + 1);
|
||||
|
||||
// Build 8-byte header
|
||||
packet.writeUInt8(this.config.cmdType & 0xff, 0); // CMD_TYPE (0xF1)
|
||||
packet.writeUInt8(type & 0xff, 1); // CMD_SUBTYPE
|
||||
packet.writeUInt16BE(totalPacketCount & 0xffff, 2); // TOTAL_PACKETS (constant)
|
||||
packet.writeUInt16BE(remainingPackets & 0xffff, 4); // REMAINING_PACKETS (countdown)
|
||||
packet.writeUInt16BE(payloadLength & 0xffff, 6); // PAYLOAD_LENGTH
|
||||
|
||||
payload.copy(packet, headerLength);
|
||||
|
||||
// Calculate checksum: (-sum(header + payload)) & 0xFF
|
||||
let sum = 0;
|
||||
for (let i = 0; i < headerLength + payloadLength; i++) {
|
||||
sum += packet[i]!;
|
||||
}
|
||||
const checksum = -sum & 0xff;
|
||||
packet.writeUInt8(checksum, headerLength + payloadLength);
|
||||
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user