198 Commits

Author SHA1 Message Date
Rune Harlyk 6108aa9bf6 Switch to UV 2026-05-11 21:01:45 +02:00
Rune Harlyk b590f157e1 🔥 Removes unused sim test script 2026-05-11 21:01:14 +02:00
Rune Harlyk 0f2a0c65ba 🐛 Use mpu.heading for heading calculations 2026-03-20 17:19:41 +01:00
Rune Harlyk 37474e840d Update clone command to include submodules 2026-03-20 16:06:39 +01:00
Rune Harlyk e1d37a907d 🐛 ESP P4 i2c pull up 2026-03-06 23:35:20 +01:00
Rune Harlyk eba00f98cd Clean up macros 2026-02-09 16:39:46 +01:00
Rune Harlyk 43e7f13888 Adds working camera stream for p4 with correct colors and good perf 2026-02-09 16:39:46 +01:00
Rune Harlyk d81b1b0851 🙏 Working camera stream with p4 2026-02-09 16:39:46 +01:00
Rune Harlyk bf2fd957af Adds support for esp32 P4 2026-02-09 16:39:46 +01:00
Rune Harlyk d6075deb6c 🔥 Removes old stateful persistence 2026-02-01 00:45:55 +01:00
Rune Harlyk ff1444b2bc Makes wifi try to connect to latest 2026-01-31 21:05:37 +01:00
Rune Harlyk e5e9841dd3 Sets cpu freq to 240 2026-01-31 21:00:39 +01:00
Rune Harlyk f4f8035f37 🐛 Handle spa 2026-01-31 19:32:43 +01:00
Rune Harlyk 13300aa9e0 🎨 Update monitor filters 2026-01-31 19:32:43 +01:00
Rune Harlyk cdf6c83be5 ♻️ Moves sdkconfig.defaults 2026-01-31 19:32:43 +01:00
Rune Harlyk bd984309f1 ♻️ Handle merging 2026-01-31 19:32:43 +01:00
Rune Harlyk aca8ee6de5 Full migration to esp-idf 2026-01-31 19:32:43 +01:00
Niklas Jensen 21ed3d51d2 Fix build pipeline 2026-01-31 14:37:31 +01:00
Niklas Jensen f04e97443d Removed specific mentions of protobuf for endpoints
Also removed features unused json endpoint data
2026-01-31 14:37:31 +01:00
Niklas Jensen 07f5ffd5a7 Default center angle of servos to 2 2026-01-31 14:37:31 +01:00
Niklas Jensen 9b5261a022 Moved all filenames to filesystem file 2026-01-31 14:37:31 +01:00
Niklas Jensen 5df67bffb2 Updated debug in platform.ini to pass build pipeline 2026-01-31 14:37:31 +01:00
Niklas Jensen bdee1d0e04 Made proper response structure for network scan 2026-01-31 14:37:31 +01:00
Niklas Jensen bce9041f1f Allocate stateful persistance on heap - not stack 2026-01-31 14:37:31 +01:00
Niklas Jensen 2ce29ae0cc Updated proto sending over api to be dynamically allocated 2026-01-31 14:37:31 +01:00
Niklas Jensen 0af2f5ebec Rename proto macro to name that makes more sense 2026-01-31 14:37:31 +01:00
Niklas Jensen 4575f63921 Updated all wifi endpoints to use protobufs 2026-01-31 14:37:31 +01:00
Niklas Jensen 56d81f75cb Unused and untested peripheral endpoint updated to protobufs 2026-01-31 14:37:31 +01:00
Niklas Jensen 72e2522dcd Updated mdns to use protobufs (completely untested) 2026-01-31 14:37:31 +01:00
Niklas Jensen e1f44a6f06 Camera api to protobuf - still and stream not tested 2026-01-31 14:37:31 +01:00
Niklas Jensen 1a280f5356 Updated WIFI on esp and svelte side to use proto 2026-01-31 14:37:31 +01:00
Niklas Jensen 6d62b00c0e Fixed custom url matcher 2026-01-31 14:37:31 +01:00
Niklas Jensen 0a2d3c0e31 UNTESTED: fix for sending proper content type on config endpoint 2026-01-31 14:37:31 +01:00
Niklas Jensen 25063c1bd4 Updated edit in fs to use upload 2026-01-31 14:37:31 +01:00
Niklas Jensen f9a99ce013 Make /api/files/... to proto endpoints 2026-01-31 14:37:31 +01:00
Niklas Jensen bd012046f2 added esp builtin debugging capabilities 2026-01-31 14:37:31 +01:00
Niklas Jensen 1e333a0ffe Redo /api/system endpoints to proto 2026-01-31 14:37:31 +01:00
Niklas Jensen 1931551fa8 Remake delete for api to protobuf 2026-01-31 14:37:31 +01:00
Niklas Jensen 92da5b0dac Revert filesystem as it makes more sense to do over socket 2026-01-31 14:37:31 +01:00
Niklas Jensen 9666baf858 Remake filesystem listing to protobuf 2026-01-31 14:37:31 +01:00
Niklas Jensen 6e7f7bb657 Updating servo table data for Svelte 2026-01-31 14:37:31 +01:00
Niklas Jensen dbca9bd0b7 Converted servocontroller to protobufs + persistance defaults 2026-01-31 14:37:31 +01:00
Niklas Jensen a4e900fb65 Expanded comments to made endpoint and persistance easier to understand 2026-01-31 14:37:31 +01:00
Niklas Jensen 476c49f474 Protobuf persistance + Readded persistance for ap service 2026-01-31 14:37:31 +01:00
Niklas Jensen 7f4a158e24 Removed JSON from ap settings and service -> preparing to remake servo 2026-01-31 14:37:31 +01:00
Niklas Jensen 6e478460f5 Redone protobuf receiving to be more dynamic and scalable 2026-01-31 14:37:31 +01:00
Niklas Jensen d5af8d0294 Ap settngs post working, error msg on response 2026-01-31 14:37:31 +01:00
Niklas Jensen ae4a2fe115 Implement apstatus fetch on svelte site + protobuf decode 2026-01-31 14:37:31 +01:00
Niklas Jensen e1e656478d Redo ap settings to rest and proto 2026-01-31 14:37:31 +01:00
Rune Harlyk 02aaee0878 ♻️ Replace more arduino functions with native 2026-01-30 14:01:48 +01:00
Rune Harlyk 3f84434167 ♻️ Changes timing logging to only warn 2026-01-30 13:55:19 +01:00
Rune Harlyk 098f3b4c8f Adds heading chart 2026-01-30 12:57:51 +01:00
Rune Harlyk 608eec3894 ♻️ Only collect messages when there subscribers 2026-01-30 12:57:51 +01:00
Rune Harlyk 69e4aefec3 🎨 Adds fallback heading to be z axis 2026-01-30 12:57:51 +01:00
Rune Harlyk 1e9f38fe7b 🐛 Fixes mpu6050 dmp 2026-01-30 12:57:51 +01:00
Rune Harlyk dbc74d6f88 Replace third party libs with i2c bus drivers 2026-01-30 12:57:51 +01:00
Rune Harlyk d9e752777f 🐛 Clamps imu compensation to max roll and pitch 2026-01-29 17:04:27 +01:00
Rune Harlyk f513de0171 🐛 Fix the visualization world coordinate frame 2026-01-29 16:21:53 +01:00
Rune Harlyk 56376e6322 🐛 Fix visualization world rotation 2026-01-29 15:33:05 +01:00
Rune Harlyk a5e62d87fd 🐛 Fix walking condition 2026-01-29 13:52:15 +01:00
Rune Harlyk f033e8b0ae 🎨 Renames webserver and websocket 2026-01-24 13:36:32 +01:00
Rune Harlyk eb8b83736a ♻️ Removes PsychicHttp package from software desc 2026-01-24 13:36:32 +01:00
Rune Harlyk 57e80655cf Removes max file upload and secure getWsClients 2026-01-24 13:36:32 +01:00
Niklas Jensen 92b2d326c7 Endpoint refactoring miss by claude: ap api 2026-01-24 13:36:32 +01:00
Rune Harlyk 64199ac1a3 Removes PsychicHttp dependency 2026-01-24 13:36:32 +01:00
Rune Harlyk 0b8e060063 Adds native http wrapper to replace psychic 2026-01-24 13:36:32 +01:00
Rune Harlyk a88c8eb0be ♻️ Clean up 2026-01-22 20:38:27 +01:00
Rune Harlyk 38bb16bb6c ♻️ Update the typing for chunkedTranfer 2026-01-22 20:38:27 +01:00
Rune Harlyk f10406b29c ♻️ Makes use of tailwind for styling 2026-01-22 20:38:27 +01:00
Rune Harlyk 4ac54279a8 Makes TransferId uint32 2026-01-22 20:38:27 +01:00
Niklas Jensen aff50d6a9c Fixed comments and useless function declarations 2026-01-22 20:38:27 +01:00
Niklas Jensen 17de0b22af Overlooked logic issue when freeing malloced buffer 2026-01-22 20:38:27 +01:00
Niklas Jensen 6104c54f39 reset unrelated variables to base branch, fixed typos 2026-01-22 20:38:27 +01:00
Niklas Jensen cec9024a26 Removed useless claude generated MD 2026-01-22 20:38:27 +01:00
Niklas Jensen 70043aa139 Upped buffer for client download 2026-01-22 20:38:27 +01:00
Niklas Jensen 9b8e92ce32 Dont check fs size every chunk, and set buffer size bigger 2026-01-22 20:38:27 +01:00
Niklas Jensen 485ecb7547 Moved FS proto objects to own file, and MD tutorial 2026-01-22 20:38:27 +01:00
Niklas Jensen a799af360f Added metadata message for sending fs transfer info 2026-01-22 20:38:27 +01:00
Niklas Jensen f0c4f0f929 Remaking the upload and download scheme to rapid streaming (WIP) 2026-01-22 20:38:27 +01:00
Niklas Jensen 50ef91ab22 Dynamic allocation of protobuf encoder on huge messages 2026-01-22 20:38:27 +01:00
Niklas Jensen 1b6ffc4641 Upped chunk size to 16kb (down not working) 2026-01-22 20:38:27 +01:00
Niklas Jensen d00e7bc92d Only flush periodically (every 64 chunks) 2026-01-22 20:38:27 +01:00
Niklas Jensen 6b96d0deba Added error logging for ws send, and naming for fs ws 2026-01-22 20:38:27 +01:00
Niklas Jensen 4fa3939209 Increased socket size to fix fs chunking 2026-01-22 20:38:27 +01:00
Niklas Jensen 26c187a480 Claude: reduced the file and dir count (still bad code) but works 2026-01-22 20:38:27 +01:00
Niklas Jensen 957b60b132 Claude: ESP build fixed - untested 2026-01-22 20:38:27 +01:00
Niklas Jensen d86c86e028 Claude: Svelte remake 2026-01-22 20:38:27 +01:00
Niklas Jensen 0435605e18 Claude: Fixing esp side (and stupid amount of .md) 2026-01-22 20:38:27 +01:00
Niklas Jensen f440fa3973 Claude: File chunking system 2026-01-22 20:38:27 +01:00
Niklas Jensen 725d62747d fs chunked upload download start 2026-01-22 20:38:27 +01:00
Rune Harlyk d14e598aab 🔥 Cleanup factory settings 2026-01-03 22:25:52 +01:00
Rune Harlyk d611cd043b Make ip be uint32 instead of strings 2026-01-03 22:15:00 +01:00
Niklas Jensen 6be38b2e9e Protobuf python installation - automatic 2026-01-03 22:15:00 +01:00
Niklas Jensen fd7b3951ff System status esp + svelte - protobuf complete 2026-01-03 22:15:00 +01:00
Rune Harlyk cb74a1e9d4 ♻️ Adds ws back for testing 2026-01-03 22:15:00 +01:00
Rune Harlyk b38bc4e807 ♻️ Adds ws back for testing 2026-01-03 22:15:00 +01:00
Rune Harlyk bfac75c8fb 🚚 Rename sendEvent to emit 2026-01-03 22:15:00 +01:00
Rune Harlyk 5295ad56c8 📝 Adds websocket docs 2026-01-03 22:15:00 +01:00
Rune Harlyk 7cb5c06524 🚚 Renames controller data 2026-01-03 22:15:00 +01:00
Rune Harlyk 3a393375fd 📝 Updates api documentation 2026-01-03 22:15:00 +01:00
Rune Harlyk db0d4beb68 💥 Update change log 2026-01-03 22:15:00 +01:00
Rune Harlyk b96ea51bd8 ♻️ Makes IMU store handle data subscription 2026-01-03 22:15:00 +01:00
Rune Harlyk a31e001eb5 ♻️ Moves analytics subscription handling to store 2026-01-03 22:15:00 +01:00
Rune Harlyk 39f9e47e59 🚚 Moves system metrics out of main 2026-01-03 22:15:00 +01:00
Rune Harlyk a6e5363533 🔥 Removes test route 2026-01-03 22:15:00 +01:00
Rune Harlyk 775ca78a10 🚚 Rename websocket_message to messages 2026-01-03 22:15:00 +01:00
Rune Harlyk c4b1ae8335 ♻️ Adds requirements 2026-01-03 22:15:00 +01:00
Rune Harlyk e3ae62e120 👷 Remove $type keys 2026-01-03 22:15:00 +01:00
Rune Harlyk 2b4468d407 👷 Update proto build system 2026-01-03 22:15:00 +01:00
Rune Harlyk 685088c218 🔨 makes proto generation be part of build script 2026-01-03 22:15:00 +01:00
Rune Harlyk 0309855d5f 🔥 Removes example project 2026-01-03 22:15:00 +01:00
Rune Harlyk b0ee7b6b1b ♻️ Makes feature flags be fetched over socket 2026-01-03 22:15:00 +01:00
Rune Harlyk 0ddfe479d9 Quest socket request when connection down 2026-01-03 22:15:00 +01:00
Rune Harlyk 2b817e90ef 🙈 Ignore auto generated proto files 2026-01-03 22:15:00 +01:00
Rune Harlyk c06b349f16 ♻️ Makes imu calibration use request reponse 2026-01-03 22:15:00 +01:00
Rune Harlyk dc04204e8e Adds promise based request reponse system 2026-01-03 22:15:00 +01:00
Rune Harlyk 585adaf28f 🔨 Adds build script for ts proto 2026-01-03 22:15:00 +01:00
Rune Harlyk 6a117ac5e3 ♻️ Const cast strings in feature 2026-01-03 22:15:00 +01:00
Niklas Jensen 83e5fcd354 Updated socket test to pass 2026-01-03 22:15:00 +01:00
Niklas Jensen 9d7caab295 🐸 Sending and receiving a correlated request (WIP) 2026-01-03 22:15:00 +01:00
Niklas Jensen f3a3ebe1ea Features reponse for esp 2026-01-03 22:15:00 +01:00
Niklas Jensen 210e0363ab Features size defined 2026-01-03 22:15:00 +01:00
Rune Harlyk 568fa93368 🔥 Removes stateful socket 2026-01-03 22:15:00 +01:00
Rune Harlyk 8b12d4008e Removes msgpack and json build flags 2026-01-03 22:15:00 +01:00
Rune Harlyk 306e7488e0 ♻️ Replace arduino math function with cmath 2026-01-03 22:15:00 +01:00
Rune Harlyk a2f08540f7 🔥 Removes http system metrics 2026-01-03 22:15:00 +01:00
Rune Harlyk 4c6b0c316d 🔥 Remove json from sensor implementations 2026-01-03 22:15:00 +01:00
Rune Harlyk fa332995f9 ♻️ Handle incomming messages 2026-01-03 22:15:00 +01:00
Rune Harlyk c0c13754f4 ♻️ Updates combase to use protobufs 2026-01-03 22:15:00 +01:00
Niklas Jensen 28bb35d104 Updated proto with request->response features 2026-01-03 22:15:00 +01:00
Niklas Jensen 719e6be8a7 Protobufs to esp: broken endpoints to make protobuf work 2026-01-03 22:15:00 +01:00
Niklas Jensen a6a8f4988b Fixed filesystem chart in system metrics 2026-01-03 22:15:00 +01:00
Niklas Jensen 7461b26c97 BIG mistake in socket causing no messages to be delivered 2026-01-03 22:15:00 +01:00
Niklas Jensen 62f5758ab0 System metrics fix 1 2026-01-03 22:15:00 +01:00
Rune Harlyk 381ff9463d 🐛 Resubscribe to events after reconnect 2026-01-03 22:15:00 +01:00
Rune Harlyk 72f3650c6e ♻️ Cleanup socket 2026-01-03 22:15:00 +01:00
Rune Harlyk 86a4cee7ae ♻️ Makes messages use static array and sizes 2026-01-03 22:15:00 +01:00
Rune Harlyk 6c737c10c7 Adds build step for protobuff 2026-01-03 22:15:00 +01:00
Rune Harlyk a9e38c845a 🐛 Subscribe to socket event 2026-01-03 22:15:00 +01:00
Niklas Jensen 61905f8e95 Compile proto 2026-01-03 22:15:00 +01:00
Niklas Jensen 4e4e8fb190 Updated proto compile and temp added proto -> .c .h files 2026-01-03 22:15:00 +01:00
Rune Harlyk 96075a0110 Adds nanopb subproject 2026-01-03 22:15:00 +01:00
Rune Harlyk 0ef55bcc7e 🎨 Format and simplify controls 2026-01-03 22:15:00 +01:00
Rune Harlyk a6b5b0881a Handle vertical slider number parsin 2026-01-03 22:15:00 +01:00
Niklas Jensen f3d3cb1b6f Updated proto and embedded build for nanopb + esp fix 2026-01-03 22:15:00 +01:00
Niklas Jensen c2374bd353 Making embedded build fetch submodules for test 2026-01-03 22:15:00 +01:00
Niklas Jensen d07f3139b6 Updated socket test to use up to date structures 2026-01-03 22:15:00 +01:00
Niklas Jensen ff3a3f3d7d 🏍️ Fixed servos svelte 2026-01-03 22:15:00 +01:00
Niklas Jensen 72cde1a90a Fix i2c svelte 2026-01-03 22:15:00 +01:00
Niklas Jensen b485579d80 🔧 Fixed i2c settings 2026-01-03 22:15:00 +01:00
Niklas Jensen 13546d600c Fixed up IMU properly this time 2026-01-03 22:15:00 +01:00
Niklas Jensen 58bf8a88a6 🏌️‍♀️First iteration of proto building workflow 2026-01-03 22:15:00 +01:00
Niklas Jensen 9c1ad30771 🛜 Fixed wifi with protobuf, added rest message proto 2026-01-03 22:15:00 +01:00
Niklas Jensen a98faabfba 🦉Updated protoc to include reference to other protos 2026-01-03 22:15:00 +01:00
Niklas Jensen 10e56e25b3 Fix imu data among other things, start at wifi fix 2026-01-03 22:15:00 +01:00
Niklas Jensen d6e281d6a5 📈 Fixed TargetAngles, Gaits and more angles 2026-01-03 22:15:00 +01:00
Niklas Jensen cdaa60d0e1 Updated servo angles and kinematic data 2026-01-03 22:15:00 +01:00
Niklas Jensen f25aba5f29 Fixed the controls and cleaned up some lookup tables 2026-01-03 22:15:00 +01:00
Niklas Jensen 1117666f26 Fix telemetry and some more models 2026-01-03 22:15:00 +01:00
Niklas Jensen 19ebceb959 Redoing input data and modes, 2026-01-03 22:15:00 +01:00
Niklas Jensen a8abaaaf61 Adjusted parts of routes layout to work with protobufs 2026-01-03 22:15:00 +01:00
Niklas Jensen a53bf806ac Updated analytics (still wip) 2026-01-03 22:15:00 +01:00
Niklas Jensen 49a7431cef Beginning of model rework 2026-01-03 22:15:00 +01:00
Niklas Jensen 4633d2eb09 Prettified listeners -> split event and msg events + unit testing 2026-01-03 22:15:00 +01:00
Niklas Jensen 73aa38951d Minor change to new message formats. LONG WAY TO GO 2026-01-03 22:15:00 +01:00
Niklas Jensen 9cddbf8a9b All errors gone from socket.ts (logic errors might still be present) 2026-01-03 22:15:00 +01:00
Niklas Jensen e4ec2dd7b7 Bad idea to gut the listeners.. 2026-01-03 22:15:00 +01:00
Niklas Jensen 2eec367e05 Absolutely gutting the listeners - VERY WIP 2026-01-03 22:15:00 +01:00
Niklas Jensen f5fc31ca5a Added protoMetadata and trying to fix sub and unsub 2026-01-03 22:15:00 +01:00
Niklas Jensen c90ebe5630 Test proper handling of invalid types 2026-01-03 22:15:00 +01:00
Niklas Jensen eab9aab5c6 sendEvent updated 2026-01-03 22:15:00 +01:00
Niklas Jensen 466f2b1b37 Cleaner event key fetching 2026-01-03 22:15:00 +01:00
Niklas Jensen 770a462d78 Send, encoding and listeners handling 2026-01-03 22:15:00 +01:00
Niklas Jensen 8098dcec9b Added basic tests for testing a real websocket 2026-01-03 22:15:00 +01:00
Niklas Jensen c4d3c8966c Socket on, off, decodemsg and onmsg updated 2026-01-03 22:15:00 +01:00
Niklas Jensen 361d8b0975 Generation of type translation for socket (claude assisted) 2026-01-03 22:15:00 +01:00
Niklas Jensen 41d1b8e56d Start of svelte socket rewrite 2026-01-03 22:15:00 +01:00
Niklas Jensen 5459d0edd4 prototest update for testing 2026-01-03 22:15:00 +01:00
Niklas Jensen 8c45f66137 Idea of how typescript should decode Protobuffer 2026-01-03 22:15:00 +01:00
Niklas Jensen c2bbeb2f2b Fixed event comma seperator 2026-01-03 22:15:00 +01:00
Niklas Jensen 8a5f8a2154 buffer adjust 2026-01-03 22:15:00 +01:00
Niklas Jensen 3015c13da8 Attempt at implementing PB sending for ESP32 2026-01-03 22:15:00 +01:00
Niklas Jensen 356ccda4ae Include minimal example of running protobuf with C 2026-01-03 22:15:00 +01:00
Niklas Jensen 1f0b416231 Add nanopb as submodule 2026-01-03 22:15:00 +01:00
Niklas Jensen 6bdefbbf54 Wrong placement of example 2026-01-03 22:15:00 +01:00
Niklas Jensen 2a25851fb6 Added page to test encoding of proto 2026-01-03 22:15:00 +01:00
Niklas Jensen 7a21580569 Added protobuf to TS (proper) 2026-01-03 22:15:00 +01:00
Niklas Jensen 8c418fd779 Revert "Added protobufs for TS"
This reverts commit 37b9baf7c8d9bf33c3ac29063f8ee697b20ef497.
2026-01-03 22:15:00 +01:00
Niklas Jensen 8e66a03c00 Added protobufs for TS 2026-01-03 22:15:00 +01:00
Rune Harlyk 9ceb7a9919 ♻️ Fix pitch and roll units 2026-01-03 14:49:15 +01:00
Rune Harlyk 04aeeb5f07 Set Svelte bundleStrategy single 2026-01-03 01:55:18 +01:00
Rune Harlyk 3451b93743 ♻️ Makes smoothing be a const 2026-01-02 23:15:17 +01:00
Rune Harlyk 4da929a6de ♻️ Moves throttling to socket out 2026-01-02 23:15:16 +01:00
Rune Harlyk 21bd4fa837 🚨 Fix linting errors 2026-01-02 22:56:14 +01:00
Rune Harlyk 3c557b69a3 🐛 Expands the number of allowed endpoints 2025-12-29 16:09:28 +01:00
Rune Harlyk 9f3b59f0a7 🎨 Improve controller bar ui 2025-12-25 20:15:45 +01:00
Rune Harlyk 0d1e27b167 🎨 Fix camera on robot 2025-12-25 20:04:00 +01:00
Rune Harlyk e1dad10a87 🎨 Improve ui of servo calibration tool 2025-12-25 19:16:36 +01:00
172 changed files with 12336 additions and 3791 deletions
+5
View File
@@ -36,6 +36,11 @@ jobs:
cache: "pnpm" cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml" cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
- run: pnpm install - run: pnpm install
- run: pnpm run build - run: pnpm run build
+8
View File
@@ -18,6 +18,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: "recursive"
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@@ -32,6 +34,12 @@ jobs:
- name: Install PlatformIO Core - name: Install PlatformIO Core
run: pip install --upgrade platformio run: pip install --upgrade platformio
- name: Install Python dependencies for nanopb
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Build PlatformIO Project - name: Build PlatformIO Project
run: pio run run: pio run
+29 -20
View File
@@ -2,13 +2,13 @@ name: Frontend Tests
on: on:
push: push:
branches: [ master ] branches: [master]
paths: paths:
- 'app/**' - "app/**"
pull_request: pull_request:
branches: [ master ] branches: [master]
paths: paths:
- 'app/**' - "app/**"
permissions: permissions:
contents: read contents: read
@@ -20,22 +20,31 @@ jobs:
run: run:
working-directory: ./app working-directory: ./app
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v2
with: with:
version: 9 version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 'latest' node-version: "latest"
cache: 'pnpm' cache: "pnpm"
cache-dependency-path: './app/pnpm-lock.yaml' cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install dependencies - name: Install Protoc
run: pnpm install uses: arduino/setup-protoc@v3
- name: Install Playwright Browsers with:
run: npx playwright install --with-deps version: "27.x"
- name: Run tests - name: Install dependencies
run: pnpm test run: pnpm install
- name: Generate Proto
run: pnpm proto
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run tests
run: pnpm test
+59
View File
@@ -0,0 +1,59 @@
name: Proto Build
on:
push:
branches: [master, protobuf-playground]
pull_request:
branches: [master, protobuf-playground]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install Python dependencies
run: pip install protobuf grpcio-tools
- name: Build Protocol Buffers (nanopb)
run: python ./submodules/nanopb/generator/nanopb_generator.py -I "./platform_shared/" -D esp32/src/platform_shared ./platform_shared/message.proto
- name: Setup Protocol Buffers compiler
uses: arduino/setup-protoc@v3
with:
version: "25.x"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
working-directory: ./app
- name: Build Protocol Buffers (Typescript)
run: pnpm proto
working-directory: ./app
+9
View File
@@ -6,3 +6,12 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
.pio .pio
managed_components/
dependencies.lock
sdkconfig
sdkconfig.*
!sdkconfig.defaults
esp32/src/platform_shared/*
!esp32/src/platform_shared/.gitkeep
app/src/lib/platform_shared/*
!app/src/lib/platform_shared/.gitkeep
+4
View File
@@ -0,0 +1,4 @@
[submodule "submodules/nanopb"]
path = submodules/nanopb
url = https://github.com/nanopb/nanopb
branch = master
+1 -1
View File
@@ -13,7 +13,7 @@
}, },
"editor.tabSize": 4, "editor.tabSize": 4,
"editor.detectIndentation": false, "editor.detectIndentation": false,
"cmake.sourceDirectory": "C:/data/repos/Hardware/Spot Micro - Leika/.pio/libdeps/esp32cam/esp32-camera", "cmake.sourceDirectory": "C:/data/repos/Hardware/Spot_Micro_Leika",
"cSpell.words": [ "cSpell.words": [
"Adafruit", "Adafruit",
"IRAM", "IRAM",
+3
View File
@@ -0,0 +1,3 @@
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(Spot_Micro_Leika)
-2
View File
@@ -1,3 +1 @@
PUBLIC_VITE_USE_HOST_NAME=true PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
-13
View File
@@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
-31
View File
@@ -1,31 +0,0 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
}
+6 -6
View File
@@ -1,8 +1,8 @@
declare module 'app-env' { declare module "app-env" {
interface ENV { interface ENV {
VITE_USE_HOST_NAME: boolean VITE_USE_HOST_NAME: boolean;
} }
const appEnv: ENV const appEnv: ENV;
export default appEnv export default appEnv;
} }
+44
View File
@@ -0,0 +1,44 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import prettier from 'eslint-config-prettier'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: [
'.DS_Store',
'node_modules/',
'build/',
'.svelte-kit/',
'package/',
'.env',
'.env.*',
'!.env.example',
'pnpm-lock.yaml',
'package-lock.json',
'yarn.lock'
]
}
)
+12 -4
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "vite build", "build": "pnpm proto && vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build", "build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit", "test": "pnpm run test:integration && pnpm run test:unit",
@@ -13,9 +13,11 @@
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test:integration": "playwright test", "test:integration": "playwright test",
"test:unit": "vitest" "test:unit": "vitest",
"proto": "node scripts/compile_protos.js"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@iconify-json/tabler": "^1.2.23", "@iconify-json/tabler": "^1.2.23",
"@playwright/test": "^1.56.0", "@playwright/test": "^1.56.0",
@@ -24,12 +26,14 @@
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/parser": "^8.46.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.37.0", "eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
@@ -37,15 +41,18 @@
"svelte-check": "^4.3.3", "svelte-check": "^4.3.3",
"svelte-focus-trap": "^1.2.0", "svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"ts-proto-descriptors": "^2.1.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"unplugin-icons": "^22.4.2", "unplugin-icons": "^22.4.2",
"vite": "^7.1.9", "vite": "^7.1.9",
"vitest": "^3.2.4" "vitest": "^3.2.4",
"ws": "^8.18.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2", "@bufbuild/protobuf": "^2.10.2",
"@niku/vite-env-caster": "^1.1.2", "@niku/vite-env-caster": "^1.1.2",
"@sveltejs/adapter-auto": "^6.1.1", "@sveltejs/adapter-auto": "^6.1.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
@@ -57,6 +64,7 @@
"svelte-dnd-list": "^0.1.8", "svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.1", "svelte-modals": "^2.0.1",
"three": "^0.180.0", "three": "^0.180.0",
"ts-proto": "^2.10.1",
"urdf-loader": "^0.12.6", "urdf-loader": "^0.12.6",
"uzip": "^0.20201231.0", "uzip": "^0.20201231.0",
"xacro-parser": "^0.3.10" "xacro-parser": "^0.3.10"
+272 -12
View File
@@ -8,9 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@msgpack/msgpack': '@bufbuild/protobuf':
specifier: ^3.1.2 specifier: ^2.10.2
version: 3.1.2 version: 2.10.2
'@niku/vite-env-caster': '@niku/vite-env-caster':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@@ -44,6 +44,9 @@ importers:
three: three:
specifier: ^0.180.0 specifier: ^0.180.0
version: 0.180.0 version: 0.180.0
ts-proto:
specifier: ^2.10.1
version: 2.10.1
urdf-loader: urdf-loader:
specifier: ^0.12.6 specifier: ^0.12.6
version: 0.12.6(three@0.180.0) version: 0.12.6(three@0.180.0)
@@ -54,6 +57,9 @@ importers:
specifier: ^0.3.10 specifier: ^0.3.10
version: 0.3.10 version: 0.3.10
devDependencies: devDependencies:
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
'@iconify-json/mdi': '@iconify-json/mdi':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3 version: 1.2.3
@@ -78,6 +84,9 @@ importers:
'@types/three': '@types/three':
specifier: ^0.180.0 specifier: ^0.180.0
version: 0.180.0 version: 0.180.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.46.0 specifier: ^8.46.0
version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) version: 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
@@ -96,6 +105,9 @@ importers:
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^3.12.4 specifier: ^3.12.4
version: 3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11) version: 3.12.4(eslint@9.37.0(jiti@2.6.1))(svelte@5.39.11)
globals:
specifier: ^17.0.0
version: 17.0.0
jsdom: jsdom:
specifier: ^27.0.0 specifier: ^27.0.0
version: 27.0.0(postcss@8.5.6) version: 27.0.0(postcss@8.5.6)
@@ -117,12 +129,18 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4.1.14 specifier: ^4.1.14
version: 4.1.14 version: 4.1.14
ts-proto-descriptors:
specifier: ^2.1.0
version: 2.1.0
tslib: tslib:
specifier: ^2.8.1 specifier: ^2.8.1
version: 2.8.1 version: 2.8.1
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint:
specifier: ^8.51.0
version: 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
unplugin-icons: unplugin-icons:
specifier: ^22.4.2 specifier: ^22.4.2
version: 22.4.2(svelte@5.39.11) version: 22.4.2(svelte@5.39.11)
@@ -132,6 +150,9 @@ importers:
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@types/node@24.7.1)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1) version: 3.2.4(@types/node@24.7.1)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1)
ws:
specifier: ^8.18.3
version: 8.18.3
packages: packages:
@@ -150,6 +171,9 @@ packages:
'@asamuzakjp/nwsapi@2.3.9': '@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@bufbuild/protobuf@2.10.2':
resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -376,6 +400,10 @@ packages:
resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==} resolution: {integrity: sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.2':
resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6': '@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -435,10 +463,6 @@ packages:
'@kurkle/color@0.3.4': '@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@msgpack/msgpack@3.1.2':
resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==}
engines: {node: '>= 18'}
'@niku/vite-env-caster@1.1.2': '@niku/vite-env-caster@1.1.2':
resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==} resolution: {integrity: sha512-6I/8REFdmfeGnK92H3nYHGc6lExwjm72jLxAsDPlfji97Eej4rOMl6WuYGLgsQI0pl5RrMRMveeRdijdL6hW+Q==}
@@ -741,6 +765,9 @@ packages:
'@types/webxr@0.5.24': '@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.46.0': '@typescript-eslint/eslint-plugin@8.46.0':
resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==} resolution: {integrity: sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -749,6 +776,14 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/eslint-plugin@8.51.0':
resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.51.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.46.0': '@typescript-eslint/parser@8.46.0':
resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==} resolution: {integrity: sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -756,22 +791,45 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.51.0':
resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.46.0': '@typescript-eslint/project-service@8.46.0':
resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==} resolution: {integrity: sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.51.0':
resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.46.0': '@typescript-eslint/scope-manager@8.46.0':
resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==} resolution: {integrity: sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.51.0':
resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.46.0': '@typescript-eslint/tsconfig-utils@8.46.0':
resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==} resolution: {integrity: sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/tsconfig-utils@8.51.0':
resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.46.0': '@typescript-eslint/type-utils@8.46.0':
resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==} resolution: {integrity: sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -779,16 +837,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.51.0':
resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.46.0': '@typescript-eslint/types@8.46.0':
resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==} resolution: {integrity: sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.51.0':
resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.46.0': '@typescript-eslint/typescript-estree@8.46.0':
resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==} resolution: {integrity: sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.51.0':
resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.46.0': '@typescript-eslint/utils@8.46.0':
resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==} resolution: {integrity: sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -796,10 +871,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.51.0':
resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.46.0': '@typescript-eslint/visitor-keys@8.46.0':
resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==} resolution: {integrity: sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.51.0':
resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@@ -915,6 +1001,10 @@ packages:
caniuse-lite@1.0.30001749: caniuse-lite@1.0.30001749:
resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==} resolution: {integrity: sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==}
case-anything@2.1.13:
resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==}
engines: {node: '>=12.13'}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1022,6 +1112,11 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1029,6 +1124,9 @@ packages:
devalue@5.3.2: devalue@5.3.2:
resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==}
dprint-node@1.0.8:
resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==}
electron-to-chromium@1.5.234: electron-to-chromium@1.5.234:
resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==}
@@ -1220,6 +1318,10 @@ packages:
resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@17.0.0:
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
engines: {node: '>=18'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -1880,6 +1982,22 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-poet@6.12.0:
resolution: {integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==}
ts-proto-descriptors@2.1.0:
resolution: {integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==}
ts-proto@2.10.1:
resolution: {integrity: sha512-4sOE1hCs0uobJgdRCtcEwdbc8MAyKP+LJqUIKxZIiKac0rPBlVKsRGEGo2oQ1MnKA2Wwk0KuGP2POkiCwPtebw==}
hasBin: true
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1887,6 +2005,13 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.51.0:
resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -2148,6 +2273,8 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {} '@asamuzakjp/nwsapi@2.3.9': {}
'@bufbuild/protobuf@2.10.2': {}
'@csstools/color-helpers@5.1.0': {} '@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -2293,6 +2420,8 @@ snapshots:
'@eslint/js@9.37.0': {} '@eslint/js@9.37.0': {}
'@eslint/js@9.39.2': {}
'@eslint/object-schema@2.1.6': {} '@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.4.0': '@eslint/plugin-kit@0.4.0':
@@ -2359,8 +2488,6 @@ snapshots:
'@kurkle/color@0.3.4': {} '@kurkle/color@0.3.4': {}
'@msgpack/msgpack@3.1.2': {}
'@niku/vite-env-caster@1.1.2': '@niku/vite-env-caster@1.1.2':
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
@@ -2596,7 +2723,6 @@ snapshots:
'@types/node@24.7.1': '@types/node@24.7.1':
dependencies: dependencies:
undici-types: 7.14.0 undici-types: 7.14.0
optional: true
'@types/stats.js@0.17.4': {} '@types/stats.js@0.17.4': {}
@@ -2612,6 +2738,10 @@ snapshots:
'@types/webxr@0.5.24': {} '@types/webxr@0.5.24': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.7.1
'@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@@ -2629,6 +2759,22 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/type-utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.51.0
eslint: 9.37.0(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.46.0 '@typescript-eslint/scope-manager': 8.46.0
@@ -2641,6 +2787,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.46.0(typescript@5.9.3)': '@typescript-eslint/project-service@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.46.0(typescript@5.9.3)
@@ -2650,15 +2808,33 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.51.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.51.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.46.0': '@typescript-eslint/scope-manager@8.46.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
'@typescript-eslint/visitor-keys': 8.46.0 '@typescript-eslint/visitor-keys': 8.46.0
'@typescript-eslint/scope-manager@8.51.0':
dependencies:
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.51.0
'@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
@@ -2671,8 +2847,22 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/type-utils@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.37.0(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.46.0': {} '@typescript-eslint/types@8.46.0': {}
'@typescript-eslint/types@8.51.0': {}
'@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.46.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.46.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.46.0(typescript@5.9.3)
@@ -2689,6 +2879,21 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.51.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/utils@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1))
@@ -2700,11 +2905,27 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.46.0': '@typescript-eslint/visitor-keys@8.46.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.0 '@typescript-eslint/types': 8.46.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.51.0':
dependencies:
'@typescript-eslint/types': 8.51.0
eslint-visitor-keys: 4.2.1
'@vitest/expect@3.2.4': '@vitest/expect@3.2.4':
dependencies: dependencies:
'@types/chai': 5.2.2 '@types/chai': 5.2.2
@@ -2823,6 +3044,8 @@ snapshots:
caniuse-lite@1.0.30001749: {} caniuse-lite@1.0.30001749: {}
case-anything@2.1.13: {}
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@@ -2917,10 +3140,16 @@ snapshots:
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
devalue@5.3.2: {} devalue@5.3.2: {}
dprint-node@1.0.8:
dependencies:
detect-libc: 1.0.3
electron-to-chromium@1.5.234: {} electron-to-chromium@1.5.234: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -3142,6 +3371,8 @@ snapshots:
globals@16.4.0: {} globals@16.4.0: {}
globals@17.0.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
graphemer@1.4.0: {} graphemer@1.4.0: {}
@@ -3734,18 +3965,47 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-poet@6.12.0:
dependencies:
dprint-node: 1.0.8
ts-proto-descriptors@2.1.0:
dependencies:
'@bufbuild/protobuf': 2.10.2
ts-proto@2.10.1:
dependencies:
'@bufbuild/protobuf': 2.10.2
case-anything: 2.1.13
ts-poet: 6.12.0
ts-proto-descriptors: 2.1.0
tslib@2.8.1: {} tslib@2.8.1: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.51.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.37.0(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {} typescript@5.9.3: {}
ufo@1.6.1: {} ufo@1.6.1: {}
undici-types@7.14.0: undici-types@7.14.0: {}
optional: true
unplugin-icons@22.4.2(svelte@5.39.11): unplugin-icons@22.4.2(svelte@5.39.11):
dependencies: dependencies:
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import { execSync } from 'child_process'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const isWindows = os.platform() === 'win32'
const projectRoot = path.resolve(__dirname, '..')
const platformSharedDir = path.resolve(projectRoot, '..', 'platform_shared')
const outputDir = path.resolve(projectRoot, 'src', 'lib', 'platform_shared')
const pluginPath =
isWindows ?
path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto.cmd')
: path.join(projectRoot, 'node_modules', '.bin', 'protoc-gen-ts_proto')
const protoFiles = ['filesystem.proto', 'message.proto', "api.proto"]
const tsProtoOpts = ['useExactTypes=false', 'outputExtensions=true', 'outputSchema=true'].join(',')
const cmd = [
'protoc',
`--plugin=protoc-gen-ts_proto=${pluginPath}`,
`--ts_proto_out=${outputDir}`,
`--ts_proto_opt=${tsProtoOpts}`,
`-I${platformSharedDir}`,
...protoFiles.map(f => path.join(platformSharedDir, f))
].join(' ')
console.log('Compiling protos...')
console.log(` Platform: ${os.platform()}`)
console.log(` Output: ${outputDir}`)
try {
execSync(cmd, { stdio: 'inherit', cwd: projectRoot })
console.log('Proto compilation complete!')
} catch (error) {
console.error('Proto compilation failed!', error)
process.exit(1)
}
+17 -3
View File
@@ -1,6 +1,9 @@
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { Err, Ok, type Result } from './utilities' import { Err, Ok, type Result } from './utilities'
import { apiLocation } from './stores' import { apiLocation } from './stores/location-store'
import type { MessageFns } from './platform_shared/filesystem'
import { Request, Response as ProtoResponse } from './platform_shared/api'
import { BinaryWriter } from '@bufbuild/protobuf/wire'
export const api = { export const api = {
get<TResponse>(endpoint: string, params?: RequestInit) { get<TResponse>(endpoint: string, params?: RequestInit) {
@@ -11,6 +14,10 @@ export const api = {
return sendRequest<TResponse>(endpoint, 'POST', data) return sendRequest<TResponse>(endpoint, 'POST', data)
}, },
post_proto<TResponse>(endpoint: string, data: Request) {
return sendRequest<TResponse>(endpoint, 'POST', Request.encode(data))
},
put<TResponse>(endpoint: string, data?: unknown) { put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data) return sendRequest<TResponse>(endpoint, 'PUT', data)
}, },
@@ -27,7 +34,11 @@ async function sendRequest<TResponse>(
params?: RequestInit params?: RequestInit
): Promise<Result<TResponse, Error>> { ): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint) endpoint = resolveUrl(endpoint)
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined
const isProtobuf = data instanceof BinaryWriter
const body = data !== null && typeof data !== 'undefined'
? (isProtobuf ? data.finish() : JSON.stringify(data))
: undefined
const request = { const request = {
...params, ...params,
@@ -36,7 +47,7 @@ async function sendRequest<TResponse>(
headers: { headers: {
...params?.headers, ...params?.headers,
Authorization: 'Basic', Authorization: 'Basic',
'Content-Type': 'application/json' 'Content-Type': isProtobuf ? 'application/x-protobuf' : 'application/json'
} }
} }
@@ -60,6 +71,9 @@ async function sendRequest<TResponse>(
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
const data = await response.json() const data = await response.json()
return Ok.new(data as TResponse) return Ok.new(data as TResponse)
} else if (contentType && contentType.includes('application/x-protobuf')) {
let data: ProtoResponse = ProtoResponse.decode(await response.bytes());
return Ok.new(data as TResponse)
} else { } else {
// Handle empty object as response // Handle empty object as response
return Ok.new(null as TResponse) return Ok.new(null as TResponse)
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
interface Props {
heading?: number
size?: string
}
let { heading = 0, size = 'w-48 h-48' }: Props = $props()
const getCardinalDirection = (h: number) => {
if (h >= 337.5 || h < 22.5) return 'N'
if (h >= 22.5 && h < 67.5) return 'NE'
if (h >= 67.5 && h < 112.5) return 'E'
if (h >= 112.5 && h < 157.5) return 'SE'
if (h >= 157.5 && h < 202.5) return 'S'
if (h >= 202.5 && h < 247.5) return 'SW'
if (h >= 247.5 && h < 292.5) return 'W'
return 'NW'
}
const ticks = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
</script>
<div class="flex flex-col items-center">
<div class="relative {size}">
<svg viewBox="0 0 200 200" class="w-full h-full">
<circle
cx="100"
cy="100"
r="90"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-30"
/>
<circle
cx="100"
cy="100"
r="70"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<circle
cx="100"
cy="100"
r="50"
fill="none"
stroke="currentColor"
stroke-width="1"
class="opacity-20"
/>
<text x="100" y="20" text-anchor="middle" class="fill-current text-sm font-bold">N</text>
<text x="180" y="105" text-anchor="middle" class="fill-current text-sm font-bold">E</text>
<text x="100" y="190" text-anchor="middle" class="fill-current text-sm font-bold">S</text>
<text x="20" y="105" text-anchor="middle" class="fill-current text-sm font-bold">W</text>
{#each ticks as tick}
<line
x1={100 + 85 * Math.sin((tick * Math.PI) / 180)}
y1={100 - 85 * Math.cos((tick * Math.PI) / 180)}
x2={100 + 78 * Math.sin((tick * Math.PI) / 180)}
y2={100 - 78 * Math.cos((tick * Math.PI) / 180)}
stroke="currentColor"
stroke-width={tick % 90 === 0 ? 2 : 1}
class="opacity-50"
/>
{/each}
<g transform="rotate({heading}, 100, 100)">
<polygon points="100,25 93,100 100,90 107,100" class="fill-error" />
<polygon points="100,175 93,100 100,110 107,100" class="fill-base-300" />
</g>
<circle cx="100" cy="100" r="8" class="fill-base-content" />
</svg>
</div>
<div class="text-2xl font-mono font-bold mt-2">{heading.toFixed(1)}°</div>
<div class="text-sm opacity-70">{getCardinalDirection(heading)}</div>
</div>
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { ComponentType } from 'svelte' import type { Component } from 'svelte'
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning' type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
@@ -11,12 +11,12 @@
class: klass = '', class: klass = '',
children = null children = null
} = $props<{ } = $props<{
icon?: ComponentType icon?: Component
title: string title: string
description?: string | number description?: string | number
variant?: Variant variant?: Variant
class?: string class?: string
children?: () => ComponentType children?: () => Component
}>() }>()
const Icon = $derived(icon) const Icon = $derived(icon)
+92 -66
View File
@@ -10,28 +10,34 @@
Color Color
} from 'three' } from 'three'
import { import {
ModesEnum,
kinematicData,
mode, mode,
model, model,
outControllerData, input,
servoAnglesOut, servoAnglesOut,
servoAngles, servoAngles,
mpu, mpu,
jointNames, jointNames,
currentKinematic, currentKinematic,
walkGait, walkGait,
walkGaitToMode kinematicData
} from '$lib/stores' } from '$lib/stores'
import { populateModelCache, throttler, getToeWorldPositions } from '$lib/utilities' import { populateModelCache, getToeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder' import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils' import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic' import { type body_state_t } from '$lib/kinematic'
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait' import {
BezierState,
CalibrationState,
GaitState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js' import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader' import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { AnglesData, KinematicData, ModesEnum } from '$lib/platform_shared/message'
interface Props { interface Props {
defaultColor?: string | null defaultColor?: string | null
@@ -51,11 +57,14 @@
let sceneManager = $state(new SceneBuilder()) let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement let canvas: HTMLCanvasElement
const NUM_ANGLES = 12 // TODO: This number should come from the robot
let currentModelAngles: number[] = new Array(12).fill(0) let currentModelAngles: AnglesData = AnglesData.create({
let modelTargetAngles: number[] = new Array(12).fill(0) angles: new Array(NUM_ANGLES).fill(0)
})
let modelTargetAngles: AnglesData = AnglesData.create({ angles: new Array(NUM_ANGLES).fill(0) })
let gui_panel: GUI let gui_panel: GUI
let Throttler = new throttler() const SMOOTH_AMOUNT = 0.2
let target: Object3D<Object3DEventMap> let target: Object3D<Object3DEventMap>
@@ -63,15 +72,17 @@
let kinematic = get(currentKinematic) let kinematic = get(currentKinematic)
let planners = { const planners: Record<ModesEnum, GaitState> = {
[ModesEnum.Deactivated]: new IdleState(), [ModesEnum.DEACTIVATED]: new IdleState(),
[ModesEnum.Idle]: new IdleState(), [ModesEnum.IDLE]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(), [ModesEnum.CALIBRATION]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(), [ModesEnum.REST]: new RestState(),
[ModesEnum.Stand]: new StandState(), [ModesEnum.STAND]: new StandState(),
[ModesEnum.Walk]: new BezierState() [ModesEnum.WALK]: new BezierState(),
[ModesEnum.UNRECOGNIZED]: new IdleState()
} }
let lastTick = performance.now() let lastTick = performance.now()
let lastRobotPosition = new Vector3()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1] const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const THREEJS_SCALE = 10 const THREEJS_SCALE = 10
@@ -99,7 +110,6 @@
'Trace feet': debug, 'Trace feet': debug,
'Target position': false, 'Target position': false,
'Trace points': 30, 'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true, 'Smooth motion': true,
omega: 0, omega: 0,
phi: 0, phi: 0,
@@ -114,16 +124,23 @@
await populateModelCache() await populateModelCache()
await createScene() await createScene()
servoAngles.subscribe(updateAnglesFromStore) servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait))) walkGait.subscribe(gait => {
const walkPlanner = planners[ModesEnum.WALK]
if (!(walkPlanner instanceof BezierState)) {
throw new Error(
`Expected BezierState for WALK mode, got ${walkPlanner.constructor.name}`
)
}
walkPlanner.set_mode(gait.gait)
})
if (panel) createPanel() if (panel) createPanel()
}) })
onDestroy(() => { onDestroy(() => {
canvas.remove()
gui_panel?.destroy() gui_panel?.destroy()
}) })
const updateAnglesFromStore = (angles: number[]) => { const updateAnglesFromStore = (angles: AnglesData) => {
if (sceneManager.isDragging) return if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return if (settings['Internal kinematic']) return
modelTargetAngles = angles modelTargetAngles = angles
@@ -156,23 +173,26 @@
} }
const updateKinematicPosition = () => { const updateKinematicPosition = () => {
kinematicData.set([ kinematicData.set(
settings.omega, KinematicData.create({
settings.phi, omega: settings.omega,
settings.psi, phi: settings.phi,
settings.xm, psi: settings.psi,
settings.ym, xm: settings.xm,
settings.zm ym: settings.ym,
]) zm: settings.zm
})
)
} }
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!)) const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
const updateAngles = (name: string, angle: number) => { const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI) modelTargetAngles.angles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle( servoAnglesOut.set(
() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), AnglesData.create({
100 angles: modelTargetAngles.angles.map(num => Math.round(num))
})
) )
} }
@@ -226,7 +246,7 @@
} }
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i])) let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles = new_angles modelTargetAngles.angles = new_angles
} }
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => { const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
@@ -234,38 +254,53 @@
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y)) robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
const cumulativeYaw = body_state.cumulative_yaw const cumulativeYaw = body_state.cumulative_yaw
const headingYaw = degToRad(-settings.phi + $mpu.heading)
const totalYaw = headingYaw + cumulativeYaw
const cosYaw = Math.cos(cumulativeYaw) const cosTotal = Math.cos(totalYaw)
const sinYaw = Math.sin(cumulativeYaw) const sinTotal = Math.sin(totalYaw)
const rotatedXm = settings.xm * cosYaw - settings.zm * sinYaw const rotatedXm = settings.xm * cosTotal - settings.zm * sinTotal
const rotatedZm = settings.xm * sinYaw + settings.zm * cosYaw const rotatedZm = settings.xm * sinTotal + settings.zm * cosTotal
const mpuHeadingRad = degToRad($mpu.heading)
const cosHead = Math.cos(mpuHeadingRad)
const sinHead = Math.sin(mpuHeadingRad)
const rotatedCumX = body_state.cumulative_x * cosHead - body_state.cumulative_z * sinHead
const rotatedCumZ = body_state.cumulative_x * sinHead + body_state.cumulative_z * cosHead
robot.position.x = smooth( robot.position.x = smooth(
robot.position.x, robot.position.x,
(-rotatedZm - body_state.cumulative_z) * THREEJS_SCALE, (-rotatedZm - rotatedCumZ) * THREEJS_SCALE,
0.1 SMOOTH_AMOUNT
) )
robot.position.z = smooth( robot.position.z = smooth(
robot.position.z, robot.position.z,
(-rotatedXm - body_state.cumulative_x) * THREEJS_SCALE, (-rotatedXm - rotatedCumX) * THREEJS_SCALE,
0.1 SMOOTH_AMOUNT
) )
const pitch = degToRad(settings.psi - 90) + body_state.cumulative_pitch const cosYaw = Math.cos(totalYaw)
const roll = degToRad(settings.omega) + body_state.cumulative_roll const sinYaw = Math.sin(totalYaw)
const cmdPitch = degToRad(settings.psi)
const cmdRoll = degToRad(settings.omega)
const pitch =
degToRad(-90) + cmdPitch * cosYaw - cmdRoll * sinYaw + body_state.cumulative_pitch
const roll = cmdPitch * sinYaw + cmdRoll * cosYaw + body_state.cumulative_roll
robot.rotation.z = smooth( robot.rotation.z = smooth(
robot.rotation.z, robot.rotation.z,
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw, degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
0.1 SMOOTH_AMOUNT
) )
robot.rotation.y = smooth(robot.rotation.y, roll, 0.1) robot.rotation.y = smooth(robot.rotation.y, roll, SMOOTH_AMOUNT)
robot.rotation.x = smooth(robot.rotation.x, pitch, 0.1) robot.rotation.x = smooth(robot.rotation.x, pitch, SMOOTH_AMOUNT)
} }
const update_camera = (robot: URDFRobot) => { const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return const delta = robot.position.clone().sub(lastRobotPosition)
sceneManager.orbit.target = robot.position.clone() sceneManager.orbit.target.add(delta)
sceneManager.camera.position.add(delta)
lastRobotPosition.copy(robot.position)
} }
const smooth = (start: number, end: number, amount: number) => { const smooth = (start: number, end: number, amount: number) => {
@@ -274,22 +309,13 @@
const update_gait = () => { const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData) const controlData = get(input)
const data = {
lx: controlData[0],
ly: controlData[1],
rx: controlData[2],
ry: controlData[3],
h: controlData[4],
s: controlData[5],
s1: controlData[6]
}
let planner = planners[get(mode)] let planner = planners[get(mode).mode]
const delta = performance.now() - lastTick const delta = performance.now() - lastTick
lastTick = performance.now() lastTick = performance.now()
body_state = planner.step(body_state, data, delta) body_state = planner.step(body_state, controlData, delta)
settings.omega = body_state.omega settings.omega = body_state.omega
settings.phi = body_state.phi settings.phi = body_state.phi
@@ -310,8 +336,8 @@
const updateTargetPosition = () => { const updateTargetPosition = () => {
target.visible = settings['Target position'] target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5) target.position.x = smooth(target.position.x, target_position.x, SMOOTH_AMOUNT)
target.position.z = smooth(target.position.z, target_position.z, 0.5) target.position.z = smooth(target.position.z, target_position.z, SMOOTH_AMOUNT)
} }
const render = () => { const render = () => {
@@ -330,12 +356,12 @@
sceneManager.transformControl.showZ = settings['Robot transform controls'] sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) { for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth( currentModelAngles.angles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI), (robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i], modelTargetAngles.angles[i],
0.1 SMOOTH_AMOUNT
) )
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i])) robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles.angles[i]))
} }
orient_robot(robot, toes) orient_robot(robot, toes)
@@ -0,0 +1,228 @@
<script lang="ts">
import { fileSystemClient } from '$lib/filesystem/chunkedTransfer'
import type { TransferProgress } from '$lib/types/models'
import { onMount } from 'svelte'
let currentPath = '/'
let files: Array<{ name: string; size: number }> = []
let directories: Array<{ name: string }> = []
let loading = false
let error = ''
let uploadProgress: TransferProgress | null = null
let downloadProgress: TransferProgress | null = null
const joinPath = (name: string) => (currentPath === '/' ? '/' + name : currentPath + '/' + name)
const getError = (e: unknown, fallback: string) =>
e instanceof Error ? e.message : (e as { error?: string })?.error || fallback
async function loadDirectory() {
loading = true
error = ''
try {
const result = await fileSystemClient.listDirectory(currentPath)
if (result.success) {
files = result.files
directories = result.directories
} else {
error = result.error || 'Failed to load directory'
}
} catch (e) {
error = getError(e, 'Unknown error')
} finally {
loading = false
}
}
async function navigateTo(path: string) {
currentPath = path
await loadDirectory()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadProgress = null
error = ''
try {
const result = await fileSystemClient.uploadFileFromBrowser(
joinPath(file.name),
file,
p => (uploadProgress = p)
)
if (result.success) await loadDirectory()
else error = result.error || 'Upload failed'
} catch (e) {
error = getError(e, 'Upload error')
} finally {
uploadProgress = null
input.value = ''
}
}
async function handleDownload(filename: string) {
downloadProgress = null
error = ''
try {
const result = await fileSystemClient.downloadFileAndSave(
joinPath(filename),
filename,
p => (downloadProgress = p)
)
if (!result.success) error = result.error || 'Download failed'
} catch (e) {
error = getError(e, 'Download error')
} finally {
downloadProgress = null
}
}
async function handleDelete(name: string, isDirectory: boolean) {
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
error = ''
try {
const result = await fileSystemClient.deleteFile(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Delete failed'
} catch (e) {
error = getError(e, 'Delete error')
}
}
async function handleCreateDirectory() {
const name = prompt('Enter directory name:')
if (!name) return
error = ''
try {
const result = await fileSystemClient.createDirectory(joinPath(name))
if (result.success) await loadDirectory()
else error = result.error || 'Failed to create directory'
} catch (e) {
error = getError(e, 'Error creating directory')
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMount(loadDirectory)
</script>
<div class="max-w-3xl mx-auto my-8 p-4 border border-gray-300 rounded-lg bg-white">
<div class="mb-4">
<h2 class="m-0 mb-2">File Manager</h2>
<div class="font-mono bg-gray-100 p-2 rounded mb-2">Current: {currentPath}</div>
<div class="flex gap-2">
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={handleCreateDirectory}>New Folder</button
>
<label
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
>
Upload File
<input type="file" on:change={handleFileUpload} class="hidden" />
</label>
<button
class="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
on:click={loadDirectory}>Refresh</button
>
</div>
</div>
{#if error}
<div class="bg-red-100 text-red-800 p-3 rounded mb-4">{error}</div>
{/if}
{#if uploadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Uploading: {uploadProgress.percentage.toFixed(1)}% ({formatBytes(
uploadProgress.bytesTransferred
)} / {formatBytes(uploadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {uploadProgress.percentage}%"
></div>
</div>
</div>
{/if}
{#if downloadProgress}
<div class="mb-4">
<div class="mb-2 text-sm">
Downloading: {downloadProgress.percentage.toFixed(1)}% ({formatBytes(
downloadProgress.bytesTransferred
)} / {formatBytes(downloadProgress.totalBytes)})
</div>
<div class="h-5 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-green-600 transition-all duration-300"
style="width: {downloadProgress.percentage}%"
></div>
</div>
</div>
{/if}
<div class="border border-gray-300 rounded min-h-[200px]">
{#if loading}
<div class="text-center p-8 text-gray-500">Loading...</div>
{:else}
{#if currentPath !== '/'}
<div
class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50 cursor-pointer"
on:click={() => navigateTo('/')}
>
<span class="text-2xl">📁</span>
<span class="flex-1 hover:underline">..</span>
</div>
{/if}
{#each directories as dir}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 bg-gray-50">
<span class="text-2xl">📁</span>
<span
class="flex-1 cursor-pointer hover:underline"
on:click={() => navigateTo(currentPath + '/' + dir.name)}>{dir.name}</span
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(dir.name, true)}>Delete</button
>
</div>
{/each}
{#each files as file}
<div class="flex items-center p-3 border-b border-gray-100 gap-2 last:border-b-0">
<span class="text-2xl">📄</span>
<span class="flex-1">{file.name}</span>
<span class="text-gray-500 text-sm">{formatBytes(file.size)}</span>
<button
class="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
on:click={() => handleDownload(file.name)}>Download</button
>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
on:click={() => handleDelete(file.name, false)}>Delete</button
>
</div>
{/each}
{#if files.length === 0 && directories.length === 0}
<div class="text-center p-8 text-gray-500">Directory is empty</div>
{/if}
{/if}
</div>
</div>
+2
View File
@@ -38,6 +38,8 @@ export { default as FolderOpenOutline } from '~icons/mdi/folder-open-outline'
export { default as TrashIcon } from '~icons/mdi/trash' export { default as TrashIcon } from '~icons/mdi/trash'
export { default as RotateCcw } from '~icons/mdi/rotate-left' export { default as RotateCcw } from '~icons/mdi/rotate-left'
export { default as RotateCw } from '~icons/mdi/rotate-right' export { default as RotateCw } from '~icons/mdi/rotate-right'
export { default as UploadIcon } from '~icons/mdi/upload'
export { default as DownloadIcon } from '~icons/mdi/download'
export { default as Down } from '~icons/tabler/chevron-down' export { default as Down } from '~icons/tabler/chevron-down'
export { default as Cancel } from '~icons/tabler/x' export { default as Cancel } from '~icons/tabler/x'
@@ -4,7 +4,7 @@
max?: number max?: number
step?: number step?: number
value?: number value?: number
oninput?: (value: number) => void oninput?: (value: Event) => void
} }
let { let {
@@ -2,13 +2,14 @@
import { Github } from '../icons' import { Github } from '../icons'
interface Props { interface Props {
github: { url: string; version: string; active?: boolean; href?: string } github: { href: string; active?: boolean }
} }
let { github }: Props = $props() let { github }: Props = $props()
</script> </script>
{#if github.active} {#if github.active}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL -->
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer"> <a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" /> <Github class="h-5 w-5" />
</a> </a>
+19 -33
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state' import { page } from '$app/state'
import { base } from '$app/paths' import { resolve } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte' import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte' import LogoButton from '../menu/LogoButton.svelte'
@@ -33,11 +33,11 @@
const github = { href: 'https://github.com/' + page.data.github, active: true } const github = { href: 'https://github.com/' + page.data.github, active: true }
import type { ComponentType } from 'svelte' import type { Component } from 'svelte'
type menuItem = { type menuItem = {
title: string title: string
icon: ComponentType icon: Component
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
@@ -45,13 +45,15 @@
} }
function withBase(path: string) { function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}` return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}`
} }
let menuItems = $state<menuItem[]>([]) const { menuClicked } = $props()
$effect(() => { const activeTitle = $derived(page.data.title)
menuItems = [
const menuItems = $derived<menuItem[]>(
[
{ {
title: 'Connection', title: 'Connection',
icon: WiFi, icon: WiFi,
@@ -79,7 +81,7 @@
title: 'Camera', title: 'Camera',
icon: Camera, icon: Camera,
href: withBase('/peripherals/camera'), href: withBase('/peripherals/camera'),
feature: $features.camera feature: true
}, },
{ {
title: 'Servo', title: 'Servo',
@@ -91,9 +93,9 @@
title: 'IMU', title: 'IMU',
icon: Rotate3d, icon: Rotate3d,
href: withBase('/peripherals/imu'), href: withBase('/peripherals/imu'),
feature: $features.imu || $features.mag || $features.bmp feature: true
} }
] ].map(sub => ({ ...sub, active: sub.title === activeTitle }))
}, },
{ {
title: 'WiFi', title: 'WiFi',
@@ -118,7 +120,7 @@
href: withBase('/wifi/mdns'), href: withBase('/wifi/mdns'),
feature: true feature: true
} }
] ].map(sub => ({ ...sub, active: sub.title === activeTitle }))
}, },
{ {
title: 'System', title: 'System',
@@ -147,36 +149,20 @@
title: 'Firmware Update', title: 'Firmware Update',
icon: Update, icon: Update,
href: withBase('/system/update'), href: withBase('/system/update'),
feature: feature: !!(
$features.ota || $features.ota ||
$features.upload_firmware || $features.upload_firmware ||
$features.download_firmware $features.download_firmware
)
} }
] ].map(sub => ({ ...sub, active: sub.title === activeTitle }))
} }
] as menuItem[] ].map(item => ({ ...item, active: item.title === activeTitle }))
}) )
const { menuClicked } = $props() const updateMenu = () => {
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked() menuClicked()
} }
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: CustomEvent) => {
setActiveMenuItem(event.details)
}
</script> </script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content"> <div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
+3 -3
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import MenuList from './MenuList.svelte' import MenuList from './MenuList.svelte'
import type { ComponentType } from 'svelte' import type { Component } from 'svelte'
type MenuItem = { type MenuItem = {
title: string title: string
icon: ComponentType icon: Component
href?: string href?: string
feature: boolean feature: boolean
active?: boolean active?: boolean
@@ -38,7 +38,7 @@
</div> </div>
</details> </details>
{:else} {:else}
<a <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --><a
href={menuItem.href} href={menuItem.href}
class="font-bold" class="font-bold"
class:bg-base-100={menuItem.active} class:bg-base-100={menuItem.active}
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { mode, modes } from '$lib/stores' import { ModeData, ModesEnum } from '$lib/platform_shared/message'
import { mode } from '$lib/stores'
const deactivate = async () => { const deactivate = async () => {
mode.set(modes.indexOf('deactivated')) mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
} }
</script> </script>
@@ -14,7 +14,7 @@
{...rest} {...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}" class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
> >
{#each options as option} {#each options as option (option)}
<option value={option}>{option}</option> <option value={option}>{option}</option>
{/each} {/each}
</select> </select>
+470
View File
@@ -0,0 +1,470 @@
import { socket } from '$lib/stores/socket'
import * as FSMessages from '$lib/platform_shared/filesystem'
import type {
FSDeleteRequest,
FSMkdirRequest,
FSListRequest,
FSDownloadRequest,
FSDownloadMetadata,
FSDownloadData,
FSDownloadComplete,
FSUploadStart,
FSUploadData,
FSUploadComplete,
FSCancelTransfer
} from '$lib/platform_shared/filesystem'
import type { Result, DataResult, ListResult, ProgressCallback } from '$lib/types/models'
const MAX_CHUNK_SIZE = 2 ** 14
type TimeoutId = ReturnType<typeof setTimeout>
type CleanupFn = (() => void) | null
interface TransferBase<T extends Result> {
resolve: (result: T) => void
reject: (error: Error) => void
onProgress?: ProgressCallback
timeoutId: TimeoutId
}
interface ActiveDownload extends TransferBase<DataResult> {
path: string
buffer: Uint8Array
fileSize: number
totalChunks: number
chunksReceived: number
bytesReceived: number
}
interface ActiveUpload extends TransferBase<Result> {
path: string
transferId: number
totalChunks: number
chunksSent: number
}
export class FileSystemClient {
private activeDownloads = new Map<number, ActiveDownload>()
private activeUploads = new Map<number, ActiveUpload>()
private pendingDownloads = new Map<string, TransferBase<DataResult>>()
private metadataListenerCleanup: CleanupFn = null
private downloadListenerCleanup: CleanupFn = null
private completeListenerCleanup: CleanupFn = null
private uploadCompleteListenerCleanup: CleanupFn = null
private transferTimeout = 60000
constructor() {
this.setupListeners()
}
private setupListeners() {
// Listen for download metadata (sent first with file size)
this.metadataListenerCleanup = socket.on(
FSMessages.FSDownloadMetadata,
(metadata: FSDownloadMetadata) => {
this.handleDownloadMetadata(metadata)
}
)
// Listen for download data chunks
this.downloadListenerCleanup = socket.on(
FSMessages.FSDownloadData,
(data: FSDownloadData) => {
this.handleDownloadData(data)
}
)
// Listen for download completion
this.completeListenerCleanup = socket.on(
FSMessages.FSDownloadComplete,
(complete: FSDownloadComplete) => {
this.handleDownloadComplete(complete)
}
)
// Listen for upload completion
this.uploadCompleteListenerCleanup = socket.on(
FSMessages.FSUploadComplete,
(complete: FSUploadComplete) => {
this.handleUploadComplete(complete)
}
)
}
private handleDownloadMetadata(metadata: FSDownloadMetadata) {
// Find the pending download by path (we don't have transferId yet)
// The metadata arrives in response to a download request
const pending = this.pendingDownloads.values().next().value
if (!pending) {
console.warn(`Received download metadata but no pending download`)
return
}
// Clear initial timeout
clearTimeout(pending.timeoutId)
// Get the path from the pending downloads (first one)
const [path] = this.pendingDownloads.keys()
this.pendingDownloads.delete(path)
if (!metadata.success) {
pending.resolve({ success: false, error: metadata.error || 'Download failed' })
return
}
const transferId = metadata.transferId
// Now we know the exact file size - allocate properly sized buffer
const buffer = new Uint8Array(metadata.fileSize)
const download: ActiveDownload = {
path,
buffer,
fileSize: metadata.fileSize,
totalChunks: metadata.totalChunks,
chunksReceived: 0,
bytesReceived: 0,
resolve: pending.resolve,
reject: pending.reject,
onProgress: pending.onProgress,
timeoutId: setTimeout(() => {
this.activeDownloads.delete(transferId)
pending.reject(new Error('Download timeout'))
}, this.transferTimeout)
}
this.activeDownloads.set(transferId, download)
}
private handleDownloadData(data: FSDownloadData) {
const download = this.activeDownloads.get(data.transferId)
if (!download) {
console.warn(`Received download data for unknown transfer: ${data.transferId}`)
return
}
// Reset timeout
clearTimeout(download.timeoutId)
download.timeoutId = setTimeout(() => {
this.activeDownloads.delete(data.transferId)
download.reject(new Error('Download timeout'))
}, this.transferTimeout)
// Copy chunk data to buffer
if (data.data && data.data.length > 0) {
const offset = data.chunkIndex * MAX_CHUNK_SIZE
download.buffer.set(data.data, offset)
download.bytesReceived += data.data.length
download.chunksReceived++
}
// Report progress
if (download.onProgress) {
download.onProgress({
transferId: data.transferId,
bytesTransferred: download.bytesReceived,
totalBytes: download.fileSize,
chunksCompleted: download.chunksReceived,
totalChunks: download.totalChunks,
percentage: (download.chunksReceived / download.totalChunks) * 100
})
}
}
private handleDownloadComplete(complete: FSDownloadComplete) {
const download = this.activeDownloads.get(complete.transferId)
if (!download) {
// This is normal for error cases where transferId wasn't set
if (complete.error) {
console.warn(`Download failed: ${complete.error}`)
}
return
}
clearTimeout(download.timeoutId)
this.activeDownloads.delete(complete.transferId)
if (complete.success) {
// Trim buffer to actual file size
const finalData = download.buffer.slice(0, complete.fileSize)
download.resolve({ success: true, data: finalData })
} else {
download.resolve({ success: false, error: complete.error || 'Download failed' })
}
}
private handleUploadComplete(complete: FSUploadComplete) {
const upload = this.activeUploads.get(complete.transferId)
if (!upload) {
console.warn(`Received upload complete for unknown transfer: ${complete.transferId}`)
return
}
clearTimeout(upload.timeoutId)
this.activeUploads.delete(complete.transferId)
if (complete.success) {
upload.resolve({ success: true })
} else {
upload.resolve({ success: false, error: complete.error || 'Upload failed' })
}
}
/** Delete a file or directory on the ESP32 */
async deleteFile(path: string): Promise<Result> {
const request: FSDeleteRequest = { path }
const response = await socket.request({
fsDeleteRequest: request
})
if (response.fsDeleteResponse) {
return {
success: response.fsDeleteResponse.success,
error: response.fsDeleteResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** Create a directory on the ESP32 */
async createDirectory(path: string): Promise<Result> {
const request: FSMkdirRequest = { path }
const response = await socket.request({
fsMkdirRequest: request
})
if (response.fsMkdirResponse) {
return {
success: response.fsMkdirResponse.success,
error: response.fsMkdirResponse.error || undefined
}
}
return { success: false, error: 'No response received' }
}
/** List files and directories at the given path */
async listDirectory(path = '/'): Promise<ListResult> {
const request: FSListRequest = { path }
const response = await socket.request({
fsListRequest: request
})
if (response.fsListResponse) {
const resp = response.fsListResponse
return {
success: resp.success,
error: resp.error || undefined,
files: (resp.files || []).map((f) => ({ name: f.name, size: f.size })),
directories: (resp.directories || []).map((d) => ({ name: d.name }))
}
}
return { success: false, error: 'No response received', files: [], directories: [] }
}
/** Download a file from the ESP32 using streaming transfer */
async downloadFile(path: string, onProgress?: ProgressCallback): Promise<DataResult> {
return new Promise((resolve, reject) => {
// Send download request - server will send metadata first, then stream chunks
const request: FSDownloadRequest = { path }
// Set up timeout for initial metadata response
const initialTimeout = setTimeout(() => {
this.pendingDownloads.delete(path)
reject(new Error('Download request timeout - no metadata received'))
}, this.transferTimeout)
// Track this pending download - will be converted to active when metadata arrives
this.pendingDownloads.set(path, {
resolve,
reject,
onProgress,
timeoutId: initialTimeout
})
// Send the download request (server will respond with metadata, then stream data)
socket.request({ fsDownloadRequest: request }).catch((err) => {
clearTimeout(initialTimeout)
this.pendingDownloads.delete(path)
reject(err)
})
})
}
/** Upload a file to the ESP32 using streaming transfer */
async uploadFile(path: string, data: Uint8Array, onProgress?: ProgressCallback): Promise<Result> {
const fileSize = data.length
const chunkSize = MAX_CHUNK_SIZE
const totalChunks = Math.ceil(fileSize / chunkSize) || 1
// Start upload - get transfer ID
const startRequest: FSUploadStart = {
path,
fileSize,
totalChunks
}
const startResponse = await socket.request({
fsUploadStart: startRequest
})
if (!startResponse.fsUploadStartResponse) {
return { success: false, error: 'Failed to start upload' }
}
const startResp = startResponse.fsUploadStartResponse
if (!startResp.success) {
return { success: false, error: startResp.error || 'Failed to start upload' }
}
const transferId = startResp.transferId
return new Promise((resolve, reject) => {
// Set up upload tracking
const upload: ActiveUpload = {
path,
transferId,
totalChunks,
chunksSent: 0,
resolve,
reject,
onProgress,
timeoutId: setTimeout(() => {
this.activeUploads.delete(transferId)
reject(new Error('Upload timeout - no completion received'))
}, this.transferTimeout)
}
this.activeUploads.set(transferId, upload)
// Stream all chunks without waiting for ACKs
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const offset = chunkIndex * chunkSize
const end = Math.min(offset + chunkSize, fileSize)
const chunkData = data.slice(offset, end)
const uploadData: FSUploadData = {
transferId,
chunkIndex,
data: chunkData
}
// Send chunk as fire-and-forget message
socket.emit(FSMessages.FSUploadData, uploadData)
upload.chunksSent++
// Report progress
if (onProgress) {
onProgress({
transferId,
bytesTransferred: end,
totalBytes: fileSize,
chunksCompleted: chunkIndex + 1,
totalChunks,
percentage: ((chunkIndex + 1) / totalChunks) * 100
})
}
}
// All chunks sent - now wait for completion message from server
// The timeout will handle if server doesn't respond
})
}
/** Cancel an ongoing transfer */
async cancelTransfer(transferId: number): Promise<Pick<Result, 'success'>> {
const request: FSCancelTransfer = { transferId }
// Clean up local state
const download = this.activeDownloads.get(transferId)
if (download) {
clearTimeout(download.timeoutId)
this.activeDownloads.delete(transferId)
download.resolve({ success: false, error: 'Transfer cancelled' })
}
const upload = this.activeUploads.get(transferId)
if (upload) {
clearTimeout(upload.timeoutId)
this.activeUploads.delete(transferId)
upload.resolve({ success: false, error: 'Transfer cancelled' })
}
const response = await socket.request({
fsCancelTransfer: request
})
if (response.fsCancelTransferResponse) {
return { success: response.fsCancelTransferResponse.success }
}
return { success: false }
}
/** Upload a File object from browser */
async uploadFileFromBrowser(
destinationPath: string,
file: File,
onProgress?: ProgressCallback
): Promise<Result> {
const arrayBuffer = await file.arrayBuffer()
const data = new Uint8Array(arrayBuffer)
return this.uploadFile(destinationPath, data, onProgress)
}
/** Download a file and save it to browser */
async downloadFileAndSave(
path: string,
filename: string,
onProgress?: ProgressCallback
): Promise<Result> {
const result = await this.downloadFile(path, onProgress)
if (!result.success || !result.data) {
return { success: false, error: result.error }
}
// Create blob and trigger download
const blob = new Blob([result.data.buffer as ArrayBuffer])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
return { success: true }
}
/** Cleanup listeners when no longer needed */
destroy() {
this.metadataListenerCleanup?.()
this.downloadListenerCleanup?.()
this.completeListenerCleanup?.()
this.uploadCompleteListenerCleanup?.()
// Cancel all active transfers
for (const [, download] of this.activeDownloads) {
clearTimeout(download.timeoutId)
download.reject(new Error('FileSystemClient destroyed'))
}
this.activeDownloads.clear()
for (const [, upload] of this.activeUploads) {
clearTimeout(upload.timeoutId)
upload.reject(new Error('FileSystemClient destroyed'))
}
this.activeUploads.clear()
}
}
export const fileSystemClient = new FileSystemClient()
+22 -34
View File
@@ -1,6 +1,7 @@
import { get } from 'svelte/store' import { get } from 'svelte/store'
import type { body_state_t } from './kinematic' import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags' import { currentKinematic } from './stores/featureFlags'
import { ControllerData, WalkGaits } from './platform_shared/message'
export interface gait_state_t { export interface gait_state_t {
step_height: number step_height: number
@@ -11,16 +12,6 @@ export interface gait_state_t {
step_depth: number step_depth: number
} }
export interface ControllerCommand {
lx: number
ly: number
rx: number
ry: number
h: number
s: number
s1: number
}
export abstract class GaitState { export abstract class GaitState {
protected abstract name: string protected abstract name: string
@@ -62,7 +53,7 @@ export abstract class GaitState {
end() { end() {
console.log('Ending', this.name) console.log('Ending', this.name)
} }
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
this.map_command(command) this.map_command(command)
this.body_state = body_state this.body_state = body_state
this.dt = dt / 1000 this.dt = dt / 1000
@@ -79,14 +70,14 @@ export abstract class GaitState {
return body_state return body_state
} }
map_command(command: ControllerCommand) { map_command(command: ControllerData) {
const kin = this.kinematic const kin = this.kinematic
this.gait_state = { this.gait_state = {
step_height: command.s1 * kin.max_step_height, step_height: command.s1 * kin.max_step_height,
step_x: command.ly * kin.max_step_length, step_x: command.left!.y * kin.max_step_length,
step_z: -command.lx * kin.max_step_length, step_z: -command.left!.x * kin.max_step_length,
step_velocity: command.s, step_velocity: command.speed,
step_angle: command.rx, step_angle: command.right!.x,
step_depth: kin.default_step_depth step_depth: kin.default_step_depth
} }
} }
@@ -94,8 +85,7 @@ export abstract class GaitState {
export class IdleState extends GaitState { export class IdleState extends GaitState {
protected name = 'Idle' protected name = 'Idle'
step(body_state: body_state_t, command: ControllerData) {
step(body_state: body_state_t, command: ControllerCommand) {
super.step(body_state, command) super.step(body_state, command)
return body_state return body_state
} }
@@ -104,7 +94,7 @@ export class IdleState extends GaitState {
export class CalibrationState extends GaitState { export class CalibrationState extends GaitState {
protected name = 'Calibration' protected name = 'Calibration'
step(body_state: body_state_t, _command: ControllerCommand) { step(body_state: body_state_t, _command: ControllerData) {
super.step(body_state, _command) super.step(body_state, _command)
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
@@ -120,7 +110,7 @@ export class CalibrationState extends GaitState {
export class RestState extends GaitState { export class RestState extends GaitState {
protected name = 'Rest' protected name = 'Rest'
step(body_state: body_state_t, _command: ControllerCommand) { step(body_state: body_state_t, _command: ControllerData) {
super.step(body_state, _command) super.step(body_state, _command)
body_state.omega = 0 body_state.omega = 0
body_state.phi = 0 body_state.phi = 0
@@ -136,15 +126,15 @@ export class RestState extends GaitState {
export class StandState extends GaitState { export class StandState extends GaitState {
protected name = 'Stand' protected name = 'Stand'
step(body_state: body_state_t, command: ControllerCommand) { step(body_state: body_state_t, command: ControllerData) {
super.step(body_state, command) super.step(body_state, command)
const kin = this.kinematic const kin = this.kinematic
body_state.omega = 0 body_state.omega = 0
body_state.ym = kin.min_body_height + command.h * kin.body_height_range body_state.ym = kin.min_body_height + command.height * kin.body_height_range
body_state.psi = command.ry * kin.max_pitch body_state.psi = command.right!.y * kin.max_pitch
body_state.phi = command.rx * kin.max_roll body_state.phi = command.right!.x * kin.max_roll
body_state.xm = command.ly * kin.max_body_shift_x body_state.xm = command.left!.y * kin.max_body_shift_x
body_state.zm = command.lx * kin.max_body_shift_z body_state.zm = command.left!.x * kin.max_body_shift_z
body_state.feet = this.default_feet_pos body_state.feet = this.default_feet_pos
return body_state return body_state
} }
@@ -156,7 +146,7 @@ export class BezierState extends GaitState {
protected phase_num = 0 protected phase_num = 0
protected step_length = 0 protected step_length = 0
protected stand_offset = 0.75 protected stand_offset = 0.75
protected mode: 'crawl' | 'trot' = 'trot' protected mode: WalkGaits = WalkGaits.TROT
protected speed_factor = 1 protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25] offset = [0, 0.5, 0.75, 0.25]
@@ -178,11 +168,9 @@ export class BezierState extends GaitState {
super.begin() super.begin()
} }
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) { set_mode(mode: WalkGaits, duty?: number, order?: [number, number, number, number]) {
console.log('BezierState set_mode', mode)
this.mode = mode this.mode = mode
if (mode === 'crawl') { if (mode === WalkGaits.CRAWL) {
this.speed_factor = 0.5 this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85 this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1] const o = order ?? [3, 0, 2, 1]
@@ -201,10 +189,10 @@ export class BezierState extends GaitState {
super.end() super.end()
} }
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) { step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
super.step(body_state, command, dt) super.step(body_state, command, dt)
const kin = this.kinematic const kin = this.kinematic
this.body_state.ym = kin.min_body_height + command.h * kin.body_height_range this.body_state.ym = kin.min_body_height + command.height * kin.body_height_range
this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2) this.step_length = Math.sqrt(this.gait_state.step_x ** 2 + this.gait_state.step_z ** 2)
if (this.gait_state.step_x < 0) this.step_length = -this.step_length if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase() this.update_phase()
@@ -232,7 +220,7 @@ export class BezierState extends GaitState {
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0 const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return if (!moving) return
if (this.mode !== 'crawl') return if (this.mode !== WalkGaits.CRAWL) return
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states() const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
+23 -56
View File
@@ -1,67 +1,34 @@
import { type Analytics } from '$lib/types/models' import { AnalyticsData } from '$lib/platform_shared/message'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { socket } from './socket'
const analytics_data = {
uptime: <number[]>[],
free_heap: <number[]>[],
total_heap: <number[]>[],
used_heap: <number[]>[],
min_free_heap: <number[]>[],
max_alloc_heap: <number[]>[],
fs_used: <number[]>[],
fs_total: <number[]>[],
core_temp: <number[]>[],
cpu0_usage: <number[]>[],
cpu1_usage: <number[]>[],
cpu_usage: <number[]>[]
}
const maxAnalyticsData = 100 const maxAnalyticsData = 100
function createAnalytics() { function createAnalytics() {
const { subscribe, update } = writable(analytics_data) const { subscribe, update } = writable<AnalyticsData[]>([])
let unsubscribe: (() => void) | null = null
let listenerCount = 0
const addData = (content: AnalyticsData) => {
update(data => [...data, content].slice(-maxAnalyticsData))
}
return { return {
subscribe, subscribe,
addData: (content: Analytics) => { addData,
update(analytics_data => ({ listen: () => {
...analytics_data, listenerCount++
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData), if (!unsubscribe) {
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice( unsubscribe = socket.on(AnalyticsData, addData)
-maxAnalyticsData }
), },
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice( stop: () => {
-maxAnalyticsData listenerCount = Math.max(0, listenerCount - 1)
), if (listenerCount === 0 && unsubscribe) {
used_heap: [ unsubscribe()
...analytics_data.used_heap, unsubscribe = null
(content.total_heap - content.free_heap) / 1000 }
].slice(-maxAnalyticsData),
min_free_heap: [
...analytics_data.min_free_heap,
content.min_free_heap / 1000
].slice(-maxAnalyticsData),
max_alloc_heap: [
...analytics_data.max_alloc_heap,
content.max_alloc_heap / 1000
].slice(-maxAnalyticsData),
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(
-maxAnalyticsData
),
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(
-maxAnalyticsData
),
core_temp: [...analytics_data.core_temp, content.core_temp].slice(
-maxAnalyticsData
),
cpu0_usage: [...analytics_data.cpu0_usage, content.cpu0_usage].slice(
-maxAnalyticsData
),
cpu1_usage: [...analytics_data.cpu1_usage, content.cpu1_usage].slice(
-maxAnalyticsData
),
cpu_usage: [...analytics_data.cpu_usage, content.cpu_usage].slice(-maxAnalyticsData)
}))
} }
} }
} }
+15 -7
View File
@@ -1,9 +1,9 @@
import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import Kinematic from '$lib/kinematic' import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities' import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store' import { derived, type Writable } from 'svelte/store'
import { resolve } from '$app/paths' import { resolve } from '$app/paths'
import { socket } from '$lib/stores'
let featureFlagsStore: Writable<Record<string, boolean | string>> let featureFlagsStore: Writable<Record<string, boolean | string>>
@@ -11,12 +11,20 @@ export function useFeatureFlags() {
if (!featureFlagsStore) { if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {}) featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
api.get<Record<string, boolean>>('/api/features').then(result => { socket
if (result.isOk()) featureFlagsStore.set(result.inner) .request({ featuresDataRequest: {} })
else { .then(response => {
notifications.error('Feature flag could not be fetched', 2500) if (response.featuresDataResponse) {
} featureFlagsStore.set(
}) response.featuresDataResponse as unknown as Record<string, boolean | string>
)
} else {
notifications.error('Feature flags could not be fetched', 2500)
}
})
.catch(() => {
notifications.error('Feature flags could not be fetched', 2500)
})
} }
return featureFlagsStore return featureFlagsStore
+2 -1
View File
@@ -4,7 +4,8 @@ export const isFullscreen = writable(false)
export function toggleFullscreen() { export function toggleFullscreen() {
isFullscreen.update(state => { isFullscreen.update(state => {
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen() if (!state) document.documentElement.requestFullscreen()
else document.exitFullscreen()
return !state return !state
}) })
} }
+24 -30
View File
@@ -1,40 +1,34 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import type { IMUMsg } from '$lib/types/models' import { IMUData } from '$lib/platform_shared/message'
import { socket } from './socket'
const maxIMUData = 100 const maxIMUData = 100
export const imu = (() => { export const imu = (() => {
const { subscribe, update } = writable({ const { subscribe, update } = writable<IMUData[]>([])
x: [] as number[],
y: [] as number[],
z: [] as number[],
heading: [] as number[],
altitude: [] as number[],
pressure: [] as number[],
bmp_temp: [] as number[]
})
const addData = (content: IMUMsg) => { let unsubscribe: (() => void) | null = null
update(data => { let listenerCount = 0
if (content.imu && content.imu[4]) {
data.x = [...data.x, content.imu[0]].slice(-maxIMUData)
data.y = [...data.y, content.imu[1]].slice(-maxIMUData)
data.z = [...data.z, content.imu[2]].slice(-maxIMUData)
}
if (content.mag && content.mag[4]) { const addData = (content: IMUData) => {
data.heading = [...data.heading, content.mag[3]].slice(-maxIMUData) update(data => [...data, content].slice(-maxIMUData))
}
if (content.bmp && content.bmp[3]) {
data.pressure = [...data.pressure, content.bmp[0]].slice(-maxIMUData)
data.altitude = [...data.altitude, content.bmp[1]].slice(-maxIMUData)
data.bmp_temp = [...data.bmp_temp, content.bmp[2]].slice(-maxIMUData)
}
return data
})
} }
return { subscribe, addData } return {
subscribe,
addData,
listen: () => {
listenerCount++
if (!unsubscribe) {
unsubscribe = socket.on(IMUData, addData)
}
},
stop: () => {
listenerCount = Math.max(0, listenerCount - 1)
if (listenerCount === 0 && unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
}
})() })()
+42 -40
View File
@@ -1,4 +1,12 @@
import type { ControllerInput } from '$lib/types/models' import Kinematic from '$lib/kinematic'
import {
ControllerData,
KinematicData,
ModeData,
ModesEnum,
WalkGaitData,
WalkGaits
} from '$lib/platform_shared/message'
import { persistentStore } from '$lib/utilities/svelte-utilities' import { persistentStore } from '$lib/utilities/svelte-utilities'
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store'
@@ -8,47 +16,41 @@ export const jointNames = persistentStore('joint_names', <string[]>[])
export const model = writable() export const model = writable()
export const modes = ['deactivated', 'idle', 'calibration', 'rest', 'stand', 'walk'] as const export const mode: Writable<ModeData> = writable(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
export type Modes = (typeof modes)[number] export const walkGait: Writable<WalkGaitData> = writable(
WalkGaitData.create({ gait: WalkGaits.TROT })
)
export enum ModesEnum { export const kinematicData = writable(KinematicData.create())
Deactivated = 0,
Idle = 1, export const input: Writable<ControllerData> = writable(
Calibration = 2, ControllerData.create({
Rest = 3, left: { x: 0, y: 0 },
Stand = 4, right: { x: 0, y: 0 },
Walk = 5 height: 0.7,
s1: 0.5,
speed: 0.5
})
)
function enumToValuesAndLabels<T extends number>(enumObj: Record<string, T | string>) {
const entries = Object.entries(enumObj).filter(
([key, v]) => typeof v === 'number' && key !== 'UNRECOGNIZED'
) as [string, T][]
return {
values: entries.map(([, v]) => v),
labels: Object.fromEntries(
entries.map(([k, v]) => [v, k.charAt(0) + k.slice(1).toLowerCase()])
) as Record<T, string>
}
} }
export enum WalkGaits { const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum)
Trot = 0, export const modes = modesData.values
Crawl = 1 export const modeLabels = modesData.labels
}
export const walkGaits = ['trot', 'crawl'] as const const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits)
export const walkGaits = walkGaitsData.values
export const walkGaitLabels: Record<WalkGaits, string> = { export const walkGaitLabels = walkGaitsData.labels
[WalkGaits.Trot]: 'Trot',
[WalkGaits.Crawl]: 'Crawl'
}
export const walkGaitToMode = (gait: WalkGaits): 'trot' | 'crawl' => {
return gait === WalkGaits.Trot ? 'trot' : 'crawl'
}
export const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated)
export const walkGait: Writable<WalkGaits> = writable(WalkGaits.Trot)
export const outControllerData = writable([0, 0, 0, 0, 0, 1, 0])
export const kinematicData = writable([0, 0, 0, 0, 1, 0])
export const input: Writable<ControllerInput> = writable({
left: { x: 0, y: 0 },
right: { x: 0, y: 0 },
height: 0.5,
speed: 0.5,
s1: 0.5
})
+8 -23
View File
@@ -1,27 +1,12 @@
import { AnglesData } from '$lib/platform_shared/message'
import { writable, type Writable } from 'svelte/store' import { writable, type Writable } from 'svelte/store'
import { type angles } from '$lib/types/models'
export const servoAnglesOut: Writable<number[]> = writable([ export const servoAnglesOut: Writable<AnglesData> = writable(
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90 AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] })
]) )
export const servoAngles: Writable<number[]> = writable([ export const servoAngles: Writable<AnglesData> = writable(
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90 AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] })
]) )
export const logs = writable([] as string[])
export const mpu = writable({ heading: 0 }) export const mpu = writable({ heading: 0 })
export const sonar = writable([0, 0]) export const sonar = writable([0, 0])
export const distances = writable({})
export interface socketDataCollection {
angles: Writable<angles>
logs: Writable<string[]>
mpu: Writable<unknown>
distances: Writable<unknown>
}
export const socketData = {
angles: servoAngles,
logs,
mpu,
distances
}
+230 -79
View File
@@ -1,44 +1,121 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { encode, decode } from '@msgpack/msgpack' import {
Message,
CorrelationRequest,
CorrelationResponse,
protoMetadata,
type MessageFns
} from '$lib/platform_shared/message'
import * as Messages from '$lib/platform_shared/message'
import { protoMetadata as filesystemProtoMetadata } from '$lib/platform_shared/filesystem'
const socketEvents = ['open', 'close', 'error', 'message', 'unresponsive'] as const export const MESSAGE_TYPE_TO_KEY = new Map<MessageFns<unknown>, string>()
type SocketEvent = (typeof socketEvents)[number] export const MESSAGE_TYPE_TO_TAG = new Map<MessageFns<unknown>, number>()
export const MESSAGE_KEY_TO_TAG = new Map<string, number>()
export const MESSAGE_TAG_TO_KEY = new Map<number, string>()
type SocketMessage = [number, string?, unknown?] type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'>
type PendingRequest = {
let useBinary = false resolve: (response: CorrelationResponse) => void
reject: (error: Error) => void
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => { timeoutId: ReturnType<typeof setTimeout>
useBinary = data instanceof ArrayBuffer
try {
if (useBinary) {
return decode(new Uint8Array(data as ArrayBuffer)) as SocketMessage
}
return JSON.parse(data as string)
} catch (error) {
console.error(`Could not decode data: ${new Uint8Array(data as ArrayBuffer)} - ${error}`)
}
return null
} }
const encodeMessage = (data: unknown) => { // Combine references from both message.proto and filesystem.proto
try { const combinedReferences: Record<string, MessageFns<unknown>> = {
return useBinary ? encode(data) : JSON.stringify(data) ...protoMetadata.references,
} catch (error) { ...filesystemProtoMetadata.references
console.error(`Could not encode data: ${data} - ${error}`) }
const MessageType = protoMetadata.fileDescriptor.messageType?.find(
(msg: { name: string }) => msg.name === 'Message'
)
if (MessageType?.field) {
for (const field of MessageType.field) {
if (field.typeName) {
const messageFns = combinedReferences[field.typeName]
if (messageFns && field.jsonName && field.number) {
MESSAGE_TYPE_TO_KEY.set(messageFns, field.jsonName)
MESSAGE_TYPE_TO_TAG.set(messageFns, field.number)
MESSAGE_KEY_TO_TAG.set(field.jsonName, field.number)
MESSAGE_TAG_TO_KEY.set(field.number, field.jsonName)
}
}
} }
} }
function getNameFromMessageType<T>(event_type: MessageFns<T>): string {
const event = MESSAGE_TYPE_TO_KEY.get(event_type as MessageFns<unknown>)
if (!event) {
throw new Error(
"Event type not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
)
}
return event
}
function getTagFromMessageType<T>(event_type: MessageFns<T>): number {
const fieldNumber = MESSAGE_TYPE_TO_TAG.get(event_type as MessageFns<unknown>)
if (fieldNumber === undefined) {
throw new Error(
"Tag not found in 'Message'. The MessageFns you passed doesn't correspond to any Message field."
)
}
return fieldNumber
}
type SocketEvent = 'open' | 'close' | 'error' | 'message' | 'unresponsive'
type TaggedMessage = { tag: number; msg: Message }
export const decodeMessage = (data: ArrayBuffer): TaggedMessage => {
const decoded = Message.decode(new Uint8Array(data))
const values = Object.entries(decoded).filter(([, value]) => value !== undefined)
if (values.length != 1) {
throw new Error('Message included either 0 or more than 1 data point')
}
const fieldName = values[0][0]
const tag = MESSAGE_KEY_TO_TAG.get(fieldName)
if (tag === undefined) {
throw new Error(`Tag not found for field: ${fieldName}`)
}
return { tag: tag, msg: decoded }
}
export const encodeMessage = (data: Message): Uint8Array<ArrayBuffer> => {
const encoded = Message.encode(data).finish()
return encoded
}
function createWebSocket() { function createWebSocket() {
const listeners = new Map<string, Set<(data?: unknown) => void>>() const message_listeners = new Map<number, Set<(data?: unknown) => void>>()
const event_listeners = new Map<string, Set<(data?: unknown) => void>>()
const pending_requests = new Map<number, PendingRequest>()
const queued_requests = new Map<
string,
{
data: CorrelationRequestData
resolve: (r: CorrelationResponse) => void
reject: (e: Error) => void
}
>()
const { subscribe, set } = writable(false) const { subscribe, set } = writable(false)
const reconnectTimeoutTime = 5000 const reconnectTimeoutTime = 500000
const requestTimeoutTime = 30000
let correlationIdCounter = 0
let unresponsiveTimeoutId: ReturnType<typeof setTimeout> let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
let reconnectTimeoutId: ReturnType<typeof setTimeout> let reconnectTimeoutId: ReturnType<typeof setTimeout>
let ws: WebSocket let ws: WebSocket
let socketUrl: string | URL let socketUrl: string | URL
function getRequestKey(data: CorrelationRequestData): string {
return (
Object.keys(data).find(k => data[k as keyof CorrelationRequestData] !== undefined) ??
'unknown'
)
}
function init(url: string | URL) { function init(url: string | URL) {
socketUrl = url socketUrl = url
connect() connect()
@@ -49,7 +126,7 @@ function createWebSocket() {
set(false) set(false)
clearTimeout(unresponsiveTimeoutId) clearTimeout(unresponsiveTimeoutId)
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
listeners.get(reason)?.forEach(listener => listener(event)) event_listeners.get(reason)?.forEach(listener => listener(event))
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime) reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
} }
@@ -57,102 +134,176 @@ function createWebSocket() {
ws = new WebSocket(socketUrl) ws = new WebSocket(socketUrl)
ws.binaryType = 'arraybuffer' ws.binaryType = 'arraybuffer'
ws.onopen = ev => { ws.onopen = ev => {
ping()
useBinary = true
ping() ping()
set(true) set(true)
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
listeners.get('open')?.forEach(listener => listener(ev)) resubscribeAll()
for (const event of listeners.keys()) { flushQueuedRequests()
if (socketEvents.includes(event as SocketEvent)) continue event_listeners.get('open')?.forEach(listener => listener(ev))
subscribeToEvent(event)
}
} }
ws.onmessage = frame => { ws.onmessage = frame => {
resetUnresponsiveCheck() resetUnresponsiveCheck()
const message = decodeMessage(frame.data)
if (!message) return for (const [correlationId, pending] of pending_requests) {
const [, event, payload = undefined] = message clearTimeout(pending.timeoutId)
if (event) listeners.get(event)?.forEach(listener => listener(payload)) pending.timeoutId = setTimeout(() => {
pending_requests.delete(correlationId)
pending.reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
}
const { tag, msg } = decodeMessage(frame.data)
if (msg.correlationResponse) {
const pending = pending_requests.get(msg.correlationResponse.correlationId)
if (pending) {
clearTimeout(pending.timeoutId)
pending_requests.delete(msg.correlationResponse.correlationId)
pending.resolve(msg.correlationResponse)
}
return
}
if (tag) {
const key = MESSAGE_TAG_TO_KEY.get(tag)!
message_listeners
.get(tag)
?.forEach(listener => listener(msg[key as keyof typeof msg]))
}
} }
ws.onerror = ev => disconnect('error', ev) ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev) ws.onclose = ev => disconnect('close', ev)
} }
function unsubscribe(event: string, listener?: (data: unknown) => void) { function unsubscribe<MT>(event_type: MessageFns<MT>, listener: (data: MT) => void) {
const eventListeners = listeners.get(event) const tag = getTagFromMessageType(event_type)
if (!eventListeners) return const message_listeners_totag = message_listeners.get(tag)
if (!message_listeners_totag) return
if (!eventListeners.size) { message_listeners_totag?.delete(listener as (data?: unknown) => void)
unsubscribeToEvent(event) if (message_listeners_totag.size == 0) {
} unsubscribeToMessageFromServer(event_type)
if (listener) {
eventListeners?.delete(listener)
} else {
listeners.delete(event)
} }
} }
function unsubscribeEvent(event_type: SocketEvent, listener: (data: unknown) => void) {
const message_listeners_totag = event_listeners.get(event_type)
if (!message_listeners_totag) return
message_listeners_totag?.delete(listener)
}
function resetUnresponsiveCheck() { function resetUnresponsiveCheck() {
clearTimeout(unresponsiveTimeoutId) clearTimeout(unresponsiveTimeoutId)
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime) unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
} }
function sendEvent(event: string, data: unknown) { function emit<T>(event: MessageFns<T>, data: T) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
send([2, event, data]) const type = getNameFromMessageType(event)
const wsm = Message.create() as Record<string, unknown>
wsm[type] = data
send(wsm as Message)
} }
function unsubscribeToEvent(event: string) { function unsubscribeToMessageFromServer<T>(event_type: MessageFns<T>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
send([1, event]) const unsub_msg = Messages.UnsubscribeNotification.create({
tag: getTagFromMessageType(event_type)
})
send(Message.create({ unsubNotif: unsub_msg }))
} }
function subscribeToEvent(event: string) { function subscribeToEvent<T>(event_type: MessageFns<T>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return if (!ws || ws.readyState !== WebSocket.OPEN) return
send([0, event]) const sub_msg = Messages.SubscribeNotification.create({
tag: getTagFromMessageType(event_type)
})
send(Message.create({ subNotif: sub_msg }))
} }
function send(data: unknown) { function resubscribeAll() {
if (!ws || ws.readyState !== WebSocket.OPEN) return for (const tag of message_listeners.keys()) {
const serialized = encodeMessage(data) const sub_msg = Messages.SubscribeNotification.create({ tag })
if (!serialized) { send(Message.create({ subNotif: sub_msg }))
console.error('Could not serialize data:', data)
return
} }
ws.send(serialized) }
function send(data: Message) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const encoded = encodeMessage(data)
ws.send(encoded)
} }
function ping() { function ping() {
const serialized = encodeMessage([4]) send(Message.create({ pingmsg: {} }))
if (!serialized) { }
console.error('Could not serialize message')
return function request(
data: CorrelationRequestData,
resolve: (r: CorrelationResponse) => void,
reject: (e: Error) => void
) {
const correlationId = ++correlationIdCounter
const timeoutId = setTimeout(() => {
pending_requests.delete(correlationId)
reject(new Error(`Request timeout (id: ${correlationId})`))
}, requestTimeoutTime)
pending_requests.set(correlationId, { resolve, reject, timeoutId })
const request = CorrelationRequest.create({ correlationId, ...data })
send(Message.create({ correlationRequest: request }))
}
function flushQueuedRequests() {
for (const [, { data, resolve, reject }] of queued_requests) {
request(data, resolve, reject)
} }
ws.send(serialized) queued_requests.clear()
} }
return { return {
subscribe, subscribe,
sendEvent, emit,
init, init,
on: <T>(event: string, listener: (data: T) => void): (() => void) => { on: <MT>(event_type: MessageFns<MT>, listener: (data: MT) => void): (() => void) => {
let eventListeners = listeners.get(event) const tag = getTagFromMessageType(event_type)
if (!eventListeners) {
if (!socketEvents.includes(event as SocketEvent)) { let message_listeners_totag = message_listeners.get(tag)
subscribeToEvent(event) if (!message_listeners_totag) {
} message_listeners_totag = new Set()
eventListeners = new Set() message_listeners.set(tag, message_listeners_totag)
listeners.set(event, eventListeners) subscribeToEvent(event_type)
} }
eventListeners.add(listener as (data: unknown) => void) message_listeners_totag.add(listener as (data: unknown) => void)
return () => { return () => {
unsubscribe(event, listener as (data: unknown) => void) unsubscribe(event_type, listener)
} }
}, },
off: <T>(event: string, listener?: (data: T) => void) => { onEvent: (event_type: SocketEvent, listener: (data: unknown) => void): (() => void) => {
unsubscribe(event, listener as (data: unknown) => void) let listeners = event_listeners.get(event_type)
if (!listeners) {
listeners = new Set()
event_listeners.set(event_type, listeners)
}
listeners.add(listener)
return () => {
unsubscribeEvent(event_type, listener)
}
},
request: (data: CorrelationRequestData): Promise<CorrelationResponse> => {
return new Promise((resolve, reject) => {
if (ws && ws.readyState === WebSocket.OPEN) {
request(data, resolve, reject)
} else {
const key = getRequestKey(data)
const existing = queued_requests.get(key)
if (existing) {
existing.reject(new Error('Request superseded by newer request'))
}
queued_requests.set(key, { data, resolve, reject })
}
})
} }
} }
} }
+18 -20
View File
@@ -1,33 +1,31 @@
import type { DownloadOTA } from '$lib/types/models' import { DownloadOTAData, RSSIData } from '$lib/platform_shared/message'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
const telemetry_data = { type telemetry_data_type = {
rssi: { rssi: RSSIData
rssi: 0 download_ota: DownloadOTAData
},
download_ota: {
status: 'none',
progress: 0,
error: ''
}
} }
const telemetry_data: telemetry_data_type = {
rssi: RSSIData.create(),
download_ota: DownloadOTAData.create()
} // Note: perhaps init these as null instead of an undefined create()
function createTelemetry() { function createTelemetry() {
const { subscribe, update } = writable(telemetry_data) const { subscribe, update } = writable(telemetry_data)
return { return {
subscribe, subscribe,
setRSSI: (data: number) => { setRSSI: (data: RSSIData) => {
update(telemetry_data => ({ update(telemetry_data => {
...telemetry_data, telemetry_data.rssi = data
rssi: { rssi: data } return telemetry_data
})) })
}, },
setDownloadOTA: (data: DownloadOTA) => { setDownloadOTA: (data: DownloadOTAData) => {
update(telemetry_data => ({ update(telemetry_data => {
...telemetry_data, telemetry_data.download_ota = data
download_ota: { status: data.status, progress: data.progress, error: data.error } return telemetry_data
})) })
} }
} }
} }
+24 -191
View File
@@ -19,14 +19,6 @@ export enum MessageTopic {
export type vector = { x: number; y: number } export type vector = { x: number; y: number }
export interface ControllerInput {
left: vector
right: vector
height: number
speed: number
s1: number
}
export type GithubRelease = { export type GithubRelease = {
message: string message: string
tag_name: string tag_name: string
@@ -36,175 +28,11 @@ export type GithubRelease = {
}> }>
} }
export type angles = number[] | Int16Array
export type WifiStatus = {
status: number
local_ip: string
mac_address: string
rssi: number
ssid: string
bssid: string
channel: number
subnet_mask: string
gateway_ip: string
dns_ip_1: string
dns_ip_2?: string
}
export type WifiSettings = {
hostname: string
priority_RSSI: boolean
wifi_networks: KnownNetworkItem[]
}
export type NetworkList = {
networks: NetworkItem[]
}
export type KnownNetworkItem = {
ssid: string
password: string
static_ip_config: boolean
local_ip?: string
subnet_mask?: string
gateway_ip?: string
dns_ip_1?: string
dns_ip_2?: string
}
export type NetworkItem = {
rssi: number
ssid: string
bssid: string
channel: number
encryption_type: number
}
export type ApStatus = {
status: number
ip_address: string
mac_address: string
station_num: number
}
export type ApSettings = {
provision_mode: number
ssid: string
password: string
channel: number
ssid_hidden: boolean
max_clients: number
local_ip: string
gateway_ip: string
subnet_mask: string
}
export type DownloadOTA = {
status: string
progress: number
error: string
}
export type Analytics = {
max_alloc_heap: number
psram_size: number
free_psram: number
free_heap: number
total_heap: number
min_free_heap: number
core_temp: number
fs_total: number
fs_used: number
uptime: number
cpu0_usage: number
cpu1_usage: number
cpu_usage: number
}
export type Rssi = { export type Rssi = {
rssi: number rssi: number
ssid: string ssid: string
} }
export type StaticSystemInformation = {
esp_platform: string
firmware_version: string
cpu_freq_mhz: number
cpu_type: string
cpu_rev: number
cpu_cores: number
sketch_size: number
free_sketch_space: number
sdk_version: string
arduino_version: string
flash_chip_size: number
flash_chip_speed: number
cpu_reset_reason: string
}
export type SystemInformation = Analytics & StaticSystemInformation
export type IMU = {
x: number
y: number
z: number
heading: number
altitude: number
bmp_temp: number
pressure: number
}
export type IMUMsg = {
imu: [number, number, number, number, boolean]
mag: [number, number, number, number, boolean]
bmp: [number, number, number, boolean]
}
export type IMUCalibrationResult = {
success: boolean
}
export interface I2CDevice {
address: number
part_number: string
name: string
}
export type PinConfig = {
pin: number
mode: string
type: string
role: string
}
export type PeripheralsConfiguration = {
sda: number
scl: number
frequency: number
pins: PinConfig[]
}
export type CameraSettings = {
framesize: number
quality: number
brightness: number
contrast: number
saturation: number
sharpness: number
denoise: number
special_effect: number
wb_mode: number
vflip: boolean
hmirror: boolean
}
export type File = number
export interface Directory {
[key: string]: File | Directory
}
export type Servo = { export type Servo = {
name: string name: string
channel: number channel: number
@@ -220,31 +48,36 @@ export type ServoConfiguration = {
servos: Servo[] servos: Servo[]
} }
export interface MDNSServiceQuery { export interface Result {
services: MDNSServiceItem[] success: boolean
error?: string
} }
export interface MDNSServiceItem { export interface DataResult extends Result {
ip: string data?: Uint8Array
port: number }
export interface FileInfo {
name: string
size: number
}
export interface DirectoryInfo {
name: string name: string
} }
export interface MDNSService { export interface ListResult extends Result {
service: string files: FileInfo[]
protocol: string directories: DirectoryInfo[]
port: number
} }
export interface MDNSTxtRecord { export interface TransferProgress {
key: string transferId: number
value: string bytesTransferred: number
totalBytes: number
chunksCompleted: number
totalChunks: number
percentage: number
} }
export interface MDNSStatus { export type ProgressCallback = (progress: TransferProgress) => void
started: boolean
hostname: string
instance: string
services: MDNSService[]
global_txt_records: MDNSTxtRecord[]
}
+1 -1
View File
@@ -1,4 +1,4 @@
export class throttler { export class Throttler {
private _throttlePause: boolean private _throttlePause: boolean
constructor() { constructor() {
this._throttlePause = false this._throttlePause = false
+1
View File
@@ -6,3 +6,4 @@ export * from './buffer-utilities'
export * from './model-utilities' export * from './model-utilities'
export * from './string-utilities' export * from './string-utilities'
export * from './color-utilities' export * from './color-utilities'
export * from './ip-utilities'
+23
View File
@@ -0,0 +1,23 @@
export function ipToUint32(ip: string): number {
const parts = ip.split('.')
if (parts.length !== 4) return 0
return (
(parseInt(parts[0], 10) |
(parseInt(parts[1], 10) << 8) |
(parseInt(parts[2], 10) << 16) |
(parseInt(parts[3], 10) << 24)) >>>
0
)
}
export function uint32ToIp(ip: number): string {
return [ip & 0xff, (ip >>> 8) & 0xff, (ip >>> 16) & 0xff, (ip >>> 24) & 0xff].join('.')
}
export function isValidIpString(ip: string | undefined): boolean {
if (!ip) return false
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
return regexExp.test(ip)
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const cacheModelFiles = async () => {
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) { for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const normalizedPath = path.startsWith('/') ? path : '/' + path const normalizedPath = path.startsWith('/') ? path : '/' + path
const resolvedUrl = resolve(normalizedPath as any) const resolvedUrl = `${resolve('/')}${normalizedPath}`
fileService?.saveFile(resolvedUrl, data) fileService?.saveFile(resolvedUrl, data)
fileService?.saveFile(normalizedPath, data) fileService?.saveFile(normalizedPath, data)
} }
+39 -36
View File
@@ -10,11 +10,9 @@
import Statusbar from '../lib/components/statusbar/statusbar.svelte' import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import { import {
telemetry, telemetry,
analytics,
ModesEnum,
kinematicData, kinematicData,
mode, mode,
outControllerData, input,
servoAngles, servoAngles,
servoAnglesOut, servoAnglesOut,
socket, socket,
@@ -22,8 +20,17 @@
useFeatureFlags, useFeatureFlags,
walkGait walkGait
} from '$lib/stores' } from '$lib/stores'
import { type Analytics, type DownloadOTA } from '$lib/types/models' import {
import { MessageTopic } from '$lib/types/models' AnglesData,
DownloadOTAData,
ControllerData,
KinematicData,
ModeData,
RSSIData,
SonarData,
WalkGaitData
} from '$lib/platform_shared/message'
import { Throttler } from '$lib/utilities'
interface Props { interface Props {
children?: import('svelte').Snippet children?: import('svelte').Snippet
@@ -32,6 +39,7 @@
let { children }: Props = $props() let { children }: Props = $props()
const features = useFeatureFlags() const features = useFeatureFlags()
const throttler = new Throttler()
onMount(async () => { onMount(async () => {
const ws = $apiLocation ? $apiLocation : window.location.host const ws = $apiLocation ? $apiLocation : window.location.host
@@ -39,58 +47,53 @@
addEventListeners() addEventListeners()
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data)) input.subscribe(data => throttler.throttle(() => socket.emit(ControllerData, data), 100))
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data)) mode.subscribe(data => socket.emit(ModeData, data))
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data)) walkGait.subscribe(data => socket.emit(WalkGaitData, data))
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data)) servoAnglesOut.subscribe(data =>
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data)) throttler.throttle(() => socket.emit(AnglesData, data), 100)
)
kinematicData.subscribe(data => socket.emit(KinematicData, data))
}) })
onDestroy(() => { onDestroy(() => {
removeEventListeners() removeEventListeners()
}) })
const eventListeners: (() => void)[] = []
const addEventListeners = () => { const addEventListeners = () => {
socket.on('open', handleOpen) eventListeners.push(
socket.on('close', handleClose) socket.onEvent('open', handleOpen),
socket.on('error', handleError) socket.onEvent('close', handleClose),
socket.on(MessageTopic.rssi, handleNetworkStatus) socket.onEvent('error', handleError),
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data)) socket.on(RSSIData, data => telemetry.setRSSI(data)),
socket.on(MessageTopic.analytics, handleAnalytics) socket.on(ModeData, data => mode.set(data)),
socket.on(MessageTopic.angles, (angles: number[]) => { socket.on(AnglesData, data => {
if (angles.length) servoAngles.set(angles) servoAngles.set(data)
}) })
)
features.subscribe(data => { features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT) if (data?.download_firmware)
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data)) eventListeners.push(
socket.on(DownloadOTAData, data => telemetry.setDownloadOTA(data))
)
if (data?.sonar) eventListeners.push(socket.on(SonarData, data => console.log(data)))
}) })
} }
const removeEventListeners = () => { const removeEventListeners = () => {
socket.off(MessageTopic.analytics, handleAnalytics) eventListeners.forEach(offFunction => offFunction())
socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
} }
const handleOpen = () => { const handleOpen = () => notifications.success('Connection to device established', 5000)
notifications.success('Connection to device established', 5000)
}
const handleClose = () => { const handleClose = () => {
notifications.error('Connection to device lost', 5000) notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(0) telemetry.setRSSI(RSSIData.create({ rssi: 0 }))
} }
const handleError = (data: unknown) => console.error(data) const handleError = (data: unknown) => console.error(data)
const handleAnalytics = (data: Analytics) => analytics.addData(data)
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
let menuOpen = $state(false) let menuOpen = $state(false)
</script> </script>
+3 -1
View File
@@ -16,7 +16,9 @@ const registerFetchIntercept = async () => {
const pathOnly = urlObj.pathname const pathOnly = urlObj.pathname
file = await fileService?.getFile(pathOnly) file = await fileService?.getFile(pathOnly)
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner)) if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
} catch {} } catch {
console.error('Failed to get file for ', url)
}
} }
return originalFetch(resource, config) return originalFetch(resource, config)
+2 -2
View File
@@ -5,12 +5,12 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { mpu, socket } from '$lib/stores' import { mpu, socket } from '$lib/stores'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { MessageTopic, type IMU } from '$lib/types/models' import { IMUData } from '$lib/platform_shared/message'
let layout = $derived($views.find(v => v.name === $selectedView)!) let layout = $derived($views.find(v => v.name === $selectedView)!)
onMount(() => { onMount(() => {
socket.on(MessageTopic.imu, (data: IMU) => { socket.on(IMUData, (data: IMUData) => {
imu.addData(data) imu.addData(data)
if (data.heading) if (data.heading)
mpu.update(mpuData => { mpu.update(mpuData => {
+60 -82
View File
@@ -1,30 +1,24 @@
<script lang="ts"> <script lang="ts">
import nipplejs from 'nipplejs' import nipplejs from 'nipplejs'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { capitalize, throttler } from '$lib/utilities'
import { import {
input, input,
outControllerData,
mode, mode,
modes,
type Modes,
ModesEnum,
WalkGaits,
walkGait, walkGait,
modes,
modeLabels,
walkGaits,
walkGaitLabels walkGaitLabels
} from '$lib/stores' } from '$lib/stores'
import type { vector } from '$lib/types/models' import type { vector } from '$lib/types/models'
import { VerticalSlider } from '$lib/components/input' import { VerticalSlider } from '$lib/components/input'
import { gamepadAxes, gamepadButtonsEdges, hasGamepad } from '$lib/stores/gamepad' import { gamepadAxes, gamepadButtonsEdges, hasGamepad } from '$lib/stores/gamepad'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import { ModeData, ModesEnum, WalkGaitData, WalkGaits } from '$lib/platform_shared/message'
let throttle = new throttler()
let left: nipplejs.JoystickManager let left: nipplejs.JoystickManager
let right: nipplejs.JoystickManager let right: nipplejs.JoystickManager
let throttle_timing = 40
let data = new Array(7)
$effect(() => { $effect(() => {
if ($hasGamepad) { if ($hasGamepad) {
notifications.success('🎮 Gamepad connected', 3000) notifications.success('🎮 Gamepad connected', 3000)
@@ -40,18 +34,18 @@
if (!$hasGamepad) return if (!$hasGamepad) return
const b = $gamepadButtonsEdges const b = $gamepadButtonsEdges
if (!b.length) return if (!b.length) return
if (b[0]?.justPressed) mode.set(5) if (b[0]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.WALK }))
if (b[1]?.justPressed) mode.set(4) if (b[1]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.STAND }))
if (b[2]?.justPressed) mode.set(3) if (b[2]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.REST }))
if (b[3]?.justPressed) mode.set(0) if (b[3]?.justPressed) mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
if (b[12]?.justPressed) if (b[12]?.justPressed)
input.update(inputData => { input.update(inputData => {
inputData['height'] = Math.min(inputData.height + 0.1, 1) inputData.height = Math.min(inputData.height + 0.1, 1)
return inputData return inputData
}) })
if (b[13]?.justPressed) if (b[13].justPressed)
input.update(inputData => { input.update(inputData => {
inputData['height'] = Math.min(inputData.height - 0.1, 1) inputData.height = Math.min(inputData.height - 0.1, 1)
return inputData return inputData
}) })
}) })
@@ -84,136 +78,120 @@
inputData[key] = data inputData[key] = data
return inputData return inputData
}) })
throttle.throttle(updateData, throttle_timing)
}
const updateData = () => {
data[0] = $input.left.x
data[1] = $input.left.y
data[2] = $input.right.x
data[3] = $input.right.y
data[4] = $input.height
data[5] = $input.speed
data[6] = $input.s1
outControllerData.set(data)
} }
const handleKeyup = (event: KeyboardEvent) => { const handleKeyup = (event: KeyboardEvent) => {
const down = event.type === 'keydown' const down = event.type === 'keydown'
input.update(data => { input.update(data => {
if (event.key === 'w') data.left.y = down ? 1 : 0 if (event.key === 'w') data.left!.y = down ? 1 : 0
if (event.key === 'a') data.left.x = down ? -1 : 0 if (event.key === 'a') data.left!.x = down ? -1 : 0
if (event.key === 's') data.left.y = down ? -1 : 0 if (event.key === 's') data.left!.y = down ? -1 : 0
if (event.key === 'd') data.left.x = down ? 1 : 0 if (event.key === 'd') data.left!.x = down ? 1 : 0
if (event.key === 'ArrowLeft') data.right.x = down ? 1 : 0 if (event.key === 'ArrowLeft') data.right!.x = down ? 1 : 0
if (event.key === 'ArrowRight') data.right.x = down ? -1 : 0 if (event.key === 'ArrowRight') data.right!.x = down ? -1 : 0
return data return data
}) })
throttle.throttle(updateData, throttle_timing)
} }
const handleRange = (event: Event, key: 'speed' | 'height' | 's1') => { const handleRange = (value: number, key: 'speed' | 'height' | 's1') => {
const value: number = Number((event.target as HTMLInputElement).value)
input.update(inputData => { input.update(inputData => {
inputData[key] = value inputData[key] = value
return inputData return inputData
}) })
throttle.throttle(updateData, throttle_timing)
} }
const changeMode = (modeValue: Modes) => { const changeMode = (modeValue: ModesEnum) => {
mode.set(modes.indexOf(modeValue)) mode.set(ModeData.create({ mode: modeValue }))
} }
const changeWalkGait = (walkGaitValue: WalkGaits) => { const changeWalkGait = (walkGaitValue: WalkGaits) => {
walkGait.set(walkGaitValue) walkGait.set(WalkGaitData.create({ gait: walkGaitValue }))
} }
</script> </script>
<div class="absolute top-0 left-0 w-screen h-screen"> <div class="absolute top-0 left-0 w-screen h-screen">
<div class="absolute top-0 left-0 h-full w-full flex portrait:hidden"> <div class="absolute top-0 left-0 h-full w-full flex max-[599px]:hidden">
<div id="left" class="flex w-60 items-center justify-end"></div> <div id="left" class="flex w-60 items-center justify-end"></div>
<div class="flex-1"></div> <div class="flex-1"></div>
<div id="right" class="flex w-60 items-center"></div> <div id="right" class="flex w-60 items-center"></div>
</div> </div>
<div class="absolute bottom-0 right-0 p-4 z-10 gap-2 flex-col hidden lg:flex"> <div
class="absolute bottom-0 right-0 p-4 z-10 gap-1.5 flex-col hidden lg:flex opacity-40 hover:opacity-80 transition-opacity duration-300"
>
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<kbd class="kbd">W</kbd> <kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">W</kbd>
</div> </div>
<div class="flex justify-center gap-2 w-full"> <div class="flex justify-center gap-1.5 w-full">
<kbd class="kbd">A</kbd> <kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">A</kbd>
<kbd class="kbd">S</kbd> <kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">S</kbd>
<kbd class="kbd">D</kbd> <kbd class="kbd kbd-sm bg-base-100/80 border-base-content/20 shadow-md">D</kbd>
</div> </div>
<div class="flex justify-center w-full"></div>
</div> </div>
<div class="absolute bottom-0 z-10 flex items-end"> <div class="absolute bottom-0 z-10 flex items-end pointer-events-none">
<div <div
class="flex items-center flex-col bg-base-300 bg-opacity-50 p-3 pb-2 gap-2 rounded-tr-xl" class="flex items-center flex-col backdrop-blur-sm bg-base-300/60 p-3 pb-2 gap-2 rounded-tr-2xl border-t border-base-content/5 pointer-events-auto"
> >
<VerticalSlider <VerticalSlider
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
oninput={(e: Event) => handleRange(e, 'height')} oninput={e => handleRange(Number((e.target as HTMLInputElement).value), 'height')}
/> />
<label for="height">Ht</label> <label for="height" class="text-xs font-medium opacity-70">Ht</label>
</div> </div>
<div <div
class="flex items-end gap-4 bg-base-300 bg-opacity-50 h-min rounded-tr-xl pl-0 p-3 portrait:hidden" class="flex items-end gap-4 backdrop-blur-sm bg-base-300/60 h-min rounded-tr-2xl pl-0 p-3 border-t border-r border-base-content/5 pointer-events-auto"
> >
<div class="join"> <div class="join shadow-lg">
{#each modes as modeValue} {#each modes as modeValue (modeValue)}
<button <button
class="btn join-item" class="btn join-item btn-sm transition-all duration-200"
class:btn-primary={$mode === modes.indexOf(modeValue)} class:btn-primary={$mode.mode === modeValue}
onclick={() => changeMode(modeValue)} onclick={() => changeMode(modeValue)}
> >
{capitalize(modeValue)} {modeLabels[modeValue]}
</button> </button>
{/each} {/each}
</div> </div>
{#if $mode === ModesEnum.Walk} {#if $mode.mode === ModesEnum.WALK}
<div class="join"> <div class="join shadow-md">
{#each Object.values(WalkGaits) as gaitValue} {#each walkGaits as gaitValue (gaitValue)}
{#if typeof gaitValue === 'number'} <button
<button class="btn join-item btn-xs transition-all duration-200"
class="btn join-item btn-sm" class:btn-secondary={$walkGait.gait === gaitValue}
class:btn-secondary={$walkGait === gaitValue} onclick={() => changeWalkGait(gaitValue)}
onclick={() => changeWalkGait(gaitValue)} >
> {walkGaitLabels[gaitValue]}
{walkGaitLabels[gaitValue]} </button>
</button>
{/if}
{/each} {/each}
</div> </div>
<div class="flex gap-4"> <div class="flex gap-4">
<div> <div class="flex flex-col gap-1">
<label for="s1">S1</label> <label for="s1" class="text-xs font-medium opacity-70">Step height</label>
<input <input
type="range" type="range"
name="s1" name="s1"
min="0" min="0"
step="0.01" step="0.01"
max="1" max="1"
oninput={e => handleRange(e, 's1')} oninput={e =>
class="range range-sm range-primary" handleRange(Number((e.target as HTMLInputElement).value), 's1')}
class="range range-xs range-primary"
/> />
</div> </div>
<div> <div class="flex flex-col gap-1">
<label for="speed">Speed</label> <label for="speed" class="text-xs font-medium opacity-70">Speed</label>
<input <input
type="range" type="range"
name="speed" name="speed"
min="0" min="0"
step="0.01" step="0.01"
max="1" max="1"
oninput={e => handleRange(e, 'speed')} oninput={e =>
class="range range-sm range-primary" handleRange(Number((e.target as HTMLInputElement).value), 'speed')}
class="range range-xs range-primary"
/> />
</div> </div>
</div> </div>
@@ -1,38 +1,40 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api' import { api } from '$lib/api'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import type { CameraSettings } from '$lib/types/models' import { CameraSettings, Request, type Response as ProtoResponse } from '$lib/platform_shared/api'
let settings: CameraSettings = $state({
brightness: 0, let settings = $state<CameraSettings>(CameraSettings.create({}))
contrast: 0,
framesize: 0,
vflip: false,
hmirror: false,
special_effect: 0,
quality: 0,
saturation: 0,
sharpness: 0,
denoise: 0,
wb_mode: 0
})
const getCameraSettings = async () => { const getCameraSettings = async () => {
const result = await api.get<CameraSettings>('/api/camera/settings') const result = await api.get<ProtoResponse>('/api/camera/settings')
if (result.isErr()) { if (result.isErr()) {
console.error('An error occurred', result.inner) console.error('An error occurred', result.inner)
return return
} }
settings = result.inner if (result.inner.cameraSettings) {
settings = result.inner.cameraSettings
}
} }
const updateCameraSettings = async () => { const updateCameraSettings = async () => {
const result = await api.post<CameraSettings>('/api/camera/settings', settings) const request = Request.create({
cameraSettings: settings
})
const result = await api.post_proto<ProtoResponse>('/api/camera/settings', request)
if (result.isErr()) { if (result.isErr()) {
console.error('An error occurred', result.inner) console.error('An error occurred', result.inner)
return return
} }
settings = result.inner if (result.inner.cameraSettings) {
settings = result.inner.cameraSettings
}
} }
// Helper to convert number (0/1) to boolean for checkbox binding
const getVflip = () => settings.vflip !== 0
const setVflip = (value: boolean) => (settings.vflip = value ? 1 : 0)
const getHmirror = () => settings.hmirror !== 0
const setHmirror = (value: boolean) => (settings.hmirror = value ? 1 : 0)
</script> </script>
{#await getCameraSettings()} {#await getCameraSettings()}
@@ -78,19 +80,29 @@
<label class="cursor-pointer flex items-center justify-between"> <label class="cursor-pointer flex items-center justify-between">
Vertical flip Vertical flip
<input type="checkbox" class="toggle" bind:checked={settings.vflip} /> <input
type="checkbox"
class="toggle"
checked={getVflip()}
onchange={(e) => setVflip(e.currentTarget.checked)}
/>
</label> </label>
<label class="cursor-pointer flex items-center justify-between"> <label class="cursor-pointer flex items-center justify-between">
Horizontal flip Horizontal flip
<input type="checkbox" class="toggle" bind:checked={settings.hmirror} /> <input
type="checkbox"
class="toggle"
checked={getHmirror()}
onchange={(e) => setHmirror(e.currentTarget.checked)}
/>
</label> </label>
<label for="special_effect" class="flex items-center"> <label for="special_effect" class="flex items-center">
<span class="basis-1/2">Special Effect</span> <span class="basis-1/2">Special Effect</span>
<select <select
class="select select-bordered select-sm w-full max-w-xs" class="select select-bordered select-sm w-full max-w-xs"
bind:value={settings.special_effect} bind:value={settings.specialEffect}
> >
<option value={0}>No effect</option> <option value={0}>No effect</option>
<option value={1}>Negative</option> <option value={1}>Negative</option>
+10 -34
View File
@@ -2,49 +2,25 @@
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic, type I2CDevice } from '$lib/types/models'
import { Connection } from '$lib/components/icons' import { Connection } from '$lib/components/icons'
import I2CSetting from './i2cSetting.svelte' import I2CSetting from './i2cSetting.svelte'
import type { I2CDevice } from '$lib/platform_shared/message'
const i2cDevices = [
{ address: 30, part_number: 'HMC5883', name: '3-Axis Digital Compass/Magnetometer IC' },
{ address: 41, part_number: 'BNO055', name: '9-Axis Absolute Orientation Sensor' },
{ address: 64, part_number: 'PCA9685', name: '16-channel PWM driver default address' },
{ address: 72, part_number: 'ADS1115', name: '4-channel 16-bit ADC' },
{
address: 104,
part_number: 'MPU6050',
name: 'Six-Axis (Gyro + Accelerometer) MEMS MotionTracking™ Devices'
},
{ address: 115, part_number: 'PAJ7620U2', name: 'Gesture sensor' },
{ address: 119, part_number: 'BMP085', name: 'Temp/Barometric' }
]
let active_devices: I2CDevice[] = $state([]) let active_devices: I2CDevice[] = $state([])
let isLoading = $state(false) let isLoading = $state(false)
onMount(() => { onMount(() => {
socket.on(MessageTopic.i2cScan, handleScan)
triggerScan() triggerScan()
return () => socket.off(MessageTopic.i2cScan, handleScan)
}) })
const handleScan = (data: { addresses: number[] }) => { const triggerScan = async () => {
active_devices = data.addresses.map(
(address: number) =>
i2cDevices.find(device => device.address === address) || {
address,
part_number: 'Unknown',
name: 'Unknown'
}
)
isLoading = false
}
const triggerScan = () => {
isLoading = true isLoading = true
socket.sendEvent(MessageTopic.i2cScan, '') try {
const response = await socket.request({ i2cScanDataRequest: {} })
active_devices = response.i2cScanData?.devices ?? []
} finally {
isLoading = false
}
} }
</script> </script>
@@ -71,8 +47,8 @@
{#if active_devices.length === 0} {#if active_devices.length === 0}
<div>No I2C devices found</div> <div>No I2C devices found</div>
{:else} {:else}
{#each active_devices as device} {#each active_devices as device (device.address)}
<div>[{device.address.toString(16)}] {device.part_number} - {device.name}</div> <div>[{device.address.toString(16)}] {device.partNumber} - {device.name}</div>
{/each} {/each}
{/if} {/if}
</div> </div>
@@ -1,22 +1,31 @@
<script lang="ts"> <script lang="ts">
import { Cancel, Edit, EditOff, Power } from '$lib/components/icons' import { Cancel, Edit, EditOff, Power } from '$lib/components/icons'
import { socket } from '$lib/stores' import { api } from '$lib/api'
import { MessageTopic, type PeripheralsConfiguration } from '$lib/types/models'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import {
type PeripheralSettings,
Request,
type Response as ProtoResponse
} from '$lib/platform_shared/api'
let settings: PeripheralsConfiguration | null = $state(null) let settings = $state<PeripheralSettings | null>(null)
let isEditing = $state(false) let isEditing = $state(false)
onMount(() => { onMount(() => {
socket.on(MessageTopic.peripheralSettings, handleSettings) getPeripheralSettings()
socket.sendEvent(MessageTopic.peripheralSettings, '')
return () => socket.off(MessageTopic.peripheralSettings, handleSettings)
}) })
const handleSettings = (data: Record<string, unknown>) => { const getPeripheralSettings = async () => {
settings = data as PeripheralsConfiguration const result = await api.get<ProtoResponse>('/api/peripherals/settings')
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
if (result.inner.peripheralSettings) {
settings = result.inner.peripheralSettings
}
} }
const handleSave = () => { const handleSave = () => {
@@ -28,9 +37,21 @@
cancel: { label: 'Cancel', icon: Cancel }, cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'Confirm', icon: Power } confirm: { label: 'Confirm', icon: Power }
}, },
onConfirm: () => { onConfirm: async () => {
modals.close() modals.close()
socket.sendEvent(MessageTopic.peripheralSettings, settings) if (!settings) return
const request = Request.create({
peripheralSettings: settings
})
const result = await api.post_proto<ProtoResponse>('/api/peripherals/settings', request)
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
if (result.inner.peripheralSettings) {
settings = result.inner.peripheralSettings
}
isEditing = false
} }
}) })
} }
+208 -128
View File
@@ -1,29 +1,33 @@
<script lang="ts"> <script lang="ts">
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import Compass from '$lib/components/Compass.svelte'
import { imu } from '$lib/stores/imu' import { imu } from '$lib/stores/imu'
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { socket } from '$lib/stores' import { socket, mpu } from '$lib/stores'
import { MessageTopic, type IMUMsg, type IMUCalibrationResult } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags' import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Rotate3d } from '$lib/components/icons' import { Rotate3d } from '$lib/components/icons'
import { type IMUCalibrateData } from '$lib/platform_shared/message'
Chart.register(...registerables) Chart.register(...registerables)
const features = useFeatureFlags() const features = useFeatureFlags()
let intervalId: ReturnType<typeof setInterval> | number let intervalId: ReturnType<typeof setInterval> | number
let isCalibrating = $state(false) let isCalibrating = $state(false)
let calibrationResult = $state<IMUCalibrationResult | null>(null) let calibrationResult = $state<IMUCalibrateData | null>(null)
let angleChartElement: HTMLCanvasElement let angleChartElement: HTMLCanvasElement = $state()!
let tempChartElement: HTMLCanvasElement let tempChartElement: HTMLCanvasElement = $state()!
let altitudeChartElement: HTMLCanvasElement let altitudeChartElement: HTMLCanvasElement = $state()!
let headingChartElement: HTMLCanvasElement = $state()!
let angleChart: Chart let angleChart: Chart
let tempChart: Chart let tempChart: Chart
let altitudeChart: Chart let altitudeChart: Chart
let headingChart: Chart
const getChartColors = () => { const getChartColors = () => {
const style = getComputedStyle(document.body) const style = getComputedStyle(document.body)
@@ -65,114 +69,155 @@
const colors = getChartColors() const colors = getChartColors()
const baseConfig = createBaseChartConfig(colors.background) const baseConfig = createBaseChartConfig(colors.background)
angleChart = new Chart(angleChartElement, { if (angleChartElement) {
type: 'line', angleChart = new Chart(angleChartElement, {
data: { type: 'line',
datasets: [ data: {
{ datasets: [
label: 'x', {
borderColor: colors.primary, label: 'x',
backgroundColor: colors.primary, borderColor: colors.primary,
borderWidth: 2, backgroundColor: colors.primary,
data: $imu.x, borderWidth: 2,
yAxisID: 'y' data: $imu.map(datapoint => datapoint.x),
}, yAxisID: 'y'
{ },
label: 'y', {
borderColor: colors.secondary, label: 'y',
backgroundColor: colors.secondary, borderColor: colors.secondary,
borderWidth: 2, backgroundColor: colors.secondary,
data: $imu.y, borderWidth: 2,
yAxisID: 'y' data: $imu.map(datapoint => datapoint.y),
}, yAxisID: 'y'
{ },
label: 'z', {
borderColor: colors.accent, label: 'z',
backgroundColor: colors.accent, borderColor: colors.accent,
borderWidth: 2, backgroundColor: colors.accent,
data: $imu.z, borderWidth: 2,
yAxisID: 'y' data: $imu.map(datapoint => datapoint.z),
} yAxisID: 'y'
] }
}, ]
options: { },
...baseConfig, options: {
scales: { ...baseConfig,
...baseConfig.scales, scales: {
y: { ...baseConfig.scales,
...baseConfig.scales.y, y: {
title: { ...baseConfig.scales.y,
display: true, title: {
text: 'Angle [°]', display: true,
color: colors.background, text: 'Angle [°]',
font: { size: 16, weight: 'bold' } color: colors.background,
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
} })
}) }
tempChart = new Chart(tempChartElement, { if (tempChartElement) {
type: 'line', tempChart = new Chart(tempChartElement, {
data: { type: 'line',
datasets: [ data: {
{ datasets: [
label: 'Barometer temperature', {
borderColor: colors.secondary, label: 'Barometer temperature',
backgroundColor: colors.secondary, borderColor: colors.secondary,
borderWidth: 2, backgroundColor: colors.secondary,
data: $imu.bmp_temp, borderWidth: 2,
yAxisID: 'y' data: $imu.map(datapoint => datapoint.bmpTemp),
} yAxisID: 'y'
] }
}, ]
options: { },
...baseConfig, options: {
scales: { ...baseConfig,
...baseConfig.scales, scales: {
y: { ...baseConfig.scales,
...baseConfig.scales.y, y: {
title: { ...baseConfig.scales.y,
display: true, title: {
text: 'Temperature [C°]', display: true,
color: colors.background, text: 'Temperature [C°]',
font: { size: 16, weight: 'bold' } color: colors.background,
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
} })
}) }
altitudeChart = new Chart(altitudeChartElement, { if (altitudeChartElement) {
type: 'line', altitudeChart = new Chart(altitudeChartElement, {
data: { type: 'line',
datasets: [ data: {
{ datasets: [
label: 'Altitude', {
borderColor: colors.primary, label: 'Altitude',
backgroundColor: colors.primary, borderColor: colors.primary,
borderWidth: 2, backgroundColor: colors.primary,
data: $imu.altitude, borderWidth: 2,
yAxisID: 'y' data: $imu.map(datapoint => datapoint.altitude),
} yAxisID: 'y'
] }
}, ]
options: { },
...baseConfig, options: {
scales: { ...baseConfig,
...baseConfig.scales, scales: {
y: { ...baseConfig.scales,
...baseConfig.scales.y, y: {
title: { ...baseConfig.scales.y,
display: true, title: {
text: 'Altitude [M]', display: true,
color: colors.background, text: 'Altitude [M]',
font: { size: 16, weight: 'bold' } color: colors.background,
font: { size: 16, weight: 'bold' }
}
} }
} }
} }
} })
}) }
if (headingChartElement) {
headingChart = new Chart(headingChartElement, {
type: 'line',
data: {
datasets: [
{
label: 'Heading',
borderColor: colors.accent,
backgroundColor: colors.accent,
borderWidth: 2,
data: $imu.map(datapoint => datapoint.heading),
yAxisID: 'y'
}
]
},
options: {
...baseConfig,
scales: {
...baseConfig.scales,
y: {
...baseConfig.scales.y,
min: 0,
max: 360,
title: {
display: true,
text: 'Heading [°]',
color: colors.background,
font: { size: 16, weight: 'bold' }
}
}
}
}
})
}
} }
const updateChartData = (chart: Chart, data: number[]) => { const updateChartData = (chart: Chart, data: number[]) => {
@@ -184,49 +229,64 @@
} }
const updateData = () => { const updateData = () => {
if ($features.imu) { if ($features.imu && angleChart) {
angleChart.data.labels = $imu.x const x = $imu.map(datapoint => datapoint.x)
angleChart.data.datasets[0].data = $imu.x const y = $imu.map(datapoint => datapoint.y)
angleChart.data.datasets[1].data = $imu.y const z = $imu.map(datapoint => datapoint.z)
angleChart.data.datasets[2].data = $imu.z
const allValues = [...$imu.x, ...$imu.y, ...$imu.z] angleChart.data.labels = Array.from({ length: $imu.length }, (_, i) => i + 1)
angleChart.data.datasets[0].data = x
angleChart.data.datasets[1].data = y
angleChart.data.datasets[2].data = z
const allValues = [...x, ...y, ...z]
angleChart.options.scales!.y!.min = Math.min(...allValues) - 1 angleChart.options.scales!.y!.min = Math.min(...allValues) - 1
angleChart.options.scales!.y!.max = Math.max(...allValues) + 1 angleChart.options.scales!.y!.max = Math.max(...allValues) + 1
angleChart.update('none') angleChart.update('none')
} }
if ($features.bmp) { if ($features.bmp && tempChart && altitudeChart) {
updateChartData(tempChart, $imu.bmp_temp) updateChartData(
updateChartData(altitudeChart, $imu.altitude) tempChart,
$imu.map(datapoint => datapoint.bmpTemp)
)
updateChartData(
altitudeChart,
$imu.map(datapoint => datapoint.altitude)
)
}
if ($features.mag && headingChart) {
const headingData = $imu.map(datapoint => datapoint.heading)
headingChart.data.labels = headingData
headingChart.data.datasets[0].data = headingData
headingChart.update('none')
if ($imu.length > 0) {
mpu.set({ heading: $imu[$imu.length - 1].heading })
}
} }
} }
onMount(() => { onMount(() => {
socket.on(MessageTopic.imu, (data: IMUMsg) => { imu.listen()
console.log(data)
imu.addData(data)
})
socket.on(MessageTopic.imuCalibrate, (data: IMUCalibrationResult) => {
isCalibrating = false
calibrationResult = data
})
initializeCharts() initializeCharts()
intervalId = setInterval(updateData, 200) intervalId = setInterval(updateData, 200)
}) })
onDestroy(() => { onDestroy(() => {
socket.off(MessageTopic.imu) imu.stop()
socket.off(MessageTopic.imuCalibrate)
clearInterval(intervalId) clearInterval(intervalId)
}) })
function startCalibration() { async function startCalibration() {
isCalibrating = true isCalibrating = true
calibrationResult = null calibrationResult = null
socket.sendEvent(MessageTopic.imuCalibrate, {}) try {
const response = await socket.request({ imuCalibrateExecute: {} })
calibrationResult = response.imuCalibrateData ?? null
} finally {
isCalibrating = false
}
} }
</script> </script>
@@ -252,7 +312,11 @@
{/if} {/if}
</button> </button>
{#if calibrationResult} {#if calibrationResult}
<span class="badge" class:badge-success={calibrationResult.success} class:badge-error={!calibrationResult.success}> <span
class="badge"
class:badge-success={calibrationResult.success}
class:badge-error={!calibrationResult.success}
>
{calibrationResult.success ? 'Calibrated' : 'Failed'} {calibrationResult.success ? 'Calibrated' : 'Failed'}
</span> </span>
{/if} {/if}
@@ -269,7 +333,23 @@
</div> </div>
{/if} {/if}
{#if $features.mag}
<div class="divider">Magnetometer</div>
<div class="flex flex-col lg:flex-row gap-4 items-center">
<Compass heading={$mpu.heading} />
<div class="flex-1 w-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={headingChartElement}></canvas>
</div>
</div>
</div>
{/if}
{#if $features.bmp} {#if $features.bmp}
<div class="divider">Barometer</div>
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<div <div
class="flex w-full flex-col space-y-1 h-60" class="flex w-full flex-col space-y-1 h-60"
@@ -2,43 +2,48 @@
import { api } from '$lib/api' import { api } from '$lib/api'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { RotateCw, RotateCcw } from '$lib/components/icons' import { RotateCw, RotateCcw } from '$lib/components/icons'
import { Request, Response, type ServoSettings } from '$lib/platform_shared/api'
import { notifications } from '$lib/components/toasts/notifications'
interface Props { interface Props {
data?: Record<string, unknown> servoSettings?: ServoSettings | null
servoId?: number servoId?: number
pwm?: number pwm?: number
} }
let { let {
data = $bindable({ servoSettings = $bindable(null),
servos: []
}),
pwm = $bindable(306), pwm = $bindable(306),
servoId = $bindable(0) servoId = $bindable(0)
}: Props = $props() }: Props = $props()
const updateValue = (event: Event, index: number, key: string) => {
data.servos[index][key] = Number((event.target as HTMLInputElement).value)
}
const syncConfig = async () => { const syncConfig = async () => {
await api.post('/api/servo/config', data) if (!servoSettings) return
notifications.info("Uploading servo config...", 3000)
await api.post_proto<Response>('/api/servo/config', Request.create({ servoSettings }))
notifications.success('Servo config uploaded successfully', 3000)
} }
const toggleDirection = async (index: number) => { const toggleDirection = async (index: number) => {
data.servos[index].direction = data.servos[index].direction === 1 ? -1 : 1 if (!servoSettings) return
servoSettings.servos[index].direction = servoSettings.servos[index].direction === 1 ? -1 : 1
await syncConfig() await syncConfig()
} }
onMount(async () => { onMount(async () => {
const result = await api.get('/api/servo/config') const result = await api.get<Response>('/api/servo/config')
if (result.isOk()) { if (result.isOk() && result.inner.servoSettings) {
data = result.inner servoSettings = result.inner.servoSettings
} else {
console.log("Failed to fetch servo config!")
console.log(result)
} }
}) })
const setCenterPWM = async () => { const setCenterPWM = async () => {
if (!servoSettings) return
console.log('setCenterPWM', servoId, pwm) console.log('setCenterPWM', servoId, pwm)
data.servos[servoId]['center_pwm'] = pwm servoSettings.servos[servoId].centerPwm = pwm
await syncConfig() await syncConfig()
} }
</script> </script>
@@ -47,6 +52,7 @@
<button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button> <button class="btn btn-sm btn-primary" onclick={() => setCenterPWM()}>Set center pwm</button>
</div> </div>
{#if servoSettings}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-xs"> <table class="table table-xs">
<thead> <thead>
@@ -59,16 +65,16 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.servos as servo, index} {#each servoSettings.servos as servo, index (index)}
<tr class="hover:bg-base-200"> <tr class="hover:bg-base-200">
<td class="font-medium">Servo {index}</td> <td class="font-medium">Servo {index}</td>
<td> <td>
<input <input
type="number" type="number"
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.center_pwm} value={servo.centerPwm}
onblur={syncConfig} onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_pwm')} oninput={event => servo.centerPwm = Number((event.target as HTMLInputElement).value)}
min="80" min="80"
max="600" max="600"
/> />
@@ -78,9 +84,9 @@
type="number" type="number"
step="0.1" step="0.1"
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.center_angle} value={servo.centerAngle}
onblur={syncConfig} onblur={syncConfig}
oninput={event => updateValue(event, index, 'center_angle')} oninput={event => servo.centerAngle = Number((event.target as HTMLInputElement).value)}
min="-90" min="-90"
max="90" max="90"
/> />
@@ -105,7 +111,7 @@
class="input input-sm input-bordered w-20" class="input input-sm input-bordered w-20"
value={servo.conversion} value={servo.conversion}
onblur={syncConfig} onblur={syncConfig}
oninput={event => updateValue(event, index, 'conversion')} oninput={event => servo.conversion = Number((event.target as HTMLInputElement).value)}
min="0" min="0"
max="10" max="10"
/> />
@@ -115,3 +121,4 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{/if}
+55 -36
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ServoPWMData, ServoStateData } from '$lib/platform_shared/message'
import { socket } from '$lib/stores' import { socket } from '$lib/stores'
import { MessageTopic } from '$lib/types/models' import { Throttler } from '$lib/utilities'
import { throttler as Throttler } from '$lib/utilities'
let { servoId = $bindable(0), pwm = $bindable(306) } = $props() let { servoId = $bindable(0), pwm = $bindable(306) } = $props()
@@ -12,16 +12,16 @@
const throttler = new Throttler() const throttler = new Throttler()
const activateServo = () => { const activateServo = () => {
socket.sendEvent(MessageTopic.servoState, { active: 1 }) socket.emit(ServoStateData, ServoStateData.create({ active: true }))
} }
const deactivateServo = () => { const deactivateServo = () => {
socket.sendEvent(MessageTopic.servoState, { active: 0 }) socket.emit(ServoStateData, ServoStateData.create({ active: false }))
} }
const updatePWM = () => { const updatePWM = () => {
throttler.throttle(() => { throttler.throttle(() => {
socket.sendEvent(MessageTopic.servoPWM, { servo_id: servoId, pwm }) socket.emit(ServoPWMData, ServoPWMData.create({ servoId: servoId, servoPwm: pwm }))
}, 10) }, 10)
} }
@@ -30,37 +30,56 @@
} }
</script> </script>
<div class="flex flex-col"> <div class="flex flex-col gap-6 p-4 bg-base-200 rounded-xl">
<h2 class="text-lg">General servo configuration</h2> <div class="flex flex-col gap-2">
<span>Servo</span> <h2 class="text-lg font-semibold">PWM Control</h2>
<span>{pwm}</span> <div class="flex items-center justify-between">
</div> <span class="text-sm opacity-70">PWM Value</span>
<input <span class="text-2xl font-mono font-bold text-primary">{pwm}</span>
type="range" </div>
min="80"
max="600"
bind:value={pwm}
oninput={updatePWM}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<div class="flex flex-col">
<h2 class="text-lg">General servo configuration</h2>
<span>
<label for="mode">All servoes</label>
<input type="checkbox" class="toggle" bind:checked={allServos} onchange={toggleMode} />
</span>
<span>
<label for="active">Active</label>
<input <input
type="checkbox" type="range"
class="toggle" min="80"
bind:checked={active} max="600"
onchange={active ? activateServo : deactivateServo} bind:value={pwm}
oninput={updatePWM}
class="range range-primary"
/> />
</span> </div>
<span class="flex items-center gap-2">
<label for="servoId">Servo active {servoId}</label> <div class="divider my-0"></div>
<input type="range" min="0" max="11" step="1" bind:value={servoId} />
</span> <div class="flex flex-col gap-3">
<h2 class="text-lg font-semibold">Servo Selection</h2>
<label class="flex items-center justify-between cursor-pointer">
<span>All servos</span>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={allServos}
onchange={toggleMode}
/>
</label>
<label class="flex items-center justify-between cursor-pointer">
<span>Active</span>
<input
type="checkbox"
class="toggle toggle-success"
bind:checked={active}
onchange={active ? activateServo : deactivateServo}
/>
</label>
<label class="flex items-center justify-between">
<span>Servo {servoId}</span>
<input
type="range"
min="0"
max="11"
step="1"
bind:value={servoId}
class="range range-sm w-32"
disabled={allServos}
/>
</label>
</div>
</div> </div>
+406 -162
View File
@@ -1,182 +1,426 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import Folder from './Folder.svelte' import { fileSystemClient } from '$lib/filesystem/chunkedTransfer'
import { api } from '$lib/api' import type { TransferProgress } from '$lib/types/models'
import type { Directory } from '$lib/types/models' import { FolderIcon, Add, FileIcon, UploadIcon, DownloadIcon, TrashIcon } from '$lib/components/icons'
import { FolderIcon, Add, FileIcon } from '$lib/components/icons' import { modals } from 'svelte-modals'
import { modals } from 'svelte-modals' import NewFolderDialog from './NewFolderDialog.svelte'
import NewFolderDialog from './NewFolderDialog.svelte' import NewFileDialog from './NewFileDialog.svelte'
import NewFileDialog from './NewFileDialog.svelte' import { api } from '$lib/api'
import type { Response } from '$lib/platform_shared/api'
let filename = $state('') let currentPath = $state('/')
let content = $state('') let files = $state<Array<{ name: string; size: number }>>([])
let isEditing = $state(false) let directories = $state<Array<{ name: string }>>([])
let loading = $state(false)
let error = $state('')
const getFiles = async () => { let selectedFile = $state('')
const result = await api.get<Directory>('/api/files') let fileContent = $state('')
if (result.isOk()) { let isEditing = $state(false)
return result.inner let fileLoading = $state(false)
}
return { root: {} }
}
const getContent = async (name: string) => { let uploadProgress = $state<TransferProgress | null>(null)
if (!name) return '' let downloadProgress = $state<TransferProgress | null>(null)
const result = await api.get(`/api/config/${name}`) let uploadInputRef: HTMLInputElement
if (result.isOk()) {
content = JSON.stringify(result.inner, null, 4)
return content
}
return ''
}
const saveContent = async () => { async function loadDirectory(path: string = currentPath) {
if (!filename) return loading = true
const result = await api.post('/api/files/edit', { error = ''
file: '/config/' + filename, try {
content const result = await fileSystemClient.listDirectory(path)
}) if (result.success) {
if (result.isOk()) { files = result.files
isEditing = false directories = result.directories
} currentPath = path
} } else {
error = result.error || 'Failed to load directory'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading = false
}
}
const deleteFile = async (name: string) => { async function navigateTo(dirName: string) {
if (!confirm(`Are you sure you want to delete ${name}?`)) return const newPath = currentPath === '/' ? `/${dirName}` : `${currentPath}/${dirName}`
const result = await api.post('/api/files/delete', { file: '/config/' + name }) await loadDirectory(newPath)
if (result.isOk()) { selectedFile = ''
filename = '' fileContent = ''
content = '' }
}
}
const createFolder = async (folderName: string) => { async function navigateUp() {
if (!folderName) return if (currentPath === '/') return
const result = await api.post('/api/files/mkdir', { const parts = currentPath.split('/').filter(Boolean)
path: '/config/' + folderName parts.pop()
}) const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
if (result.isOk()) { await loadDirectory(newPath)
// Refresh the file list selectedFile = ''
await getFiles() fileContent = ''
} }
}
const updateSelected = async (name: string) => { async function loadFileContent(filename: string) {
filename = name fileLoading = true
isEditing = false error = ''
await getContent(name) try {
} const filePath = currentPath === '/' ? `/${filename}` : `${currentPath}/${filename}`
const result = await fileSystemClient.downloadFile(filePath)
const openNewFolderDialog = () => { if (result.success && result.data) {
modals.open(NewFolderDialog, { // Convert bytes to string (assuming UTF-8 text file)
onConfirm: createFolder const decoder = new TextDecoder('utf-8')
}) fileContent = decoder.decode(result.data)
} selectedFile = filename
isEditing = false
} else {
error = result.error || 'Failed to load file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load file'
} finally {
fileLoading = false
}
}
const createFile = async (fileName: string) => { async function saveFileContent() {
if (!fileName) return if (!selectedFile) return
const result = await api.post('/api/files/edit', {
file: '/config/' + fileName,
content: '{}' // Default empty JSON object
})
if (result.isOk()) {
// Refresh the file list and select the new file
await getFiles()
await updateSelected(fileName)
}
}
const openNewFileDialog = () => { error = ''
modals.open(NewFileDialog, { try {
onConfirm: createFile const filePath = currentPath === '/' ? `/${selectedFile}` : `${currentPath}/${selectedFile}`
}) const data = new TextEncoder().encode(fileContent)
}
const result = await fileSystemClient.uploadFile(filePath, data)
if (result.success) {
isEditing = false
await loadDirectory() // Refresh to update file sizes
} else {
error = result.error || 'Failed to save file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save file'
}
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadProgress = null
error = ''
const destinationPath = currentPath === '/'
? `/${file.name}`
: `${currentPath}/${file.name}`
try {
const result = await fileSystemClient.uploadFileFromBrowser(
destinationPath,
file,
(progress) => {
uploadProgress = progress
}
)
if (result.success) {
await loadDirectory()
} else {
error = result.error || 'Upload failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Upload error'
} finally {
uploadProgress = null
input.value = ''
}
}
async function handleDownload(filename: string) {
downloadProgress = null
error = ''
const filePath = currentPath === '/'
? `/${filename}`
: `${currentPath}/${filename}`
try {
const result = await fileSystemClient.downloadFileAndSave(filePath, filename, (progress) => {
downloadProgress = progress
})
if (!result.success) {
error = result.error || 'Download failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Download error'
} finally {
downloadProgress = null
}
}
async function handleDelete(name: string, isDirectory: boolean) {
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
error = ''
const path = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`
try {
const result = await fileSystemClient.deleteFile(path)
if (result.success) {
if (selectedFile === name) {
selectedFile = ''
fileContent = ''
}
await loadDirectory()
} else {
error = result.error || 'Delete failed'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Delete error'
}
}
async function createFolder(folderName: string) {
if (!folderName) return
error = ''
const path = currentPath === '/' ? `/${folderName}` : `${currentPath}/${folderName}`
try {
const result = await api.post_proto<Response>('/api/files/mkdir', {
fileMkdirRequest: { path }
})
if (result.isOk() && result.inner.statusCode === 200) {
await loadDirectory()
} else if (result.isErr()) {
error = 'Failed to create directory'
} else {
error = result.inner.errorMessage || 'Failed to create directory'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error creating directory'
}
}
async function createFile(fileName: string) {
if (!fileName) return
error = ''
const path = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`
try {
const result = await fileSystemClient.uploadFile(path, new Uint8Array(0))
if (result.success) {
await loadDirectory()
await loadFileContent(fileName)
} else {
error = result.error || 'Failed to create file'
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error creating file'
}
}
function openNewFolderDialog() {
modals.open(NewFolderDialog, {
onConfirm: createFolder
})
}
function openNewFileDialog() {
modals.open(NewFileDialog, {
onConfirm: createFile
})
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Load initial directory
$effect(() => {
loadDirectory('/')
})
</script> </script>
<!-- <SettingsCard collapsible={false}> -->
<!-- {#snippet icon()} -->
<FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" /> <FolderIcon class="flex-shrink-0 mr-2 h-6 w-6 self-end" />
<!-- {/snippet}
{#snippet title()} --> <div class="flex justify-between items-center w-full gap-2 mb-4">
<div class="flex justify-between items-center w-full gap-2"> <span class="text-xl font-bold">File System</span>
<span>File System</span> <div class="flex gap-2">
<div class="flex gap-2"> <button class="btn btn-sm btn-primary flex items-center gap-2" onclick={() => uploadInputRef.click()}>
<button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}> <UploadIcon class="w-4 h-4" />
<FileIcon class="w-4 h-4" /> Upload File
New File </button>
</button> <button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFileDialog}>
<button <FileIcon class="w-4 h-4" />
class="btn btn-sm btn-primary flex items-center gap-2" New File
onclick={openNewFolderDialog} </button>
> <button class="btn btn-sm btn-primary flex items-center gap-2" onclick={openNewFolderDialog}>
<Add class="w-4 h-4" /> <Add class="w-4 h-4" />
New Folder New Folder
</button> </button>
</div> </div>
</div> </div>
<!-- {/snippet} -->
<input
type="file"
bind:this={uploadInputRef}
onchange={handleFileUpload}
style="display: none;"
/>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
{#if uploadProgress}
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>Uploading...</span>
<span>{uploadProgress.percentage.toFixed(1)}% ({formatBytes(uploadProgress.bytesTransferred)} / {formatBytes(uploadProgress.totalBytes)})</span>
</div>
<progress class="progress progress-primary w-full" value={uploadProgress.percentage} max="100"></progress>
</div>
{/if}
{#if downloadProgress}
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>Downloading...</span>
<span>{downloadProgress.percentage.toFixed(1)}% ({formatBytes(downloadProgress.bytesTransferred)} / {formatBytes(downloadProgress.totalBytes)})</span>
</div>
<progress class="progress progress-primary w-full" value={downloadProgress.percentage} max="100"></progress>
</div>
{/if}
<div class="flex flex-col md:flex-row gap-4 w-full"> <div class="flex flex-col md:flex-row gap-4 w-full">
<!-- File Tree --> <!-- File Tree -->
<div <div class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4">
class="w-full md:w-[300px] md:min-w-[300px] md:max-w-[300px] border-b md:border-b-0 md:border-r pb-4 md:pb-0 md:pr-4" <!-- Current Path -->
> <div class="mb-4 p-2 bg-base-200 rounded font-mono text-sm flex items-center justify-between">
{#await getFiles()} <span class="truncate">{currentPath}</span>
<Spinner /> {#if currentPath !== '/'}
{:then files} <button class="btn btn-xs btn-ghost" onclick={navigateUp}>
<Folder ↑ Up
name="/" </button>
files={files.root} {/if}
expanded </div>
selected={updateSelected}
onDelete={deleteFile}
/>
{/await}
</div>
<!-- File Content --> {#if loading}
<div class="flex-1 min-w-0"> <Spinner />
{#if filename} {:else}
<div <!-- Directories -->
class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2" {#each directories as dir (dir.name)}
> <div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group">
<h3 class="text-lg font-semibold truncate">{filename}</h3> <button class="flex items-center gap-2 flex-1" onclick={() => navigateTo(dir.name)}>
<div class="flex gap-2"> <FolderIcon class="w-5 h-5 text-yellow-500" />
{#if isEditing} <span class="text-sm">{dir.name}</span>
<button class="btn btn-sm btn-primary" onclick={saveContent}>Save</button> </button>
<button <button
class="btn btn-sm btn-secondary" class="opacity-0 group-hover:opacity-100 btn btn-xs btn-ghost btn-square"
onclick={() => (isEditing = false)} onclick={() => handleDelete(dir.name, true)}
> >
Cancel <TrashIcon class="w-4 h-4 text-error" />
</button> </button>
{:else} </div>
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}> {/each}
Edit
</button>
<button class="btn btn-sm btn-danger" onclick={() => deleteFile(filename)}>
Delete
</button>
{/if}
</div>
</div>
{#await getContent(filename)} <!-- Files -->
<Spinner /> {#each files as file (file.name)}
{:then} <div class="flex items-center py-1 px-2 hover:bg-base-200 rounded group">
{#if isEditing} <button
<textarea class="flex items-center gap-2 flex-1 min-w-0"
class="w-full h-[300px] sm:h-[500px] font-mono p-2 bg-gray-800 text-white" onclick={() => loadFileContent(file.name)}
bind:value={content} class:font-bold={selectedFile === file.name}
></textarea> >
{:else} <FileIcon class="w-4 h-4 flex-shrink-0" />
<pre <span class="text-sm truncate">{file.name}</span>
class="bg-gray-800 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px]">{content}</pre> <span class="text-xs opacity-60 ml-auto flex-shrink-0">{formatBytes(file.size)}</span>
{/if} </button>
{/await} <div class="flex gap-1 opacity-0 group-hover:opacity-100 flex-shrink-0">
{:else} <button
<div class="text-center text-gray-500">Select a file to view its contents</div> class="btn btn-xs btn-ghost btn-square"
{/if} onclick={() => handleDownload(file.name)}
</div> title="Download"
>
<DownloadIcon class="w-4 h-4 text-info" />
</button>
<button
class="btn btn-xs btn-ghost btn-square"
onclick={() => handleDelete(file.name, false)}
title="Delete"
>
<TrashIcon class="w-4 h-4 text-error" />
</button>
</div>
</div>
{/each}
{#if files.length === 0 && directories.length === 0}
<div class="text-center text-base-content/50 py-8">
Directory is empty
</div>
{/if}
{/if}
</div>
<!-- File Content -->
<div class="flex-1 min-w-0">
{#if selectedFile}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 gap-2">
<h3 class="text-lg font-semibold truncate">{selectedFile}</h3>
<div class="flex gap-2">
{#if isEditing}
<button class="btn btn-sm btn-primary" onclick={saveFileContent}>
Save
</button>
<button class="btn btn-sm btn-ghost" onclick={() => {
isEditing = false
loadFileContent(selectedFile)
}}>
Cancel
</button>
{:else}
<button class="btn btn-sm btn-primary" onclick={() => (isEditing = true)}>
Edit
</button>
<button class="btn btn-sm btn-ghost" onclick={() => handleDownload(selectedFile)}>
<DownloadIcon class="w-4 h-4 mr-1" />
Download
</button>
<button class="btn btn-sm btn-error" onclick={() => handleDelete(selectedFile, false)}>
Delete
</button>
{/if}
</div>
</div>
{#if fileLoading}
<Spinner />
{:else if isEditing}
<textarea
class="textarea textarea-bordered w-full h-[300px] sm:h-[500px] font-mono text-sm"
bind:value={fileContent}
></textarea>
{:else}
<pre class="bg-base-200 p-4 rounded overflow-auto max-h-[300px] sm:max-h-[500px] text-sm">{fileContent}</pre>
{/if}
{:else}
<div class="text-center text-base-content/50 py-16">
Select a file to view its contents
</div>
{/if}
</div>
</div> </div>
<!-- </SettingsCard> -->
@@ -30,7 +30,7 @@
{#if expanded} {#if expanded}
<ul class="ml-4 border-l border-gray-600 mt-1"> <ul class="ml-4 border-l border-gray-600 mt-1">
{#each Object.entries(files) as [itemName, content]} {#each Object.entries(files) as [itemName, content] (itemName)}
<li class="py-1"> <li class="py-1">
{#if typeof content === 'object'} {#if typeof content === 'object'}
<Folder name={itemName} files={content} {selected} {onDelete} /> <Folder name={itemName} files={content} {selected} {onDelete} />
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount, onDestroy } from 'svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -21,17 +21,18 @@
let temperatureChart: Chart let temperatureChart: Chart
onMount(() => { onMount(() => {
analytics.listen()
heapChart = new Chart(heapChartElement, { heapChart = new Chart(heapChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.map(datapoint => datapoint.uptime),
datasets: [ datasets: [
{ {
label: 'Used Heap', label: 'Used Heap',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.used_heap, data: $analytics.map(datapoint => datapoint.totalHeap - datapoint.freeHeap),
fill: true, fill: true,
yAxisID: 'y' yAxisID: 'y'
} }
@@ -77,7 +78,7 @@
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.total_heap[0]), max: Math.round($analytics[0]?.totalHeap ?? 0),
grid: { color: daisyColor('--color-base-content', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--color-base-content') color: daisyColor('--color-base-content')
@@ -90,14 +91,14 @@
filesystemChart = new Chart(filesystemChartElement, { filesystemChart = new Chart(filesystemChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.map(datapoint => datapoint.uptime),
datasets: [ datasets: [
{ {
label: 'File System Used', label: 'File System Used',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.fs_used, data: $analytics.map(datapoint => datapoint.fsUsed),
fill: true, fill: true,
yAxisID: 'y' yAxisID: 'y'
} }
@@ -143,7 +144,7 @@
}, },
position: 'left', position: 'left',
min: 0, min: 0,
max: Math.round($analytics.fs_total[0]), max: Math.round($analytics[0]?.fsTotal ?? 0),
grid: { color: daisyColor('--color-base-content', 10) }, grid: { color: daisyColor('--color-base-content', 10) },
ticks: { ticks: {
color: daisyColor('--color-base-content') color: daisyColor('--color-base-content')
@@ -156,14 +157,14 @@
temperatureChart = new Chart(temperatureChartElement, { temperatureChart = new Chart(temperatureChartElement, {
type: 'line', type: 'line',
data: { data: {
labels: $analytics.uptime, labels: $analytics.map(datapoint => datapoint.uptime),
datasets: [ datasets: [
{ {
label: 'Core Temperature', label: 'Core Temperature',
borderColor: daisyColor('--color-primary'), borderColor: daisyColor('--color-primary'),
backgroundColor: daisyColor('--color-primary', 50), backgroundColor: daisyColor('--color-primary', 50),
borderWidth: 2, borderWidth: 2,
data: $analytics.core_temp, data: $analytics.map(datapoint => datapoint.coreTemp),
yAxisID: 'y' yAxisID: 'y'
} }
] ]
@@ -221,19 +222,23 @@
setInterval(updateData, 500) setInterval(updateData, 500)
}) })
onDestroy(() => analytics.stop())
function updateData() { function updateData() {
heapChart.data.labels = $analytics.uptime heapChart.data.labels = $analytics.map(datapoint => datapoint.uptime)
heapChart.data.datasets[0].data = $analytics.used_heap heapChart.data.datasets[0].data = $analytics.map(
heapChart.options.scales!.y!.max = Math.ceil($analytics.total_heap[0]) datapoint => datapoint.totalHeap - datapoint.freeHeap
)
heapChart.options.scales!.y!.max = Math.ceil($analytics[0]?.totalHeap ?? 0)
heapChart.update('none') heapChart.update('none')
filesystemChart.data.labels = $analytics.uptime filesystemChart.data.labels = $analytics.map(datapoint => datapoint.uptime)
filesystemChart.data.datasets[0].data = $analytics.fs_used filesystemChart.data.datasets[0].data = $analytics.map(datapoint => datapoint.fsUsed)
heapChart.options.scales!.y!.max = Math.ceil($analytics.fs_total[0]) filesystemChart.options.scales!.y!.max = Math.ceil($analytics[0]?.fsTotal ?? 0)
filesystemChart.update('none') filesystemChart.update('none')
temperatureChart.data.labels = $analytics.uptime temperatureChart.data.labels = $analytics.map(datapoint => datapoint.uptime)
temperatureChart.data.datasets[0].data = $analytics.core_temp temperatureChart.data.datasets[0].data = $analytics.map(datapoint => datapoint.coreTemp)
temperatureChart.update('none') temperatureChart.update('none')
} }
</script> </script>
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import type { ComponentType } from 'svelte' import type { Component } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte' import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { type SystemInformation, type Analytics, MessageTopic } from '$lib/types/models'
import { socket } from '$lib/stores/socket' import { socket } from '$lib/stores/socket'
import { api } from '$lib/api' import { api } from '$lib/api'
import { convertSeconds } from '$lib/utilities' import { convertSeconds } from '$lib/utilities'
@@ -32,29 +31,37 @@
} from '$lib/components/icons' } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import ActionButton from './ActionButton.svelte' import ActionButton from './ActionButton.svelte'
import { AnalyticsData, type SystemInformation } from '$lib/platform_shared/message'
import Error from '../../+error.svelte'
import { notifications } from '$lib/components/toasts/notifications'
const features = useFeatureFlags() const features = useFeatureFlags()
let systemInformation: SystemInformation | null = $state(null) let systemInformation: SystemInformation | null = $state(null)
async function getSystemStatus() { async function getSystemStatus() {
const result = await api.get<SystemInformation>('/api/system/status') socket
if (result.isErr()) { .request({ systemInformationRequest: {} })
console.error('Error:', result.inner) .then(response => {
return if (response.systemInformationResponse) {
} systemInformation = response.systemInformationResponse
systemInformation = result.inner return systemInformation;
return systemInformation } else { throw new TypeError("System Information not found in reponse") }
})
return
} }
const postFactoryReset = async () => await api.post('/api/system/reset') const postFactoryReset = async () => await api.post('/api/system/reset')
const postSleep = async () => await api.post('api/sleep') const postSleep = async () => await api.post('api/sleep')
onMount(() => socket.on(MessageTopic.analytics, handleSystemData)) let unsub: (() => void) | undefined = undefined
onMount(() => (unsub = socket.on(AnalyticsData, handleSystemData)))
onDestroy(() => {
if (unsub) unsub()
})
onDestroy(() => socket.off(MessageTopic.analytics, handleSystemData)) const handleSystemData = (data: AnalyticsData) => {
const handleSystemData = (data: Analytics) => {
if (systemInformation) { if (systemInformation) {
systemInformation = { systemInformation = {
...systemInformation, ...systemInformation,
@@ -111,7 +118,7 @@
} }
interface ActionButtonDef { interface ActionButtonDef {
icon: ComponentType icon: Component
label: string label: string
onClick: () => void onClick: () => void
type?: string type?: string
@@ -159,58 +166,63 @@
<StatusItem <StatusItem
icon={CPU} icon={CPU}
title="Chip" title="Chip"
description={`${systemInformation.cpu_type} Rev ${systemInformation.cpu_rev}`} description={`${systemInformation.staticSystemInformation?.cpuType} Rev ${systemInformation.staticSystemInformation?.cpuRev}`}
/> />
<StatusItem <StatusItem
icon={SDK} icon={SDK}
title="SDK Version" title="SDK Version"
description={`ESP-IDF ${systemInformation.sdk_version} / Arduino ${systemInformation.arduino_version}`} description={`ESP-IDF ${systemInformation.staticSystemInformation?.sdkVersion} / Arduino ${systemInformation.staticSystemInformation?.arduinoVersion}`}
/> />
<StatusItem <StatusItem
icon={CPP} icon={CPP}
title="Firmware Version" title="Firmware Version"
description={systemInformation.firmware_version} description={systemInformation.staticSystemInformation?.firmwareVersion}
/> />
<StatusItem <StatusItem
icon={Speed} icon={Speed}
title="CPU Frequency" title="CPU Frequency"
description={`${systemInformation.cpu_freq_mhz} MHz ${ description={`${systemInformation.staticSystemInformation?.cpuFreqMhz} MHz ${
systemInformation.cpu_cores == 2 ? 'Dual Core' : 'Single Core' systemInformation.staticSystemInformation?.cpuCores == 2 ?
'Dual Core'
: 'Single Core'
}`} }`}
/> />
<StatusItem <StatusItem
icon={Heap} icon={Heap}
title="Heap (Free / Max Alloc)" title="Heap (Free / Max Alloc)"
description={`${systemInformation.free_heap} / ${systemInformation.max_alloc_heap} bytes`} description={`${systemInformation.analyticsData?.freeHeap} / ${systemInformation.analyticsData?.maxAllocHeap} bytes`}
/> />
<StatusItem <StatusItem
icon={Pyramid} icon={Pyramid}
title="PSRAM (Size / Free)" title="PSRAM (Size / Free)"
description={`${systemInformation.psram_size} / ${systemInformation.psram_size} bytes`} description={`${systemInformation.analyticsData!.psramSize - systemInformation.analyticsData!.freePsram} / ${systemInformation.analyticsData?.psramSize} bytes`}
/> />
<StatusItem <StatusItem
icon={Sketch} icon={Sketch}
title="Sketch (Used / Free)" title="Sketch (Used / Free)"
description={`${( description={`${(
(systemInformation.sketch_size / systemInformation.free_sketch_space) * (systemInformation.staticSystemInformation!.sketchSize /
systemInformation.staticSystemInformation!.freeSketchSpace) *
100 100
).toFixed(1)} % of ).toFixed(1)} % of
${systemInformation.free_sketch_space / 1000000} MB used (${ ${systemInformation.staticSystemInformation!.freeSketchSpace / 1000000} MB used (${
(systemInformation.free_sketch_space - systemInformation.sketch_size) / 1000000 (systemInformation.staticSystemInformation!.freeSketchSpace -
systemInformation.staticSystemInformation!.sketchSize) /
1000000
} MB free)`} } MB free)`}
/> />
<StatusItem <StatusItem
icon={Flash} icon={Flash}
title="Flash Chip (Size / Speed)" title="Flash Chip (Size / Speed)"
description={`${systemInformation.flash_chip_size / 1000000} MB / ${ description={`${systemInformation.staticSystemInformation!.flashChipSize / 1000000} MB / ${
systemInformation.flash_chip_speed / 1000000 systemInformation.staticSystemInformation!.flashChipSpeed / 1000000
} MHz`} } MHz`}
/> />
@@ -218,10 +230,15 @@
icon={Folder} icon={Folder}
title="File System (Used / Total)" title="File System (Used / Total)"
description={`${( description={`${(
(systemInformation.fs_used / systemInformation.fs_total) * (systemInformation.analyticsData!.fsUsed /
systemInformation.analyticsData!.fsTotal) *
100 100
).toFixed(1)} % of ${systemInformation.fs_total / 1000000} MB used (${ ).toFixed(
(systemInformation.fs_total - systemInformation.fs_used) / 1000000 1
)} % of ${systemInformation.analyticsData!.fsTotal / 1000000} MB used (${
(systemInformation.analyticsData!.fsTotal -
systemInformation.analyticsData!.fsUsed) /
1000000
} }
MB free)`} MB free)`}
/> />
@@ -230,22 +247,22 @@
icon={Temperature} icon={Temperature}
title="Core Temperature" title="Core Temperature"
description={`${ description={`${
systemInformation.core_temp == 53.33 ? systemInformation.analyticsData!.coreTemp == 53.33 ?
'NaN' 'NaN'
: systemInformation.core_temp.toFixed(2) + ' °C' : systemInformation.analyticsData!.coreTemp.toFixed(2) + ' °C'
}`} }`}
/> />
<StatusItem <StatusItem
icon={Stopwatch} icon={Stopwatch}
title="Uptime" title="Uptime"
description={convertSeconds(systemInformation.uptime)} description={convertSeconds(systemInformation.analyticsData!.uptime)}
/> />
<StatusItem <StatusItem
icon={Power} icon={Power}
title="Reset Reason" title="Reset Reason"
description={systemInformation.cpu_reset_reason} description={systemInformation.staticSystemInformation?.cpuResetReason}
/> />
</div> </div>
{/if} {/if}
@@ -253,7 +270,7 @@
</div> </div>
<div class="mt-4 flex flex-wrap justify-end gap-2"> <div class="mt-4 flex flex-wrap justify-end gap-2">
{#each actionButtons as button} {#each actionButtons as button (button.label)}
{#if button.condition === undefined || button.condition()} {#if button.condition === undefined || button.condition()}
<ActionButton <ActionButton
onclick={button.onClick} onclick={button.onClick}
@@ -108,7 +108,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each githubReleases as release} {#each githubReleases as release (release.tag_name)}
<tr <tr
class={( class={(
compareVersions( compareVersions(
@@ -119,8 +119,8 @@
'bg-primary text-primary-content' 'bg-primary text-primary-content'
: 'bg-base-100 h-14'} : 'bg-base-100 h-14'}
> >
<td align="left" class="text-base font-semibold"> <td align="left" class="text-base font-semibold"
<a ><!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL --><a
href={release.html_url} href={release.html_url}
class="link link-hover" class="link link-hover"
target="_blank" target="_blank"
+55 -41
View File
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { preventDefault } from 'svelte/legacy'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -8,33 +6,46 @@
import SettingsCard from '$lib/components/SettingsCard.svelte' import SettingsCard from '$lib/components/SettingsCard.svelte'
import { notifications } from '$lib/components/toasts/notifications' import { notifications } from '$lib/components/toasts/notifications'
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import type { ApSettings, ApStatus } from '$lib/types/models'
import { api } from '$lib/api' import { api } from '$lib/api'
import { ipToUint32, uint32ToIp, isValidIpString } from '$lib/utilities'
import { AP, Devices, Home, MAC } from '$lib/components/icons' import { AP, Devices, Home, MAC } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import { APSettings, APStatus, Request, Response } from '$lib/platform_shared/api'
import { input } from '$lib/stores'
let apSettings: ApSettings | null = $state(null) let apSettings: APSettings | null = $state(null)
let apStatus: ApStatus | null = $state(null) let apStatus: APStatus | null = $state(null)
let formField: Record<string, unknown> = $state() let ipDisplay = $state({
local_ip: '',
gateway_ip: '',
subnet_mask: ''
})
let formField: Record<string, unknown> = $state({})
async function getAPStatus() { async function getAPStatus() {
const result = await api.get<ApStatus>('/api/wifi/ap/status') const result = await api.get<Response>('/api/ap/status')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
apStatus = result.inner
return apStatus apStatus = result.inner.apStatus!
} }
async function getAPSettings() { async function getAPSettings() {
const result = await api.get<ApSettings>('/api/wifi/ap/settings') const result = await api.get<Response>('/api/ap/settings')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
apSettings = result.inner apSettings = result.inner.apSettings!
ipDisplay = {
local_ip: uint32ToIp(apSettings.localIp),
gateway_ip: uint32ToIp(apSettings.gatewayIp),
subnet_mask: uint32ToIp(apSettings.subnetMask)
}
return apSettings return apSettings
} }
@@ -76,22 +87,28 @@
subnet_mask: false subnet_mask: false
}) })
async function postAPSettings(data: ApSettings) { async function postAPSettings(data: APSettings) {
const result = await api.post<ApSettings>('/api/wifi/ap/settings', data) const result = await api.post_proto<Response>('/api/ap/settings', Request.create({ apSettings: data }))
if (result.isErr()) { if (result.isErr()) {
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000)
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
if (result.inner.statusCode !== 200) {
notifications.error(result.inner.errorMessage || 'Failed to update settings', 3000)
return
}
if (result.inner.apSettings) {
apSettings = result.inner.apSettings
}
notifications.success('Access Point settings updated.', 3000) notifications.success('Access Point settings updated.', 3000)
apSettings = result.inner
} }
function handleSubmitAP() { function handleSubmitAP(e: Event) {
e.preventDefault()
if (!apSettings) return if (!apSettings) return
let valid = true let valid = true
// Validate SSID
if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) { if (apSettings.ssid.length < 3 || apSettings.ssid.length > 32) {
valid = false valid = false
formErrors.ssid = true formErrors.ssid = true
@@ -99,7 +116,6 @@
formErrors.ssid = false formErrors.ssid = false
} }
// Validate Channel
let channel = Number(apSettings.channel) let channel = Number(apSettings.channel)
if (1 > channel || channel > 13) { if (1 > channel || channel > 13) {
valid = false valid = false
@@ -108,8 +124,7 @@
formErrors.channel = false formErrors.channel = false
} }
// Validate max_clients let maxClients = Number(apSettings.maxClients)
let maxClients = Number(apSettings.max_clients)
if (1 > maxClients || maxClients > 8) { if (1 > maxClients || maxClients > 8) {
valid = false valid = false
formErrors.max_clients = true formErrors.max_clients = true
@@ -117,36 +132,31 @@
formErrors.max_clients = false formErrors.max_clients = false
} }
// RegEx for IPv4 if (!isValidIpString(ipDisplay.gateway_ip)) {
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
// Validate gateway IP
if (!regexExp.test(apSettings.gateway_ip)) {
valid = false valid = false
formErrors.gateway_ip = true formErrors.gateway_ip = true
} else { } else {
formErrors.gateway_ip = false formErrors.gateway_ip = false
} }
// Validate Subnet Mask if (!isValidIpString(ipDisplay.subnet_mask)) {
if (!regexExp.test(apSettings.subnet_mask)) {
valid = false valid = false
formErrors.subnet_mask = true formErrors.subnet_mask = true
} else { } else {
formErrors.subnet_mask = false formErrors.subnet_mask = false
} }
// Validate local IP if (!isValidIpString(ipDisplay.local_ip)) {
if (!regexExp.test(apSettings.local_ip)) {
valid = false valid = false
formErrors.local_ip = true formErrors.local_ip = true
} else { } else {
formErrors.local_ip = false formErrors.local_ip = false
} }
// Submit JSON to REST API
if (valid) { if (valid) {
apSettings.localIp = ipToUint32(ipDisplay.local_ip)
apSettings.gatewayIp = ipToUint32(ipDisplay.gateway_ip)
apSettings.subnetMask = ipToUint32(ipDisplay.subnet_mask)
postAPSettings(apSettings) postAPSettings(apSettings)
} }
} }
@@ -175,14 +185,18 @@
description={apStatusDescription[apStatus.status]} description={apStatusDescription[apStatus.status]}
/> />
<StatusItem icon={Home} title="IP Address" description={apStatus.ip_address} /> <StatusItem
icon={Home}
title="IP Address"
description={uint32ToIp(apStatus.ipAddress)}
/>
<StatusItem icon={MAC} title="MAC Address" description={apStatus.mac_address} /> <StatusItem icon={MAC} title="MAC Address" description={apStatus.macAddress} />
<StatusItem <StatusItem
icon={Devices} icon={Devices}
title="AP Clients" title="AP Clients"
description={apStatus.station_num} description={apStatus.stationNum}
/> />
</div> </div>
{/if} {/if}
@@ -205,7 +219,7 @@
> >
<form <form
class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 p-0s sm:grid-cols-2"
onsubmit={preventDefault(handleSubmitAP)} onsubmit={handleSubmitAP}
novalidate novalidate
bind:this={formField} bind:this={formField}
> >
@@ -216,9 +230,9 @@
<select <select
class="select select-bordered w-full" class="select select-bordered w-full"
id="apmode" id="apmode"
bind:value={apSettings.provision_mode} bind:value={apSettings.provisionMode}
> >
{#each provisionMode as mode} {#each provisionMode as mode (mode.id)}
<option value={mode.id}> <option value={mode.id}>
{mode.text} {mode.text}
</option> </option>
@@ -296,7 +310,7 @@
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
bind:value={apSettings.max_clients} bind:value={apSettings.maxClients}
id="clients" id="clients"
required required
/> />
@@ -320,7 +334,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.local_ip} bind:value={ipDisplay.local_ip}
id="localIP" id="localIP"
required required
/> />
@@ -345,7 +359,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.gateway_ip} bind:value={ipDisplay.gateway_ip}
id="gateway" id="gateway"
required required
/> />
@@ -369,7 +383,7 @@
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={apSettings.subnet_mask} bind:value={ipDisplay.subnet_mask}
id="subnet" id="subnet"
required required
/> />
@@ -384,7 +398,7 @@
<label class="label my-auto cursor-pointer justify-start gap-4"> <label class="label my-auto cursor-pointer justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
bind:checked={apSettings.ssid_hidden} bind:checked={apSettings.ssidHidden}
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
/> />
<span class="">Hide SSID</span> <span class="">Hide SSID</span>
+23 -10
View File
@@ -6,33 +6,46 @@
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import type { MDNSStatus, MDNSServiceItem, MDNSServiceQuery } from '$lib/types/models' import {
type MDNSStatus,
type MDNSQueryResult,
Request,
type Response as ProtoResponse
} from '$lib/platform_shared/api'
import { compareIp } from '$lib/utilities' import { compareIp } from '$lib/utilities'
let mdnsStatus: MDNSStatus | undefined = $state() let mdnsStatus = $state<MDNSStatus | undefined>()
let services: MDNSServiceItem[] = $state([]) let services = $state<MDNSQueryResult[]>([])
let isLoading = $state(false) let isLoading = $state(false)
const getMDNSStatus = async () => { const getMDNSStatus = async () => {
const result = await api.get<MDNSStatus>('/api/mdns/status') const result = await api.get<ProtoResponse>('/api/mdns/status')
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
return return
} }
mdnsStatus = result.inner if (result.inner.mdnsStatus) {
mdnsStatus = result.inner.mdnsStatus
}
} }
const queryMDNSServices = async () => { const queryMDNSServices = async () => {
isLoading = true isLoading = true
const result = await api.post<MDNSServiceQuery>('/api/mdns/query', { const request = Request.create({
service: 'http', mdnsQueryRequest: {
protocol: 'tcp' service: 'http',
protocol: 'tcp'
}
}) })
const result = await api.post_proto<ProtoResponse>('/api/mdns/query', request)
if (result.isErr()) { if (result.isErr()) {
console.error('Error:', result.inner) console.error('Error:', result.inner)
isLoading = false
return return
} }
services = result.inner.services.sort((a, b) => compareIp(a.ip, b.ip)) if (result.inner.mdnsQueryResponse) {
services = result.inner.mdnsQueryResponse.services.sort((a, b) => compareIp(a.ip, b.ip))
}
isLoading = false isLoading = false
} }
@@ -88,7 +101,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each services as service} {#each services as service (service.ip)}
<tr> <tr>
<td><Devices class="h-6 w-6" /></td> <td><Devices class="h-6 w-6" /></td>
<td>{service.name}</td> <td>{service.name}</td>
+14 -12
View File
@@ -3,14 +3,14 @@
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte' import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import type { NetworkItem, NetworkList } from '$lib/types/models' import { type WifiNetworkScan, type Response as ProtoResponse } from '$lib/platform_shared/api'
import { api } from '$lib/api' import { api } from '$lib/api'
import { AP, Network, Reload, Cancel } from '$lib/components/icons' import { AP, Network, Reload, Cancel } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals' import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
let { isOpen, storeNetwork }: ModalProps = $props() let { isOpen, storeNetwork }: ModalProps = $props()
const encryptionType = [ const encryptionTypes = [
'Open', 'Open',
'WEP', 'WEP',
'WPA PSK', 'WPA PSK',
@@ -22,7 +22,7 @@
'WAPI PSK' 'WAPI PSK'
] ]
let listOfNetworks: NetworkItem[] = $state([]) let listOfNetworks = $state<WifiNetworkScan[]>([])
let scanActive = $state(false) let scanActive = $state(false)
@@ -38,19 +38,21 @@
} }
async function pollingResults() { async function pollingResults() {
const result = await api.get<NetworkList>('/api/wifi/networks') const result = await api.get<ProtoResponse>('/api/wifi/networks')
if (result.isErr()) { if (result.isErr() || !result.inner) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return false return false
} }
let response = result.inner // Check if scan is complete (status 200 means we have results)
listOfNetworks = response.networks if (result.inner.statusCode === 200 && result.inner.wifiNetworkList) {
scanActive = false listOfNetworks = result.inner.wifiNetworkList.networks ?? []
if (listOfNetworks.length) { scanActive = false
clearInterval(pollingId) clearInterval(pollingId)
pollingId = 0 pollingId = 0
return listOfNetworks.length
} }
return listOfNetworks.length // Still scanning (status 202)
return 0
} }
onMount(() => { onMount(() => {
@@ -87,7 +89,7 @@
</div> </div>
{:else} {:else}
<ul class="menu"> <ul class="menu">
{#each listOfNetworks as network} {#each listOfNetworks as network (network.ssid)}
<li> <li>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
@@ -106,7 +108,7 @@
<div> <div>
<div class="font-bold">{network.ssid}</div> <div class="font-bold">{network.ssid}</div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
Security: {encryptionType[network.encryption_type]}, Security: {encryptionTypes[network.encryptionType]},
Channel: {network.channel} Channel: {network.channel}
</div> </div>
</div> </div>
+126 -101
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { modals } from 'svelte-modals' import { modals } from 'svelte-modals'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing' import { cubicOut } from 'svelte/easing'
@@ -12,13 +11,14 @@
import Spinner from '$lib/components/Spinner.svelte' import Spinner from '$lib/components/Spinner.svelte'
import InfoDialog from '$lib/components/InfoDialog.svelte' import InfoDialog from '$lib/components/InfoDialog.svelte'
import { import {
MessageTopic, type WifiStatus,
type KnownNetworkItem,
type WifiSettings, type WifiSettings,
type WifiStatus type WifiNetwork,
} from '$lib/types/models' type Response as ProtoResponse,
import { socket } from '$lib/stores' Request
} from '$lib/platform_shared/api'
import { api } from '$lib/api' import { api } from '$lib/api'
import { ipToUint32, uint32ToIp, isValidIpString } from '$lib/utilities'
import { import {
Cancel, Cancel,
Delete, Delete,
@@ -40,18 +40,26 @@
} from '$lib/components/icons' } from '$lib/components/icons'
import StatusItem from '$lib/components/StatusItem.svelte' import StatusItem from '$lib/components/StatusItem.svelte'
let networkEditable: KnownNetworkItem = $state({ let networkEditable: WifiNetwork = $state({
ssid: '', ssid: '',
password: '', password: '',
static_ip_config: false, staticIpConfig: false,
local_ip: undefined, localIp: 0,
subnet_mask: undefined, subnetMask: 0,
gateway_ip: undefined, gatewayIp: 0,
dns_ip_1: undefined, dnsIp1: 0,
dns_ip_2: undefined dnsIp2: 0
}) })
let static_ip_config = $state(false) let ipDisplay = $state({
localIp: '',
subnetMask: '',
gatewayIp: '',
dnsIp1: '',
dnsIp2: ''
})
let staticIpConfig = $state(false)
let newNetwork: boolean = $state(true) let newNetwork: boolean = $state(true)
let showNetworkEditor: boolean = $state(false) let showNetworkEditor: boolean = $state(false)
@@ -59,61 +67,60 @@
let wifiStatus: WifiStatus | null = $state(null) let wifiStatus: WifiStatus | null = $state(null)
let wifiSettings: WifiSettings | null = $state(null) let wifiSettings: WifiSettings | null = $state(null)
let dndNetworkList: KnownNetworkItem[] = $state([]) let dndNetworkList: WifiNetwork[] = $state([])
let showWifiDetails = $state(false) let showWifiDetails = $state(false)
let formField: Record<string, unknown> = $state() let formField: Record<string, unknown> = $state({})
let formErrors = $state({ let formErrors = $state({
ssid: false, ssid: false,
local_ip: false, localIp: false,
gateway_ip: false, gatewayIp: false,
subnet_mask: false, subnetMask: false,
dns_1: false, dnsIp1: false,
dns_2: false dnsIp2: false
}) })
let formErrorhostname = $state(false) let formErrorhostname = $state(false)
async function getWifiStatus() { async function getWifiStatus() {
const result = await api.get<WifiStatus>('/api/wifi/sta/status') const result = await api.get<ProtoResponse>('/api/wifi/sta/status')
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return return
} }
wifiStatus = result.inner if (result.inner.wifiStatus) {
wifiStatus = result.inner.wifiStatus
}
return wifiStatus return wifiStatus
} }
async function getWifiSettings() { async function getWifiSettings() {
const result = await api.get<WifiSettings>('/api/wifi/sta/settings') const result = await api.get<ProtoResponse>('/api/wifi/sta/settings')
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
return return
} }
wifiSettings = result.inner wifiSettings = result.inner.wifiSettings!
dndNetworkList = wifiSettings.wifi_networks dndNetworkList = wifiSettings.wifiNetworks
return wifiSettings return wifiSettings
} }
onDestroy(() => socket.off(MessageTopic.WiFiSettings))
onMount(() => {
socket.on<WifiSettings>(MessageTopic.WiFiSettings, data => {
wifiSettings = data
dndNetworkList = wifiSettings.wifi_networks
})
})
async function postWiFiSettings(data: WifiSettings) { async function postWiFiSettings(data: WifiSettings) {
const result = await api.post<WifiSettings>('/api/wifi/sta/settings', data) const result = await api.post_proto<ProtoResponse>('/api/wifi/sta/settings', Request.create({ wifiSettings: data }))
if (result.isErr()) { if (result.isErr()) {
console.error(`Error occurred while fetching: `, result.inner) console.error(`Error occurred while fetching: `, result.inner)
notifications.error('User not authorized.', 3000) notifications.error('User not authorized.', 3000)
return return
} }
wifiSettings = result.inner if (result.inner.statusCode !== 200) {
notifications.error(result.inner.errorMessage || 'Failed to update settings', 3000)
return
}
if (result.inner.wifiSettings) {
wifiSettings = result.inner.wifiSettings
}
notifications.success('Wi-Fi settings updated.', 3000) notifications.success('Wi-Fi settings updated.', 3000)
} }
@@ -124,7 +131,7 @@
} else { } else {
formErrorhostname = false formErrorhostname = false
// Update global wifiSettings object // Update global wifiSettings object
wifiSettings.wifi_networks = dndNetworkList wifiSettings.wifiNetworks = dndNetworkList
// Post to REST API // Post to REST API
postWiFiSettings(wifiSettings) postWiFiSettings(wifiSettings)
console.log(wifiSettings) console.log(wifiSettings)
@@ -135,7 +142,6 @@
event.preventDefault() event.preventDefault()
let valid = true let valid = true
// Validate SSID
if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) { if (networkEditable.ssid.length < 3 || networkEditable.ssid.length > 32) {
valid = false valid = false
formErrors.ssid = true formErrors.ssid = true
@@ -143,60 +149,57 @@
formErrors.ssid = false formErrors.ssid = false
} }
networkEditable.static_ip_config = static_ip_config networkEditable.staticIpConfig = staticIpConfig
if (networkEditable.static_ip_config) { if (networkEditable.staticIpConfig) {
// RegEx for IPv4 if (!isValidIpString(ipDisplay.gatewayIp)) {
const regexExp =
/\b(?:(?:2(?:[0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9])\.){3}(?:(?:2([0-4][0-9]|5[0-5])|[0-1]?[0-9]?[0-9]))\b/
// Validate gateway IP
if (!regexExp.test(networkEditable.gateway_ip!)) {
valid = false valid = false
formErrors.gateway_ip = true formErrors.gatewayIp = true
} else { } else {
formErrors.gateway_ip = false formErrors.gatewayIp = false
} }
// Validate Subnet Mask if (!isValidIpString(ipDisplay.subnetMask)) {
if (!regexExp.test(networkEditable.subnet_mask!)) {
valid = false valid = false
formErrors.subnet_mask = true formErrors.subnetMask = true
} else { } else {
formErrors.subnet_mask = false formErrors.subnetMask = false
} }
// Validate local IP if (!isValidIpString(ipDisplay.localIp)) {
if (!regexExp.test(networkEditable.local_ip!)) {
valid = false valid = false
formErrors.local_ip = true formErrors.localIp = true
} else { } else {
formErrors.local_ip = false formErrors.localIp = false
} }
// Validate DNS 1 if (!isValidIpString(ipDisplay.dnsIp1)) {
if (!regexExp.test(networkEditable.dns_ip_1!)) {
valid = false valid = false
formErrors.dns_1 = true formErrors.dnsIp1 = true
} else { } else {
formErrors.dns_1 = false formErrors.dnsIp1 = false
} }
// Validate DNS 2 if (!isValidIpString(ipDisplay.dnsIp2)) {
if (!regexExp.test(networkEditable.dns_ip_2!)) {
valid = false valid = false
formErrors.dns_2 = true formErrors.dnsIp2 = true
} else { } else {
formErrors.dns_2 = false formErrors.dnsIp2 = false
} }
networkEditable.localIp = ipToUint32(ipDisplay.localIp)
networkEditable.subnetMask = ipToUint32(ipDisplay.subnetMask)
networkEditable.gatewayIp = ipToUint32(ipDisplay.gatewayIp)
networkEditable.dnsIp1 = ipToUint32(ipDisplay.dnsIp1)
networkEditable.dnsIp2 = ipToUint32(ipDisplay.dnsIp2)
} else { } else {
formErrors.local_ip = false formErrors.localIp = false
formErrors.subnet_mask = false formErrors.subnetMask = false
formErrors.gateway_ip = false formErrors.gatewayIp = false
formErrors.dns_1 = false formErrors.dnsIp1 = false
formErrors.dns_2 = false formErrors.dnsIp2 = false
} }
// Submit JSON to REST API
if (valid) { if (valid) {
if (newNetwork) { if (newNetwork) {
dndNetworkList.push(networkEditable) dndNetworkList.push(networkEditable)
@@ -204,8 +207,12 @@
dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable) dndNetworkList.splice(dndNetworkList.indexOf(networkEditable), 1, networkEditable)
} }
addNetwork() addNetwork()
dndNetworkList = [...dndNetworkList] //Trigger reactivity dndNetworkList = [...dndNetworkList]
showNetworkEditor = false showNetworkEditor = false
if (wifiSettings) {
wifiSettings.wifiNetworks = dndNetworkList
postWiFiSettings(wifiSettings)
}
} }
} }
@@ -225,12 +232,19 @@
networkEditable = { networkEditable = {
ssid: '', ssid: '',
password: '', password: '',
static_ip_config: false, staticIpConfig: false,
local_ip: undefined, localIp: 0,
subnet_mask: undefined, subnetMask: 0,
gateway_ip: undefined, gatewayIp: 0,
dns_ip_1: undefined, dnsIp1: 0,
dns_ip_2: undefined dnsIp2: 0
}
ipDisplay = {
localIp: '',
subnetMask: '',
gatewayIp: '',
dnsIp1: '',
dnsIp2: ''
} }
} }
@@ -238,6 +252,13 @@
newNetwork = false newNetwork = false
showNetworkEditor = true showNetworkEditor = true
networkEditable = dndNetworkList[index] networkEditable = dndNetworkList[index]
ipDisplay = {
localIp: networkEditable.localIp ? uint32ToIp(networkEditable.localIp) : '',
subnetMask: networkEditable.subnetMask ? uint32ToIp(networkEditable.subnetMask) : '',
gatewayIp: networkEditable.gatewayIp ? uint32ToIp(networkEditable.gatewayIp) : '',
dnsIp1: networkEditable.dnsIp1 ? uint32ToIp(networkEditable.dnsIp1) : '',
dnsIp2: networkEditable.dnsIp2 ? uint32ToIp(networkEditable.dnsIp2) : ''
}
} }
function confirmDelete(index: number) { function confirmDelete(index: number) {
@@ -316,7 +337,7 @@
<StatusItem <StatusItem
icon={Home} icon={Home}
title="IP Address" title="IP Address"
description={wifiStatus.local_ip} description={uint32ToIp(wifiStatus.localIp)}
/> />
<StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}> <StatusItem icon={WiFi} title="RSSI" description={`${wifiStatus.rssi} dBm`}>
@@ -347,7 +368,7 @@
<StatusItem <StatusItem
icon={MAC} icon={MAC}
title="MAC Address" title="MAC Address"
description={wifiStatus.mac_address} description={wifiStatus.macAddress}
/> />
<StatusItem <StatusItem
@@ -359,16 +380,20 @@
<StatusItem <StatusItem
icon={Gateway} icon={Gateway}
title="Gateway IP" title="Gateway IP"
description={wifiStatus.gateway_ip} description={uint32ToIp(wifiStatus.gatewayIp)}
/> />
<StatusItem <StatusItem
icon={Subnet} icon={Subnet}
title="Subnet Mask" title="Subnet Mask"
description={wifiStatus.subnet_mask} description={uint32ToIp(wifiStatus.subnetMask)}
/> />
<StatusItem icon={DNS} title="DNS" description={wifiStatus.dns_ip_1} /> <StatusItem
icon={DNS}
title="DNS"
description={uint32ToIp(wifiStatus.dnsIp1)}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -485,7 +510,7 @@
> >
<input <input
type="checkbox" type="checkbox"
bind:checked={wifiSettings.priority_RSSI} bind:checked={wifiSettings.priorityRssi}
class="checkbox checkbox-primary sm:-mb-5" class="checkbox checkbox-primary sm:-mb-5"
/> />
<span class="sm:-mb-5">Connect to strongest WiFi</span> <span class="sm:-mb-5">Connect to strongest WiFi</span>
@@ -534,13 +559,13 @@
> >
<input <input
type="checkbox" type="checkbox"
bind:checked={static_ip_config} bind:checked={staticIpConfig}
class="checkbox checkbox-primary sm:-mb-5" class="checkbox checkbox-primary sm:-mb-5"
/> />
<span class="sm:-mb-5">Static IP Config?</span> <span class="sm:-mb-5">Static IP Config?</span>
</label> </label>
</div> </div>
{#if static_ip_config} {#if staticIpConfig}
<div <div
class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2" class="grid w-full grid-cols-1 content-center gap-x-4 px-4 sm:grid-cols-2"
transition:slide|local={{ duration: 300, easing: cubicOut }} transition:slide|local={{ duration: 300, easing: cubicOut }}
@@ -552,21 +577,21 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.local_ip formErrors.localIp
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={networkEditable.local_ip} bind:value={ipDisplay.localIp}
id="localIP" id="localIP"
required required
/> />
<label class="label" for="localIP"> <label class="label" for="localIP">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.local_ip formErrors.localIp
) ? ) ?
'' ''
: 'hidden'}">Must be a valid IPv4 address</span : 'hidden'}">Must be a valid IPv4 address</span
@@ -581,20 +606,20 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.gateway_ip formErrors.gatewayIp
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={networkEditable.gateway_ip} bind:value={ipDisplay.gatewayIp}
required required
/> />
<label class="label" for="gateway"> <label class="label" for="gateway">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.gateway_ip formErrors.gatewayIp
) ? ) ?
'' ''
: 'hidden'}">Must be a valid IPv4 address</span : 'hidden'}">Must be a valid IPv4 address</span
@@ -608,20 +633,20 @@
<input <input
type="text" type="text"
class="input input-bordered w-full {( class="input input-bordered w-full {(
formErrors.subnet_mask formErrors.subnetMask
) ? ) ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={networkEditable.subnet_mask} bind:value={ipDisplay.subnetMask}
required required
/> />
<label class="label" for="subnet"> <label class="label" for="subnet">
<span <span
class="label-text-alt text-error {( class="label-text-alt text-error {(
formErrors.subnet_mask formErrors.subnetMask
) ? ) ?
'' ''
: 'hidden'}" : 'hidden'}"
@@ -636,18 +661,18 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.dns_1 ? class="input input-bordered w-full {formErrors.dnsIp1 ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={networkEditable.dns_ip_1} bind:value={ipDisplay.dnsIp1}
required required
/> />
<label class="label" for="gateway"> <label class="label" for="gateway">
<span <span
class="label-text-alt text-error {formErrors.dns_1 ? class="label-text-alt text-error {formErrors.dnsIp1 ?
'' ''
: 'hidden'}" : 'hidden'}"
> >
@@ -661,18 +686,18 @@
</label> </label>
<input <input
type="text" type="text"
class="input input-bordered w-full {formErrors.dns_2 ? class="input input-bordered w-full {formErrors.dnsIp2 ?
'border-error border-2' 'border-error border-2'
: ''}" : ''}"
minlength="7" minlength="7"
maxlength="15" maxlength="15"
size="15" size="15"
bind:value={networkEditable.dns_ip_2} bind:value={ipDisplay.dnsIp2}
required required
/> />
<label class="label" for="subnet"> <label class="label" for="subnet">
<span <span
class="label-text-alt text-error {formErrors.dns_2 ? class="label-text-alt text-error {formErrors.dnsIp2 ?
'' ''
: 'hidden'}" : 'hidden'}"
> >
+3
View File
@@ -17,6 +17,9 @@ const config = {
}), }),
paths: { paths: {
base: basePath base: basePath
},
output: {
bundleStrategy: 'single'
} }
} }
} }
+4 -4
View File
@@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
test('has title', async ({ page }) => { test('has title', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page).toHaveTitle(/Spot micro controller/) await expect(page).toHaveTitle(/Spot micro controller/)
}) })
test('index page has expected h1', async ({ page }) => { test('index page has expected h1', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible() await expect(page.getByRole('heading', { name: 'Spot micro controller' }).first()).toBeVisible()
}) })
+21 -21
View File
@@ -1,28 +1,28 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest'
import { humanFileSize } from '../../src/lib/utilities/string-utilities'; import { humanFileSize } from '../../src/lib/utilities/string-utilities'
describe('humanFileSize', () => { describe('humanFileSize', () => {
it('returns "0B" for 0 bytes', () => { it('returns "0B" for 0 bytes', () => {
expect(humanFileSize(0)).toBe('0B'); expect(humanFileSize(0)).toBe('0B')
}); })
it('returns the size in bytes correctly', () => { it('returns the size in bytes correctly', () => {
expect(humanFileSize(500)).toBe('500B'); expect(humanFileSize(500)).toBe('500B')
}); })
it('returns the size in kB correctly', () => { it('returns the size in kB correctly', () => {
expect(humanFileSize(1024)).toBe('1kB'); expect(humanFileSize(1024)).toBe('1kB')
}); })
it('returns the size in MB correctly', () => { it('returns the size in MB correctly', () => {
expect(humanFileSize(1048576)).toBe('1MB'); // 1024 * 1024 expect(humanFileSize(1048576)).toBe('1MB') // 1024 * 1024
}); })
it('returns the size in GB correctly', () => { it('returns the size in GB correctly', () => {
expect(humanFileSize(1073741824)).toBe('1GB'); // 1024 * 1024 * 1024 expect(humanFileSize(1073741824)).toBe('1GB') // 1024 * 1024 * 1024
}); })
it('rounds to 2 decimal places correctly', () => { it('rounds to 2 decimal places correctly', () => {
expect(humanFileSize(1536)).toBe('1.5kB'); // 1024 + 512 expect(humanFileSize(1536)).toBe('1.5kB') // 1024 + 512
}); })
}); })
+34 -34
View File
@@ -1,44 +1,44 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest'
import { toUint8, toInt8 } from '../../src/lib/utilities/math-utilities'; import { toUint8, toInt8 } from '../../src/lib/utilities/math-utilities'
describe('toUint8', () => { describe('toUint8', () => {
it('min interval value should get 0', () => { it('min interval value should get 0', () => {
expect(toUint8(-1, -1, 1)).toBe(0); expect(toUint8(-1, -1, 1)).toBe(0)
}); })
it('middle interval value should get 128', () => { it('middle interval value should get 128', () => {
expect(toUint8(0, -1, 1)).toBe(128); expect(toUint8(0, -1, 1)).toBe(128)
}); })
it('max interval value should get 255', () => { it('max interval value should get 255', () => {
expect(toUint8(1, -1, 1)).toBe(255); expect(toUint8(1, -1, 1)).toBe(255)
}); })
it('min value should be clamped', () => { it('min value should be clamped', () => {
expect(toUint8(-2, -1, 1)).toBe(0); expect(toUint8(-2, -1, 1)).toBe(0)
}); })
it('max value should be clamped', () => { it('max value should be clamped', () => {
expect(toUint8(2, -1, 1)).toBe(255); expect(toUint8(2, -1, 1)).toBe(255)
}); })
}); })
describe('toInt8', () => { describe('toInt8', () => {
it('min interval value should get -128', () => { it('min interval value should get -128', () => {
expect(toInt8(-1, -1, 1)).toBe(-128); expect(toInt8(-1, -1, 1)).toBe(-128)
}); })
it('middle interval value should get 0', () => { it('middle interval value should get 0', () => {
expect(toInt8(0, -1, 1)).toBe(0); expect(toInt8(0, -1, 1)).toBe(0)
}); })
it('max interval value should get 127', () => { it('max interval value should get 127', () => {
expect(toInt8(1, -1, 1)).toBe(127); expect(toInt8(1, -1, 1)).toBe(127)
}); })
it('min value should be clamped', () => { it('min value should be clamped', () => {
expect(toInt8(-2, -1, 1)).toBe(-128); expect(toInt8(-2, -1, 1)).toBe(-128)
}); })
it('max value should be clamped', () => { it('max value should be clamped', () => {
expect(toInt8(2, -1, 1)).toBe(127); expect(toInt8(2, -1, 1)).toBe(127)
}); })
}); })
+31 -31
View File
@@ -1,39 +1,39 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest'
import { Result } from '../../src/lib/utilities/result'; import { Result } from '../../src/lib/utilities/result'
describe('Result', () => { describe('Result', () => {
it('should create a success result correctly', () => { it('should create a success result correctly', () => {
const successValue = 'Success value'; const successValue = 'Success value'
const result = Result.ok(successValue); const result = Result.ok(successValue)
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true)
expect(result.isErr()).toBe(false); expect(result.isErr()).toBe(false)
expect(result.inner).toBe(successValue); expect(result.inner).toBe(successValue)
}); })
it('should create an error result correctly', () => { it('should create an error result correctly', () => {
const errorMessage = 'Error message'; const errorMessage = 'Error message'
const result = Result.err(errorMessage); const result = Result.err(errorMessage)
expect(result.isOk()).toBe(false); expect(result.isOk()).toBe(false)
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true)
expect(result.inner).toBe(errorMessage); expect(result.inner).toBe(errorMessage)
}); })
it('should type guard success and error results correctly', () => { it('should type guard success and error results correctly', () => {
const successResult = Result.ok(123); const successResult = Result.ok(123)
const errorResult = Result.err('Error'); const errorResult = Result.err('Error')
if (successResult.isOk()) { if (successResult.isOk()) {
expect(typeof successResult.inner).toBe('number'); expect(typeof successResult.inner).toBe('number')
} else { } else {
throw new Error('Expected successResult to be ok'); throw new Error('Expected successResult to be ok')
} }
if (errorResult.isErr()) { if (errorResult.isErr()) {
expect(typeof errorResult.inner).toBe('string'); expect(typeof errorResult.inner).toBe('string')
} else { } else {
throw new Error('Expected errorResult to be fail'); throw new Error('Expected errorResult to be fail')
} }
}); })
}); })
+265
View File
@@ -0,0 +1,265 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { WebSocketServer } from 'ws'
import { decodeMessage, MESSAGE_KEY_TO_TAG, socket } from '../../src/lib/stores/socket'
import { IMUData, PingMsg, PongMsg, Message } from '../../src/lib/platform_shared/message'
// Helper function to create encoded WebSocket messages
function createEncodedMessage(messageType: 'imu' | 'rssi' | 'mode', data: unknown): Uint8Array {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message: any = {}
message[messageType] = data
const wsMessage = Message.create(message)
return Message.encode(wsMessage).finish()
}
describe.sequential('WebSocket Integration Tests', () => {
let wss: WebSocketServer
let TEST_PORT = 8765
beforeEach(async () => {
// Use a different port for each test to avoid conflicts
TEST_PORT++
// Create real WebSocket server
wss = new WebSocketServer({ port: TEST_PORT })
// Wait for server to start
await new Promise<void>(resolve => {
wss.on('listening', () => resolve())
})
})
afterEach(async () => {
// Close all connections and server
wss.clients.forEach(client => client.close())
await new Promise<void>(resolve => {
wss.close(() => resolve())
})
// Wait a bit for cleanup
await new Promise(resolve => setTimeout(resolve, 100))
})
it('should connect to WebSocket server', async () => {
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for connection
await new Promise(resolve => setTimeout(resolve, 100))
let isConnected = false
socket.subscribe(value => {
isConnected = value
})()
expect(isConnected).toBe(true)
})
it('should receive and decode IMU data from server', async () => {
let receivedIMUData: IMUData = null
// Subscribe to IMU messages before connecting
const unsubscribe = socket.on(IMUData, data => {
receivedIMUData = data
})
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for client to connect
await new Promise<void>(resolve => {
wss.on('connection', ws => {
// Server sends IMU data to client
const imuPayload = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
const encodedMessage = createEncodedMessage('imu', imuPayload)
ws.send(encodedMessage)
setTimeout(resolve, 50)
})
})
expect(receivedIMUData).toBeDefined()
expect(receivedIMUData.x).toBe(3.25)
expect(receivedIMUData.y).toBe(2.5)
expect(receivedIMUData.z).toBe(1.75)
expect(receivedIMUData.heading).toBe(10)
expect(receivedIMUData.altitude).toBe(11)
expect(receivedIMUData.bmpTemp).toBe(22)
expect(receivedIMUData.pressure).toBe(23)
unsubscribe()
})
it('should send IMU data from client to server using emit', async () => {
let serverReceivedData: any = null
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
// Wait for client to connect and send data
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Test timeout - server did not receive message'))
}, 3000)
wss.on('connection', ws => {
// console.log('Server: Client connected')
// Server listens for messages from client
ws.on('message', (data: Buffer) => {
// console.log('Server: Received message, length:', data.length)
// Skip empty messages (from ping, etc.)
if (data.length === 0) {
console.log('Server: Skipping empty message (Probably a ping')
return
}
try {
// Decode the protobuf message
const decoded = Message.decode(new Uint8Array(data))
// console.log('Server: Decoded message:', JSON.stringify(decoded, null, 2))
// Only resolve if we got actual IMU data
if (decoded.imu) {
serverReceivedData = decoded
clearTimeout(timeout)
resolve()
} else {
// console.log('Server: Message decoded but no IMU data, waiting...')
}
} catch (error) {
console.error('Server: Failed to decode:', error)
clearTimeout(timeout)
reject(error)
}
})
})
// Wait for WebSocket to be fully connected
setTimeout(() => {
console.log('Client: Sending IMU data...')
// Client sends IMU data to server
const imuData = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
socket.emit(IMUData, imuData)
console.log('Client: emit called')
}, 150)
})
// Verify server received the data
expect(serverReceivedData).toBeDefined()
expect(serverReceivedData?.imu).toBeDefined()
expect(serverReceivedData?.imu.x).toBe(3.25)
expect(serverReceivedData?.imu.y).toBe(2.5)
expect(serverReceivedData?.imu.z).toBe(1.75)
expect(serverReceivedData?.imu.heading).toBe(10)
expect(serverReceivedData?.imu.altitude).toBe(11)
expect(serverReceivedData?.imu.bmpTemp).toBe(22)
expect(serverReceivedData?.imu.pressure).toBe(23)
})
it('should fail to serialize data on emit', async () => {
// Connect socket
socket.init(`ws://localhost:${TEST_PORT}`)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Test timeout'))
}, 1000)
// Wait for WebSocket to be fully connected
setTimeout(() => {
console.log('Client: Sending invalid message type...')
// Send any invalid message type
const wsm = Message.create()
try {
socket.emit(Message as any, wsm)
clearTimeout(timeout)
reject(new Error('Expected emit to throw, but it did not'))
} catch (e) {
console.log('Client: emit correctly threw error:', e)
clearTimeout(timeout)
resolve()
}
}, 150)
})
})
})
describe('Message Protobuf Encoding/Decoding', () => {
it('should encode and decode IMU data correctly', () => {
const imuData = IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
const encoded = IMUData.encode(imuData).finish()
const decoded = IMUData.decode(encoded)
expect(decoded.x).toBe(3.25)
expect(decoded.y).toBe(2.5)
expect(decoded.z).toBe(1.75)
expect(decoded.heading).toBe(10)
expect(decoded.altitude).toBe(11)
expect(decoded.bmpTemp).toBe(22)
expect(decoded.pressure).toBe(23)
})
it('should encode and decode two empty types correctly', () => {
const encoded_ping = Message.encode(Message.create({ pingmsg: PingMsg.create() })).finish()
const decoded_ping = decodeMessage(encoded_ping.buffer)
expect(decoded_ping.tag).toBe(MESSAGE_KEY_TO_TAG.get('pingmsg'))
const encoded_pong = Message.encode(Message.create({ pongmsg: PongMsg.create() })).finish()
const decoded_pong = decodeMessage(encoded_pong.buffer)
expect(decoded_pong.tag).toBe(MESSAGE_KEY_TO_TAG.get('pongmsg'))
})
it('should encode and decode complete Message', () => {
const original = Message.create({
imu: IMUData.create({
x: 3.25,
y: 2.5,
z: 1.75,
heading: 10,
altitude: 11,
bmpTemp: 22,
pressure: 23
})
})
const encoded = Message.encode(original).finish()
const decoded = Message.decode(encoded)
expect(decoded.imu).toBeDefined()
expect(decoded.imu?.x).toBe(3.25)
expect(decoded.imu?.y).toBe(2.5)
expect(decoded.imu?.z).toBe(1.75)
expect(decoded.imu?.heading).toBe(10)
expect(decoded.imu?.altitude).toBe(11)
expect(decoded.imu?.bmpTemp).toBe(22)
expect(decoded.imu?.pressure).toBe(23)
})
})
+35 -35
View File
@@ -1,46 +1,46 @@
import { describe, it, expect, beforeEach, afterEach, vitest } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vitest } from 'vitest'
import { throttler } from '../../src/lib/utilities/buffer-utilities'; import { Throttler } from '../../src/lib/utilities/buffer-utilities'
describe('throttler', () => { describe('throttler', () => {
let throttleInstance: throttler; let throttleInstance: Throttler
let callback: Function; let callback: () => void
beforeEach(() => { beforeEach(() => {
vitest.useFakeTimers(); vitest.useFakeTimers()
throttleInstance = new throttler(); throttleInstance = new Throttler()
callback = vitest.fn(); callback = vitest.fn()
}); })
afterEach(() => { afterEach(() => {
vitest.useRealTimers(); vitest.useRealTimers()
}); })
it('should call the callback function after the specified time', () => { it('should call the callback function after the specified time', () => {
throttleInstance.throttle(callback, 1000); throttleInstance.throttle(callback, 1000)
expect(callback).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled()
vitest.advanceTimersByTime(1000); vitest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1)
}); })
it('should not call the callback function if throttle is called again within the timeout period', () => { it('should not call the callback function if throttle is called again within the timeout period', () => {
throttleInstance.throttle(callback, 1000); throttleInstance.throttle(callback, 1000)
throttleInstance.throttle(callback, 1000); throttleInstance.throttle(callback, 1000)
vitest.advanceTimersByTime(500); vitest.advanceTimersByTime(500)
expect(callback).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled()
vitest.advanceTimersByTime(500); vitest.advanceTimersByTime(500)
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1)
}); })
it('should allow the callback to be called again after the timeout period', () => { it('should allow the callback to be called again after the timeout period', () => {
throttleInstance.throttle(callback, 1000); throttleInstance.throttle(callback, 1000)
vitest.advanceTimersByTime(1000); vitest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledTimes(1)
throttleInstance.throttle(callback, 1000); throttleInstance.throttle(callback, 1000)
vitest.advanceTimersByTime(1000); vitest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledTimes(2)
}); })
}); })
+13 -8
View File
@@ -1,12 +1,17 @@
import { defineConfig, UserConfigExport } from 'vitest/config' import { defineConfig, UserConfigExport } from 'vitest/config'
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte'
import path from 'path'
const config: UserConfigExport = { const config: UserConfigExport = {
plugins: [svelte()], plugins: [svelte()],
test: { resolve: {
globals: true, alias: {
environment: 'jsdom' $lib: path.resolve(__dirname, './src/lib')
} }
}; },
test: {
globals: true,
environment: 'jsdom'
}
}
export default defineConfig(config) export default defineConfig(config)
+33
View File
@@ -0,0 +1,33 @@
{
"build": {
"core": "esp32",
"extra_flags": [
"-DBOARD_HAS_PSRAM"
],
"f_cpu": "360000000L",
"f_flash": "80000000L",
"f_psram": "200000000L",
"flash_mode": "qio",
"mcu": "esp32p4",
"variant": "esp32p4"
},
"connectivity": [
"wifi"
],
"debug": {
"openocd_target": "esp32p4.cfg"
},
"frameworks": [
"espidf"
],
"name": "ESP32-P4 Dev Board (32MB PSRAM + 32MB Flash, C6 coprocessor)",
"upload": {
"flash_size": "32MB",
"maximum_ram_size": 786432,
"maximum_size": 33554432,
"require_upload_port": true,
"speed": 1500000
},
"url": "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32p4/",
"vendor": "Espressif"
}
+14 -1
View File
@@ -2,7 +2,20 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [0.2.0]
### Added
- Implemented cumulative robot displacement in the visualization [#161](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/161)
- Adds gesture control [#157](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/157)
- Stand mode imu compensation [#155](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/155)
### Changed
- Protobuf replacement for JSON and MsgPack communication between Svelte and ESP32 [#164](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/164)
- Removed the used of Arduino strings [#160](https://github.com/runeharlyk/SpotMicroESP32-Leika/pull/160)
## [0.1.0]
### Added ### Added
+75 -39
View File
@@ -1,44 +1,80 @@
# API # API
<!-- https://dev.bostondynamics.com/docs/concepts/choreography/choreography_in_tablet.html -->
The back end exposes a number of API endpoints which are referenced in the table below. The back end exposes a number of API endpoints which are referenced in the table below.
| Method | Endpoint | Authentication | POST JSON Body | Info | ## System
| ------ | -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| GET | /rest/features | `NONE_REQUIRED` | none | Tells the client which features of the UI should be use |
| GET | /rest/apStatus | `IS_AUTHENTICATED` | none | Current AP status and client information |
| GET | /rest/apSettings | `IS_ADMIN` | none | Current AP settings |
| POST | /rest/apSettings | `IS_ADMIN` | `{"provision_mode": 1,"ssid": "ESP32-SvelteKit-e89f6d20372c","password": "esp-sveltekit","channel": 1,"ssid_hidden": false,"max_clients": 4,"local_ip": "192.168.4.1","gateway_ip": "192.168.4.1","subnet_mask": "255.255.255.0"}` | Update AP settings |
| GET | /rest/wifiStatus | `IS_AUTHENTICATED` | none | Current status of the wifi client connection |
| GET | /rest/scanNetworks | `IS_ADMIN` | none | Async Scan for Networks in Range |
| GET | /rest/listNetworks | `IS_ADMIN` | none | List networks in range after successful scanning. Otherwise triggers scanning. |
| GET | /rest/wifiSettings | `IS_ADMIN` | none | Current WiFi settings |
| POST | /rest/wifiSettings | `IS_ADMIN` | `{"hostname":"esp32-f412fa4495f8","priority_RSSI":true,"wifi_networks":[{"ssid":"YourSSID","password":"YourPassword","static_ip_config":false}]}` | Update WiFi settings and credentials |
| GET | /rest/systemStatus | `IS_AUTHENTICATED` | none | Get system information about the ESP. |
| POST | /rest/restart | `IS_ADMIN` | none | Restart the ESP32 |
| POST | /rest/factoryReset | `IS_ADMIN` | none | Reset the ESP32 and all settings to their default values |
| POST | /rest/uploadFirmware | `IS_ADMIN` | none | File upload of firmware.bin |
| POST | /rest/sleep | `IS_AUTHENTICATED` | none | Puts the device in deep sleep mode |
| POST | /rest/downloadUpdate | `IS_ADMIN` | `{"download_url": "https://github.com/theelims/ESP32-sveltekit/releases/download/v0.1.0/firmware_esp32s3.bin"}` | Download link for OTA. This requires a valid SSL certificate and will follow redirects. |
<!-- | HTTP Method | Endpoint | Description | Parameters | | Method | Endpoint | Description |
|-------------|----------------|----------------------------|---------------------------| | ------ | ------------------- | -------------------------------------------- |
| GET | /api/sensor/mpu | Retrieve the mpu state | | | GET | /api/features | Get enabled features for the UI |
| GET | /api/sensor/magnetometer | Retrieve the magnetometer state | | | GET | /api/system/status | Get system information about the ESP |
| GET | /api/sensor/distances | Retrieve the distances state | | | POST | /api/system/reset | Reset the ESP32 and all settings to defaults |
| GET | /api/sensor/distance/{position} | Retrieve the distance state | `position`: The position of the distance sensor **LEFT** and **RIGHT** | | POST | /api/system/restart | Restart the ESP32 |
| GET | /api/sensor/stream | Retrieve the camera stream | | | POST | /api/system/sleep | Put the device in deep sleep mode |
| GET | /api/actuator | Retrieve the actuator states | |
| GET | /api/actuator/{id} | Retrieve the actuator state for `id` | `id`: The ID of the actuator | ## WiFi
| POST | /api/actuator/{id} | Set the actuator state | `id`: The ID of the actuator|
| GET | /api/kinematics/feet | Retrieve the current feet positions as (x, y, z) coordinates| | | Method | Endpoint | Description |
| GET | /api/kinematics/body | Retrieve the current body position as a (x, y, z) coordinates| | | ------ | ---------------------- | ------------------------------------- |
| GET | /api/kinematics/bodystate | Retrieve the current body and feet positions | | | GET | /api/wifi/sta/settings | Get current WiFi settings |
| GET | /api/system/log | Retrieve the system log | | | POST | /api/wifi/sta/settings | Update WiFi settings and credentials |
| GET | /api/system/info | Retrieve the system information | | | GET | /api/wifi/scan | Trigger async scan for networks |
| GET | /api/system/settings | Retrieve the system settings | | | GET | /api/wifi/networks | List networks in range after scanning |
| POST | /api/system/settings | Set the system settings | | | GET | /api/wifi/sta/status | Get WiFi client connection status |
| POST | /api/system/reset | Reset system | |
| POST | /api/system/power/off | Power of the system | | ## Access Point
| POST | /api/system/stop | Stop power to actuators | `id`: The stop level **CUT**, **SETTLE_THEN_CUT**, **NONE** | -->
| Method | Endpoint | Description |
| ------ | ---------------- | --------------------- |
| GET | /api/ap/status | Get current AP status |
| GET | /api/ap/settings | Get AP settings |
| POST | /api/ap/settings | Update AP settings |
## Camera (if enabled)
| Method | Endpoint | Description |
| ------ | -------------------- | ---------------------- |
| GET | /api/camera/still | Capture a still image |
| GET | /api/camera/stream | Get camera stream |
| GET | /api/camera/settings | Get camera settings |
| POST | /api/camera/settings | Update camera settings |
## Servo
| Method | Endpoint | Description |
| ------ | ----------------- | ----------------------- |
| GET | /api/servo/config | Get servo configuration |
| POST | /api/servo/config | Update servo config |
## Peripherals
| Method | Endpoint | Description |
| ------ | ---------------- | -------------------------- |
| GET | /api/peripherals | Get peripheral settings |
| POST | /api/peripherals | Update peripheral settings |
## mDNS (if enabled)
| Method | Endpoint | Description |
| ------ | ---------------- | -------------------- |
| GET | /api/mdns | Get mDNS settings |
| POST | /api/mdns | Update mDNS settings |
| GET | /api/mdns/status | Get mDNS status |
| POST | /api/mdns/query | Query mDNS services |
## Filesystem
| Method | Endpoint | Description |
| ------ | ----------------- | ---------------- |
| GET | /api/config/\* | Get config file |
| GET | /api/files | List files |
| POST | /api/files | Upload file |
| POST | /api/files/delete | Delete file |
| POST | /api/files/edit | Edit file |
| POST | /api/files/mkdir | Create directory |
## WebSocket
Real-time communication is handled via WebSocket at `/api/ws` using Protocol Buffers.
See [websocket.md](websocket.md) for the full WebSocket API documentation.
-2
View File
@@ -4,8 +4,6 @@ The software make use of a range of different libraries to enhance the functiona
Up to date list can be seen in platformio.ini file. Up to date list can be seen in platformio.ini file.
The libraries includes: The libraries includes:
- Esp32SvelteKit
- PsychicHttp
- ArduinoJson - ArduinoJson
- Adafruit SSD1306 - Adafruit SSD1306
- Adafruit GFX Library - Adafruit GFX Library
+47
View File
@@ -0,0 +1,47 @@
# WebSocket API
The ESP32 exposes a WebSocket endpoint at `/api/ws` for real-time bidirectional communication using Protocol Buffers (protobuf).
## Connection
Connect to the WebSocket at:
```
ws://<device-ip>/api/ws
```
All messages are binary-encoded protobuf `Message` wrappers defined in `platform_shared/message.proto`.
## Message Flow
The WebSocket supports three communication patterns:
1. **Client to Server**: Commands like controller input, mode changes, servo control
2. **Server to Client**: Periodic data broadcasts like IMU, system metrics, RSSI, servo angles
3. **Request-Response**: Use `socket.request()` for operations requiring a response
## Example: Sending Controller Input
```typescript
import { Message, ControllerData } from "./proto/message";
const input: ControllerData = {
left: { x: 0.5, y: 0.0 },
right: { x: 0.0, y: 0.0 },
height: 0.1,
speed: 1.0,
s1: 0.0,
};
const message = Message.encode({ ControllerData: input }).finish();
socket.send(message);
```
## Example: Request-Response
```typescript
const response = await socket.request({ imuCalibrateExecute: {} });
const result = response.imuCalibrateData;
```
See `platform_shared/message.proto` for all available message types and their definitions.
-3
View File
@@ -3,6 +3,3 @@ build_flags =
-D BUILD_TARGET=\"$PIOENV\" -D BUILD_TARGET=\"$PIOENV\"
-D APPLICATION_CORE=0 -D APPLICATION_CORE=0
-D EMBED_WEBAPP=1 -D EMBED_WEBAPP=1
-D USE_MSGPACK=1 ; Use either msgpack or json
-D USE_JSON=0 ; Use either msgpack or json
+1 -14
View File
@@ -1,9 +1,3 @@
; The indicated settings support placeholder substitution as follows:
;
; #{platform} - The microcontroller platform, e.g. "esp32" or "esp8266"
; #{unique_id} - A unique identifier derived from the MAC address, e.g. "0b0a859d6816"
; #{random} - A random number encoded as a hex string, e.g. "55722f94"
[factory_settings] [factory_settings]
build_flags = build_flags =
-D APP_NAME=\"Spot-Micro\" ; [a-zA-Z0-9-_] -D APP_NAME=\"Spot-Micro\" ; [a-zA-Z0-9-_]
@@ -16,7 +10,7 @@ build_flags =
; Access point settings ; Access point settings
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED -D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
-D FACTORY_AP_SSID=\"Spot-Micro-#{unique_id}\" ; 1-64 characters, supports placeholders -D FACTORY_AP_SSID=\"Spot-Micro\" ; 1-64 characters
-D FACTORY_AP_PASSWORD=\"spot-leika\" ; 8-64 characters -D FACTORY_AP_PASSWORD=\"spot-leika\" ; 8-64 characters
-D FACTORY_AP_CHANNEL=1 -D FACTORY_AP_CHANNEL=1
-D FACTORY_AP_SSID_HIDDEN=false -D FACTORY_AP_SSID_HIDDEN=false
@@ -25,16 +19,9 @@ build_flags =
-D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\" -D FACTORY_AP_GATEWAY_IP=\"192.168.4.1\"
-D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\" -D FACTORY_AP_SUBNET_MASK=\"255.255.255.0\"
; OTA settings
-D FACTORY_OTA_PORT=8266
-D FACTORY_OTA_PASSWORD=\"spot-leika\"
-D FACTORY_OTA_ENABLED=true
; Servo settings ; Servo settings
-D FACTORY_SERVO_NUM=12
-D FACTORY_SERVO_OSCILLATOR_FREQUENCY=27000000 -D FACTORY_SERVO_OSCILLATOR_FREQUENCY=27000000
-D FACTORY_SERVO_PWM_FREQUENCY=50 -D FACTORY_SERVO_PWM_FREQUENCY=50
-D FACTORY_SERVO_CENTER_ANGLE=90
; Deep Sleep Configuration ; Deep Sleep Configuration
-D WAKEUP_PIN_NUMBER=38 ; pin number to wake up the ESP -D WAKEUP_PIN_NUMBER=38 ; pin number to wake up the ESP
+12 -11
View File
@@ -1,10 +1,13 @@
#pragma once
#include <template/stateful_service.h> #include <template/stateful_service.h>
#include <template/stateful_endpoint.h> #include <template/stateful_proto_endpoint.h>
#include <template/stateful_persistence.h> #include <template/stateful_persistence.h>
#include <settings/ap_settings.h> #include <settings/ap_settings.h>
#include <utils/timing.h> #include <utils/timing.h>
#include <WiFi.h> #include <wifi/wifi_idf.h>
#include "esp_timer.h" #include <wifi/dns_server.h>
#include <esp_timer.h>
#include <string> #include <string>
class APService : public StatefulService<APSettings> { class APService : public StatefulService<APSettings> {
@@ -16,21 +19,19 @@ class APService : public StatefulService<APSettings> {
void loop(); void loop();
void recoveryMode(); void recoveryMode();
esp_err_t getStatus(PsychicRequest *request); esp_err_t getStatusProto(httpd_req_t *request);
void status(JsonObject &root); void statusProto(api_APStatus &proto);
APNetworkStatus getAPNetworkStatus(); APNetworkStatus getAPNetworkStatus();
StatefulHttpEndpoint<APSettings> endpoint; StatefulProtoEndpoint<APSettings, api_APSettings> protoEndpoint;
private: private:
PsychicHttpServer *_server; FSPersistencePB<APSettings> _persistence;
FSPersistence<APSettings> _persistence;
DNSServer *_dnsServer; DNSServer *_dnsServer;
volatile unsigned long _lastManaged; volatile unsigned long _lastManaged;
volatile boolean _reconfigureAp; volatile bool _reconfigureAp;
volatile boolean _recoveryMode = false; volatile bool _recoveryMode = false;
void reconfigureAP(); void reconfigureAP();
void manageAP(); void manageAP();
+92 -100
View File
@@ -1,135 +1,127 @@
#pragma once #pragma once
#include <ArduinoJson.h> #include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <functional> #include <functional>
#include <list>
enum message_type_t { CONNECT = 0, DISCONNECT = 1, EVENT = 2, PING = 3, PONG = 4, BINARY_EVENT = 5 }; #include <map>
#include <type_traits>
typedef std::function<void(JsonVariant &root, int originId)> EventCallback; #include <communication/proto_helpers.h>
typedef std::function<void(const std::string &originId, bool sync)> SubscribeCallback;
class CommAdapterBase { class CommAdapterBase {
public: public:
CommAdapterBase() { mutex_ = xSemaphoreCreateMutex(); } CommAdapterBase() {
mutex_ = xSemaphoreCreateMutex();
decoder_.onSubscribe([this](int32_t tag, int cid) { subscribe(tag, cid); });
decoder_.onUnsubscribe([this](int32_t tag, int cid) { unsubscribe(tag, cid); });
decoder_.onPing([this](int cid) { sendPong(cid); });
}
~CommAdapterBase() { vSemaphoreDelete(mutex_); } ~CommAdapterBase() { vSemaphoreDelete(mutex_); }
virtual void begin() {} virtual void begin() {}
bool hasSubscribers(const char *event) { return !client_subscriptions[event].empty(); } bool hasSubscribers(int32_t tag) {
xSemaphoreTake(mutex_, portMAX_DELAY);
void onEvent(std::string event, EventCallback callback) { event_callbacks[event].push_back(std::move(callback)); } bool result = !client_subscriptions_[tag].empty();
xSemaphoreGive(mutex_);
void onSubscribe(std::string event, SubscribeCallback callback) { return result;
subscribe_callbacks[event].push_back(std::move(callback));
} }
void emit(const char *event, JsonVariant &payload, const char *originId = "", bool onlyToSameOrigin = false) { ProtoDecoder& decoder() { return decoder_; }
int originSubscriptionId = originId[0] ? atoi(originId) : -1;
xSemaphoreTake(mutex_, portMAX_DELAY); template <typename T>
auto &subscriptions = client_subscriptions[event]; void on(std::function<void(const T&, int)> handler) {
if (subscriptions.empty()) { decoder_.on<T>(handler);
xSemaphoreGive(mutex_); }
template <typename T>
void emit(const T& data, int clientId = -1) {
constexpr pb_size_t tag = MessageTraits<T>::tag;
if (clientId < 0 && !hasSubscribers(tag)) return;
msg_.which_message = tag;
MessageTraits<T>::assign(msg_, data);
size_t out_size;
pb_get_encoded_size(&out_size, socket_message_Message_fields, &msg_);
uint8_t* buffer = pb_heap_enc_buf;
if (out_size > sizeof(pb_heap_enc_buf)) { // If the encoded size exceeds our buffer size, we needs to malloc a
// buffer of a proper size
buffer = (uint8_t*)malloc(out_size);
}
pb_ostream_t stream = pb_ostream_from_buffer(buffer, out_size);
if (!pb_encode(&stream, socket_message_Message_fields, &msg_)) {
ESP_LOGE("ProtoComm", "Failed to encode message (tag %d), buffer too small?", (int)tag);
return; return;
} }
JsonDocument doc; if (clientId >= 0) {
JsonArray array = doc.to<JsonArray>(); send(buffer, stream.bytes_written, clientId);
array.add(static_cast<uint8_t>(message_type_t::EVENT)); } else {
array.add(event); sendToSubscribers(tag, buffer, stream.bytes_written);
array.add(payload); }
#if USE_MSGPACK if (pb_heap_enc_buf != buffer) {
std::string bin; free(buffer);
serializeMsgPack(doc, bin); }
xSemaphoreGive(mutex_);
send(reinterpret_cast<const uint8_t *>(bin.data()), bin.size(), -1);
#else
String out;
serializeJson(doc, out);
xSemaphoreGive(mutex_);
send(out.c_str(), -1);
#endif
} }
protected: protected:
void send(const char *data, int cid = -1) { send(reinterpret_cast<const uint8_t *>(data), strlen(data), cid); } virtual void send(const uint8_t* data, size_t len, int cid = -1) = 0;
virtual void send(const uint8_t *data, size_t len, int cid = -1) = 0;
void subscribe(const char *event, int cid = 0) { void subscribe(int32_t tag, int cid = 0) {
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
client_subscriptions[event].push_back(cid); client_subscriptions_[tag].push_back(cid);
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
ESP_LOGI("ProtoComm", "Client %d subscribed to tag %d", cid, (int)tag);
} }
void unsubscribe(const char *event, int cid = 0) {
void unsubscribe(int32_t tag, int cid = 0) {
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
client_subscriptions[event].remove(cid); client_subscriptions_[tag].remove(cid);
xSemaphoreGive(mutex_);
ESP_LOGI("ProtoComm", "Client %d unsubscribed from tag %d", cid, (int)tag);
}
void removeClient(int cid) {
xSemaphoreTake(mutex_, portMAX_DELAY);
for (auto& [tag, clients] : client_subscriptions_) {
clients.remove(cid);
}
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
} }
void handleEventCallbacks(std::string event, JsonVariant &jsonObject, int originId) { void handleIncoming(const uint8_t* data, size_t len, int cid) {
for (auto &callback : event_callbacks[event]) { if (!decoder_.decode(data, len, cid)) {
callback(jsonObject, originId); ESP_LOGE("ProtoComm", "Failed to decode incoming message from client %d", cid);
} }
} }
virtual void handleIncoming(const uint8_t *data, size_t len, int cid = 0) { void sendPong(int cid) {
JsonDocument doc; uint8_t pongBuffer[16];
#if USE_MSGPACK msg_.which_message = socket_message_Message_pongmsg_tag;
DeserializationError error = deserializeMsgPack(doc, data, len); msg_.message.pongmsg = socket_message_PongMsg_init_zero;
#else pb_ostream_t stream = pb_ostream_from_buffer(pongBuffer, sizeof(pongBuffer));
DeserializationError error = deserializeJson(doc, data, len); if (pb_encode(&stream, socket_message_Message_fields, &msg_)) {
#endif send(pongBuffer, stream.bytes_written, cid);
if (error) {
ESP_LOGE("Comm Base", "Failed to deserialize incoming: (%s)", error.c_str());
return;
} }
JsonArray obj = doc.as<JsonArray>(); // TODO: Make const
message_type_t type = static_cast<message_type_t>(obj[0].as<uint8_t>());
switch (type) {
case message_type_t::CONNECT: {
const char *event = obj[1].as<const char *>();
ESP_LOGI("Comm Base", "CONNECT topic: %s (cid=%d)", event, cid);
subscribe(event, cid);
break;
}
case message_type_t::DISCONNECT: {
const char *event = obj[1].as<const char *>();
ESP_LOGI("Comm Base", "DISCONNECT topic: %s (cid=%d)", event, cid);
unsubscribe(event, cid);
break;
}
case message_type_t::EVENT: {
const char *event = obj[1].as<const char *>();
JsonVariant payload = obj[2].as<JsonVariant>();
handleEventCallbacks(event, payload, cid);
break;
}
case message_type_t::PING: {
ESP_LOGI("Comm Base", "PING (cid=%d)", cid);
ping(cid);
break;
}
case message_type_t::PONG: ESP_LOGI("Comm Base", "PONG (cid=%d)", cid); break;
default: ESP_LOGW("Comm Base", "Unknown message type: %d", static_cast<int>(type)); break;
}
}
void ping(int cid) {
#if USE_MSGPACK
static const uint8_t pong[] = {0x91, 0x04};
send(pong, sizeof(pong), cid);
#else
send("[4]", cid);
#endif
} }
SemaphoreHandle_t mutex_; SemaphoreHandle_t mutex_;
std::map<std::string, std::list<int>> client_subscriptions; std::map<int32_t, std::list<int>> client_subscriptions_;
std::map<std::string, std::list<EventCallback>> event_callbacks; ProtoDecoder decoder_;
std::map<std::string, std::list<SubscribeCallback>> subscribe_callbacks; socket_message_Message msg_ = socket_message_Message_init_zero;
uint8_t pb_heap_enc_buf[PROTO_BUFFER_SIZE];
private:
void sendToSubscribers(int32_t tag, const uint8_t* data, size_t len) {
xSemaphoreTake(mutex_, portMAX_DELAY);
for (int cid : client_subscriptions_[tag]) {
send(data, len, cid);
}
xSemaphoreGive(mutex_);
}
}; };
+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include <pb_encode.h>
#include <pb_decode.h>
#include <platform_shared/message.pb.h>
#include <functional>
#include <map>
#define PROTO_BUFFER_SIZE 2048
template <typename T>
struct MessageTraits;
#define DEFINE_MESSAGE_TRAITS(DataType, field) \
template <> \
struct MessageTraits<socket_message_##DataType> { \
static constexpr pb_size_t tag = socket_message_Message_##field##_tag; \
static void assign(socket_message_Message& msg, const socket_message_##DataType& data) { \
msg.message.field = data; \
} \
static const socket_message_##DataType& access(const socket_message_Message& msg) { \
return msg.message.field; \
} \
};
DEFINE_MESSAGE_TRAITS(IMUData, imu)
DEFINE_MESSAGE_TRAITS(ModeData, mode)
DEFINE_MESSAGE_TRAITS(AnalyticsData, analytics)
DEFINE_MESSAGE_TRAITS(AnglesData, angles)
DEFINE_MESSAGE_TRAITS(RSSIData, rssi)
DEFINE_MESSAGE_TRAITS(KinematicData, kinematic_data)
DEFINE_MESSAGE_TRAITS(IMUCalibrateData, imu_calibrate)
DEFINE_MESSAGE_TRAITS(I2CScanData, i2c_scan)
DEFINE_MESSAGE_TRAITS(PeripheralSettingsData, peripheral_settings)
DEFINE_MESSAGE_TRAITS(ControllerData, controller_data)
DEFINE_MESSAGE_TRAITS(WalkGaitData, walk_gait)
DEFINE_MESSAGE_TRAITS(IMUCalibrateExecute, imu_calibrate_execute)
DEFINE_MESSAGE_TRAITS(I2CScanDataRequest, i2c_scan_data_request)
DEFINE_MESSAGE_TRAITS(PeripheralSettingsDataRequest, peripheral_settings_data_request)
DEFINE_MESSAGE_TRAITS(ServoPWMData, servo_pwm)
DEFINE_MESSAGE_TRAITS(ServoStateData, servo_state)
DEFINE_MESSAGE_TRAITS(CorrelationRequest, correlation_request)
DEFINE_MESSAGE_TRAITS(CorrelationResponse, correlation_response)
// Streaming file transfer messages
DEFINE_MESSAGE_TRAITS(FSDownloadMetadata, fs_download_metadata)
DEFINE_MESSAGE_TRAITS(FSDownloadData, fs_download_data)
DEFINE_MESSAGE_TRAITS(FSDownloadComplete, fs_download_complete)
DEFINE_MESSAGE_TRAITS(FSUploadData, fs_upload_data)
DEFINE_MESSAGE_TRAITS(FSUploadComplete, fs_upload_complete)
#undef DEFINE_MESSAGE_TRAITS
class ProtoDecoder {
public:
using SubscribeHandler = std::function<void(int32_t tag, int clientId)>;
using UnsubscribeHandler = std::function<void(int32_t tag, int clientId)>;
using PingHandler = std::function<void(int clientId)>;
void onSubscribe(SubscribeHandler handler) { subscribeHandler_ = handler; }
void onUnsubscribe(UnsubscribeHandler handler) { unsubscribeHandler_ = handler; }
void onPing(PingHandler handler) { pingHandler_ = handler; }
template <typename T>
void on(std::function<void(const T&, int)> handler) {
handlers_[MessageTraits<T>::tag] = [handler, this](int clientId) {
handler(MessageTraits<T>::access(msg_), clientId);
};
}
bool decode(const uint8_t* data, size_t len, int clientId) {
pb_istream_t stream = pb_istream_from_buffer(data, len);
if (!pb_decode(&stream, socket_message_Message_fields, &msg_)) {
return false;
}
switch (msg_.which_message) {
case socket_message_Message_sub_notif_tag:
if (subscribeHandler_) subscribeHandler_(msg_.message.sub_notif.tag, clientId);
return true;
case socket_message_Message_unsub_notif_tag:
if (unsubscribeHandler_) unsubscribeHandler_(msg_.message.unsub_notif.tag, clientId);
return true;
case socket_message_Message_pingmsg_tag:
if (pingHandler_) pingHandler_(clientId);
return true;
default: {
auto it = handlers_.find(msg_.which_message);
if (it != handlers_.end()) {
it->second(clientId);
return true;
}
return false;
}
}
}
private:
socket_message_Message msg_ = socket_message_Message_init_zero;
SubscribeHandler subscribeHandler_;
UnsubscribeHandler unsubscribeHandler_;
PingHandler pingHandler_;
std::map<pb_size_t, std::function<void(int)>> handlers_;
};
+145
View File
@@ -0,0 +1,145 @@
#pragma once
#ifndef CONFIG_HTTPD_WS_SUPPORT
#define CONFIG_HTTPD_WS_SUPPORT 1
#endif
#include <esp_http_server.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <functional>
#include <vector>
#include <string>
#include <map>
#include <pb_encode.h>
#include <pb_decode.h>
#include <platform_shared/api.pb.h>
#include <freertos/semphr.h>
using HttpGetHandler = std::function<esp_err_t(httpd_req_t*)>;
using HttpPostHandler = std::function<esp_err_t(httpd_req_t*, api_Request*)>;
using WsFrameHandler = std::function<esp_err_t(httpd_req_t*, httpd_ws_frame_t*)>;
using WsOpenHandler = std::function<void(httpd_req_t*)>;
using WsCloseHandler = std::function<void(int)>;
// Macro to register a proto endpoint that extracts a specific payload type
// Usage: STAITC_PROTO_POST_ENDPOINT(server, "/api/files/delete", file_delete_request, FileSystem::handleDelete)
// Handler signature: esp_err_t handleDelete(httpd_req_t* req, const api_FileDeleteRequest& payload)
#define STAITC_PROTO_POST_ENDPOINT(server_ref, uri, payload_type, handler) \
(server_ref).on(uri, HTTP_POST, [&](httpd_req_t *request, api_Request *protoReq) { \
if (protoReq->which_payload != api_Request_##payload_type##_tag) { \
return WebServer::sendError(request, 400, "Invalid request payload"); \
} \
return handler(request, protoReq->payload.payload_type); \
})
struct HttpRoute {
std::string uri;
httpd_method_t method;
HttpGetHandler getHandler;
HttpPostHandler postHandler;
bool isWebsocket;
};
class WebServer {
public:
WebServer();
~WebServer();
void config(size_t maxUriHandlers, size_t stackSize);
esp_err_t listen(uint16_t port);
void stop();
void on(const char* uri, httpd_method_t method, HttpGetHandler handler);
void on(const char* uri, httpd_method_t method, HttpPostHandler handler);
void onWsFrame(WsFrameHandler handler);
void onWsOpen(WsOpenHandler handler);
void onWsClose(WsCloseHandler handler);
void registerWebsocket(const char* uri);
esp_err_t wsSend(int sockfd, const uint8_t* data, size_t len);
esp_err_t wsSendAll(const uint8_t* data, size_t len);
void addWsClient(int sockfd);
void removeWsClient(int sockfd);
std::vector<int> getWsClients();
void addDefaultHeader(const char* key, const char* value);
httpd_handle_t getHandle() { return server_; }
static esp_err_t sendError(httpd_req_t* req, int status, const char* message);
static esp_err_t sendOk(httpd_req_t* req);
static esp_err_t send(httpd_req_t* req, int status, const uint8_t* data, size_t len);
template <typename T>
static esp_err_t send(httpd_req_t* req, int status, const T& msg, const pb_msgdesc_t* fields) {
size_t size = 0;
if (!pb_get_encoded_size(&size, fields, &msg)) {
return sendError(req, 500, "Failed to calculate proto size");
}
uint8_t* buffer = (uint8_t*)malloc(size);
if (!buffer) {
return sendError(req, 500, "Failed to allocate memory for proto");
}
pb_ostream_t stream = pb_ostream_from_buffer(buffer, size);
if (!pb_encode(&stream, fields, &msg)) {
free(buffer);
return sendError(req, 500, "Failed to encode proto");
}
esp_err_t result = send(req, status, buffer, stream.bytes_written);
free(buffer);
return result;
}
template <typename T>
static bool receiveProto(httpd_req_t* req, T& msg, const pb_msgdesc_t* fields) {
size_t contentLen = req->content_len;
if (contentLen == 0 || contentLen > 4096) {
return false;
}
uint8_t* buffer = (uint8_t*)malloc(contentLen);
if (!buffer) {
return false;
}
int received = 0;
int remaining = contentLen;
while (remaining > 0) {
int ret = httpd_req_recv(req, (char*)buffer + received, remaining);
if (ret <= 0) {
free(buffer);
return false;
}
received += ret;
remaining -= ret;
}
pb_istream_t stream = pb_istream_from_buffer(buffer, contentLen);
bool success = pb_decode(&stream, fields, &msg);
free(buffer);
return success;
}
private:
httpd_handle_t server_ = nullptr;
httpd_config_t config_;
std::vector<HttpRoute> routes_;
std::map<std::string, std::string> defaultHeaders_;
std::vector<int> wsClients_;
SemaphoreHandle_t wsMutex_;
WsFrameHandler wsFrameHandler_;
WsOpenHandler wsOpenHandler_;
WsCloseHandler wsCloseHandler_;
static esp_err_t httpHandler(httpd_req_t* req);
static esp_err_t wsHandler(httpd_req_t* req);
void applyDefaultHeaders(httpd_req_t* req);
esp_err_t registerRoute(const HttpRoute& route);
};
extern WebServer server;
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include <communication/webserver.h>
#include <communication/comm_base.hpp>
class Websocket : public CommAdapterBase {
public:
Websocket(WebServer& server, const char* route = "/api/ws");
void begin() override;
private:
WebServer& server_;
const char* route_;
void onWsOpen(httpd_req_t* req);
void onWsClose(int sockfd);
esp_err_t onFrame(httpd_req_t* req, httpd_ws_frame_t* frame);
void send(const uint8_t* data, size_t len, int cid = -1) override;
};
@@ -1,36 +0,0 @@
#ifndef Socket_h
#define Socket_h
#include <PsychicHttp.h>
#include <template/stateful_service.h>
#include <list>
#include <map>
#include <vector>
#include <string>
#include <communication/comm_base.hpp>
class Websocket : public CommAdapterBase {
public:
Websocket(PsychicHttpServer &server, const char *route = "/api/ws");
void begin() override;
void onEvent(std::string event, EventCallback callback);
void emit(const char *event, JsonVariant &payload, const char *originId = "", bool onlyToSameOrigin = false);
private:
PsychicWebSocketHandler _socket;
PsychicHttpServer &_server;
const char *_route;
void onWSOpen(PsychicWebSocketClient *client);
void onWSClose(PsychicWebSocketClient *client);
esp_err_t onFrame(PsychicWebSocketRequest *request, httpd_ws_frame *frame);
void send(const uint8_t *data, size_t len, int cid = -1) override;
};
#endif
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef PGM_P
#define PGM_P const char *
#endif
#ifndef pgm_read_byte
#define pgm_read_byte(addr) (*(const unsigned char *)(addr))
#endif
#ifndef pgm_read_word
#define pgm_read_word(addr) (*(const unsigned short *)(addr))
#endif
#ifndef pgm_read_dword
#define pgm_read_dword(addr) (*(const unsigned long *)(addr))
#endif
+6 -31
View File
@@ -1,69 +1,48 @@
#ifndef Features_h #pragma once
#define Features_h
#include <WiFi.h> #include <sdkconfig.h>
#include <ArduinoJson.h> #include <wifi/wifi_idf.h>
#include <PsychicHttp.h> #include <esp_http_server.h>
#include "platform_shared/message.pb.h"
#define FT_ENABLED(feature) feature #define FT_ENABLED(feature) feature
// ESP32 camera off by default
#ifndef USE_CAMERA #ifndef USE_CAMERA
#define USE_CAMERA 0 #define USE_CAMERA 0
#endif #endif
// ESP32 IMU on by default
#ifndef USE_MPU6050 #ifndef USE_MPU6050
#define USE_MPU6050 0 #define USE_MPU6050 0
#endif #endif
// ESP32 IMU on by default
#ifndef USE_BNO055 #ifndef USE_BNO055
#define USE_BNO055 1 #define USE_BNO055 1
#endif #endif
// ESP32 magnetometer on by default
#ifndef USE_HMC5883 #ifndef USE_HMC5883
#define USE_HMC5883 0 #define USE_HMC5883 0
#endif #endif
// ESP32 barometer off by default
#ifndef USE_BMP180 #ifndef USE_BMP180
#define USE_BMP180 0 #define USE_BMP180 0
#endif #endif
// ESP32 SONAR off by default
#ifndef USE_USS #ifndef USE_USS
#define USE_USS 0 #define USE_USS 0
#endif #endif
// PCA9685 Servo controller on by default
#ifndef USE_PCA9685 #ifndef USE_PCA9685
#define USE_PCA9685 1 #define USE_PCA9685 1
#endif #endif
// WS2812 LED strip off by default
#ifndef USE_WS2812 #ifndef USE_WS2812
#define USE_WS2812 0 #define USE_WS2812 0
#endif #endif
// ESP32 MDNS on by default
#ifndef USE_MDNS #ifndef USE_MDNS
#define USE_MDNS 1 #define USE_MDNS 1
#endif #endif
// ESP32 MSGPACK on by default
#ifndef USE_MSGPACK
#define USE_MSGPACK 1
#endif
// ESP32 JSON off by default
#ifndef USE_JSON
#define USE_JSON 0
#endif
static_assert(!(USE_JSON == 1 && USE_MSGPACK == 1), "Cannot set both USE_JSON and USE_MSGPACK to 1 simultaneously");
#if defined(SPOTMICRO_ESP32) && defined(SPOTMICRO_ESP32_MINI) && defined(SPOTMICRO_YERTLE) #if defined(SPOTMICRO_ESP32) && defined(SPOTMICRO_ESP32_MINI) && defined(SPOTMICRO_YERTLE)
#error "Only one kinematics variant must be defined" #error "Only one kinematics variant must be defined"
#endif #endif
@@ -86,10 +65,6 @@ namespace feature_service {
void printFeatureConfiguration(); void printFeatureConfiguration();
void features(JsonObject &root); void features_request(const socket_message_FeaturesDataRequest& fd_req, socket_message_FeaturesDataResponse& fd_res);
esp_err_t getFeatures(PsychicRequest *request);
} // namespace feature_service } // namespace feature_service
#endif
+31 -19
View File
@@ -1,33 +1,45 @@
#pragma once #pragma once
#include <PsychicHttp.h> #include <esp_http_server.h>
#include <esp_littlefs.h>
#include <LittleFS.h> #include <esp_vfs.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string> #include <string>
#include <cstdio>
#include <platform_shared/api.pb.h>
#define ESP_FS LittleFS #define MOUNT_POINT "/littlefs"
#define AP_SETTINGS_FILE "/config/apSettings.json" #define FS_CONFIG_DIRECTORY MOUNT_POINT "/config"
#define CAMERA_SETTINGS_FILE "/config/cameraSettings.json" #define DEVICE_CONFIG_FILE MOUNT_POINT "/config/peripheral.pb"
#define FS_CONFIG_DIRECTORY "/config" #define CAMERA_SETTINGS_FILE MOUNT_POINT "/config/cameraSettings.pb"
#define DEVICE_CONFIG_FILE "/config/peripheral.json" #define AP_SETTINGS_FILE MOUNT_POINT "/config/apSettings.pb"
#define WIFI_SETTINGS_FILE "/config/wifiSettings.json" #define MDNS_SETTINGS_FILE MOUNT_POINT "/config/mdnsSettings.pb"
#define SERVO_SETTINGS_FILE "/config/servoSettings.json" #define WIFI_SETTINGS_FILE MOUNT_POINT "/config/wifiSettings.pb"
#define MDNS_SETTINGS_FILE "/config/mdnsSettings.json" #define PERIPHERAL_SETTINGS_FILE MOUNT_POINT "/config/peripheralSettings.pb"
#define SERVO_SETTINGS_FILE MOUNT_POINT "/config/servoSettings.pb"
namespace FileSystem { namespace FileSystem {
extern PsychicUploadHandler *uploadHandler;
bool init();
void listFilesProto(const std::string &directory, api_FileEntry *entry);
std::string listFiles(const std::string &directory, bool isRoot = true); std::string listFiles(const std::string &directory, bool isRoot = true);
bool deleteFile(const char *filename); bool deleteFile(const char *filename);
bool editFile(const char *filename, const uint8_t *content, size_t size);
bool editFile(const char *filename, const char *content); bool editFile(const char *filename, const char *content);
esp_err_t uploadFile(PsychicRequest *request, const std::string &filename, uint64_t index, uint8_t *data, size_t len, bool fileExists(const char *filename);
bool last); std::string readFile(const char *filename);
bool writeFile(const char *filename, const char *content);
bool writeFile(const char *filename, const uint8_t *content, size_t size);
bool mkdirRecursive(const char *path);
esp_err_t getFiles(PsychicRequest *request); esp_err_t getFilesProto(httpd_req_t *request);
esp_err_t getConfigFile(PsychicRequest *request); esp_err_t getFiles(httpd_req_t *request);
esp_err_t handleDelete(PsychicRequest *request, JsonVariant &json); esp_err_t getConfigFile(httpd_req_t *request);
esp_err_t handleEdit(PsychicRequest *request, JsonVariant &json); esp_err_t handleDelete(httpd_req_t *request, const api_FileDeleteRequest &req);
esp_err_t handleEdit(httpd_req_t *request, const api_FileEditRequest &req);
esp_err_t mkdir(httpd_req_t *request, const api_FileMkdirRequest &req);
esp_err_t mkdir(PsychicRequest *request, JsonVariant &json);
} // namespace FileSystem } // namespace FileSystem
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <platform_shared/message.pb.h>
#include <filesystem.h>
#include <map>
#include <string>
#include <functional>
#include <cstdio>
#define FS_MAX_CHUNK_SIZE 16384
#define FS_TRANSFER_TIMEOUT_MS 30000
namespace FileSystemWS {
struct DownloadState {
std::string path;
FILE* file;
uint32_t fileSize;
uint32_t chunkSize;
uint32_t totalChunks;
uint32_t chunksSent;
uint32_t lastActivityTime;
int clientId;
};
struct UploadState {
std::string path;
FILE* file;
uint32_t fileSize;
uint32_t totalChunks;
uint32_t chunksReceived;
uint32_t bytesReceived;
uint32_t lastActivityTime;
int clientId;
bool hasError;
std::string errorMessage;
};
using SendMetadataCallback = std::function<void(const socket_message_FSDownloadMetadata&, int clientId)>;
using SendCallback = std::function<void(const socket_message_FSDownloadData&, int clientId)>;
using SendCompleteCallback = std::function<void(const socket_message_FSDownloadComplete&, int clientId)>;
using SendUploadCompleteCallback = std::function<void(const socket_message_FSUploadComplete&, int clientId)>;
class FileSystemHandler {
public:
FileSystemHandler();
void setSendCallbacks(SendMetadataCallback sendMetadata, SendCallback sendData, SendCompleteCallback sendComplete,
SendUploadCompleteCallback sendUploadComplete);
socket_message_FSDeleteResponse handleDelete(const socket_message_FSDeleteRequest& req);
socket_message_FSMkdirResponse handleMkdir(const socket_message_FSMkdirRequest& req);
socket_message_FSListResponse handleList(const socket_message_FSListRequest& req);
void handleDownloadRequest(const socket_message_FSDownloadRequest& req, int clientId);
socket_message_FSUploadStartResponse handleUploadStart(const socket_message_FSUploadStart& req, int clientId);
void handleUploadData(const socket_message_FSUploadData& req);
socket_message_FSCancelTransferResponse handleCancelTransfer(const socket_message_FSCancelTransfer& req);
void cleanupExpiredTransfers();
void processPendingDownloads();
private:
std::map<uint32_t, DownloadState> downloads_;
std::map<uint32_t, UploadState> uploads_;
uint32_t transferIdCounter_;
inline uint32_t generateTransferId() { return ++transferIdCounter_; }
SendMetadataCallback sendMetadataCallback_;
SendCallback sendDataCallback_;
SendCompleteCallback sendCompleteCallback_;
SendUploadCompleteCallback sendUploadCompleteCallback_;
void listDirectory(const std::string& path, socket_message_FSListResponse& response);
bool deleteRecursive(const std::string& path);
bool sendNextDownloadChunk(uint32_t transferId);
void finalizeUpload(uint32_t transferId, bool success, const std::string& error = "");
};
extern FileSystemHandler fsHandler;
} // namespace FileSystemWS
+35 -23
View File
@@ -1,49 +1,61 @@
#pragma once #pragma once
#include <esp32-hal.h> #include <sdkconfig.h>
#include <esp_system.h>
#if CONFIG_IDF_TARGET_ESP32 // ESP32/PICO-D4 #if CONFIG_IDF_TARGET_ESP32
#include "esp32/rom/rtc.h" #include "esp32/rom/rtc.h"
#ifndef ESP_PLATFORM #ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM "ESP32" #define ESP_PLATFORM_NAME "ESP32"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32S2 #elif CONFIG_IDF_TARGET_ESP32S2
#include "esp32/rom/rtc.h" #include "esp32s2/rom/rtc.h"
#ifndef ESP_PLATFORM #ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM "ESP32-S2" #define ESP_PLATFORM_NAME "ESP32-S2"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32C3 #elif CONFIG_IDF_TARGET_ESP32C3
#include "esp32c3/rom/rtc.h" #include "esp32c3/rom/rtc.h"
#ifndef ESP_PLATFORM #ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM "ESP32-C3" #define ESP_PLATFORM_NAME "ESP32-C3"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32S3 #elif CONFIG_IDF_TARGET_ESP32S3
#include "esp32s3/rom/rtc.h" #include "esp32s3/rom/rtc.h"
#ifndef ESP_PLATFORM #ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM "ESP32-S3" #define ESP_PLATFORM_NAME "ESP32-S3"
#endif #endif
#elif CONFIG_IDF_TARGET_ESP32C6
#include "esp32c6/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM_NAME "ESP32-C6"
#endif
#elif CONFIG_IDF_TARGET_ESP32P4
#include "esp32p4/rom/rtc.h"
#ifndef ESP_PLATFORM_NAME
#define ESP_PLATFORM_NAME "ESP32-P4"
#endif
#define ESP32P4_USES_C6_COPROCESSOR 1
#else #else
#error Target CONFIG_IDF_TARGET is not supported #error Target CONFIG_IDF_TARGET is not supported
#endif #endif
#ifndef ARDUINO_VERSION
#ifndef STRINGIFY
#define STRINGIFY(s) #s
#endif
#define ARDUINO_VERSION_STR(major, minor, patch) "v" STRINGIFY(major) "." STRINGIFY(minor) "." STRINGIFY(patch)
#define ARDUINO_VERSION \
ARDUINO_VERSION_STR(ESP_ARDUINO_VERSION_MAJOR, ESP_ARDUINO_VERSION_MINOR, ESP_ARDUINO_VERSION_PATCH)
#endif
/* /*
* I2C software connection * I2C software connection
*/ */
#if CONFIG_IDF_TARGET_ESP32P4
#ifndef SDA_PIN #ifndef SDA_PIN
#define SDA_PIN SDA #define SDA_PIN 7
#endif #endif
#ifndef SCL_PIN #ifndef SCL_PIN
#define SCL_PIN SCL #define SCL_PIN 8
#endif
#else
#ifndef SDA_PIN
#define SDA_PIN 21
#endif
#ifndef SCL_PIN
#define SCL_PIN 22
#endif
#endif #endif
#ifndef I2C_FREQUENCY #ifndef I2C_FREQUENCY
#define I2C_FREQUENCY 100000UL #define I2C_FREQUENCY 1000000UL
#endif #endif
+5 -5
View File
@@ -39,8 +39,8 @@ class KinConfig {
}; };
// Max constants // Max constants
static constexpr float max_roll = 15 * DEG2RAD_F; static constexpr float max_roll = 20.0f;
static constexpr float max_pitch = 15 * DEG2RAD_F; static constexpr float max_pitch = 15.0f;
static constexpr float max_body_shift_x = W / 3; static constexpr float max_body_shift_x = W / 3;
static constexpr float max_body_shift_z = W / 3; static constexpr float max_body_shift_z = W / 3;
@@ -72,7 +72,7 @@ struct alignas(16) body_state_t {
!IS_ALMOST_EQUAL(zm, other.zm)) { !IS_ALMOST_EQUAL(zm, other.zm)) {
return false; return false;
} }
return arrayEqual(feet, other.feet, 0.1f); return arrayEqual(feet, other.feet, 0.001f);
} }
}; };
@@ -184,13 +184,13 @@ class Kinematics {
} }
inline void legIK(float x, float y, float z, float out[3]) { inline void legIK(float x, float y, float z, float out[3]) {
float F = sqrt(max(0.0f, x * x + y * y - coxa * coxa)); float F = sqrt(fmax(0.0f, x * x + y * y - coxa * coxa));
float G = F - coxa_offset; float G = F - coxa_offset;
float H = sqrt(G * G + z * z); float H = sqrt(G * G + z * z);
float theta1 = -atan2f(y, x) - atan2f(F, -coxa); float theta1 = -atan2f(y, x) - atan2f(F, -coxa);
float D = (H * H - femur * femur - tibia * tibia) / (2 * femur * tibia); float D = (H * H - femur * femur - tibia * tibia) / (2 * femur * tibia);
float theta3 = acosf(max(-1.0f, min(1.0f, D))); float theta3 = acosf(fmax(-1.0f, fmin(1.0f, D)));
float theta2 = atan2f(z, G) - atan2f(tibia * sinf(theta3), femur + tibia * cosf(theta3)); float theta2 = atan2f(z, G) - atan2f(tibia * sinf(theta3), femur + tibia * cosf(theta3));
out[0] = RAD_TO_DEG_F(theta1); out[0] = RAD_TO_DEG_F(theta1);
out[1] = RAD_TO_DEG_F(theta2); out[1] = RAD_TO_DEG_F(theta2);
+14 -17
View File
@@ -1,34 +1,31 @@
#pragma once #pragma once
#include <PsychicHttp.h> #include <esp_http_server.h>
#include <ESPmDNS.h> #include <mdns.h>
#include <template/stateful_service.h> #include <template/stateful_service.h>
#include <template/stateful_endpoint.h> #include <template/stateful_proto_endpoint.h>
#include <template/stateful_persistence.h> #include <template/stateful_persistence.h>
#include <settings/mdns_settings.h> #include <settings/mdns_settings.h>
#include <utils/timing.h> #include <utils/timing.h>
#include <string>
class MDNSService : public StatefulService<MDNSSettings> { class MDNSService : public StatefulService<MDNSSettings> {
private:
FSPersistence<MDNSSettings> _persistence;
bool _started {false};
void reconfigureMDNS();
void startMDNS();
void stopMDNS();
void addServices();
public: public:
MDNSService(); MDNSService();
~MDNSService(); ~MDNSService();
void begin(); void begin();
esp_err_t getStatus(PsychicRequest *request); esp_err_t getStatus(httpd_req_t *request);
void getStatus(JsonVariant &root); esp_err_t queryServices(httpd_req_t *request, api_Request *protoReq);
static esp_err_t queryServices(PsychicRequest *request, JsonVariant &json); StatefulProtoEndpoint<MDNSSettings, api_MDNSSettings> protoEndpoint;
StatefulHttpEndpoint<MDNSSettings> endpoint; private:
FSPersistencePB<MDNSSettings> _persistence;
bool _started {false};
void reconfigureMDNS();
void startMDNS();
void stopMDNS();
void addServices();
}; };
+9 -20
View File
@@ -1,28 +1,17 @@
#pragma once #pragma once
#include <ArduinoJson.h> #include <platform_shared/message.pb.h>
struct CommandMsg { struct CommandMsg {
float lx, ly, rx, ry, h, s, s1; float lx, ly, rx, ry, h, s, s1;
friend void toJson(JsonVariant v, CommandMsg const &c) {
JsonArray arr = v.to<JsonArray>();
arr.add(c.lx);
arr.add(c.ly);
arr.add(c.rx);
arr.add(c.ry);
arr.add(c.h);
arr.add(c.s);
arr.add(c.s1);
}
void fromJson(JsonVariantConst o) { void fromProto(const socket_message_ControllerData& data) {
JsonArrayConst arr = o.as<JsonArrayConst>(); lx = data.has_left ? data.left.x : 0;
lx = arr[0].as<float>(); ly = data.has_left ? data.left.y : 0;
ly = arr[1].as<float>(); rx = data.has_right ? data.right.x : 0;
rx = arr[2].as<float>(); ry = data.has_right ? data.right.y : 0;
ry = arr[3].as<float>(); h = data.height;
h = arr[4].as<float>(); s = data.speed;
s = arr[5].as<float>(); s1 = data.s1;
s1 = arr[6].as<float>();
} }
}; };
+8 -9
View File
@@ -1,7 +1,6 @@
#ifndef MotionService_h #ifndef MotionService_h
#define MotionService_h #define MotionService_h
#include <ArduinoJson.h>
#include "esp_timer.h" #include "esp_timer.h"
#include <kinematics.h> #include <kinematics.h>
@@ -22,23 +21,23 @@ class MotionService {
public: public:
void begin(); void begin();
void anglesEvent(JsonVariant &root, int originId); void handleAngles(const socket_message_AnglesData& data);
void handleInput(JsonVariant &root, int originId); void handleInput(const socket_message_ControllerData& data);
void handleWalkGait(JsonVariant &root, int originId); void handleWalkGait(const socket_message_WalkGaitData& data);
void handleMode(JsonVariant &root, int originId); void handleMode(const socket_message_ModeData& data);
void setState(MotionState *newState); void setState(MotionState* newState);
void handleGestures(const gesture_t ges); void handleGestures(const gesture_t ges);
bool update(Peripherals *peripherals); bool update(Peripherals* peripherals);
bool update_angles(float new_angles[12], float angles[12]); bool update_angles(float new_angles[12], float angles[12]);
float *getAngles() { return angles; } float* getAngles() { return angles; }
inline bool isActive() { return state != nullptr; } inline bool isActive() { return state != nullptr; }
@@ -49,7 +48,7 @@ class MotionService {
friend class MotionState; friend class MotionState;
MotionState *state = nullptr; MotionState* state = nullptr;
RestState restState; RestState restState;
StandState standState; StandState standState;
+12 -7
View File
@@ -2,6 +2,8 @@
#include <kinematics.h> #include <kinematics.h>
#include <message_types.h> #include <message_types.h>
#include <utils/math_utils.h>
#include <cstring>
class MotionState { class MotionState {
protected: protected:
@@ -17,21 +19,24 @@ class MotionState {
body_state.ym = lerp(body_state.ym, target_body_state.ym, smoothing_factor); body_state.ym = lerp(body_state.ym, target_body_state.ym, smoothing_factor);
body_state.zm = lerp(body_state.zm, target_body_state.zm, smoothing_factor); body_state.zm = lerp(body_state.zm, target_body_state.zm, smoothing_factor);
body_state.phi = lerp(body_state.phi, target_body_state.phi, smoothing_factor); body_state.phi = lerp(body_state.phi, target_body_state.phi, smoothing_factor);
body_state.psi = lerp(body_state.psi, target_body_state.psi - imuCompensate * psi_offset, smoothing_factor); const float target_psi =
body_state.omega = clamp(target_body_state.psi - imuCompensate * psi_offset, -KinConfig::max_pitch, KinConfig::max_pitch);
lerp(body_state.omega, target_body_state.omega - imuCompensate * omega_offset, smoothing_factor); const float target_omega =
clamp(target_body_state.omega - imuCompensate * omega_offset, -KinConfig::max_roll, KinConfig::max_roll);
body_state.psi = lerp(body_state.psi, target_psi, smoothing_factor);
body_state.omega = lerp(body_state.omega, target_omega, smoothing_factor);
} }
void updateFeet(body_state_t& body_state, const float smoothing_factor = default_smoothing_factor) { void updateFeet(body_state_t& body_state, const float smoothing_factor = default_smoothing_factor) {
if (target_body_state.feet != body_state.feet) { if (std::memcmp(target_body_state.feet, body_state.feet, sizeof(body_state.feet)) != 0) {
body_state.updateFeet(target_body_state.feet); body_state.updateFeet(target_body_state.feet);
} }
} }
public: public:
void updateImuOffsets(const float omega_offset, const float psi_offset) { void updateImuOffsets(const float new_omega, const float new_psi) {
this->omega_offset = omega_offset * RAD2DEG_F; omega_offset = RAD_TO_DEG_F(new_omega);
this->psi_offset = psi_offset * RAD2DEG_F; psi_offset = RAD_TO_DEG_F(new_psi);
} }
virtual ~MotionState() {} virtual ~MotionState() {}
+8 -5
View File
@@ -2,6 +2,7 @@
#include <motion_states/state.h> #include <motion_states/state.h>
#include <utils/math_utils.h> #include <utils/math_utils.h>
#include <algorithm>
#include <array> #include <array>
#include <functional> #include <functional>
@@ -115,14 +116,16 @@ class WalkState : public MotionState {
target_gait_state.step_depth = KinConfig::default_step_depth; target_gait_state.step_depth = KinConfig::default_step_depth;
} }
static inline bool isZero(float num) { return std::fabs(num) < 0.01; } static inline bool isZero(float num) { return std::fabs(num) < 0.001; }
void updatePhase(float dt) { void updatePhase(float dt) {
if (isZero(gait_state.step_x) && isZero(gait_state.step_z) && isZero(gait_state.step_angle)) { const bool moving = !isZero(gait_state.step_x) || !isZero(gait_state.step_z) || !isZero(gait_state.step_angle);
if (!moving) {
phase_time = 0; phase_time = 0;
return; return;
} }
phase_time = std::fmod(phase_time + dt * gait_state.step_velocity * speed_factor, 1.0f); const float velocity = std::max(gait_state.step_velocity, 0.5f);
phase_time = std::fmod(phase_time + dt * velocity * speed_factor, 1.0f);
} }
LegStates getLegStates() { LegStates getLegStates() {
@@ -242,7 +245,7 @@ class WalkState : public MotionState {
float angle = std::atan2(gait_state.step_z, step_length) * 2.0f; float angle = std::atan2(gait_state.step_z, step_length) * 2.0f;
curve(length, angle, arg, phase, delta_pos); curve(length, angle, arg, phase, delta_pos);
length = gait_state.step_angle * 2.0f; length = gait_state.step_angle * KinConfig::max_step_length;
angle = yawArc(default_feet_pos[index], body_state.feet[index]); angle = yawArc(default_feet_pos[index], body_state.feet[index]);
curve(length, angle, arg, phase, delta_rot); curve(length, angle, arg, phase, delta_rot);
@@ -275,7 +278,7 @@ class WalkState : public MotionState {
point[1] += b * BEZIER_HEIGHTS[i] * *height; point[1] += b * BEZIER_HEIGHTS[i] * *height;
point[2] += b * BEZIER_STEPS[i] * length * Z_POLAR; point[2] += b * BEZIER_STEPS[i] * length * Z_POLAR;
phase_power *= phase; phase_power *= t;
inv_phase_power /= one_minus_phase; inv_phase_power /= one_minus_phase;
} }
} }
+12 -38
View File
@@ -1,68 +1,42 @@
#pragma once #pragma once
#include <list>
#include <SPI.h>
#include <Wire.h>
#include <ArduinoJson.h>
#include <utils/math_utils.h> #include <utils/math_utils.h>
#include <Adafruit_BMP085_U.h>
#include <Adafruit_Sensor.h>
#include <peripherals/sensor.hpp> #include <peripherals/sensor.hpp>
#include <peripherals/drivers/bmp180.h>
struct BarometerMsg : public SensorMessageBase { struct BarometerMsg {
float pressure {-1}; float pressure {-1};
float altitude {-1}; float altitude {-1};
float temperature {-1}; float temperature {-1};
bool success {false}; bool success {false};
void toJson(JsonVariant v) const override {
JsonArray arr = v.to<JsonArray>();
arr.add(pressure);
arr.add(altitude);
arr.add(temperature);
arr.add(success);
}
void fromJson(JsonVariantConst v) override {
JsonArrayConst arr = v.as<JsonArrayConst>();
pressure = arr[0] | -1.0f;
altitude = arr[1] | -1.0f;
temperature = arr[2] | -1.0f;
success = arr[3] | false;
}
friend void toJson(JsonVariant v, BarometerMsg const& a) { a.toJson(v); }
}; };
class Barometer : public SensorBase<BarometerMsg> { class Barometer : public SensorBase<BarometerMsg> {
public: public:
bool initialize() override { bool initialize() override {
_msg.success = _bmp.begin(); _msg.success = _bmp.begin();
if (_msg.success) {
ESP_LOGI("BMP", "BMP180 initialized successfully");
} else {
ESP_LOGE("BMP", "BMP180 initialization failed");
}
return _msg.success; return _msg.success;
} }
bool update() override { bool update() override {
if (!_msg.success) return false; if (!_msg.success) return false;
_bmp.getTemperature(&_msg.temperature); if (!_bmp.update()) return false;
sensors_event_t event; _msg.temperature = _bmp.getTemperature();
_bmp.getEvent(&event); _msg.pressure = _bmp.getPressure();
_msg.pressure = event.pressure; _msg.altitude = _bmp.getAltitude();
_msg.altitude = _bmp.pressureToAltitude(seaLevelPressure, _msg.pressure);
return true; return true;
} }
float getPressure() { return _msg.pressure; } float getPressure() { return _msg.pressure; }
float getAltitude() { return _msg.altitude; } float getAltitude() { return _msg.altitude; }
float getTemperature() { return _msg.temperature; } float getTemperature() { return _msg.temperature; }
bool active() { return _msg.success; } bool active() { return _msg.success; }
private: private:
Adafruit_BMP085_Unified _bmp {10085}; BMP180Driver _bmp;
const float seaLevelPressure = SENSORS_PRESSURE_SEALEVELHPA;
}; };
+22 -23
View File
@@ -1,50 +1,49 @@
#ifndef CameraService_h #pragma once
#define CameraService_h
#include <ArduinoJson.h> #include <esp_http_server.h>
#include <PsychicHttp.h>
#include <WiFi.h>
#include <async_worker.h>
#include <features.h> #include <features.h>
#include <template/stateful_socket.h> #include <template/stateful_service.h>
#include <template/stateful_proto_endpoint.h>
#include <template/stateful_persistence.h> #include <template/stateful_persistence.h>
#include <template/stateful_endpoint.h>
#include <settings/camera_settings.h> #include <settings/camera_settings.h>
namespace Camera { namespace Camera {
#define USE_DVP_CAMERA (USE_CAMERA && !CONFIG_IDF_TARGET_ESP32P4)
#define USE_CSI_CAMERA (USE_CAMERA && CONFIG_IDF_TARGET_ESP32P4)
#if USE_DVP_CAMERA
#include <esp_camera.h> #include <esp_camera.h>
#if USE_CAMERA
#include <peripherals/camera_pins.h> #include <peripherals/camera_pins.h>
#endif
#define PART_BOUNDARY "frame"
#define EVENT_CAMERA_SETTINGS "CameraSettings"
camera_fb_t *safe_camera_fb_get(); camera_fb_t *safe_camera_fb_get();
sensor_t *safe_sensor_get(); sensor_t *safe_sensor_get();
void safe_sensor_return(); void safe_sensor_return();
#endif
class CameraService : public StatefulService<CameraSettings> { #define PART_BOUNDARY "frame"
class CameraService
#if USE_DVP_CAMERA
: public StatefulService<CameraSettings>
#endif
{
public: public:
CameraService(); CameraService();
esp_err_t begin(); esp_err_t begin();
esp_err_t cameraStill(PsychicRequest *request); esp_err_t cameraStill(httpd_req_t *request);
esp_err_t cameraStream(PsychicRequest *request); esp_err_t cameraStream(httpd_req_t *request);
StatefulHttpEndpoint<CameraSettings> endpoint; #if USE_DVP_CAMERA
StatefulProtoEndpoint<CameraSettings, api_CameraSettings> protoEndpoint;
private: private:
EventEndpoint<CameraSettings> _eventEndpoint; FSPersistencePB<CameraSettings> _persistence;
FSPersistence<CameraSettings> _persistence;
void updateCamera(); void updateCamera();
#endif
}; };
} // namespace Camera } // namespace Camera
#endif // end CameraService_h
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <peripherals/i2c_bus.h>
#include <cmath>
class BMP180Driver {
public:
static constexpr uint8_t DEFAULT_ADDR = 0x77;
static constexpr float SEA_LEVEL_HPA = 1013.25f;
BMP180Driver(uint8_t addr = DEFAULT_ADDR) : _addr(addr) {}
bool begin() {
if (!I2CBus::instance().probe(_addr)) return false;
uint8_t id = readReg8(REG_CHIP_ID);
if (id != 0x55) return false;
_ac1 = readReg16(0xAA);
_ac2 = readReg16(0xAC);
_ac3 = readReg16(0xAE);
_ac4 = readReg16U(0xB0);
_ac5 = readReg16U(0xB2);
_ac6 = readReg16U(0xB4);
_b1 = readReg16(0xB6);
_b2 = readReg16(0xB8);
_mb = readReg16(0xBA);
_mc = readReg16(0xBC);
_md = readReg16(0xBE);
_initialized = true;
return true;
}
bool update() {
if (!_initialized) return false;
writeReg(REG_CONTROL, CMD_TEMP);
vTaskDelay(pdMS_TO_TICKS(5));
int32_t ut = readReg16(REG_OUT_MSB);
writeReg(REG_CONTROL, CMD_PRESSURE + (_oss << 6));
vTaskDelay(pdMS_TO_TICKS(2 + (3 << _oss)));
int32_t up = readReg24(REG_OUT_MSB) >> (8 - _oss);
int32_t x1 = ((ut - _ac6) * _ac5) >> 15;
int32_t x2 = (_mc << 11) / (x1 + _md);
int32_t b5 = x1 + x2;
_temperature = ((b5 + 8) >> 4) / 10.0f;
int32_t b6 = b5 - 4000;
x1 = (_b2 * ((b6 * b6) >> 12)) >> 11;
x2 = (_ac2 * b6) >> 11;
int32_t x3 = x1 + x2;
int32_t b3 = (((_ac1 * 4 + x3) << _oss) + 2) >> 2;
x1 = (_ac3 * b6) >> 13;
x2 = (_b1 * ((b6 * b6) >> 12)) >> 16;
x3 = ((x1 + x2) + 2) >> 2;
uint32_t b4 = (_ac4 * (uint32_t)(x3 + 32768)) >> 15;
uint32_t b7 = ((uint32_t)up - b3) * (50000 >> _oss);
int32_t p;
if (b7 < 0x80000000) {
p = (b7 << 1) / b4;
} else {
p = (b7 / b4) << 1;
}
x1 = (p >> 8) * (p >> 8);
x1 = (x1 * 3038) >> 16;
x2 = (-7357 * p) >> 16;
p = p + ((x1 + x2 + 3791) >> 4);
_pressure = p / 100.0f;
_altitude = 44330.0f * (1.0f - powf(_pressure / SEA_LEVEL_HPA, 0.1903f));
return true;
}
float getPressure() const { return _pressure; }
float getAltitude() const { return _altitude; }
float getTemperature() const { return _temperature; }
bool isInitialized() const { return _initialized; }
private:
static constexpr uint8_t REG_CHIP_ID = 0xD0;
static constexpr uint8_t REG_CONTROL = 0xF4;
static constexpr uint8_t REG_OUT_MSB = 0xF6;
static constexpr uint8_t CMD_TEMP = 0x2E;
static constexpr uint8_t CMD_PRESSURE = 0x34;
void writeReg(uint8_t reg, uint8_t val) { I2CBus::instance().writeReg(_addr, reg, &val, 1); }
uint8_t readReg8(uint8_t reg) {
uint8_t val = 0;
I2CBus::instance().readReg(_addr, reg, &val, 1);
return val;
}
int16_t readReg16(uint8_t reg) {
uint8_t buf[2];
I2CBus::instance().readReg(_addr, reg, buf, 2);
return (int16_t)((buf[0] << 8) | buf[1]);
}
uint16_t readReg16U(uint8_t reg) {
uint8_t buf[2];
I2CBus::instance().readReg(_addr, reg, buf, 2);
return (uint16_t)((buf[0] << 8) | buf[1]);
}
int32_t readReg24(uint8_t reg) {
uint8_t buf[3];
I2CBus::instance().readReg(_addr, reg, buf, 3);
return ((int32_t)buf[0] << 16) | ((int32_t)buf[1] << 8) | buf[2];
}
uint8_t _addr;
bool _initialized = false;
uint8_t _oss = 0;
int16_t _ac1, _ac2, _ac3;
uint16_t _ac4, _ac5, _ac6;
int16_t _b1, _b2;
int16_t _mb, _mc, _md;
float _temperature = 0;
float _pressure = 0;
float _altitude = 0;
};
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <peripherals/i2c_bus.h>
class BNO055Driver {
public:
static constexpr uint8_t DEFAULT_ADDR = 0x29;
BNO055Driver(uint8_t addr = DEFAULT_ADDR) : _addr(addr) {}
bool begin() {
if (!I2CBus::instance().probe(_addr)) return false;
uint8_t id = readReg(REG_CHIP_ID);
if (id != BNO055_ID) return false;
writeReg(REG_OPR_MODE, MODE_CONFIG);
vTaskDelay(pdMS_TO_TICKS(25));
writeReg(REG_SYS_TRIGGER, 0x20);
vTaskDelay(pdMS_TO_TICKS(650));
while (readReg(REG_CHIP_ID) != BNO055_ID) {
vTaskDelay(pdMS_TO_TICKS(10));
}
vTaskDelay(pdMS_TO_TICKS(50));
writeReg(REG_PWR_MODE, PWR_NORMAL);
vTaskDelay(pdMS_TO_TICKS(10));
writeReg(REG_PAGE_ID, 0);
writeReg(REG_SYS_TRIGGER, 0x80);
vTaskDelay(pdMS_TO_TICKS(10));
writeReg(REG_OPR_MODE, MODE_NDOF);
vTaskDelay(pdMS_TO_TICKS(20));
_initialized = true;
return true;
}
bool update() {
if (!_initialized) return false;
uint8_t buf[6];
if (I2CBus::instance().readReg(_addr, REG_EULER_H_LSB, buf, 6) != ESP_OK) return false;
int16_t h = (buf[1] << 8) | buf[0];
int16_t r = (buf[3] << 8) | buf[2];
int16_t p = (buf[5] << 8) | buf[4];
_euler[0] = h / 16.0f;
_euler[1] = r / 16.0f;
_euler[2] = p / 16.0f;
return true;
}
bool calibrate() {
if (!_initialized) return false;
uint8_t calData[22];
writeReg(REG_OPR_MODE, MODE_CONFIG);
vTaskDelay(pdMS_TO_TICKS(25));
if (I2CBus::instance().readReg(_addr, REG_ACCEL_OFFSET_X_LSB, calData, 22) != ESP_OK) {
writeReg(REG_OPR_MODE, MODE_NDOF);
return false;
}
writeReg(REG_OPR_MODE, MODE_NDOF);
vTaskDelay(pdMS_TO_TICKS(20));
return true;
}
float getHeading() const { return _euler[0]; }
float getRoll() const { return _euler[1]; }
float getPitch() const { return _euler[2]; }
bool isInitialized() const { return _initialized; }
uint8_t getCalibrationStatus() { return readReg(REG_CALIB_STAT); }
private:
static constexpr uint8_t BNO055_ID = 0xA0;
static constexpr uint8_t REG_CHIP_ID = 0x00;
static constexpr uint8_t REG_PAGE_ID = 0x07;
static constexpr uint8_t REG_ACCEL_OFFSET_X_LSB = 0x55;
static constexpr uint8_t REG_OPR_MODE = 0x3D;
static constexpr uint8_t REG_PWR_MODE = 0x3E;
static constexpr uint8_t REG_SYS_TRIGGER = 0x3F;
static constexpr uint8_t REG_EULER_H_LSB = 0x1A;
static constexpr uint8_t REG_CALIB_STAT = 0x35;
static constexpr uint8_t MODE_CONFIG = 0x00;
static constexpr uint8_t MODE_NDOF = 0x0C;
static constexpr uint8_t PWR_NORMAL = 0x00;
void writeReg(uint8_t reg, uint8_t val) { I2CBus::instance().writeReg(_addr, reg, &val, 1); }
uint8_t readReg(uint8_t reg) {
uint8_t val = 0;
I2CBus::instance().readReg(_addr, reg, &val, 1);
return val;
}
uint8_t _addr;
bool _initialized = false;
float _euler[3] = {0};
};
@@ -0,0 +1,81 @@
#pragma once
#include <peripherals/i2c_bus.h>
#include <cmath>
class HMC5883LDriver {
public:
static constexpr uint8_t DEFAULT_ADDR = 0x1E;
HMC5883LDriver(uint8_t addr = DEFAULT_ADDR) : _addr(addr) {}
bool begin() {
if (!I2CBus::instance().probe(_addr)) return false;
uint8_t idA = readReg(REG_ID_A);
uint8_t idB = readReg(REG_ID_B);
uint8_t idC = readReg(REG_ID_C);
if (idA != 'H' || idB != '4' || idC != '3') return false;
writeReg(REG_CONFIG_A, 0x70);
writeReg(REG_CONFIG_B, 0x20);
writeReg(REG_MODE, 0x00);
_initialized = true;
return true;
}
bool update() {
if (!_initialized) return false;
uint8_t buf[6];
if (I2CBus::instance().readReg(_addr, REG_DATA_X_MSB, buf, 6) != ESP_OK) return false;
int16_t x = (buf[0] << 8) | buf[1];
int16_t z = (buf[2] << 8) | buf[3];
int16_t y = (buf[4] << 8) | buf[5];
_mag[0] = x * _scale;
_mag[1] = y * _scale;
_mag[2] = z * _scale;
_heading = atan2f(_mag[1], _mag[0]);
_heading += _declination;
if (_heading < 0) _heading += 2 * M_PI;
if (_heading > 2 * M_PI) _heading -= 2 * M_PI;
_heading *= 180.0f / M_PI;
return true;
}
void setDeclination(float dec) { _declination = dec; }
float getMagX() const { return _mag[0]; }
float getMagY() const { return _mag[1]; }
float getMagZ() const { return _mag[2]; }
float getHeading() const { return _heading; }
bool isInitialized() const { return _initialized; }
private:
static constexpr uint8_t REG_CONFIG_A = 0x00;
static constexpr uint8_t REG_CONFIG_B = 0x01;
static constexpr uint8_t REG_MODE = 0x02;
static constexpr uint8_t REG_DATA_X_MSB = 0x03;
static constexpr uint8_t REG_ID_A = 0x0A;
static constexpr uint8_t REG_ID_B = 0x0B;
static constexpr uint8_t REG_ID_C = 0x0C;
static constexpr float _scale = 0.92f;
void writeReg(uint8_t reg, uint8_t val) { I2CBus::instance().writeReg(_addr, reg, &val, 1); }
uint8_t readReg(uint8_t reg) {
uint8_t val = 0;
I2CBus::instance().readReg(_addr, reg, &val, 1);
return val;
}
uint8_t _addr;
bool _initialized = false;
float _mag[3] = {0};
float _heading = 0;
float _declination = 0.22f;
};

Some files were not shown because too many files have changed in this diff Show More