feat: initial commit

This commit is contained in:
2026-02-03 00:29:31 +07:00
commit 3dfa7acb03
50 changed files with 5369 additions and 0 deletions
+22
View File
@@ -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
+41
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+466
View File
@@ -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=="],
}
}
+43
View File
@@ -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 <yuzu@kirameki.cafe>",
"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"
]
}
+1
View File
@@ -0,0 +1 @@
you know, p-chan, ive always really - *clank clank clank clank*
+48
View File
@@ -0,0 +1,48 @@
{
"knownPackets": {
"imageInfo": {
"description": "Image info packet: {\"type\":6,\"number\":1}",
"hex": "f10600000000001a7b2274797065223a362c226e756d626572223a317d77"
},
"deviceStatusResponse": {
"description": "Device status response Type 13",
"hex": "7b2274797065223a31332c22616c6c7370616365223a31363338342c22667265657370616365223a31333839322c226465766e616d65223a224265616d426f78222c2273697a65223a223634783332222c226272616e64223a317d",
"json": {
"type": 13,
"allspace": 16384,
"freespace": 13892,
"devname": "BeamBox",
"size": "64x32",
"brand": 1
}
},
"getPacketSuccess": {
"description": "Success response from device",
"text": "GetPacketSuccess"
},
"getPacketFail": {
"description": "Failure response from device",
"text": "GetPacketFail"
},
"errorResponse": {
"description": "Error response from device",
"text": "1111111111"
}
},
"expectedHeaders": [
{
"description": "1KB JPEG, 64x32",
"jpegSize": 1024,
"width": 64,
"height": 32,
"hex": "494d4200000000240000041800000b0040002000000024000004000000000000000000"
},
{
"description": "100 byte JPEG, 128x64",
"jpegSize": 100,
"width": 128,
"height": 64,
"hex": "494d4200000000240000008800000b0080004000000024000000640000000000000000"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

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