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
@@ -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;
}
}