366 Commits

Author SHA1 Message Date
Niklas Jensen 4dd7a5dce9 add icm to imt feature list 2026-01-31 14:41:19 +01:00
Niklas Jensen c7e6376afe Fixed build 2026-01-31 14:41:19 +01:00
Niklas Jensen bc8145aca9 Initialize default DMP for ICM20948 2026-01-31 14:41:19 +01:00
Niklas Jensen 90f561a62b Fix calling of IMU/MAG data when no new data available 2026-01-31 14:41:19 +01:00
Niklas Jensen 32966a3430 Make sure that ICM20948 is only updated once per loop when needed 2026-01-31 14:41:19 +01:00
Niklas Jensen ee571ed0ff Removed garbage code 2026-01-31 14:41:19 +01:00
Niklas Jensen 135ab88e25 Added peripheral logging - Removed main logging 2026-01-31 14:41:19 +01:00
Niklas Jensen cefeb11e10 Timing expansion: measurements per function (CLAUDE) 2026-01-31 14:41:19 +01:00
Niklas Jensen e5e8a94acd Fixed double begin on ICM20948 2026-01-31 14:40:47 +01:00
Niklas Jensen 6f3e254341 Better definitions for SPI port 2026-01-31 14:40:47 +01:00
Niklas Jensen 7221418378 Enable SPI for icm20948 2026-01-31 14:40:46 +01:00
Niklas Jensen b8038e402b Fixed frequency of I2C to work with servo controller 2026-01-31 14:40:46 +01:00
Niklas Jensen 2037b654a6 Fix IMU ang MAG, added MAG chart to svelte 2026-01-31 14:40:46 +01:00
Niklas Jensen 941a2b10f7 Early stages of magnetometer from ICM20948 2026-01-31 14:40:46 +01:00
Niklas Jensen fcf058921a Add void pointer for initializing sensors 2026-01-31 14:40:46 +01:00
Niklas Jensen 0047810098 Ignore weird success flag on IMU update 2026-01-31 14:40:46 +01:00
Niklas Jensen 326decdae3 Added PAJ7620U2 defs, set mag for ICM20948 temp 2026-01-31 14:40:46 +01:00
Niklas Jensen ebbe1f9ca4 Fix USE_ICM20948 checks in peripherals.cpp 2026-01-31 14:40:46 +01:00
Niklas Jensen 42cce24c11 Added ICM20948 support 2026-01-31 14:40:46 +01:00
Niklas Jensen 48829dafaa Moved all filenames to filesystem file 2026-01-31 14:40:45 +01:00
Niklas Jensen 2536410ee3 Unused and untested peripheral endpoint updated to protobufs 2026-01-31 14:40:31 +01:00
Niklas Jensen 41da5163bc Converted servocontroller to protobufs + persistance defaults 2026-01-31 14:40:22 +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
Rune Harlyk 26c36b8302 🎨 Makes gesture sensor more readable and motion take last gesture 2025-09-10 15:16:00 +02:00
Rune Harlyk bfc259e660 Adds gesture controls 2025-09-10 15:16:00 +02:00
Rune Harlyk 6368bf9213 🎨 Makes use of msg type for sensors 2025-09-10 11:15:44 +02:00
Rune Harlyk cd802f1c22 Makes fsm states by time aware 2025-09-08 22:39:53 +02:00
Rune Harlyk 59bb1d9579 ️ Improves imu speed by making it non blocking and run faster 2025-09-08 22:37:57 +02:00
Rune Harlyk ae98ba76f7 Makes stand imu compensating 2025-09-06 21:02:28 +02:00
Rune Harlyk bd8c8fd988 🐛 Fixes imu handling 2025-09-06 19:55:57 +02:00
Rune Harlyk 7de5a1aa7c 🎨 Lerp gait params to target 2025-09-05 15:22:47 +02:00
Rune Harlyk a3e4fdd8a5 🎨 Moves kinematics config to kinematics file 2025-09-05 14:55:02 +02:00
Rune Harlyk f82fa051f2 🎨 Renames states folder 2025-09-04 23:33:45 +02:00
Rune Harlyk b66ddc3e81 Introduces motion as a state machine 2025-09-04 23:33:45 +02:00
Rune Harlyk c85ac41ebc 🐛 Makes step height dynamic 2025-09-04 21:03:09 +02:00
Rune Harlyk 78d01533f4 Makes body rotation controllable 2025-09-04 19:31:45 +02:00
Rune Harlyk 18d4d66758 Makes robot stand compensate imu 2025-09-04 19:27:48 +02:00
Rune Harlyk 1b9dc9bb9e Makes motion use target position for body state 2025-09-04 19:27:17 +02:00
Rune Harlyk 767d1157df Makes kinematics params be based on config 2025-09-04 19:08:54 +02:00
Rune Harlyk 1799889712 Introduces kinmatics config to sync mapping between variants 2025-09-04 18:02:38 +02:00
Rune Harlyk 0b5d7b1534 Fixes gait into bezier 2025-09-04 17:33:25 +02:00
Rune Harlyk 10b78e6919 🎨 Smoother crawl body shift 2025-09-04 17:33:25 +02:00
Rune Harlyk 3fd72d081e 🎨 Correct behavoir 2025-09-04 17:33:25 +02:00
Rune Harlyk 1f3a465d3e 🎨 Adds speed factor to frontend 2025-09-04 17:33:25 +02:00
Rune Harlyk cddb6023e7 🎨 Better base walking speed 2025-09-04 17:33:25 +02:00
Rune Harlyk 2f46484e0a 🎨 Simplifies gait 2025-09-04 17:33:25 +02:00
Rune Harlyk 4fcaf5d77d 🐛 Try to handle body shifting 2025-09-04 17:33:25 +02:00
Rune Harlyk ea8ddb43ef 🎨 Adds speed factor between gaits 2025-09-04 17:33:25 +02:00
Rune Harlyk 774c546487 🎨 Cleanup crawl 2025-09-04 17:33:25 +02:00
Rune Harlyk 6f46c1f598 🎨 Renames kinematics config 2025-09-04 17:33:25 +02:00
Rune Harlyk bc810ee2dd 🎨 Adds defaults to notification service 2025-09-04 17:33:25 +02:00
Rune Harlyk 54a0419770 🎨 Cleans up gait handling code 2025-09-04 17:33:25 +02:00
Rune Harlyk d7a6bffe0a 🎨 Update the rotation command handling 2025-09-01 22:53:14 +02:00
Rune Harlyk df087decdb 🎨 Renames topics 2025-09-01 18:48:27 +02:00
Rune Harlyk 527764b0b5 🐛 Expands number of endpoints 2025-09-01 18:43:12 +02:00
Rune Harlyk 8c97c68d11 🚩 Add feature flag for spot pico 2025-09-01 18:42:51 +02:00
Rune Harlyk e5bf10cdb0 🎨 Updates and simplifies command handling 2025-09-01 18:41:59 +02:00
Rune Harlyk de3912ff10 Adds kinematics for spot pico 2025-08-22 12:31:22 +02:00
Rune Harlyk 251a791876 Enables better zoom for viz 2025-08-21 23:13:50 +02:00
Rune Harlyk e36365ead6 Adds gif of short walk 2025-08-11 14:40:49 +02:00
Rune Harlyk cb5c095888 🐛 Removes camera endpoint using feature flag 2025-08-03 15:53:49 +02:00
Rune Harlyk 281fa32c89 🐛 Fixes the relative paths 2025-08-02 16:43:45 +02:00
Rune Harlyk d899701195 Simplifies frontend test 2025-07-16 21:58:39 +02:00
Rune Harlyk 7061166fcd 🎨 Matches command mapping in frontend 2025-07-16 21:47:24 +02:00
Rune Harlyk 36b39d41ba 🎨 Replace magic number for stand_frac 2025-07-16 21:44:55 +02:00
Rune Harlyk 7d0a7861ea 🎨 Formats extensions.json 2025-07-16 20:41:28 +02:00
Rune Harlyk bf8c9bce95 📝 Updates readme 2025-07-16 20:40:34 +02:00
Rune Harlyk 9c984d3215 🎨 Inlines cors wildcard 2025-07-16 20:33:12 +02:00
Rune Harlyk 43e76770a8 ️ Removes unnecessary lerp 2025-07-16 20:32:46 +02:00
Rune Harlyk 6e10eabd9f 🔥 Cleans up peripherals service 2025-07-16 20:32:19 +02:00
Rune Harlyk 922a4e3665 🔥 Removes certs 2025-07-16 20:27:33 +02:00
Rune Harlyk 5e162ffb71 ️ Adds build flags for speed and gc 2025-07-16 20:26:21 +02:00
Rune Harlyk f21ce92d43 🐛 Excludes models files for other variants when building 2025-07-12 12:43:07 +02:00
Rune Harlyk 98f3fc674b Makes socket messages event typed 2025-07-11 18:59:07 +02:00
Rune Harlyk c5901c65b3 Adds yertle model visulization 2025-07-11 15:16:47 +02:00
Rune Harlyk 2eab893dd7 🚩 Expands feature flag handling with persistence 2025-07-11 15:16:47 +02:00
Rune Harlyk a3be035f98 🚚 Moves firmware to src and include 2025-07-11 12:16:23 +02:00
Rune Harlyk 743aa073b7 🚀 Makes deploy action run 2025-07-10 23:18:15 +02:00
Rune Harlyk a3de13c619 🔧 Makes default visualization be spot micro 2025-07-10 22:32:27 +02:00
Rune Harlyk 90be771211 🚀 Deploys app 2025-07-10 22:28:05 +02:00
Rune Harlyk 7d79ec39ab Fixes more linter errors 2025-07-10 21:54:38 +02:00
Rune Harlyk 211ff7205b 🔧 Adds env with default variables 2025-07-10 21:54:38 +02:00
Rune Harlyk d0aa3b7b42 💄 Updates colors for metrics chart 2025-07-10 21:54:38 +02:00
Rune Harlyk d529eaa201 Fixes build warning and errors 2025-07-10 21:54:38 +02:00
Rune Harlyk c8ee64d7f4 🐛 Fixes event socket binary serialization buffer length 2025-07-10 20:44:04 +02:00
Rune Harlyk ec4c3fd98e Changes mgspack dependency 2025-07-10 19:04:39 +02:00
Rune Harlyk 0cc372cd36 🐛 Fixes some linting errors 2025-07-10 19:04:39 +02:00
Rune Harlyk 9be405a89d 🐛 Maps frontend gait params same as backend 2025-07-10 19:04:39 +02:00
Rune Harlyk e3cfe89e19 ♻️ Replaces JsonObject with JsonVariant 2025-07-10 19:04:39 +02:00
Rune Harlyk 144b99c180 🔥 Removes debug logging 2025-07-10 19:04:39 +02:00
Rune Harlyk c788e118e3 ️ Adds O3 build flag 2025-07-10 19:04:39 +02:00
Rune Harlyk aae16335b3 ♻️ Centralizes socket serialization 2025-07-10 19:04:39 +02:00
Rune Harlyk a43c250ed1 Adds msgPack and update message protocol 2025-07-10 19:04:39 +02:00
362 changed files with 232195 additions and 14168 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
env:
BASE_PATH: /SpotMicroESP32-Leika
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
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
- name: Setup Pages
uses: actions/configure-pages@v4
with:
static_site_generator: "sveltekit"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: app/build/
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+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
+4 -1
View File
@@ -2,8 +2,11 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
__pycache__/
*.py[cod]
*$py.class
.pio
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
+4 -4
View File
@@ -2,10 +2,10 @@
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"platformio.platformio-ide",
"svelte.svelte-vscode"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
+1
View File
@@ -0,0 +1 @@
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
View File
@@ -3,7 +3,6 @@ node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
+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 -65
View File
@@ -1,67 +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": {
"@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",
"jwt-decode": "^4.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",
"@types/msgpack-lite": "^0.1.11",
"msgpack-lite": "^0.1.26"
},
"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
+2138 -2206
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
+9 -17
View File
@@ -1,26 +1,18 @@
<script lang="ts">
import { focusTrap } from 'svelte-focus-trap';
import { fly } from 'svelte/transition';
import { Check } from './icons';
import { exitBeforeEnter } from 'svelte-modals';
// provided by <Modals />
interface Props {
isOpen: boolean;
title: string;
message: string;
onDismiss: any;
dismiss?: any;
}
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,
dismiss = { label: 'Dismiss', icon: Check }
}: Props = $props();
labels = {
dismiss: { label: 'Dismiss', icon: Check }
}
}: ModalProps = $props()
</script>
{#if isOpen}
@@ -43,7 +35,7 @@
class="btn btn-warning text-warning-content inline-flex items-center"
onclick={onDismiss}
>
<dismiss.icon class="mr-2 h-5 w-5" /><span>{dismiss.label}</span>
<labels.dismiss.icon class="mr-2 h-5 w-5" /><span>{labels.dismiss.label}</span>
</button>
</div>
</div>
@@ -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 = $state()
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>
+358 -324
View File
@@ -1,337 +1,371 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
BufferGeometry,
Line,
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
Object3D,
SphereGeometry,
Vector3,
type NormalBufferAttributes,
type Object3DEventMap
} from 'three'
import {
ModesEnum,
kinematicData,
mode,
model,
outControllerData,
servoAnglesOut,
servoAngles,
mpu,
jointNames
} 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 Kinematic, { type body_state_t } from '$lib/kinematic'
import {
BezierState,
CalibrationState,
EightPhaseWalkState,
FourPhaseWalkState,
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 = $state()
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 = new Kinematic()
let planners = {
[ModesEnum.Deactivated]: new IdleState(),
[ModesEnum.Idle]: new IdleState(),
[ModesEnum.Calibration]: new CalibrationState(),
[ModesEnum.Rest]: new RestState(),
[ModesEnum.Stand]: new StandState(),
[ModesEnum.Crawl]: new EightPhaseWalkState(),
[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)
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(8, 30, 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 = {
stop: controlData[0],
lx: controlData[1],
ly: controlData[2],
rx: controlData[3],
ry: controlData[4],
h: controlData[5],
s: controlData[6],
s1: controlData[7]
}
body_state.ym = ((data.h + 127) * 0.35) / 100
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 cosHead = Math.cos(headingYaw)
const sinHead = Math.sin(headingYaw)
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,37 +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 -169
View File
@@ -1,189 +1,186 @@
<script lang="ts">
import { page } from '$app/state'
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 appEnv from 'app-env'
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'
let menuItems = $state<menuItem[]>([])
type menuItem = {
title: string
icon: Component
href?: string
feature: boolean
active?: boolean
submenu?: menuItem[]
}
$effect(() => {
menuItems = [
{
title: 'Connection',
icon: WiFi,
href: '/connection',
feature: !appEnv.VITE_USE_HOST_NAME
},
{
title: 'Controller',
icon: MdiController,
href: '/controller',
feature: true
},
{
title: 'Peripherals',
icon: Devices,
feature: true,
submenu: [
{
title: 'I2C',
icon: Connection,
href: '/peripherals/i2c',
feature: true
},
{
title: 'Camera',
icon: Camera,
href: '/peripherals/camera',
feature: $features.camera
},
{
title: 'Servo',
icon: MotorOutline,
href: '/peripherals/servo',
feature: true
},
{
title: 'IMU',
icon: Rotate3d,
href: '/peripherals/imu',
feature: $features.imu || $features.mag || $features.bmp
}
]
},
{
title: 'WiFi',
icon: WiFi,
feature: true,
submenu: [
{
title: 'WiFi Station',
icon: Router,
href: '/wifi/sta',
feature: true
},
{
title: 'Access Point',
icon: AP,
href: '/wifi/ap',
feature: true
},
{
title: 'mDNS',
icon: DNS,
href: '/wifi/mdns',
feature: true
}
]
},
{
title: 'System',
icon: Settings,
feature: true,
submenu: [
{
title: 'System Status',
icon: Health,
href: '/system/status',
feature: true
},
{
title: 'File System',
icon: Folder,
href: '/system/filesystem',
feature: true
},
{
title: 'System Metrics',
icon: Metrics,
href: '/system/metrics',
feature: true
},
{
title: 'Firmware Update',
icon: Update,
href: '/system/update',
feature: $features.ota || $features.upload_firmware || $features.download_firmware
}
]
}
] as menuItem[]
})
function withBase(path: string) {
return `${resolve('/')}${path.startsWith('/') ? path.slice(1) : path}`
}
const { menuClicked } = $props()
const { menuClicked } = $props()
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 activeTitle = $derived(page.data.title)
$effect(() => {
setActiveMenuItem(page.data.title)
})
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 }))
)
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,80 +1,80 @@
<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;
update?: boolean
}
let { update = $bindable(false) }: Props = $props();
let { update = $bindable(false) }: Props = $props()
let firmwareVersion: string = $state('');
let firmwareDownloadLink: string = $state('');
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;
console.warn('Error: Could not find releases in the repository')
return
}
if (result.isErr()) {
console.error('Error:', result.inner);
return;
console.error('Error:', result.inner)
return
}
const results = result.inner;
update = false;
firmwareVersion = '';
const results = result.inner
update = false
firmwareVersion = ''
if (compareVersions(results.tag_name, $features.firmware_version) === 1) {
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)
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);
update = true
firmwareVersion = results.tag_name
firmwareDownloadLink = results.assets[i].browser_download_url
notifications.info('Firmware update available.', 5000)
}
}
}
}
async function postGithubDownload(url: string) {
const result = await api.post('/api/downloadUpdate', { download_url: url });
const result = await api.post('/api/downloadUpdate', { download_url: url })
if (result.isErr()) {
console.error('Error:', result.inner);
return;
console.error('Error:', result.inner)
return
}
}
onMount(async () => {
if ($features.download_firmware) {
await getGithubAPI();
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000); // once per hour
await getGithubAPI()
setInterval(async () => await getGithubAPI(), 60 * 60 * 1000) // once per hour
}
});
})
function confirmGithubUpdate(url: string) {
modals.open(ConfirmDialog, {
@@ -85,12 +85,12 @@
confirm: { label: 'Update', icon: CloudDown }
},
onConfirm: () => {
postGithubDownload(url);
postGithubDownload(url)
modals.open(GithubUpdateDialog, {
onConfirm: () => modals.closeAll()
});
})
}
});
})
}
</script>
@@ -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>
+30 -30
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 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) => send(msg, 'error', timeout),
warning: (msg: string, timeout: number) => send(msg, 'warning', timeout),
info: (msg: string, timeout: number) => send(msg, 'info', timeout),
success: (msg: string, timeout: number) => 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();
export const notifications = createNotificationStore()
@@ -1,98 +1,97 @@
<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 = $state();
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,
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)
},
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) }
}
}
}
});
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)
},
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);
}, 500)
})
</script>
<div class="w-full h-full overflow-x-auto">
<div
class="flex w-full flex-col space-y-1 h-60"
@@ -100,4 +99,4 @@
>
<canvas bind:this={chartElement}></canvas>
</div>
</div>
</div>
@@ -1,12 +1,12 @@
<script lang="ts">
interface Props {
options?: string[];
selectedOption?: string;
change: () => void;
[key: string]: any;
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
@@ -14,7 +14,7 @@
{...rest}
class="select select-bordered select-sm lg:select-md max-w-xs {rest.class || ''}"
>
{#each options as option}
{#each options as option (option)}
<option value={option}>{option}</option>
{/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()
+427 -378
View File
@@ -1,447 +1,496 @@
import { get } from 'svelte/store'
import type { body_state_t } from './kinematic'
import Kinematic from './kinematic'
import { fromInt8 } from './utilities'
const { sin } = Math
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 {
stop: number
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 new Kinematic().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 / 128 + 1) / 2,
step_x: Math.floor(fromInt8(command.ly, -1, 1) * 10) / 10,
step_z: -(Math.floor(fromInt8(command.lx, -1, 1) * 10) / 10),
step_velocity: command.s / 128 + 1,
step_angle: command.rx / 128,
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'
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
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'
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
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, dt: number = 0.02) {
body_state.omega = 0
body_state.phi = command.rx / 8
body_state.psi = command.ry / 8
body_state.xm = command.ly / 2 / 100
body_state.zm = command.lx / 2 / 100
body_state.feet = this.default_feet_pos
return body_state
}
}
abstract class PhaseGaitState extends GaitState {
protected tick = 0
protected phase = 0
protected phase_time = 0
protected abstract num_phases: number
protected abstract phase_speed_factor: number
protected abstract swing_stand_ratio: number
protected contact_phases!: number[][]
protected shifts!: number[][]
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
super.step(body_state, command, dt)
this.update_phase()
this.update_body_position()
this.update_feet_positions()
return this.body_state
}
update_phase() {
this.phase_time += this.dt * this.phase_speed_factor * this.gait_state.step_velocity
if (this.phase_time >= 1) {
this.phase += 1
if (this.phase == this.num_phases) this.phase = 0
this.phase_time = 0
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
}
}
update_body_position() {
if (this.num_phases === 4) return
const shift = this.shifts[Math.floor(this.phase / 2)]
this.body_state.xm += (shift[0] - this.body_state.xm) * this.dt * 4
this.body_state.zm += (shift[2] - this.body_state.zm) * this.dt * 4
}
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[] {
const contact = this.contact_phases[index][this.phase]
return contact ? this.stand(index) : this.swing(index)
}
stand(index: number): number[] {
const delta_pos = [
-this.gait_state.step_x * this.dt * this.swing_stand_ratio,
0,
-this.gait_state.step_z * this.dt * this.swing_stand_ratio
]
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]
this.body_state.feet[index][1] = this.default_feet_pos[index][1]
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2]
return this.body_state.feet[index]
}
swing(index: number): number[] {
const delta_pos = [this.gait_state.step_x * this.dt, 0, this.gait_state.step_z * this.dt]
if (this.gait_state.step_x == 0) {
delta_pos[0] =
(this.default_feet_pos[index][0] - this.body_state.feet[index][0]) * this.dt * 8
}
if (this.gait_state.step_z == 0) {
delta_pos[2] =
(this.default_feet_pos[index][2] - this.body_state.feet[index][2]) * this.dt * 8
}
this.body_state.feet[index][0] = this.body_state.feet[index][0] + delta_pos[0]
this.body_state.feet[index][1] =
this.default_feet_pos[index][1] + sin(this.phase_time * Math.PI) * this.gait_state.step_height
this.body_state.feet[index][2] = this.body_state.feet[index][2] + delta_pos[2]
return this.body_state.feet[index]
}
}
export class FourPhaseWalkState extends PhaseGaitState {
protected name = 'Four phase walk'
protected num_phases = 4
protected phase_speed_factor = 6
protected contact_phases = [
[1, 0, 1, 1],
[1, 1, 1, 0],
[1, 1, 1, 0],
[1, 0, 1, 1]
]
protected swing_stand_ratio = 1 / (this.num_phases - 1)
begin() {
super.begin()
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt)
}
}
export class EightPhaseWalkState extends PhaseGaitState {
protected name = 'Eight phase walk'
protected num_phases = 8
protected phase_speed_factor = 4
protected contact_phases = [
[1, 0, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 0, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 0, 1, 1, 1, 1]
]
protected shifts = [
[-0.05, 0, -0.2],
[0.3, 0, 0.2],
[-0.05, 0, 0.2],
[0.3, 0, -0.2]
]
protected swing_stand_ratio = 1 / (this.num_phases - 1)
begin() {
super.begin()
}
end() {
super.end()
}
step(body_state: body_state_t, command: ControllerCommand, dt: number = 0.02) {
return super.step(body_state, command, dt)
}
}
export class BezierState extends GaitState {
protected name = 'Bezier'
protected phase = 0
protected phase_num = 0
protected step_length: number = 0
offset = [0, 0.5, 0.5, 0]
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]
begin() {
super.begin()
}
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
end() {
super.end()
}
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 }
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
constructor() {
super()
this.set_mode(this.mode)
}
this.update_phase()
this.update_feet_positions()
return this.body_state
}
update_phase() {
this.phase += this.dt * this.gait_state.step_velocity * 2
if (this.phase >= 1) {
this.phase_num += 1
this.phase_num %= 2
this.phase = 0
begin() {
super.begin()
}
}
update_feet_positions() {
for (let i = 0; i < 4; i++) {
this.body_state.feet[i] = this.update_foot_position(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]
}
}
}
update_foot_position(index: number): number[] {
let phase = this.phase + this.offset[index]
if (phase >= 1) {
phase -= 1
end() {
super.end()
}
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 <= 0.75 ?
this.stand_controller(index, phase / 0.75)
: this.swing_controller(index, (phase - 0.75) / (1 - 0.75))
}
stand_controller(index: number, phase: number) {
let depth = this.gait_state.step_depth
return this.controller(index, phase, stance_curve, depth)
}
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
}
swing_controller(index: number, phase: number) {
let height = this.gait_state.step_height
return this.controller(index, phase, bezier_curve, height)
}
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
}
}
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)
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
length = this.gait_state.step_angle * 2
angle = yawArc(this.default_feet_pos[index], this.body_state.feet[index])
if (this.mode !== WalkGaits.CRAWL) return
const delta_rot = controller(length, angle, ...args, phase)
const { stance, swing, next_swing, time_to_lift } = this.get_leg_states()
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
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
return 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]
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
}
} 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
}
+156 -103
View File
@@ -1,23 +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
}
const { cos, sin, atan2, acos, sqrt, max, min } = Math
@@ -25,107 +40,145 @@ const { cos, sin, atan2, acos, sqrt, max, min } = Math
const DEG2RAD = 0.017453292519943
export default class Kinematic {
l1: number
l2: number
l3: number
l4: 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() {
this.l1 = 60.5 / 100
this.l2 = 10 / 100
this.l3 = 111.7 / 100
this.l4 = 118.5 / 100
this.L = 207.5 / 100
this.W = 78 / 100
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.l1 : this.l1)]
})
}
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.l1 * this.l1))
const G = F - this.l2
const H = sqrt(G * G + z * z)
const t1 = -atan2(y, x) - atan2(F, -this.l1)
const D = (H * H - this.l3 * this.l3 - this.l4 * this.l4) / (2 * this.l3 * this.l4)
const t3 = acos(max(-1, min(1, D)))
const t2 = atan2(z, G) - atan2(this.l4 * sin(t3), this.l3 + this.l4 * 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]
]
}
}
+195 -226
View File
@@ -17,363 +17,332 @@ import {
MeshPhongMaterial,
EquirectangularReflectionMapping,
ACESFilmicToneMapping,
MathUtils,
Group,
MeshBasicMaterial,
RepeatWrapping
} 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';
RepeatWrapping,
type Object3D
} from 'three'
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'
export const addScene = () => new Scene();
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;
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();
this.scene = new Scene()
if (this.scene.environment?.mapping) {
this.scene.environment.mapping = EquirectangularReflectionMapping;
this.scene.environment.mapping = EquirectangularReflectionMapping
}
return this;
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;
};
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;
};
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 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);
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);
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);
})
mirror.rotateX(-Math.PI / 2)
this.scene.add(mirror)
return this;
};
return this
}
public addOrbitControls = (minDistance: number, maxDistance: number, autoRotate = true) => {
this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
this.orbit.minDistance = minDistance;
this.orbit.maxDistance = maxDistance;
this.orbit.autoRotate = autoRotate;
this.orbit.update();
return this;
};
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;
};
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);
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;
};
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 canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
const squareSize = size / squares;
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);
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;
};
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;
};
this.scene.fog = new FogExp2(color, density)
return this
}
public fillParent = () => {
const parentElement = this.renderer.domElement.parentElement;
const parentElement = this.renderer.domElement.parentElement
if (parentElement) {
const width = parentElement.clientWidth;
const height = parentElement.clientHeight;
this.handleResize(width, height);
const width = parentElement.clientWidth
const height = parentElement.clientHeight
this.handleResize(width, height)
}
return this;
};
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;
};
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 addRenderCb = (callback: () => void) => {
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;
};
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);
)
this.scene.add(arrowHelper)
return this
}
isJoint = (j: URDFJoint) => j.isURDFJoint && j.jointType !== 'fixed';
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) => {
const traverse = (c: Object3D) => {
if (c.type === 'Mesh') {
if (revert) {
c.material = c.__origMaterial;
delete c.__origMaterial;
c.material = c.__origMaterial
delete c.__origMaterial
} else {
c.__origMaterial = c.material;
c.material = material;
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];
const child = c.children[i]
if (!child.isURDFCollider) {
traverse(c.children[i]);
traverse(c.children[i])
}
}
}
};
traverse(m);
};
}
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 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: any) => {
this.modelGroup = new Group();
this.modelGroup.add(model);
this.model = model;
this.scene.add(this.modelGroup);
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: any) => {
const highlightColor = '#FFFFFF';
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);
};
this.setJointValue(joint.name, angle)
updateAngle({ [joint.name]: angle })
}
dragControls.onDragStart = () => {
this.orbit.enabled = false;
this.isDragging = true;
};
this.orbit.enabled = false
this.isDragging = true
}
dragControls.onDragEnd = () => {
this.orbit.enabled = true;
this.isDragging = false;
};
this.orbit.enabled = true
this.isDragging = false
}
dragControls.onHover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, false, highlightMaterial);
this.highlightLinkGeometry(joint, false, highlightMaterial)
dragControls.onUnhover = (joint: URDFMimicJoint) =>
this.highlightLinkGeometry(joint, true, highlightMaterial);
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;
};
)
return this
}
public toggleFog = () => {
this.scene.fog = this.scene.fog ? null : this.fog;
};
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;
};
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)
+67 -15
View File
@@ -1,20 +1,72 @@
import { api } from '$lib/api';
import { notifications } from '$lib/components/toasts/notifications';
import { writable, type Writable } from 'svelte/store';
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 { resolve } from '$app/paths'
import { socket } from '$lib/stores'
let featureFlagsStore: Writable<Record<string, boolean>>;
let featureFlagsStore: Writable<Record<string, boolean | string>>
export function useFeatureFlags() {
if (!featureFlagsStore) {
featureFlagsStore = writable<Record<string, boolean>>({});
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: 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
}
}
}
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
})
export const currentKinematic = derived(
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 appEnv from 'app-env';
import { persistentStore } from '$lib/utilities'
import { writable } from 'svelte/store'
import { PUBLIC_VITE_USE_HOST_NAME } from '$env/static/public'
export const location = appEnv.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([])
+47 -36
View File
@@ -1,45 +1,56 @@
import type { ControllerInput } from '$lib/types/models';
import { persistentStore } from '$lib/utilities/svelte-utilities';
import { writable, type Writable } from 'svelte/store';
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'
export const emulateModel = writable(true);
export const emulateModel = writable(true)
export const jointNames = persistentStore('joint_names', <string[]>[]);
export const jointNames = persistentStore('joint_names', <string[]>[])
export const model = writable();
export const model = writable()
export const modes = [
'deactivated',
'idle',
'calibration',
'rest',
'stand',
'crawl',
'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,
Idle,
Calibration,
Rest,
Stand,
Crawl,
Walk
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 const mode: Writable<ModesEnum> = writable(ModesEnum.Deactivated);
const modesData = enumToValuesAndLabels<ModesEnum>(ModesEnum)
export const modes = modesData.values
export const modeLabels = modesData.labels
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: 50,
speed: 50,
s1: 50
});
const walkGaitsData = enumToValuesAndLabels<WalkGaits>(WalkGaits)
export const walkGaits = walkGaitsData.values
export const walkGaitLabels = walkGaitsData.labels
-27
View File
@@ -1,27 +0,0 @@
import { readable } from 'svelte/store';
export const heading = readable(0, (set) => {
const updateHeading = (e: any) => {
let alpha;
if (e.webkitCompassHeading) alpha = e.webkitCompassHeading;
else if (e.alpha) alpha = e.alpha;
else {
let q = e.target.quaternion;
alpha =
Math.atan2(2 * q[0] * q[1] + 2 * q[2] * q[3], 1 - 2 * q[1] * q[1] - 2 * q[2] * q[2]) *
(180 / Math.PI);
if (alpha < 0) alpha += 360;
}
set(alpha);
};
if ('AbsoluteOrientationSensor' in window) {
var sensor = new window.AbsoluteOrientationSensor({ frequency: 60 }) as any;
sensor.addEventListener('reading', updateHeading);
sensor.start();
} else if (window.DeviceMotionEvent) window.addEventListener('deviceorientation', updateHeading);
return () => {
if ('AbsoluteOrientationSensor' in window) sensor.removeEventListener('reading', updateHeading);
window.addEventListener('deviceorientation', updateHeading);
};
});
+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])
+299 -120
View File
@@ -1,132 +1,311 @@
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>()
export enum Topics {
imu = 0,
mode = 1,
command = 2,
servo = 3,
input = 4,
angles = 5,
position = 6
type CorrelationRequestData = Omit<CorrelationRequest, 'correlationId'>
type PendingRequest = {
resolve: (response: CorrelationResponse) => void
reject: (error: Error) => void
timeoutId: ReturnType<typeof setTimeout>
}
// 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() {
let listeners = new Map<string | Topics, Set<(data?: unknown) => void>>()
const { subscribe, set } = writable(false)
const reconnectTimeoutTime = 5000
let unresponsiveTimeoutId: number
let reconnectTimeoutId: number
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.onopen = ev => {
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 as unknown as Topics)
}
}
ws.onmessage = message => {
resetUnresponsiveCheck()
let data = message.data
if (data instanceof ArrayBuffer) {
listeners.get('binary')?.forEach(listener => listener(data))
return
}
data = data.substring(1)
if (!data) return
let event = data.substring(data.indexOf('/') + 1, data.indexOf('['))
let payload = data.substring(data.indexOf('[') + 1, data.lastIndexOf(']'))
try {
payload = JSON.parse(payload)
} catch (error) {}
if (event) listeners.get(event)?.forEach(listener => listener(payload))
}
ws.onerror = ev => disconnect('error', ev)
ws.onclose = ev => disconnect('close', ev)
}
function unsubscribe(event: Topics, listener?: (data: any) => void) {
let 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: Topics, data: unknown) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify([2, event, data]))
}
function unsubscribeToEvent(event: Topics) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(`[1,${event}]`)
}
function subscribeToEvent(event: Topics) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(`[0,${event}]`)
}
return {
subscribe,
sendEvent,
init,
on: <T>(event: Topics | SocketEvent, listener: (data: T) => void): (() => void) => {
let eventListeners = listeners.get(event)
if (!eventListeners) {
if (!socketEvents.includes(event)) {
subscribeToEvent(event)
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
}
eventListeners = new Set()
listeners.set(event, eventListeners)
}
eventListeners.add(listener as (data: any) => 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
return () => {
unsubscribe(event, listener)
}
},
off: (event: Topics, listener?: (data: any) => void) => {
unsubscribe(event, listener)
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
}
+60 -198
View File
@@ -1,221 +1,83 @@
export enum MessageTopic {
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 -3
View File
@@ -1,4 +1,6 @@
export const daisyColor = (name: string, opacity: number = 100) => {
const color = getComputedStyle(document.documentElement).getPropertyValue(name);
return `oklch(${color} / ${opacity}%)`;
};
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
}
+63 -59
View File
@@ -1,92 +1,96 @@
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'
import { jointNames, model } from '$lib/stores'
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('/yertle.URDF')
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('/URDF.zip')
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
}
+5 -11
View File
@@ -1,15 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
setTimeout(() => {
goto('/');
}, 3000);
});
import { page } from '$app/state'
import { resolve } from '$app/paths'
</script>
<div class="flex justify-center items-center w-full h-full">
<h1 class="text-4xl">404 - Page not found</h1>
<p>You will be redirected to the home page in 3 seconds</p>
</div>
<h1>{page.status} {page.error?.message}</h1>
<span>Go to <a class="btn btn-primary" href={resolve('/')}>Home page</a></span>
</div>
+72 -64
View File
@@ -1,93 +1,100 @@
<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 { 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,
input,
servoAngles,
servoAnglesOut,
socket,
location,
useFeatureFlags
} from '$lib/stores';
import type { Analytics, DownloadOTA } from '$lib/types/models';
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;
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`);
const ws = $apiLocation ? $apiLocation : window.location.host
socket.init(`ws://${ws}/api/ws`)
addEventListeners();
addEventListeners()
outControllerData.subscribe(data => socket.sendEvent('input', { data }));
mode.subscribe(data => socket.sendEvent('mode', { data }));
servoAnglesOut.subscribe(data => socket.sendEvent('angles', { data }));
kinematicData.subscribe(data => socket.sendEvent('position', { data }));
});
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))
})
onDestroy(() => {
removeEventListeners();
});
removeEventListeners()
})
const eventListeners: (() => void)[] = []
const addEventListeners = () => {
socket.on('open', handleOpen);
socket.on('close', handleClose);
socket.on('error', handleError);
socket.on('rssi', handleNetworkStatus);
socket.on('mode', (data: ModesEnum) => mode.set(data));
socket.on('analytics', handleAnalytics);
socket.on('angles', (angles: number[]) => {
if (angles.length) servoAngles.set(angles);
});
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) socket.on('otastatus', handleOAT);
if (data?.sonar) socket.on('sonar', data => console.log(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 removeEventListeners = () => {
socket.off('analytics', handleAnalytics);
socket.off('open', handleOpen);
socket.off('close', handleClose);
socket.off('rssi', handleNetworkStatus);
socket.off('otastatus', handleOAT);
};
eventListeners.forEach(offFunction => offFunction())
}
const handleOpen = () => {
notifications.success('Connection to device established', 5000);
};
const handleOpen = () => notifications.success('Connection to device established', 5000)
const handleClose = () => {
notifications.error('Connection to device lost', 5000);
telemetry.setRSSI(0);
};
notifications.error('Connection to device lost', 5000)
telemetry.setRSSI(RSSIData.create({ rssi: 0 }))
}
const handleError = (data: any) => console.error(data);
const handleError = (data: unknown) => console.error(data)
const handleAnalytics = (data: Analytics) => analytics.addData(data);
const handleNetworkStatus = (data: number) => telemetry.setRSSI(data);
const handleOAT = (data: DownloadOTA) => telemetry.setDownloadOTA(data);
let menuOpen = $state(false);
let menuOpen = $state(false)
</script>
<svelte:head>
@@ -111,13 +118,14 @@
</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}
onkeydown={e => e.key === 'Escape' && modals.closeAll()}
role="button"
tabindex="0"
></div>
{/snippet}
</Modals>
+32 -18
View File
@@ -1,22 +1,36 @@
export const prerender = false;
export const ssr = false;
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) => {
let url = resource instanceof Request ? resource.url : resource.toString();
let 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: '2024 Rune Harlyk'
};
};
await registerFetchIntercept()
return {
title: 'Spot micro controller',
github: 'runeharlyk/SpotMicroESP32-Leika',
app_name: 'Spot Micro Controller',
copyright: '2025 Rune Harlyk'
}
}

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