From 3dfa7acb039d73ac61bf773a282b357e218502ce Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 2 Feb 2026 18:23:31 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 22 + README.md | 41 ++ assets/screenshot.png | Bin 0 -> 20782 bytes bun.lock | 466 ++++++++++++++ package.json | 43 ++ src/__tests__/fixtures/invalid-image.txt | 1 + src/__tests__/fixtures/known-packets.json | 48 ++ src/__tests__/fixtures/test-1x1.png | Bin 0 -> 67 bytes src/__tests__/utils/test-helpers.ts | 117 ++++ src/cli/index.tsx | 116 ++++ src/cli/types.ts | 14 + src/components/App.tsx | 93 +++ src/components/ConnectionStatus.tsx | 58 ++ src/components/Header.tsx | 21 + src/components/Status.tsx | 202 +++++++ src/components/StatusApp.tsx | 68 +++ src/components/UploadProgress.tsx | 85 +++ src/components/index.ts | 6 + src/hooks/useDeviceStatus.ts | 144 +++++ src/hooks/useUpload.ts | 255 ++++++++ src/index.tsx | 5 + src/lib/ble/ble-client.ts | 569 ++++++++++++++++++ src/lib/ble/index.ts | 1 + src/lib/core/beambox-uploader.ts | 260 ++++++++ src/lib/core/index.ts | 2 + src/lib/index.ts | 5 + src/lib/processing/image-processor.test.ts | 286 +++++++++ src/lib/processing/image-processor.ts | 149 +++++ src/lib/processing/index.ts | 1 + src/lib/protocol/builders/imb-header.test.ts | 246 ++++++++ src/lib/protocol/builders/imb-header.ts | 122 ++++ .../protocol/builders/payload-builder.test.ts | 386 ++++++++++++ src/lib/protocol/builders/payload-builder.ts | 143 +++++ src/lib/protocol/constants.ts | 20 + src/lib/protocol/index.ts | 16 + src/lib/protocol/interfaces/config.ts | 113 ++++ src/lib/protocol/interfaces/defaults.ts | 55 ++ src/lib/protocol/interfaces/device-status.ts | 40 ++ src/lib/protocol/interfaces/index.ts | 4 + .../protocol/interfaces/parsed-response.ts | 44 ++ src/lib/protocol/packet-types.ts | 136 +++++ .../protocol/parsers/response-parser.test.ts | 338 +++++++++++ src/lib/protocol/parsers/response-parser.ts | 163 +++++ src/lib/protocol/response-types.ts | 33 + src/lib/utils/errors.test.ts | 146 +++++ src/lib/utils/errors.ts | 65 ++ src/lib/utils/index.ts | 2 + src/lib/utils/logger.ts | 153 +++++ src/utils/app-utils.ts | 44 ++ tsconfig.json | 22 + 50 files changed, 5369 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/screenshot.png create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/__tests__/fixtures/invalid-image.txt create mode 100644 src/__tests__/fixtures/known-packets.json create mode 100644 src/__tests__/fixtures/test-1x1.png create mode 100644 src/__tests__/utils/test-helpers.ts create mode 100644 src/cli/index.tsx create mode 100644 src/cli/types.ts create mode 100644 src/components/App.tsx create mode 100644 src/components/ConnectionStatus.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/Status.tsx create mode 100644 src/components/StatusApp.tsx create mode 100644 src/components/UploadProgress.tsx create mode 100644 src/components/index.ts create mode 100644 src/hooks/useDeviceStatus.ts create mode 100644 src/hooks/useUpload.ts create mode 100644 src/index.tsx create mode 100644 src/lib/ble/ble-client.ts create mode 100644 src/lib/ble/index.ts create mode 100644 src/lib/core/beambox-uploader.ts create mode 100644 src/lib/core/index.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/processing/image-processor.test.ts create mode 100644 src/lib/processing/image-processor.ts create mode 100644 src/lib/processing/index.ts create mode 100644 src/lib/protocol/builders/imb-header.test.ts create mode 100644 src/lib/protocol/builders/imb-header.ts create mode 100644 src/lib/protocol/builders/payload-builder.test.ts create mode 100644 src/lib/protocol/builders/payload-builder.ts create mode 100644 src/lib/protocol/constants.ts create mode 100644 src/lib/protocol/index.ts create mode 100644 src/lib/protocol/interfaces/config.ts create mode 100644 src/lib/protocol/interfaces/defaults.ts create mode 100644 src/lib/protocol/interfaces/device-status.ts create mode 100644 src/lib/protocol/interfaces/index.ts create mode 100644 src/lib/protocol/interfaces/parsed-response.ts create mode 100644 src/lib/protocol/packet-types.ts create mode 100644 src/lib/protocol/parsers/response-parser.test.ts create mode 100644 src/lib/protocol/parsers/response-parser.ts create mode 100644 src/lib/protocol/response-types.ts create mode 100644 src/lib/utils/errors.test.ts create mode 100644 src/lib/utils/errors.ts create mode 100644 src/lib/utils/index.ts create mode 100644 src/lib/utils/logger.ts create mode 100644 src/utils/app-utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec79605 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Bun +node_modules/ +dist/ +*.lockb +*.bun + +# TypeScript +*.tsbuildinfo + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e53f8c --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# beamboxctl + +Unofficial CLI for managing beambox e-Badge devices over Bluetooth LE. + +![beamboxctl screenshot](assets/screenshot.png) + +## About beambox + +[beambox](https://beambox.com.cn/) is a consumer electronics brand that makes e-Badge display devices which accept image uploads over BLE. This project is not affiliated with, endorsed by, or supported by the beambox manufacturer. + +## Supported Devices + +- [beambox e-Badge Pulse](https://beambox.com.cn/pages/shop) (368x368) +- Other beambox e-Badge models may work, but are untested + +## Development + +Requirements: +- Bun +- Bluetooth LE access on your system + +Run in development mode: + +```sh +bun run dev +``` + +Build and run the CLI: + +```sh +bun run build +bun run start +``` + +## Legal + +This is an unofficial, community-built project. It is provided as-is without warranty. All trademarks and product names are the property of their respective owners, including beambox. + +## License + +MIT diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..6b907cf8aa1c888ce0d65a062003651f887a2b0b GIT binary patch literal 20782 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 literal 0 HcmV?d00001 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6cf87bb --- /dev/null +++ b/bun.lock @@ -0,0 +1,466 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "beamboxctl", + "dependencies": { + "commander": "latest", + "ink": "latest", + "ink-big-text": "latest", + "ink-spinner": "latest", + "node-ble": "latest", + "react": "latest", + "sharp": "latest", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "latest", + }, + "peerDependencies": { + "typescript": "latest", + }, + }, + }, + "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@nornagon/put": ["@nornagon/put@0.0.8", "", {}, "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow=="], + + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + + "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aproba": ["aproba@1.2.0", "", {}, "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="], + + "are-we-there-yet": ["are-we-there-yet@1.1.7", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" } }, "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g=="], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="], + + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], + + "cfonts": ["cfonts@3.3.1", "", { "dependencies": { "supports-color": "^8", "window-size": "^1" }, "bin": { "cfonts": "bin/index.js" } }, "sha512-ZGEmN3W9mViWEDjsuPo4nK4h39sfh6YtoneFYp9WLPI/rw8BaSSrfQC6jkrGW3JMvV3ZnExJB/AEqXc/nHYxkw=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "code-point-at": ["code-point-at@1.1.0", "", {}, "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g=="], + + "dbus-next": ["dbus-next@0.10.2", "", { "dependencies": { "@nornagon/put": "0.0.8", "event-stream": "3.3.4", "hexy": "^0.2.10", "jsbi": "^2.0.5", "long": "^4.0.0", "safe-buffer": "^5.1.1", "xml2js": "^0.4.17" }, "optionalDependencies": { "usocket": "^0.3.0" } }, "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ=="], + + "define-property": ["define-property@1.0.0", "", { "dependencies": { "is-descriptor": "^1.0.0" } }, "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "event-stream": ["event-stream@3.3.4", "", { "dependencies": { "duplexer": "~0.1.1", "from": "~0", "map-stream": "~0.1.0", "pause-stream": "0.0.11", "split": "0.3", "stream-combiner": "~0.0.4", "through": "~2.3.1" } }, "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extsprintf": ["extsprintf@1.3.0", "", {}, "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "forever-agent": ["forever-agent@0.6.1", "", {}, "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="], + + "form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ=="], + + "from": ["from@0.1.7", "", {}, "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gauge": ["gauge@2.7.4", "", { "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.0", "object-assign": "^4.1.0", "signal-exit": "^3.0.0", "string-width": "^1.0.1", "strip-ansi": "^3.0.1", "wide-align": "^1.1.0" } }, "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="], + + "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hexy": ["hexy@0.2.11", "", { "bin": { "hexy": "./bin/hexy_cmd.js" } }, "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="], + + "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ=="], + + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ink": ["ink@6.6.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ=="], + + "ink-big-text": ["ink-big-text@2.0.0", "", { "dependencies": { "cfonts": "^3.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "ink": ">=4", "react": ">=18" } }, "sha512-Juzqv+rIOLGuhMJiE50VtS6dg6olWfzFdL7wsU/EARSL5Eaa5JNXMogMBm9AkjgzO2Y3UwWCOh87jbhSn8aNdw=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "is-accessor-descriptor": ["is-accessor-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA=="], + + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + + "is-data-descriptor": ["is-data-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw=="], + + "is-descriptor": ["is-descriptor@1.0.3", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + + "is-number": ["is-number@3.0.0", "", { "dependencies": { "kind-of": "^3.0.2" } }, "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg=="], + + "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsbi": ["jsbi@2.0.5", "", {}, "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ=="], + + "jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw=="], + + "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], + + "long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "map-stream": ["map-stream@0.1.0", "", {}, "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="], + + "node-ble": ["node-ble@1.13.0", "", { "dependencies": { "dbus-next": "^0.10.2" } }, "sha512-jg8GmyZwowUcze6t/GEEunYvAcASR9hMUpRxs2jehmgdalDQ0xXtkmDfdAQO4Rq3b1gBxBRo43E/ASzbPf3d/A=="], + + "node-gyp": ["node-gyp@7.1.2", "", { "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", "graceful-fs": "^4.2.3", "nopt": "^5.0.0", "npmlog": "^4.1.2", "request": "^2.88.2", "rimraf": "^3.0.2", "semver": "^7.3.2", "tar": "^6.0.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@4.1.2", "", { "dependencies": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", "gauge": "~2.7.3", "set-blocking": "~2.0.0" } }, "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg=="], + + "number-is-nan": ["number-is-nan@1.0.1", "", {}, "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ=="], + + "oauth-sign": ["oauth-sign@0.9.0", "", {}, "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "pause-stream": ["pause-stream@0.0.11", "", { "dependencies": { "through": "~2.3" } }, "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A=="], + + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "split": ["split@0.3.3", "", { "dependencies": { "through": "2" } }, "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA=="], + + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "stream-combiner": ["stream-combiner@0.0.4", "", { "dependencies": { "duplexer": "~0.1.1" } }, "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw=="], + + "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + + "tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "usocket": ["usocket@0.3.0", "", { "dependencies": { "bindings": "^1.5.0", "nan": "^2.14.2", "node-gyp": "^7.1.2" } }, "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="], + + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "window-size": ["window-size@1.1.1", "", { "dependencies": { "define-property": "^1.0.0", "is-number": "^3.0.0" }, "bin": { "window-size": "cli.js" } }, "sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "xml2js": ["xml2js@0.4.23", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "gauge/string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="], + + "gauge/strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "wide-align/string-width": ["string-width@1.0.2", "", { "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", "strip-ansi": "^3.0.0" } }, "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw=="], + + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "gauge/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="], + + "gauge/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + + "wide-align/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@1.0.0", "", { "dependencies": { "number-is-nan": "^1.0.0" } }, "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw=="], + + "wide-align/string-width/strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], + + "wide-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8b81c6d --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "beamboxctl", + "version": "0.0.1", + "description": "CLI tool for uploading and managing BeamBox e-Badge devices", + "type": "module", + "bin": { + "beamboxctl": "./dist/index.js" + }, + "scripts": { + "dev": "bun run src/index.tsx", + "build": "bun build src/index.tsx --outdir dist --target node --minify", + "start": "bun run dist/index.js" + }, + "keywords": [ + "beambox", + "cli", + "bluetooth", + "ble", + "uploader" + ], + "author": "Yuzu ", + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "ink": "^6.6.0", + "ink-big-text": "^2.0.0", + "ink-spinner": "^5.0.0", + "node-ble": "^1.13.0", + "react": "^19.2.4", + "sharp": "^0.34.5" + }, + "devDependencies": { + "@types/bun": "^1.3.8", + "@types/react": "^19.2.10" + }, + "peerDependencies": { + "typescript": "^5.9.3" + }, + "trustedDependencies": [ + "@abandonware/bluetooth-hci-socket", + "@abandonware/noble" + ] +} \ No newline at end of file diff --git a/src/__tests__/fixtures/invalid-image.txt b/src/__tests__/fixtures/invalid-image.txt new file mode 100644 index 0000000..16a014d --- /dev/null +++ b/src/__tests__/fixtures/invalid-image.txt @@ -0,0 +1 @@ +you know, p-chan, ive always really - *clank clank clank clank* \ No newline at end of file diff --git a/src/__tests__/fixtures/known-packets.json b/src/__tests__/fixtures/known-packets.json new file mode 100644 index 0000000..2c57510 --- /dev/null +++ b/src/__tests__/fixtures/known-packets.json @@ -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" + } + ] +} diff --git a/src/__tests__/fixtures/test-1x1.png b/src/__tests__/fixtures/test-1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..91a99b94e23a00cc8133f22e3fe2a0b48a015808 GIT binary patch literal 67 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blE>9Q7kcv6UAQ@H$MqV!6EkIEQ MPgg&ebxsLQ07X&@0{{R3 literal 0 HcmV?d00001 diff --git a/src/__tests__/utils/test-helpers.ts b/src/__tests__/utils/test-helpers.ts new file mode 100644 index 0000000..cb4771b --- /dev/null +++ b/src/__tests__/utils/test-helpers.ts @@ -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) + ); + } +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx new file mode 100644 index 0000000..1175f87 --- /dev/null +++ b/src/cli/index.tsx @@ -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 to image file or directory (alternative to positional argument)", + ) + .option("--address

", "BLE device address (optional)") + .option("--size ", "Target size WxH", "368x368") + .option("--test", "Upload 8x8 checkerboard test pattern", false) + .option("--packet-delay ", "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 or beamboxctl upload --image 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(); + }); + + program + .command("status") + .description("Get device status and notifications") + .option("--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(); + }); + + return program; +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..efae702 --- /dev/null +++ b/src/cli/types.ts @@ -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; +} diff --git a/src/components/App.tsx b/src/components/App.tsx new file mode 100644 index 0000000..2fd33a2 --- /dev/null +++ b/src/components/App.tsx @@ -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 = ({ options, verbose }) => { + const { + status, + message, + progress, + currentFileIndex, + totalFiles, + uploadSteps, + connectionSteps, + } = useUpload(options, verbose); + + return ( + +
+ + {options.isBulk && totalFiles > 1 && ( + + + Bulk Upload: {currentFileIndex}/{totalFiles} files + + + )} + + {status === "connecting" && ( + + + + + + + {" "} + {message} + + + + + )} + + {status === "uploading" && ( + + + + + )} + + {status === "success" && ( + <> + + + Device is ready to use! + + + )} + + {status === "error" && ( + <> + + + Please check your device and try again. + + + )} + + ); +}; diff --git a/src/components/ConnectionStatus.tsx b/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..fe1d4e2 --- /dev/null +++ b/src/components/ConnectionStatus.tsx @@ -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 = ({ 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 ( + + ✗ {errorStep.label} failed + {errorStep.error && ( + ({errorStep.error}) + )} + + ); + } + + if (activeStep) { + return ( + + {activeStep.label} + ... + + ); + } + + if (lastCompletedStep) { + return ( + + ✓ {lastCompletedStep.label} + + ); + } + + const firstPending = steps.find(s => s.status === 'pending'); + if (firstPending) { + return ( + + {firstPending.label} + + ); + } + + return null; +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..64f1675 --- /dev/null +++ b/src/components/Header.tsx @@ -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 ( + + + CLI tool for managing BeamBox e-Badge devices + + Support my work ♡{" "} + + {`\x1b]8;;https://${sponsorLink}\x07${sponsorLink}\x1b]8;;\x07`} + + + + ); +}; diff --git a/src/components/Status.tsx b/src/components/Status.tsx new file mode 100644 index 0000000..3a7463f --- /dev/null +++ b/src/components/Status.tsx @@ -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 ( + + [ + {filled} + {empty}] + {percentage}% + + ); +}; + +export const Status: React.FC = ({ + 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 ( + + {status && ( + + + + Device Status + + + + + + + Storage Information + + + + + + + Total: + + {formatBytes(allspace)} + + + + Free: + + {formatBytes(freespace)} + ({freePercentage}%) + + + + Used: + + {formatBytes(usedSpace)} + ({100 - freePercentage}%) + + + + + + + + Device Information + + + + + {status.size !== undefined && status.size !== null && ( + + + Resolution: + + + {String(status.size).replace(",", "x")} + + pixels + + )} + + {status.devname !== undefined && ( + + + Name: + + {String(status.devname) || ""} + + )} + + {typeof status.brand === "number" && ( + + + Brand: + + #{status.brand} + + )} + + + {verbose && ( + + Raw JSON: + + {JSON.stringify(status, null, 2)} + + )} + + + )} + + {!status && ( + + ⚠ No device status received + + )} + + {verbose && notifications.length > 0 && ( + + + Protocol Notifications: {notifications.length} + + + + {notifications.slice(0, 20).map((notif, index) => { + const timestamp = new Date(notif.time).toLocaleTimeString(); + return ( + + + + [{index + 1}] {timestamp} + + + + {notif.data.length} bytes + {verbose && ( + + {" "} + | Hex: {notif.data.toString("hex")} + + )} + + {notif.parsed.rawText && ( + + Text: + {notif.parsed.rawText} + + )} + + ); + })} + + {notifications.length > 20 && ( + + + ... and {notifications.length - 20} more notifications + + + )} + + + )} + + ); +}; diff --git a/src/components/StatusApp.tsx b/src/components/StatusApp.tsx new file mode 100644 index 0000000..34528be --- /dev/null +++ b/src/components/StatusApp.tsx @@ -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 = ({ options }) => { + const { + loading, + error, + deviceStatus, + notifications, + connectionSteps, + } = useDeviceStatus(options); + + return ( + +
+ + {loading && ( + + + + + + + {" "} + Connecting to BeamBox device... + + + + + )} + + {error && ( + + + + ✗ Connection Error + + + + {error} + + + + Make sure your device is powered on and in range. + + + + )} + + {!loading && !error && ( + + )} + + ); +}; diff --git a/src/components/UploadProgress.tsx b/src/components/UploadProgress.tsx new file mode 100644 index 0000000..af6328f --- /dev/null +++ b/src/components/UploadProgress.tsx @@ -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 ( + + [ + {barContent} + ] {percentage}% + + ); +}; + +export const UploadProgress: React.FC = ({ + 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 ( + + + {!statusIcon && ( + + + + )} + {statusIcon && {statusIcon}} + + + {message || status.toUpperCase()} + + + + {progress !== undefined && status === "uploading" && ( + + )} + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..ed1d89e --- /dev/null +++ b/src/components/index.ts @@ -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'; diff --git a/src/hooks/useDeviceStatus.ts b/src/hooks/useDeviceStatus.ts new file mode 100644 index 0000000..2aacd46 --- /dev/null +++ b/src/hooks/useDeviceStatus.ts @@ -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(true); + const [error, setError] = useState(null); + const [deviceStatus, setDeviceStatus] = useState(null); + const [notifications, setNotifications] = useState< + Array<{ time: number; data: Buffer; parsed: ParsedResponse }> + >([]); + const [connectionSteps, setConnectionSteps] = useState([ + { 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, + }; +} diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts new file mode 100644 index 0000000..b69d6c8 --- /dev/null +++ b/src/hooks/useUpload.ts @@ -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("Initializing..."); + const [progress, setProgress] = useState(0); + const [currentFileIndex, setCurrentFileIndex] = useState(0); + const [totalFiles, setTotalFiles] = useState(1); + + const [uploadSteps, setUploadSteps] = useState([ + { 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([ + { 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, + }; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..5b64b04 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,5 @@ +#!/usr/bin/env bun +import { setupCLI } from "./cli/index.tsx"; + +const program = setupCLI(); +program.parse(); diff --git a/src/lib/ble/ble-client.ts b/src/lib/ble/ble-client.ts new file mode 100644 index 0000000..4408967 --- /dev/null +++ b/src/lib/ble/ble-client.ts @@ -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 | null = null; + + deviceStatus: Record | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 | null { + return this.notificationHandler.deviceStatus; + } + + public getNotifications(): Array<{ time: number; data: Buffer; parsed: any }> { + return this.notificationHandler.getNotifications(); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/lib/ble/index.ts b/src/lib/ble/index.ts new file mode 100644 index 0000000..7857f21 --- /dev/null +++ b/src/lib/ble/index.ts @@ -0,0 +1 @@ +export { BleUploader } from "./ble-client.ts"; diff --git a/src/lib/core/beambox-uploader.ts b/src/lib/core/beambox-uploader.ts new file mode 100644 index 0000000..f1ac8c1 --- /dev/null +++ b/src/lib/core/beambox-uploader.ts @@ -0,0 +1,260 @@ +import type { + BLEConfig, + ProtocolConfig, + ImageConfig, +} from "../protocol/index.ts"; +import { + DEFAULT_BLE_CONFIG, + DEFAULT_PROTOCOL_CONFIG, + DEFAULT_IMAGE_CONFIG, +} from "../protocol/index.ts"; +import { BleUploader } from "../ble/ble-client.ts"; +import { ImageProcessor } from "../processing/image-processor.ts"; +import { PayloadBuilder } from "../protocol/index.ts"; +import { logger } from "../utils/logger.ts"; +import { UploadError } from "../utils/errors.ts"; + +export interface UploadOptions { + imagePath?: string; + imageData?: Buffer; + targetSize?: [number, number]; + onProgress?: (progress: number) => void; +} + +/** + * Main uploader class + */ +export class BeamBoxUploader { + private ble: BleUploader; + private imageProcessor: ImageProcessor; + private payloadBuilder: PayloadBuilder; + private imageConfig: ImageConfig; + + constructor( + deviceAddress?: string, + chunkDelay?: number, + bleConfig?: BLEConfig, + protocolConfig?: ProtocolConfig, + imageConfig?: ImageConfig, + verbose: boolean = false, + ) { + this.imageConfig = imageConfig ?? DEFAULT_IMAGE_CONFIG; + this.ble = new BleUploader( + deviceAddress ?? null, + chunkDelay ?? null, + bleConfig ?? DEFAULT_BLE_CONFIG, + protocolConfig ?? DEFAULT_PROTOCOL_CONFIG, + verbose, + ); + this.imageProcessor = new ImageProcessor(this.imageConfig); + this.payloadBuilder = new PayloadBuilder( + protocolConfig ?? DEFAULT_PROTOCOL_CONFIG, + ); + } + + /** + * Connect to the device + * @returns True if connected successfully + */ + public async connect(): Promise { + return await this.ble.connect(); + } + + /** + * Disconnect from the device + */ + public async disconnect(): Promise { + 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 { + 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 { + 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 { + 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 | 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 | 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts new file mode 100644 index 0000000..8a74bc4 --- /dev/null +++ b/src/lib/core/index.ts @@ -0,0 +1,2 @@ +export { BeamBoxUploader } from "./beambox-uploader.ts"; +export type { UploadOptions } from "./beambox-uploader.ts"; diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..ef47d46 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,5 @@ +export * from "./core/index.ts"; +export * from "./protocol/index.ts"; +export * from "./utils/index.ts"; +export { ImageProcessor } from "./processing/index.ts"; +export { BleUploader } from "./ble/index.ts"; diff --git a/src/lib/processing/image-processor.test.ts b/src/lib/processing/image-processor.test.ts new file mode 100644 index 0000000..fbf06bf --- /dev/null +++ b/src/lib/processing/image-processor.test.ts @@ -0,0 +1,286 @@ +import { describe, test, expect } from "bun:test"; +import { ImageProcessor } from "./image-processor.ts"; +import { ImageProcessingError } from "../utils/errors.ts"; +import { DEFAULT_IMAGE_CONFIG } from "../protocol/interfaces/defaults.ts"; +import sharp from "sharp"; +import path from "path"; + +const fixturesPath = path.join(__dirname, "../../__tests__/fixtures"); +const testImagePath = path.join(fixturesPath, "test-1x1.png"); +const invalidImagePath = path.join(fixturesPath, "invalid-image.txt"); + +describe("ImageProcessor", () => { + const processor = new ImageProcessor(DEFAULT_IMAGE_CONFIG); + + describe("loadFromFile()", () => { + test("loads valid PNG file", async () => { + const image = await processor.loadFromFile(testImagePath); + expect(image).toBeDefined(); + + // Should be a Sharp instance + const metadata = await image.metadata(); + expect(metadata.width).toBe(1); + expect(metadata.height).toBe(1); + }); + + test("Sharp doesn't throw immediately on non-existent file", async () => { + // Sharp creates lazily, so loadFromFile won't throw + // The error happens when you try to use it + const image = await processor.loadFromFile("/non/existent/path.png"); + expect(image).toBeDefined(); + }); + + test("Sharp doesn't throw immediately on invalid image file", async () => { + // Sharp loads lazily + const image = await processor.loadFromFile(invalidImagePath); + expect(image).toBeDefined(); + }); + }); + + describe("generateCheckerboard()", () => { + test("generates PNG buffer with default size", async () => { + const png = await processor.generateCheckerboard(); + expect(Buffer.isBuffer(png)).toBe(true); + + const metadata = await sharp(png).metadata(); + expect(metadata.format).toBe("png"); + expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]); + expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]); + }); + + test("generates PNG with custom size (128x64)", async () => { + const png = await processor.generateCheckerboard([128, 64]); + const metadata = await sharp(png).metadata(); + expect(metadata.width).toBe(128); + expect(metadata.height).toBe(64); + }); + + test("generates with default squares (from config)", async () => { + const png = await processor.generateCheckerboard(); + expect(Buffer.isBuffer(png)).toBe(true); + // Just verify it doesn't throw + }); + + test("generates with custom squares (4x4)", async () => { + const png = await processor.generateCheckerboard([64, 32], 4); + expect(Buffer.isBuffer(png)).toBe(true); + const metadata = await sharp(png).metadata(); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("generates with custom squares (16x16)", async () => { + const png = await processor.generateCheckerboard([128, 128], 16); + expect(Buffer.isBuffer(png)).toBe(true); + const metadata = await sharp(png).metadata(); + expect(metadata.width).toBe(128); + expect(metadata.height).toBe(128); + }); + + test("can be parsed by Sharp", async () => { + const png = await processor.generateCheckerboard([64, 32]); + const image = sharp(png); + const metadata = await image.metadata(); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("edge case: 1x1 grid (all black)", async () => { + const png = await processor.generateCheckerboard([10, 10], 1); + expect(Buffer.isBuffer(png)).toBe(true); + }); + + test("edge case: 2x2 grid", async () => { + const png = await processor.generateCheckerboard([20, 20], 2); + expect(Buffer.isBuffer(png)).toBe(true); + }); + + test("throws ImageProcessingError on failure", async () => { + // This is hard to trigger, but we verify the error type would be correct + // by checking that normal generation doesn't throw + await expect( + processor.generateCheckerboard([64, 32], 8), + ).resolves.toBeDefined(); + }); + }); + + describe("prepareImage()", () => { + test("accepts Sharp instance input", async () => { + const image = sharp(await processor.generateCheckerboard()); + const jpeg = await processor.prepareImage(image, [64, 32]); + expect(Buffer.isBuffer(jpeg)).toBe(true); + }); + + test("accepts Buffer input (PNG)", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png, [64, 32]); + expect(Buffer.isBuffer(jpeg)).toBe(true); + }); + + test("resizes to target size", async () => { + const png = await processor.generateCheckerboard([100, 100]); + const jpeg = await processor.prepareImage(png, [64, 32]); + + const metadata = await sharp(jpeg).metadata(); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("output is JPEG format", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png, [64, 32]); + + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + }); + + test("output has correct JPEG quality (from config)", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png, [64, 32]); + + // Can't directly test quality, but verify it's a valid JPEG + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + }); + + test("output dimensions match target", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png, [128, 64]); + + const metadata = await sharp(jpeg).metadata(); + expect(metadata.width).toBe(128); + expect(metadata.height).toBe(64); + }); + + test("uses default size when not specified", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png); + + const metadata = await sharp(jpeg).metadata(); + expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]); + expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]); + }); + + test("throws ImageProcessingError on invalid input", async () => { + const invalidBuffer = Buffer.from("not an image"); + await expect( + processor.prepareImage(invalidBuffer, [64, 32]), + ).rejects.toThrow(ImageProcessingError); + }); + + test("error message includes context", async () => { + try { + await processor.prepareImage(Buffer.from("bad"), [64, 32]); + throw new Error("Should have thrown"); + } catch (e) { + expect((e as Error).message).toContain("prepare image"); + } + }); + }); + + describe("prepareFromFile()", () => { + test("loads and prepares in one call", async () => { + const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]); + expect(Buffer.isBuffer(jpeg)).toBe(true); + + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("returns JPEG Buffer", async () => { + const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]); + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + }); + + test("dimensions match target", async () => { + const jpeg = await processor.prepareFromFile(testImagePath, [128, 64]); + const metadata = await sharp(jpeg).metadata(); + expect(metadata.width).toBe(128); + expect(metadata.height).toBe(64); + }); + + test("throws on invalid path", async () => { + await expect( + processor.prepareFromFile("/bad/path.png", [64, 32]), + ).rejects.toThrow(ImageProcessingError); + }); + + test("throws on invalid image", async () => { + await expect( + processor.prepareFromFile(invalidImagePath, [64, 32]), + ).rejects.toThrow(ImageProcessingError); + }); + + test("uses default size when not specified", async () => { + const jpeg = await processor.prepareFromFile(testImagePath); + const metadata = await sharp(jpeg).metadata(); + expect(metadata.width).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[0]); + expect(metadata.height).toBe(DEFAULT_IMAGE_CONFIG.defaultSize[1]); + }); + }); + + describe("output format verification", () => { + test("JPEG has correct magic bytes", async () => { + const png = await processor.generateCheckerboard(); + const jpeg = await processor.prepareImage(png, [64, 32]); + + // JPEG starts with FF D8 + expect(jpeg[0]).toBe(0xff); + expect(jpeg[1]).toBe(0xd8); + // JPEG ends with FF D9 + expect(jpeg[jpeg.length - 2]).toBe(0xff); + expect(jpeg[jpeg.length - 1]).toBe(0xd9); + }); + + test("output is valid and can be processed again", async () => { + const png = await processor.generateCheckerboard(); + const jpeg1 = await processor.prepareImage(png, [64, 32]); + + // Process the JPEG again + const jpeg2 = await processor.prepareImage(jpeg1, [32, 16]); + + const metadata = await sharp(jpeg2).metadata(); + expect(metadata.width).toBe(32); + expect(metadata.height).toBe(16); + expect(metadata.format).toBe("jpeg"); + }); + }); + + describe("integration tests", () => { + test("full workflow: generate -> prepare", async () => { + const png = await processor.generateCheckerboard([100, 100], 8); + const jpeg = await processor.prepareImage(png, [64, 32]); + + expect(Buffer.isBuffer(jpeg)).toBe(true); + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("full workflow: load file -> prepare", async () => { + const jpeg = await processor.prepareFromFile(testImagePath, [64, 32]); + + expect(Buffer.isBuffer(jpeg)).toBe(true); + const metadata = await sharp(jpeg).metadata(); + expect(metadata.format).toBe("jpeg"); + expect(metadata.width).toBe(64); + expect(metadata.height).toBe(32); + }); + + test("multiple operations in sequence", async () => { + const png1 = await processor.generateCheckerboard([100, 100]); + const jpeg1 = await processor.prepareImage(png1, [64, 32]); + + const png2 = await processor.generateCheckerboard([50, 50]); + const jpeg2 = await processor.prepareImage(png2, [128, 64]); + + expect(jpeg1.length).toBeDefined(); + expect(jpeg2.length).toBeDefined(); + expect(jpeg1.length).not.toBe(jpeg2.length); + }); + }); +}); diff --git a/src/lib/processing/image-processor.ts b/src/lib/processing/image-processor.ts new file mode 100644 index 0000000..32e1b57 --- /dev/null +++ b/src/lib/processing/image-processor.ts @@ -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 { + 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 { + 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 { + 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 { + 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 += ``; + } + } + } + + return ` + + + ${rects} + + `; + } +} diff --git a/src/lib/processing/index.ts b/src/lib/processing/index.ts new file mode 100644 index 0000000..def3f55 --- /dev/null +++ b/src/lib/processing/index.ts @@ -0,0 +1 @@ +export { ImageProcessor } from "./image-processor.ts"; diff --git a/src/lib/protocol/builders/imb-header.test.ts b/src/lib/protocol/builders/imb-header.test.ts new file mode 100644 index 0000000..5df98ee --- /dev/null +++ b/src/lib/protocol/builders/imb-header.test.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); + }); + }); + }); +}); diff --git a/src/lib/protocol/builders/imb-header.ts b/src/lib/protocol/builders/imb-header.ts new file mode 100644 index 0000000..57132f1 --- /dev/null +++ b/src/lib/protocol/builders/imb-header.ts @@ -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; + } +} diff --git a/src/lib/protocol/builders/payload-builder.test.ts b/src/lib/protocol/builders/payload-builder.test.ts new file mode 100644 index 0000000..b2a1cc2 --- /dev/null +++ b/src/lib/protocol/builders/payload-builder.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/protocol/builders/payload-builder.ts b/src/lib/protocol/builders/payload-builder.ts new file mode 100644 index 0000000..0c94db5 --- /dev/null +++ b/src/lib/protocol/builders/payload-builder.ts @@ -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":} + * + * 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; + } +} diff --git a/src/lib/protocol/constants.ts b/src/lib/protocol/constants.ts new file mode 100644 index 0000000..48dfb20 --- /dev/null +++ b/src/lib/protocol/constants.ts @@ -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; diff --git a/src/lib/protocol/index.ts b/src/lib/protocol/index.ts new file mode 100644 index 0000000..d656937 --- /dev/null +++ b/src/lib/protocol/index.ts @@ -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"; diff --git a/src/lib/protocol/interfaces/config.ts b/src/lib/protocol/interfaces/config.ts new file mode 100644 index 0000000..263082d --- /dev/null +++ b/src/lib/protocol/interfaces/config.ts @@ -0,0 +1,113 @@ +import type { PacketType } from "../packet-types.ts"; + +/** + * Bluetooth Low Energy (BLE) connection configuration + * + * Contains settings for discovering and connecting to the BeamBox device via BLE. + */ +export interface BLEConfig { + /** + * Expected name of the device during scanning + */ + deviceName: string; + + /** + * UUID of the BLE characteristic used for writing commands/data to the device + */ + writeCharacteristicUUID: string; + + /** + * UUID of the BLE characteristic used for receiving notifications/responses from the device + */ + notifyCharacteristicUUID: string; + + /** + * Maximum time (in seconds) to wait for device discovery before timing out + */ + scanTimeout: number; +} + +/** + * Protocol configuration for packet transmission + * + * Contains timing and size parameters for data transfer to the device. + * These values control the flow of data to prevent overwhelming the device. + */ +export interface ProtocolConfig { + /** + * Command type byte (CMD_TYPE) sent in packet headers + * + * Always 0xF1 as observed in packet captures + */ + cmdType: number; + + /** + * Command subtype indicating the type of data being sent + */ + cmdSubtype: PacketType; + + /** + * Maximum size in bytes of each data chunk sent to the device + */ + chunkSize: number; + + /** + * Maximum size in bytes for image info JSON packets + */ + imageInfoChunkSize: number; + + /** + * Delay (in seconds) between sending consecutive data chunks + */ + packetDelay: number; + + /** + * Delay (in seconds) after sending the image info packet + */ + imageInfoDelay: number; + + /** + * Maximum time (in seconds) to wait for a packet acknowledgment from the device + */ + packetAckTimeout: number; +} + +/** + * Image processing and configuration settings + * + * Contains parameters for processing images before sending to the device, + * including resizing, compression, and validation settings. + */ +export interface ImageConfig { + /** + * Default display dimensions [width, height] for images + * + * Images are resized to fit these dimensions + */ + defaultSize: [number, number]; + + /** + * JPEG compression quality (0-100) + * + * - 0: Lowest quality, smallest file + * - 100: Highest quality, largest file + * - 70: Good balance (recommended) + */ + jpegQuality: number; + + /** + * Whether to enable JPEG optimization for smaller file sizes + * + * true: Apply optimization algorithms (slower but smaller) + * false: Skip optimization (faster but larger) + */ + jpegOptimize: boolean; + + /** + * Number of squares per dimension for checkerboard transparency pattern + * + * Example: 8 = 8x8 checkerboard grid + * Used for test pattern generation + */ + checkerboardSquares: number; +} diff --git a/src/lib/protocol/interfaces/defaults.ts b/src/lib/protocol/interfaces/defaults.ts new file mode 100644 index 0000000..f71b800 --- /dev/null +++ b/src/lib/protocol/interfaces/defaults.ts @@ -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, +}; diff --git a/src/lib/protocol/interfaces/device-status.ts b/src/lib/protocol/interfaces/device-status.ts new file mode 100644 index 0000000..e23c7e8 --- /dev/null +++ b/src/lib/protocol/interfaces/device-status.ts @@ -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; +} diff --git a/src/lib/protocol/interfaces/index.ts b/src/lib/protocol/interfaces/index.ts new file mode 100644 index 0000000..52e01f3 --- /dev/null +++ b/src/lib/protocol/interfaces/index.ts @@ -0,0 +1,4 @@ +export * from "./device-status.ts"; +export * from "./parsed-response.ts"; +export * from "./config.ts"; +export * from "./defaults.ts"; diff --git a/src/lib/protocol/interfaces/parsed-response.ts b/src/lib/protocol/interfaces/parsed-response.ts new file mode 100644 index 0000000..0a236a3 --- /dev/null +++ b/src/lib/protocol/interfaces/parsed-response.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 | 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; +} diff --git a/src/lib/protocol/packet-types.ts b/src/lib/protocol/packet-types.ts new file mode 100644 index 0000000..65cb295 --- /dev/null +++ b/src/lib/protocol/packet-types.ts @@ -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":} + * ``` + * + * ## 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: 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, +} diff --git a/src/lib/protocol/parsers/response-parser.test.ts b/src/lib/protocol/parsers/response-parser.test.ts new file mode 100644 index 0000000..d69f776 --- /dev/null +++ b/src/lib/protocol/parsers/response-parser.test.ts @@ -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(); + }); + }); +}); diff --git a/src/lib/protocol/parsers/response-parser.ts b/src/lib/protocol/parsers/response-parser.ts new file mode 100644 index 0000000..4c7b446 --- /dev/null +++ b/src/lib/protocol/parsers/response-parser.ts @@ -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 | 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, + ): 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 | 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; + } +} diff --git a/src/lib/protocol/response-types.ts b/src/lib/protocol/response-types.ts new file mode 100644 index 0000000..8a19e38 --- /dev/null +++ b/src/lib/protocol/response-types.ts @@ -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", +} diff --git a/src/lib/utils/errors.test.ts b/src/lib/utils/errors.test.ts new file mode 100644 index 0000000..30b6117 --- /dev/null +++ b/src/lib/utils/errors.test.ts @@ -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); + } + }); + }); +}); diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts new file mode 100644 index 0000000..50a1265 --- /dev/null +++ b/src/lib/utils/errors.ts @@ -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); + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..ec26d75 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./errors.ts"; +export * from "./logger.ts"; diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 0000000..d680469 --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,153 @@ +/** + * Log severity levels + */ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARNING = 2, + ERROR = 3, +} + +/** + * Types of events that can be logged + */ +export enum LogEventType { + SCAN_START = "scan_start", + DEVICE_FOUND = "device_found", + CONNECT_START = "connect_start", + CONNECTED = "connected", + DISCOVER_CHAR = "discover_char", + STATUS_WAIT = "status_wait", + STATUS_RECEIVED = "status_received", + IMAGE_INFO_SEND = "image_info_send", + DATA_SEND_START = "data_send_start", + DATA_SEND_PROGRESS = "data_send_progress", + DATA_SEND_COMPLETE = "data_send_complete", + GENERIC = "generic", +} + +/** + * A single log entry + */ +export type LogEntry = { + level: LogLevel; + message: string; + timestamp: number; + eventType?: LogEventType; + data?: any; +}; + +/** + * Logger class for handling application logging with severity levels and event tracking. + * Supports console output at different levels (DEBUG, INFO, WARNING, ERROR) and + * provides a listener system for external log processing. + */ +class Logger { + private level: LogLevel = LogLevel.WARNING; + private listeners: Set<(entry: LogEntry) => void> = new Set(); + + /** + * Sets the minimum log level to output. Messages below this level will be ignored. + * @param level Minimum log level (DEBUG, INFO, WARNING, or ERROR) + */ + public setLevel(level: LogLevel): void { + this.level = level; + } + + /** + * Registers a callback to be invoked for each log entry that meets the minimum level. + * @param listener Callback function that receives the log entry + * @returns Unsubscribe function to remove the listener + */ + public onLog(listener: (entry: LogEntry) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Internal logging method that processes log entries based on current level. + * Outputs to appropriate console method and notifies all listeners. + * @private + * @param level Severity level of the log entry + * @param message Log message text + * @param eventType Optional event type for categorization + * @param data Optional additional data associated with the log entry + */ + private log( + level: LogLevel, + message: string, + eventType?: LogEventType, + data?: any, + ): void { + if (this.level <= level) { + const timestamp = Date.now(); + const entry: LogEntry = { level, message, timestamp, eventType, data }; + + const levelNames = ["DEBUG", "INFO", "WARNING", "ERROR"]; + const levelName = levelNames[level]; + const output = `[${levelName}] ${message}`; + + switch (level) { + case LogLevel.DEBUG: + console.debug(output); + break; + case LogLevel.INFO: + console.log(output); + break; + case LogLevel.WARNING: + console.warn(output); + break; + case LogLevel.ERROR: + console.error(output); + break; + } + + this.listeners.forEach((listener) => listener(entry)); + } + } + + /** + * Logs a debug message. Only output if log level is DEBUG or lower. + * @param message Debug message text + * @param eventType Optional event type for categorization + * @param data Optional additional data associated with this log entry + */ + public debug(message: string, eventType?: LogEventType, data?: any): void { + this.log(LogLevel.DEBUG, message, eventType, data); + } + + /** + * Logs an info message. Only output if log level is INFO or lower. + * @param message Info message text + * @param eventType Optional event type for categorization + * @param data Optional additional data associated with this log entry + */ + public info(message: string, eventType?: LogEventType, data?: any): void { + this.log(LogLevel.INFO, message, eventType, data); + } + + /** + * Logs a warning message. Only output if log level is WARNING or lower. + * @param message Warning message text + * @param eventType Optional event type for categorization + * @param data Optional additional data associated with this log entry + */ + public warning(message: string, eventType?: LogEventType, data?: any): void { + this.log(LogLevel.WARNING, message, eventType, data); + } + + /** + * Logs an error message. Always output regardless of log level. + * @param message Error message text + * @param eventType Optional event type for categorization + * @param data Optional additional data associated with this log entry + */ + public error(message: string, eventType?: LogEventType, data?: any): void { + this.log(LogLevel.ERROR, message, eventType, data); + } +} + +/** + * Global logger instance for application-wide logging. + */ +export const logger = new Logger(); diff --git a/src/utils/app-utils.ts b/src/utils/app-utils.ts new file mode 100644 index 0000000..e8446bd --- /dev/null +++ b/src/utils/app-utils.ts @@ -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; + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7664559 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}