291 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
Rune Harlyk bc27e5000a 🎨 Update connection url 2025-12-25 14:02:21 +01:00
Rune Harlyk a67d4643b0 🎨 Handle static config files 2025-12-25 13:39:15 +01:00
Rune Harlyk 4e24d87e4b Make read imu and mag be timing based 2025-12-25 13:38:50 +01:00
Rune Harlyk 630bab7678 ♻️ Remove duplicate ping pong handling 2025-12-25 13:37:05 +01:00
Rune Harlyk f54c957be8 🐛 Secure sub and unsub with mutex 2025-12-25 13:36:49 +01:00
Rune Harlyk ed88e47944 🐛 Map rotation bound correct in rad 2025-12-25 13:36:24 +01:00
Rune Harlyk ba36bcc5a5 ♻️ Adds filesystems endpoints back 2025-12-25 13:36:01 +01:00
Rune Harlyk 5e2e29d2a4 ♻️ Change kinematics units to SI 2025-12-24 13:44:45 +01:00
Rune Harlyk 3be08a31ed Adds imu calibration 2025-12-24 13:44:43 +01:00
Rune Harlyk e22ac69e9b 🚨 Fix warnings 2025-12-24 12:34:36 +01:00
Rune Harlyk 0e54f0430f Adds rest of missing api endpoints 2025-12-23 22:28:23 +01:00
Rune Harlyk 0556f86473 🎨 Adds endpoints for wifi and ap 2025-12-20 21:07:32 +01:00
Rune Harlyk 097cc0e33e 🐛 Update base step height in visualizer 2025-12-08 22:30:56 +01:00
Rune Harlyk fe76f2d7dd Adds system metrics endpoints 2025-11-27 21:15:47 +01:00
Rune Harlyk f78a0f50bd 🐛 Fix imu and magnotometer 2025-11-27 20:59:11 +01:00
Rune Harlyk d43e98d06b 🐛 Imu temp in message 2025-11-27 18:44:36 +01:00
Rune Harlyk ffb2bc8749 🐛 Fix socket deadlock 2025-11-27 18:44:12 +01:00
Rune Harlyk 6c61227623 Emit imu, mag and bmp data 2025-11-27 17:38:37 +01:00
Rune Harlyk 7d2f384898 🐛 Fix system metric emit 2025-11-27 17:32:48 +01:00
Rune Harlyk 8a80559ea7 🐛 Call begin on camera service 2025-11-27 17:28:49 +01:00
Niklas Jensen 7c3dd2d15b Fixed ordering of readme (developing->running) 2025-11-27 14:29:47 +01:00
Niklas Jensen 135c7b0c94 Fixed esp32 prebuild FS directory location 2025-11-27 14:29:47 +01:00
Rune Harlyk 06d457f4e5 🐛 Fixes barometer 2025-11-04 20:03:07 +01:00
Rune Harlyk 67c5936399 🎨 Update observation space to match real world 2025-10-23 15:41:02 +02:00
Rune Harlyk f1751f2589 🐛 Fixes drag angles handling 2025-10-20 21:12:39 +02:00
Rune Harlyk 48c0b01f93 🎨 Remove sky in favor of static background color 2025-10-20 21:07:05 +02:00
Rune Harlyk 64ef3d31eb 🐛 Fix relative path in app 2025-10-20 20:34:33 +02:00
Rune Harlyk b14f005b22 🐛 Fix model loading on github pages 2025-10-20 20:17:57 +02:00
Rune Harlyk 72a288145d 🎨 Set 3D representation as default view 2025-10-20 19:22:23 +02:00
Rune Harlyk af0815b01f 🎨 Reduce stand offset 2025-10-20 19:08:09 +02:00
Rune Harlyk df3e813470 🎨 Improve rotation handling 2025-10-20 19:08:09 +02:00
Rune Harlyk 1b28b8b7fd 🐛 Fix stl relative model path 2025-10-20 19:08:09 +02:00
Rune Harlyk c449cb3390 🎨 Adds rotation keyboard controls 2025-10-20 19:08:09 +02:00
Rune Harlyk 05a420f345 Adds cumulative displacement of the robot 2025-10-20 19:08:09 +02:00
Rune Harlyk df395657e3 🎨 Removes deprecated base 2025-10-20 17:35:39 +02:00
Rune Harlyk 8970457353 🎨 Fix different typing problems 2025-10-14 20:07:12 +02:00
Rune Harlyk 0aab42f0e9 🎮 Maps controller buttons to modes 2025-10-14 19:41:40 +02:00
Rune Harlyk 76d965ff43 🎨 Updates defaults motion smoothing 2025-10-11 20:51:02 +02:00
Rune Harlyk 0b9921e592 🎨 Updates duty and fixes direction angle 2025-10-11 19:16:30 +02:00
Rune Harlyk aee29c47e4 🎨 Improves mode handling 2025-10-11 15:29:22 +02:00
Rune Harlyk f2ee454b89 ⬆️ Upgrade frontend dependencies 2025-10-11 11:02:17 +02:00
Rune Harlyk a77eb0b1e0 🎨 Lint project 2025-10-11 10:54:07 +02:00
Rune Harlyk 91a7b170fe 🎨 format 2025-10-11 10:42:32 +02:00
Rune Harlyk 4d51b9f556 🎨 Adds kinematics config to readme 2025-10-10 22:23:51 +02:00
Rune Harlyk 92a98064c3 🎨 Updates readme 2025-10-10 22:05:27 +02:00
Rune Harlyk 1fbddd483c Adds option to control sim using web app 2025-10-10 22:05:27 +02:00
Rune Harlyk d47ce02cc6 ️ Makes training parallelized 2025-10-10 22:05:27 +02:00
Rune Harlyk 01c4a80c8f 🔥 Clean up gitignore 2025-10-10 22:05:27 +02:00
Rune Harlyk 174d77a9fd Updates training script with stablebaseline 2025-10-10 22:05:27 +02:00
Rune Harlyk a078f28a82 🎨 Use real variables 2025-10-10 22:05:27 +02:00
Rune Harlyk f3f3864b83 🔥 Remove simple play kinematics 2025-10-10 22:05:27 +02:00
Rune Harlyk 46bb5f74b1 🎨 Fixes gait in sim 2025-10-10 22:05:27 +02:00
Rune Harlyk 89a0316fb4 Adds script to play with kinematics 2025-10-10 22:05:27 +02:00
Rune Harlyk 51ee910fb6 🐛 Fixes many smaller simulation pains 2025-10-10 22:05:27 +02:00
Rune Harlyk a198de05c2 Fixes body kin rot 2025-10-10 22:05:27 +02:00
Rune Harlyk d3db2b3650 ♻️ Update sim structure 2025-10-10 22:05:27 +02:00
Rune Harlyk 5a6f195f56 🫐 Updates foot color for urdf 2025-10-10 22:05:27 +02:00
Rune Harlyk 0cae981779 🧁 Simplifies backpart stl 2025-10-10 22:05:27 +02:00
Rune Harlyk c541b3f474 🧼 Removes print 2025-10-10 22:05:27 +02:00
Rune Harlyk ceccb2c901 🪇 Adds git input function to GUI 2025-10-10 22:05:27 +02:00
Rune Harlyk 8c21f3e2e4 🎯 Updates number of solve iterations 2025-10-10 22:05:27 +02:00
Rune Harlyk 55eecdc8d7 🛹 Adds static gui to env 2025-10-10 22:05:27 +02:00
Rune Harlyk b98c0e866b 🍒 Saves the initial state for faster reload 2025-10-10 22:05:27 +02:00
Rune Harlyk 3d294f38c2 🪴 Adds gitignore for python 2025-10-10 22:05:27 +02:00
Rune Harlyk a237dc3995 📏 Tries to rebuild kinematics in python 2025-10-10 22:05:27 +02:00
Rune Harlyk 80c74dc745 🧹 Formats urdf 2025-10-10 22:05:27 +02:00
Rune Harlyk fb9313913d 🤖 Adds plane 2025-10-10 22:05:27 +02:00
Rune Harlyk 33e7fac74c 🤖 Adds initial sim structure 2025-10-10 22:05:27 +02:00
Rune Harlyk 2face72aee 🎨 Clamp servo pwm 2025-10-09 18:33:17 +02:00
Rune Harlyk 1f8e7efdb2 Adds option to rotate gesture sensor 2025-10-09 18:33:04 +02:00
Rune Harlyk b184449e7b 🔥 Clean up arduino libs 2025-10-09 18:31:40 +02:00
Rune Harlyk bc31b1b2dd Replace millis with esp timer 2025-10-09 17:49:36 +02:00
Rune Harlyk 12e1f80830 🐛 Adds missing function definitions in socket adapter 2025-09-18 18:50:04 +02:00
Rune Harlyk 1cadcf8bdb 🎨 Pull subscribe logic out from websocket 2025-09-18 18:50:04 +02:00
Rune Harlyk 06d27e0644 🎨 Renames event socket to websocket adapter 2025-09-18 18:50:04 +02:00
Rune Harlyk 98b519dee8 🐛 Adds servo config over http 2025-09-18 18:50:04 +02:00
Rune Harlyk 4da2d7fa20 🔥 Cleans up build flags 2025-09-18 18:50:04 +02:00
Rune Harlyk 0f992b26e9 🔥 Removes unused feature flags 2025-09-18 18:50:04 +02:00
Rune Harlyk 2a57d1ecc3 🔥 Removes firmware rename script 2025-09-18 18:50:04 +02:00
Rune Harlyk fd3180d08b 🔥 Removes unused libs 2025-09-18 18:50:04 +02:00
Rune Harlyk 43b5216d9f ️ Removes task manager dependency 2025-09-18 18:50:04 +02:00
Rune Harlyk e1e11346b4 🔥 Removes unused functions and constants 2025-09-18 18:50:04 +02:00
Rune Harlyk 3ce8c88a84 🎨 Replace Arduino String with std::string 2025-09-18 18:50:04 +02:00
Rune Harlyk 0285b522f1 🎨 Replaces delay with vTaskDelay 2025-09-18 18:50:04 +02:00
Rune Harlyk 4ea287b162 🐛 Fixes table linking 2025-09-14 19:43:34 +02:00
Rune Harlyk c2d52449b4 🎨 Makes file system service use define var 2025-09-14 19:43:34 +02:00
Rune Harlyk f9a0880cd9 Moves servo event to main 2025-09-14 19:43:34 +02:00
Rune Harlyk 1bb098e952 ⬇️ Downgrades fastled version 2025-09-14 19:43:34 +02:00
Rune Harlyk 9c74c8e87b 🚨 Fixes build error for esp-idf 2025-09-14 19:43:34 +02:00
Rune Harlyk 3f4d956903 Adds partion tables 2025-09-14 19:43:34 +02:00
Rune Harlyk a5371c36b9 ♻️ Moves peripherals to source file, add sensor base 2025-09-14 19:43:34 +02:00
Rune Harlyk 41b863a0eb ♻️ Moves motion implementation to source file 2025-09-14 19:43:34 +02:00
Rune Harlyk 7fd35f3f48 ♻️ Major clean up of project structure 2025-09-14 19:43:34 +02:00
317 changed files with 23497 additions and 12679 deletions
+5
View File
@@ -36,6 +36,11 @@ jobs:
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
- run: pnpm install
- run: pnpm run build
+8
View File
@@ -18,6 +18,8 @@ jobs:
steps:
- uses: actions/checkout@v3
with:
submodules: "recursive"
- uses: actions/cache@v3
with:
path: |
@@ -32,6 +34,12 @@ jobs:
- name: Install PlatformIO Core
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
run: pio run
+30 -21
View File
@@ -2,14 +2,14 @@ name: Frontend Tests
on:
push:
branches: [ master ]
branches: [master]
paths:
- 'app/**'
- "app/**"
pull_request:
branches: [ master ]
branches: [master]
paths:
- 'app/**'
- "app/**"
permissions:
contents: read
@@ -20,22 +20,31 @@ jobs:
run:
working-directory: ./app
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'latest'
cache: 'pnpm'
cache-dependency-path: './app/pnpm-lock.yaml'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "latest"
cache: "pnpm"
cache-dependency-path: "./app/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
version: "27.x"
- name: Run tests
run: pnpm test
- name: Install dependencies
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.class
.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.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": [
"Adafruit",
"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)
+1 -3
View File
@@ -1,3 +1 @@
PUBLIC_VITE_USE_HOST_NAME=true
PUBLIC_USE_JSON=true
PUBLIC_USE_MSGPACK=true
PUBLIC_VITE_USE_HOST_NAME=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'
}
}
]
};
+1 -2
View File
@@ -1,13 +1,12 @@
{
"useTabs": false,
"singleQuote": true,
"tabWidth": 2,
"tabWidth": 4,
"trailingComma": "none",
"arrowParens": "avoid",
"experimentalTernaries": true,
"printWidth": 100,
"semi": false,
"svelteBracketNewLine": false,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+5 -1
View File
@@ -1,3 +1,7 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
+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'
]
}
)
+71 -63
View File
@@ -1,65 +1,73 @@
{
"name": "spot_micro_controller",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/tabler": "^1.1.109",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^8.56.0",
"@types/three": "^0.162.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.45.1",
"jsdom": "^24.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.0.12",
"tslib": "^2.6.1",
"typescript": "^5.5.0",
"unplugin-icons": "^0.18.5",
"vite": "^6.2.1",
"vitest": "^1.2.0"
},
"type": "module",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"@niku/vite-env-caster": "^1.0.2",
"@sveltejs/adapter-auto": "^4.0.0",
"@tailwindcss/vite": "^4.0.12",
"chart.js": "^4.4.2",
"compare-versions": "^6.1.0",
"cross-env": "^7.0.3",
"daisyui": "^5.0.0",
"nipplejs": "^0.10.1",
"svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.0",
"three": "^0.162.0",
"urdf-loader": "^0.12.1",
"uzip": "^0.20201231.0",
"xacro-parser": "^0.3.9"
},
"packageManager": "pnpm@9.3.0"
"name": "spot_micro_controller",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev --host",
"build": "pnpm proto && vite build",
"build:embedded": "cross-env VITE_USE_HOST_NAME=true vite build",
"preview": "vite preview",
"test": "pnpm run test:integration && pnpm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest",
"proto": "node scripts/compile_protos.js"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/tabler": "^1.2.23",
"@playwright/test": "^1.56.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.46.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^9.6.1",
"@types/three": "^0.180.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.37.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^17.0.0",
"jsdom": "^27.0.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.39.11",
"svelte-check": "^4.3.3",
"svelte-focus-trap": "^1.2.0",
"tailwindcss": "^4.1.14",
"ts-proto-descriptors": "^2.1.0",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0",
"unplugin-icons": "^22.4.2",
"vite": "^7.1.9",
"vitest": "^3.2.4",
"ws": "^8.18.3"
},
"type": "module",
"dependencies": {
"@bufbuild/protobuf": "^2.10.2",
"@niku/vite-env-caster": "^1.1.2",
"@sveltejs/adapter-auto": "^6.1.1",
"@tailwindcss/vite": "^4.1.14",
"chart.js": "^4.5.0",
"compare-versions": "^6.1.1",
"cross-env": "^10.1.0",
"daisyui": "^5.2.0",
"nipplejs": "^0.10.2",
"svelte-dnd-list": "^0.1.8",
"svelte-modals": "^2.0.1",
"three": "^0.180.0",
"ts-proto": "^2.10.1",
"urdf-loader": "^0.12.6",
"uzip": "^0.20201231.0",
"xacro-parser": "^0.3.10"
},
"packageManager": "pnpm@9.3.0"
}
+9 -9
View File
@@ -1,12 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
import type { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 4173
},
testDir: 'tests/integration',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 4173
},
testDir: 'tests/integration',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
}
export default config;
export default config
+2140 -2166
View File
File diff suppressed because it is too large Load Diff
+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)
}
+8
View File
@@ -23,6 +23,14 @@
--base-content: oklch(0.3 0.012 256);
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
#nipple_0_0,
#nipple_1_1 {
z-index: 10 !important;
+8 -8
View File
@@ -1,13 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
export {}
+14 -11
View File
@@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo512.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo512.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+48 -35
View File
@@ -1,22 +1,29 @@
import { get } from 'svelte/store';
import { Err, Ok, type Result } from './utilities';
import { location } from './stores';
import { get } from 'svelte/store'
import { Err, Ok, type Result } from './utilities'
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 namespace api {
export function get<TResponse>(endpoint: string, params?: RequestInit) {
return sendRequest<TResponse>(endpoint, 'GET', null, params);
}
export const api = {
get<TResponse>(endpoint: string, params?: RequestInit) {
return sendRequest<TResponse>(endpoint, 'GET', null, params)
},
export function post<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'POST', data);
}
post<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'POST', data)
},
export function put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data);
}
post_proto<TResponse>(endpoint: string, data: Request) {
return sendRequest<TResponse>(endpoint, 'POST', Request.encode(data))
},
export function remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'DELETE');
put<TResponse>(endpoint: string, data?: unknown) {
return sendRequest<TResponse>(endpoint, 'PUT', data)
},
remove<TResponse>(endpoint: string) {
return sendRequest<TResponse>(endpoint, 'DELETE')
}
}
@@ -26,8 +33,12 @@ async function sendRequest<TResponse>(
data?: unknown,
params?: RequestInit
): Promise<Result<TResponse, Error>> {
endpoint = resolveUrl(endpoint);
const body = data !== null && typeof data !== 'undefined' ? JSON.stringify(data) : undefined;
endpoint = resolveUrl(endpoint)
const isProtobuf = data instanceof BinaryWriter
const body = data !== null && typeof data !== 'undefined'
? (isProtobuf ? data.finish() : JSON.stringify(data))
: undefined
const request = {
...params,
@@ -36,45 +47,47 @@ async function sendRequest<TResponse>(
headers: {
...params?.headers,
Authorization: 'Basic',
'Content-Type': 'application/json'
'Content-Type': isProtobuf ? 'application/x-protobuf' : 'application/json'
}
};
}
let response;
let response
try {
response = await fetch(endpoint, request);
} catch (error) {
return Err.new(new Error(), 'An error has occurred');
response = await fetch(endpoint, request)
} catch {
return Err.new(new Error(), 'An error has occurred')
}
const isResponseOk = response.status >= 200 && response.status < 400;
const isResponseOk = response.status >= 200 && response.status < 400
if (!isResponseOk) {
if (response.status === 401) {
return Err.new(new ApiError(response), 'User was not authorized');
return Err.new(new ApiError(response), 'User was not authorized')
}
return Err.new(new ApiError(response), 'An error has occurred');
return Err.new(new ApiError(response), 'An error has occurred')
}
const contentType =
response.headers.get('Content-Type') ?? response.headers.get('Content-Type');
const contentType = response.headers.get('Content-Type') ?? response.headers.get('Content-Type')
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return Ok.new(data as TResponse);
const data = await response.json()
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 {
// Handle empty object as response
return Ok.new(null as TResponse);
return Ok.new(null as TResponse)
}
}
function resolveUrl(url: string): string {
if (url.startsWith('http') || !get(location)) return url;
const protocol = window.location.protocol;
return `${protocol}//${get(location)}${url.startsWith('/') ? '' : '/'}${url}`;
if (url.startsWith('http') || !get(apiLocation)) return url
const protocol = window.location.protocol
return `${protocol}//${get(apiLocation)}${url.startsWith('/') ? '' : '/'}${url}`
}
export class ApiError extends Error {
constructor(public readonly response: Response) {
super(`${response.status}`);
super(`${response.status}`)
}
}
+7 -7
View File
@@ -1,18 +1,18 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { Down } from './icons';
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { Down } from './icons'
function openCollapsible() {
open = !open;
open = !open
if (open) {
opened();
opened()
} else {
closed();
closed()
}
}
let { icon, title, children, open, opened, closed, class: klass } = $props();
let { icon, title, children, open, opened, closed, class: klass } = $props()
</script>
<div class="{klass} relative grid w-full max-w-2xl self-center overflow-hidden">
+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>
+40 -35
View File
@@ -1,43 +1,48 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { Cancel, Check } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { Cancel, Check } from '$lib/components/icons'
import { modals, exitBeforeEnter, type ModalProps } from 'svelte-modals'
let {
isOpen,
title,
message,
onConfirm,
labels = {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
}
}: ModalProps = $props()
let {
isOpen,
title,
message,
onConfirm,
labels = {
cancel: { label: 'Cancel', icon: Cancel },
confirm: { label: 'OK', icon: Check }
}
}: ModalProps = $props()
</script>
{#if isOpen}
{@const SvelteComponent = labels?.confirm.icon}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
{@const SvelteComponent = labels?.confirm.icon}
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div>
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button class="btn btn-error inline-flex items-center" onclick={() => modals.close()}>
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
</button>
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
</button>
</div>
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap
>
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div>
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-error inline-flex items-center"
onclick={() => modals.close()}
>
<labels.cancel.icon class="mr-2 h-5 w-5" /><span>{labels?.cancel.label}</span>
</button>
<button class="btn btn-primary inline-flex items-center" onclick={onConfirm}>
<SvelteComponent class="mr-2 h-5 w-5" /><span>{labels?.confirm.label}</span>
</button>
</div>
</div>
</div>
</div>
{/if}
@@ -1,61 +1,61 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { telemetry } from '$lib/stores/telemetry';
import { Cancel } from './icons';
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { telemetry } from '$lib/stores/telemetry'
import { Cancel } from './icons'
import { modals, exitBeforeEnter, onBeforeClose } from 'svelte-modals'
// provided by <Modals />
interface Props {
isOpen: boolean;
isOpen: boolean
}
let { isOpen }: Props = $props();
let { isOpen }: Props = $props()
let updating = $state(true);
let updating = $state(true)
let progress = $state(0);
let progress = $state(0)
$effect(() => {
if ($telemetry.download_ota.status == 'progress') {
progress = $telemetry.download_ota.progress;
progress = $telemetry.download_ota.progress
}
});
})
$effect(() => {
if ($telemetry.download_ota.status == 'error') {
updating = false;
updating = false
}
});
})
let message = $state('Preparing ...');
let message = $state('Preparing ...')
$effect(() => {
if ($telemetry.download_ota.status == 'progress') {
message = 'Downloading ...';
message = 'Downloading ...'
} else if ($telemetry.download_ota.status == 'error') {
message = $telemetry.download_ota.error;
message = $telemetry.download_ota.error
} else if ($telemetry.download_ota.status == 'finished') {
message = 'Restarting ...';
progress = 0;
message = 'Restarting ...'
progress = 0
// Reload page after 5 sec
setTimeout(() => {
modals.closeAll();
location.reload();
}, 5000);
modals.closeAll()
location.reload()
}, 5000)
}
});
})
onBeforeClose(() => {
if (updating) {
// prevents modal from closing
return false;
return false
} else {
$telemetry.download_ota.status = 'idle';
$telemetry.download_ota.error = '';
$telemetry.download_ota.progress = 0;
return true;
$telemetry.download_ota.status = 'idle'
$telemetry.download_ota.error = ''
$telemetry.download_ota.progress = 0
return true
}
});
})
</script>
{#if isOpen}
@@ -89,8 +89,8 @@
class="btn btn-warning text-warning-content inline-flex flex-none items-center"
disabled={updating}
onclick={() => {
modals.closeAll();
location.reload();
modals.closeAll()
location.reload()
}}
>
<Cancel class="mr-2 h-5 w-5" /><span>Close</span></button
+35 -32
View File
@@ -1,40 +1,43 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { Check } from './icons';
import { exitBeforeEnter, type ModalProps } from 'svelte-modals';
import { focusTrap } from 'svelte-focus-trap'
import { fly } from 'svelte/transition'
import { Check } from './icons'
import { exitBeforeEnter, type ModalProps } from 'svelte-modals'
let {
isOpen,
title,
message,
onDismiss,
labels = {
dismiss: { label: 'Dismiss', icon: Check },
},
}: ModalProps = $props();
let {
isOpen,
title,
message,
onDismiss,
labels = {
dismiss: { label: 'Dismiss', icon: Check }
}
}: ModalProps = $props()
</script>
{#if isOpen}
<div
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap>
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg">
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div>
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}>
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
</button>
</div>
role="dialog"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
transition:fly={{ y: 50 }}
use:exitBeforeEnter
use:focusTrap
>
<div
class="rounded-box bg-base-100 pointer-events-auto flex min-w-fit max-w-md flex-col justify-between p-4 shadow-lg"
>
<h2 class="text-base-content text-start text-2xl font-bold">{title}</h2>
<div class="divider my-2"></div>
<p class="text-base-content mb-1 text-start">{message}</p>
<div class="divider my-2"></div>
<div class="flex justify-end gap-2">
<button
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
</button>
</div>
</div>
</div>
</div>
{/if}
@@ -1,78 +1,78 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three';
import { imu } from '$lib/stores/imu';
import SceneBuilder from '$lib/sceneBuilder';
import { onMount, onDestroy } from 'svelte'
import * as THREE from 'three'
import { imu } from '$lib/stores/imu'
import SceneBuilder from '$lib/sceneBuilder'
let canvas: HTMLCanvasElement;
let sceneBuilder: SceneBuilder;
let cube: THREE.Mesh;
let targetRotation = new THREE.Euler();
let lastUpdateTime = 0;
const LERP_SPEED = 5; // rotations per second
let canvas: HTMLCanvasElement
let sceneBuilder: SceneBuilder
let cube: THREE.Mesh
let targetRotation = new THREE.Euler()
let lastUpdateTime = 0
const LERP_SPEED = 5 // rotations per second
const initThreeJS = () => {
sceneBuilder = new SceneBuilder()
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
.addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent();
const initThreeJS = () => {
sceneBuilder = new SceneBuilder()
.addRenderer({ canvas: canvas, antialias: true, alpha: true })
.addPerspectiveCamera({ x: 2, y: 0, z: 2 })
.addOrbitControls(1, 10, false)
.addAmbientLight({ color: 0x404040, intensity: 0.5 })
.addDirectionalLight({ color: 0xffffff, intensity: 3, x: 10, y: 20, z: 7 })
.fillParent()
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.8,
});
cube = new THREE.Mesh(geometry, material);
sceneBuilder.scene.add(cube);
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshPhongMaterial({
color: 0x00ff00,
transparent: true,
opacity: 0.8
})
cube = new THREE.Mesh(geometry, material)
sceneBuilder.scene.add(cube)
sceneBuilder.addRenderCb(() => {
if (!cube) return;
const currentTime = performance.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000; // convert to seconds
lastUpdateTime = currentTime;
sceneBuilder.addRenderCb(() => {
if (!cube) return
const currentTime = performance.now()
const deltaTime = (currentTime - lastUpdateTime) / 1000 // convert to seconds
lastUpdateTime = currentTime
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime);
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor);
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor);
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor);
});
const lerpFactor = Math.min(1, LERP_SPEED * deltaTime)
cube.rotation.x = THREE.MathUtils.lerp(cube.rotation.x, targetRotation.x, lerpFactor)
cube.rotation.y = THREE.MathUtils.lerp(cube.rotation.y, targetRotation.y, lerpFactor)
cube.rotation.z = THREE.MathUtils.lerp(cube.rotation.z, targetRotation.z, lerpFactor)
})
sceneBuilder.startRenderLoop();
};
const updateOrientation = () => {
if (!cube) return;
const y = -$imu.x[$imu.x.length - 1] || 0;
const x = $imu.y[$imu.y.length - 1] || 0;
const z = -$imu.z[$imu.z.length - 1] || 0;
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
);
};
onMount(() => {
initThreeJS();
});
onDestroy(() => {
sceneBuilder?.renderer?.dispose();
});
$effect(() => {
if ($imu) {
updateOrientation();
sceneBuilder.startRenderLoop()
}
});
const updateOrientation = () => {
if (!cube) return
const y = -$imu.x[$imu.x.length - 1] || 0
const x = $imu.y[$imu.y.length - 1] || 0
const z = -$imu.z[$imu.z.length - 1] || 0
targetRotation.set(
THREE.MathUtils.degToRad(x),
THREE.MathUtils.degToRad(y),
THREE.MathUtils.degToRad(z)
)
}
onMount(() => {
initThreeJS()
})
onDestroy(() => {
sceneBuilder?.renderer?.dispose()
})
$effect(() => {
if ($imu) {
updateOrientation()
}
})
</script>
<div class="h-60 w-60 border-2 border-base-300 rounded-md">
<canvas class="w-full h-full" bind:this={canvas}></canvas>
<canvas class="w-full h-full" bind:this={canvas}></canvas>
</div>
+65 -49
View File
@@ -1,60 +1,76 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { Down } from './icons'
interface Props {
open?: boolean
collapsible?: boolean
icon?: import('svelte').Snippet
title?: import('svelte').Snippet
children?: import('svelte').Snippet
right?: import('svelte').Snippet
}
import { slide } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { Down } from './icons'
interface Props {
open?: boolean
collapsible?: boolean
icon?: import('svelte').Snippet
title?: import('svelte').Snippet
children?: import('svelte').Snippet
right?: import('svelte').Snippet
}
let { open = $bindable(true), collapsible = true, icon, title, children, right }: Props = $props()
let {
open = $bindable(true),
collapsible = true,
icon,
title,
children,
right
}: Props = $props()
</script>
{#if collapsible}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
<span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
</span>
<button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => {
open = !open
}}>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {open ?
'rotate-180'
: ''}" />
</button>
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
>
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
>
<span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
</span>
<button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => {
open = !open
}}
>
<Down
class="text-base-content h-auto w-6 transition-transform duration-300 ease-in-out {(
open
) ?
'rotate-180'
: ''}"
/>
</button>
</div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
{@render children?.()}
</div>
{/if}
</div>
{#if open}
<div
class="flex flex-col gap-2 p-4 pt-0"
transition:slide|local={{ duration: 300, easing: cubicOut }}>
{@render children?.()}
</div>
{/if}
</div>
{:else}
<div
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg">
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium">
<span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
</span>
{@render right?.()}
class="bg-base-200 rounded-box relative grid w-full max-w-2xl self-center overflow-hidden shadow-lg"
>
<div
class="min-h-16 flex w-full items-center justify-between space-x-3 p-4 text-xl font-medium"
>
<span class="inline-flex items-baseline">
{@render icon?.()}
{@render title?.()}
</span>
{@render right?.()}
</div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div>
<div class="flex flex-col gap-2 p-4 pt-0">
{@render children?.()}
</div>
</div>
{/if}
+3 -4
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import { Loader } from "./icons";
import { Loader } from './icons'
</script>
<div class="flex h-full w-full flex-col items-center justify-center p-6">
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
<p class="text-xl">Loading...</p>
<Loader class="text-primary h-14 w-auto animate-spin stroke-2" />
<p class="text-xl">Loading...</p>
</div>
+37 -35
View File
@@ -1,45 +1,47 @@
<script lang="ts">
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
import type { Component } from 'svelte'
const {
icon,
title,
description = '',
variant = 'primary',
class: klass = '',
children = null
} = $props<{
icon?: any
title: string
description?: string | number
variant?: Variant
class?: string
children?: () => any
}>()
type Variant = 'success' | 'error' | 'primary' | 'info' | 'warning'
const Icon = $derived(icon)
const {
icon,
title,
description = '',
variant = 'primary',
class: klass = '',
children = null
} = $props<{
icon?: Component
title: string
description?: string | number
variant?: Variant
class?: string
children?: () => Component
}>()
const variants: Record<Variant, [string, string]> = {
success: ['bg-success', 'text-success-content'],
error: ['bg-error', 'text-error-content'],
primary: ['bg-primary', 'text-primary-content'],
info: ['bg-info', 'text-info-content'],
warning: ['bg-warning', 'text-warning-content']
}
const Icon = $derived(icon)
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
const [bgColor, textColor] = variants[variantKey]
const variants: Record<Variant, [string, string]> = {
success: ['bg-success', 'text-success-content'],
error: ['bg-error', 'text-error-content'],
primary: ['bg-primary', 'text-primary-content'],
info: ['bg-info', 'text-info-content'],
warning: ['bg-warning', 'text-warning-content']
}
const variantKey: Variant = (variant as Variant) in variants ? (variant as Variant) : 'primary'
const [bgColor, textColor] = variants[variantKey]
</script>
<div class="rounded-box bg-base-100 flex items-center space-x-3 px-4 py-2 {klass}">
{#if icon}
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
<Icon class="{textColor} h-auto w-full scale-75" />
{#if icon}
<div class="mask mask-hexagon {bgColor} h-auto w-10 flex-none">
<Icon class="{textColor} h-auto w-full scale-75" />
</div>
{/if}
<div class="grow">
<div class="font-bold">{title}</div>
<div class="text-sm opacity-75 grow">{description}</div>
</div>
{/if}
<div class="grow">
<div class="font-bold">{title}</div>
<div class="text-sm opacity-75 grow">{description}</div>
</div>
{@render children?.()}
{@render children?.()}
</div>
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { location } from '$lib/stores';
import { onDestroy } from 'svelte'
import { apiLocation } from '$lib/stores'
let source = $state(`${$location}/api/camera/stream`);
let source = $state(`${$apiLocation}/api/camera/stream`)
onDestroy(() => (source = '#'));
onDestroy(() => (source = '#'))
</script>
<div class="w-full h-full">
+31 -29
View File
@@ -1,35 +1,37 @@
<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from './icons';
import { flip } from 'svelte/animate'
import { fly } from 'svelte/transition'
import { notifications } from '$lib/components/toasts/notifications'
import { error, info, success, warning } from './icons'
/** @type {{theme?: any, icon?: any}} */
let { theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
}, icon = {
error: error,
success: success,
warning: warning,
info: info
} } = $props();
/** @type {{theme?: any, icon?: any}} */
let {
theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
},
icon = {
error: error,
success: success,
warning: warning,
info: info
}
} = $props()
</script>
<div class="toast toast-end mr-4">
{#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
{#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
</div>
+359 -319
View File
@@ -1,332 +1,372 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
BufferGeometry,
Line,
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
type Object3D,
SphereGeometry,
Vector3,
type NormalBufferAttributes,
type Object3DEventMap
} from 'three'
import {
ModesEnum,
kinematicData,
mode,
model,
outControllerData,
servoAnglesOut,
servoAngles,
mpu,
jointNames,
currentKinematic,
walkGait,
walkGaits,
walkGaitToMode
} from '$lib/stores'
import {
extractFootColor,
populateModelCache,
throttler,
getToeWorldPositions
} from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic'
import { BezierState, CalibrationState, IdleState, RestState, StandState } from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
import { onDestroy, onMount } from 'svelte'
import {
Mesh,
MeshBasicMaterial,
type Object3D,
SphereGeometry,
Vector3,
type Object3DEventMap,
Color
} from 'three'
import {
mode,
model,
input,
servoAnglesOut,
servoAngles,
mpu,
jointNames,
currentKinematic,
walkGait,
kinematicData
} from '$lib/stores'
import { populateModelCache, getToeWorldPositions } from '$lib/utilities'
import SceneBuilder from '$lib/sceneBuilder'
import { lerp, degToRad } from 'three/src/math/MathUtils'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { type body_state_t } from '$lib/kinematic'
import {
BezierState,
CalibrationState,
GaitState,
IdleState,
RestState,
StandState
} from '$lib/gait'
import { radToDeg } from 'three/src/math/MathUtils.js'
import type { URDFRobot } from 'urdf-loader'
import { get } from 'svelte/store'
import { AnglesData, KinematicData, ModesEnum } from '$lib/platform_shared/message'
interface Props {
sky?: boolean
orbit?: boolean
panel?: boolean
debug?: boolean
ground?: boolean
}
let { sky = true, orbit = false, panel = true, debug = false, ground = true }: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
let currentModelAngles: number[] = new Array(12).fill(0)
let modelTargetAngles: number[] = new Array(12).fill(0)
let gui_panel: GUI
let Throttler = new throttler()
let feet_trace = new Array(4).fill([])
let trace_lines: BufferGeometry<NormalBufferAttributes>[] = []
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Walk]: new BezierState()
}
let lastTick = performance.now()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.5,
zm: 0,
feet: kinematic.getDefaultFeetPos()
}
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Fix camera on robot': true,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.7,
zm: 0,
Background: 'black'
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
walkGait.subscribe(gait => planners[ModesEnum.Walk].set_mode(walkGaitToMode(gait)))
if (panel) createPanel()
})
onDestroy(() => {
canvas.remove()
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: number[]) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background')
}
const updateKinematicPosition = () => {
kinematicData.set([
settings.omega,
settings.phi,
settings.psi,
settings.xm,
settings.ym,
settings.zm
])
}
const updateAngles = (name: string, angle: number) => {
modelTargetAngles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
Throttler.throttle(() => servoAnglesOut.set(modelTargetAngles.map(num => Math.round(num))), 100)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(updateAngles)
}
if (sky) sceneManager.addSky()
for (let i = 0; i < 4; i++) {
const geometry = new BufferGeometry()
const material = new LineBasicMaterial({ color: extractFootColor() })
const line = new Line(geometry, material)
trace_lines.push(geometry)
sceneManager.scene.add(line)
}
}
const renderTraceLines = (foot_positions: Vector3[]) => {
if (!settings['Trace feet']) {
if (!feet_trace.length) return
trace_lines.forEach((line, i) => line.setFromPoints(feet_trace[i].slice(-1)))
feet_trace = new Array(4).fill([])
return
interface Props {
defaultColor?: string | null
orbit?: boolean
panel?: boolean
debug?: boolean
ground?: boolean
}
trace_lines.forEach((line, i) => {
feet_trace[i].push(foot_positions[i])
feet_trace[i] = feet_trace[i].slice(-settings['Trace points'])
line.setFromPoints(feet_trace[i])
let {
defaultColor = '#0091ff',
orbit = false,
panel = true,
debug = false,
ground = true
}: Props = $props()
let sceneManager = $state(new SceneBuilder())
let canvas: HTMLCanvasElement
const NUM_ANGLES = 12 // TODO: This number should come from the robot
let currentModelAngles: AnglesData = AnglesData.create({
angles: new Array(NUM_ANGLES).fill(0)
})
}
let modelTargetAngles: AnglesData = AnglesData.create({ angles: new Array(NUM_ANGLES).fill(0) })
let gui_panel: GUI
const SMOOTH_AMOUNT = 0.2
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet
let target: Object3D<Object3DEventMap>
let target_position = { x: 0, z: 0, yaw: 0 }
let kinematic = get(currentKinematic)
const planners: Record<ModesEnum, GaitState> = {
[ModesEnum.DEACTIVATED]: new IdleState(),
[ModesEnum.IDLE]: new IdleState(),
[ModesEnum.CALIBRATION]: new CalibrationState(),
[ModesEnum.REST]: new RestState(),
[ModesEnum.STAND]: new StandState(),
[ModesEnum.WALK]: new BezierState(),
[ModesEnum.UNRECOGNIZED]: new IdleState()
}
let lastTick = performance.now()
let lastRobotPosition = new Vector3()
const dir = [1, -1, -1, -1, -1, -1, 1, -1, -1, -1, -1, -1]
const THREEJS_SCALE = 10
let body_state = {
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.15,
zm: 0,
feet: kinematic.getDefaultFeetPos(),
cumulative_x: 0,
cumulative_y: 0,
cumulative_z: 0,
cumulative_roll: 0,
cumulative_pitch: 0,
cumulative_yaw: 0
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles = new_angles
}
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
robot.position.z = smooth(robot.position.z, -settings.xm, 0.1)
robot.position.x = smooth(robot.position.x, -settings.zm, 0.1)
robot.rotation.z = smooth(robot.rotation.z, degToRad(-settings.phi + $mpu.heading + 90), 0.1)
robot.rotation.y = smooth(robot.rotation.y, degToRad(settings.omega), 0.1)
robot.rotation.x = smooth(robot.rotation.x, degToRad(settings.psi - 90), 0.1)
}
const update_camera = (robot: URDFRobot) => {
if (!settings['Fix camera on robot']) return
sceneManager.orbit.target = robot.position.clone()
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(outControllerData)
const data = {
lx: controlData[0],
ly: controlData[1],
rx: controlData[2],
ry: controlData[3],
h: controlData[4],
s: controlData[5],
s1: controlData[6]
}
body_state.ym = data.h
let planner = planners[get(mode)]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, data, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z * 100
settings.zm = -robot.position.x * 100
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, 0.5)
target.position.z = smooth(target.position.z, target_position.z, 0.5)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
renderTraceLines(toes)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles[i],
0.1
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles[i]))
let settings = {
'Internal kinematic': true,
'Robot transform controls': false,
'Auto orient robot': true,
'Trace feet': debug,
'Target position': false,
'Trace points': 30,
'Smooth motion': true,
omega: 0,
phi: 0,
psi: 0,
xm: 0,
ym: 0.15,
zm: 0,
Background: defaultColor
}
orient_robot(robot, toes)
updateTargetPosition()
}
onMount(async () => {
await populateModelCache()
await createScene()
servoAngles.subscribe(updateAnglesFromStore)
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()
})
onDestroy(() => {
gui_panel?.destroy()
})
const updateAnglesFromStore = (angles: AnglesData) => {
if (sceneManager.isDragging) return
if (settings['Internal kinematic']) return
modelTargetAngles = angles
}
const createPanel = () => {
gui_panel = new GUI({ width: 310 })
gui_panel.close()
gui_panel.domElement.id = 'three-gui-panel'
const general = gui_panel.addFolder('General')
general.add(settings, 'Internal kinematic')
general.add(settings, 'Robot transform controls')
general.add(settings, 'Auto orient robot')
const kinematic = gui_panel.addFolder('Kinematics')
kinematic.add(settings, 'omega', -20, 20).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'phi', -30, 30).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'psi', -20, 15).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'xm', -1, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'ym', 0, 1).onChange(updateKinematicPosition).listen()
kinematic.add(settings, 'zm', -1.3, 1.3).onChange(updateKinematicPosition).listen()
const visibility = gui_panel.addFolder('Visualization')
visibility.add(settings, 'Trace feet')
visibility.add(settings, 'Trace points', 1, 1000, 1)
visibility.add(settings, 'Target position')
visibility.add(settings, 'Smooth motion')
visibility.addColor(settings, 'Background').onChange(setSceneBackground).listen()
}
const updateKinematicPosition = () => {
kinematicData.set(
KinematicData.create({
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm
})
)
}
const setSceneBackground = (c: string | null) => (sceneManager.scene.background = new Color(c!))
const updateAngles = (name: string, angle: number) => {
modelTargetAngles.angles[$jointNames.indexOf(name)] = angle * (180 / Math.PI)
servoAnglesOut.set(
AnglesData.create({
angles: modelTargetAngles.angles.map(num => Math.round(num))
})
)
}
const createScene = async () => {
sceneManager
.addRenderer({ antialias: true, canvas, alpha: true })
.addPerspectiveCamera({ x: -0.5, y: 0.5, z: 1 })
.addOrbitControls(2, 20, orbit)
.addDirectionalLight({ x: 10, y: 20, z: 10, color: 0xffffff, intensity: 3 })
.addAmbientLight({ color: 0xffffff, intensity: 0.5 })
.addFogExp2(0xcccccc, 0.015)
.addModel($model as URDFRobot)
.addTransformControls(sceneManager.model)
.fillParent()
.addRenderCb(render)
.startRenderLoop()
if (ground) sceneManager.addGroundPlane()
const geometry = new SphereGeometry(0.1, 32, 16)
const material = new MeshBasicMaterial({ color: 0xffff00 })
target = new Mesh(geometry, material)
sceneManager.scene.add(target)
if (debug) {
sceneManager.addDragControl(angles => {
Object.entries(angles).forEach(([name, angle]) => {
updateAngles(name, angle)
})
})
}
if (defaultColor) setSceneBackground(settings['Background'] || defaultColor)
}
const calculate_kinematics = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const position: body_state_t = {
omega: settings.omega,
phi: settings.phi,
psi: settings.psi,
xm: settings.xm,
ym: settings.ym,
zm: settings.zm,
feet: body_state.feet,
cumulative_x: body_state.cumulative_x,
cumulative_y: body_state.cumulative_y,
cumulative_z: body_state.cumulative_z,
cumulative_roll: body_state.cumulative_roll,
cumulative_pitch: body_state.cumulative_pitch,
cumulative_yaw: body_state.cumulative_yaw
}
let new_angles = kinematic.calcIK(position).map((x, i) => radToDeg(x * dir[i]))
modelTargetAngles.angles = new_angles
}
const orient_robot = (robot: URDFRobot, toes: Vector3[]) => {
if (settings['Robot transform controls'] || !settings['Auto orient robot']) return
robot.position.y = robot.position.y - Math.min(...toes.map(toe => toe.y))
const cumulativeYaw = body_state.cumulative_yaw
const headingYaw = degToRad(-settings.phi + $mpu.heading)
const totalYaw = headingYaw + cumulativeYaw
const cosTotal = Math.cos(totalYaw)
const sinTotal = Math.sin(totalYaw)
const rotatedXm = settings.xm * cosTotal - settings.zm * sinTotal
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,
(-rotatedZm - rotatedCumZ) * THREEJS_SCALE,
SMOOTH_AMOUNT
)
robot.position.z = smooth(
robot.position.z,
(-rotatedXm - rotatedCumX) * THREEJS_SCALE,
SMOOTH_AMOUNT
)
const cosYaw = Math.cos(totalYaw)
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,
degToRad(-settings.phi + $mpu.heading + 90) + cumulativeYaw,
SMOOTH_AMOUNT
)
robot.rotation.y = smooth(robot.rotation.y, roll, SMOOTH_AMOUNT)
robot.rotation.x = smooth(robot.rotation.x, pitch, SMOOTH_AMOUNT)
}
const update_camera = (robot: URDFRobot) => {
const delta = robot.position.clone().sub(lastRobotPosition)
sceneManager.orbit.target.add(delta)
sceneManager.camera.position.add(delta)
lastRobotPosition.copy(robot.position)
}
const smooth = (start: number, end: number, amount: number) => {
return settings['Smooth motion'] ? lerp(start, end, amount) : end
}
const update_gait = () => {
if (sceneManager.isDragging || !settings['Internal kinematic']) return
const controlData = get(input)
let planner = planners[get(mode).mode]
const delta = performance.now() - lastTick
lastTick = performance.now()
body_state = planner.step(body_state, controlData, delta)
settings.omega = body_state.omega
settings.phi = body_state.phi
settings.psi = body_state.psi
settings.xm = body_state.xm
settings.ym = body_state.ym
settings.zm = body_state.zm
}
const update_robot_position = (robot: URDFRobot) => {
if (!settings['Robot transform controls']) return
settings.omega = radToDeg(robot.rotation.y)
settings.phi = radToDeg(robot.rotation.z) + $mpu.heading - 90
settings.psi = radToDeg(robot.rotation.x) + 90
settings.xm = robot.position.z / THREEJS_SCALE
settings.zm = -robot.position.x / THREEJS_SCALE
}
const updateTargetPosition = () => {
target.visible = settings['Target position']
target.position.x = smooth(target.position.x, target_position.x, SMOOTH_AMOUNT)
target.position.z = smooth(target.position.z, target_position.z, SMOOTH_AMOUNT)
}
const render = () => {
const robot = sceneManager.model
if (!robot) return
const toes = getToeWorldPositions(robot)
update_camera(robot)
update_gait()
calculate_kinematics()
update_robot_position(robot)
sceneManager.transformControl.showX = settings['Robot transform controls']
sceneManager.transformControl.showY = settings['Robot transform controls']
sceneManager.transformControl.showZ = settings['Robot transform controls']
for (let i = 0; i < $jointNames.length; i++) {
currentModelAngles.angles[i] = smooth(
(robot.joints[$jointNames[i]].angle as number) * (180 / Math.PI),
modelTargetAngles.angles[i],
SMOOTH_AMOUNT
)
robot.joints[$jointNames[i]].setJointValue(degToRad(currentModelAngles.angles[i]))
}
orient_robot(robot, toes)
updateTargetPosition()
}
</script>
<svelte:window onresize={sceneManager.fillParent} />
@@ -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 RotateCcw } from '~icons/mdi/rotate-left'
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 Cancel } from '~icons/tabler/x'
@@ -1,19 +1,19 @@
<script lang="ts">
import { MdiEyeOffOutline, MdiEyeOutline } from "../icons";
import { MdiEyeOffOutline, MdiEyeOutline } from '../icons'
interface Props {
show?: boolean;
value?: string;
id?: string;
show?: boolean
value?: string
id?: string
}
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props();
let type = $derived(show ? 'text' : 'password');
let { show = $bindable(false), value = $bindable(''), id = '' }: Props = $props()
const handleInput = (e: any) => value = e.target.value
let type = $derived(show ? 'text' : 'password')
const togglePassword = () => show = !show
const handleInput = (e: Event) => (value = (e.target as HTMLInputElement).value)
const togglePassword = () => (show = !show)
</script>
<label class="input input-bordered flex items-center gap-2">
@@ -23,4 +23,4 @@
<MdiEyeOffOutline class="text-base-content/50 h-6 {show ? 'block' : 'hidden'}" />
<MdiEyeOutline class="text-base-content/50 h-6 {show ? 'hidden' : 'block'}" />
</div>
</label>
</label>
@@ -1,34 +1,35 @@
<script lang="ts">
interface Props {
min?: number
max?: number
step?: number
value?: any
oninput?: any
}
interface Props {
min?: number
max?: number
step?: number
value?: number
oninput?: (value: Event) => void
}
let {
min = 0,
max = 100,
step = 1,
value = $bindable((max - min) / 2),
...rest
}: Props = $props()
let {
min = 0,
max = 100,
step = 1,
value = $bindable((max - min) / 2),
...rest
}: Props = $props()
</script>
<input
type="range"
style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer"
{min}
{max}
{step}
bind:value
{...rest} />
type="range"
style="writing-mode: vertical-lr; direction: rtl"
class="cursor-pointer"
{min}
{max}
{step}
bind:value
{...rest}
/>
<style>
input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem);
}
input[type='range']::-webkit-slider-runnable-track {
background: oklch(var(--p) / 1);
border-radius: var(--rounded-box, 1rem);
}
</style>
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as PasswordInput } from './InputPassword.svelte';
export { default as VerticalSlider } from './VerticalSlider.svelte';
export { default as PasswordInput } from './InputPassword.svelte'
export { default as VerticalSlider } from './VerticalSlider.svelte'
+2 -2
View File
@@ -1,9 +1,9 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
children?: import('svelte').Snippet
}
let { children }: Props = $props();
let { children }: Props = $props()
</script>
<div class="box-border overflow-hidden flex-1">
@@ -1,40 +1,41 @@
<script lang="ts">
import WidgetContainer from './WidgetContainer.svelte';
import {
WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig,
} from '$lib/stores/application';
import Widget from './Widget.svelte';
import WidgetContainer from './WidgetContainer.svelte'
import {
WidgetComponents,
type WidgetContainerConfig,
isWidgetConfig
} from '$lib/stores/application'
import Widget from './Widget.svelte'
interface Props {
container: WidgetContainerConfig;
}
interface Props {
container: WidgetContainerConfig
}
let { container }: Props = $props();
let { container }: Props = $props()
</script>
<div class="w-full h-full flex flex-col overflow-hidden">
<div
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'}>
{#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget>
{#if isWidgetConfig(widget)}
{@const SvelteComponent = WidgetComponents[widget.component]}
<SvelteComponent {...widget.props} />
{:else if widget.widgets}
<WidgetContainer container={widget} />
{/if}
</Widget>
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}>
</div>
{/if}
{/each}
</div>
<div
class="flex w-full h-full"
class:flex-row={container.layout === 'column'}
class:flex-col={container.layout === 'row'}
class:flex-wrap={container.layout === 'wrap'}
>
{#each container.widgets as widget, index (widget.id + '-' + index)}
<Widget>
{#if isWidgetConfig(widget)}
{@const SvelteComponent = WidgetComponents[widget.component]}
<SvelteComponent {...widget.props} />
{:else if widget.widgets}
<WidgetContainer container={widget} />
{/if}
</Widget>
{#if index !== container.widgets.length - 1}
<div
class="divider bg-base-300 m-0"
class:divider-horizontal={container.layout === 'column'}
></div>
{/if}
{/each}
</div>
</div>
@@ -1,15 +1,16 @@
<script lang="ts">
import { Github } from "../icons";
import { Github } from '../icons'
interface Props {
github: any;
}
interface Props {
github: { href: string; active?: boolean }
}
let { github }: Props = $props();
let { github }: Props = $props()
</script>
{#if github.active}
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- external URL -->
<a href={github.href} class="btn btn-ghost" target="_blank" rel="noopener noreferrer">
<Github class="h-5 w-5" />
</a>
{/if}
@@ -1,14 +1,15 @@
<script>
import logo from '$lib/assets/logo512.png';
import logo from '$lib/assets/logo512.png'
import { resolve } from '$app/paths'
/** @type {{appName: any}} */
let { appName } = $props();
/** @type {{appName: any}} */
let { appName } = $props()
</script>
<a
href="/"
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
href={resolve('/')}
class="rounded-box mb-4 flex items-center hover:scale-[1.02] active:scale-[0.98]"
>
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
<img src={logo} alt="Logo" class="h-12 w-12" />
<h1 class="px-4 text-2xl font-bold">{appName}</h1>
</a>
+166 -174
View File
@@ -1,194 +1,186 @@
<script lang="ts">
import { page } from '$app/state'
import { base } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte'
import MenuList from '../menu/MenuList.svelte'
import {
Connection,
Settings,
MdiController,
Devices,
Camera,
Rotate3d,
MotorOutline,
Health,
Folder,
Update,
WiFi,
Router,
AP,
Copyright,
Metrics,
DNS
} from '$lib/components/icons'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
import { page } from '$app/state'
import { resolve } from '$app/paths'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import GithubButton from '../menu/GithubButton.svelte'
import LogoButton from '../menu/LogoButton.svelte'
import MenuList from '../menu/MenuList.svelte'
import {
Connection,
Settings,
MdiController,
Devices,
Camera,
Rotate3d,
MotorOutline,
Health,
Folder,
Update,
WiFi,
Router,
AP,
Copyright,
Metrics,
DNS
} from '$lib/components/icons'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
const features = useFeatureFlags()
const features = useFeatureFlags()
const appName = page.data.app_name
const appName = page.data.app_name
const copyright = page.data.copyright
const copyright = page.data.copyright
const github = { href: 'https://github.com/' + page.data.github, active: true }
const github = { href: 'https://github.com/' + page.data.github, active: true }
type menuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
import type { Component } from 'svelte'
function withBase(path: string) {
return `${base}${path.startsWith('/') ? path : '/' + path}`
}
type menuItem = {
title: string
icon: Component
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
let menuItems = $state<menuItem[]>([])
function withBase(path: string) {
return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}`
}
$effect(() => {
menuItems = [
{
title: 'Connection',
icon: WiFi,
href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: withBase('/controller'),
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: withBase('/peripherals/i2c'),
feature: true
},
{
title: 'Camera',
icon: Camera,
href: withBase('/peripherals/camera'),
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: withBase('/peripherals/servo'),
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: withBase('/peripherals/imu'),
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: withBase('/wifi/sta'),
feature: true
},
{
title: 'Access Point',
icon: AP,
href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: withBase('/system/status'),
feature: true
},
{
title: 'File System',
icon: Folder,
href: withBase('/system/filesystem'),
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: withBase('/system/metrics'),
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: withBase('/system/update'),
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
const { menuClicked } = $props()
const { menuClicked } = $props()
const activeTitle = $derived(page.data.title)
function setActiveMenuItem(targetTitle: string) {
menuItems.forEach(item => {
item.active = item.title === targetTitle
item.submenu?.forEach(subItem => {
subItem.active = subItem.title === targetTitle
})
})
menuItems = menuItems
menuClicked()
}
const menuItems = $derived<menuItem[]>(
[
{
title: 'Connection',
icon: WiFi,
href: withBase('/connection'),
feature: !PUBLIC_VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: withBase('/controller'),
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: withBase('/peripherals/i2c'),
feature: true
},
{
title: 'Camera',
icon: Camera,
href: withBase('/peripherals/camera'),
feature: true
},
{
title: 'Servo',
icon: MotorOutline,
href: withBase('/peripherals/servo'),
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: withBase('/peripherals/imu'),
feature: true
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: withBase('/wifi/sta'),
feature: true
},
{
title: 'Access Point',
icon: AP,
href: withBase('/wifi/ap'),
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: withBase('/wifi/mdns'),
feature: true
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: withBase('/system/status'),
feature: true
},
{
title: 'File System',
icon: Folder,
href: withBase('/system/filesystem'),
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: withBase('/system/metrics'),
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: withBase('/system/update'),
feature: !!(
$features.ota ||
$features.upload_firmware ||
$features.download_firmware
)
}
].map(sub => ({ ...sub, active: sub.title === activeTitle }))
}
].map(item => ({ ...item, active: item.title === activeTitle }))
)
$effect(() => {
setActiveMenuItem(page.data.title)
})
const updateMenu = (event: any) => {
setActiveMenuItem(event.details)
}
const updateMenu = () => {
menuClicked()
}
</script>
<div class="flex h-full w-80 flex-col p-4 bg-base-200 text-base-content">
<LogoButton {appName} />
<LogoButton {appName} />
<MenuList
{menuItems}
select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0" />
<MenuList
{menuItems}
select={updateMenu}
class="grow flex-nowrap overflow-y-auto overflow-x-hidden"
level="0"
/>
<div class="divider my-0"></div>
<div class="divider my-0"></div>
<div class="flex items-center justify-between">
<GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright}
<div class="flex items-center justify-between">
<GithubButton {github} />
<div class="flex items-center justify-end text-sm gap-2">
<Copyright class="h-4 w-4" />{copyright}
</div>
</div>
</div>
</div>
+48 -40
View File
@@ -1,48 +1,56 @@
<script lang="ts">
import MenuList from './MenuList.svelte'
type MenuItem = {
title: string
icon: ConstructorOfATypedSvelteComponent
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
import MenuList from './MenuList.svelte'
import type { Component } from 'svelte'
let { level, menuItems, select, class: klass } = $props()
type MenuItem = {
title: string
icon: Component
href?: string
feature: boolean
active?: boolean
submenu?: MenuItem[]
}
const selectMenuItem = (title: string) => {
select(title)
}
let { level, menuItems, select, class: klass } = $props()
const selectMenuItem = (title: string) => {
select(title)
}
</script>
<ul class={klass + ' menu w-full'}>
{#each menuItems as MenuItem[] as menuItem, i (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="font-bold">
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</summary>
<div class="pl-4">
<MenuList menuItems={menuItem.submenu} level={level + 1} {select} class={klass} />
</div>
</details>
{:else}
<a
href={menuItem.href}
class="font-bold"
class:bg-base-100={menuItem.active}
class:text-lg={level === 0}
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{#each menuItems as MenuItem[] as menuItem (menuItem.title)}
{#if menuItem.feature}
<li>
{#if menuItem.submenu}
<details open={menuItem.submenu.some(subItem => subItem.active)}>
<summary class="font-bold">
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</summary>
<div class="pl-4">
<MenuList
menuItems={menuItem.submenu}
level={level + 1}
{select}
class={klass}
/>
</div>
</details>
{:else}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --><a
href={menuItem.href}
class="font-bold"
class:bg-base-100={menuItem.active}
class:text-lg={level === 0}
class:text-md={level === 1}
onclick={() => selectMenuItem(menuItem.title)}
>
<menuItem.icon class="h-6 w-6" />
{menuItem.title}
</a>
{/if}
</li>
{/if}
</li>
{/if}
{/each}
{/each}
</ul>
@@ -1,10 +1,10 @@
<script lang="ts">
import { isFullscreen, toggleFullscreen } from '$lib/stores';
import { MdiFullscreenExit, MdiFullscreen } from '../icons';
import { isFullscreen, toggleFullscreen } from '$lib/stores'
import { MdiFullscreenExit, MdiFullscreen } from '../icons'
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen);
const SvelteComponent = $derived($isFullscreen ? MdiFullscreenExit : MdiFullscreen)
</script>
<button onclick={toggleFullscreen}>
<SvelteComponent class="h-7 w-7" />
</button>
</button>
@@ -1,33 +1,33 @@
<script lang="ts">
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from "../icons";
import { WiFi, WiFi0, WiFi1, WiFi2, WifiOff } from '../icons'
interface Props {
showDBm?: boolean;
rssi?: number;
}
interface Props {
showDBm?: boolean
rssi?: number
}
let { showDBm = false, rssi = 0 }: Props = $props();
let { showDBm = false, rssi = 0 }: Props = $props()
const getWiFiIcon = () => {
if (rssi === 0) return WifiOff;
if (rssi >= -55) return WiFi;
if (rssi >= -75) return WiFi2;
if (rssi >= -85) return WiFi1;
return WiFi0;
};
const getWiFiIcon = () => {
if (rssi === 0) return WifiOff
if (rssi >= -55) return WiFi
if (rssi >= -75) return WiFi2
if (rssi >= -85) return WiFi1
return WiFi0
}
const SvelteComponent = $derived(getWiFiIcon());
const SvelteComponent = $derived(getWiFiIcon())
</script>
<div class="indicator">
<div class="tooltip tooltip-left" data-tip={rssi + " dBm"}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm
</span>
{/if}
<div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" />
</div>
</div>
</div>
<div class="tooltip tooltip-left" data-tip={rssi + ' dBm'}>
{#if showDBm}
<span class="indicator-item indicator-start badge badge-accent badge-outline badge-xs">
{rssi} dBm
</span>
{/if}
<div class="h-7 w-7">
<SvelteComponent class="absolute inset-0 h-full w-full" />
</div>
</div>
</div>
@@ -1,13 +1,13 @@
<script lang="ts">
import { useFeatureFlags } from '$lib/stores';
import { modals } from 'svelte-modals';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { api } from '$lib/api';
import { Cancel, Power } from '../icons';
import { useFeatureFlags } from '$lib/stores'
import { modals } from 'svelte-modals'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import { api } from '$lib/api'
import { Cancel, Power } from '../icons'
const features = useFeatureFlags();
const features = useFeatureFlags()
const postSleep = async () => await api.post('/api/system/sleep');
const postSleep = async () => await api.post('/api/system/sleep')
const confirmSleep = () => {
modals.open(ConfirmDialog, {
@@ -18,11 +18,11 @@
confirm: { label: 'Switch Off', icon: Power }
},
onConfirm: () => {
modals.close();
postSleep();
modals.close()
postSleep()
}
});
};
})
}
</script>
{#if $features.sleep}
@@ -1,10 +1,10 @@
<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 () => {
mode.set(modes.indexOf('deactivated'));
};
mode.set(ModeData.create({ mode: ModesEnum.DEACTIVATED }))
}
</script>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
<button onclick={deactivate} class="bg-error text-white btn rounded-none">STOP</button>
@@ -1,9 +1,9 @@
<script lang="ts">
import { MdiWeatherSunny, MdiMoonAndStars } from "../icons";
import { MdiWeatherSunny, MdiMoonAndStars } from '../icons'
</script>
<label class="swap swap-rotate">
<input type="checkbox" value="light" class="theme-controller" />
<MdiWeatherSunny class="swap-off h-7 w-7" />
<MdiMoonAndStars class="swap-on h-7 w-7" />
</label>
</label>
@@ -1,17 +1,18 @@
<script lang="ts">
import {Hamburger} from '../icons'
import { Hamburger } from '../icons'
import { resolve } from '$app/paths'
</script>
<div class="topbar absolute left-0 top-0 w-full z-20 flex justify-between bg-zinc-800">
<div class="flex gap-2 p-2">
<a href="/">
<Hamburger class="h-8 w-8"/>
<div class="flex gap-2 p-2">
<a href={resolve('/')}>
<Hamburger class="h-8 w-8" />
</a>
</div>
</div>
<style>
.topbar {
height: 50px;
}
.topbar {
height: 50px;
}
</style>
@@ -1,109 +1,111 @@
<script lang="ts">
import { page } from '$app/state'
import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte'
import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons'
import { page } from '$app/state'
import { modals } from 'svelte-modals'
import { notifications } from '$lib/components/toasts/notifications'
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'
import GithubUpdateDialog from '$lib/components/GithubUpdateDialog.svelte'
import { compareVersions } from 'compare-versions'
import { onMount } from 'svelte'
import { api } from '$lib/api'
import type { GithubRelease } from '$lib/types/models'
import { useFeatureFlags } from '$lib/stores/featureFlags'
import { Cancel, CloudDown, Firmware } from '../icons'
const features = useFeatureFlags()
const features = useFeatureFlags()
interface Props {
update?: boolean
}
let { update = $bindable(false) }: Props = $props()
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
interface Props {
update?: boolean
}
const results = result.inner
update = false
firmwareVersion = ''
let { update = $bindable(false) }: Props = $props()
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string)
) {
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
let firmwareVersion: string = $state('')
let firmwareDownloadLink: string = $state('')
async function getGithubAPI() {
const headers = {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}
const result = await api.get<GithubRelease>(
`https://api.github.com/repos/${page.data.github}/releases/latest`,
{ headers }
)
if (result.inner.message === '404' || result.inner.message == 'Not Found') {
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url })
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
}
const results = result.inner
update = false
firmwareVersion = ''
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
if (compareVersions(results.tag_name, $features.firmware_version as string) === 1) {
// iterate over assets and find the correct one
for (let i = 0; i < results.assets.length; i++) {
// check if the asset is of type *.bin
if (
results.assets[i].name.includes('.bin') &&
results.assets[i].name.includes($features.firmware_built_target as string)
) {
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
}
}
}
}
})
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url })
if (result.isErr()) {
console.error('Error:', result.inner)
return
}
}
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
}
})
}
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
title: 'Confirm flashing new firmware to the device',
message: 'Are you sure you want to overwrite the existing firmware with a new one?',
labels: {
cancel: { label: 'Abort', icon: Cancel },
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
})
}
})
}
</script>
{#if update}
<div class="indicator flex-none">
<button
class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1">
{firmwareVersion}
</span>
<Firmware class="h-7 w-7" />
</button>
</div>
<div class="indicator flex-none">
<button
class="btn btn-square btn-ghost h-9 w-9"
onclick={() => confirmGithubUpdate(firmwareDownloadLink)}
>
<span
class="indicator-item indicator-top indicator-center badge badge-info badge-xs top-2 scale-75 lg:top-1"
>
{firmwareVersion}
</span>
<Firmware class="h-7 w-7" />
</button>
</div>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { selectedView, views } from "$lib/stores/application";
import Selector from "../widget/Selector.svelte";
import { selectedView, views } from '$lib/stores/application'
import Selector from '../widget/Selector.svelte'
</script>
<Selector bind:selectedOption={$selectedView} options={$views.map((v) => v.name)} />
<Selector bind:selectedOption={$selectedView} options={$views.map(v => v.name)} />
@@ -1,38 +1,38 @@
<script lang="ts">
import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry'
import { page } from '$app/state'
import { telemetry } from '$lib/stores/telemetry'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons'
import RssiIndicator from '$lib/components/statusbar/RSSIIndicator.svelte'
import UpdateIndicator from '$lib/components/statusbar/UpdateIndicator.svelte'
import SleepButton from './SleepButton.svelte'
import ThemeButton from './ThemeButton.svelte'
import FullscreenButton from './FullscreenButton.svelte'
import StopButton from './StopButton.svelte'
import ViewSelector from './ViewSelector.svelte'
import { Hamburger } from '../icons'
</script>
<div class="navbar bg-base-300 sticky top-0 z-10 h-12 min-h-fit drop-shadow-lg lg:h-16 gap-2 pr-0">
<div class="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" />
</label>
{#if page.data.title === 'Controller'}
<ViewSelector />
{:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if}
</div>
<div class="flex flex-1 gap-2">
<label for="main-menu" class="btn btn-ghost btn-circle btn-sm drawer-button">
<Hamburger class="h-6 w-auto" />
</label>
{#if page.data.title === 'Controller'}
<ViewSelector />
{:else}
<h1 class="px-2 text-xl font-bold lg:text-2xl">{page.data.title}</h1>
{/if}
</div>
<UpdateIndicator />
<UpdateIndicator />
<FullscreenButton />
<FullscreenButton />
<ThemeButton />
<ThemeButton />
<RssiIndicator rssi={$telemetry.rssi.rssi} />
<RssiIndicator rssi={$telemetry.rssi.rssi} />
<SleepButton />
<SleepButton />
<StopButton />
<StopButton />
</div>
+31 -29
View File
@@ -1,35 +1,37 @@
<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { notifications } from '$lib/components/toasts/notifications';
import { error, info, success, warning } from '../icons';
import { flip } from 'svelte/animate'
import { fly } from 'svelte/transition'
import { notifications } from '$lib/components/toasts/notifications'
import { error, info, success, warning } from '../icons'
/** @type {{theme?: any, icon?: any}} */
let { theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
}, icon = {
error: error,
success: success,
warning: warning,
info: info
} } = $props();
/** @type {{theme?: any, icon?: any}} */
let {
theme = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
info: 'alert-info'
},
icon = {
error: error,
success: success,
warning: warning,
info: info
}
} = $props()
</script>
<div class="toast toast-end mr-4 z-20">
{#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
{#each $notifications as notification (notification.id)}
{@const SvelteComponent = icon[notification.type]}
<div
animate:flip={{ duration: 400 }}
class="alert animate-none {theme[notification.type]}"
in:fly={{ y: 100, duration: 400 }}
out:fly={{ x: 100, duration: 400 }}
>
<SvelteComponent class="h-6 w-6 shrink-0" />
<span>{notification.message}</span>
</div>
{/each}
</div>
+27 -27
View File
@@ -1,42 +1,42 @@
import { writable, derived, type Writable } from 'svelte/store'
import { writable } from 'svelte/store'
type StateType = 'info' | 'success' | 'warning' | 'error'
type State = {
id: string
type: StateType
message: string
id: string
type: StateType
message: string
}
function createNotificationStore() {
const state: State[] = []
const notifications = writable(state)
const { subscribe } = notifications
const state: State[] = []
const notifications = writable(state)
const { subscribe } = notifications
function send(message: string, type: StateType = 'info', timeout: number) {
const id = generateId()
setTimeout(() => {
notifications.update(state => {
return state.filter(n => n.id !== id)
})
}, timeout)
notifications.update(state => {
return [...state, { id, type, message }]
})
}
function send(message: string, type: StateType = 'info', timeout: number) {
const id = generateId()
setTimeout(() => {
notifications.update(state => {
return state.filter(n => n.id !== id)
})
}, timeout)
notifications.update(state => {
return [...state, { id, type, message }]
})
}
return {
subscribe,
send,
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
}
return {
subscribe,
send,
error: (msg: string, timeout: number = 4000) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number = 4000) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number = 4000) => send(msg, 'info', timeout),
success: (msg: string, timeout: number = 4000) => send(msg, 'success', timeout)
}
}
function generateId() {
return '_' + Math.random().toString(36).substr(2, 9)
return '_' + Math.random().toString(36).substr(2, 9)
}
export const notifications = createNotificationStore()
@@ -1,101 +1,102 @@
<script lang="ts">
import { daisyColor } from '$lib/utilities';
import { Chart, registerables } from 'chart.js';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { daisyColor } from '$lib/utilities'
import { Chart, registerables } from 'chart.js'
import { onMount } from 'svelte'
import { cubicOut } from 'svelte/easing'
import { slide } from 'svelte/transition'
let chartElement: HTMLCanvasElement;
let chart: Chart;
let chartElement: HTMLCanvasElement
let chart: Chart<'line', number[], number>
interface Props {
label: any;
data: number[];
title: any;
}
interface Props {
label: string
data: number[]
title: string
}
let { label, data, title }: Props = $props();
let { label, data, title }: Props = $props()
Chart.register(...registerables);
Chart.register(...registerables)
onMount(() => {
chart = new Chart(chartElement, {
type: 'line',
data: {
labels: data,
datasets: [
{
label,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y',
},
],
},
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true,
},
tooltip: {
mode: 'index',
intersect: false,
},
},
elements: {
point: {
radius: 0,
},
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10),
onMount(() => {
chart = new Chart(chartElement, {
type: 'line',
data: {
labels: data,
datasets: [
{
label,
borderColor: daisyColor('--p'),
backgroundColor: daisyColor('--p', 50),
borderWidth: 2,
data,
yAxisID: 'y'
}
]
},
ticks: {
color: daisyColor('--bc'),
},
display: false,
},
y: {
type: 'linear',
title: {
display: true,
text: title,
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold',
},
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc'),
},
border: { color: daisyColor('--bc', 10) },
},
},
},
});
options: {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: true
},
tooltip: {
mode: 'index',
intersect: false
}
},
elements: {
point: {
radius: 0
}
},
scales: {
x: {
grid: {
color: daisyColor('--bc', 10)
},
ticks: {
color: daisyColor('--bc')
},
display: false
},
y: {
type: 'linear',
title: {
display: true,
text: title,
color: daisyColor('--bc'),
font: {
size: 16,
weight: 'bold'
}
},
position: 'left',
min: 0,
max: 100,
grid: { color: daisyColor('--bc', 10) },
ticks: {
color: daisyColor('--bc')
},
border: { color: daisyColor('--bc', 10) }
}
}
}
})
setInterval(() => {
chart.data.labels = data;
chart.data.datasets[0].data = data;
}, 500);
});
setInterval(() => {
chart.data.labels = data
chart.data.datasets[0].data = data
}, 500)
})
</script>
<div class="w-full h-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={chartElement}></canvas>
</div>
<div
class="flex w-full flex-col space-y-1 h-60"
transition:slide|local={{ duration: 300, easing: cubicOut }}
>
<canvas bind:this={chartElement}></canvas>
</div>
</div>
+14 -13
View File
@@ -1,19 +1,20 @@
<script lang="ts">
interface Props {
options?: string[];
selectedOption?: string;
change?: () => void;
[key: string]: any;
}
interface Props {
options?: string[]
selectedOption?: string
change?: () => void
[key: string]: unknown
}
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props();
let { options = [], selectedOption = $bindable(''), ...rest }: Props = $props()
</script>
<select
bind:value={selectedOption}
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}">
{#each options as option}
<option value={option}>{option}</option>
{/each}
bind:value={selectedOption}
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
>
{#each options as option (option)}
<option value={option}>{option}</option>
{/each}
</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()
+422 -351
View File
@@ -1,425 +1,496 @@
import { get } from 'svelte/store'
import type { body_state_t } from './kinematic'
import { currentKinematic } from './stores/featureFlags'
import { ControllerData, WalkGaits } from './platform_shared/message'
export interface gait_state_t {
step_height: number
step_x: number
step_z: number
step_angle: number
step_velocity: number
step_depth: number
}
export interface ControllerCommand {
lx: number
ly: number
rx: number
ry: number
h: number
s: number
s1: number
step_height: number
step_x: number
step_z: number
step_angle: number
step_velocity: number
step_depth: number
}
export abstract class GaitState {
protected abstract name: string
protected abstract name: string
protected dt = 0.02
protected body_state!: body_state_t
protected gait_state: gait_state_t = {
step_height: 0.4,
step_x: 0,
step_z: 0,
step_angle: 0,
step_velocity: 1,
step_depth: 0.002
}
protected dt = 0.02
protected body_state!: body_state_t
public get default_feet_pos() {
return get(currentKinematic).getDefaultFeetPos()
}
protected get default_height() {
return 0.5
}
begin() {
console.log('Starting', this.name)
}
end() {
console.log('Ending', this.name)
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
this.map_command(command)
this.body_state = body_state
this.dt = dt / 1000
return body_state
}
map_command(command: ControllerCommand) {
const newCommand = {
step_height: 0.4 + (command.s1 + 1) / 2,
step_x: command.ly,
step_z: -command.lx,
step_velocity: command.s,
step_angle: command.rx,
step_depth: 0.002
protected get kinematic() {
return get(currentKinematic)
}
this.gait_state = newCommand
}
protected gait_state: gait_state_t = {
step_height: 0,
step_x: 0,
step_z: 0,
step_angle: 0,
step_velocity: 1,
step_depth: 0
}
public get default_feet_pos() {
return this.kinematic.getDefaultFeetPos()
}
protected get default_height() {
return this.kinematic.default_body_height
}
protected get default_step_depth() {
return this.kinematic.default_step_depth
}
protected get default_step_height() {
return this.kinematic.default_step_height
}
begin() {
console.log('Starting', this.name)
}
end() {
console.log('Ending', this.name)
}
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
this.map_command(command)
this.body_state = body_state
this.dt = dt / 1000
if (body_state.cumulative_x === undefined) {
body_state.cumulative_x = 0
body_state.cumulative_y = 0
body_state.cumulative_z = 0
body_state.cumulative_roll = 0
body_state.cumulative_pitch = 0
body_state.cumulative_yaw = 0
}
return body_state
}
map_command(command: ControllerData) {
const kin = this.kinematic
this.gait_state = {
step_height: command.s1 * kin.max_step_height,
step_x: command.left!.y * kin.max_step_length,
step_z: -command.left!.x * kin.max_step_length,
step_velocity: command.speed,
step_angle: command.right!.x,
step_depth: kin.default_step_depth
}
}
}
export class IdleState extends GaitState {
protected name = 'Idle'
protected name = 'Idle'
step(body_state: body_state_t, command: ControllerData) {
super.step(body_state, command)
return body_state
}
}
export class CalibrationState extends GaitState {
protected name = 'Calibration'
protected name = 'Calibration'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
step(body_state: body_state_t, _command: ControllerCommand) {
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.default_height * 10
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
}
step(body_state: body_state_t, _command: ControllerData) {
super.step(body_state, _command)
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.kinematic.max_body_height
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
}
}
export class RestState extends GaitState {
protected name = 'Rest'
protected name = 'Rest'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
step(body_state: body_state_t, _command: ControllerCommand) {
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.default_height / 2
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
}
step(body_state: body_state_t, _command: ControllerData) {
super.step(body_state, _command)
body_state.omega = 0
body_state.phi = 0
body_state.psi = 0
body_state.xm = 0
body_state.ym = this.kinematic.min_body_height
body_state.zm = 0
body_state.feet = this.default_feet_pos
return body_state
}
}
export class StandState extends GaitState {
protected name = 'Stand'
protected name = 'Stand'
step(body_state: body_state_t, command: ControllerCommand) {
body_state.omega = 0
body_state.phi = command.rx * 10 * (Math.PI / 2)
body_state.psi = command.ry * 10 * (Math.PI / 2)
body_state.xm = command.ly / 4
body_state.zm = command.lx / 4
body_state.feet = this.default_feet_pos
return body_state
}
step(body_state: body_state_t, command: ControllerData) {
super.step(body_state, command)
const kin = this.kinematic
body_state.omega = 0
body_state.ym = kin.min_body_height + command.height * kin.body_height_range
body_state.psi = command.right!.y * kin.max_pitch
body_state.phi = command.right!.x * kin.max_roll
body_state.xm = command.left!.y * kin.max_body_shift_x
body_state.zm = command.left!.x * kin.max_body_shift_z
body_state.feet = this.default_feet_pos
return body_state
}
}
export class BezierState extends GaitState {
protected name = 'Bezier'
protected phase = 0
protected phase_num = 0
protected step_length = 0
protected stand_offset = 0.85
protected mode: 'crawl' | 'trot' = 'trot'
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
protected name = 'Bezier'
protected phase = 0
protected phase_num = 0
protected step_length = 0
protected stand_offset = 0.75
protected mode: WalkGaits = WalkGaits.TROT
protected speed_factor = 1
offset = [0, 0.5, 0.75, 0.25]
protected shift_start_pos = { x: 0, z: 0 }
protected shift_target_pos = { x: 0, z: 0 }
protected shift_start_time = 0
protected current_shift_leg = -1
protected shift_start_pos = { x: 0, z: 0 }
protected shift_target_pos = { x: 0, z: 0 }
protected shift_start_time = 0
protected current_shift_leg = -1
constructor() {
super()
this.set_mode(this.mode)
}
protected last_body_state: body_state_t | null = null
protected cumulative_position = { x: 0, y: 0, z: 0 }
protected cumulative_orientation = { roll: 0, pitch: 0, yaw: 0 }
begin() {
super.begin()
}
set_mode(mode: 'crawl' | 'trot', duty?: number, order?: [number, number, number, number]) {
console.log('BezierState set_mode', mode)
this.mode = mode
if (mode === 'crawl') {
this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1]
const base = [0, 0.25, 0.5, 0.75]
const offsets = new Array(4).fill(0)
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.offset = offsets
} else {
this.speed_factor = 2
this.stand_offset = duty ?? 0.6
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
constructor() {
super()
this.set_mode(this.mode)
}
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt)
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
this.update_phase()
this.update_body_position()
this.update_feet_positions()
return this.body_state
}
update_phase() {
const m = this.gait_state
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
this.phase = 0
return
begin() {
super.begin()
}
this.phase += this.dt * m.step_velocity * this.speed_factor
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
}
}
update_body_position() {
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return
if (this.mode !== 'crawl') return
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
if (this.current_shift_leg !== next_swing) {
this.current_shift_leg = next_swing
this.shift_start_pos.x = this.body_state.xm
this.shift_start_pos.z = this.body_state.zm
const remaining_legs = stance.filter(leg => leg !== next_swing)
const target = this.stance_centroid(remaining_legs)
this.shift_target_pos.x = target[0]
this.shift_target_pos.z = target[2]
this.shift_start_time = time_to_lift
}
const total_time = this.shift_start_time
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
this.body_state.xm = this.lerp(
this.shift_start_pos.x,
this.shift_target_pos.x,
smooth_progress
)
this.body_state.zm = this.lerp(
this.shift_start_pos.z,
this.shift_target_pos.z,
smooth_progress
)
}
}
protected lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
protected stance_centroid(legs: number[]): number[] {
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
let sx = 0,
sz = 0
for (const i of legs) {
sx += this.body_state.feet[i][0]
sz += this.body_state.feet[i][2]
}
return [sx / legs.length, 0, sz / legs.length]
}
protected get_leg_states(): {
stance: number[]
swing: number[]
next_swing: number
time_to_lift: number
} {
const stance: number[] = []
const swing: number[] = []
let next_swing = -1
let min_time_to_swing = Infinity
for (let i = 0; i < 4; i++) {
let phase = this.phase + this.offset[i]
if (phase >= 1) phase -= 1
if (phase <= this.stand_offset) {
stance.push(i)
const time_to_swing = this.stand_offset - phase
if (time_to_swing < min_time_to_swing) {
min_time_to_swing = time_to_swing
next_swing = i
set_mode(mode: WalkGaits, duty?: number, order?: [number, number, number, number]) {
this.mode = mode
if (mode === WalkGaits.CRAWL) {
this.speed_factor = 0.5
this.stand_offset = duty ?? 0.85
const o = order ?? [3, 0, 2, 1]
const base = [0, 0.25, 0.5, 0.75]
const offsets = new Array(4).fill(0)
for (let i = 0; i < 4; i++) offsets[o[i]] = base[i]
this.offset = offsets
} else {
this.speed_factor = 2
this.stand_offset = duty ?? 0.6
this.offset = order ? (order.map(v => v % 1) as number[]) : [0, 0.5, 0.5, 0]
}
} else {
swing.push(i)
}
}
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
}
end() {
super.end()
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
step(body_state: body_state_t, command: ControllerData, dt: number = 0.02) {
super.step(body_state, command, dt)
const kin = this.kinematic
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)
if (this.gait_state.step_x < 0) this.step_length = -this.step_length
this.update_phase()
this.update_body_position()
this.update_feet_positions()
this.update_cumulative_position()
return this.body_state
}
update_feet_positions() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
}
update_phase() {
const m = this.gait_state
if (m.step_x === 0 && m.step_z === 0 && m.step_angle === 0) {
this.phase = 0
return
}
this.phase += this.dt * m.step_velocity * this.speed_factor
if (this.phase >= 1) {
this.phase_num = (this.phase_num + 1) % 2
this.phase = 0
}
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index]
if (phase >= 1) phase -= 1
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
return phase <= this.stand_offset ?
this.stand_controller(index, phase / this.stand_offset)
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
}
update_body_position() {
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (!moving) return
stand_controller(index: number, phase: number) {
const depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
}
if (this.mode !== WalkGaits.CRAWL) return
swing_controller(index: number, phase: number) {
const height = this.gait_state.step_height
return this.controller(index, phase, bezier_curve, height)
}
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
controller(
index: number,
phase: number,
controller: (length: number, angle: number, ...args: number[]) => number[],
...args: number[]
) {
let length = this.step_length / 2
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
const delta_pos = controller(length, angle, ...args, phase)
if (stance.length >= 3 && swing.length === 0 && next_swing !== -1) {
if (this.current_shift_leg !== next_swing) {
this.current_shift_leg = next_swing
this.shift_start_pos.x = this.body_state.xm
this.shift_start_pos.z = this.body_state.zm
length = this.gait_state.step_angle * 2
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
const remaining_legs = stance.filter(leg => leg !== next_swing)
const target = this.stance_centroid(remaining_legs)
this.shift_target_pos.x = target[0]
this.shift_target_pos.z = target[2]
const delta_rot = controller(length, angle, ...args, phase)
this.shift_start_time = time_to_lift
}
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
const total_time = this.shift_start_time
const progress = total_time > 0 ? 1 - time_to_lift / total_time : 1
const smooth_progress = this.smoothstep01(Math.max(0, Math.min(1, progress)))
return this.body_state.feet[index]
}
this.body_state.xm = this.lerp(
this.shift_start_pos.x,
this.shift_target_pos.x,
smooth_progress
)
this.body_state.zm = this.lerp(
this.shift_start_pos.z,
this.shift_target_pos.z,
smooth_progress
)
}
}
protected lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
protected stance_centroid(legs: number[]): number[] {
if (legs.length === 0) return [this.body_state.xm, 0, this.body_state.zm]
let sx = 0,
sz = 0
for (const i of legs) {
sx += this.body_state.feet[i][0]
sz += this.body_state.feet[i][2]
}
return [sx / legs.length, 0, sz / legs.length]
}
protected get_leg_states(): {
stance: number[]
swing: number[]
next_swing: number
time_to_lift: number
} {
const stance: number[] = []
const swing: number[] = []
let next_swing = -1
let min_time_to_swing = Infinity
for (let i = 0; i < 4; i++) {
let phase = this.phase + this.offset[i]
if (phase >= 1) phase -= 1
if (phase <= this.stand_offset) {
stance.push(i)
const time_to_swing = this.stand_offset - phase
if (time_to_swing < min_time_to_swing) {
min_time_to_swing = time_to_swing
next_swing = i
}
} else {
swing.push(i)
}
}
return { stance, swing, next_swing, time_to_lift: min_time_to_swing }
}
protected smoothstep01(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
update_feet_positions() {
for (let i = 0; i < 4; i++) this.body_state.feet[i] = this.update_foot_position(i)
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index]
if (phase >= 1) phase -= 1
this.body_state.feet[index][0] = this.default_feet_pos[index][0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.default_feet_pos[index][2]
return phase <= this.stand_offset ?
this.stand_controller(index, phase / this.stand_offset)
: this.swing_controller(index, (phase - this.stand_offset) / (1 - this.stand_offset))
}
stand_controller(index: number, phase: number) {
const depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
}
swing_controller(index: number, phase: number) {
const height = this.gait_state.step_height
return this.controller(index, phase, bezier_curve, height)
}
controller(
index: number,
phase: number,
controller: (length: number, angle: number, ...args: number[]) => number[],
...args: number[]
) {
let length = this.step_length / 2
let angle = Math.atan2(this.gait_state.step_z, this.step_length) * 2
const delta_pos = controller(length, angle, ...args, phase)
const kin = this.kinematic
length = this.gait_state.step_angle * kin.max_step_length
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
const delta_rot = controller(length, angle, ...args, phase)
this.body_state.feet[index][0] += delta_pos[0] + delta_rot[0] * 0.2
this.body_state.feet[index][2] += delta_pos[2] + delta_rot[2] * 0.2
if (this.gait_state.step_x || this.gait_state.step_z || this.gait_state.step_angle)
this.body_state.feet[index][1] += delta_pos[1] + delta_rot[1] * 0.2
return this.body_state.feet[index]
}
update_cumulative_position() {
if (this.last_body_state === null) {
this.last_body_state = { ...this.body_state }
this.body_state.cumulative_x = 0
this.body_state.cumulative_y = 0
this.body_state.cumulative_z = 0
this.body_state.cumulative_roll = 0
this.body_state.cumulative_pitch = 0
this.body_state.cumulative_yaw = 0
return
}
const m = this.gait_state
const moving = m.step_x !== 0 || m.step_z !== 0 || m.step_angle !== 0
if (moving) {
const step_displacement_x_local =
m.step_x * m.step_velocity * this.dt * this.speed_factor
const step_displacement_z_local =
m.step_z * m.step_velocity * this.dt * this.speed_factor
const step_displacement_yaw =
m.step_angle * m.step_velocity * this.dt * this.speed_factor
const cos_yaw = Math.cos(this.cumulative_orientation.yaw)
const sin_yaw = Math.sin(this.cumulative_orientation.yaw)
const step_displacement_x =
step_displacement_x_local * cos_yaw - step_displacement_z_local * sin_yaw
const step_displacement_z =
step_displacement_x_local * sin_yaw + step_displacement_z_local * cos_yaw
this.cumulative_position.x += step_displacement_x
this.cumulative_position.z += step_displacement_z
this.cumulative_orientation.yaw += step_displacement_yaw
}
this.body_state.cumulative_x = this.cumulative_position.x
this.body_state.cumulative_y = this.cumulative_position.y
this.body_state.cumulative_z = this.cumulative_position.z
this.body_state.cumulative_roll = this.cumulative_orientation.roll
this.body_state.cumulative_pitch = this.cumulative_orientation.pitch
this.body_state.cumulative_yaw = this.cumulative_orientation.yaw
this.last_body_state = { ...this.body_state }
}
}
const stance_curve = (length: number, angle: number, depth: number, phase: number): number[] => {
const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle)
const X_POLAR = Math.cos(angle)
const Y_POLAR = Math.sin(angle)
const step = length * (1 - 2 * phase)
const X = step * X_POLAR
const Z = step * Y_POLAR
let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z]
const step = length * (1 - 2 * phase)
const X = step * X_POLAR
const Z = step * Y_POLAR
let Y = 0
if (length !== 0) Y = -depth * Math.cos((Math.PI * (X + Y)) / (2 * length))
return [X, Y, Z]
}
const yawArc = (default_foot_pos: number[], current_foot_pos: number[]): number => {
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
const offsets = [
current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1]
]
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
const offset_mod = Math.atan2(offset_mag, foot_mag)
const foot_mag = Math.sqrt(default_foot_pos[0] ** 2 + default_foot_pos[2] ** 2)
const foot_dir = Math.atan2(default_foot_pos[2], default_foot_pos[0])
const offsets = [
current_foot_pos[0] - default_foot_pos[0],
current_foot_pos[2] - default_foot_pos[2],
current_foot_pos[1] - default_foot_pos[1]
]
const offset_mag = Math.sqrt(offsets[0] ** 2 + offsets[2] ** 2)
const offset_mod = Math.atan2(offset_mag, foot_mag)
return Math.PI / 2.0 + foot_dir + offset_mod
return Math.PI / 2.0 + foot_dir + offset_mod
}
const bezier_curve = (length: number, angle: number, height: number, phase: number): number[] => {
const control_points = get_control_points(length, angle, height)
const n = control_points.length - 1
const control_points = get_control_points(length, angle, height)
const n = control_points.length - 1
const point = [0, 0, 0]
for (let i = 0; i <= n; i++) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
point[0] += bernstein_poly * control_points[i][0]
point[1] += bernstein_poly * control_points[i][1]
point[2] += bernstein_poly * control_points[i][2]
}
return point
const point = [0, 0, 0]
for (let i = 0; i <= n; i++) {
const bernstein_poly = comb(n, i) * Math.pow(phase, i) * Math.pow(1 - phase, n - i)
point[0] += bernstein_poly * control_points[i][0]
point[1] += bernstein_poly * control_points[i][1]
point[2] += bernstein_poly * control_points[i][2]
}
return point
}
const get_control_points = (length: number, angle: number, height: number): number[][] => {
const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle)
const X_POLAR = Math.cos(angle)
const Z_POLAR = Math.sin(angle)
const STEP = [
-length,
-length * 1.4,
-length * 1.5,
-length * 1.5,
-length * 1.5,
0.0,
0.0,
0.0,
length * 1.5,
length * 1.5,
length * 1.4,
length
]
const STEP = [
-length,
-length * 1.4,
-length * 1.5,
-length * 1.5,
-length * 1.5,
0.0,
0.0,
0.0,
length * 1.5,
length * 1.5,
length * 1.4,
length
]
const Y = [
0.0,
0.0,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 1.1,
height * 1.1,
height * 1.1,
0.0,
0.0
]
const Y = [
0.0,
0.0,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 0.9,
height * 1.1,
height * 1.1,
height * 1.1,
0.0,
0.0
]
const control_points: number[][] = []
const control_points: number[][] = []
for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z])
}
for (let i = 0; i < STEP.length; i++) {
const X = STEP[i] * X_POLAR
const Z = STEP[i] * Z_POLAR
control_points.push([X, Y[i], Z])
}
return control_points
return control_points
}
const comb = (n: number, k: number): number => {
if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1
k = Math.min(k, n - k)
let c = 1
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
return c
if (k < 0 || k > n) return 0
if (k === 0 || k === n) return 1
k = Math.min(k, n - k)
let c = 1
for (let i = 0; i < k; i++) c = (c * (n - i)) / (i + 1)
return c
}
+153 -109
View File
@@ -1,32 +1,38 @@
export interface body_state_t {
omega: number
phi: number
psi: number
xm: number
ym: number
zm: number
feet: number[][]
omega: number
phi: number
psi: number
xm: number
ym: number
zm: number
feet: number[][]
cumulative_x: number
cumulative_y: number
cumulative_z: number
cumulative_roll: number
cumulative_pitch: number
cumulative_yaw: number
}
export interface position {
x: number
y: number
z: number
x: number
y: number
z: number
}
export interface target_position {
x: number
z: number
yaw: number
x: number
z: number
yaw: number
}
export interface KinematicParams {
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
}
const { cos, sin, atan2, acos, sqrt, max, min } = Math
@@ -34,107 +40,145 @@ const { cos, sin, atan2, acos, sqrt, max, min } = Math
const DEG2RAD = 0.017453292519943
export default class Kinematic {
coxa: number
coxa_offset: number
femur: number
tibia: number
coxa: number
coxa_offset: number
femur: number
tibia: number
L: number
W: number
L: number
W: number
DEG2RAD = DEG2RAD
DEG2RAD = DEG2RAD
mountOffsets: number[][]
max_roll: number
max_pitch: number
max_body_shift_x: number
max_body_shift_z: number
max_leg_reach: number
min_body_height: number
max_body_height: number
body_height_range: number
max_step_length: number
max_step_height: number
default_step_depth: number
default_body_height: number
default_step_height: number
invMountRot = [
[0, 0, -1],
[0, 1, 0],
[1, 0, 0]
]
mountOffsets: number[][]
default_feet_positions: number[][]
constructor(params: KinematicParams) {
this.coxa = params.coxa
this.coxa_offset = params.coxa_offset
this.femur = params.femur
this.tibia = params.tibia
this.L = params.L
this.W = params.W
this.mountOffsets = [
[this.L / 2, 0, this.W / 2],
[this.L / 2, 0, -this.W / 2],
[-this.L / 2, 0, this.W / 2],
[-this.L / 2, 0, -this.W / 2]
invMountRot = [
[0, 0, -1],
[0, 1, 0],
[1, 0, 0]
]
}
getDefaultFeetPos(): number[][] {
return this.mountOffsets.map((offset, i) => {
return [offset[0], -1, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
})
}
constructor(params: KinematicParams) {
this.coxa = params.coxa
this.coxa_offset = params.coxa_offset
this.femur = params.femur
this.tibia = params.tibia
this.L = params.L
this.W = params.W
calcIK(p: body_state_t): number[] {
const roll = p.omega * this.DEG2RAD
const pitch = p.phi * this.DEG2RAD
const yaw = p.psi * this.DEG2RAD
const rot = this.euler2R(roll, pitch, yaw)
const inv_rot = [
[rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
this.max_roll = 15 * (Math.PI / 2)
this.max_pitch = 15 * (Math.PI / 2)
this.max_body_shift_x = this.W / 3
this.max_body_shift_z = this.W / 3
this.max_leg_reach = this.femur + this.tibia - this.coxa_offset
this.min_body_height = this.max_leg_reach * 0.45
this.max_body_height = this.max_leg_reach * 1
this.body_height_range = this.max_body_height - this.min_body_height
this.max_step_length = this.max_leg_reach * 0.8
this.max_step_height = this.max_leg_reach / 2
this.default_step_depth = 0.002
this.default_body_height = this.min_body_height + this.body_height_range / 2
this.default_step_height = this.default_body_height / 2
const [mx, my, mz] = this.mountOffsets[i]
const px = bx - mx,
py = by - my,
pz = bz - mz
this.mountOffsets = [
[this.L / 2, 0, this.W / 2],
[this.L / 2, 0, -this.W / 2],
[-this.L / 2, 0, this.W / 2],
[-this.L / 2, 0, -this.W / 2]
]
const lx =
this.invMountRot[0][0] * px + this.invMountRot[0][1] * py + this.invMountRot[0][2] * pz
const ly =
this.invMountRot[1][0] * px + this.invMountRot[1][1] * py + this.invMountRot[1][2] * pz
const lz =
this.invMountRot[2][0] * px + this.invMountRot[2][1] * py + this.invMountRot[2][2] * pz
this.default_feet_positions = this.mountOffsets.map((offset, i) => {
return [offset[0], 0, offset[2] + (i % 2 === 1 ? -this.coxa : this.coxa)]
})
}
const xLocal = i % 2 === 1 ? -lx : lx
return this.legIK(xLocal, ly, lz)
})
}
getDefaultFeetPos(): number[][] {
return this.default_feet_positions.map(pos => [...pos])
}
private legIK(x: number, y: number, z: number): [number, number, number] {
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
const G = F - this.coxa_offset
const H = sqrt(G * G + z * z)
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
const D =
(H * H - this.femur * this.femur - this.tibia * this.tibia) / (2 * this.femur * this.tibia)
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
return [t1, t2, t3]
}
calcIK(p: body_state_t): number[] {
const roll = p.omega * this.DEG2RAD
const pitch = p.phi * this.DEG2RAD
const yaw = p.psi * this.DEG2RAD
const rot = this.euler2R(roll, pitch, yaw)
const inv_rot = [
[rot[0][0], rot[1][0], rot[2][0]],
[rot[0][1], rot[1][1], rot[2][1]],
[rot[0][2], rot[1][2], rot[2][2]]
]
const inv_trans = [
-inv_rot[0][0] * p.xm - inv_rot[0][1] * p.ym - inv_rot[0][2] * p.zm,
-inv_rot[1][0] * p.xm - inv_rot[1][1] * p.ym - inv_rot[1][2] * p.zm,
-inv_rot[2][0] * p.xm - inv_rot[2][1] * p.ym - inv_rot[2][2] * p.zm
]
return p.feet.flatMap((foot, i) => {
const [wx, wy, wz] = foot
const bx = inv_rot[0][0] * wx + inv_rot[0][1] * wy + inv_rot[0][2] * wz + inv_trans[0]
const by = inv_rot[1][0] * wx + inv_rot[1][1] * wy + inv_rot[1][2] * wz + inv_trans[1]
const bz = inv_rot[2][0] * wx + inv_rot[2][1] * wy + inv_rot[2][2] * wz + inv_trans[2]
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
const cr = cos(roll),
sr = sin(roll)
const cp = cos(pitch),
sp = sin(pitch)
const cy = cos(yaw),
sy = sin(yaw)
return [
[cp * cy, -cp * sy, sp],
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
]
}
const [mx, my, mz] = this.mountOffsets[i]
const px = bx - mx,
py = by - my,
pz = bz - mz
const lx =
this.invMountRot[0][0] * px +
this.invMountRot[0][1] * py +
this.invMountRot[0][2] * pz
const ly =
this.invMountRot[1][0] * px +
this.invMountRot[1][1] * py +
this.invMountRot[1][2] * pz
const lz =
this.invMountRot[2][0] * px +
this.invMountRot[2][1] * py +
this.invMountRot[2][2] * pz
const xLocal = i % 2 === 1 ? -lx : lx
return this.legIK(xLocal, ly, lz)
})
}
private legIK(x: number, y: number, z: number): [number, number, number] {
const F = sqrt(max(0, x * x + y * y - this.coxa * this.coxa))
const G = F - this.coxa_offset
const H = sqrt(G * G + z * z)
const t1 = -atan2(y, x) - atan2(F, -this.coxa)
const D =
(H * H - this.femur * this.femur - this.tibia * this.tibia) /
(2 * this.femur * this.tibia)
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.tibia * sin(t3), this.femur + this.tibia * cos(t3))
return [t1, t2, t3]
}
private euler2R(roll: number, pitch: number, yaw: number): number[][] {
const cr = cos(roll),
sr = sin(roll)
const cp = cos(pitch),
sp = sin(pitch)
const cy = cos(yaw),
sy = sin(yaw)
return [
[cp * cy, -cp * sy, sp],
[sr * sp * cy + sy * cr, -sr * sp * sy + cr * cy, -sr * cp],
[sr * sy - sp * cr * cy, sr * cy + sp * sy * cr, cr * cp]
]
}
}
+313 -345
View File
@@ -1,380 +1,348 @@
import {
Mesh,
PerspectiveCamera,
PlaneGeometry,
Scene,
WebGLRenderer,
AmbientLight,
DirectionalLight,
PCFSoftShadowMap,
type GridHelper,
ArrowHelper,
Vector3,
FogExp2,
CanvasTexture,
type ColorRepresentation,
type WebGLRendererParameters,
MeshPhongMaterial,
EquirectangularReflectionMapping,
ACESFilmicToneMapping,
MathUtils,
Group,
MeshBasicMaterial,
RepeatWrapping
Mesh,
PerspectiveCamera,
PlaneGeometry,
Scene,
WebGLRenderer,
AmbientLight,
DirectionalLight,
PCFSoftShadowMap,
type GridHelper,
ArrowHelper,
Vector3,
FogExp2,
CanvasTexture,
type ColorRepresentation,
type WebGLRendererParameters,
MeshPhongMaterial,
EquirectangularReflectionMapping,
ACESFilmicToneMapping,
Group,
MeshBasicMaterial,
RepeatWrapping,
type Object3D
} from 'three'
import { Sky } from 'three/addons/objects/Sky.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
import { Reflector } from 'three/examples/jsm/objects/Reflector.js'
import { type URDFJoint, type URDFMimicJoint, type URDFRobot } from 'urdf-loader'
import { PointerURDFDragControls } from 'urdf-loader/src/URDFDragControls'
import { sunCalculator } from './utilities/position-utilities'
export const addScene = () => new Scene()
interface position {
x?: number
y?: number
z?: number
x?: number
y?: number
z?: number
}
interface light {
color?: ColorRepresentation
intensity?: number
color?: ColorRepresentation
intensity?: number
}
interface arrowOptions {
origin: position
direction: position
length?: number
color?: ColorRepresentation
origin: position
direction: position
length?: number
color?: ColorRepresentation
}
type directionalLight = position & light
export default class SceneBuilder {
public scene: Scene
public camera!: PerspectiveCamera
public ground!: Mesh
public renderer!: WebGLRenderer
public orbit: OrbitControls
public callback: Function | undefined
public gridHelper!: GridHelper
public model!: URDFRobot
public liveStreamTexture!: CanvasTexture
private fog!: FogExp2
private isLoaded: boolean = false
public isDragging: boolean = false
highlightMaterial: any
sky!: Sky
transformControl: TransformControls
public modelGroup!: Group
public scene: Scene
public camera!: PerspectiveCamera
public ground!: Mesh
public renderer!: WebGLRenderer
public orbit: OrbitControls
public callback: (() => void) | undefined
public gridHelper!: GridHelper
public model!: URDFRobot
public liveStreamTexture!: CanvasTexture
private fog!: FogExp2
private isLoaded: boolean = false
public isDragging: boolean = false
transformControl: TransformControls
public modelGroup!: Group
constructor() {
this.scene = new Scene()
if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping
}
return this
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters)
this.renderer.outputColorSpace = 'srgb'
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = PCFSoftShadowMap
this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addSky = () => {
this.sky = new Sky()
this.sky.scale.setScalar(450000)
this.scene.add(this.sky)
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: sunCalculator.calculateSunElevation(),
azimuth: 200,
exposure: this.renderer.toneMappingExposure
}
const uniforms = this.sky.material.uniforms
uniforms['turbidity'].value = effectController.turbidity
uniforms['rayleigh'].value = effectController.rayleigh
uniforms['mieCoefficient'].value = effectController.mieCoefficient
uniforms['mieDirectionalG'].value = effectController.mieDirectionalG
this.renderer.toneMappingExposure = 0.5
const phi = MathUtils.degToRad(90 - effectController.elevation)
const theta = MathUtils.degToRad(effectController.azimuth)
const sun = new Vector3()
sun.setFromSphericalCoords(1, phi, theta)
uniforms['sunPosition'].value.copy(sun)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity)
this.scene.add(ambientLight)
return this
}
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity)
directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
this.scene.add(directionalLight)
return this
}
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
}
const texture = new CanvasTexture(canvas)
texture.wrapS = texture.wrapT = RepeatWrapping
texture.anisotropy = 16
return texture
}
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density)
return this
}
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement
if (parentElement) {
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this
}
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
public addRenderCb = (callback: Function) => {
this.callback = callback
return this
}
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera)
this.orbit.update()
this.handleRobotShadow()
if (this.callback) this.callback()
if (!this.liveStreamTexture) return
})
return this
}
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
)
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
)
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
private setJointValue(jointName: string, angle: number) {
if (!this.model) return
if (!this.model.joints[jointName]) return
this.model.joints[jointName].setJointValue(angle)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: any) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
constructor() {
this.scene = new Scene()
if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping
}
}
return this
}
if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) {
const child = c.children[i]
if (!child.isURDFCollider) {
traverse(c.children[i])
}
public addRenderer = (parameters?: WebGLRendererParameters) => {
this.renderer = new WebGLRenderer(parameters)
this.renderer.outputColorSpace = 'srgb'
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = PCFSoftShadowMap
this.renderer.toneMapping = ACESFilmicToneMapping
this.renderer.toneMappingExposure = 0.85
if (!parameters?.canvas) document.body.appendChild(this.renderer.domElement)
return this
}
public addPerspectiveCamera = (options: position) => {
this.camera = new PerspectiveCamera()
this.camera.position.set(options.x ?? 0, options.y ?? 2.7, options.z ?? 0)
this.scene.add(this.camera)
return this
}
public addGroundPlane = (options?: position) => {
const checkerboardTexture = this.createCheckerboardTexture(1024, 2)
checkerboardTexture.wrapS = RepeatWrapping
checkerboardTexture.wrapT = RepeatWrapping
checkerboardTexture.repeat.set(100, 100)
const checkerboardMat = new MeshBasicMaterial({
map: checkerboardTexture,
opacity: 0.1,
transparent: true
})
const plane = new PlaneGeometry(400, 400)
this.ground = new Mesh(plane, checkerboardMat)
this.ground.rotation.x = -Math.PI / 2
this.ground.position.set(options?.x ?? 0, options?.y ?? 0.01, options?.z ?? 0)
this.ground.receiveShadow = true
this.scene.add(this.ground)
const mirror = new Reflector(plane, {
clipBias: 0.003,
textureWidth: window.innerWidth * window.devicePixelRatio,
textureHeight: window.innerHeight * window.devicePixelRatio,
color: 0x00bfff
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement)
this.orbit.minDistance = minDistance + (maxDistance - minDistance) / 2
this.orbit.maxDistance = maxDistance
this.orbit.autoRotate = autoRotate
this.orbit.update()
this.orbit.minDistance = minDistance
return this
}
public addAmbientLight = (options: light) => {
const ambientLight = new AmbientLight(options.color, options.intensity)
this.scene.add(ambientLight)
return this
}
public addDirectionalLight = (options: directionalLight) => {
const directionalLight = new DirectionalLight(options.color, options.intensity)
directionalLight.castShadow = true
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.left = -10
directionalLight.shadow.mapSize.set(4096, 4096)
directionalLight.position.set(options.x ?? 0, options.y ?? 0, options.z ?? 0)
this.scene.add(directionalLight)
return this
}
private createCheckerboardTexture = (size: number, squares: number) => {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares
for (let y = 0; y < squares; y++) {
for (let x = 0; x < squares; x++) {
context!.fillStyle = (x + y) % 2 === 0 ? '#ffffff' : '#000000'
context!.fillRect(x * squareSize, y * squareSize, squareSize, squareSize)
}
}
}
const texture = new CanvasTexture(canvas)
texture.wrapS = texture.wrapT = RepeatWrapping
texture.anisotropy = 16
return texture
}
traverse(m)
}
public addTransformControls = (model: any) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
this.transformControl.addEventListener('dragging-changed', (event: any) => {
this.orbit.enabled = !event.value
this.isDragging = !event.value
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
public addModel = (model: any) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: any) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle(joint.name, angle)
public addFogExp2 = (color: ColorRepresentation, density?: number) => {
this.scene.fog = new FogExp2(color, density)
return this
}
dragControls.onDragStart = () => {
this.orbit.enabled = false
this.isDragging = true
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement
if (parentElement) {
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true
this.isDragging = false
public handleResize = (width = window.innerWidth, height = window.innerHeight) => {
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
return this
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener(
'touchstart',
data => dragControls._mouseDown(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public addRenderCb = (callback: () => void) => {
this.callback = callback
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog
}
public startRenderLoop = () => {
this.renderer.setAnimationLoop(() => {
this.renderer.render(this.scene, this.camera)
this.orbit.update()
this.handleRobotShadow()
if (this.callback) this.callback()
if (!this.liveStreamTexture) return
})
return this
}
private handleRobotShadow = () => {
if (this.isLoaded) return
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
setTimeout(() => clearInterval(intervalId), 1000)
this.isLoaded = true
}
public addArrowHelper = (options?: arrowOptions) => {
const dir = new Vector3(
options?.direction.x ?? 0,
options?.direction.y ?? 0,
options?.direction.z ?? 0
)
const origin = new Vector3(
options?.origin.x ?? 0,
options?.origin.y ?? 0,
options?.origin.z ?? 0
)
const arrowHelper = new ArrowHelper(
dir,
origin,
options?.length ?? 1.5,
options?.color ?? 0xff0000
)
this.scene.add(arrowHelper)
return this
}
private setJointValue(jointName: string, angle: number) {
if (!this.model) return
if (!this.model.joints[jointName]) return
this.model.joints[jointName].setJointValue(angle)
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed'
highlightLinkGeometry = (m: URDFMimicJoint, revert: boolean, material: MeshPhongMaterial) => {
const traverse = (c: Object3D) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material
c.material = material
}
}
if (c === m || !this.isJoint(c)) {
for (let i = 0; i < c.children.length; i++) {
const child = c.children[i]
if (!child.isURDFCollider) {
traverse(c.children[i])
}
}
}
}
traverse(m)
}
public addTransformControls = (model: Object3D) => {
this.transformControl = new TransformControls(this.camera, this.renderer.domElement)
this.transformControl.addEventListener('dragging-changed', (event: { value: boolean }) => {
this.orbit.enabled = !event.value
this.isDragging = !event.value
})
this.transformControl.attach(model)
this.scene.add(this.transformControl)
this.transformControl.setMode('rotate')
return this
}
public addModel = (model: URDFRobot) => {
this.modelGroup = new Group()
this.modelGroup.add(model)
this.model = model
this.scene.add(this.modelGroup)
return this
}
public addDragControl = (updateAngle: (angles: Record<string, number>) => void) => {
const highlightColor = '#FFFFFF'
const highlightMaterial = new MeshPhongMaterial({
shininess: 10,
color: highlightColor,
emissive: highlightColor,
emissiveIntensity: 0.9
})
const dragControls = new PointerURDFDragControls(
this.scene,
this.camera,
this.renderer.domElement
)
dragControls.updateJoint = (joint: URDFMimicJoint, angle: number) => {
this.setJointValue(joint.name, angle)
updateAngle({ [joint.name]: angle })
}
dragControls.onDragStart = () => {
this.orbit.enabled = false
this.isDragging = true
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true
this.isDragging = false
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial)
this.renderer.domElement.addEventListener(
'touchstart',
data => dragControls._mouseDown(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchmove',
data => dragControls._mouseMove(data.touches[0]),
{ passive: true }
)
this.renderer.domElement.addEventListener(
'touchend',
data => dragControls._mouseUp(data.touches[0]),
{ passive: true }
)
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog
}
private handleRobotShadow = () => {
if (this.isLoaded) return
const intervalId = setInterval(() => this.model?.traverse(c => (c.castShadow = true)), 10)
setTimeout(() => clearInterval(intervalId), 1000)
this.isLoaded = true
}
}
+42 -43
View File
@@ -1,54 +1,53 @@
import { Result } from '$lib/utilities/result';
import { browser } from '$app/environment';
import { Result } from '$lib/utilities/result'
import { browser } from '$app/environment'
class FileService {
private dbPromise: Promise<Result<IDBDatabase, string>> | null = browser
? this.openDatabase()
: null;
private dbPromise: Promise<Result<IDBDatabase, string>> | null =
browser ? this.openDatabase() : null
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
return new Promise((resolve) => {
const request = indexedDB.open('fileStorageDB', 1);
private async openDatabase(): Promise<Result<IDBDatabase, string>> {
return new Promise(resolve => {
const request = indexedDB.open('fileStorageDB', 1)
request.onupgradeneeded = () => {
request.result.createObjectStore('files');
};
request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Error opening database'));
});
}
request.onupgradeneeded = () => {
request.result.createObjectStore('files')
}
request.onsuccess = () => resolve(Result.ok(request.result))
request.onerror = () => resolve(Result.err('Error opening database'))
})
}
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
if (!browser || !this.dbPromise)
return Result.err('Not running in browser or DB not initialized');
const dbResult = await this.dbPromise;
if (dbResult.isErr()) return Result.err('Database not initialized');
const store = dbResult.inner.transaction('files', mode).objectStore('files');
return Result.ok(store);
}
private async getStore(mode: IDBTransactionMode): Promise<Result<IDBObjectStore, string>> {
if (!browser || !this.dbPromise)
return Result.err('Not running in browser or DB not initialized')
const dbResult = await this.dbPromise
if (dbResult.isErr()) return Result.err('Database not initialized')
const store = dbResult.inner.transaction('files', mode).objectStore('files')
return Result.ok(store)
}
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const storeResult = await this.getStore('readwrite');
if (storeResult.isErr()) return Result.err('Failed to access store');
public async saveFile(key: string, file: Uint8Array): Promise<Result<IDBValidKey, string>> {
const storeResult = await this.getStore('readwrite')
if (storeResult.isErr()) return Result.err('Failed to access store')
return new Promise((resolve) => {
const request = storeResult.inner.put(file, key);
request.onsuccess = () => resolve(Result.ok(request.result));
request.onerror = () => resolve(Result.err('Failed to save file'));
});
}
return new Promise(resolve => {
const request = storeResult.inner.put(file, key)
request.onsuccess = () => resolve(Result.ok(request.result))
request.onerror = () => resolve(Result.err('Failed to save file'))
})
}
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly');
if (storeResult.isErr()) return Result.err('Failed to access store');
public async getFile(key: string): Promise<Result<Uint8Array | undefined, string>> {
const storeResult = await this.getStore('readonly')
if (storeResult.isErr()) return Result.err('Failed to access store')
return new Promise((resolve) => {
const request = storeResult.inner.get(key);
request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File not found'));
request.onerror = () => resolve(Result.err('Failed to retrieve file'));
});
}
return new Promise(resolve => {
const request = storeResult.inner.get(key)
request.onsuccess = () =>
resolve(request.result ? Result.ok(request.result) : Result.err('File not found'))
request.onerror = () => resolve(Result.err('Failed to retrieve file'))
})
}
}
export default browser ? new FileService() : null;
export default browser ? new FileService() : null
+2 -2
View File
@@ -1,2 +1,2 @@
export { default as fileService } from './file-service';
export { default as resultService } from './result-service';
export { default as fileService } from './file-service'
export { default as resultService } from './result-service'
+14 -14
View File
@@ -1,19 +1,19 @@
import { errorLogs, latestErrorLog } from '$lib/stores';
import type { Result } from '$lib/utilities';
import { errorLogs, latestErrorLog } from '$lib/stores'
import type { Result } from '$lib/utilities'
class ResultService {
public handleResult(result: Result<unknown, string>, tag?: string) {
if (result.isErr()) {
const errorLogEntry = { tag, message: result.inner, exception: result.exception };
latestErrorLog.set(errorLogEntry);
errorLogs.update((entries) => {
entries.push(errorLogEntry);
return entries;
});
}
public handleResult(result: Result<unknown, string>, tag?: string) {
if (result.isErr()) {
const errorLogEntry = { tag, message: result.inner, exception: result.exception }
latestErrorLog.set(errorLogEntry)
errorLogs.update(entries => {
entries.push(errorLogEntry)
return entries
})
}
return result;
}
return result
}
}
export default new ResultService();
export default new ResultService()
+30 -49
View File
@@ -1,55 +1,36 @@
import { type Analytics } from '$lib/types/models';
import { writable } from 'svelte/store';
import { AnalyticsData } from '$lib/platform_shared/message'
import { writable } from 'svelte/store'
import { socket } from './socket'
let 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() {
const { subscribe, update } = writable(analytics_data);
const { subscribe, update } = writable<AnalyticsData[]>([])
return {
subscribe,
addData: (content: Analytics) => {
update((analytics_data) => ({
...analytics_data,
uptime: [...analytics_data.uptime, content.uptime].slice(-maxAnalyticsData),
free_heap: [...analytics_data.free_heap, content.free_heap / 1000].slice(-maxAnalyticsData),
total_heap: [...analytics_data.total_heap, content.total_heap / 1000].slice(
-maxAnalyticsData
),
used_heap: [
...analytics_data.used_heap,
(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)
}));
}
};
let unsubscribe: (() => void) | null = null
let listenerCount = 0
const addData = (content: AnalyticsData) => {
update(data => [...data, content].slice(-maxAnalyticsData))
}
return {
subscribe,
addData,
listen: () => {
listenerCount++
if (!unsubscribe) {
unsubscribe = socket.on(AnalyticsData, addData)
}
},
stop: () => {
listenerCount = Math.max(0, listenerCount - 1)
if (listenerCount === 0 && unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
}
}
export const analytics = createAnalytics();
export const analytics = createAnalytics()
+49 -49
View File
@@ -1,67 +1,67 @@
import { persistentStore } from '$lib/utilities';
import { get, type Writable } from 'svelte/store';
import { persistentStore } from '$lib/utilities'
import { get, type Writable } from 'svelte/store'
import Visualization from '$lib/components/Visualization.svelte';
import Stream from '$lib/components/Stream.svelte';
import ChartWidget from '$lib/components/widget/ChartWidget.svelte';
import Visualization from '$lib/components/Visualization.svelte'
import Stream from '$lib/components/Stream.svelte'
import ChartWidget from '$lib/components/widget/ChartWidget.svelte'
export interface WidgetConfig {
id: string | number;
component: keyof typeof WidgetComponents;
props?: Record<string, any>;
id: string | number
component: keyof typeof WidgetComponents
props?: Record<string, unknown>
}
export interface WidgetContainerConfig {
id: string | number;
layout?: 'row' | 'column' | 'wrap';
header?: string;
widgets: Array<WidgetConfig | WidgetContainerConfig>;
id: string | number
layout?: 'row' | 'column' | 'wrap'
header?: string
widgets: Array<WidgetConfig | WidgetContainerConfig>
}
export const isWidgetConfig = (
widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget;
widget: WidgetConfig | WidgetContainerConfig
): widget is WidgetConfig => 'component' in widget
export const WidgetComponents = {
Visualization,
Stream,
ChartWidget
};
Visualization,
Stream,
ChartWidget
}
interface View {
name: string;
content: WidgetContainerConfig;
name: string
content: WidgetContainerConfig
}
const defaultViews: View[] = [
{
name: 'Stream',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Stream' }]
}
},
{
name: '3D representation',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
}
},
{
name: 'Split screen',
content: {
id: 'root',
widgets: [
{ id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } }
]
}
}
];
{
name: '3D representation',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Visualization', props: { debug: true } }]
}
},
{
name: 'Stream',
content: {
id: 'root',
layout: 'column',
widgets: [{ id: 2, component: 'Stream' }]
}
},
{
name: 'Split screen',
content: {
id: 'root',
widgets: [
{ id: 2, component: 'Stream' },
{ id: 2, component: 'Visualization', props: { debug: true } }
]
}
}
]
export const views: Writable<View[]> = persistentStore('views', defaultViews);
export const views: Writable<View[]> = persistentStore('views', defaultViews)
export const selectedView = persistentStore('selected_view', get(views)[0].name);
export const selectedView = persistentStore('selected_view', get(views)[0].name)
+51 -41
View File
@@ -1,62 +1,72 @@
import { api } from '$lib/api'
import { notifications } from '$lib/components/toasts/notifications'
import Kinematic from '$lib/kinematic'
import { persistentStore } from '$lib/utilities'
import { derived, type Writable } from 'svelte/store'
import { base } from '$app/paths'
import { resolve } from '$app/paths'
import { socket } from '$lib/stores'
let featureFlagsStore: Writable<Record<string, boolean | string>>
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
if (!featureFlagsStore) {
featureFlagsStore = persistentStore<Record<string, boolean | string>>('FeatureFlags', {})
api.get<Record<string, boolean>>('/api/features').then(result => {
if (result.isOk()) featureFlagsStore.set(result.inner)
else {
notifications.error('Feature flag could not be fetched', 2500)
}
})
}
socket
.request({ featuresDataRequest: {} })
.then(response => {
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
}
const base = resolve('/')
export const variants = {
SPOTMICRO_ESP32: {
model: `${base}/spot_micro.urdf.xacro`,
stl: `${base}/stl.zip`,
kinematics: {
coxa: 60.5 / 100,
coxa_offset: 10 / 100,
femur: 111.7 / 100,
tibia: 118.5 / 100,
L: 207.5 / 100,
W: 78 / 100
SPOTMICRO_ESP32: {
model: `${base}spot_micro.urdf.xacro`,
stl: `${base}stl.zip`,
kinematics: {
coxa: 0.0605,
coxa_offset: 0.01,
femur: 0.1112,
tibia: 0.1185,
L: 0.2075,
W: 0.078
}
},
SPOTMICRO_YERTLE: {
model: `${base}yertle.URDF`,
stl: `${base}URDF.zip`,
kinematics: {
coxa: 0.035,
coxa_offset: 0.0,
femur: 0.13,
tibia: 0.13,
L: 0.24,
W: 0.078
}
}
},
SPOTMICRO_YERTLE: {
model: `${base}/yertle.URDF`,
stl: `${base}/URDF.zip`,
kinematics: {
coxa: 35 / 100,
coxa_offset: 0 / 100,
femur: 130 / 100,
tibia: 130 / 100,
L: 240 / 100,
W: 78 / 100
}
}
}
export const currentVariant = derived(useFeatureFlags(), $flagStore => {
const variantFlag = $flagStore['variant'] as string
return variantFlag && variants[variantFlag as keyof typeof variants] ?
variants[variantFlag as keyof typeof variants]
: variants.SPOTMICRO_ESP32
const variantFlag = $flagStore['variant'] as string
return variantFlag && variants[variantFlag as keyof typeof variants] ?
variants[variantFlag as keyof typeof variants]
: variants.SPOTMICRO_ESP32
})
export const currentKinematic = derived(
currentVariant,
$variant => new Kinematic($variant.kinematics)
currentVariant,
$variant => new Kinematic($variant.kinematics)
)
+15 -14
View File
@@ -1,24 +1,25 @@
import { writable } from 'svelte/store';
import { writable } from 'svelte/store'
export const isFullscreen = writable(false);
export const isFullscreen = writable(false)
export function toggleFullscreen() {
isFullscreen.update((state) => {
!state ? document.documentElement.requestFullscreen() : document.exitFullscreen();
return !state;
});
isFullscreen.update(state => {
if (!state) document.documentElement.requestFullscreen()
else document.exitFullscreen()
return !state
})
}
export function enterFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen.set(true);
}
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.set(true)
}
}
export function exitFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
isFullscreen.set(false);
}
if (document.fullscreenElement) {
document.exitFullscreen()
isFullscreen.set(false)
}
}
+71 -31
View File
@@ -1,47 +1,87 @@
import { readable, derived } from 'svelte/store'
export type GamepadState = {
available: boolean
gamepads: Gamepad[]
available: boolean
gamepads: Gamepad[]
}
const DEADZONE = 0.15
const dz = (x: number) => {
const a = Math.abs(x)
if (a < DEADZONE) return 0
return ((a - DEADZONE) / (1 - DEADZONE)) * Math.sign(x)
}
let raf = 0
let running = false
export const gamepads = readable<GamepadState>({ available: false, gamepads: [] }, set => {
const update = () => {
const hasGamepadAPI = 'getGamepads' in navigator
if (!hasGamepadAPI) {
set({ available: false, gamepads: [] })
return
const update = () => {
const pads = navigator.getGamepads?.() ?? []
const list = Array.from(pads)
.map(p => p || null)
.filter(Boolean) as Gamepad[]
set({ available: 'getGamepads' in navigator, gamepads: list })
raf = requestAnimationFrame(update)
}
const gps = navigator.getGamepads?.() ?? []
const validGamepads = gps.filter(Boolean) as Gamepad[]
set({
available: true,
gamepads: validGamepads
})
const onConnect = () => update()
const onDisconnect = () => update()
const onVis = () => {
if (document.hidden) {
running = false
cancelAnimationFrame(raf)
} else if (!running) {
running = true
raf = requestAnimationFrame(update)
}
}
window.addEventListener('gamepadconnected', onConnect)
window.addEventListener('gamepaddisconnected', onDisconnect)
document.addEventListener('visibilitychange', onVis)
running = true
raf = requestAnimationFrame(update)
}
window.addEventListener('gamepadconnected', update)
window.addEventListener('gamepaddisconnected', update)
let raf = requestAnimationFrame(update)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('gamepadconnected', update)
window.removeEventListener('gamepaddisconnected', update)
}
return () => {
running = false
cancelAnimationFrame(raf)
window.removeEventListener('gamepadconnected', onConnect)
window.removeEventListener('gamepaddisconnected', onDisconnect)
document.removeEventListener('visibilitychange', onVis)
}
})
export const gamepad = derived(gamepads, $gamepads =>
$gamepads.available && $gamepads.gamepads.length > 0 ? $gamepads.gamepads[0] : null
export const gamepad = derived(gamepads, s =>
s.available && s.gamepads.length ? s.gamepads[0] : null
)
export const gamepadAxes = derived(gamepad, $gamepad => $gamepad?.axes ?? [0, 0, 0, 0])
export const hasGamepad = derived(gamepads, s => s.available && s.gamepads.length > 0)
export const gamepadButtons = derived(gamepad, $gamepad => $gamepad?.buttons ?? [])
export const gamepadAxes = derived(gamepad, g => (g ? g.axes.map(dz) : [0, 0, 0, 0]))
export const hasGamepad = derived(
gamepads,
$gamepads => $gamepads.available && $gamepads.gamepads.length > 0
)
type ButtonEdge = { pressed: boolean; value: number; justPressed: boolean; justReleased: boolean }
const prev = new Map<number, { pressed: boolean; value: number }[]>()
export const gamepadButtons = derived(gamepad, g => g?.buttons ?? [])
export const gamepadButtonsEdges = derived(gamepad, g => {
if (!g) return [] as ButtonEdge[]
const p = prev.get(g.index) || []
const out = g.buttons.map((b, i): ButtonEdge => {
const pr = p[i] || { pressed: false, value: 0 }
const pressed = !!b.pressed || b.value > 0.5
return {
pressed,
value: b.value,
justPressed: pressed && !pr.pressed,
justReleased: !pressed && pr.pressed
}
})
prev.set(
g.index,
out.map(x => ({ pressed: x.pressed, value: x.value }))
)
return out
})
+29 -22
View File
@@ -1,27 +1,34 @@
import { writable } from 'svelte/store';
import type { IMU } from '$lib/types/models';
import { writable } from 'svelte/store'
import { IMUData } from '$lib/platform_shared/message'
import { socket } from './socket'
const maxIMUData = 100;
const maxIMUData = 100
export const imu = (() => {
const { subscribe, update } = writable({
x: [] as number[],
y: [] as number[],
z: [] as number[],
heading: [] as number[],
altitude: [] as number[],
pressure: [] as number[],
bmp_temp: [] as number[]
});
const { subscribe, update } = writable<IMUData[]>([])
const addData = (content: IMU) => {
update(data => {
(Object.keys(content) as (keyof IMU)[]).forEach(key => {
data[key] = [...data[key], content[key]].slice(-maxIMUData);
});
return data;
});
};
let unsubscribe: (() => void) | null = null
let listenerCount = 0
return { subscribe, addData };
})();
const addData = (content: IMUData) => {
update(data => [...data, content].slice(-maxIMUData))
}
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
}
}
}
})()
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './socket-store';
export * from './logging-store';
export * from './model-store';
export * from './socket';
export * from './fullscreen';
export * from './telemetry';
export * from './analytics';
export * from './featureFlags';
export * from './location-store';
export * from './socket-store'
export * from './logging-store'
export * from './model-store'
export * from './socket'
export * from './fullscreen'
export * from './telemetry'
export * from './analytics'
export * from './featureFlags'
export * from './location-store'
+5 -4
View File
@@ -1,5 +1,6 @@
import { persistentStore } from '$lib/utilities';
import { writable } from 'svelte/store';
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public';
import { persistentStore } from '$lib/utilities'
import { writable } from 'svelte/store'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
export const location = PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '');
export const apiLocation =
PUBLIC_VITE_USE_HOST_NAME ? writable('') : persistentStore('location', '')
+6 -6
View File
@@ -1,11 +1,11 @@
import { writable, type Writable } from 'svelte/store';
import { writable, type Writable } from 'svelte/store'
export interface errorLog {
message: unknown;
tag?: string;
exception?: unknown;
message: unknown
tag?: string
exception?: unknown
}
export const latestErrorLog: Writable<errorLog> = writable();
export const latestErrorLog: Writable<errorLog> = writable()
export const errorLogs: Writable<errorLog[]> = writable([]);
export const errorLogs: Writable<errorLog[]> = writable([])
+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 { writable, type Writable } from 'svelte/store'
@@ -8,47 +16,41 @@ export const jointNames = persistentStore('joint_names', <string[]>[])
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 {
Deactivated = 0,
Idle = 1,
Calibration = 2,
Rest = 3,
Stand = 4,
Walk = 5
export const kinematicData = writable(KinematicData.create())
export const input: Writable<ControllerData> = writable(
ControllerData.create({
left: { x: 0, y: 0 },
right: { x: 0, y: 0 },
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 {
Trot = 0,
Crawl = 1
}
const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum)
export const modes = modesData.values
export const modeLabels = modesData.labels
export const walkGaits = ['trot', 'crawl'] as const
export const walkGaitLabels: Record<WalkGaits, string> = {
[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.05
})
const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits)
export const walkGaits = walkGaitsData.values
export const walkGaitLabels = walkGaitsData.labels
+10 -25
View File
@@ -1,27 +1,12 @@
import { writable, type Writable } from 'svelte/store';
import { type angles } from '$lib/types/models';
import { AnglesData } from '$lib/platform_shared/message'
import { writable, type Writable } from 'svelte/store'
export const servoAnglesOut: Writable<number[]> = writable([
0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90
]);
export const servoAngles: Writable<number[]> = writable([
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 sonar = writable([0, 0]);
export const distances = writable({});
export const servoAnglesOut: Writable<AnglesData> = writable(
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] })
)
export const servoAngles: Writable<AnglesData> = writable(
AnglesData.create({ angles: [0, 45, -90, 0, 45, -90, 0, 45, -90, 0, 45, -90] })
)
export interface socketDataCollection {
angles: Writable<angles>;
logs: Writable<string[]>;
mpu: Writable<unknown>;
distances: Writable<unknown>;
}
export const socketData = {
angles: servoAngles,
logs,
mpu,
distances
};
export const mpu = writable({ heading: 0 })
export const sonar = writable([0, 0])
+307 -156
View File
@@ -1,160 +1,311 @@
import { writable } from 'svelte/store';
import { encode, decode } from '@msgpack/msgpack';
import { writable } from 'svelte/store'
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;
type SocketEvent = (typeof socketEvents)[number];
export const MESSAGE_TYPE_TO_KEY = new Map<MessageFns<unknown>, string>()
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?];
let useBinary = false;
const decodeMessage = (data: string | ArrayBuffer): SocketMessage | null => {
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) => {
try {
return useBinary ? encode(data) : JSON.stringify(data);
} catch (error) {
console.error(`Could not encode data: ${data} - ${error}`);
}
};
function createWebSocket() {
const listeners = new Map<string, Set<(data?: unknown) => void>>();
const { subscribe, set } = writable(false);
const reconnectTimeoutTime = 5000;
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>;
let reconnectTimeoutId: ReturnType<typeof setTimeout>;
let ws: WebSocket;
let socketUrl: string | URL;
function init(url: string | URL) {
socketUrl = url;
connect();
}
function disconnect(reason: SocketEvent, event?: Event) {
ws.close();
set(false);
clearTimeout(unresponsiveTimeoutId);
clearTimeout(reconnectTimeoutId);
listeners.get(reason)?.forEach(listener => listener(event));
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime);
}
function connect() {
ws = new WebSocket(socketUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = ev => {
ping();
useBinary = true;
ping();
set(true);
clearTimeout(reconnectTimeoutId);
listeners.get('open')?.forEach(listener => listener(ev));
for (const event of listeners.keys()) {
if (socketEvents.includes(event as SocketEvent)) continue;
subscribeToEvent(event);
}
};
ws.onmessage = frame => {
resetUnresponsiveCheck();
const message = decodeMessage(frame.data);
if (!message) return;
const [, event, payload = undefined] = message;
if (event) listeners.get(event)?.forEach(listener => listener(payload));
};
ws.onerror = ev => disconnect('error', ev);
ws.onclose = ev => disconnect('close', ev);
}
function unsubscribe(event: string, listener?: (data: unknown) => void) {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
if (!eventListeners.size) {
unsubscribeToEvent(event);
}
if (listener) {
eventListeners?.delete(listener);
} else {
listeners.delete(event);
}
}
function resetUnresponsiveCheck() {
clearTimeout(unresponsiveTimeoutId);
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime);
}
function sendEvent(event: string, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
send([2, event, data]);
}
function unsubscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
send([1, event]);
}
function subscribeToEvent(event: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
send([0, event]);
}
function send(data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const serialized = encodeMessage(data);
if (!serialized) {
console.error('Could not serialize data:', data);
return;
}
ws.send(serialized);
}
function ping() {
const serialized = encodeMessage([4]);
if (!serialized) {
console.error('Could not serialize message');
return;
}
ws.send(serialized);
}
return {
subscribe,
sendEvent,
init,
on: <T>(event: string, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event);
if (!eventListeners) {
if (!socketEvents.includes(event as SocketEvent)) {
subscribeToEvent(event);
}
eventListeners = new Set();
listeners.set(event, eventListeners);
}
eventListeners.add(listener as (data: unknown) => void);
return () => {
unsubscribe(event, listener as (data: unknown) => void);
};
},
off: <T>(event: string, listener?: (data: T) => void) => {
unsubscribe(event, listener as (data: unknown) => void);
},
};
type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'>
type PendingRequest = {
resolve: (response: CorrelationResponse) => void
reject: (error: Error) => void
timeoutId: ReturnType<typeof setTimeout>
}
export const socket = createWebSocket();
// Combine references from both message.proto and filesystem.proto
const combinedReferences: Record<string, MessageFns<unknown>> = {
...protoMetadata.references,
...filesystemProtoMetadata.references
}
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() {
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 reconnectTimeoutTime = 500000
const requestTimeoutTime = 30000
let correlationIdCounter = 0
let unresponsiveTimeoutId: ReturnType<typeof setTimeout>
let reconnectTimeoutId: ReturnType<typeof setTimeout>
let ws: WebSocket
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) {
socketUrl = url
connect()
}
function disconnect(reason: SocketEvent, event?: Event) {
ws.close()
set(false)
clearTimeout(unresponsiveTimeoutId)
clearTimeout(reconnectTimeoutId)
event_listeners.get(reason)?.forEach(listener => listener(event))
reconnectTimeoutId = setTimeout(connect, reconnectTimeoutTime)
}
function connect() {
ws = new WebSocket(socketUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = ev => {
ping()
set(true)
clearTimeout(reconnectTimeoutId)
resubscribeAll()
flushQueuedRequests()
event_listeners.get('open')?.forEach(listener => listener(ev))
}
ws.onmessage = frame => {
resetUnresponsiveCheck()
for (const [correlationId, pending] of pending_requests) {
clearTimeout(pending.timeoutId)
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.onclose = ev => disconnect('close', ev)
}
function unsubscribe<MT>(event_type: MessageFns<MT>, listener: (data: MT) => void) {
const tag = getTagFromMessageType(event_type)
const message_listeners_totag = message_listeners.get(tag)
if (!message_listeners_totag) return
message_listeners_totag?.delete(listener as (data?: unknown) => void)
if (message_listeners_totag.size == 0) {
unsubscribeToMessageFromServer(event_type)
}
}
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() {
clearTimeout(unresponsiveTimeoutId)
unresponsiveTimeoutId = setTimeout(() => disconnect('unresponsive'), reconnectTimeoutTime)
}
function emit<T>(event: MessageFns<T>, data: T) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const type = getNameFromMessageType(event)
const wsm = Message.create() as Record<string, unknown>
wsm[type] = data
send(wsm as Message)
}
function unsubscribeToMessageFromServer<T>(event_type: MessageFns<T>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const unsub_msg = Messages.UnsubscribeNotification.create({
tag: getTagFromMessageType(event_type)
})
send(Message.create({ unsubNotif: unsub_msg }))
}
function subscribeToEvent<T>(event_type: MessageFns<T>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const sub_msg = Messages.SubscribeNotification.create({
tag: getTagFromMessageType(event_type)
})
send(Message.create({ subNotif: sub_msg }))
}
function resubscribeAll() {
for (const tag of message_listeners.keys()) {
const sub_msg = Messages.SubscribeNotification.create({ tag })
send(Message.create({ subNotif: sub_msg }))
}
}
function send(data: Message) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const encoded = encodeMessage(data)
ws.send(encoded)
}
function ping() {
send(Message.create({ pingmsg: {} }))
}
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)
}
queued_requests.clear()
}
return {
subscribe,
emit,
init,
on: <MT>(event_type: MessageFns<MT>, listener: (data: MT) => void): (() => void) => {
const tag = getTagFromMessageType(event_type)
let message_listeners_totag = message_listeners.get(tag)
if (!message_listeners_totag) {
message_listeners_totag = new Set()
message_listeners.set(tag, message_listeners_totag)
subscribeToEvent(event_type)
}
message_listeners_totag.add(listener as (data: unknown) => void)
return () => {
unsubscribe(event_type, listener)
}
},
onEvent: (event_type: SocketEvent, listener: (data: unknown) => void): (() => 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 })
}
})
}
}
}
export const socket = createWebSocket()
+23 -25
View File
@@ -1,35 +1,33 @@
import type { DownloadOTA } from '$lib/types/models';
import { writable } from 'svelte/store';
import { DownloadOTAData, RSSIData } from '$lib/platform_shared/message'
import { writable } from 'svelte/store'
let telemetry_data = {
rssi: {
rssi: 0
},
download_ota: {
status: 'none',
progress: 0,
error: ''
}
};
type telemetry_data_type = {
rssi: RSSIData
download_ota: DownloadOTAData
}
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() {
const { subscribe, set, update } = writable(telemetry_data);
const { subscribe, update } = writable(telemetry_data)
return {
subscribe,
setRSSI: (data: number) => {
update(telemetry_data => ({
...telemetry_data,
rssi: { rssi: data }
}));
setRSSI: (data: RSSIData) => {
update(telemetry_data => {
telemetry_data.rssi = data
return telemetry_data
})
},
setDownloadOTA: (data: DownloadOTA) => {
update(telemetry_data => ({
...telemetry_data,
download_ota: { status: data.status, progress: data.progress, error: data.error }
}));
setDownloadOTA: (data: DownloadOTAData) => {
update(telemetry_data => {
telemetry_data.download_ota = data
return telemetry_data
})
}
};
}
}
export const telemetry = createTelemetry();
export const telemetry = createTelemetry()
+15 -15
View File
@@ -1,17 +1,17 @@
declare module 'three/src/math/MathUtils' {
export function generateUUID(): string;
export function clamp(value: number, min: number, max: number): number;
export function euclideanModulo(n: number, m: number): number;
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number;
export function lerp(x: number, y: number, t: number): number;
export function smoothstep(x: number, min: number, max: number): number;
export function smootherstep(x: number, min: number, max: number): number;
export function randInt(low: number, high: number): number;
export function randFloat(low: number, high: number): number;
export function randFloatSpread(range: number): number;
export function degToRad(degrees: number): number;
export function radToDeg(radians: number): number;
export function isPowerOfTwo(value: number): boolean;
export function ceilPowerOfTwo(value: number): number;
export function floorPowerOfTwo(value: number): number;
export function generateUUID(): string
export function clamp(value: number, min: number, max: number): number
export function euclideanModulo(n: number, m: number): number
export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number
export function lerp(x: number, y: number, t: number): number
export function smoothstep(x: number, min: number, max: number): number
export function smootherstep(x: number, min: number, max: number): number
export function randInt(low: number, high: number): number
export function randFloat(low: number, high: number): number
export function randFloatSpread(range: number): number
export function degToRad(degrees: number): number
export function radToDeg(radians: number): number
export function isPowerOfTwo(value: number): boolean
export function ceilPowerOfTwo(value: number): number
export function floorPowerOfTwo(value: number): number
}
+57 -213
View File
@@ -1,239 +1,83 @@
export enum MessageTopic {
imu = 'imu',
mode = 'mode',
input = 'input',
analytics = 'analytics',
position = 'position',
angles = 'angles',
i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus',
gait = 'walk_gait',
servoState = 'servoState',
servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings',
sonar = 'sonar',
rssi = 'rssi'
imu = 'imu',
imuCalibrate = 'imuCalibrate',
mode = 'mode',
input = 'input',
analytics = 'analytics',
position = 'position',
angles = 'angles',
i2cScan = 'i2cScan',
peripheralSettings = 'peripheralSettings',
otastatus = 'otastatus',
gait = 'walk_gait',
servoState = 'servoState',
servoPWM = 'servoPWM',
WiFiSettings = 'WiFiSettings',
sonar = 'sonar',
rssi = 'rssi'
}
export type vector = { x: number; y: number }
export interface ControllerInput {
left: vector
right: vector
height: number
speed: number
s1: number
}
export type GithubRelease = {
message: string
tag_name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
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
message: string
tag_name: string
assets: Array<{
name: string
browser_download_url: string
}>
}
export type Rssi = {
rssi: number
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 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
rssi: number
ssid: string
}
export type Servo = {
name: string
channel: number
inverted: boolean
angle: number
center_angle: number
name: string
channel: number
inverted: boolean
angle: number
center_angle: number
}
export type ServoConfiguration = {
is_active: boolean
servo_pwm_frequency: number
servo_oscillator_frequency: number
servos: Servo[]
is_active: boolean
servo_pwm_frequency: number
servo_oscillator_frequency: number
servos: Servo[]
}
export interface MDNSServiceQuery {
services: MDNSServiceItem[]
export interface Result {
success: boolean
error?: string
}
export interface MDNSServiceItem {
ip: string
port: number
name: string
export interface DataResult extends Result {
data?: Uint8Array
}
export interface MDNSService {
service: string
protocol: string
port: number
export interface FileInfo {
name: string
size: number
}
export interface MDNSTxtRecord {
key: string
value: string
export interface DirectoryInfo {
name: string
}
export interface MDNSStatus {
started: boolean
hostname: string
instance: string
services: MDNSService[]
global_txt_records: MDNSTxtRecord[]
export interface ListResult extends Result {
files: FileInfo[]
directories: DirectoryInfo[]
}
export interface TransferProgress {
transferId: number
bytesTransferred: number
totalBytes: number
chunksCompleted: number
totalChunks: number
percentage: number
}
export type ProgressCallback = (progress: TransferProgress) => void
+11 -11
View File
@@ -1,14 +1,14 @@
declare module 'uzip' {
interface UZIP {
parse(data: Uint8Array | ArrayBuffer): any;
compress(data: any): Uint8Array | ArrayBuffer;
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
decompress(data: Uint8Array | ArrayBuffer): any;
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer;
encode(data: any): Uint8Array | ArrayBuffer;
decode(data: Uint8Array | ArrayBuffer): any;
}
interface UZIP {
parse(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
compress(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
compressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
decompress(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
decompressRaw(data: Uint8Array | ArrayBuffer): Uint8Array | ArrayBuffer
encode(data: Record<string, Uint8Array>): Uint8Array | ArrayBuffer
decode(data: Uint8Array | ArrayBuffer): Record<string, Uint8Array>
}
const uzip: UZIP;
export default uzip;
const uzip: UZIP
export default uzip
}
+13 -13
View File
@@ -1,15 +1,15 @@
export class throttler {
private _throttlePause: boolean;
constructor() {
this._throttlePause = false;
}
throttle = (callback: Function, time: number) => {
if (this._throttlePause) return;
export class Throttler {
private _throttlePause: boolean
constructor() {
this._throttlePause = false
}
throttle = (callback: () => void, time: number) => {
if (this._throttlePause) return
this._throttlePause = true;
setTimeout(() => {
callback();
this._throttlePause = false;
}, time);
};
this._throttlePause = true
setTimeout(() => {
callback()
this._throttlePause = false
}, time)
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (opacity >= 100) return color;
const alpha = Math.min(Math.max(opacity, 0), 100) / 100;
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`;
};
const color = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
if (opacity >= 100) return color
const alpha = Math.min(Math.max(opacity, 0), 100) / 100
return `${color.replace(/(\/\s*\d+(\.\d+)?\))|\)$/, '')} / ${alpha})`
}
+9 -9
View File
@@ -1,9 +1,9 @@
export * from './result';
export * from './string-utilities';
export * from './svelte-utilities';
export * from './math-utilities';
export * from './buffer-utilities';
export * from './model-utilities';
export * from './position-utilities';
export * from './string-utilities';
export * from './color-utilities';
export * from './result'
export * from './string-utilities'
export * from './svelte-utilities'
export * from './math-utilities'
export * from './buffer-utilities'
export * from './model-utilities'
export * from './string-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)
}
+13 -13
View File
@@ -1,18 +1,18 @@
export const toUint8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number));
let scaled = ((number - min) / (max - min)) * 255;
return Math.round(scaled) & 0xff;
};
number = Math.max(min, Math.min(max, number))
const scaled = ((number - min) / (max - min)) * 255
return Math.round(scaled) & 0xff
}
export const toInt8 = (number: number, min: number, max: number) => {
number = Math.max(min, Math.min(max, number));
let scaled = ((number - min) / (max - min)) * 255 - 128;
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0;
};
number = Math.max(min, Math.min(max, number))
const scaled = ((number - min) / (max - min)) * 255 - 128
return Math.max(-128, Math.min(127, Math.round(scaled))) | 0
}
export const fromInt8 = (int8: number, min: number, max: number) => {
int8 = Math.max(-128, Math.min(127, int8));
const scaled = (int8 + 128) / 255;
const number = scaled * (max - min) + min;
return number;
};
int8 = Math.max(-128, Math.min(127, int8))
const scaled = (int8 + 128) / 255
const number = scaled * (max - min) + min
return number
}
+61 -58
View File
@@ -1,4 +1,4 @@
import { Color, LoaderUtils, Vector3 } from 'three'
import { Color, Vector3 } from 'three'
import URDFLoader, { type URDFRobot } from 'urdf-loader'
import { XacroLoader } from 'xacro-parser'
import { Result } from '$lib/utilities'
@@ -6,88 +6,91 @@ import { currentVariant, jointNames, model } from '$lib/stores'
import uzip from 'uzip'
import { fileService } from '$lib/services'
import { get } from 'svelte/store'
import { resolve } from '$app/paths'
let model_xml: XMLDocument
export const populateModelCache = async () => {
await cacheModelFiles()
const modelRes = await loadModel(get(currentVariant).model)
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME)
model.set(urdf)
} else {
console.error(modelRes.inner, { exception: modelRes.exception })
}
await cacheModelFiles()
const modelRes = await loadModel(get(currentVariant).model)
if (modelRes.isOk()) {
const [urdf, JOINT_NAME] = modelRes.inner
jointNames.set(JOINT_NAME)
model.set(urdf)
} else {
console.error(modelRes.inner, { exception: modelRes.exception })
}
}
export const cacheModelFiles = async () => {
const data = await fetch(get(currentVariant).stl)
const data = await fetch(get(currentVariant).stl)
const files = uzip.parse(await data.arrayBuffer())
const files = uzip.parse(await data.arrayBuffer())
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const url = new URL(path, window.location.href)
fileService?.saveFile(url.toString(), data)
}
for (const [path, data] of Object.entries(files) as [path: string, data: Uint8Array][]) {
const normalizedPath = path.startsWith('/') ? path : '/' + path
const resolvedUrl = `${resolve('/')}${normalizedPath}`
fileService?.saveFile(resolvedUrl, data)
fileService?.saveFile(normalizedPath, data)
}
}
export const loadModel = async (url: string): Promise<Result<[URDFRobot, string[]], string>> => {
const urdfLoader = new URDFLoader()
urdfLoader.workingPath = LoaderUtils.extractUrlBase(url)
const urdfLoader = new URDFLoader()
let xml = url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
let xml =
url.endsWith('.xacro') ? await loadXacro(url) : await fetch(url).then(res => res.text())
if (typeof xml === 'string') {
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
}
return new Promise(resolve => {
model_xml = xml
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
resolve(Result.ok([model, joints]))
} catch (error) {
resolve(Result.err('Failed to load model', error))
if (typeof xml === 'string') {
xml = new window.DOMParser().parseFromString(xml, 'text/xml')
}
})
return new Promise(resolve => {
model_xml = xml
try {
const model = urdfLoader.parse(xml)
setupRobot(model)
const joints = Object.entries(model.joints)
.filter(joint => joint[1].jointType !== 'fixed')
.map(joint => joint[0])
resolve(Result.ok([model, joints]))
} catch (error) {
resolve(Result.err('Failed to load model', error))
}
})
}
const loadXacro = async (url: string): Promise<XMLDocument> =>
new Promise((resolve, reject) => {
new XacroLoader().load(url, resolve, reject)
})
new Promise((resolve, reject) => {
new XacroLoader().load(url, resolve, reject)
})
function setupRobot(robot: URDFRobot) {
robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2
robot.scale.setScalar(10)
robot.traverse(c => (c.castShadow = true))
robot.updateMatrixWorld(true)
robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2
robot.scale.setScalar(10)
robot.traverse(c => (c.castShadow = true))
robot.updateMatrixWorld(true)
}
export function getToeWorldPositions(robot: URDFRobot): Vector3[] {
const toes: Vector3[] = []
robot.traverse(c => {
if (c.name.includes('toe') && !c.name.includes('_link'))
toes.push(c.getWorldPosition(new Vector3()))
})
return toes
const toes: Vector3[] = []
robot.traverse(c => {
if (c.name.includes('toe') && !c.name.includes('_link'))
toes.push(c.getWorldPosition(new Vector3()))
})
return toes
}
export const extractFootColor = () => {
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
const colorAttrStr = colorElem.getAttribute('rgba') as string
const colorStr = colorAttrStr
.split(' ')
.slice(0, 3)
.map(val => Math.floor(+val * 255))
.join(', ')
const colorElem = model_xml.querySelector('material[name=foot_color] > color') as Element
const colorAttrStr = colorElem.getAttribute('rgba') as string
const colorStr = colorAttrStr
.split(' ')
.slice(0, 3)
.map(val => Math.floor(+val * 255))
.join(', ')
return new Color(`rgb(${colorStr})`)
return new Color(`rgb(${colorStr})`)
}
@@ -1,84 +0,0 @@
class SunCalculator {
calculateSunElevation(lat: number = 55, lon: number = 12) {
const now = new Date();
const JD = this.getJulianDate(now);
const solarDec = this.getSolarDeclination(JD);
const solarTime = this.getSolarTime(now, lon);
const hourAngle = (solarTime - 12) * 15;
const elevation = Math.asin(
Math.sin(this.degToRad(lat)) * Math.sin(solarDec) +
Math.cos(this.degToRad(lat)) * Math.cos(solarDec) * Math.cos(this.degToRad(hourAngle))
);
return this.radToDeg(elevation);
}
getJulianDate(date: Date) {
const Y = date.getUTCFullYear();
const M = date.getUTCMonth() + 1;
const D =
date.getUTCDate() +
date.getUTCHours() / 24 +
date.getUTCMinutes() / 1440 +
date.getUTCSeconds() / 86400;
const A = Math.floor((14 - M) / 12);
const Y1 = Y + 4800 - A;
const M1 = M + 12 * A - 3;
return (
D +
Math.floor((153 * M1 + 2) / 5) +
365 * Y1 +
Math.floor(Y1 / 4) -
Math.floor(Y1 / 100) +
Math.floor(Y1 / 400) -
32045
);
}
getSolarDeclination(JulianDate: number) {
const n = JulianDate - 2451545;
const L = (280.46 + 0.9856474 * n) % 360;
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
const lambda = this.degToRad(L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g));
return Math.asin(Math.sin(lambda) * Math.sin(this.degToRad(23.44)));
}
getSolarTime(date: Date, lon: number) {
const EoT = this.getEquationOfTime(date);
const offset = date.getTimezoneOffset() / 60;
const standardMeridian = Math.round(lon / 15) * 15;
const solarTime =
date.getUTCHours() +
(date.getUTCMinutes() + (4 * (standardMeridian - lon) + EoT)) / 60 -
offset;
return (solarTime + 24) % 24;
}
getEquationOfTime(date: Date) {
const JD = this.getJulianDate(date);
const n = JD - 2451545;
const g = this.degToRad((357.528 + 0.9856003 * n) % 360);
const q = this.degToRad((280.46 + 0.9856474 * n) % 360);
return (
4 *
this.radToDeg(
0.000075 +
0.001868 * Math.cos(q) -
0.032077 * Math.sin(g) -
0.014615 * Math.cos(2 * q) -
0.040849 * Math.sin(2 * g)
)
);
}
degToRad(deg: number) {
return deg * (Math.PI / 180);
}
radToDeg(rad: number) {
return rad * (180 / Math.PI);
}
}
export const sunCalculator = new SunCalculator();
+34 -34
View File
@@ -1,42 +1,42 @@
export class Err<T, U> {
#inner: T;
#exception?: U;
#inner: T
#exception?: U
constructor(inner: T, exception?: U) {
this.#inner = inner;
this.#exception = exception;
}
constructor(inner: T, exception?: U) {
this.#inner = inner
this.#exception = exception
}
get inner(): T {
return this.#inner;
}
get inner(): T {
return this.#inner
}
get exception(): U | undefined {
return this.#exception;
}
get exception(): U | undefined {
return this.#exception
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): false {
return false;
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): false {
return false
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): this is Err<T, U> {
return true;
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): this is Err<T, U> {
return true
}
/**
* Create an `Err`
* @param inner
* @returns `Err(inner)`
*/
static new<E, F>(inner: E, exception: F): Err<E, F> {
return new Err<E, F>(inner, exception);
}
/**
* Create an `Err`
* @param inner
* @returns `Err(inner)`
*/
static new<E, F>(inner: E, exception: F): Err<E, F> {
return new Err<E, F>(inner, exception)
}
}
+3 -3
View File
@@ -1,3 +1,3 @@
export * from './err';
export * from './ok';
export * from './result';
export * from './err'
export * from './ok'
export * from './result'
+36 -36
View File
@@ -1,44 +1,44 @@
export class Ok<T> {
#inner: T;
#inner: T
constructor(inner: T) {
this.#inner = inner;
}
constructor(inner: T) {
this.#inner = inner
}
get inner(): T {
return this.#inner;
}
get inner(): T {
return this.#inner
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): this is Ok<T> {
return true;
}
/**
* Type guard for `Ok`
* @returns `true` if `Ok`; `false` if `Err`
*/
isOk(): this is Ok<T> {
return true
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): false {
return false;
}
/**
* Type guard for `Err`
* @returns `true` if `Err`; `false` if `Ok`
*/
isErr(): false {
return false
}
/**
* Create an `Ok`
* @param inner
* @returns `Ok(inner)`
*/
static new<T>(inner: T): Ok<T> {
return new Ok<T>(inner);
}
/**
* Create an `Ok`
* @param inner
* @returns `Ok(inner)`
*/
static new<T>(inner: T): Ok<T> {
return new Ok<T>(inner)
}
/**
* Create an empty `Ok`
* @returns `Ok(void)`
*/
static void(): Ok<void> {
return new Ok(undefined);
}
/**
* Create an empty `Ok`
* @returns `Ok(void)`
*/
static void(): Ok<void> {
return new Ok(undefined)
}
}
+16 -16
View File
@@ -1,20 +1,20 @@
import { Err } from './err';
import { Ok } from './ok';
import { Err } from './err'
import { Ok } from './ok'
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>;
export type Result<T = unknown, E = unknown, F = unknown> = Ok<T> | Err<E, F>
export namespace Result {
/**
* @returns `Ok<T>`
*/
export function ok<T = unknown>(value: T) {
return Ok.new(value);
}
export const Result = {
/**
* @returns `Ok<T>`
*/
ok<T = unknown>(value: T) {
return Ok.new(value)
},
/**
* @returns `Err<E, F>`
*/
export function err<E = unknown, F = unknown>(error: E, exception?: F) {
return Err.new(error, exception);
}
/**
* @returns `Err<E, F>`
*/
err<E = unknown, F = unknown>(error: E, exception?: F) {
return Err.new(error, exception)
}
}
+32 -32
View File
@@ -1,47 +1,47 @@
export const humanFileSize = (size: number): string => {
const units = ['B', 'kB', 'MB', 'GB', 'TB']
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
const units = ['B', 'kB', 'MB', 'GB', 'TB']
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024))
return Number((size / Math.pow(1024, i)).toFixed(2)) * 1 + units[i]
}
export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
export const convertSeconds = (seconds: number) => {
// Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
// Calculate the number of seconds, minutes, hours, and days
let minutes = Math.floor(seconds / 60)
let hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
// Calculate the remaining hours, minutes, and seconds
hours = hours % 24
minutes = minutes % 60
seconds = seconds % 60
// Calculate the remaining hours, minutes, and seconds
hours = hours % 24
minutes = minutes % 60
seconds = seconds % 60
// Create the formatted string
let result = ''
if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' '
}
if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
}
if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
}
result += seconds + ' second' + (seconds > 1 ? 's' : '')
// Create the formatted string
let result = ''
if (days > 0) {
result += days + ' day' + (days > 1 ? 's' : '') + ' '
}
if (hours > 0) {
result += hours + ' hour' + (hours > 1 ? 's' : '') + ' '
}
if (minutes > 0) {
result += minutes + ' minute' + (minutes > 1 ? 's' : '') + ' '
}
result += seconds + ' second' + (seconds > 1 ? 's' : '')
return result
return result
}
export const compareIp = (ip1: string, ip2: string) => {
const ip1Parts = ip1.split('.').map(Number)
const ip2Parts = ip2.split('.').map(Number)
for (let i = 0; i < 4; i++) {
if (ip1Parts[i] !== ip2Parts[i]) {
return ip1Parts[i] > ip2Parts[i] ? 1 : -1
const ip1Parts = ip1.split('.').map(Number)
const ip2Parts = ip2.split('.').map(Number)
for (let i = 0; i < 4; i++) {
if (ip1Parts[i] !== ip2Parts[i]) {
return ip1Parts[i] > ip2Parts[i] ? 1 : -1
}
}
}
return 0
return 0
}
+11 -11
View File
@@ -1,16 +1,16 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { writable } from 'svelte/store'
import { browser } from '$app/environment'
export const persistentStore = <T>(key: string, initialValue: T) => {
const savedValue = browser ? localStorage.getItem(key) : null;
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue;
const store = writable<T>();
const savedValue = browser ? localStorage.getItem(key) : null
const data: T = savedValue !== null ? JSON.parse(savedValue) : initialValue
const store = writable<T>()
store.subscribe(value => {
if (browser) localStorage.setItem(key, JSON.stringify(value));
});
store.subscribe(value => {
if (browser) localStorage.setItem(key, JSON.stringify(value))
})
store.set(data);
store.set(data)
return store;
};
return store
}
+4 -3
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { page } from '$app/state'
import { page } from '$app/state'
import { resolve } from '$app/paths'
</script>
<div class="flex justify-center items-center w-full h-full">
<h1>{page.status} {page.error?.message}</h1>
<span>Go to <a class="btn btn-primary" href="/">Home page</a></span>
<h1>{page.status} {page.error?.message}</h1>
<span>Go to <a class="btn btn-primary" href={resolve('/')}>Home page</a></span>
</div>
+105 -101
View File
@@ -1,129 +1,133 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { page } from '$app/state'
import { Modals, modals } from 'svelte-modals'
import Toast from '$lib/components/toasts/Toast.svelte'
import { notifications } from '$lib/components/toasts/notifications'
import { fade } from 'svelte/transition'
import '../app.css'
import Menu from '../lib/components/menu/Menu.svelte'
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import {
telemetry,
analytics,
ModesEnum,
kinematicData,
mode,
outControllerData,
servoAngles,
servoAnglesOut,
socket,
location,
useFeatureFlags,
walkGait
} from '$lib/stores'
import { type Analytics, type DownloadOTA } from '$lib/types/models'
import { MessageTopic } from '$lib/types/models'
import { onDestroy, onMount } from 'svelte'
import { page } from '$app/state'
import { Modals, modals } from 'svelte-modals'
import Toast from '$lib/components/toasts/Toast.svelte'
import { notifications } from '$lib/components/toasts/notifications'
import { fade } from 'svelte/transition'
import '../app.css'
import Menu from '../lib/components/menu/Menu.svelte'
import Statusbar from '../lib/components/statusbar/statusbar.svelte'
import {
telemetry,
kinematicData,
mode,
input,
servoAngles,
servoAnglesOut,
socket,
apiLocation,
useFeatureFlags,
walkGait
} from '$lib/stores'
import {
AnglesData,
DownloadOTAData,
ControllerData,
KinematicData,
ModeData,
RSSIData,
SonarData,
WalkGaitData
} from '$lib/platform_shared/message'
import { Throttler } from '$lib/utilities'
interface Props {
children?: import('svelte').Snippet
}
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props()
let { children }: Props = $props()
const features = useFeatureFlags()
const features = useFeatureFlags()
const throttler = new Throttler()
onMount(async () => {
const ws = $location ? $location : window.location.host
socket.init(`ws://${ws}/api/ws/events`)
onMount(async () => {
const ws = $apiLocation ? $apiLocation : window.location.host
socket.init(`ws://${ws}/api/ws`)
addEventListeners()
addEventListeners()
outControllerData.subscribe(data => socket.sendEvent(MessageTopic.input, data))
mode.subscribe(data => socket.sendEvent(MessageTopic.mode, data))
walkGait.subscribe(data => socket.sendEvent(MessageTopic.gait, data))
servoAnglesOut.subscribe(data => socket.sendEvent(MessageTopic.angles, data))
kinematicData.subscribe(data => socket.sendEvent(MessageTopic.position, data))
})
onDestroy(() => {
removeEventListeners()
})
const addEventListeners = () => {
socket.on('open', handleOpen)
socket.on('close', handleClose)
socket.on('error', handleError)
socket.on(MessageTopic.rssi, handleNetworkStatus)
socket.on(MessageTopic.mode, (data: ModesEnum) => mode.set(data))
socket.on(MessageTopic.analytics, handleAnalytics)
socket.on(MessageTopic.angles, (angles: number[]) => {
if (angles.length) servoAngles.set(angles)
input.subscribe(data => throttler.throttle(() => socket.emit(ControllerData, data), 100))
mode.subscribe(data => socket.emit(ModeData, data))
walkGait.subscribe(data => socket.emit(WalkGaitData, data))
servoAnglesOut.subscribe(data =>
throttler.throttle(() => socket.emit(AnglesData, data), 100)
)
kinematicData.subscribe(data => socket.emit(KinematicData, data))
})
features.subscribe(data => {
if (data?.download_firmware) socket.on(MessageTopic.otastatus, handleOAT)
if (data?.sonar) socket.on(MessageTopic.sonar, data => console.log(data))
onDestroy(() => {
removeEventListeners()
})
}
const removeEventListeners = () => {
socket.off(MessageTopic.analytics, handleAnalytics)
socket.off('open', handleOpen)
socket.off('close', handleClose)
socket.off(MessageTopic.rssi, handleNetworkStatus)
socket.off(MessageTopic.otastatus, handleOAT)
}
const eventListeners: (() => void)[] = []
const addEventListeners = () => {
eventListeners.push(
socket.onEvent('open', handleOpen),
socket.onEvent('close', handleClose),
socket.onEvent('error', handleError),
socket.on(RSSIData, data => telemetry.setRSSI(data)),
socket.on(ModeData, data => mode.set(data)),
socket.on(AnglesData, data => {
servoAngles.set(data)
})
)
features.subscribe(data => {
if (data?.download_firmware)
eventListeners.push(
socket.on(DownloadOTAData, data => telemetry.setDownloadOTA(data))
)
if (data?.sonar) eventListeners.push(socket.on(SonarData, data => console.log(data)))
})
}
const handleOpen = () => {
notifications.success('Connection to device established', 5000)
}
const removeEventListeners = () => {
eventListeners.forEach(offFunction => offFunction())
}
const handleClose = () => {
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(0)
}
const handleOpen = () => notifications.success('Connection to device established', 5000)
const handleError = (data: any) => console.error(data)
const handleClose = () => {
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(RSSIData.create({ rssi: 0 }))
}
const handleAnalytics = (data: Analytics) => analytics.addData(data)
const handleError = (data: unknown) => console.error(data)
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data)
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data)
let menuOpen = $state(false)
let menuOpen = $state(false)
</script>
<svelte:head>
<title>{page.data.title}</title>
<title>{page.data.title}</title>
</svelte:head>
<div class="drawer">
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<Statusbar />
<input id="main-menu" type="checkbox" class="drawer-toggle" bind:checked={menuOpen} />
<div class="drawer-content flex flex-col">
<!-- Status bar content here -->
<Statusbar />
<!-- Main page content here -->
{@render children?.()}
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay"></label>
<Menu menuClicked={() => (menuOpen = false)} />
</div>
<!-- Main page content here -->
{@render children?.()}
</div>
<!-- Side Navigation -->
<div class="drawer-side z-30 shadow-lg">
<label for="main-menu" class="drawer-overlay"></label>
<Menu menuClicked={() => (menuOpen = false)} />
</div>
</div>
<Modals>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#snippet backdrop()}
<div
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
transition:fade
onclick={modals.closeAll}>
</div>
{/snippet}
{#snippet backdrop()}
<div
class="fixed inset-0 z-40 max-h-full max-w-full bg-black/20 backdrop-blur-sm"
transition:fade
onclick={modals.closeAll}
onkeydown={e => e.key === 'Escape' && modals.closeAll()}
role="button"
tabindex="0"
></div>
{/snippet}
</Modals>
<Toast />
+28 -14
View File
@@ -2,21 +2,35 @@ export const prerender = true
export const ssr = false
const registerFetchIntercept = async () => {
const { fetch: originalFetch } = window
const fileService = (await import('$lib/services/file-service')).default
window.fetch = async (resource, config) => {
const url = resource instanceof Request ? resource.url : resource.toString()
const file = await fileService?.getFile(url)
return file?.isOk() ? new Response(file.inner) : originalFetch(resource, config)
}
const { fetch: originalFetch } = window
const fileService = (await import('$lib/services/file-service')).default
window.fetch = async (resource, config) => {
const url = resource instanceof Request ? resource.url : resource.toString()
let file = await fileService?.getFile(url)
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
if (url.startsWith('http')) {
try {
const urlObj = new URL(url)
const pathOnly = urlObj.pathname
file = await fileService?.getFile(pathOnly)
if (file?.isOk() && file.inner) return new Response(new Uint8Array(file.inner))
} catch {
console.error('Failed to get file for ', url)
}
}
return originalFetch(resource, config)
}
}
export const load = async () => {
await registerFetchIntercept()
return {
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk'
}
await registerFetchIntercept()
return {
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk'
}
}
+22 -19
View File
@@ -1,27 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation'
import Visualization from '$lib/components/Visualization.svelte'
import { socket } from '$lib/stores'
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import Visualization from '$lib/components/Visualization.svelte'
import { socket } from '$lib/stores'
import { onMount } from 'svelte'
import { resolve } from '$app/paths'
onMount(() => {
socket.subscribe(isConnected => {
if (isConnected) {
goto('/controller')
}
onMount(() => {
socket.subscribe(isConnected => {
if (isConnected) {
goto(resolve('/controller'))
}
})
})
})
</script>
<div class="hero bg-base-100 h-screen">
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
<div class="w-64 h-64">
<Visualization sky={false} orbit panel={false} ground={false} />
<div class="card md:card-side bg-base-200 shadow-2xl flex justify-center items-center">
<div class="w-64 h-64">
<Visualization defaultColor={null} orbit panel={false} ground={false} />
</div>
<div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
<p class="py-6 text-center"></p>
<a class="btn btn-primary" href={resolve($socket ? '/controller' : '/connection')}>
Add Robot Dog
</a>
</div>
</div>
<div class="card-body w-80">
<h2 class="card-title text-center text-2xl">Begin you journey</h2>
<p class="py-6 text-center"></p>
<a class="btn btn-primary" href={$socket ? '/controller' : '/connection'}> Add Robot Dog </a>
</div>
</div>
</div>

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