mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: initial commit
This commit is contained in:
+22
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
# beamboxctl
|
||||
|
||||
Unofficial CLI for managing beambox e-Badge devices over Bluetooth LE.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
you know, p-chan, ive always really - *clank clank clank clank*
|
||||
@@ -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 |
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
import { setupCLI } from "./cli/index.tsx";
|
||||
|
||||
const program = setupCLI();
|
||||
program.parse();
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BleUploader } from "./ble-client.ts";
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
BLEConfig,
|
||||
ProtocolConfig,
|
||||
ImageConfig,
|
||||
} from "../protocol/index.ts";
|
||||
import {
|
||||
DEFAULT_BLE_CONFIG,
|
||||
DEFAULT_PROTOCOL_CONFIG,
|
||||
DEFAULT_IMAGE_CONFIG,
|
||||
} from "../protocol/index.ts";
|
||||
import { BleUploader } from "../ble/ble-client.ts";
|
||||
import { ImageProcessor } from "../processing/image-processor.ts";
|
||||
import { PayloadBuilder } from "../protocol/index.ts";
|
||||
import { logger } from "../utils/logger.ts";
|
||||
import { UploadError } from "../utils/errors.ts";
|
||||
|
||||
export interface UploadOptions {
|
||||
imagePath?: string;
|
||||
imageData?: Buffer;
|
||||
targetSize?: [number, number];
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main uploader class
|
||||
*/
|
||||
export class BeamBoxUploader {
|
||||
private ble: BleUploader;
|
||||
private imageProcessor: ImageProcessor;
|
||||
private payloadBuilder: PayloadBuilder;
|
||||
private imageConfig: ImageConfig;
|
||||
|
||||
constructor(
|
||||
deviceAddress?: string,
|
||||
chunkDelay?: number,
|
||||
bleConfig?: BLEConfig,
|
||||
protocolConfig?: ProtocolConfig,
|
||||
imageConfig?: ImageConfig,
|
||||
verbose: boolean = false,
|
||||
) {
|
||||
this.imageConfig = imageConfig ?? DEFAULT_IMAGE_CONFIG;
|
||||
this.ble = new BleUploader(
|
||||
deviceAddress ?? null,
|
||||
chunkDelay ?? null,
|
||||
bleConfig ?? DEFAULT_BLE_CONFIG,
|
||||
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
|
||||
verbose,
|
||||
);
|
||||
this.imageProcessor = new ImageProcessor(this.imageConfig);
|
||||
this.payloadBuilder = new PayloadBuilder(
|
||||
protocolConfig ?? DEFAULT_PROTOCOL_CONFIG,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the device
|
||||
* @returns True if connected successfully
|
||||
*/
|
||||
public async connect(): Promise<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BeamBoxUploader } from "./beambox-uploader.ts";
|
||||
export type { UploadOptions } from "./beambox-uploader.ts";
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./core/index.ts";
|
||||
export * from "./protocol/index.ts";
|
||||
export * from "./utils/index.ts";
|
||||
export { ImageProcessor } from "./processing/index.ts";
|
||||
export { BleUploader } from "./ble/index.ts";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./errors.ts";
|
||||
export * from "./logger.ts";
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Log severity levels
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARNING = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of events that can be logged
|
||||
*/
|
||||
export enum LogEventType {
|
||||
SCAN_START = "scan_start",
|
||||
DEVICE_FOUND = "device_found",
|
||||
CONNECT_START = "connect_start",
|
||||
CONNECTED = "connected",
|
||||
DISCOVER_CHAR = "discover_char",
|
||||
STATUS_WAIT = "status_wait",
|
||||
STATUS_RECEIVED = "status_received",
|
||||
IMAGE_INFO_SEND = "image_info_send",
|
||||
DATA_SEND_START = "data_send_start",
|
||||
DATA_SEND_PROGRESS = "data_send_progress",
|
||||
DATA_SEND_COMPLETE = "data_send_complete",
|
||||
GENERIC = "generic",
|
||||
}
|
||||
|
||||
/**
|
||||
* A single log entry
|
||||
*/
|
||||
export type LogEntry = {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
eventType?: LogEventType;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logger class for handling application logging with severity levels and event tracking.
|
||||
* Supports console output at different levels (DEBUG, INFO, WARNING, ERROR) and
|
||||
* provides a listener system for external log processing.
|
||||
*/
|
||||
class Logger {
|
||||
private level: LogLevel = LogLevel.WARNING;
|
||||
private listeners: Set<(entry: LogEntry) => void> = new Set();
|
||||
|
||||
/**
|
||||
* Sets the minimum log level to output. Messages below this level will be ignored.
|
||||
* @param level Minimum log level (DEBUG, INFO, WARNING, or ERROR)
|
||||
*/
|
||||
public setLevel(level: LogLevel): void {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback to be invoked for each log entry that meets the minimum level.
|
||||
* @param listener Callback function that receives the log entry
|
||||
* @returns Unsubscribe function to remove the listener
|
||||
*/
|
||||
public onLog(listener: (entry: LogEntry) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logging method that processes log entries based on current level.
|
||||
* Outputs to appropriate console method and notifies all listeners.
|
||||
* @private
|
||||
* @param level Severity level of the log entry
|
||||
* @param message Log message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with the log entry
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
eventType?: LogEventType,
|
||||
data?: any,
|
||||
): void {
|
||||
if (this.level <= level) {
|
||||
const timestamp = Date.now();
|
||||
const entry: LogEntry = { level, message, timestamp, eventType, data };
|
||||
|
||||
const levelNames = ["DEBUG", "INFO", "WARNING", "ERROR"];
|
||||
const levelName = levelNames[level];
|
||||
const output = `[${levelName}] ${message}`;
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(output);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.log(output);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
console.warn(output);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
console.error(output);
|
||||
break;
|
||||
}
|
||||
|
||||
this.listeners.forEach((listener) => listener(entry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message. Only output if log level is DEBUG or lower.
|
||||
* @param message Debug message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public debug(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.DEBUG, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info message. Only output if log level is INFO or lower.
|
||||
* @param message Info message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public info(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.INFO, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message. Only output if log level is WARNING or lower.
|
||||
* @param message Warning message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public warning(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.WARNING, message, eventType, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message. Always output regardless of log level.
|
||||
* @param message Error message text
|
||||
* @param eventType Optional event type for categorization
|
||||
* @param data Optional additional data associated with this log entry
|
||||
*/
|
||||
public error(message: string, eventType?: LogEventType, data?: any): void {
|
||||
this.log(LogLevel.ERROR, message, eventType, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global logger instance for application-wide logging.
|
||||
*/
|
||||
export const logger = new Logger();
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user