diff --git a/Cargo.lock b/Cargo.lock index 76008359..7cd59082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,15 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] [[package]] name = "adler2" @@ -68,9 +77,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -83,50 +92,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] [[package]] name = "array-init" @@ -146,7 +158,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -158,7 +170,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -170,7 +182,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -181,7 +193,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -190,6 +202,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -198,9 +225,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bincode" @@ -217,7 +244,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -228,7 +255,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -254,9 +281,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -281,9 +308,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" @@ -302,7 +329,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -313,9 +340,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cassowary" @@ -334,9 +361,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.45" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -377,9 +404,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -412,9 +439,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -422,9 +449,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -434,21 +461,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cobs" @@ -467,11 +494,38 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -649,7 +703,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "mio", "parking_lot", @@ -665,13 +719,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.2", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -688,9 +742,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -719,9 +773,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.188" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ac4eaf7ebe29e92f1b091ceefec7710a53a6f6154b2460afda626c113b65b9" +checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" dependencies = [ "cc", "cxx-build", @@ -734,59 +788,49 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.188" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2abd4c3021eefbac5149f994c117b426852bca3a0aad227698527bca6d4ea657" +checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.12.0", + "indexmap 2.13.0", "proc-macro2", "quote", "scratch", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "cxxbridge-cmd" -version = "1.0.188" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f12fbc5888b2311f23e52a601e11ad7790d8f0dbb903ec26e2513bf5373ed70" +checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.12.0", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "cxxbridge-flags" -version = "1.0.188" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d3dd7870af06e283f3f8ce0418019c96171c9ce122cfb9c8879de3d84388fd" +checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" [[package]] name = "cxxbridge-macro" -version = "1.0.188" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26f0d82da663316786791c3d0e9f9edc7d1ee1f04bdad3d2643086a69d6256c" +checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "syn 2.0.117", ] [[package]] @@ -795,22 +839,8 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.110", + "darling_core", + "darling_macro", ] [[package]] @@ -823,18 +853,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -843,9 +862,9 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -863,9 +882,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der" @@ -894,9 +913,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -921,7 +940,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -965,7 +984,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1028,6 +1047,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1042,9 +1071,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastbloom" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", @@ -1060,9 +1089,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1072,9 +1101,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1121,9 +1150,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1136,9 +1165,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1146,15 +1175,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1163,38 +1192,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1204,15 +1233,14 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -1220,9 +1248,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1240,11 +1268,30 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "git-version" version = "0.3.9" @@ -1262,7 +1309,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1300,9 +1347,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -1356,12 +1403,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1379,9 +1425,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1449,9 +1495,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1463,9 +1509,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1482,6 +1528,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1509,6 +1561,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -1522,12 +1580,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1564,15 +1622,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1620,9 +1678,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" @@ -1648,9 +1706,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1669,9 +1727,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] @@ -1700,11 +1758,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.177" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -1718,17 +1782,16 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.10.0", "libc", ] @@ -1760,9 +1823,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1787,9 +1850,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -1808,9 +1871,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" dependencies = [ "twox-hash", ] @@ -1835,9 +1898,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -1866,9 +1929,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", @@ -1901,7 +1964,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -1921,7 +1984,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -1989,9 +2052,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -2033,6 +2096,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -2044,9 +2116,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2056,9 +2128,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -2066,6 +2138,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking" version = "2.2.1" @@ -2118,9 +2196,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -2128,9 +2206,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -2138,37 +2216,27 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", ] -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset", - "indexmap 2.12.0", -] - [[package]] name = "petgraph" version = "0.8.3" @@ -2177,7 +2245,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.12.0", + "indexmap 2.13.0", "serde", ] @@ -2212,7 +2280,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2226,15 +2294,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -2332,23 +2394,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2373,7 +2435,7 @@ checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2384,7 +2446,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -2397,9 +2459,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -2407,42 +2469,41 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools 0.14.0", "log", "multimap", - "once_cell", - "petgraph 0.7.1", + "petgraph", "prettyplease", "prost", "prost-types", "regex", - "syn 2.0.110", + "syn 2.0.117", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] @@ -2508,7 +2569,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2521,7 +2582,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2543,8 +2604,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", + "socket2 0.6.3", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2552,9 +2613,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "fastbloom", @@ -2567,7 +2628,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2582,16 +2643,16 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2602,6 +2663,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2620,7 +2687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2640,7 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2649,14 +2716,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2667,7 +2734,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2676,7 +2743,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm 0.28.1", @@ -2697,7 +2764,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2706,9 +2773,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2728,14 +2795,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2745,9 +2812,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2756,9 +2823,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "ring" @@ -2768,7 +2835,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2809,7 +2876,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "once_cell", "serde", "serde_derive", @@ -2835,6 +2902,7 @@ dependencies = [ "pyo3", "ros-z-cdr", "ros-z-codegen", + "ros-z-derive", "ros-z-msgs", "ros-z-protocol", "ros-z-schema", @@ -2848,6 +2916,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "trybuild", "uuid", "zenoh", "zenoh-buffers", @@ -2882,6 +2951,19 @@ dependencies = [ "zenoh-buffers", ] +[[package]] +name = "ros-z-cli" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "ros-z", + "ros-z-protocol", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "ros-z-codegen" version = "0.1.0" @@ -2899,7 +2981,7 @@ dependencies = [ "serde_json", "serial_test", "sha2", - "syn 2.0.110", + "syn 2.0.117", "tempfile", ] @@ -2934,7 +3016,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3041,7 +3123,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3049,6 +3131,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3079,31 +3167,31 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "log", "once_cell", @@ -3116,9 +3204,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3137,9 +3225,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3174,9 +3262,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -3203,9 +3291,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3227,9 +3315,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -3248,9 +3336,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "either", @@ -3262,14 +3350,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3302,11 +3390,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -3315,9 +3403,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3375,7 +3463,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3386,35 +3474,44 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", ] [[package]] name = "serde_with" -version = "3.15.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -3423,14 +3520,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3439,7 +3536,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -3448,11 +3545,12 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" dependencies = [ - "futures", + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot", @@ -3462,22 +3560,22 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "serialport" -version = "4.7.3" +version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "core-foundation", "core-foundation-sys", @@ -3486,7 +3584,7 @@ dependencies = [ "nix 0.26.4", "scopeguard", "unescaper", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -3538,9 +3636,9 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ "dirs", ] @@ -3574,10 +3672,11 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3605,15 +3704,15 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3629,7 +3728,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3644,12 +3743,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3786,7 +3885,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3798,7 +3897,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3820,9 +3919,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3837,7 +3936,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3852,16 +3951,22 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3885,11 +3990,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3900,18 +4005,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3920,7 +4025,7 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "libc", "log", @@ -3939,30 +4044,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3980,9 +4085,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3995,31 +4100,33 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tls-listener" -version = "0.11.0" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab41256c16d6fc2b3021545f20bf77a73200b18bd54040ac656dddfca6205bfa" +checksum = "1461056cc1ef47003f7ee16e4cef3741068d4c7f6b627bfce49b7c00c120a530" dependencies = [ "futures-util", "pin-project-lite", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-rustls", ] [[package]] name = "token-cell" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c7b0772e96c7fa6646b16c116753b3d1db503400209237230aa992c9e3a269" +checksum = "fb48920ae769b58126c8c93269805011c793201f95fde28b479b81a9a531bbde" dependencies = [ "paste", + "portable-atomic", + "rustversion", ] [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -4027,20 +4134,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4081,9 +4188,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -4093,41 +4200,86 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.0", +] + [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ - "indexmap 2.12.0", - "toml_datetime", + "indexmap 2.13.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4137,25 +4289,35 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -4179,9 +4341,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -4198,6 +4360,21 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.0.7+spec-1.1.0", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -4262,18 +4439,18 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unescaper" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c01d12e3a56a4432a8b436f293c25f4808bdf9e9f9f98f9260bba1f1bc5a1f26" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -4330,20 +4507,20 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "unzip-n" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7e85a0596447f0f2ac090e16bc4c516c6fe91771fb0c0ccf7fa3dae896b9c" +checksum = "3b5bb2756c16fb66f80cfbf5fb0e0c09a7001e739f453c9ec241b9c8b1556fda" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -4371,11 +4548,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -4400,7 +4577,7 @@ checksum = "8c44ce98e7227a04eeb4cf9c784109a5c9710e54849ceb4f09f8597247897f1e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "unzip-n", ] @@ -4455,18 +4632,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -4477,9 +4663,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4487,26 +4673,60 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -4519,18 +4739,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -4609,7 +4829,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4620,7 +4840,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4910,18 +5130,106 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -4931,9 +5239,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "x509-parser" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", "data-encoding", @@ -4942,7 +5250,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4965,7 +5273,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -4984,9 +5292,9 @@ dependencies = [ [[package]] name = "zenoh" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9ff8cb89f5267b8486a69466bc42f240f1ee2d5089e72395a23094e7b74f21" +checksum = "7717b0e6a0a3463680b70e567496a7e26f10dbc2584fd79af922a530f596fcf0" dependencies = [ "ahash", "arc-swap", @@ -5002,10 +5310,9 @@ dependencies = [ "lazy_static", "nonempty-collections", "once_cell", - "petgraph 0.8.3", + "petgraph", "phf", "rand 0.8.5", - "ref-cast", "rustc_version", "serde", "serde_json", @@ -5037,18 +5344,18 @@ dependencies = [ [[package]] name = "zenoh-buffers" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9216c3d6c84b56f3e3be634e52365022038e1ac1b9f662f10d425cbf6c0fa8" +checksum = "8bcbe41150794b2c49f6aa107e56a9a995cad538ea6a9cd20f1e127181330c55" dependencies = [ "zenoh-collections", ] [[package]] name = "zenoh-codec" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14bc6747664aa9ecf17becd6e9a29282e535a350cd7c6bd8de7bf2dc662fb93d" +checksum = "6c92a5f6928fb4d6d33ef34c1818266720885409521f9f3016d0e57f90abe9d1" dependencies = [ "tracing", "uhlc", @@ -5059,18 +5366,18 @@ dependencies = [ [[package]] name = "zenoh-collections" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d642ecfe0d85f0cd846be9bc92805d926c092a6e6c7a575b6346752f8c3ae16" +checksum = "fb87ed45b3cba05fdb53fddc0644ef4eaa1a6ed68e944f7d761b483c4571a0ce" dependencies = [ "ahash", ] [[package]] name = "zenoh-config" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39765a5f9975aba204c99f2f65308db4952dbea8e5ac79c78ac1eaf5711e970a" +checksum = "654d7a535f32d8bf5b1bfd0e21431a495d3ff60cfd2a7bc0f27172fa3caaa88e" dependencies = [ "json5", "nonempty-collections", @@ -5080,6 +5387,7 @@ dependencies = [ "serde_json", "serde_with", "serde_yaml", + "toml 0.9.12+spec-1.1.0", "tracing", "uhlc", "validated_struct", @@ -5093,9 +5401,9 @@ dependencies = [ [[package]] name = "zenoh-core" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c0c1388dccf287aec4e9d5e638630dc9d536db9f1da3522889b42697723b9b" +checksum = "3f0fccee692fa37f7585f0b2d1b3153203ae6073293bc13fe0d5bc5dd12ca1fd" dependencies = [ "lazy_static", "tokio", @@ -5105,9 +5413,9 @@ dependencies = [ [[package]] name = "zenoh-crypto" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b433e08df3b03f2af2d23bd29a32aa5f5522c52e66d63e3d135bfa66373736dd" +checksum = "ae0e4d9b102043c6229f4a6d4e760ba96b10f02abef3bbd646f0ea431eec76e8" dependencies = [ "aes", "hmac", @@ -5119,9 +5427,9 @@ dependencies = [ [[package]] name = "zenoh-ext" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddc96d0b206af968465ca8497cd2f8bab9639bb028391f354b057fc9a1d6b0" +checksum = "ba3e818887ffe081162471b46e77e9a188a2223ab78c59c72f3473397516375c" dependencies = [ "async-trait", "bincode", @@ -5139,15 +5447,15 @@ dependencies = [ [[package]] name = "zenoh-keyexpr" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a3c47c89cb55ea45a1b3fe7d1fe8682ea93530b1fc5245257812db14b55b3d" +checksum = "5328ae37471ae54300a354107c625a864a8e8cd074ecaf9e6e9286f78d1dd021" dependencies = [ - "getrandom 0.2.16", - "hashbrown 0.16.0", + "getrandom 0.2.17", + "hashbrown 0.16.1", "keyed-set", "rand 0.8.5", - "schemars 1.1.0", + "schemars 1.2.1", "serde", "token-cell", "zenoh-result", @@ -5155,9 +5463,9 @@ dependencies = [ [[package]] name = "zenoh-link" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6218cecab58435f31fb8b2e185f74f35af8aedd96e8bdd3557b333206b1acfda" +checksum = "80440bf41c90987e5b1c1b02f42ab475ba616d7115d180c26ef4cdeb3f6afe7a" dependencies = [ "zenoh-config", "zenoh-link-commons", @@ -5175,9 +5483,9 @@ dependencies = [ [[package]] name = "zenoh-link-commons" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98adc618f7edb570b9333ce583934a7c63e3a619cb49666515bfc06a000d7b6" +checksum = "c6b104d459fa6e1abec12bce867ac845444b20b4e1a7f8ca8533b4c9edd2b00c" dependencies = [ "async-trait", "base64", @@ -5209,9 +5517,9 @@ dependencies = [ [[package]] name = "zenoh-link-quic" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d0c25d681db958b7714370d5e1d72a523129633d9262b098e18d44824d39893" +checksum = "69e02852210b8ce8c93008e7e60f806e38eee8e57e0dcb362a9a8547ce308b97" dependencies = [ "async-trait", "base64", @@ -5235,9 +5543,9 @@ dependencies = [ [[package]] name = "zenoh-link-quic_datagram" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb427adbc8d367505b9a62a6614c9d7802e420f03466d98644bb7bf24516f" +checksum = "2b5e7a2fc08ab6e5df126f23b7fa30a58bf80a7c7ca0b9f7b931c5b37168f031" dependencies = [ "async-trait", "quinn", @@ -5256,9 +5564,9 @@ dependencies = [ [[package]] name = "zenoh-link-serial" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee91f2e7f7ea44e10e3cdffcda728fcbfcd1d1f9111420164dbfab4c3872c28" +checksum = "c4cabd8a16e1153ce4e9c85ebea74339e082c6fa4b74e1181942c13df89792bd" dependencies = [ "async-trait", "tokio", @@ -5275,9 +5583,9 @@ dependencies = [ [[package]] name = "zenoh-link-tcp" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f23bd5d06a0014ce5a205961d6d47c8e8d792d9fd050ae9d0c9b609a187995" +checksum = "af351f5b66df749677f8fc6e6a1ee4c15f004439791540f38c737e7c5e764be1" dependencies = [ "async-trait", "socket2 0.5.10", @@ -5293,9 +5601,9 @@ dependencies = [ [[package]] name = "zenoh-link-tls" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17544cde682dbcd2a712114786feee14295c28a9f66fc1021637355511e32fa9" +checksum = "f36da0bed1d9a7231fac2bb0d86ee6b08e7239fe42b36d0e877c7c3dd858ec00" dependencies = [ "async-trait", "base64", @@ -5323,9 +5631,9 @@ dependencies = [ [[package]] name = "zenoh-link-udp" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "587ca1de1caa0b444106d31c26801f4e87b354293d2d37afd8f70d9e793fe35e" +checksum = "30c279cd4144cb2ab14eba32cab956c03869d7e02b088a8a9965c070f6512805" dependencies = [ "async-trait", "libc", @@ -5345,9 +5653,9 @@ dependencies = [ [[package]] name = "zenoh-link-unixsock_stream" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac46470a17af5861e07ed9d2440277e7a3dea55109b0988866b635f7daf29ac4" +checksum = "bf7c5144802d0fdda1123484a79d944265947e528ba0763784e05ae0bd40db40" dependencies = [ "async-trait", "nix 0.29.0", @@ -5364,9 +5672,9 @@ dependencies = [ [[package]] name = "zenoh-link-ws" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4be09c3e32d510cdddf3289bc333d53471883198fca51d58248458a20df3d51" +checksum = "306c59fde40c46e3d7838ad628645eaf4cd3a76355b91e0e076628267fb98446" dependencies = [ "async-trait", "futures-util", @@ -5385,21 +5693,21 @@ dependencies = [ [[package]] name = "zenoh-macros" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b760a458cd906ac888b37fd1abdb21a0f58ecc64cc3882f83a976cb5ca8e0632" +checksum = "21523a418d0c52310ed9f3127099a40244040ea1dc3d420f5b4fa3178e27a8ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "zenoh-keyexpr", ] [[package]] name = "zenoh-plugin-trait" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7325b773c43a86a94f800cb971ab7e4b7e01ce76819c9c100ea783a47c3a25e4" +checksum = "9f54cb62ce3d7b8c2261f091f7f0ba855780f70690e7e8238e2deb490cdf8260" dependencies = [ "git-version", "libloading", @@ -5415,9 +5723,9 @@ dependencies = [ [[package]] name = "zenoh-protocol" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4d3dad7aeeea780495692b195cd56515569c32b76b9dd077cc408c3ebca03f" +checksum = "8688f5f4748123ac6e5950c51c281ba65ad444f4435624e1f93112d94f4809db" dependencies = [ "const_format", "rand 0.8.5", @@ -5425,23 +5733,24 @@ dependencies = [ "uhlc", "zenoh-buffers", "zenoh-keyexpr", + "zenoh-macros", "zenoh-result", ] [[package]] name = "zenoh-result" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b4dbfea68b947a790d5525bcf061e91e2fdc2798bce619851919b353a8580fa" +checksum = "a1f04e46152cf494d1bc3e35948fed82cf5e57e559e5f2c4ff4223d0984b537d" dependencies = [ "anyhow", ] [[package]] name = "zenoh-runtime" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760a1f7880f98427ad849d600257d1455a18afe981681f362684a3f91042537e" +checksum = "63259ecef5e54b5875ffb4ac4aff6d31680cfac4c78cdc6f9a385f8f28fc5396" dependencies = [ "lazy_static", "ron", @@ -5454,9 +5763,9 @@ dependencies = [ [[package]] name = "zenoh-shm" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42184ae820d64d40814c0dd3b48f748c55a3ff5591c524f1bde36f21ccfda488" +checksum = "d5905d7d5dcfb934e94d225aff5e01546c2ad5f38b577709416893968041066d" dependencies = [ "advisory-lock", "async-trait", @@ -5484,9 +5793,9 @@ dependencies = [ [[package]] name = "zenoh-stats" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8954c79580ed3365580d86f40657f2cd6bf4b4c72dd463de936368df8c709c65" +checksum = "03fd0409089a7630c6af81f8106979194b2e6c450fdcae36b62aeb14292aded8" dependencies = [ "ahash", "prometheus-client", @@ -5498,9 +5807,9 @@ dependencies = [ [[package]] name = "zenoh-sync" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98f132137bb003f10b7fff086cb18addf8e8273b9c0d2722a53b5074c8a79965" +checksum = "17ba3547e15ebfe1799d84b5e6861502f026b967508601f4f344b1edfc42d946" dependencies = [ "arc-swap", "event-listener", @@ -5513,9 +5822,9 @@ dependencies = [ [[package]] name = "zenoh-task" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b17d10136fdabec7e21a3fcef568c210ee6a2d71cde6adcde99e9236584f3a1" +checksum = "b888098524b4ed57978e899f35d16250cf7805bbcc962312be7e1d5d60c1dcf4" dependencies = [ "futures", "tokio", @@ -5527,9 +5836,9 @@ dependencies = [ [[package]] name = "zenoh-transport" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50739b4c45e0963df8377abddb74701a4b708b178590eae92f27a604e25daf44" +checksum = "a7c32b85396519616c4088ca973fa5ec4f798fdd49aa203b3bb2b60e468cd587" dependencies = [ "async-trait", "crossbeam-utils", @@ -5563,9 +5872,9 @@ dependencies = [ [[package]] name = "zenoh-util" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9512987c13925d32d3331507c8807853d5b682ea8da94d0ba6534c7a8ace48aa" +checksum = "1b3ee5dc455e67c571bd0d6177a5da0e744fdd544817799cc7d7d83adea94172" dependencies = [ "async-trait", "const_format", @@ -5576,7 +5885,7 @@ dependencies = [ "libc", "libloading", "pnet_datalink", - "schemars 1.1.0", + "schemars 1.2.1", "serde", "serde_json", "shellexpand", @@ -5590,22 +5899,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5625,7 +5934,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -5665,5 +5974,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8b43227a..7c39fe4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/ros-z-msgs", "crates/ros-z-tests", "crates/ros-z-console", + "crates/ros-z-cli", "crates/ros-z-bridge", "crates/ros-z/examples/protobuf_demo", ] diff --git a/book/src/chapters/custom_messages.md b/book/src/chapters/custom_messages.md index 12fa9f45..b15fba89 100644 --- a/book/src/chapters/custom_messages.md +++ b/book/src/chapters/custom_messages.md @@ -2,10 +2,10 @@ ros-z supports two approaches for defining custom message types: -| Approach | Definition | Best For | -|----------|------------|----------| -| **Rust-Native** | Write Rust structs directly | Prototyping, ros-z-only systems | -| **Schema-Generated** | Write `.msg`/`.srv` files, generate Rust | Production, ROS 2 interop | +| Approach | Definition | Best For | +| -------------------- | ---------------------------------------- | ------------------------------- | +| **Rust-Native** | Write Rust structs directly | Prototyping, ros-z-only systems | +| **Schema-Generated** | Write `.msg`/`.srv` files, generate Rust | Production, ROS 2 interop | ```mermaid flowchart TD @@ -21,38 +21,38 @@ flowchart TD ## Rust-Native Messages -**Define messages directly in Rust by implementing required traits.** This approach is fast for prototyping but only works between ros-z nodes. +**Define messages directly in Rust and derive their schema metadata.** This approach is fast for prototyping, and plain named structs can still participate in the standard ROS 2 type description service. ```admonish warning -Rust-Native messages use `TypeHash::zero()` and won't interoperate with ROS 2 C++/Python nodes. +Rust-native messages defined with `#[derive(MessageTypeInfo)]` are limited to ROS 2 schema-compatible shapes. If you need `Option`, enums, or other ros-z-only schema extensions, use `#[derive(ExtendedMessageTypeInfo)]` plus the parallel extended type description service instead of the standard ROS 2 type description service. ``` ### Workflow of Rust-Native Messages ```mermaid graph LR - A[Define Struct] --> B[Impl MessageTypeInfo] + A[Define Struct] --> B[Derive MessageTypeInfo] B --> C[Add Serde Traits] - C --> D[Impl WithTypeInfo] + C --> D[Impl ZMessage] D --> E[Use in Pub/Sub] ``` ### Required Traits -| Trait | Purpose | Key Method | -|-------|---------|------------| -| **MessageTypeInfo** | Type identification | `type_name()`, `type_hash()` | -| **WithTypeInfo** | ros-z integration | `type_info()` | -| **Serialize/Deserialize** | Data encoding | From `serde` | +| Trait | Purpose | Key Method | +| ------------------------- | ------------------------------------ | ------------------------------------------------ | +| **MessageTypeInfo** | Type identification + runtime schema | `type_name()`, `type_hash()`, `message_schema()` | +| **Serialize/Deserialize** | Data encoding | From `serde` | +| **ZMessage** | ros-z serialization path | `type Serdes = SerdeCdrSerdes` | ### Message Example ```rust,ignore -use ros_z::{MessageTypeInfo, entity::TypeHash}; -use ros_z::ros_msg::WithTypeInfo; +use ros_z::MessageTypeInfo; use serde::{Serialize, Deserialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, MessageTypeInfo)] +#[ros_msg(type_name = "my_msgs/msg/RobotStatus")] struct RobotStatus { battery_level: f32, position_x: f32, @@ -60,19 +60,21 @@ struct RobotStatus { is_moving: bool, } -impl MessageTypeInfo for RobotStatus { - fn type_name() -> &'static str { - "my_msgs::msg::dds_::RobotStatus_" - } - - fn type_hash() -> TypeHash { - TypeHash::zero() // ros-z-to-ros-z only - } +impl ros_z::msg::ZMessage for RobotStatus { + type Serdes = ros_z::msg::SerdeCdrSerdes; } - -impl WithTypeInfo for RobotStatus {} ``` +`MessageTypeInfo` derive in core `ros-z` intentionally supports only ROS 2 schema-compatible named +structs: primitive numeric/bool types, `String`, `Vec`, fixed arrays `[T; N]`, and nested +message types. Tuple structs, unit structs, enums, `Option`, maps, `usize`, `isize`, and other +Rust-only shapes are rejected at compile time. + +For richer serde shapes such as `Option` or enums, use `ros_z::ExtendedMessageTypeInfo`. +It keeps normal `message_schema()` support for types that are still ROS 2 compatible, and uses a +separate `~get_extended_type_description` service for extended-only schemas when the publisher node +is created with `.with_extended_type_description_service()`. + ### Service Example ```rust,ignore @@ -105,6 +107,9 @@ impl ZService for NavigateTo { See the `z_custom_message` example: +When a publisher node enables the type description service, derived custom message types +automatically register their runtime schema so dynamic subscribers can discover them. + ```bash # Terminal 1: Router cargo run --example zenoh_router @@ -272,14 +277,14 @@ ROS_Z_MSG_PATH="./my_robot_msgs" cargo build ## Comparison -| Feature | Rust-Native | Schema-Generated | -|---------|-------------|------------------| -| **Definition** | Rust structs | `.msg`/`.srv` files | -| **Type Hashes** | `TypeHash::zero()` | Proper RIHS01 hashes | -| **Standard Type Refs** | Manual | Automatic (`geometry_msgs`, etc.) | -| **ROS 2 Interop** | No | Partial (messages yes, services limited) | -| **Setup Complexity** | Low | Medium (build.rs required) | -| **Best For** | Prototyping | Production | +| Feature | Rust-Native | Schema-Generated | +| ---------------------- | ------------------ | ---------------------------------------- | +| **Definition** | Rust structs | `.msg`/`.srv` files | +| **Type Hashes** | `TypeHash::zero()` | Proper RIHS01 hashes | +| **Standard Type Refs** | Manual | Automatic (`geometry_msgs`, etc.) | +| **ROS 2 Interop** | No | Partial (messages yes, services limited) | +| **Setup Complexity** | Low | Medium (build.rs required) | +| **Best For** | Prototyping | Production | --- diff --git a/book/src/chapters/keyexpr_formats.md b/book/src/chapters/keyexpr_formats.md index 6ad35746..dabe5441 100644 --- a/book/src/chapters/keyexpr_formats.md +++ b/book/src/chapters/keyexpr_formats.md @@ -62,6 +62,12 @@ robot/sensors/camera/** # Topic /robot/sensors/camera - Using `zenoh-bridge-ros2dds` - Integrating with CycloneDDS or FastDDS nodes via Zenoh +**Discovery consequences:** + +- `Ros2Dds` graph discovery can identify publishers/subscribers/services by topic or service name, but its liveliness data does not provide publisher node identity. +- Topic-based graph helpers such as publisher/subscriber matching work with `Ros2Dds`. +- Node-based discovery and automatic schema discovery via `create_dyn_sub_auto()` are not supported with `Ros2Dds`, because the publishing node cannot be identified from discovery data. + ## Key Expression Behavior (IMPORTANT) Understanding how topic names are converted to key expressions is critical for debugging: diff --git a/crates/rmw-zenoh-rs/src/rmw.rs b/crates/rmw-zenoh-rs/src/rmw.rs index 200e2c6d..e62f6247 100644 --- a/crates/rmw-zenoh-rs/src/rmw.rs +++ b/crates/rmw-zenoh-rs/src/rmw.rs @@ -1271,6 +1271,7 @@ pub extern "C" fn rmw_create_service( let service_impl = crate::service::ServiceImpl { inner: zserver, + pending: std::collections::HashMap::new(), service_name: service_name_cstr, request_ts: service_type_support, response_ts: service_type_support, diff --git a/crates/rmw-zenoh-rs/src/service.rs b/crates/rmw-zenoh-rs/src/service.rs index 10cfa98b..d948d81c 100644 --- a/crates/rmw-zenoh-rs/src/service.rs +++ b/crates/rmw-zenoh-rs/src/service.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::ffi::CString; use std::sync::Mutex; use std::sync::atomic::{AtomicI64, Ordering}; @@ -89,15 +90,11 @@ impl ClientImpl { tracing::debug!( "[ClientImpl::take_response] Attempting to take response, rx has {} items", - if self.inner.rx().is_empty() { - 0 - } else { - self.inner.rx().len() - } + if self.inner.rmw_has_responses() { 1 } else { 0 } ); // Try to receive a response - if let Ok(sample) = self.inner.rx().try_recv() { + if let Some(sample) = self.inner.rmw_try_take_response_sample()? { tracing::debug!("[ClientImpl::take_response] Got response sample"); let payload = sample.payload(); @@ -172,6 +169,8 @@ impl ClientImpl { /// Service implementation for RMW pub struct ServiceImpl { pub inner: ros_z::service::ZServer, + pub pending: + HashMap>, pub service_name: CString, pub request_ts: crate::type_support::ServiceTypeSupport, pub response_ts: crate::type_support::ServiceTypeSupport, @@ -195,69 +194,23 @@ impl ServiceImpl { *taken = false; } - // Try to receive a request from the raw receiver - if let Some(query) = self.inner.try_queue().and_then(|q| q.try_recv()) { - // Get the payload bytes - let bytes = if let Some(payload) = query.payload() { - payload.to_bytes().to_vec() - } else { - return Ok(()); - }; - - // Extract attachment from query to get GID, sequence number, and timestamp - let key = if let Some(attachment_bytes) = query.attachment() { - match ros_z::attachment::Attachment::try_from(attachment_bytes) { - Ok(attachment) => { - let key: ros_z::service::QueryKey = attachment.into(); - tracing::debug!( - "[ServiceImpl::take_request] Got request with sn: {}, gid: {:?}", - key.sn, - key.gid - ); - key - } - Err(e) => { - tracing::warn!("Failed to extract attachment from query: {}", e); - // Fallback to placeholder - ros_z::service::QueryKey { - gid: [0u8; 16], - sn: 0i64, - } - } - } - } else { - tracing::warn!("No attachment in query, using placeholder QueryKey"); - ros_z::service::QueryKey { - gid: [0u8; 16], - sn: 0i64, - } - }; - - // Extract timestamp from attachment - let source_timestamp = if let Some(attachment_bytes) = query.attachment() { - match ros_z::attachment::Attachment::try_from(attachment_bytes) { - Ok(attachment) => attachment.source_timestamp, - Err(_) => 0, - } - } else { - 0 - }; + if let Some(request_ctx) = self.inner.try_take_request()? { + let request_id = request_ctx.id().clone(); + let source_timestamp = 0; + let (request_msg, reply) = request_ctx.into_parts(); + let bytes = request_msg.0; // Set received_timestamp to current time let received_timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |v| v.as_nanos() as i64); - // Store the query for later response + // Store the reply context for later response tracing::debug!( - "[ServiceImpl::take_request] Storing query with key sn:{}, inserting into map", - key.sn - ); - self.inner.map_insert(key.clone(), query); - tracing::debug!( - "[ServiceImpl::take_request] Map now has {} entries", - self.inner.map_len() + "[ServiceImpl::take_request] Storing reply context with sn:{}", + request_id.sequence_number ); + self.pending.insert(request_id.clone(), reply); // Deserialize into the provided request buffer using request MessageTypeSupport unsafe { @@ -269,9 +222,8 @@ impl ServiceImpl { // Fill request_header with sequence info and timestamps if !request_header.is_null() { unsafe { - (*request_header).request_id.sequence_number = key.sn; - // Copy GID from key - for (i, &byte) in key.gid.iter().enumerate() { + (*request_header).request_id.sequence_number = request_id.sequence_number; + for (i, &byte) in request_id.writer_guid.iter().enumerate() { if i < 16 { (*request_header).request_id.writer_guid[i] = byte; } @@ -293,42 +245,39 @@ impl ServiceImpl { request_header: *const rmw_request_id_t, response: *const c_void, ) -> Result<()> { - // Extract QueryKey from request_header - let key = unsafe { + let request_id = unsafe { let mut gid = [0u8; 16]; gid.copy_from_slice(&(*request_header).writer_guid); - ros_z::service::QueryKey { - gid, - sn: (*request_header).sequence_number, + ros_z::service::RequestId { + writer_guid: gid, + sequence_number: (*request_header).sequence_number, } }; tracing::debug!( "[ServiceImpl::send_response] Sending response for key sn:{}, gid:{:?}", - key.sn, - key.gid - ); - tracing::debug!( - "[ServiceImpl::send_response] Map has {} entries before send_response", - self.inner.map_len() + request_id.sequence_number, + request_id.writer_guid ); // Create RosMessage Response from the raw pointer using response MessageTypeSupport let resp = crate::msg::RosMessage::new(response, self.response_ts.response); - // Send response - match self.inner.send_response(&resp, &key) { - Ok(_) => { - tracing::debug!("[ServiceImpl::send_response] Response sent successfully"); - Ok(()) - } - Err(e) => { - tracing::error!( - "[ServiceImpl::send_response] Failed to send response: {}", - e - ); - Err(e) - } + match self.pending.remove(&request_id) { + Some(reply) => match reply.reply_blocking(&resp) { + Ok(_) => { + tracing::debug!("[ServiceImpl::send_response] Response sent successfully"); + Ok(()) + } + Err(e) => { + tracing::error!( + "[ServiceImpl::send_response] Failed to send response: {}", + e + ); + Err(e) + } + }, + None => Err(zenoh::Error::from("Pending request not found")), } } } @@ -337,7 +286,7 @@ impl Waitable for ClientImpl { fn is_ready(&self) -> bool { // Acquire fence to ensure we see the latest channel state from other threads std::sync::atomic::fence(std::sync::atomic::Ordering::Acquire); - !self.inner.rx().is_empty() + self.inner.rmw_has_responses() } } diff --git a/crates/ros-z-bridge/src/bridge.rs b/crates/ros-z-bridge/src/bridge.rs index dc4b7515..084e8f05 100644 --- a/crates/ros-z-bridge/src/bridge.rs +++ b/crates/ros-z-bridge/src/bridge.rs @@ -206,7 +206,7 @@ impl Bridge { let key = BridgeKey { topic: ep.topic.clone(), type_name: type_info.name.clone(), - kind: ep.kind, + kind: ep.kind.into(), }; if event.appeared { diff --git a/crates/ros-z-bridge/src/discovery.rs b/crates/ros-z-bridge/src/discovery.rs index 55e3c285..ff1e1e1c 100644 --- a/crates/ros-z-bridge/src/discovery.rs +++ b/crates/ros-z-bridge/src/discovery.rs @@ -135,22 +135,22 @@ pub fn start_discovery( mod tests { use super::*; use ros_z_protocol::{ - EntityKind, TypeHash, TypeInfo, + EndpointKind, TypeHash, TypeInfo, entity::{EndpointEntity, NodeEntity}, }; fn make_endpoint(hash: TypeHash) -> Entity { Entity::Endpoint(EndpointEntity { id: 1, - node: NodeEntity { + node: Some(NodeEntity { domain_id: 0, z_id: "1234567890abcdef1234567890abcdef".parse().unwrap(), id: 0, name: "test_node".to_string(), namespace: "/".to_string(), enclave: String::new(), - }, - kind: EntityKind::Publisher, + }), + kind: EndpointKind::Publisher, topic: "/chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs::msg::dds_::String_", hash)), qos: Default::default(), @@ -187,15 +187,15 @@ mod tests { fn classify_endpoint_no_type_info_returns_none() { let entity = Entity::Endpoint(EndpointEntity { id: 2, - node: NodeEntity { + node: Some(NodeEntity { domain_id: 0, z_id: "1234567890abcdef1234567890abcdef".parse().unwrap(), id: 0, name: "no_type_node".to_string(), namespace: "/".to_string(), enclave: String::new(), - }, - kind: EntityKind::Publisher, + }), + kind: EndpointKind::Publisher, topic: "/anon".to_string(), type_info: None, qos: Default::default(), diff --git a/crates/ros-z-bridge/src/forwarder.rs b/crates/ros-z-bridge/src/forwarder.rs index ec10ef46..92afb44d 100644 --- a/crates/ros-z-bridge/src/forwarder.rs +++ b/crates/ros-z-bridge/src/forwarder.rs @@ -97,7 +97,7 @@ pub fn start_service_forwarder( let target_ke = target_ke_arc.clone(); let proxy_ke_log = proxy_ke_log.clone(); // Extract payload and attachment from the query. - // The attachment carries the QueryKey (sequence number, writer GUID) + // The attachment carries the request identity (sequence number, writer GUID) // required by ros-z service.rs — it must be forwarded verbatim. let payload: Option = query.payload().cloned(); let attachment: Option = query.attachment().cloned(); diff --git a/crates/ros-z-cli/Cargo.toml b/crates/ros-z-cli/Cargo.toml new file mode 100644 index 00000000..dcb8cfd0 --- /dev/null +++ b/crates/ros-z-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ros-z-cli" +version = "0.1.0" +edition = "2024" +description = "Scriptable command-line companion to ros-z" +homepage = "https://github.com/ZettaScaleLabs/ros-z" + +[[bin]] +name = "rosz" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive"] } +color-eyre = "0.6" +ros-z = { path = "../ros-z", features = ["ros2dds"] } +ros-z-protocol = { path = "../ros-z-protocol", features = ["ros2dds"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/ros-z-cli/src/app/context.rs b/crates/ros-z-cli/src/app/context.rs new file mode 100644 index 00000000..841339e1 --- /dev/null +++ b/crates/ros-z-cli/src/app/context.rs @@ -0,0 +1,120 @@ +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use color_eyre::eyre::{Result, WrapErr, eyre}; +use ros_z::{ + Builder, + context::{ZContext, ZContextBuilder}, + dynamic::DynSubBuilder, + graph::GraphSnapshot, + node::ZNode, + parameter::{ParameterClient, ParameterTarget}, +}; + +use crate::{ + cli::Backend, + support::{display_error, graph::SnapshotFingerprint}, +}; + +const GRAPH_POLL_INTERVAL: Duration = Duration::from_millis(200); +const GRAPH_SETTLE_TIMEOUT: Duration = Duration::from_secs(2); + +pub struct AppContext { + context: ZContext, + node: Arc, + domain_id: usize, +} + +impl AppContext { + pub fn new(router: &str, domain_id: usize, backend: Backend) -> Result { + let keyexpr_format = match backend { + Backend::RmwZenoh => ros_z_protocol::KeyExprFormat::RmwZenoh, + Backend::Ros2Dds => ros_z_protocol::KeyExprFormat::Ros2Dds, + }; + + let context = ZContextBuilder::default() + .with_mode("peer") + .with_connect_endpoints([router]) + .with_domain_id(domain_id) + .keyexpr_format(keyexpr_format) + .build() + .map_err(|error| eyre!(error)) + .wrap_err("failed to build ros-z context")?; + let node = Arc::new( + context + .create_node("rosz") + .with_type_description_service() + .build() + .map_err(|error| eyre!(error)) + .wrap_err("failed to build rosz node")?, + ); + + Ok(Self { + context, + node, + domain_id, + }) + } + + pub fn graph(&self) -> &ros_z::graph::Graph { + self.context.graph().as_ref() + } + + pub fn snapshot(&self) -> GraphSnapshot { + self.graph().snapshot(self.domain_id) + } + + pub async fn wait_for_graph_settle(&self) { + let deadline = Instant::now() + GRAPH_SETTLE_TIMEOUT; + let mut previous = SnapshotFingerprint::from(&self.snapshot()); + + while Instant::now() < deadline { + tokio::time::sleep(GRAPH_POLL_INTERVAL).await; + + let current_snapshot = self.snapshot(); + let current = SnapshotFingerprint::from(¤t_snapshot); + if current == previous { + return; + } + previous = current; + } + } + + pub async fn wait_for_graph_condition(&self, predicate: F) + where + F: Fn(&ros_z::graph::Graph) -> bool, + { + let deadline = Instant::now() + GRAPH_SETTLE_TIMEOUT; + + while Instant::now() < deadline { + if predicate(self.graph()) { + return; + } + tokio::time::sleep(GRAPH_POLL_INTERVAL).await; + } + } + + pub async fn create_dynamic_subscriber_builder( + &self, + topic: &str, + discovery_timeout: Duration, + ) -> Result { + self.node + .create_dyn_sub_auto(topic, discovery_timeout) + .await + .map_err(|error| eyre!(error)) + } + + pub fn parameter_client(&self, target: ParameterTarget) -> Result { + ParameterClient::new(Arc::clone(&self.node), target).map_err(display_error) + } + + pub fn shutdown(&self) -> Result<()> { + self.context + .shutdown() + .map_err(|error| eyre!(error)) + .wrap_err("failed to close ros-z context") + } +} diff --git a/crates/ros-z-cli/src/app/mod.rs b/crates/ros-z-cli/src/app/mod.rs new file mode 100644 index 00000000..0385265c --- /dev/null +++ b/crates/ros-z-cli/src/app/mod.rs @@ -0,0 +1,3 @@ +pub mod context; + +pub use context::AppContext; diff --git a/crates/ros-z-cli/src/cli.rs b/crates/ros-z-cli/src/cli.rs new file mode 100644 index 00000000..08ca7e84 --- /dev/null +++ b/crates/ros-z-cli/src/cli.rs @@ -0,0 +1,215 @@ +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default)] +pub enum Backend { + #[default] + RmwZenoh, + #[value(name = "ros2dds")] + Ros2Dds, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum ListTarget { + Topics, + Nodes, + Services, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum InfoTarget { + Topic, + Service, + Node, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum ParameterValueTypeArg { + Bool, + Integer, + Double, + String, + ByteArray, + BoolArray, + IntegerArray, + DoubleArray, + StringArray, + NotSet, +} + +#[derive(Debug, Parser)] +#[command(name = "rosz")] +#[command(about = "Scriptable command-line companion to ros-z")] +pub struct Cli { + /// Zenoh router address + #[arg(long, default_value = "tcp/127.0.0.1:7447", global = true)] + pub router: String, + + /// ROS domain ID + #[arg(long, default_value_t = 0, global = true)] + pub domain: usize, + + /// Backend selection (rmw-zenoh or ros2dds) + #[arg(long, value_enum, default_value = "rmw-zenoh", global = true)] + pub backend: Backend, + + /// Emit JSON output when supported + #[arg(long, global = true)] + pub json: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// List graph entities + List { + #[arg(value_enum)] + target: ListTarget, + }, + /// Watch graph changes continuously + Watch, + /// Show the full graph snapshot + Graph, + /// Dynamically inspect a topic's messages + Echo { + topic: String, + #[arg(long)] + count: Option, + #[arg(long)] + timeout: Option, + }, + /// Show metadata for a topic, service, or node + Info { + #[arg(value_enum)] + target: InfoTarget, + name: String, + }, + /// Remote parameter operations + Param { + #[command(subcommand)] + command: ParamCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum ParamCommand { + /// List parameters on a node + List { + #[arg(long)] + node: String, + #[arg(long)] + prefix: Vec, + #[arg(long)] + depth: Option, + }, + /// Get a parameter from a node + Get { + name: String, + #[arg(long)] + node: String, + }, + /// Set a parameter on a node + Set { + name: String, + value: String, + #[arg(long)] + node: String, + #[arg(long = "type", value_enum)] + value_type: Option, + #[arg(long)] + atomic: bool, + }, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Backend, Cli, Command, ListTarget, ParamCommand, ParameterValueTypeArg}; + + #[test] + fn parses_echo_command_with_defaults() { + let cli = Cli::parse_from(["rosz", "echo", "/chatter", "--count", "1"]); + + assert_eq!(cli.router, "tcp/127.0.0.1:7447"); + assert_eq!(cli.domain, 0); + assert_eq!(cli.backend, Backend::RmwZenoh); + assert!(!cli.json); + + match cli.command { + Command::Echo { + topic, + count, + timeout, + } => { + assert_eq!(topic, "/chatter"); + assert_eq!(count, Some(1)); + assert_eq!(timeout, None); + } + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn parses_global_flags_after_subcommand() { + let cli = Cli::parse_from([ + "rosz", + "list", + "topics", + "--router", + "tcp/192.168.1.10:7447", + "--domain", + "7", + "--backend", + "ros2dds", + "--json", + ]); + + assert_eq!(cli.router, "tcp/192.168.1.10:7447"); + assert_eq!(cli.domain, 7); + assert_eq!(cli.backend, Backend::Ros2Dds); + assert!(cli.json); + + match cli.command { + Command::List { target } => assert_eq!(target, ListTarget::Topics), + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn parses_param_set_with_type_override() { + let cli = Cli::parse_from([ + "rosz", + "param", + "set", + "max_speed", + "42", + "--node", + "talker", + "--type", + "integer", + "--atomic", + ]); + + match cli.command { + Command::Param { command } => match command { + ParamCommand::Set { + name, + value, + node, + value_type, + atomic, + } => { + assert_eq!(name, "max_speed"); + assert_eq!(value, "42"); + assert_eq!(node, "talker"); + assert_eq!(value_type, Some(ParameterValueTypeArg::Integer)); + assert!(atomic); + } + other => panic!("unexpected param command: {other:?}"), + }, + other => panic!("unexpected command: {other:?}"), + } + } +} diff --git a/crates/ros-z-cli/src/commands/echo.rs b/crates/ros-z-cli/src/commands/echo.rs new file mode 100644 index 00000000..eeb5bedf --- /dev/null +++ b/crates/ros-z-cli/src/commands/echo.rs @@ -0,0 +1,266 @@ +use std::time::Duration; + +use color_eyre::eyre::{Result, WrapErr, bail, eyre}; +use ros_z::{ + Builder, + dynamic::{ + DynSub, DynamicMessage, DynamicNamedValue, DynamicValue, EnumPayloadValue, EnumValue, + MessageSchemaTypeDescription, + }, +}; +use serde_json::{Map, Value}; + +use crate::{ + app::AppContext, + model::echo::{EchoHeader, EchoMessageView}, + render::{OutputMode, json, text}, +}; + +const TYPE_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); + +pub async fn run( + app: &AppContext, + output_mode: OutputMode, + topic: &str, + count: Option, + timeout: Option, +) -> Result<()> { + let subscriber = app + .create_dynamic_subscriber_builder(topic, TYPE_DISCOVERY_TIMEOUT) + .await + .and_then(|builder| builder.build().map_err(|error| eyre!(error))) + .wrap_err_with(|| format!("failed to subscribe to {topic}"))?; + let schema = subscriber + .schema() + .ok_or_else(|| eyre!("dynamic subscriber missing schema for {topic}"))?; + let header = EchoHeader::new( + topic.to_string(), + schema.type_name.to_string(), + schema + .compute_type_hash() + .map(|hash| hash.to_rihs_string()) + .unwrap_or_else(|_| "unknown".to_string()), + ); + let deadline = timeout + .map(Duration::from_secs_f64) + .map(|timeout| tokio::time::Instant::now() + timeout); + let mut seen = 0usize; + + if output_mode.is_text() { + text::print_echo_header(&header); + } + + loop { + if count.is_some_and(|limit| seen >= limit) { + return Ok(()); + } + + let message = receive_message(&subscriber, deadline, topic).await?; + seen += 1; + + match output_mode { + OutputMode::Json => { + let view = EchoMessageView::new( + header.topic.clone(), + message.schema().type_name.to_string(), + header.type_hash.clone(), + dynamic_message_to_json(&message), + ); + json::print_line(&view)?; + } + OutputMode::Text => { + text::print_echo_message(&format_message_pretty(&message), count, seen); + } + } + } +} + +async fn receive_message( + subscriber: &DynSub, + deadline: Option, + topic: &str, +) -> Result { + let receive = subscriber.async_recv(); + + match deadline { + Some(deadline) => match tokio::time::timeout_at(deadline, receive).await { + Ok(result) => { + result.map_err(|error| eyre!("subscriber receive failed for {topic}: {error}")) + } + Err(_) => bail!("timed out waiting for messages on {topic}"), + }, + None => receive + .await + .map_err(|error| eyre!("subscriber receive failed for {topic}: {error}")), + } +} + +fn dynamic_message_to_json(message: &DynamicMessage) -> Value { + let mut fields = Map::new(); + for (name, value) in message.iter() { + fields.insert(name.to_string(), dynamic_value_to_json(value)); + } + Value::Object(fields) +} + +fn dynamic_value_to_json(value: &DynamicValue) -> Value { + match value { + DynamicValue::Bool(value) => Value::Bool(*value), + DynamicValue::Int8(value) => Value::Number((*value).into()), + DynamicValue::Int16(value) => Value::Number((*value).into()), + DynamicValue::Int32(value) => Value::Number((*value).into()), + DynamicValue::Int64(value) => Value::Number((*value).into()), + DynamicValue::Uint8(value) => Value::Number((*value).into()), + DynamicValue::Uint16(value) => Value::Number((*value).into()), + DynamicValue::Uint32(value) => Value::Number((*value).into()), + DynamicValue::Uint64(value) => Value::Number((*value).into()), + DynamicValue::Float32(value) => serde_json::Number::from_f64(*value as f64) + .map(Value::Number) + .unwrap_or(Value::Null), + DynamicValue::Float64(value) => serde_json::Number::from_f64(*value) + .map(Value::Number) + .unwrap_or(Value::Null), + DynamicValue::String(value) => Value::String(value.clone()), + DynamicValue::Bytes(value) => Value::Array( + value + .iter() + .map(|byte| Value::Number((*byte).into())) + .collect(), + ), + DynamicValue::Message(value) => dynamic_message_to_json(value), + DynamicValue::Optional(None) => Value::Null, + DynamicValue::Optional(Some(value)) => dynamic_value_to_json(value), + DynamicValue::Enum(value) => enum_value_to_json(value), + DynamicValue::Array(values) => { + Value::Array(values.iter().map(dynamic_value_to_json).collect()) + } + } +} + +fn enum_value_to_json(value: &EnumValue) -> Value { + let mut fields = Map::new(); + fields.insert( + "variant_index".to_string(), + Value::Number(value.variant_index.into()), + ); + fields.insert( + "variant_name".to_string(), + Value::String(value.variant_name.clone()), + ); + fields.insert("payload".to_string(), enum_payload_to_json(&value.payload)); + Value::Object(fields) +} + +fn enum_payload_to_json(payload: &EnumPayloadValue) -> Value { + match payload { + EnumPayloadValue::Unit => Value::Null, + EnumPayloadValue::Newtype(value) => dynamic_value_to_json(value), + EnumPayloadValue::Tuple(values) => { + Value::Array(values.iter().map(dynamic_value_to_json).collect()) + } + EnumPayloadValue::Struct(fields) => Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), dynamic_value_to_json(&field.value))) + .collect(), + ), + } +} + +fn format_message_pretty(message: &DynamicMessage) -> String { + let mut output = String::new(); + for (name, value) in message.iter() { + format_value_pretty(&mut output, name, value, 0); + } + output +} + +fn format_value_pretty(output: &mut String, name: &str, value: &DynamicValue, indent: usize) { + let prefix = " ".repeat(indent); + + match value { + DynamicValue::Bool(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Int8(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Int16(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Int32(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Int64(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Uint8(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Uint16(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Uint32(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Uint64(value) => output.push_str(&format!("{}{}: {}\n", prefix, name, value)), + DynamicValue::Float32(value) => { + output.push_str(&format!("{}{}: {}\n", prefix, name, value)) + } + DynamicValue::Float64(value) => { + output.push_str(&format!("{}{}: {}\n", prefix, name, value)) + } + DynamicValue::String(value) => { + output.push_str(&format!("{}{}: \"{}\"\n", prefix, name, value)) + } + DynamicValue::Bytes(value) => output.push_str(&format!( + "{}{}: [bytes: {} bytes]\n", + prefix, + name, + value.len() + )), + DynamicValue::Message(value) => { + output.push_str(&format!("{}{}:\n", prefix, name)); + for (nested_name, nested_value) in value.iter() { + format_value_pretty(output, nested_name, nested_value, indent + 1); + } + } + DynamicValue::Optional(None) => output.push_str(&format!("{}{}: null\n", prefix, name)), + DynamicValue::Optional(Some(value)) => format_value_pretty(output, name, value, indent), + DynamicValue::Enum(value) => format_enum_value_pretty(output, name, value, indent), + DynamicValue::Array(values) => { + if values.is_empty() { + output.push_str(&format!("{}{}[]: []\n", prefix, name)); + } else { + output.push_str(&format!("{}{}[{}]:\n", prefix, name, values.len())); + for (index, value) in values.iter().enumerate() { + format_value_pretty(output, &format!("[{}]", index), value, indent + 1); + } + } + } + } +} + +fn format_enum_value_pretty(output: &mut String, name: &str, value: &EnumValue, indent: usize) { + let prefix = " ".repeat(indent); + output.push_str(&format!("{}{}:\n", prefix, name)); + output.push_str(&format!("{} variant: {}\n", prefix, value.variant_name)); + format_enum_payload_pretty(output, &value.payload, indent + 1); +} + +fn format_enum_payload_pretty(output: &mut String, payload: &EnumPayloadValue, indent: usize) { + let prefix = " ".repeat(indent); + + match payload { + EnumPayloadValue::Unit => output.push_str(&format!("{}payload: null\n", prefix)), + EnumPayloadValue::Newtype(value) => format_value_pretty(output, "payload", value, indent), + EnumPayloadValue::Tuple(values) => { + if values.is_empty() { + output.push_str(&format!("{}payload[]: []\n", prefix)); + } else { + output.push_str(&format!("{}payload[{}]:\n", prefix, values.len())); + for (index, value) in values.iter().enumerate() { + format_value_pretty(output, &format!("[{}]", index), value, indent + 1); + } + } + } + EnumPayloadValue::Struct(fields) => { + if fields.is_empty() { + output.push_str(&format!("{}payload: {{}}\n", prefix)); + } else { + output.push_str(&format!("{}payload:\n", prefix)); + format_named_fields_pretty(output, fields, indent + 1); + } + } + } +} + +fn format_named_fields_pretty(output: &mut String, fields: &[DynamicNamedValue], indent: usize) { + for field in fields { + format_value_pretty(output, &field.name, &field.value, indent); + } +} diff --git a/crates/ros-z-cli/src/commands/graph.rs b/crates/ros-z-cli/src/commands/graph.rs new file mode 100644 index 00000000..1b68c664 --- /dev/null +++ b/crates/ros-z-cli/src/commands/graph.rs @@ -0,0 +1,19 @@ +use color_eyre::eyre::Result; + +use crate::{ + app::AppContext, + render::{OutputMode, json, text}, +}; + +pub async fn run(app: &AppContext, output_mode: OutputMode) -> Result<()> { + app.wait_for_graph_settle().await; + let snapshot = app.snapshot(); + + match output_mode { + OutputMode::Json => json::print_pretty(&snapshot), + OutputMode::Text => { + text::print_graph_snapshot(&snapshot); + Ok(()) + } + } +} diff --git a/crates/ros-z-cli/src/commands/info.rs b/crates/ros-z-cli/src/commands/info.rs new file mode 100644 index 00000000..54e8f69d --- /dev/null +++ b/crates/ros-z-cli/src/commands/info.rs @@ -0,0 +1,120 @@ +use color_eyre::eyre::{Result, eyre}; +use ros_z::entity::EntityKind; + +use crate::{ + app::AppContext, + cli::InfoTarget, + model::info::{NodeInfo, ServiceInfo, TopicInfo}, + render::{OutputMode, json, text}, + support::{ + endpoints::{named_types, summarize_endpoints}, + nodes::{can_resolve_node_target, graph_node_key, resolve_node_target}, + }, +}; + +pub async fn run( + app: &AppContext, + output_mode: OutputMode, + target: InfoTarget, + name: &str, +) -> Result<()> { + match target { + InfoTarget::Topic => render_topic_info(app, output_mode, name).await, + InfoTarget::Service => render_service_info(app, output_mode, name).await, + InfoTarget::Node => render_node_info(app, output_mode, name).await, + } +} + +async fn render_topic_info(app: &AppContext, output_mode: OutputMode, topic: &str) -> Result<()> { + app.wait_for_graph_condition(|graph| { + graph + .get_topic_names_and_types() + .iter() + .any(|(name, _)| name == topic) + }) + .await; + + let graph = app.graph(); + let type_name = graph + .get_topic_names_and_types() + .into_iter() + .find_map(|(name, type_name)| (name == topic).then_some(type_name)) + .ok_or_else(|| eyre!("topic not found: {topic}"))?; + let info = TopicInfo::new( + topic.to_string(), + type_name, + summarize_endpoints(graph.get_entities_by_topic(EntityKind::Publisher, topic)), + summarize_endpoints(graph.get_entities_by_topic(EntityKind::Subscription, topic)), + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&info), + OutputMode::Text => { + text::print_topic_info(&info); + Ok(()) + } + } +} + +async fn render_service_info( + app: &AppContext, + output_mode: OutputMode, + service: &str, +) -> Result<()> { + app.wait_for_graph_condition(|graph| { + graph + .get_service_names_and_types() + .iter() + .any(|(name, _)| name == service) + }) + .await; + + let graph = app.graph(); + let type_name = graph + .get_service_names_and_types() + .into_iter() + .find_map(|(name, type_name)| (name == service).then_some(type_name)) + .ok_or_else(|| eyre!("service not found: {service}"))?; + let info = ServiceInfo::new( + service.to_string(), + type_name, + summarize_endpoints(graph.get_entities_by_service(EntityKind::Service, service)), + summarize_endpoints(graph.get_entities_by_service(EntityKind::Client, service)), + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&info), + OutputMode::Text => { + text::print_service_info(&info); + Ok(()) + } + } +} + +async fn render_node_info(app: &AppContext, output_mode: OutputMode, selector: &str) -> Result<()> { + app.wait_for_graph_condition(|graph| can_resolve_node_target(graph, selector)) + .await; + + let graph = app.graph(); + let target = resolve_node_target(graph, selector)?; + let node_key = graph_node_key(&target); + let info = NodeInfo::new( + target.name.clone(), + target.namespace.clone(), + target.fully_qualified_name(), + named_types(graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Publisher)), + named_types(graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Subscription)), + named_types(graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Service)), + named_types(graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Client)), + named_types(graph.get_action_server_names_and_types_by_node(node_key.clone())), + named_types(graph.get_action_client_names_and_types_by_node(node_key)), + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&info), + OutputMode::Text => { + text::print_node_info(&info); + Ok(()) + } + } +} diff --git a/crates/ros-z-cli/src/commands/list.rs b/crates/ros-z-cli/src/commands/list.rs new file mode 100644 index 00000000..1b10ea6c --- /dev/null +++ b/crates/ros-z-cli/src/commands/list.rs @@ -0,0 +1,80 @@ +use color_eyre::eyre::Result; +use ros_z::entity::EntityKind; + +use crate::{ + app::AppContext, + cli::ListTarget, + model::graph::{NodeSummary, ServiceSummary, TopicSummary}, + render::{OutputMode, json, text}, +}; + +pub async fn run(app: &AppContext, output_mode: OutputMode, target: ListTarget) -> Result<()> { + app.wait_for_graph_settle().await; + + match target { + ListTarget::Topics => render_topics(output_mode, app), + ListTarget::Nodes => render_nodes(output_mode, app), + ListTarget::Services => render_services(output_mode, app), + } +} + +fn render_topics(output_mode: OutputMode, app: &AppContext) -> Result<()> { + let mut topics: Vec<_> = app + .snapshot() + .topics + .into_iter() + .map(TopicSummary::from) + .collect(); + topics.sort_by(|left, right| left.name.cmp(&right.name)); + + match output_mode { + OutputMode::Json => json::print_pretty(&topics), + OutputMode::Text => { + text::print_topic_summaries(&topics); + Ok(()) + } + } +} + +fn render_nodes(output_mode: OutputMode, app: &AppContext) -> Result<()> { + let mut nodes: Vec<_> = app + .graph() + .get_node_names() + .into_iter() + .map(|(name, namespace)| NodeSummary::new(name, namespace)) + .collect(); + nodes.sort_by(|left, right| left.fqn.cmp(&right.fqn)); + + match output_mode { + OutputMode::Json => json::print_pretty(&nodes), + OutputMode::Text => { + text::print_node_summaries(&nodes); + Ok(()) + } + } +} + +fn render_services(output_mode: OutputMode, app: &AppContext) -> Result<()> { + let graph = app.graph(); + let mut services: Vec<_> = graph + .get_service_names_and_types() + .into_iter() + .map(|(name, type_name)| { + ServiceSummary::new( + name.clone(), + type_name, + graph.count_by_service(EntityKind::Service, &name), + graph.count_by_service(EntityKind::Client, &name), + ) + }) + .collect(); + services.sort_by(|left, right| left.name.cmp(&right.name)); + + match output_mode { + OutputMode::Json => json::print_pretty(&services), + OutputMode::Text => { + text::print_service_summaries(&services); + Ok(()) + } + } +} diff --git a/crates/ros-z-cli/src/commands/mod.rs b/crates/ros-z-cli/src/commands/mod.rs new file mode 100644 index 00000000..7164cc02 --- /dev/null +++ b/crates/ros-z-cli/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod echo; +pub mod graph; +pub mod info; +pub mod list; +pub mod param; +pub mod watch; diff --git a/crates/ros-z-cli/src/commands/param.rs b/crates/ros-z-cli/src/commands/param.rs new file mode 100644 index 00000000..3e8907df --- /dev/null +++ b/crates/ros-z-cli/src/commands/param.rs @@ -0,0 +1,141 @@ +use color_eyre::eyre::{Result, bail, eyre}; +use ros_z::parameter::{Parameter, ParameterTarget}; + +use crate::{ + app::AppContext, + cli::ParamCommand, + model::param::{ParameterGetView, ParameterListView, ParameterSetView, ParameterValueView}, + render::{OutputMode, json, text}, + support::{ + display_error, + nodes::{can_resolve_node_target, resolve_node_target}, + param_parse::parse_parameter_value, + }, +}; + +pub async fn run(app: &AppContext, output_mode: OutputMode, command: ParamCommand) -> Result<()> { + match command { + ParamCommand::List { + node, + prefix, + depth, + } => render_param_list(app, output_mode, &node, prefix, depth).await, + ParamCommand::Get { name, node } => render_param_get(app, output_mode, &node, &name).await, + ParamCommand::Set { + name, + value, + node, + value_type, + atomic, + } => render_param_set(app, output_mode, &node, &name, &value, value_type, atomic).await, + } +} + +async fn render_param_list( + app: &AppContext, + output_mode: OutputMode, + selector: &str, + prefix: Vec, + depth: Option, +) -> Result<()> { + let target = resolve_parameter_target(app, selector).await?; + let client = app.parameter_client(target.clone())?; + let response = client.list(&prefix, depth).await.map_err(display_error)?; + let view = ParameterListView::new( + target.fully_qualified_name(), + response.names, + response.prefixes, + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&view), + OutputMode::Text => { + text::print_parameter_list(&view); + Ok(()) + } + } +} + +async fn render_param_get( + app: &AppContext, + output_mode: OutputMode, + selector: &str, + name: &str, +) -> Result<()> { + let target = resolve_parameter_target(app, selector).await?; + let client = app.parameter_client(target.clone())?; + let values = client.get(&[name]).await.map_err(display_error)?; + let value = values + .into_iter() + .next() + .ok_or_else(|| eyre!("parameter not returned: {name}"))?; + let view = ParameterGetView::new( + target.fully_qualified_name(), + name.to_string(), + ParameterValueView::from_parameter_value(&value)?, + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&view), + OutputMode::Text => { + text::print_parameter_get(&view); + Ok(()) + } + } +} + +async fn render_param_set( + app: &AppContext, + output_mode: OutputMode, + selector: &str, + name: &str, + value: &str, + value_type: Option, + atomic: bool, +) -> Result<()> { + let target = resolve_parameter_target(app, selector).await?; + let client = app.parameter_client(target.clone())?; + let parameter_value = parse_parameter_value(value, value_type)?; + let parameter = Parameter::new(name.to_string(), parameter_value.clone()); + + if atomic { + let result = client + .set_atomically(&[parameter]) + .await + .map_err(display_error)?; + if !result.successful { + bail!("parameter update rejected: {}", result.reason); + } + } else { + let results = client.set(&[parameter]).await.map_err(display_error)?; + let result = results + .into_iter() + .next() + .ok_or_else(|| eyre!("missing set result for parameter {name}"))?; + if !result.successful { + bail!("parameter update rejected: {}", result.reason); + } + } + + let view = ParameterSetView::new( + target.fully_qualified_name(), + name.to_string(), + ParameterValueView::from_parameter_value(¶meter_value)?, + atomic, + true, + ); + + match output_mode { + OutputMode::Json => json::print_pretty(&view), + OutputMode::Text => { + text::print_parameter_set(&view); + Ok(()) + } + } +} + +async fn resolve_parameter_target(app: &AppContext, selector: &str) -> Result { + app.wait_for_graph_condition(|graph| can_resolve_node_target(graph, selector)) + .await; + resolve_node_target(app.graph(), selector) +} diff --git a/crates/ros-z-cli/src/commands/watch.rs b/crates/ros-z-cli/src/commands/watch.rs new file mode 100644 index 00000000..15d937bf --- /dev/null +++ b/crates/ros-z-cli/src/commands/watch.rs @@ -0,0 +1,37 @@ +use std::time::Duration; + +use color_eyre::eyre::Result; + +use crate::{ + app::AppContext, + model::watch::WatchEvent, + render::{OutputMode, json, text}, + support::graph::diff_snapshots, +}; + +const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(500); + +pub async fn run(app: &AppContext, output_mode: OutputMode) -> Result<()> { + app.wait_for_graph_settle().await; + + let mut previous = app.snapshot(); + match output_mode { + OutputMode::Json => json::print_line(&WatchEvent::InitialState { + snapshot: previous.clone(), + })?, + OutputMode::Text => text::print_graph_snapshot(&previous), + } + + loop { + tokio::time::sleep(WATCH_POLL_INTERVAL).await; + + let current = app.snapshot(); + for event in diff_snapshots(&previous, ¤t) { + match output_mode { + OutputMode::Json => json::print_line(&event)?, + OutputMode::Text => text::print_watch_event(&event), + } + } + previous = current; + } +} diff --git a/crates/ros-z-cli/src/lib.rs b/crates/ros-z-cli/src/lib.rs new file mode 100644 index 00000000..66b90462 --- /dev/null +++ b/crates/ros-z-cli/src/lib.rs @@ -0,0 +1,41 @@ +mod app; +pub mod cli; +mod commands; +mod model; +mod render; +mod support; + +use color_eyre::eyre::Result; + +use crate::{ + app::AppContext, + cli::{Cli, Command}, + render::OutputMode, +}; + +pub async fn run(cli: Cli) -> Result<()> { + let output_mode = OutputMode::from_json_flag(cli.json); + let app = AppContext::new(&cli.router, cli.domain, cli.backend)?; + + let result = match cli.command { + Command::List { target } => commands::list::run(&app, output_mode, target).await, + Command::Watch => commands::watch::run(&app, output_mode).await, + Command::Graph => commands::graph::run(&app, output_mode).await, + Command::Echo { + topic, + count, + timeout, + } => commands::echo::run(&app, output_mode, &topic, count, timeout).await, + Command::Info { target, name } => { + commands::info::run(&app, output_mode, target, &name).await + } + Command::Param { command } => commands::param::run(&app, output_mode, command).await, + }; + let shutdown_result = app.shutdown(); + + match (result, shutdown_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + } +} diff --git a/crates/ros-z-cli/src/main.rs b/crates/ros-z-cli/src/main.rs new file mode 100644 index 00000000..f34bfdd2 --- /dev/null +++ b/crates/ros-z-cli/src/main.rs @@ -0,0 +1,10 @@ +use clap::Parser; +use color_eyre::eyre::{Result, WrapErr}; + +use ros_z_cli::{cli::Cli, run}; + +#[tokio::main] +async fn main() -> Result<()> { + color_eyre::install().wrap_err("failed to install color-eyre")?; + run(Cli::parse()).await +} diff --git a/crates/ros-z-cli/src/model/echo.rs b/crates/ros-z-cli/src/model/echo.rs new file mode 100644 index 00000000..9880db4b --- /dev/null +++ b/crates/ros-z-cli/src/model/echo.rs @@ -0,0 +1,40 @@ +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize)] +pub struct EchoHeader { + pub topic: String, + #[serde(rename = "type")] + pub type_name: String, + pub type_hash: String, +} + +impl EchoHeader { + pub fn new(topic: String, type_name: String, type_hash: String) -> Self { + Self { + topic, + type_name, + type_hash, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct EchoMessageView { + pub topic: String, + #[serde(rename = "type")] + pub type_name: String, + pub type_hash: String, + pub data: Value, +} + +impl EchoMessageView { + pub fn new(topic: String, type_name: String, type_hash: String, data: Value) -> Self { + Self { + topic, + type_name, + type_hash, + data, + } + } +} diff --git a/crates/ros-z-cli/src/model/graph.rs b/crates/ros-z-cli/src/model/graph.rs new file mode 100644 index 00000000..ca11d43d --- /dev/null +++ b/crates/ros-z-cli/src/model/graph.rs @@ -0,0 +1,61 @@ +use serde::Serialize; + +use crate::support::nodes::fully_qualified_node_name; + +#[derive(Debug, Clone, Serialize)] +pub struct TopicSummary { + pub name: String, + #[serde(rename = "type")] + pub type_name: String, + pub publishers: usize, + pub subscribers: usize, +} + +impl From for TopicSummary { + fn from(value: ros_z::graph::TopicSnapshot) -> Self { + Self { + name: value.name, + type_name: value.type_name, + publishers: value.publishers, + subscribers: value.subscribers, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct NodeSummary { + pub name: String, + pub namespace: String, + pub fqn: String, +} + +impl NodeSummary { + pub fn new(name: String, namespace: String) -> Self { + let fqn = fully_qualified_node_name(&namespace, &name); + Self { + name, + namespace, + fqn, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceSummary { + pub name: String, + #[serde(rename = "type")] + pub type_name: String, + pub servers: usize, + pub clients: usize, +} + +impl ServiceSummary { + pub fn new(name: String, type_name: String, servers: usize, clients: usize) -> Self { + Self { + name, + type_name, + servers, + clients, + } + } +} diff --git a/crates/ros-z-cli/src/model/info.rs b/crates/ros-z-cli/src/model/info.rs new file mode 100644 index 00000000..a7d1a5de --- /dev/null +++ b/crates/ros-z-cli/src/model/info.rs @@ -0,0 +1,110 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct EndpointSummary { + pub node: Option, + pub type_hash: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct NamedType { + pub name: String, + #[serde(rename = "type")] + pub type_name: String, +} + +impl NamedType { + pub fn new(name: String, type_name: String) -> Self { + Self { name, type_name } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct TopicInfo { + pub name: String, + #[serde(rename = "type")] + pub type_name: String, + pub publishers: Vec, + pub subscribers: Vec, +} + +impl TopicInfo { + pub fn new( + name: String, + type_name: String, + publishers: Vec, + subscribers: Vec, + ) -> Self { + Self { + name, + type_name, + publishers, + subscribers, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceInfo { + pub name: String, + #[serde(rename = "type")] + pub type_name: String, + pub servers: Vec, + pub clients: Vec, +} + +impl ServiceInfo { + pub fn new( + name: String, + type_name: String, + servers: Vec, + clients: Vec, + ) -> Self { + Self { + name, + type_name, + servers, + clients, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct NodeInfo { + pub name: String, + pub namespace: String, + pub fqn: String, + pub publishers: Vec, + pub subscribers: Vec, + pub services: Vec, + pub clients: Vec, + pub action_servers: Vec, + pub action_clients: Vec, +} + +impl NodeInfo { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + namespace: String, + fqn: String, + publishers: Vec, + subscribers: Vec, + services: Vec, + clients: Vec, + action_servers: Vec, + action_clients: Vec, + ) -> Self { + Self { + name, + namespace, + fqn, + publishers, + subscribers, + services, + clients, + action_servers, + action_clients, + } + } +} diff --git a/crates/ros-z-cli/src/model/mod.rs b/crates/ros-z-cli/src/model/mod.rs new file mode 100644 index 00000000..8d014d7a --- /dev/null +++ b/crates/ros-z-cli/src/model/mod.rs @@ -0,0 +1,5 @@ +pub mod echo; +pub mod graph; +pub mod info; +pub mod param; +pub mod watch; diff --git a/crates/ros-z-cli/src/model/param.rs b/crates/ros-z-cli/src/model/param.rs new file mode 100644 index 00000000..c8f1f1ac --- /dev/null +++ b/crates/ros-z-cli/src/model/param.rs @@ -0,0 +1,123 @@ +use color_eyre::eyre::Result; +use ros_z::parameter::ParameterValue; +use serde::Serialize; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, Serialize)] +pub struct ParameterListView { + pub node: String, + pub names: Vec, + pub prefixes: Vec, +} + +impl ParameterListView { + pub fn new(node: String, names: Vec, prefixes: Vec) -> Self { + Self { + node, + names, + prefixes, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ParameterValueView { + #[serde(rename = "type")] + pub value_type: String, + pub value: Value, + #[serde(skip_serializing)] + pub display: String, +} + +impl ParameterValueView { + pub fn from_parameter_value(value: &ParameterValue) -> Result { + Ok(Self { + value_type: parameter_value_type_name(value).to_string(), + value: parameter_value_to_json(value), + display: format_parameter_value(value)?, + }) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ParameterGetView { + pub node: String, + pub name: String, + #[serde(flatten)] + pub parameter: ParameterValueView, +} + +impl ParameterGetView { + pub fn new(node: String, name: String, parameter: ParameterValueView) -> Self { + Self { + node, + name, + parameter, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ParameterSetView { + pub node: String, + pub name: String, + #[serde(flatten)] + pub parameter: ParameterValueView, + pub atomic: bool, + pub successful: bool, +} + +impl ParameterSetView { + pub fn new( + node: String, + name: String, + parameter: ParameterValueView, + atomic: bool, + successful: bool, + ) -> Self { + Self { + node, + name, + parameter, + atomic, + successful, + } + } +} + +fn parameter_value_to_json(value: &ParameterValue) -> Value { + match value { + ParameterValue::NotSet => Value::Null, + ParameterValue::Bool(value) => json!(value), + ParameterValue::Integer(value) => json!(value), + ParameterValue::Double(value) => json!(value), + ParameterValue::String(value) => json!(value), + ParameterValue::ByteArray(value) => json!(value), + ParameterValue::BoolArray(value) => json!(value), + ParameterValue::IntegerArray(value) => json!(value), + ParameterValue::DoubleArray(value) => json!(value), + ParameterValue::StringArray(value) => json!(value), + } +} + +fn parameter_value_type_name(value: &ParameterValue) -> &'static str { + match value { + ParameterValue::NotSet => "not_set", + ParameterValue::Bool(_) => "bool", + ParameterValue::Integer(_) => "integer", + ParameterValue::Double(_) => "double", + ParameterValue::String(_) => "string", + ParameterValue::ByteArray(_) => "byte_array", + ParameterValue::BoolArray(_) => "bool_array", + ParameterValue::IntegerArray(_) => "integer_array", + ParameterValue::DoubleArray(_) => "double_array", + ParameterValue::StringArray(_) => "string_array", + } +} + +fn format_parameter_value(value: &ParameterValue) -> Result { + Ok(match value { + ParameterValue::String(value) => value.clone(), + _ => serde_json::to_string(¶meter_value_to_json(value))?, + }) +} diff --git a/crates/ros-z-cli/src/model/watch.rs b/crates/ros-z-cli/src/model/watch.rs new file mode 100644 index 00000000..bb495bf3 --- /dev/null +++ b/crates/ros-z-cli/src/model/watch.rs @@ -0,0 +1,14 @@ +use ros_z::graph::GraphSnapshot; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum WatchEvent { + InitialState { snapshot: GraphSnapshot }, + TopicDiscovered { name: String, type_name: String }, + TopicRemoved { name: String }, + NodeDiscovered { namespace: String, name: String }, + NodeRemoved { namespace: String, name: String }, + ServiceDiscovered { name: String, type_name: String }, + ServiceRemoved { name: String }, +} diff --git a/crates/ros-z-cli/src/render/json.rs b/crates/ros-z-cli/src/render/json.rs new file mode 100644 index 00000000..337b28ba --- /dev/null +++ b/crates/ros-z-cli/src/render/json.rs @@ -0,0 +1,12 @@ +use color_eyre::eyre::Result; +use serde::Serialize; + +pub fn print_pretty(value: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +pub fn print_line(value: &T) -> Result<()> { + println!("{}", serde_json::to_string(value)?); + Ok(()) +} diff --git a/crates/ros-z-cli/src/render/mod.rs b/crates/ros-z-cli/src/render/mod.rs new file mode 100644 index 00000000..7fc59790 --- /dev/null +++ b/crates/ros-z-cli/src/render/mod.rs @@ -0,0 +1,18 @@ +pub mod json; +pub mod text; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + Text, + Json, +} + +impl OutputMode { + pub const fn from_json_flag(json: bool) -> Self { + if json { Self::Json } else { Self::Text } + } + + pub const fn is_text(self) -> bool { + matches!(self, Self::Text) + } +} diff --git a/crates/ros-z-cli/src/render/text.rs b/crates/ros-z-cli/src/render/text.rs new file mode 100644 index 00000000..a6080bd5 --- /dev/null +++ b/crates/ros-z-cli/src/render/text.rs @@ -0,0 +1,226 @@ +use ros_z::graph::GraphSnapshot; + +use crate::{ + model::{ + echo::EchoHeader, + graph::{NodeSummary, ServiceSummary, TopicSummary}, + info::{EndpointSummary, NamedType, NodeInfo, ServiceInfo, TopicInfo}, + param::{ParameterGetView, ParameterListView, ParameterSetView}, + watch::WatchEvent, + }, + support::nodes::fully_qualified_node_name, +}; + +pub fn print_topic_summaries(topics: &[TopicSummary]) { + let name_width = column_width(topics.iter().map(|topic| topic.name.as_str())); + let type_width = column_width(topics.iter().map(|topic| topic.type_name.as_str())); + + for topic in topics { + println!( + "{: = snapshot + .topics + .clone() + .into_iter() + .map(TopicSummary::from) + .collect(); + print_topic_summaries(&topics); + println!(); + + println!("Nodes ({})", snapshot.nodes.len()); + let mut nodes: Vec<_> = snapshot + .nodes + .iter() + .map(|node| NodeSummary::new(node.name.clone(), node.namespace.clone())) + .collect(); + nodes.sort_by(|left, right| left.fqn.cmp(&right.fqn)); + print_node_summaries(&nodes); + println!(); + + println!("Services ({})", snapshot.services.len()); + let mut services: Vec<_> = snapshot + .services + .iter() + .map(|service| ServiceSummary::new(service.name.clone(), service.type_name.clone(), 0, 0)) + .collect(); + services.sort_by(|left, right| left.name.cmp(&right.name)); + let name_width = column_width(services.iter().map(|service| service.name.as_str())); + let type_width = column_width(services.iter().map(|service| service.type_name.as_str())); + for service in services { + println!( + "{:, seen: usize) { + print!("{message}"); + if count.is_none_or(|limit| seen < limit) { + println!(); + } +} + +pub fn print_watch_event(event: &WatchEvent) { + match event { + WatchEvent::InitialState { snapshot } => print_graph_snapshot(snapshot), + WatchEvent::TopicDiscovered { name, type_name } => { + println!("topic + {name} ({type_name})"); + } + WatchEvent::TopicRemoved { name } => { + println!("topic - {name}"); + } + WatchEvent::NodeDiscovered { namespace, name } => { + println!("node + {}", fully_qualified_node_name(namespace, name)); + } + WatchEvent::NodeRemoved { namespace, name } => { + println!("node - {}", fully_qualified_node_name(namespace, name)); + } + WatchEvent::ServiceDiscovered { name, type_name } => { + println!("service + {name} ({type_name})"); + } + WatchEvent::ServiceRemoved { name } => { + println!("service - {name}"); + } + } +} + +fn print_endpoint_section(label: &str, endpoints: &[EndpointSummary]) { + println!("{label} ({})", endpoints.len()); + if endpoints.is_empty() { + println!("none"); + return; + } + + for endpoint in endpoints { + match (&endpoint.node, &endpoint.type_hash) { + (Some(node), Some(type_hash)) => println!("{node} [{type_hash}]"), + (Some(node), None) => println!("{node}"), + (None, Some(type_hash)) => println!("unknown [{type_hash}]"), + (None, None) => println!("unknown"), + } + } +} + +fn print_named_type_section(label: &str, entries: &[NamedType]) { + println!("{label} ({})", entries.len()); + if entries.is_empty() { + println!("none"); + return; + } + + let name_width = column_width(entries.iter().map(|entry| entry.name.as_str())); + for entry in entries { + println!("{:(values: impl Iterator) -> usize { + values.map(str::len).max().unwrap_or(0) +} diff --git a/crates/ros-z-cli/src/support/endpoints.rs b/crates/ros-z-cli/src/support/endpoints.rs new file mode 100644 index 00000000..1eb282fa --- /dev/null +++ b/crates/ros-z-cli/src/support/endpoints.rs @@ -0,0 +1,39 @@ +use std::{collections::BTreeSet, sync::Arc}; + +use ros_z::entity::{Entity, entity_get_endpoint}; + +use crate::{ + model::info::{EndpointSummary, NamedType}, + support::nodes::fully_qualified_node_name, +}; + +pub fn summarize_endpoints(entities: Vec>) -> Vec { + let mut endpoints = BTreeSet::new(); + + for entity in entities { + if let Some(endpoint) = entity_get_endpoint(&entity) { + let node = endpoint + .node + .as_ref() + .map(|node| fully_qualified_node_name(&node.namespace, &node.name)); + let type_hash = endpoint + .type_info + .as_ref() + .map(|type_info| type_info.hash.to_string()); + endpoints.insert((node, type_hash)); + } + } + + endpoints + .into_iter() + .map(|(node, type_hash)| EndpointSummary { node, type_hash }) + .collect() +} + +pub fn named_types(entries: Vec<(String, String)>) -> Vec { + let unique: BTreeSet<_> = entries.into_iter().collect(); + unique + .into_iter() + .map(|(name, type_name)| NamedType::new(name, type_name)) + .collect() +} diff --git a/crates/ros-z-cli/src/support/graph.rs b/crates/ros-z-cli/src/support/graph.rs new file mode 100644 index 00000000..d736af88 --- /dev/null +++ b/crates/ros-z-cli/src/support/graph.rs @@ -0,0 +1,131 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use ros_z::graph::GraphSnapshot; + +use crate::model::watch::WatchEvent; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotFingerprint { + topics: Vec<(String, String, usize, usize)>, + nodes: Vec<(String, String)>, + services: Vec<(String, String)>, +} + +impl From<&GraphSnapshot> for SnapshotFingerprint { + fn from(snapshot: &GraphSnapshot) -> Self { + let mut topics: Vec<_> = snapshot + .topics + .iter() + .map(|topic| { + ( + topic.name.clone(), + topic.type_name.clone(), + topic.publishers, + topic.subscribers, + ) + }) + .collect(); + topics.sort(); + + let mut nodes: Vec<_> = snapshot + .nodes + .iter() + .map(|node| (node.namespace.clone(), node.name.clone())) + .collect(); + nodes.sort(); + + let mut services: Vec<_> = snapshot + .services + .iter() + .map(|service| (service.name.clone(), service.type_name.clone())) + .collect(); + services.sort(); + + Self { + topics, + nodes, + services, + } + } +} + +pub fn diff_snapshots(previous: &GraphSnapshot, current: &GraphSnapshot) -> Vec { + let mut events = Vec::new(); + + let previous_topics: BTreeMap<_, _> = previous + .topics + .iter() + .map(|topic| (topic.name.clone(), topic.type_name.clone())) + .collect(); + let current_topics: BTreeMap<_, _> = current + .topics + .iter() + .map(|topic| (topic.name.clone(), topic.type_name.clone())) + .collect(); + for (name, type_name) in ¤t_topics { + if !previous_topics.contains_key(name) { + events.push(WatchEvent::TopicDiscovered { + name: name.clone(), + type_name: type_name.clone(), + }); + } + } + for name in previous_topics.keys() { + if !current_topics.contains_key(name) { + events.push(WatchEvent::TopicRemoved { name: name.clone() }); + } + } + + let previous_nodes: BTreeSet<_> = previous + .nodes + .iter() + .map(|node| (node.namespace.clone(), node.name.clone())) + .collect(); + let current_nodes: BTreeSet<_> = current + .nodes + .iter() + .map(|node| (node.namespace.clone(), node.name.clone())) + .collect(); + for (namespace, name) in ¤t_nodes { + if !previous_nodes.contains(&(namespace.clone(), name.clone())) { + events.push(WatchEvent::NodeDiscovered { + namespace: namespace.clone(), + name: name.clone(), + }); + } + } + for (namespace, name) in &previous_nodes { + if !current_nodes.contains(&(namespace.clone(), name.clone())) { + events.push(WatchEvent::NodeRemoved { + namespace: namespace.clone(), + name: name.clone(), + }); + } + } + + let previous_services: BTreeMap<_, _> = previous + .services + .iter() + .map(|service| (service.name.clone(), service.type_name.clone())) + .collect(); + let current_services: BTreeMap<_, _> = current + .services + .iter() + .map(|service| (service.name.clone(), service.type_name.clone())) + .collect(); + for (name, type_name) in ¤t_services { + if !previous_services.contains_key(name) { + events.push(WatchEvent::ServiceDiscovered { + name: name.clone(), + type_name: type_name.clone(), + }); + } + } + for name in previous_services.keys() { + if !current_services.contains_key(name) { + events.push(WatchEvent::ServiceRemoved { name: name.clone() }); + } + } + + events +} diff --git a/crates/ros-z-cli/src/support/mod.rs b/crates/ros-z-cli/src/support/mod.rs new file mode 100644 index 00000000..d28b048d --- /dev/null +++ b/crates/ros-z-cli/src/support/mod.rs @@ -0,0 +1,12 @@ +pub mod endpoints; +pub mod graph; +pub mod nodes; +pub mod param_parse; + +use std::fmt::Display; + +use color_eyre::eyre::{Report, eyre}; + +pub fn display_error(error: E) -> Report { + eyre!("{error}") +} diff --git a/crates/ros-z-cli/src/support/nodes.rs b/crates/ros-z-cli/src/support/nodes.rs new file mode 100644 index 00000000..c94c02df --- /dev/null +++ b/crates/ros-z-cli/src/support/nodes.rs @@ -0,0 +1,71 @@ +use std::collections::BTreeSet; + +use color_eyre::eyre::{Result, bail, eyre}; +use ros_z::parameter::ParameterTarget; + +pub fn resolve_node_target(graph: &ros_z::graph::Graph, selector: &str) -> Result { + if selector.starts_with('/') { + let target = ParameterTarget::from_fqn(selector) + .ok_or_else(|| eyre!("invalid fully-qualified node name: {selector}"))?; + if node_candidates(graph) + .iter() + .any(|candidate| candidate == &target) + { + return Ok(target); + } + bail!("node not found: {selector}"); + } + + let matches: Vec<_> = node_candidates(graph) + .into_iter() + .filter(|candidate| candidate.name == selector) + .collect(); + + match matches.as_slice() { + [] => bail!("node not found: {selector}"), + [target] => Ok(target.clone()), + _ => bail!( + "node name '{selector}' is ambiguous: {}", + matches + .iter() + .map(ParameterTarget::fully_qualified_name) + .collect::>() + .join(", ") + ), + } +} + +pub fn can_resolve_node_target(graph: &ros_z::graph::Graph, selector: &str) -> bool { + resolve_node_target(graph, selector).is_ok() +} + +pub fn graph_node_key(target: &ParameterTarget) -> (String, String) { + ( + normalize_node_namespace(&target.namespace), + target.name.clone(), + ) +} + +pub fn fully_qualified_node_name(namespace: &str, name: &str) -> String { + if namespace == "/" { + format!("/{name}") + } else { + format!("{namespace}/{name}") + } +} + +fn node_candidates(graph: &ros_z::graph::Graph) -> Vec { + let mut nodes = BTreeSet::new(); + for (name, namespace) in graph.get_node_names() { + nodes.insert(ParameterTarget::new(namespace, name)); + } + nodes.into_iter().collect() +} + +fn normalize_node_namespace(namespace: &str) -> String { + if namespace.is_empty() || namespace == "/" { + String::new() + } else { + namespace.to_string() + } +} diff --git a/crates/ros-z-cli/src/support/param_parse.rs b/crates/ros-z-cli/src/support/param_parse.rs new file mode 100644 index 00000000..db54c462 --- /dev/null +++ b/crates/ros-z-cli/src/support/param_parse.rs @@ -0,0 +1,229 @@ +use color_eyre::eyre::{Result, bail}; +use ros_z::parameter::ParameterValue; +use serde_json::Value; + +use crate::cli::ParameterValueTypeArg; + +pub fn parse_parameter_value( + input: &str, + value_type: Option, +) -> Result { + match value_type { + Some(value_type) => parse_typed_parameter_value(input, value_type), + None => infer_parameter_value(input), + } +} + +fn parse_typed_parameter_value( + input: &str, + value_type: ParameterValueTypeArg, +) -> Result { + match value_type { + ParameterValueTypeArg::Bool => Ok(ParameterValue::Bool(parse_bool(input)?)), + ParameterValueTypeArg::Integer => Ok(ParameterValue::Integer(input.parse()?)), + ParameterValueTypeArg::Double => Ok(ParameterValue::Double(input.parse()?)), + ParameterValueTypeArg::String => Ok(ParameterValue::String(input.to_string())), + ParameterValueTypeArg::ByteArray => Ok(ParameterValue::ByteArray(parse_byte_array(input)?)), + ParameterValueTypeArg::BoolArray => Ok(ParameterValue::BoolArray(parse_bool_array(input)?)), + ParameterValueTypeArg::IntegerArray => { + Ok(ParameterValue::IntegerArray(parse_integer_array(input)?)) + } + ParameterValueTypeArg::DoubleArray => { + Ok(ParameterValue::DoubleArray(parse_double_array(input)?)) + } + ParameterValueTypeArg::StringArray => { + Ok(ParameterValue::StringArray(parse_string_array(input)?)) + } + ParameterValueTypeArg::NotSet => Ok(ParameterValue::NotSet), + } +} + +fn infer_parameter_value(input: &str) -> Result { + if let Ok(values) = parse_json_array(input) { + return infer_array_parameter_value(values); + } + + if let Ok(value) = parse_bool(input) { + return Ok(ParameterValue::Bool(value)); + } + + if let Ok(value) = input.parse::() { + return Ok(ParameterValue::Integer(value)); + } + + if let Ok(value) = input.parse::() { + return Ok(ParameterValue::Double(value)); + } + + Ok(ParameterValue::String(input.to_string())) +} + +fn infer_array_parameter_value(values: Vec) -> Result { + if values.is_empty() { + return Ok(ParameterValue::StringArray(Vec::new())); + } + + if values.iter().all(Value::is_boolean) { + return Ok(ParameterValue::BoolArray( + values + .into_iter() + .map(|value| value.as_bool().expect("checked above")) + .collect(), + )); + } + + if values.iter().all(|value| value.as_i64().is_some()) { + return Ok(ParameterValue::IntegerArray( + values + .into_iter() + .map(|value| value.as_i64().expect("checked above")) + .collect(), + )); + } + + if values.iter().all(|value| value.as_f64().is_some()) { + return Ok(ParameterValue::DoubleArray( + values + .into_iter() + .map(|value| value.as_f64().expect("checked above")) + .collect(), + )); + } + + if values.iter().all(Value::is_string) { + return Ok(ParameterValue::StringArray( + values + .into_iter() + .map(|value| value.as_str().expect("checked above").to_string()) + .collect(), + )); + } + + bail!("array values must be homogeneous booleans, numbers, or strings") +} + +fn parse_json_array(input: &str) -> Result> { + let value: Value = serde_json::from_str(input)?; + match value { + Value::Array(values) => Ok(values), + _ => bail!("expected JSON array"), + } +} + +fn parse_bool(input: &str) -> Result { + match input { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("expected 'true' or 'false'"), + } +} + +fn parse_bool_array(input: &str) -> Result> { + parse_json_array(input)? + .into_iter() + .map(|value| match value { + Value::Bool(value) => Ok(value), + _ => bail!("bool arrays must use JSON booleans, e.g. [true,false]"), + }) + .collect() +} + +fn parse_integer_array(input: &str) -> Result> { + parse_json_array(input)? + .into_iter() + .map(|value| match value.as_i64() { + Some(value) => Ok(value), + None => bail!("integer arrays must use JSON integers, e.g. [1,2,3]"), + }) + .collect() +} + +fn parse_double_array(input: &str) -> Result> { + parse_json_array(input)? + .into_iter() + .map(|value| match value.as_f64() { + Some(value) => Ok(value), + None => bail!("double arrays must use JSON numbers, e.g. [1.0,2.5]"), + }) + .collect() +} + +fn parse_string_array(input: &str) -> Result> { + parse_json_array(input)? + .into_iter() + .map(|value| match value { + Value::String(value) => Ok(value), + _ => bail!("string arrays must use JSON strings, e.g. [\"a\",\"b\"]"), + }) + .collect() +} + +fn parse_byte_array(input: &str) -> Result> { + parse_json_array(input)? + .into_iter() + .map(|value| match value.as_u64() { + Some(number) if u8::try_from(number).is_ok() => Ok(number as u8), + _ => bail!("byte arrays must use JSON integers in the range 0..=255"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use ros_z::parameter::ParameterValue; + + use super::parse_parameter_value; + use crate::cli::ParameterValueTypeArg; + + #[test] + fn infers_scalar_values() { + assert_eq!( + parse_parameter_value("true", None).unwrap(), + ParameterValue::Bool(true) + ); + assert_eq!( + parse_parameter_value("42", None).unwrap(), + ParameterValue::Integer(42) + ); + assert_eq!( + parse_parameter_value("3.5", None).unwrap(), + ParameterValue::Double(3.5) + ); + assert_eq!( + parse_parameter_value("hello", None).unwrap(), + ParameterValue::String("hello".to_string()) + ); + } + + #[test] + fn infers_array_values() { + assert_eq!( + parse_parameter_value("[1,2,3]", None).unwrap(), + ParameterValue::IntegerArray(vec![1, 2, 3]) + ); + assert_eq!( + parse_parameter_value("[1.0,2.5]", None).unwrap(), + ParameterValue::DoubleArray(vec![1.0, 2.5]) + ); + assert_eq!( + parse_parameter_value(r#"["a","b"]"#, None).unwrap(), + ParameterValue::StringArray(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn honors_explicit_type_overrides() { + assert_eq!( + parse_parameter_value("001", Some(ParameterValueTypeArg::String)).unwrap(), + ParameterValue::String("001".to_string()) + ); + assert_eq!( + parse_parameter_value("[0,255]", Some(ParameterValueTypeArg::ByteArray)).unwrap(), + ParameterValue::ByteArray(vec![0, 255]) + ); + assert_eq!( + parse_parameter_value("ignored", Some(ParameterValueTypeArg::NotSet)).unwrap(), + ParameterValue::NotSet + ); + } +} diff --git a/crates/ros-z-console/src/app/render/services.rs b/crates/ros-z-console/src/app/render/services.rs index 3423d38e..b44b305e 100644 --- a/crates/ros-z-console/src/app/render/services.rs +++ b/crates/ros-z-console/src/app/render/services.rs @@ -100,10 +100,15 @@ impl App { for (idx, entity) in server_entities.iter().enumerate() { if let Some(endpoint) = entity_get_endpoint(entity) { detail.push_str(&format!(" Server {}:\n", idx + 1)); - detail.push_str(&format!( - " Node: {}/{}\n", - endpoint.node.namespace, endpoint.node.name - )); + match endpoint.node.as_ref() { + Some(node) => { + detail.push_str(&format!( + " Node: {}/{}\n", + node.namespace, node.name + )); + } + None => detail.push_str(" Node: unknown\n"), + } detail.push_str(&format_qos_detail(&endpoint.qos)); detail.push_str("\n\n"); } @@ -131,10 +136,15 @@ impl App { for (idx, entity) in client_entities.iter().enumerate() { if let Some(endpoint) = entity_get_endpoint(entity) { detail.push_str(&format!(" Client {}:\n", idx + 1)); - detail.push_str(&format!( - " Node: {}/{}\n", - endpoint.node.namespace, endpoint.node.name - )); + match endpoint.node.as_ref() { + Some(node) => { + detail.push_str(&format!( + " Node: {}/{}\n", + node.namespace, node.name + )); + } + None => detail.push_str(" Node: unknown\n"), + } detail.push_str(&format_qos_detail(&endpoint.qos)); detail.push_str("\n\n"); } diff --git a/crates/ros-z-console/src/app/render/topics.rs b/crates/ros-z-console/src/app/render/topics.rs index 898e4245..b7c9cf8d 100644 --- a/crates/ros-z-console/src/app/render/topics.rs +++ b/crates/ros-z-console/src/app/render/topics.rs @@ -145,10 +145,15 @@ impl App { for (idx, entity) in pub_entities.iter().enumerate() { if let Some(endpoint) = entity_get_endpoint(entity) { detail.push_str(&format!(" Publisher {}:\n", idx + 1)); - detail.push_str(&format!( - " Node: {}/{}\n", - endpoint.node.namespace, endpoint.node.name - )); + match endpoint.node.as_ref() { + Some(node) => { + detail.push_str(&format!( + " Node: {}/{}\n", + node.namespace, node.name + )); + } + None => detail.push_str(" Node: unknown\n"), + } if let Some(ti) = &endpoint.type_info { let hash_str = ti.hash.to_rihs_string(); @@ -187,10 +192,15 @@ impl App { for (idx, entity) in sub_entities.iter().enumerate() { if let Some(endpoint) = entity_get_endpoint(entity) { detail.push_str(&format!(" Subscriber {}:\n", idx + 1)); - detail.push_str(&format!( - " Node: {}/{}\n", - endpoint.node.namespace, endpoint.node.name - )); + match endpoint.node.as_ref() { + Some(node) => { + detail.push_str(&format!( + " Node: {}/{}\n", + node.namespace, node.name + )); + } + None => detail.push_str(" Node: unknown\n"), + } if let Some(ti) = &endpoint.type_info { let hash_str = ti.hash.to_rihs_string(); diff --git a/crates/ros-z-console/src/core/dynamic_subscriber.rs b/crates/ros-z-console/src/core/dynamic_subscriber.rs deleted file mode 100644 index 4d5a55e9..00000000 --- a/crates/ros-z-console/src/core/dynamic_subscriber.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Dynamic topic subscriber for any ROS message type -//! -//! Provides automatic schema discovery and message reception for topics -//! without compile-time knowledge of message types. - -use std::{sync::Arc, time::Duration}; - -use flume::Receiver; -use ros_z::{ - dynamic::{DynamicMessage, DynamicSerdeCdrSerdes, MessageSchema}, - node::ZNode, - pubsub::ZSub, -}; -use zenoh::sample::Sample; - -/// Manages subscription to a single topic with dynamic message types -pub struct DynamicTopicSubscriber { - /// Topic name being subscribed to - #[allow(dead_code)] - pub topic: String, - /// Discovered message schema - schema: Arc, - /// RIHS01 type hash for compatibility verification - type_hash: String, - /// Channel for receiving messages asynchronously - message_rx: Receiver, - /// Subscriber handle (kept alive to maintain subscription) - _subscriber: Arc>, -} - -impl DynamicTopicSubscriber { - /// Create a new dynamic subscriber with automatic schema discovery - /// - /// # Arguments - /// - /// * `node` - ROS node with type description service enabled - /// * `topic` - Topic name to subscribe to (e.g., "/chatter") - /// * `discovery_timeout` - Maximum time to wait for schema discovery - /// - /// # Errors - /// - /// Returns error if: - /// - No publishers found on topic within timeout - /// - Schema discovery fails - /// - Subscriber creation fails - pub async fn new( - node: &ZNode, - topic: &str, - discovery_timeout: Duration, - ) -> Result> { - // Use the node's auto-discovery method to get subscriber and schema - let (subscriber, schema) = node.create_dyn_sub_auto(topic, discovery_timeout).await?; - - // Compute type hash for informational purposes - use ros_z::dynamic::MessageSchemaTypeDescription; - let type_hash = schema - .compute_type_hash() - .map(|h| h.to_rihs_string()) - .unwrap_or_else(|_| "unknown".to_string()); - - // Create channel for async message handling - let (tx, rx) = flume::unbounded(); - - // Spawn background task to forward messages to channel - let subscriber = Arc::new(subscriber); - let sub_clone = subscriber.clone(); - tokio::task::spawn_blocking(move || { - loop { - match sub_clone.recv() { - Ok(msg) => { - if tx.send(msg).is_err() { - // Receiver dropped, exit task - break; - } - } - Err(e) => { - tracing::warn!("Subscriber recv error: {}", e); - break; - } - } - } - }); - - Ok(Self { - topic: topic.to_string(), - schema, - type_hash, - message_rx: rx, - _subscriber: subscriber, - }) - } - - /// Get the discovered message schema - pub fn schema(&self) -> &MessageSchema { - &self.schema - } - - /// Get the RIHS01 type hash - pub fn type_hash(&self) -> &str { - &self.type_hash - } - - /// Receive the next message (blocking) - /// - /// Blocks until a message is available or the channel is closed. - #[allow(dead_code)] - pub fn recv(&self) -> Result { - self.message_rx.recv() - } - - /// Try to receive a message without blocking - /// - /// Returns `Ok(Some(msg))` if a message is available, - /// `Ok(None)` if no message is ready, - /// or `Err` if the channel is closed. - pub fn try_recv(&self) -> Result, flume::TryRecvError> { - match self.message_rx.try_recv() { - Ok(msg) => Ok(Some(msg)), - Err(flume::TryRecvError::Empty) => Ok(None), - Err(e) => Err(e), - } - } - - /// Check if there are any messages waiting - #[allow(dead_code)] - pub fn has_messages(&self) -> bool { - !self.message_rx.is_empty() - } - - /// Get the number of messages currently buffered - #[allow(dead_code)] - pub fn message_count(&self) -> usize { - self.message_rx.len() - } -} - -#[cfg(test)] -mod tests { - // Note: Integration tests would require a running ROS system - // Unit tests are limited for this component -} diff --git a/crates/ros-z-console/src/core/engine.rs b/crates/ros-z-console/src/core/engine.rs index af8977a5..be40d4c8 100644 --- a/crates/ros-z-console/src/core/engine.rs +++ b/crates/ros-z-console/src/core/engine.rs @@ -8,12 +8,10 @@ use std::{ }; use parking_lot::Mutex; -use ros_z::{Builder, context::ZContext, graph::Graph, node::ZNode}; +use ros_z::{Builder, context::ZContext, dynamic::DynSubBuilder, graph::Graph, node::ZNode}; use tokio::sync::broadcast; -use super::{ - dynamic_subscriber::DynamicTopicSubscriber, events::SystemEvent, metrics::MetricsCollector, -}; +use super::{events::SystemEvent, metrics::MetricsCollector}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Backend { @@ -133,12 +131,14 @@ impl CoreEngine { /// # Errors /// /// Returns error if schema discovery fails or subscriber creation fails - pub async fn create_dynamic_subscriber( + pub async fn create_dynamic_subscriber_builder( &self, topic: &str, discovery_timeout: Duration, - ) -> Result> { - DynamicTopicSubscriber::new(&self.node, topic, discovery_timeout).await + ) -> Result> { + self.node + .create_dyn_sub_auto(topic, discovery_timeout) + .await } pub async fn start_monitoring(&self) { diff --git a/crates/ros-z-console/src/core/message_formatter.rs b/crates/ros-z-console/src/core/message_formatter.rs index 896636bc..6a83a3da 100644 --- a/crates/ros-z-console/src/core/message_formatter.rs +++ b/crates/ros-z-console/src/core/message_formatter.rs @@ -2,7 +2,9 @@ //! //! Provides JSON and human-readable text formatting for dynamic messages. -use ros_z::dynamic::{DynamicMessage, DynamicValue}; +use ros_z::dynamic::{ + DynamicMessage, DynamicNamedValue, DynamicValue, EnumPayloadValue, EnumValue, +}; use serde_json; /// Convert a DynamicMessage to a JSON value @@ -47,12 +49,45 @@ pub fn dynamic_value_to_json(value: &DynamicValue) -> serde_json::Value { ) } DynamicValue::Message(msg) => dynamic_message_to_json(msg), + DynamicValue::Optional(None) => serde_json::Value::Null, + DynamicValue::Optional(Some(value)) => dynamic_value_to_json(value), + DynamicValue::Enum(value) => enum_value_to_json(value), DynamicValue::Array(arr) => { serde_json::Value::Array(arr.iter().map(dynamic_value_to_json).collect()) } } } +fn enum_value_to_json(value: &EnumValue) -> serde_json::Value { + let mut fields = serde_json::Map::new(); + fields.insert( + "variant_index".to_string(), + serde_json::Value::Number(value.variant_index.into()), + ); + fields.insert( + "variant_name".to_string(), + serde_json::Value::String(value.variant_name.clone()), + ); + fields.insert("payload".to_string(), enum_payload_to_json(&value.payload)); + serde_json::Value::Object(fields) +} + +fn enum_payload_to_json(payload: &EnumPayloadValue) -> serde_json::Value { + match payload { + EnumPayloadValue::Unit => serde_json::Value::Null, + EnumPayloadValue::Newtype(value) => dynamic_value_to_json(value), + EnumPayloadValue::Tuple(values) => { + serde_json::Value::Array(values.iter().map(dynamic_value_to_json).collect()) + } + EnumPayloadValue::Struct(fields) => serde_json::Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), dynamic_value_to_json(&field.value))) + .collect(), + ), + } +} + /// Format a DynamicMessage as human-readable indented text /// /// Produces output like: @@ -121,6 +156,11 @@ fn format_value_pretty(output: &mut String, name: &str, value: &DynamicValue, in format_value_pretty(output, n, v, indent + 1); } } + DynamicValue::Optional(None) => { + output.push_str(&format!("{}{}: null\n", prefix, name)); + } + DynamicValue::Optional(Some(value)) => format_value_pretty(output, name, value, indent), + DynamicValue::Enum(value) => format_enum_value_pretty(output, name, value, indent), DynamicValue::Array(arr) => { if arr.is_empty() { output.push_str(&format!("{}{}[]: []\n", prefix, name)); @@ -134,10 +174,59 @@ fn format_value_pretty(output: &mut String, name: &str, value: &DynamicValue, in } } +fn format_enum_value_pretty(output: &mut String, name: &str, value: &EnumValue, indent: usize) { + let prefix = " ".repeat(indent); + output.push_str(&format!("{}{}:\n", prefix, name)); + output.push_str(&format!("{} variant: {}\n", prefix, value.variant_name)); + format_enum_payload_pretty(output, &value.payload, indent + 1); +} + +fn format_enum_payload_pretty(output: &mut String, payload: &EnumPayloadValue, indent: usize) { + let prefix = " ".repeat(indent); + + match payload { + EnumPayloadValue::Unit => { + output.push_str(&format!("{}payload: null\n", prefix)); + } + EnumPayloadValue::Newtype(value) => { + format_value_pretty(output, "payload", value, indent); + } + EnumPayloadValue::Tuple(values) => { + if values.is_empty() { + output.push_str(&format!("{}payload[]: []\n", prefix)); + } else { + output.push_str(&format!("{}payload[{}]:\n", prefix, values.len())); + for (index, value) in values.iter().enumerate() { + format_value_pretty(output, &format!("[{}]", index), value, indent + 1); + } + } + } + EnumPayloadValue::Struct(fields) => { + if fields.is_empty() { + output.push_str(&format!("{}payload: {{}}\n", prefix)); + } else { + output.push_str(&format!("{}payload:\n", prefix)); + format_named_fields_pretty(output, fields, indent + 1); + } + } + } +} + +fn format_named_fields_pretty(output: &mut String, fields: &[DynamicNamedValue], indent: usize) { + for field in fields { + format_value_pretty(output, &field.name, &field.value, indent); + } +} + #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; - use ros_z::dynamic::{FieldType, MessageSchema}; + use ros_z::dynamic::{ + EnumPayloadValue, EnumSchema, EnumValue, EnumVariantSchema, FieldSchema, FieldType, + MessageSchema, + }; #[test] fn test_json_primitives() { @@ -204,4 +293,67 @@ mod tests { assert!(formatted.contains("linear:")); assert!(formatted.contains(" x: 1")); } + + #[test] + fn test_json_optional_and_enum_values() { + let optional = DynamicValue::Optional(Some(Box::new(DynamicValue::Int32(7)))); + assert_eq!(dynamic_value_to_json(&optional), serde_json::json!(7)); + + let enum_value = DynamicValue::Enum(EnumValue::new( + 1, + "Error", + EnumPayloadValue::Struct(vec![crate::dynamic::DynamicNamedValue { + name: "code".to_string(), + value: DynamicValue::Uint32(42), + }]), + )); + let json = dynamic_value_to_json(&enum_value); + assert_eq!(json["variant_index"], 1); + assert_eq!(json["variant_name"], "Error"); + assert_eq!(json["payload"]["code"], 42); + } + + #[test] + fn test_pretty_format_optional_and_enum_fields() { + let status_schema = Arc::new(EnumSchema::new( + "test_msgs/msg/Status", + vec![ + EnumVariantSchema::new("Idle", crate::dynamic::EnumPayloadSchema::Unit), + EnumVariantSchema::new( + "Error", + crate::dynamic::EnumPayloadSchema::Struct(vec![FieldSchema::new( + "code", + FieldType::Uint32, + )]), + ), + ], + )); + + let schema = MessageSchema::builder("test_msgs/msg/State") + .field("nickname", FieldType::Optional(Box::new(FieldType::String))) + .field("status", FieldType::Enum(status_schema)) + .build() + .unwrap(); + + let mut msg = DynamicMessage::new(&schema); + msg.set("nickname", Some("ros-z".to_string())).unwrap(); + msg.set( + "status", + EnumValue::new( + 1, + "Error", + EnumPayloadValue::Struct(vec![crate::dynamic::DynamicNamedValue { + name: "code".to_string(), + value: DynamicValue::Uint32(5), + }]), + ), + ) + .unwrap(); + + let formatted = format_message_pretty(&msg); + assert!(formatted.contains("nickname: \"ros-z\"")); + assert!(formatted.contains("status:")); + assert!(formatted.contains("variant: Error")); + assert!(formatted.contains("code: 5")); + } } diff --git a/crates/ros-z-console/src/core/mod.rs b/crates/ros-z-console/src/core/mod.rs index 585ee335..68f1d92e 100644 --- a/crates/ros-z-console/src/core/mod.rs +++ b/crates/ros-z-console/src/core/mod.rs @@ -1,4 +1,3 @@ -pub mod dynamic_subscriber; pub mod engine; pub mod events; pub mod logger; diff --git a/crates/ros-z-console/src/export.rs b/crates/ros-z-console/src/export.rs index 43259675..a2867ad6 100644 --- a/crates/ros-z-console/src/export.rs +++ b/crates/ros-z-console/src/export.rs @@ -69,8 +69,10 @@ async fn export_dot( // Publishers -> Topic for entity in graph.get_entities_by_topic(EntityKind::Publisher, &topic) { - if let Some(endpoint) = entity_get_endpoint(&entity) { - let node_id = format!("{}/{}", endpoint.node.namespace, endpoint.node.name); + if let Some(endpoint) = entity_get_endpoint(&entity) + && let Some(node) = endpoint.node.as_ref() + { + let node_id = format!("{}/{}", node.namespace, node.name); dot.push_str(&format!( " \"{}\" -> \"topic:{}\" [color=blue];\n", node_id, topic @@ -80,8 +82,10 @@ async fn export_dot( // Topic -> Subscribers for entity in graph.get_entities_by_topic(EntityKind::Subscription, &topic) { - if let Some(endpoint) = entity_get_endpoint(&entity) { - let node_id = format!("{}/{}", endpoint.node.namespace, endpoint.node.name); + if let Some(endpoint) = entity_get_endpoint(&entity) + && let Some(node) = endpoint.node.as_ref() + { + let node_id = format!("{}/{}", node.namespace, node.name); dot.push_str(&format!( " \"topic:{}\" -> \"{}\" [color=green];\n", topic, node_id diff --git a/crates/ros-z-console/src/modes/headless.rs b/crates/ros-z-console/src/modes/headless.rs index 7e3377f1..567b198c 100644 --- a/crates/ros-z-console/src/modes/headless.rs +++ b/crates/ros-z-console/src/modes/headless.rs @@ -1,9 +1,12 @@ use chrono::Utc; +use ros_z::{ + Builder, + dynamic::{DynSub, MessageSchemaTypeDescription}, +}; use serde::Serialize; use std::{collections::HashMap, time::Duration}; use crate::core::{ - dynamic_subscriber::DynamicTopicSubscriber, engine::CoreEngine, message_formatter::{dynamic_message_to_json, format_message_pretty}, }; @@ -40,7 +43,7 @@ pub async fn run_headless_mode( } // Create dynamic subscribers for echo topics - let mut subscribers: HashMap = HashMap::new(); + let mut subscribers: HashMap = HashMap::new(); if !echo_topics.is_empty() { tracing::info!( @@ -50,28 +53,43 @@ pub async fn run_headless_mode( for topic in echo_topics { match core - .create_dynamic_subscriber(&topic, Duration::from_secs(5)) + .create_dynamic_subscriber_builder(&topic, Duration::from_secs(5)) .await { Ok(sub) => { + let sub = match sub.build() { + Ok(sub) => sub, + Err(e) => { + eprintln!("Failed to build subscriber for {}: {}", topic, e); + continue; + } + }; + let Some(schema) = sub.schema() else { + eprintln!( + "Failed to inspect schema for {}: missing dynamic schema", + topic + ); + continue; + }; + let type_hash = schema + .compute_type_hash() + .map(|hash| hash.to_rihs_string()) + .unwrap_or_else(|_| "unknown".to_string()); if json { // Output schema info let schema_info = serde_json::json!({ "event": "topic_subscribed", "topic": topic, - "type_name": sub.schema().type_name, - "type_hash": sub.type_hash(), - "fields": sub.schema().field_names().collect::>(), + "type_name": schema.type_name, + "type_hash": type_hash, + "fields": schema.field_names().collect::>(), }); println!("{}", serde_json::to_string(&schema_info)?); } else { println!("\n=== Subscribed to {} ===", topic); - println!("Type: {}", sub.schema().type_name); - println!("Hash: {}", sub.type_hash()); - println!( - "Fields: {:?}", - sub.schema().field_names().collect::>() - ); + println!("Type: {}", schema.type_name); + println!("Hash: {}", type_hash); + println!("Fields: {:?}", schema.field_names().collect::>()); println!(); } subscribers.insert(topic.clone(), sub); @@ -117,20 +135,27 @@ pub async fn run_headless_mode( // Handle echo messages _ = async { for (topic, subscriber) in &subscribers { - if let Ok(Some(msg)) = subscriber.try_recv() { - if json { - let msg_json = serde_json::json!({ - "event": "message_received", - "topic": topic, - "type": msg.schema().type_name, - "data": dynamic_message_to_json(&msg), - }); - if let Ok(json_str) = serde_json::to_string(&msg_json) { - println!("{}", json_str); + if let Some(result) = subscriber.try_recv() { + match result { + Ok(msg) => { + if json { + let msg_json = serde_json::json!({ + "event": "message_received", + "topic": topic, + "type": msg.schema().type_name, + "data": dynamic_message_to_json(&msg), + }); + if let Ok(json_str) = serde_json::to_string(&msg_json) { + println!("{}", json_str); + } + } else { + println!("\n=== {} ===", topic); + print!("{}", format_message_pretty(&msg)); + } + } + Err(error) => { + eprintln!("Failed to receive from {}: {}", topic, error); } - } else { - println!("\n=== {} ===", topic); - print!("{}", format_message_pretty(&msg)); } } } diff --git a/crates/ros-z-derive/src/lib.rs b/crates/ros-z-derive/src/lib.rs index 667ab782..ac6cd3bf 100644 --- a/crates/ros-z-derive/src/lib.rs +++ b/crates/ros-z-derive/src/lib.rs @@ -1,18 +1,51 @@ -//! Derive macros for Python message bridge traits +//! Derive macros for ros-z traits. //! -//! Provides `FromPyMessage` and `IntoPyMessage` derive macros for automatic -//! conversion between Python msgspec structs and Rust ROS message types. +//! Provides: +//! - `MessageTypeInfo` for Rust-native message schema generation +//! - `FromPyMessage` and `IntoPyMessage` for Python bridge conversion #![allow(clippy::collapsible_if)] use proc_macro::TokenStream; use quote::quote; use syn::{ - Attribute, Data, DeriveInput, Fields, GenericArgument, Ident, Lit, Meta, PathArguments, Type, - parse_macro_input, + Attribute, Data, DeriveInput, Expr, Fields, GenericArgument, Ident, LitStr, PathArguments, + Type, parse_macro_input, }; -/// Derive macro for extracting Rust messages from Python objects +type TokenStream2 = proc_macro2::TokenStream; + +/// Derive macro for implementing ros-z message metadata and dynamic schema generation. +/// +/// # Example +/// ```ignore +/// #[derive(MessageTypeInfo)] +/// #[ros_msg(type_name = "custom_msgs/msg/RobotStatus")] +/// pub struct RobotStatus { +/// pub battery_percentage: f64, +/// pub is_moving: bool, +/// } +/// ``` +#[proc_macro_derive(MessageTypeInfo, attributes(ros_msg))] +pub fn derive_message_type_info(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match impl_standard_message_type_info(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Derive macro for implementing ros-z extended message schema generation. +#[proc_macro_derive(ExtendedMessageTypeInfo, attributes(ros_msg))] +pub fn derive_extended_message_type_info(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match impl_message_type_info(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Derive macro for extracting Rust messages from Python objects. /// /// # Example /// ```ignore @@ -31,7 +64,7 @@ pub fn derive_from_py_message(input: TokenStream) -> TokenStream { } } -/// Derive macro for constructing Python objects from Rust messages +/// Derive macro for constructing Python objects from Rust messages. /// /// # Example /// ```ignore @@ -50,7 +83,365 @@ pub fn derive_into_py_message(input: TokenStream) -> TokenStream { } } -fn impl_from_py_message(input: &DeriveInput) -> syn::Result { +fn impl_message_type_info(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "ExtendedMessageTypeInfo derive does not support generic types in v1", + )); + } + + let attrs = parse_ros_msg_args(&input.attrs)?; + let canonical_type_name = attrs.type_name.ok_or_else(|| { + syn::Error::new_spanned( + input, + "ExtendedMessageTypeInfo derive requires #[ros_msg(type_name = \"my_pkg/msg/MyType\")]", + ) + })?; + let type_name_lit = LitStr::new(&canonical_type_name, proc_macro2::Span::call_site()); + let (package, _kind, message_name) = parse_canonical_type_name(&canonical_type_name)?; + let dds_type_name = canonical_to_dds_name(&canonical_type_name)?; + let package_lit = LitStr::new(&package, proc_macro2::Span::call_site()); + let message_name_lit = LitStr::new(&message_name, proc_macro2::Span::call_site()); + let dds_type_name_lit = LitStr::new(&dds_type_name, proc_macro2::Span::call_site()); + + match &input.data { + Data::Struct(data) => impl_message_type_info_for_struct( + name, + data, + &type_name_lit, + &package_lit, + &message_name_lit, + &dds_type_name_lit, + ), + Data::Enum(data) => impl_message_type_info_for_enum( + name, + data, + &type_name_lit, + &package_lit, + &message_name_lit, + &dds_type_name_lit, + ), + Data::Union(_) => Err(syn::Error::new_spanned( + input, + "ExtendedMessageTypeInfo derive does not support unions", + )), + } +} + +fn impl_standard_message_type_info(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "MessageTypeInfo derive does not support generic types in v1", + )); + } + + let attrs = parse_ros_msg_args(&input.attrs)?; + let canonical_type_name = attrs.type_name.ok_or_else(|| { + syn::Error::new_spanned( + input, + "MessageTypeInfo derive requires #[ros_msg(type_name = \"my_pkg/msg/MyType\")]", + ) + })?; + let type_name_lit = LitStr::new(&canonical_type_name, proc_macro2::Span::call_site()); + let (package, _kind, message_name) = parse_canonical_type_name(&canonical_type_name)?; + let dds_type_name = canonical_to_dds_name(&canonical_type_name)?; + let package_lit = LitStr::new(&package, proc_macro2::Span::call_site()); + let message_name_lit = LitStr::new(&message_name, proc_macro2::Span::call_site()); + let dds_type_name_lit = LitStr::new(&dds_type_name, proc_macro2::Span::call_site()); + + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "MessageTypeInfo derive only supports named structs in v1", + )); + }; + + let Fields::Named(fields) = &data.fields else { + let message = match &data.fields { + Fields::Unnamed(_) => "MessageTypeInfo derive does not support tuple structs in v1", + Fields::Unit => "MessageTypeInfo derive does not support unit structs in v1", + Fields::Named(_) => unreachable!(), + }; + return Err(syn::Error::new_spanned(input, message)); + }; + + let schema_fields = fields + .named + .iter() + .map(generate_standard_message_field_schema_tokens) + .collect::>>()?; + + let message_type_hash_impl = standard_message_type_hash_impl_tokens(); + + Ok(quote! { + impl ::ros_z::MessageTypeInfo for #name { + fn type_name() -> &'static str { + #dds_type_name_lit + } + + #message_type_hash_impl + + fn message_schema() -> Option<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> { + static SCHEMA: ::std::sync::OnceLock<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> = + ::std::sync::OnceLock::new(); + + Some( + SCHEMA + .get_or_init(|| { + ::std::sync::Arc::new(::ros_z::dynamic::MessageSchema { + type_name: #type_name_lit.to_string(), + package: #package_lit.to_string(), + name: #message_name_lit.to_string(), + fields: ::std::vec![#(#schema_fields),*], + type_hash: None, + }) + }) + .clone(), + ) + } + } + + impl ::ros_z::WithTypeInfo for #name {} + }) +} + +fn impl_message_type_info_for_struct( + name: &Ident, + data: &syn::DataStruct, + type_name_lit: &LitStr, + package_lit: &LitStr, + message_name_lit: &LitStr, + dds_type_name_lit: &LitStr, +) -> syn::Result { + let message_type_hash_impl = extended_message_type_hash_impl_tokens(); + + let Fields::Named(fields) = &data.fields else { + let message = match &data.fields { + Fields::Unnamed(_) => { + "ExtendedMessageTypeInfo derive does not support tuple structs in v1" + } + Fields::Unit => "ExtendedMessageTypeInfo derive does not support unit structs in v1", + Fields::Named(_) => unreachable!(), + }; + return Err(syn::Error::new_spanned(name, message)); + }; + + let schema_fields = fields + .named + .iter() + .map(generate_message_field_schema_tokens) + .collect::>>()?; + + Ok(quote! { + impl ::ros_z::ExtendedMessageTypeInfo for #name { + fn extended_message_schema() -> ::std::sync::Arc<::ros_z::dynamic::MessageSchema> { + static SCHEMA: ::std::sync::OnceLock<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> = + ::std::sync::OnceLock::new(); + + SCHEMA + .get_or_init(|| { + ::std::sync::Arc::new(::ros_z::dynamic::MessageSchema { + type_name: #type_name_lit.to_string(), + package: #package_lit.to_string(), + name: #message_name_lit.to_string(), + fields: ::std::vec![#(#schema_fields),*], + type_hash: None, + }) + }) + .clone() + } + } + + impl ::ros_z::MessageTypeInfo for #name { + fn type_name() -> &'static str { + #dds_type_name_lit + } + + #message_type_hash_impl + + fn message_schema() -> Option<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> { + let schema = ::extended_message_schema(); + if schema.uses_extended_types() { + None + } else { + Some(schema) + } + } + + fn register_type_extensions(node: &::ros_z::node::ZNode) -> ::std::result::Result<(), ::std::string::String> { + let schema = ::extended_message_schema(); + if schema.uses_extended_types() { + ::ros_z::extended_schema::register_type::(node) + } else { + Ok(()) + } + } + } + + impl ::ros_z::WithTypeInfo for #name {} + }) +} + +fn impl_message_type_info_for_enum( + name: &Ident, + data: &syn::DataEnum, + type_name_lit: &LitStr, + package_lit: &LitStr, + message_name_lit: &LitStr, + dds_type_name_lit: &LitStr, +) -> syn::Result { + let message_type_hash_impl = extended_message_type_hash_impl_tokens(); + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + name, + "ExtendedMessageTypeInfo derive requires enums to have at least one variant", + )); + } + + let variant_tokens = data + .variants + .iter() + .map(generate_enum_variant_schema_tokens) + .collect::>>()?; + + Ok(quote! { + impl #name { + fn __ros_z_enum_schema() -> ::std::sync::Arc<::ros_z::dynamic::EnumSchema> { + static ENUM_SCHEMA: ::std::sync::OnceLock<::std::sync::Arc<::ros_z::dynamic::EnumSchema>> = + ::std::sync::OnceLock::new(); + + ENUM_SCHEMA + .get_or_init(|| { + ::std::sync::Arc::new(::ros_z::dynamic::EnumSchema { + type_name: #type_name_lit.to_string(), + variants: ::std::vec![#(#variant_tokens),*], + }) + }) + .clone() + } + } + + impl ::ros_z::ExtendedMessageTypeInfo for #name { + fn extended_message_schema() -> ::std::sync::Arc<::ros_z::dynamic::MessageSchema> { + static SCHEMA: ::std::sync::OnceLock<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> = + ::std::sync::OnceLock::new(); + + SCHEMA + .get_or_init(|| { + ::std::sync::Arc::new(::ros_z::dynamic::MessageSchema { + type_name: #type_name_lit.to_string(), + package: #package_lit.to_string(), + name: #message_name_lit.to_string(), + fields: ::std::vec![ + ::ros_z::dynamic::FieldSchema::new( + "value", + ::ros_z::dynamic::FieldType::Enum(Self::__ros_z_enum_schema()), + ) + ], + type_hash: None, + }) + }) + .clone() + } + + fn extended_field_type() -> ::ros_z::dynamic::FieldType { + ::ros_z::dynamic::FieldType::Enum(Self::__ros_z_enum_schema()) + } + } + + impl ::ros_z::MessageTypeInfo for #name { + fn type_name() -> &'static str { + #dds_type_name_lit + } + + #message_type_hash_impl + + fn message_schema() -> Option<::std::sync::Arc<::ros_z::dynamic::MessageSchema>> { + None + } + + fn register_type_extensions(node: &::ros_z::node::ZNode) -> ::std::result::Result<(), ::std::string::String> { + ::ros_z::extended_schema::register_type::(node) + } + } + + impl ::ros_z::WithTypeInfo for #name {} + }) +} + +fn standard_message_type_hash_impl_tokens() -> TokenStream2 { + quote! { + fn type_hash() -> ::ros_z::entity::TypeHash { + let zero = ::ros_z::entity::TypeHash::zero(); + if zero.to_rihs_string() == "TypeHashNotSupported" { + return zero; + } + + static TYPE_HASH: ::std::sync::OnceLock<::ros_z::entity::TypeHash> = + ::std::sync::OnceLock::new(); + + TYPE_HASH + .get_or_init(|| { + use ::ros_z::dynamic::MessageSchemaTypeDescription; + + let schema = Self::message_schema() + .expect("derived message schema must be available"); + let rihs = schema + .compute_type_hash() + .expect("derived message schema must produce a type hash") + .to_rihs_string(); + + ::ros_z::entity::TypeHash::from_rihs_string(&rihs) + .expect("derived message hash must be a valid RIHS01 string") + }) + .clone() + } + } +} + +fn extended_message_type_hash_impl_tokens() -> TokenStream2 { + quote! { + fn type_hash() -> ::ros_z::entity::TypeHash { + let zero = ::ros_z::entity::TypeHash::zero(); + if zero.to_rihs_string() == "TypeHashNotSupported" { + return zero; + } + + static TYPE_HASH: ::std::sync::OnceLock<::ros_z::entity::TypeHash> = + ::std::sync::OnceLock::new(); + + TYPE_HASH + .get_or_init(|| { + let schema = ::extended_message_schema(); + let hash = if schema.uses_extended_types() { + ::ros_z::extended_schema::compute_extended_type_hash(&schema) + .expect("extended message schema must produce a type hash") + .to_rihs_string() + } else { + use ::ros_z::dynamic::MessageSchemaTypeDescription; + + schema + .compute_type_hash() + .expect("standard-compatible extended schema must produce a standard type hash") + .to_rihs_string() + }; + + ::ros_z::entity::TypeHash::from_rihs_string(&hash) + .expect("extended message hash must be a valid RIHS01 string") + }) + .clone() + } + } +} + +fn impl_from_py_message(input: &DeriveInput) -> syn::Result { let name = &input.ident; let Data::Struct(ref data) = input.data else { @@ -67,17 +458,14 @@ fn impl_from_py_message(input: &DeriveInput) -> syn::Result = fields + let field_extractions: Vec = fields .named .iter() .map(|f| { let field_name = f.ident.as_ref().unwrap(); - let field_name_str = field_name_to_py_attr(field_name); + let field_name_str = field_name_to_attr(field_name); let field_type = &f.ty; - - // Check for #[ros_msg(zbuf)] attribute - let use_zbuf = has_ros_msg_attr(&f.attrs, "zbuf"); - + let use_zbuf = parse_ros_msg_args(&f.attrs)?.zbuf; generate_field_extraction(field_name, &field_name_str, field_type, use_zbuf) }) .collect::>>()?; @@ -94,7 +482,7 @@ fn impl_from_py_message(input: &DeriveInput) -> syn::Result syn::Result { +fn impl_into_py_message(input: &DeriveInput) -> syn::Result { let name = &input.ident; let Data::Struct(ref data) = input.data else { @@ -111,20 +499,16 @@ fn impl_into_py_message(input: &DeriveInput) -> syn::Result = fields + let field_constructions: Vec = fields .named .iter() .map(|f| { let field_name = f.ident.as_ref().unwrap(); - let field_name_str = field_name_to_py_attr(field_name); + let field_name_str = field_name_to_attr(field_name); let field_type = &f.ty; - - // Check for #[ros_msg(zbuf)] attribute - let use_zbuf = has_ros_msg_attr(&f.attrs, "zbuf"); - + let use_zbuf = parse_ros_msg_args(&f.attrs)?.zbuf; generate_field_construction(field_name, &field_name_str, field_type, use_zbuf) }) .collect::>>()?; @@ -147,29 +531,341 @@ fn impl_into_py_message(input: &DeriveInput) -> syn::Result syn::Result { + let field_name = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "named fields are required"))?; + let field_name_str = field_name_to_attr(field_name); + let field_type = generate_standard_message_field_type_tokens(&field.ty)?; + + Ok(quote! { + ::ros_z::dynamic::FieldSchema::new(#field_name_str, #field_type) + }) +} + +fn generate_standard_message_field_type_tokens(ty: &Type) -> syn::Result { + match ty { + Type::Path(type_path) => { + if type_path.qself.is_some() { + return unsupported_message_type( + ty, + "qualified self types are not supported in v1", + ); + } + + let last_segment = type_path.path.segments.last().ok_or_else(|| { + syn::Error::new_spanned( + ty, + "unsupported field type for ExtendedMessageTypeInfo derive", + ) + })?; + let ident_str = last_segment.ident.to_string(); + + match ident_str.as_str() { + "bool" => Ok(quote! { ::ros_z::dynamic::FieldType::Bool }), + "i8" => Ok(quote! { ::ros_z::dynamic::FieldType::Int8 }), + "u8" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint8 }), + "i16" => Ok(quote! { ::ros_z::dynamic::FieldType::Int16 }), + "u16" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint16 }), + "i32" => Ok(quote! { ::ros_z::dynamic::FieldType::Int32 }), + "u32" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint32 }), + "i64" => Ok(quote! { ::ros_z::dynamic::FieldType::Int64 }), + "u64" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint64 }), + "f32" => Ok(quote! { ::ros_z::dynamic::FieldType::Float32 }), + "f64" => Ok(quote! { ::ros_z::dynamic::FieldType::Float64 }), + "String" => Ok(quote! { ::ros_z::dynamic::FieldType::String }), + "usize" | "isize" => unsupported_message_type( + ty, + "usize and isize are not supported by MessageTypeInfo derive in v1", + ), + "Option" => unsupported_message_type( + ty, + "Option fields are not supported by MessageTypeInfo derive in v1", + ), + "HashMap" | "BTreeMap" => unsupported_message_type( + ty, + "map fields are not supported by MessageTypeInfo derive in v1", + ), + "Vec" => { + let PathArguments::AngleBracketed(args) = &last_segment.arguments else { + return unsupported_message_type( + ty, + "Vec fields must specify an element type", + ); + }; + let Some(GenericArgument::Type(inner)) = args.args.first() else { + return unsupported_message_type( + ty, + "Vec fields must specify an element type", + ); + }; + let inner_tokens = generate_standard_message_field_type_tokens(inner)?; + Ok(quote! { + ::ros_z::dynamic::FieldType::Sequence(::std::boxed::Box::new(#inner_tokens)) + }) + } + _ => Ok(quote! { + ::ros_z::dynamic::FieldType::Message( + <#ty as ::ros_z::MessageTypeInfo>::message_schema() + .expect("derived nested message schema must be available") + ) + }), + } + } + Type::Array(array) => { + let len = match &array.len { + Expr::Lit(expr_lit) => match &expr_lit.lit { + syn::Lit::Int(value) => value.base10_parse::()?, + _ => { + return unsupported_message_type( + ty, + "array lengths must be integer literals for MessageTypeInfo derive", + ); + } + }, + _ => { + return unsupported_message_type( + ty, + "array lengths must be integer literals for MessageTypeInfo derive", + ); + } + }; + + let inner_tokens = generate_standard_message_field_type_tokens(&array.elem)?; + Ok(quote! { + ::ros_z::dynamic::FieldType::Array(::std::boxed::Box::new(#inner_tokens), #len) + }) + } + Type::Tuple(_) => unsupported_message_type( + ty, + "tuple fields are not supported by MessageTypeInfo derive in v1", + ), + _ => unsupported_message_type( + ty, + "unsupported field type for MessageTypeInfo derive in v1", + ), + } +} + +fn generate_message_field_schema_tokens(field: &syn::Field) -> syn::Result { + let field_name = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "named fields are required"))?; + let field_name_str = field_name_to_attr(field_name); + let field_type = generate_message_field_type_tokens(&field.ty)?; + + Ok(quote! { + ::ros_z::dynamic::FieldSchema::new(#field_name_str, #field_type) + }) +} + +fn generate_message_field_type_tokens(ty: &Type) -> syn::Result { + match ty { + Type::Path(type_path) => { + if type_path.qself.is_some() { + return unsupported_message_type( + ty, + "qualified self types are not supported by ExtendedMessageTypeInfo derive in v1", + ); + } + + let last_segment = type_path.path.segments.last().ok_or_else(|| { + syn::Error::new_spanned( + ty, + "unsupported field type for ExtendedMessageTypeInfo derive", + ) + })?; + let ident_str = last_segment.ident.to_string(); + + match ident_str.as_str() { + "bool" => Ok(quote! { ::ros_z::dynamic::FieldType::Bool }), + "i8" => Ok(quote! { ::ros_z::dynamic::FieldType::Int8 }), + "u8" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint8 }), + "i16" => Ok(quote! { ::ros_z::dynamic::FieldType::Int16 }), + "u16" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint16 }), + "i32" => Ok(quote! { ::ros_z::dynamic::FieldType::Int32 }), + "u32" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint32 }), + "i64" => Ok(quote! { ::ros_z::dynamic::FieldType::Int64 }), + "u64" => Ok(quote! { ::ros_z::dynamic::FieldType::Uint64 }), + "f32" => Ok(quote! { ::ros_z::dynamic::FieldType::Float32 }), + "f64" => Ok(quote! { ::ros_z::dynamic::FieldType::Float64 }), + "String" => Ok(quote! { ::ros_z::dynamic::FieldType::String }), + "usize" | "isize" => unsupported_message_type( + ty, + "usize and isize are not supported by ExtendedMessageTypeInfo derive in v1", + ), + "HashMap" | "BTreeMap" => unsupported_message_type( + ty, + "map fields are not supported by ExtendedMessageTypeInfo derive in v1", + ), + "Option" => { + let PathArguments::AngleBracketed(args) = &last_segment.arguments else { + return unsupported_message_type( + ty, + "Option fields must specify an inner type", + ); + }; + let Some(GenericArgument::Type(inner)) = args.args.first() else { + return unsupported_message_type( + ty, + "Option fields must specify an inner type", + ); + }; + let inner_tokens = generate_message_field_type_tokens(inner)?; + Ok(quote! { + ::ros_z::dynamic::FieldType::Optional(::std::boxed::Box::new(#inner_tokens)) + }) + } + "Vec" => { + let PathArguments::AngleBracketed(args) = &last_segment.arguments else { + return unsupported_message_type( + ty, + "Vec fields must specify an element type", + ); + }; + let Some(GenericArgument::Type(inner)) = args.args.first() else { + return unsupported_message_type( + ty, + "Vec fields must specify an element type", + ); + }; + let inner_tokens = generate_message_field_type_tokens(inner)?; + Ok(quote! { + ::ros_z::dynamic::FieldType::Sequence(::std::boxed::Box::new(#inner_tokens)) + }) + } + _ => Ok(quote! { + <#ty as ::ros_z::ExtendedMessageTypeInfo>::extended_field_type() + }), + } + } + Type::Array(array) => { + let len = match &array.len { + Expr::Lit(expr_lit) => match &expr_lit.lit { + syn::Lit::Int(value) => value.base10_parse::()?, + _ => { + return unsupported_message_type( + ty, + "array lengths must be integer literals for MessageTypeInfo derive", + ); + } + }, + _ => { + return unsupported_message_type( + ty, + "array lengths must be integer literals for MessageTypeInfo derive", + ); + } + }; + + let inner_tokens = generate_message_field_type_tokens(&array.elem)?; + Ok(quote! { + ::ros_z::dynamic::FieldType::Array(::std::boxed::Box::new(#inner_tokens), #len) + }) + } + Type::Tuple(_) => unsupported_message_type( + ty, + "tuple fields are not supported by ExtendedMessageTypeInfo derive in v1", + ), + _ => unsupported_message_type( + ty, + "unsupported field type for ExtendedMessageTypeInfo derive in v1", + ), + } +} + +fn generate_enum_variant_schema_tokens(variant: &syn::Variant) -> syn::Result { + let variant_name = variant.ident.to_string(); + let payload = match &variant.fields { + Fields::Unit => quote! { ::ros_z::dynamic::EnumPayloadSchema::Unit }, + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let field_type = generate_message_field_type_tokens(&fields.unnamed[0].ty)?; + quote! { + ::ros_z::dynamic::EnumPayloadSchema::Newtype(::std::boxed::Box::new(#field_type)) + } + } + Fields::Unnamed(fields) => { + let field_types = fields + .unnamed + .iter() + .map(|field| generate_message_field_type_tokens(&field.ty)) + .collect::>>()?; + quote! { + ::ros_z::dynamic::EnumPayloadSchema::Tuple(::std::vec![#(#field_types),*]) + } + } + Fields::Named(fields) => { + let field_schemas = fields + .named + .iter() + .map(generate_message_field_schema_tokens) + .collect::>>()?; + quote! { + ::ros_z::dynamic::EnumPayloadSchema::Struct(::std::vec![#(#field_schemas),*]) + } + } + }; + + Ok(quote! { + ::ros_z::dynamic::EnumVariantSchema::new(#variant_name, #payload) + }) +} + +fn unsupported_message_type(node: &T, message: &str) -> syn::Result +where + T: quote::ToTokens, +{ + Err(syn::Error::new_spanned(node, message)) +} + +fn parse_canonical_type_name(type_name: &str) -> syn::Result<(String, String, String)> { + let parts: Vec<_> = type_name.split('/').collect(); + if parts.len() != 3 { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "ros_msg type_name must look like \"my_pkg/msg/MyType\"", + )); + } + + match parts[1] { + "msg" | "srv" | "action" => Ok(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )), + _ => Err(syn::Error::new( + proc_macro2::Span::call_site(), + "ros_msg type_name kind must be one of: msg, srv, action", + )), + } +} + +fn canonical_to_dds_name(type_name: &str) -> syn::Result { + let (package, kind, name) = parse_canonical_type_name(type_name)?; + Ok(format!("{package}::{kind}::dds_::{name}_")) +} + +/// Generate extraction code for a single field. fn generate_field_extraction( field_name: &Ident, field_name_str: &str, field_type: &Type, use_zbuf: bool, -) -> syn::Result { - // Handle ZBuf fields specially - try zero-copy paths first +) -> syn::Result { if use_zbuf { return Ok(quote! { #field_name: { - use ::pyo3::types::{PyBytesMethods, PyByteArrayMethods}; + use ::pyo3::types::{PyByteArrayMethods, PyBytesMethods}; let py_attr = obj.getattr(#field_name_str)?; - // Try ZBufView first - clone is cheap (ref-counted ZSlices) if let Ok(view) = py_attr.downcast::<::ros_z::zbuf_view::ZBufView>() { view.borrow().zbuf().clone() } else if let Ok(bytes) = py_attr.downcast::<::pyo3::types::PyBytes>() { ::ros_z::ZBuf::from(bytes.as_bytes().to_vec()) } else if let Ok(bytearray) = py_attr.downcast::<::pyo3::types::PyByteArray>() { - // SAFETY: We immediately copy the data ::ros_z::ZBuf::from(unsafe { bytearray.as_bytes() }.to_vec()) } else { - // Fallback for lists (slow path) let bytes: Vec = py_attr.extract()?; ::ros_z::ZBuf::from(bytes) } @@ -177,32 +873,22 @@ fn generate_field_extraction( }); } - // Analyze the type match classify_type(field_type) { - TypeClass::Primitive => Ok(quote! { - #field_name: obj.getattr(#field_name_str)?.extract()? - }), - - TypeClass::String => Ok(quote! { + TypeClass::Primitive | TypeClass::String => Ok(quote! { #field_name: obj.getattr(#field_name_str)?.extract()? }), - TypeClass::Vec(inner) => { let inner_class = classify_type(&inner); match inner_class { - // Special case for Vec - use buffer protocol for performance TypeClass::Primitive if is_u8_type(&inner) => Ok(quote! { #field_name: { - use ::pyo3::types::{PyBytesMethods, PyByteArrayMethods}; + use ::pyo3::types::{PyByteArrayMethods, PyBytesMethods}; let py_attr = obj.getattr(#field_name_str)?; - // Try bytes/bytearray first (fast path - buffer protocol) if let Ok(bytes) = py_attr.downcast::<::pyo3::types::PyBytes>() { bytes.as_bytes().to_vec() } else if let Ok(bytearray) = py_attr.downcast::<::pyo3::types::PyByteArray>() { - // SAFETY: We immediately copy the data unsafe { bytearray.as_bytes() }.to_vec() } else { - // Fallback for lists (slow path) py_attr.extract()? } } @@ -210,84 +896,63 @@ fn generate_field_extraction( TypeClass::Primitive | TypeClass::String => Ok(quote! { #field_name: obj.getattr(#field_name_str)?.extract()? }), - _ => { - // Vec of nested messages - recursively extract - Ok(quote! { - #field_name: { - use ::pyo3::types::PyListMethods; - let py_list = obj.getattr(#field_name_str)?; - let mut vec = Vec::new(); - for item in py_list.iter()? { - vec.push(<#inner as ::ros_z::python_bridge::FromPyMessage>::from_py(&item?)?); - } - vec + _ => Ok(quote! { + #field_name: { + use ::pyo3::types::PyListMethods; + let py_list = obj.getattr(#field_name_str)?; + let mut vec = Vec::new(); + for item in py_list.iter()? { + vec.push(<#inner as ::ros_z::python_bridge::FromPyMessage>::from_py(&item?)?); } - }) - } + vec + } + }), } } - TypeClass::Array(inner, size) => { let inner_class = classify_type(&inner); match inner_class { - TypeClass::Primitive | TypeClass::String => { - // For primitive arrays, use zeroed memory for initialization - // This works for any array size - Ok(quote! { - #field_name: { - let v: Vec<_> = obj.getattr(#field_name_str)?.extract()?; - // Use zeroed memory for arrays larger than 32 elements - // SAFETY: All primitive numeric types and bool are valid when zeroed - let mut arr: #field_type = unsafe { ::std::mem::zeroed() }; - let len = ::std::cmp::min(v.len(), #size); - arr[..len].copy_from_slice(&v[..len]); - arr - } - }) - } - _ => { - // Fixed array of nested messages - Ok(quote! { - #field_name: { - use ::pyo3::types::PyListMethods; - let py_list = obj.getattr(#field_name_str)?; - let mut arr: #field_type = ::std::array::from_fn(|_| Default::default()); - for (i, item) in py_list.iter()?.enumerate().take(#size) { - arr[i] = <#inner as ::ros_z::python_bridge::FromPyMessage>::from_py(&item?)?; - } - arr + TypeClass::Primitive | TypeClass::String => Ok(quote! { + #field_name: { + let v: Vec<_> = obj.getattr(#field_name_str)?.extract()?; + let mut arr: #field_type = unsafe { ::std::mem::zeroed() }; + let len = ::std::cmp::min(v.len(), #size); + arr[..len].copy_from_slice(&v[..len]); + arr + } + }), + _ => Ok(quote! { + #field_name: { + use ::pyo3::types::PyListMethods; + let py_list = obj.getattr(#field_name_str)?; + let mut arr: #field_type = ::std::array::from_fn(|_| Default::default()); + for (i, item) in py_list.iter()?.enumerate().take(#size) { + arr[i] = <#inner as ::ros_z::python_bridge::FromPyMessage>::from_py(&item?)?; } - }) - } + arr + } + }), } } - - TypeClass::Nested => { - // Nested message - check for None and use Default if None - Ok(quote! { - #field_name: { - let py_attr = obj.getattr(#field_name_str)?; - if py_attr.is_none() { - Default::default() - } else { - <#field_type as ::ros_z::python_bridge::FromPyMessage>::from_py(&py_attr)? - } + TypeClass::Nested => Ok(quote! { + #field_name: { + let py_attr = obj.getattr(#field_name_str)?; + if py_attr.is_none() { + Default::default() + } else { + <#field_type as ::ros_z::python_bridge::FromPyMessage>::from_py(&py_attr)? } - }) - } - + } + }), TypeClass::ZBuf => Ok(quote! { #field_name: { - use ::pyo3::types::{PyBytesMethods, PyByteArrayMethods}; + use ::pyo3::types::{PyByteArrayMethods, PyBytesMethods}; let py_attr = obj.getattr(#field_name_str)?; - // Try bytes/bytearray first (fast path - buffer protocol) let bytes: Vec = if let Ok(bytes) = py_attr.downcast::<::pyo3::types::PyBytes>() { bytes.as_bytes().to_vec() } else if let Ok(bytearray) = py_attr.downcast::<::pyo3::types::PyByteArray>() { - // SAFETY: We immediately copy the data unsafe { bytearray.as_bytes() }.to_vec() } else { - // Fallback for lists (slow path) py_attr.extract()? }; ::ros_z::ZBuf::from(bytes) @@ -296,19 +961,16 @@ fn generate_field_extraction( } } -/// Generate construction code for a single field (Rust -> Python) +/// Generate construction code for a single field (Rust -> Python). fn generate_field_construction( field_name: &Ident, field_name_str: &str, field_type: &Type, use_zbuf: bool, -) -> syn::Result { - // Handle ZBuf fields specially - create zero-copy view using buffer protocol +) -> syn::Result { if use_zbuf { return Ok(quote! { { - // Create a ZBufView which implements buffer protocol for zero-copy access - // Python can use memoryview(zbuf_view) to get zero-copy access to the data let zbuf_view = ::ros_z::zbuf_view::ZBufView::new(self.#field_name.clone()); let py_view = ::pyo3::Py::new(py, zbuf_view)?; kwargs.set_item(#field_name_str, py_view)?; @@ -320,15 +982,12 @@ fn generate_field_construction( TypeClass::Primitive => Ok(quote! { kwargs.set_item(#field_name_str, self.#field_name)?; }), - TypeClass::String => Ok(quote! { kwargs.set_item(#field_name_str, &self.#field_name)?; }), - TypeClass::Vec(inner) => { let inner_class = classify_type(&inner); match inner_class { - // Special case for Vec - output as bytes for performance TypeClass::Primitive if is_u8_type(&inner) => Ok(quote! { { let py_bytes = ::pyo3::types::PyBytes::new_bound(py, &self.#field_name); @@ -338,58 +997,46 @@ fn generate_field_construction( TypeClass::Primitive | TypeClass::String => Ok(quote! { kwargs.set_item(#field_name_str, &self.#field_name)?; }), - _ => { - // Vec of nested messages - Ok(quote! { - { - use ::pyo3::types::PyListMethods; - let py_list = ::pyo3::types::PyList::empty_bound(py); - for item in &self.#field_name { - py_list.append( - <#inner as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(item, py)? - )?; - } - kwargs.set_item(#field_name_str, py_list)?; + _ => Ok(quote! { + { + use ::pyo3::types::PyListMethods; + let py_list = ::pyo3::types::PyList::empty_bound(py); + for item in &self.#field_name { + py_list.append( + <#inner as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(item, py)? + )?; } - }) - } + kwargs.set_item(#field_name_str, py_list)?; + } + }), } } - TypeClass::Array(inner, _) => { let inner_class = classify_type(&inner); match inner_class { TypeClass::Primitive | TypeClass::String => Ok(quote! { kwargs.set_item(#field_name_str, self.#field_name.to_vec())?; }), - _ => { - // Array of nested messages - Ok(quote! { - { - use ::pyo3::types::PyListMethods; - let py_list = ::pyo3::types::PyList::empty_bound(py); - for item in &self.#field_name { - py_list.append( - <#inner as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(item, py)? - )?; - } - kwargs.set_item(#field_name_str, py_list)?; + _ => Ok(quote! { + { + use ::pyo3::types::PyListMethods; + let py_list = ::pyo3::types::PyList::empty_bound(py); + for item in &self.#field_name { + py_list.append( + <#inner as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(item, py)? + )?; } - }) - } + kwargs.set_item(#field_name_str, py_list)?; + } + }), } } - - TypeClass::Nested => { - // Nested message - convert using trait - Ok(quote! { - kwargs.set_item( - #field_name_str, - <#field_type as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(&self.#field_name, py)? - )?; - }) - } - + TypeClass::Nested => Ok(quote! { + kwargs.set_item( + #field_name_str, + <#field_type as ::ros_z::python_bridge::IntoPyMessage>::into_py_message(&self.#field_name, py)? + )?; + }), TypeClass::ZBuf => Ok(quote! { { use ::zenoh_buffers::buffer::SplitBuffer; @@ -401,7 +1048,7 @@ fn generate_field_construction( } } -/// Type classification for code generation +/// Type classification for Python conversion code generation. #[derive(Debug)] enum TypeClass { Primitive, @@ -412,15 +1059,13 @@ enum TypeClass { ZBuf, } -/// Classify a type for code generation purposes +/// Classify a type for Python conversion code generation purposes. fn classify_type(ty: &Type) -> TypeClass { if let Type::Path(type_path) = ty { let segments = &type_path.path.segments; if let Some(last_segment) = segments.last() { - let ident = &last_segment.ident; - let ident_str = ident.to_string(); + let ident_str = last_segment.ident.to_string(); - // Check for primitives if matches!( ident_str.as_str(), "bool" @@ -438,17 +1083,14 @@ fn classify_type(ty: &Type) -> TypeClass { return TypeClass::Primitive; } - // Check for String if ident_str == "String" { return TypeClass::String; } - // Check for ZBuf if ident_str == "ZBuf" { return TypeClass::ZBuf; } - // Check for Vec if ident_str == "Vec" { if let PathArguments::AngleBracketed(args) = &last_segment.arguments { if let Some(GenericArgument::Type(inner)) = args.args.first() { @@ -459,10 +1101,9 @@ fn classify_type(ty: &Type) -> TypeClass { } } - // Check for arrays if let Type::Array(arr) = ty { - if let syn::Expr::Lit(lit) = &arr.len { - if let Lit::Int(int_lit) = &lit.lit { + if let Expr::Lit(lit) = &arr.len { + if let syn::Lit::Int(int_lit) = &lit.lit { if let Ok(size) = int_lit.base10_parse::() { return TypeClass::Array(Box::new((*arr.elem).clone()), size); } @@ -470,44 +1111,56 @@ fn classify_type(ty: &Type) -> TypeClass { } } - // Default to nested message TypeClass::Nested } -/// Check if a field has a specific ros_msg attribute -fn has_ros_msg_attr(attrs: &[Attribute], attr_name: &str) -> bool { +#[derive(Default)] +struct RosMsgArgs { + module: Option, + type_name: Option, + zbuf: bool, +} + +fn parse_ros_msg_args(attrs: &[Attribute]) -> syn::Result { + let mut parsed = RosMsgArgs::default(); + for attr in attrs { - if attr.path().is_ident("ros_msg") { - if let Ok(meta) = attr.parse_args::() { - if meta == attr_name { - return true; - } - } + if !attr.path().is_ident("ros_msg") { + continue; } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("module") { + let value = meta.value()?.parse::()?; + parsed.module = Some(value.value()); + return Ok(()); + } + + if meta.path.is_ident("type_name") { + let value = meta.value()?.parse::()?; + parsed.type_name = Some(value.value()); + return Ok(()); + } + + if meta.path.is_ident("zbuf") { + parsed.zbuf = true; + return Ok(()); + } + + Err(meta + .error("unsupported ros_msg attribute, expected one of: module, type_name, zbuf")) + })?; } - false + + Ok(parsed) } -/// Extract module path from #[ros_msg(module = "...")] attribute fn extract_module_path(attrs: &[Attribute]) -> syn::Result { - for attr in attrs { - if attr.path().is_ident("ros_msg") { - if let Ok(Meta::NameValue(nv)) = attr.parse_args() { - if nv.path.is_ident("module") { - if let syn::Expr::Lit(lit) = &nv.value { - if let Lit::Str(s) = &lit.lit { - return Ok(s.value()); - } - } - } - } - } - } - // Default module path - Ok("ros_z_msgs_py.types".to_string()) + Ok(parse_ros_msg_args(attrs)? + .module + .unwrap_or_else(|| "ros_z_msgs_py.types".to_string())) } -/// Check if a type is u8 fn is_u8_type(ty: &Type) -> bool { if let Type::Path(type_path) = ty { if let Some(last_segment) = type_path.path.segments.last() { @@ -517,11 +1170,8 @@ fn is_u8_type(ty: &Type) -> bool { false } -/// Convert Rust field name to Python attribute name -/// Handles r#type -> type conversion for Rust keywords -fn field_name_to_py_attr(ident: &Ident) -> String { +fn field_name_to_attr(ident: &Ident) -> String { let name = ident.to_string(); - // Strip r# prefix used for Rust keywords if let Some(stripped) = name.strip_prefix("r#") { stripped.to_string() } else { diff --git a/crates/ros-z-protocol/src/entity.rs b/crates/ros-z-protocol/src/entity.rs index 00757637..d6a2654e 100644 --- a/crates/ros-z-protocol/src/entity.rs +++ b/crates/ros-z-protocol/src/entity.rs @@ -54,7 +54,7 @@ impl Display for TopicKE { } /// ROS 2 node entity. -#[derive(Default, Debug, Hash, Clone, PartialEq, Eq)] +#[derive(Debug, Hash, Clone, PartialEq, Eq)] pub struct NodeEntity { pub domain_id: usize, pub z_id: ZenohId, @@ -85,9 +85,8 @@ impl NodeEntity { } /// ROS 2 entity kind (node, publisher, subscription, service, client). -#[derive(Default, Debug, Hash, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] pub enum EntityKind { - #[default] Node, Publisher, Subscription, @@ -122,6 +121,65 @@ impl core::str::FromStr for EntityKind { } } +/// ROS 2 endpoint kind (publisher, subscription, service, client). +#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] +pub enum EndpointKind { + Publisher, + Subscription, + Service, + Client, +} + +impl Display for EndpointKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EndpointKind::Publisher => write!(f, "MP"), + EndpointKind::Subscription => write!(f, "MS"), + EndpointKind::Service => write!(f, "SS"), + EndpointKind::Client => write!(f, "SC"), + } + } +} + +impl core::str::FromStr for EndpointKind { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "MP" => Ok(EndpointKind::Publisher), + "MS" => Ok(EndpointKind::Subscription), + "SS" => Ok(EndpointKind::Service), + "SC" => Ok(EndpointKind::Client), + _ => Err("Invalid endpoint kind"), + } + } +} + +impl From for EntityKind { + fn from(kind: EndpointKind) -> Self { + match kind { + EndpointKind::Publisher => EntityKind::Publisher, + EndpointKind::Subscription => EntityKind::Subscription, + EndpointKind::Service => EntityKind::Service, + EndpointKind::Client => EntityKind::Client, + } + } +} + +impl TryFrom for EndpointKind { + type Error = &'static str; + + fn try_from(kind: EntityKind) -> Result { + match kind { + EntityKind::Node => Err("Node is not a valid endpoint kind"), + EntityKind::Publisher => Ok(EndpointKind::Publisher), + EntityKind::Subscription => Ok(EndpointKind::Subscription), + EntityKind::Service => Ok(EndpointKind::Service), + EntityKind::Client => Ok(EndpointKind::Client), + } + } +} + /// Type hash (RIHS format). #[derive(Debug, Hash, PartialEq, Eq, Clone)] pub struct TypeHash { @@ -224,16 +282,22 @@ impl TypeInfo { } /// ROS 2 endpoint entity (publisher, subscription, service, client). -#[derive(Default, Debug, Hash, PartialEq, Eq, Clone)] +#[derive(Debug, Hash, PartialEq, Eq, Clone)] pub struct EndpointEntity { pub id: usize, - pub node: NodeEntity, - pub kind: EntityKind, + pub node: Option, + pub kind: EndpointKind, pub topic: String, pub type_info: Option, pub qos: QosProfile, } +impl EndpointEntity { + pub fn entity_kind(&self) -> EntityKind { + self.kind.into() + } +} + /// Generic ROS 2 entity (node or endpoint). #[derive(Debug, Clone, PartialEq, Eq)] pub enum Entity { diff --git a/crates/ros-z-protocol/src/format/rmw_zenoh.rs b/crates/ros-z-protocol/src/format/rmw_zenoh.rs index a0c78639..fe26ba81 100644 --- a/crates/ros-z-protocol/src/format/rmw_zenoh.rs +++ b/crates/ros-z-protocol/src/format/rmw_zenoh.rs @@ -10,8 +10,8 @@ use zenoh::{key_expr::KeyExpr, session::ZenohId, Result}; use crate::{ entity::{ - EndpointEntity, Entity, EntityConversionError, EntityKind, LivelinessKE, NodeEntity, - TopicKE, TypeHash, TypeInfo, + EndpointEntity, EndpointKind, Entity, EntityConversionError, EntityKind, LivelinessKE, + NodeEntity, TopicKE, TypeHash, TypeInfo, }, qos::QosProfile, }; @@ -33,9 +33,20 @@ impl KeyExprFormatter for RmwZenohFormatter { const ADMIN_SPACE: &'static str = "@ros2_lv"; fn topic_key_expr(entity: &EndpointEntity) -> Result { - let domain_id = entity.node.domain_id; + let EndpointEntity { + node: Some(node), + topic, + type_info, + .. + } = entity + else { + return Err(zenoh::Error::from( + "rmw-zenoh endpoint keys require node identity", + )); + }; + let domain_id = node.domain_id; let topic = { - let s = &entity.topic; + let s = topic.as_str(); let s = s.strip_prefix('/').unwrap_or(s); let s = s.strip_suffix('/').unwrap_or(s); @@ -50,14 +61,14 @@ impl KeyExprFormatter for RmwZenohFormatter { s.to_string() }; - let type_info = entity.type_info.as_ref().map_or( - format!("{EMPTY_TOPIC_TYPE}/{EMPTY_TOPIC_HASH}"), - |x| { - let type_name = Self::demangle_name(&x.name); - let type_hash = Self::demangle_name(&x.hash.to_string()); - format!("{type_name}/{type_hash}") - }, - ); + let type_info = + type_info + .as_ref() + .map_or(format!("{EMPTY_TOPIC_TYPE}/{EMPTY_TOPIC_HASH}"), |x| { + let type_name = Self::demangle_name(&x.name); + let type_hash = Self::demangle_name(&x.hash.to_string()); + format!("{type_name}/{type_hash}") + }); Ok(TopicKE::new( format!("{domain_id}/{topic}/{type_info}").try_into()?, @@ -68,19 +79,24 @@ impl KeyExprFormatter for RmwZenohFormatter { let EndpointEntity { id, node: - NodeEntity { + Some(NodeEntity { domain_id, z_id, id: node_id, name: node_name, namespace: node_namespace, enclave: _, - }, + }), kind, topic: topic_name, type_info, qos, - } = entity; + } = entity + else { + return Err(zenoh::Error::from( + "rmw-zenoh liveliness requires node identity", + )); + }; let node_namespace = if node_namespace.is_empty() { EMPTY_PLACEHOLDER.to_string() @@ -220,8 +236,8 @@ impl KeyExprFormatter for RmwZenohFormatter { Entity::Endpoint(EndpointEntity { id: entity_id, - node, - kind: entity_kind, + node: Some(node), + kind: EndpointKind::try_from(entity_kind).map_err(|_| ParsingError)?, topic: topic_name, type_info, qos, @@ -244,7 +260,7 @@ impl KeyExprFormatter for RmwZenohFormatter { #[cfg(test)] mod tests { use super::*; - use crate::entity::{EndpointEntity, EntityKind, NodeEntity, TypeInfo}; + use crate::entity::{EndpointEntity, EndpointKind, NodeEntity, TypeInfo}; use crate::qos::{QosDurability, QosHistory, QosProfile, QosReliability}; #[test] @@ -303,8 +319,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -349,8 +365,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -417,8 +433,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Subscription, + node: Some(node), + kind: EndpointKind::Subscription, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -450,8 +466,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "add_two_ints".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/AddTwoInts", @@ -486,8 +502,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Client, + node: Some(node), + kind: EndpointKind::Client, topic: "add_two_ints".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/AddTwoInts", @@ -570,8 +586,8 @@ mod tests { // Service with slashes in name (common pattern: /node_name/service_name) let entity = EndpointEntity { id: 10, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "/talker/get_type_description".to_string(), type_info: Some(TypeInfo::new( "type_description_interfaces::srv::dds_::GetTypeDescription_", @@ -612,8 +628,8 @@ mod tests { let entity = EndpointEntity { id: 11, - node, - kind: EntityKind::Client, + node: Some(node), + kind: EndpointKind::Client, topic: "/my_service/sub_service/action".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/AddTwoInts", @@ -650,8 +666,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "/ns/topic".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -691,8 +707,8 @@ mod tests { let entity = EndpointEntity { id: 2, - node, - kind: EntityKind::Subscription, + node: Some(node), + kind: EndpointKind::Subscription, topic: "/robot/sensor/data".to_string(), type_info: Some(TypeInfo::new("sensor_msgs/msg/Image", TypeHash::zero())), qos: QosProfile::default(), @@ -732,8 +748,8 @@ mod tests { let entity = EndpointEntity { id: 3, - node, - kind: EntityKind::Publisher, // Actions use pub/sub for feedback/status + node: Some(node), + kind: EndpointKind::Publisher, // Actions use pub/sub for feedback/status topic: "/fibonacci/_action/send_goal".to_string(), type_info: Some(TypeInfo::new( "action_tutorials_interfaces::action::dds_::Fibonacci_SendGoal_", @@ -774,8 +790,8 @@ mod tests { // Service with leading and trailing slashes let entity = EndpointEntity { id: 4, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "/my_service/".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/Trigger", @@ -815,8 +831,8 @@ mod tests { let entity = EndpointEntity { id: 5, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: None, // No type info qos: QosProfile::default(), @@ -853,8 +869,8 @@ mod tests { let entity = EndpointEntity { id: 6, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), // Type name with mangled slashes (as stored internally) type_info: Some(TypeInfo::new("std_msgs%msg%String", TypeHash::zero())), @@ -892,8 +908,8 @@ mod tests { let entity = EndpointEntity { id: 10, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "/talker/get_type_description".to_string(), type_info: Some(TypeInfo::new( "type_description_interfaces::srv::dds_::GetTypeDescription_", @@ -928,8 +944,8 @@ mod tests { let entity = EndpointEntity { id: 7, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "/data/temperature".to_string(), type_info: Some(TypeInfo::new( "sensor_msgs/msg/Temperature", @@ -976,8 +992,8 @@ mod tests { let entity = EndpointEntity { id: 8, - node, - kind: EntityKind::Subscription, + node: Some(node), + kind: EndpointKind::Subscription, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -1015,8 +1031,8 @@ mod tests { let entity = EndpointEntity { id: 9, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -1049,8 +1065,8 @@ mod tests { let entity = EndpointEntity { id: 10, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "image".to_string(), type_info: Some(TypeInfo::new( "sensor_msgs/msg/Image", @@ -1097,8 +1113,8 @@ mod tests { let entity = EndpointEntity { id: 11, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos, @@ -1141,8 +1157,8 @@ mod tests { let original = EndpointEntity { id: 12, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "/topic/name".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -1154,8 +1170,14 @@ mod tests { if let Entity::Endpoint(parsed_entity) = parsed { assert_eq!(parsed_entity.id, original.id); assert_eq!(parsed_entity.kind, original.kind); - assert_eq!(parsed_entity.node.name, original.node.name); - assert_eq!(parsed_entity.node.namespace, original.node.namespace); + assert_eq!( + parsed_entity.node.as_ref().unwrap().name, + original.node.as_ref().unwrap().name + ); + assert_eq!( + parsed_entity.node.as_ref().unwrap().namespace, + original.node.as_ref().unwrap().namespace + ); // Topic name should be reconstructed (slashes demangled) assert_eq!(parsed_entity.topic, "/topic/name"); } else { @@ -1178,8 +1200,8 @@ mod tests { let original = EndpointEntity { id: 13, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "/my/service".to_string(), type_info: Some(TypeInfo::new( "example_interfaces::srv::dds_::AddTwoInts_", @@ -1193,7 +1215,7 @@ mod tests { if let Entity::Endpoint(parsed_entity) = parsed { assert_eq!(parsed_entity.id, original.id); - assert_eq!(parsed_entity.kind, EntityKind::Service); + assert_eq!(parsed_entity.kind, EndpointKind::Service); assert_eq!(parsed_entity.topic, "/my/service"); assert_eq!( parsed_entity.type_info.as_ref().unwrap().name, @@ -1219,8 +1241,8 @@ mod tests { let original = EndpointEntity { id: 14, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "test".to_string(), type_info: None, qos: QosProfile::default(), @@ -1282,8 +1304,8 @@ mod tests { let entity = EndpointEntity { id: 16, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "simple_topic".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -1318,8 +1340,8 @@ mod tests { let entity = EndpointEntity { id: 17, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "/a//b".to_string(), // Consecutive slashes type_info: Some(TypeInfo::new("std_srvs/srv/Trigger", TypeHash::zero())), qos: QosProfile::default(), @@ -1348,8 +1370,8 @@ mod tests { let entity = EndpointEntity { id: 18, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), // DDS type name with ::dds_:: namespace type_info: Some(TypeInfo::new( @@ -1386,8 +1408,8 @@ mod tests { let long_topic = "/very/long/topic/name/with/many/segments/for/testing/purposes"; let entity = EndpointEntity { id: 19, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: long_topic.to_string(), type_info: Some(TypeInfo::new("std_srvs/srv/Trigger", TypeHash::zero())), qos: QosProfile::default(), @@ -1473,8 +1495,8 @@ mod kani_proofs { }; let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "/kani_topic".to_string(), type_info: Some(TypeInfo { name: "std_msgs/msg/String".to_string(), @@ -1492,8 +1514,11 @@ mod kani_proofs { let parsed = RmwZenohFormatter::parse_liveliness(&ke).expect("parse_liveliness"); if let crate::entity::Entity::Endpoint(ep) = parsed { - kani::assert(ep.node.domain_id == domain_id, "domain_id preserved"); - kani::assert(ep.kind == EntityKind::Publisher, "entity kind preserved"); + kani::assert( + ep.node.as_ref().unwrap().domain_id == domain_id, + "domain_id preserved", + ); + kani::assert(ep.kind == EndpointKind::Publisher, "entity kind preserved"); } else { kani::assert(false, "expected Endpoint entity"); } diff --git a/crates/ros-z-protocol/src/format/ros2dds.rs b/crates/ros-z-protocol/src/format/ros2dds.rs index 642b7975..7d59a7a4 100644 --- a/crates/ros-z-protocol/src/format/ros2dds.rs +++ b/crates/ros-z-protocol/src/format/ros2dds.rs @@ -12,7 +12,7 @@ use zenoh::{key_expr::KeyExpr, session::ZenohId, Result}; use crate::{ entity::{ - EndpointEntity, Entity, EntityConversionError, EntityKind, LivelinessKE, NodeEntity, + EndpointEntity, EndpointKind, Entity, EntityConversionError, LivelinessKE, NodeEntity, TopicKE, TypeHash, TypeInfo, }, qos::{QosDurability, QosHistory, QosProfile, QosReliability}, @@ -48,11 +48,10 @@ impl KeyExprFormatter for Ros2DdsFormatter { fn liveliness_key_expr(entity: &EndpointEntity, zid: &ZenohId) -> Result { // ros2dds format: @//@ros2_lv///[/] let kind = match entity.kind { - EntityKind::Publisher => "MP", - EntityKind::Subscription => "MS", - EntityKind::Service => "SS", - EntityKind::Client => "SC", - EntityKind::Node => "NN", // ros2dds doesn't actually expose node tokens + EndpointKind::Publisher => "MP", + EndpointKind::Subscription => "MS", + EndpointKind::Service => "SS", + EndpointKind::Client => "SC", }; // Escape slashes in topic name @@ -72,7 +71,7 @@ impl KeyExprFormatter for Ros2DdsFormatter { // QoS encoding for pub/sub only let qos_str = match entity.kind { - EntityKind::Publisher | EntityKind::Subscription => { + EndpointKind::Publisher | EndpointKind::Subscription => { format!("/{}", Self::encode_qos(&entity.qos, false)) } _ => String::new(), @@ -119,20 +118,24 @@ impl KeyExprFormatter for Ros2DdsFormatter { // Entity kind let kind_str = iter.next().ok_or(MissingEntityKind)?; let kind = match kind_str { - "MP" => EntityKind::Publisher, - "MS" => EntityKind::Subscription, - "SS" => EntityKind::Service, - "SC" => EntityKind::Client, + "MP" => EndpointKind::Publisher, + "MS" => EndpointKind::Subscription, + "SS" => EndpointKind::Service, + "SC" => EndpointKind::Client, "AS" | "AC" => { // Action server/client - map to Service for now - EntityKind::Service + EndpointKind::Service } _ => return Err(zenoh::Error::from(ParsingError)), }; // Topic key expression (escaped) let topic_escaped = iter.next().ok_or(MissingTopicName)?; - let topic = Self::demangle_name(topic_escaped); + let topic = match Self::demangle_name(topic_escaped) { + topic if topic.is_empty() => "/".to_string(), + topic if topic.starts_with('/') => topic, + topic => format!("/{}", topic), + }; // Type name (escaped) let type_escaped = iter.next().ok_or(MissingTopicType)?; @@ -146,16 +149,6 @@ impl KeyExprFormatter for Ros2DdsFormatter { QosProfile::default() }; - // Create a placeholder node (ros2dds doesn't include node info in liveliness) - let node = NodeEntity { - id: 0, - domain_id: 0, - z_id, - name: String::new(), - namespace: String::new(), - enclave: String::new(), - }; - let type_info = if type_name.is_empty() || type_name == "unknown" { None } else { @@ -165,9 +158,11 @@ impl KeyExprFormatter for Ros2DdsFormatter { }) }; + let _ = z_id; + Ok(Entity::Endpoint(EndpointEntity { id: 0, - node, + node: None, kind, topic, type_info, @@ -417,8 +412,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -446,8 +441,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Publisher, + node: Some(node), + kind: EndpointKind::Publisher, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -505,8 +500,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Subscription, + node: Some(node), + kind: EndpointKind::Subscription, topic: "chatter".to_string(), type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), qos: QosProfile::default(), @@ -523,6 +518,39 @@ mod tests { ); } + #[test] + fn test_parse_liveliness_restores_absolute_topic_name() { + let zid: zenoh::session::ZenohId = "1234567890abcdef1234567890abcdef".parse().unwrap(); + let node = NodeEntity::new( + 0, + zid, + 1, + "test_node".to_string(), + "/".to_string(), + String::new(), + ); + + let entity = EndpointEntity { + id: 1, + node: Some(node), + kind: EndpointKind::Publisher, + topic: "/chatter".to_string(), + type_info: Some(TypeInfo::new("std_msgs/msg/String", TypeHash::zero())), + qos: QosProfile::default(), + }; + + let liveliness_ke = Ros2DdsFormatter::liveliness_key_expr(&entity, &zid).unwrap(); + let parsed = Ros2DdsFormatter::parse_liveliness(&liveliness_ke).unwrap(); + + match parsed { + Entity::Endpoint(endpoint) => { + assert_eq!(endpoint.topic, "/chatter"); + assert!(endpoint.node.is_none()); + } + other => panic!("expected endpoint entity, got {:?}", other), + } + } + /// Test service server liveliness key expression #[test] fn test_service_liveliness_key_expr() { @@ -538,8 +566,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Service, + node: Some(node), + kind: EndpointKind::Service, topic: "add_two_ints".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/AddTwoInts", @@ -585,8 +613,8 @@ mod tests { let entity = EndpointEntity { id: 1, - node, - kind: EntityKind::Client, + node: Some(node), + kind: EndpointKind::Client, topic: "add_two_ints".to_string(), type_info: Some(TypeInfo::new( "example_interfaces/srv/AddTwoInts", diff --git a/crates/ros-z-protocol/src/lib.rs b/crates/ros-z-protocol/src/lib.rs index 2c6d3079..06312bee 100644 --- a/crates/ros-z-protocol/src/lib.rs +++ b/crates/ros-z-protocol/src/lib.rs @@ -34,8 +34,8 @@ //! //! let entity = EndpointEntity { //! id: 1, -//! node, -//! kind: EntityKind::Publisher, +//! node: Some(node), +//! kind: EndpointKind::Publisher, //! topic: "/chatter".to_string(), //! type_info: None, //! qos: Default::default(), @@ -53,7 +53,9 @@ pub mod entity; pub mod format; pub mod qos; -pub use entity::{EndpointEntity, Entity, EntityKind, NodeEntity, TypeHash, TypeInfo}; +pub use entity::{ + EndpointEntity, EndpointKind, Entity, EntityKind, NodeEntity, TypeHash, TypeInfo, +}; #[cfg(feature = "rmw-zenoh")] pub use format::rmw_zenoh::RmwZenohFormatter; #[cfg(feature = "ros2dds")] diff --git a/crates/ros-z-protocol/tests/key_expr.rs b/crates/ros-z-protocol/tests/key_expr.rs index 9a34e182..8d74c01a 100644 --- a/crates/ros-z-protocol/tests/key_expr.rs +++ b/crates/ros-z-protocol/tests/key_expr.rs @@ -6,7 +6,7 @@ //! return Err rather than panicking. use ros_z_protocol::{ - entity::{EndpointEntity, EntityKind, NodeEntity, TypeHash, TypeInfo}, + entity::{EndpointEntity, EndpointKind, NodeEntity, TypeHash, TypeInfo}, format::{rmw_zenoh::RmwZenohFormatter, KeyExprFormatter}, qos::{QosDurability, QosHistory, QosProfile, QosReliability}, }; @@ -27,10 +27,10 @@ fn default_node(domain_id: usize) -> NodeEntity { } } -fn endpoint_entity(domain_id: usize, kind: EntityKind, topic: &str) -> EndpointEntity { +fn endpoint_entity(domain_id: usize, kind: EndpointKind, topic: &str) -> EndpointEntity { EndpointEntity { id: 42, - node: default_node(domain_id), + node: Some(default_node(domain_id)), kind, topic: topic.to_string(), type_info: Some(TypeInfo { @@ -56,7 +56,7 @@ fn parse_liveliness(ke_str: &str) -> zenoh::Result/// @@ -252,7 +261,7 @@ fn test_topic_key_expr_format() { #[test] fn test_topic_key_expr_preserves_internal_slashes() { - let entity = endpoint_entity(0, EntityKind::Publisher, "/ns/topic"); + let entity = endpoint_entity(0, EndpointKind::Publisher, "/ns/topic"); let ke = RmwZenohFormatter::topic_key_expr(&entity).unwrap(); let ke_str = ke.to_string(); // Internal slashes must be preserved (not mangled) diff --git a/crates/ros-z-py/src/service.rs b/crates/ros-z-py/src/service.rs index 11c3a8b0..43186aaa 100644 --- a/crates/ros-z-py/src/service.rs +++ b/crates/ros-z-py/src/service.rs @@ -1,7 +1,7 @@ use crate::traits::{RawClient, RawServer}; use pyo3::prelude::*; use pyo3::types::PyDict; -use ros_z::service::QueryKey; +use ros_z::service::RequestId; use std::time::Duration; /// Python wrapper for service client @@ -27,56 +27,21 @@ impl PyZClient { #[allow(unsafe_op_in_unsafe_fn)] #[pymethods] impl PyZClient { - /// Send a service request - unsafe fn send_request(&self, py: Python, data: &Bound<'_, PyAny>) -> PyResult<()> { + /// Call a service request and wait for its response. + #[pyo3(signature = (data, timeout=None))] + unsafe fn call( + &self, + py: Python, + data: &Bound<'_, PyAny>, + timeout: Option, + ) -> PyResult { let cdr_bytes = ros_z_msgs::serialize_to_cdr(&self.request_type_name, data.py(), data)?; - - py.allow_threads(|| { - self.inner - .send_request_serialized(&cdr_bytes) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) - }) - } - - /// Receive a service response (blocking) - #[pyo3(signature = (timeout=None))] - unsafe fn take_response(&self, py: Python, timeout: Option) -> PyResult> { let timeout_duration = timeout.map(Duration::from_secs_f64); - let result = py.allow_threads(|| self.inner.take_response_serialized(timeout_duration)); - - match result { - Ok(cdr_bytes) => { - let obj = - ros_z_msgs::deserialize_from_cdr(&self.response_type_name, py, &cdr_bytes)?; - Ok(Some(obj)) - } - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("timeout") - || err_str.contains("Timeout") - || err_str.contains("timed out") - || err_str.contains("No sample available") - { - Ok(None) - } else { - Err(pyo3::exceptions::PyRuntimeError::new_err(err_str)) - } - } - } - } - - /// Try to receive a response without blocking - unsafe fn try_take_response(&self, py: Python) -> PyResult> { - match self.inner.try_take_response_serialized() { - Ok(Some(cdr_bytes)) => { - let obj = - ros_z_msgs::deserialize_from_cdr(&self.response_type_name, py, &cdr_bytes)?; - Ok(Some(obj)) - } - Ok(None) => Ok(None), - Err(e) => Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), - } + let cdr_bytes = py + .allow_threads(|| self.inner.call_serialized(&cdr_bytes, timeout_duration)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + ros_z_msgs::deserialize_from_cdr(&self.response_type_name, py, &cdr_bytes) } /// Get the service type name (for debugging) @@ -127,8 +92,8 @@ impl PyZServer { let obj = ros_z_msgs::deserialize_from_cdr(&self.request_type_name, py, &cdr_bytes)?; let request_id = PyDict::new_bound(py); - request_id.set_item("sn", key.sn)?; - request_id.set_item("gid", key.gid.to_vec())?; + request_id.set_item("sn", key.sequence_number)?; + request_id.set_item("gid", key.writer_guid.to_vec())?; Ok((request_id.into(), obj)) } @@ -147,7 +112,10 @@ impl PyZServer { let gid_vec: Vec = request_id.get_item("gid")?.unwrap().extract()?; let mut gid = [0u8; 16]; gid.copy_from_slice(&gid_vec[..16]); - let key = QueryKey { sn, gid }; + let key = RequestId { + sequence_number: sn, + writer_guid: gid, + }; py.allow_threads(|| { let inner = self diff --git a/crates/ros-z-py/src/traits.rs b/crates/ros-z-py/src/traits.rs index 2dffc261..5a1e1865 100644 --- a/crates/ros-z-py/src/traits.rs +++ b/crates/ros-z-py/src/traits.rs @@ -93,19 +93,17 @@ impl RawSubscriber for GenericSubWrapper { // -- Generic service wrappers using RawBytesService -- -use ros_z::service::{QueryKey, ZClient, ZServer}; +use ros_z::service::{RequestId, ServiceReply, ZClient, ZServer}; /// Type-erased client trait for Python interop pub(crate) trait RawClient: Send + Sync { - fn send_request_serialized(&self, data: &[u8]) -> Result<()>; - fn take_response_serialized(&self, timeout: Option) -> Result>; - fn try_take_response_serialized(&self) -> Result>>; + fn call_serialized(&self, data: &[u8], timeout: Option) -> Result>; } /// Type-erased server trait for Python interop pub(crate) trait RawServer: Send + Sync { - fn take_request_serialized(&self) -> Result<(QueryKey, Vec)>; - fn send_response_serialized(&self, data: &[u8], key: &QueryKey) -> Result<()>; + fn take_request_serialized(&self) -> Result<(RequestId, Vec)>; + fn send_response_serialized(&self, data: &[u8], request_id: &RequestId) -> Result<()>; } /// Generic client wrapper using RawBytesService @@ -120,85 +118,72 @@ impl GenericClientWrapper { } impl RawClient for GenericClientWrapper { - fn send_request_serialized(&self, data: &[u8]) -> Result<()> { + fn call_serialized(&self, data: &[u8], timeout: Option) -> Result> { let request = RawBytesMessage(data.to_vec()); + let timeout = timeout.unwrap_or(Duration::from_secs(3600)); let rt = tokio::runtime::Handle::try_current() .or_else(|_| tokio::runtime::Runtime::new().map(|rt| rt.handle().clone()))?; - rt.block_on(async { + let response = rt.block_on(async { self.inner - .send_request(&request) + .call_or_timeout(&request, timeout) .await - .map_err(|e| anyhow::anyhow!("Failed to send request: {}", e)) - }) - } - - fn take_response_serialized(&self, timeout: Option) -> Result> { - let timeout_duration = timeout.unwrap_or(Duration::from_secs(3600)); - let response = self - .inner - .take_response_timeout(timeout_duration) - .map_err(|e| anyhow::anyhow!("Failed to receive response: {}", e))?; + .map_err(|e| anyhow::anyhow!("Failed to call service: {}", e)) + })?; Ok(response.0) } - - fn try_take_response_serialized(&self) -> Result>> { - match self.inner.take_response_timeout(Duration::from_millis(1)) { - Ok(response) => Ok(Some(response.0)), - Err(e) => { - let err_str = e.to_string(); - if err_str.contains("timeout") - || err_str.contains("Timeout") - || err_str.contains("No sample available") - { - Ok(None) - } else { - Err(anyhow::anyhow!("Failed to receive response: {}", e)) - } - } - } - } } /// Generic server wrapper using RawBytesService pub struct GenericServerWrapper { inner: std::sync::Mutex>, + pending: std::sync::Mutex>>, } impl GenericServerWrapper { pub fn new(inner: ZServer) -> Self { Self { inner: std::sync::Mutex::new(inner), + pending: std::sync::Mutex::new(std::collections::HashMap::new()), } } } impl RawServer for GenericServerWrapper { - fn take_request_serialized(&self) -> Result<(QueryKey, Vec)> { + fn take_request_serialized(&self) -> Result<(RequestId, Vec)> { let mut server = self .inner .lock() .map_err(|e| anyhow::anyhow!("Failed to lock server: {}", e))?; - let (key, request) = server + let request = server .take_request() .map_err(|e| anyhow::anyhow!("Failed to receive request: {}", e))?; + let (request, reply) = request.into_parts(); + let request_id = reply.id().clone(); + + self.pending + .lock() + .map_err(|e| anyhow::anyhow!("Failed to lock pending replies: {}", e))? + .insert(request_id.clone(), reply); - Ok((key, request.0)) + Ok((request_id, request.0)) } - fn send_response_serialized(&self, data: &[u8], key: &QueryKey) -> Result<()> { + fn send_response_serialized(&self, data: &[u8], request_id: &RequestId) -> Result<()> { let response = RawBytesMessage(data.to_vec()); - let mut server = self - .inner + let reply = self + .pending .lock() - .map_err(|e| anyhow::anyhow!("Failed to lock server: {}", e))?; + .map_err(|e| anyhow::anyhow!("Failed to lock pending replies: {}", e))? + .remove(request_id) + .ok_or_else(|| anyhow::anyhow!("Unknown request id"))?; - server - .send_response(&response, key) + reply + .reply_blocking(&response) .map_err(|e| anyhow::anyhow!("Failed to send response: {}", e)) } } diff --git a/crates/ros-z-tests/tests/common/mod.rs b/crates/ros-z-tests/tests/common/mod.rs index 966715a2..d7640d30 100644 --- a/crates/ros-z-tests/tests/common/mod.rs +++ b/crates/ros-z-tests/tests/common/mod.rs @@ -209,8 +209,9 @@ pub fn wait_for_service_ready( let rt = tokio::runtime::Runtime::new()?; let result = rt.block_on(async { - client.send_request(&test_request).await?; - client.take_response_timeout(Duration::from_millis(500)) + client + .call_or_timeout(&test_request, Duration::from_millis(500)) + .await }); if result.is_ok() { diff --git a/crates/ros-z-tests/tests/dds_interop.rs b/crates/ros-z-tests/tests/dds_interop.rs index bdc1f9af..b4980909 100644 --- a/crates/ros-z-tests/tests/dds_interop.rs +++ b/crates/ros-z-tests/tests/dds_interop.rs @@ -275,12 +275,7 @@ fn test_ros2_dds_service_server_to_ros_z_client() { let req = AddTwoIntsRequest { a: 15, b: 27 }; println!("Sending request: {} + {}", req.a, req.b); - client - .send_request(&req) - .await - .expect("Failed to send request"); - - match client.take_response_timeout(Duration::from_secs(10)) { + match client.call_or_timeout(&req, Duration::from_secs(10)).await { Ok(resp) => { println!("Received response: {}", resp.sum); assert_eq!(resp.sum, 42, "Expected 15 + 27 = 42, got {}", resp.sum); @@ -342,12 +337,16 @@ fn test_ros_z_service_server_to_ros2_dds_client() { // Handle one request match server.async_take_request().await { - Ok((key, req)) => { - println!("Received request: {} + {}", req.a, req.b); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; - server - .send_response(&resp, &key) - .expect("Failed to send response"); + Ok(req) => { + println!( + "Received request: {} + {}", + req.message().a, + req.message().b + ); + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; + req.reply(&resp).await.expect("Failed to send response"); println!("Sent response: {}", resp.sum); } Err(e) => { diff --git a/crates/ros-z-tests/tests/humble_jazzy_bridge.rs b/crates/ros-z-tests/tests/humble_jazzy_bridge.rs index cb520b9d..d23fa52d 100644 --- a/crates/ros-z-tests/tests/humble_jazzy_bridge.rs +++ b/crates/ros-z-tests/tests/humble_jazzy_bridge.rs @@ -145,9 +145,8 @@ fn test_service_humble_server_jazzy_client() { .expect("failed to create client"); let req = ros_z_msgs::ros::example_interfaces::AddTwoIntsRequest { a: 3, b: 7 }; - client.send_request(&req).await?; // ctx / node / client are dropped here, inside the runtime - client.take_response_timeout(Duration::from_secs(20)) + client.call_or_timeout(&req, Duration::from_secs(20)).await }); let response = response.expect("did not receive service response within timeout"); @@ -187,13 +186,16 @@ fn test_service_jazzy_server_humble_client() { // Handle one request or give up after 10 s. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); loop { - if let Ok((key, req)) = server.take_request() { - let resp = - ros_z_msgs::ros::example_interfaces::AddTwoIntsResponse { sum: req.a + req.b }; - let _ = server.send_response(&resp, &key); + if let Ok(req) = server.take_request() { + let resp = ros_z_msgs::ros::example_interfaces::AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; + let _ = req.reply_blocking(&resp); println!( "Handled Humble→Jazzy service call: {} + {} = {}", - req.a, req.b, resp.sum + req.message().a, + req.message().b, + resp.sum ); break; } diff --git a/crates/ros-z-tests/tests/parameter_tests.rs b/crates/ros-z-tests/tests/parameter_tests.rs index 88986fc6..5393b49a 100644 --- a/crates/ros-z-tests/tests/parameter_tests.rs +++ b/crates/ros-z-tests/tests/parameter_tests.rs @@ -519,15 +519,14 @@ mod service_tests { .build() .expect("get client"); - get_client - .send_request(&GetParametersRequest { - names: vec!["value".to_string()], - }) - .await - .expect("send"); - let resp: GetParametersResponse = get_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &GetParametersRequest { + names: vec!["value".to_string()], + }, + Duration::from_secs(5), + ) + .await .expect("response"); assert_eq!(resp.values.len(), 1); @@ -540,16 +539,15 @@ mod service_tests { .build() .expect("list client"); - list_client - .send_request(&ListParametersRequest { - prefixes: vec![], - depth: 0, - }) - .await - .expect("send"); - let list_resp: ListParametersResponse = list_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &ListParametersRequest { + prefixes: vec![], + depth: 0, + }, + Duration::from_secs(5), + ) + .await .expect("list response"); assert!(list_resp.result.names.contains(&"value".to_string())); @@ -564,18 +562,17 @@ mod service_tests { wire_value.r#type = 2; wire_value.integer_value = 42; - set_client - .send_request(&SetParametersRequest { - parameters: vec![rcl_interfaces::Parameter { - name: "value".to_string(), - value: wire_value, - }], - }) - .await - .expect("send"); - let set_resp: SetParametersResponse = set_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &SetParametersRequest { + parameters: vec![rcl_interfaces::Parameter { + name: "value".to_string(), + value: wire_value, + }], + }, + Duration::from_secs(5), + ) + .await .expect("set response"); assert_eq!(set_resp.results.len(), 1); @@ -591,15 +588,14 @@ mod service_tests { .build() .expect("types client"); - types_client - .send_request(&GetParameterTypesRequest { - names: vec!["value".to_string(), "nonexistent".to_string()], - }) - .await - .expect("send"); - let types_resp: GetParameterTypesResponse = types_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &GetParameterTypesRequest { + names: vec!["value".to_string(), "nonexistent".to_string()], + }, + Duration::from_secs(5), + ) + .await .expect("types response"); let type_bytes = types_resp.types.contiguous(); @@ -613,15 +609,14 @@ mod service_tests { .build() .expect("desc client"); - desc_client - .send_request(&DescribeParametersRequest { - names: vec!["value".to_string()], - }) - .await - .expect("send"); - let desc_resp: DescribeParametersResponse = desc_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &DescribeParametersRequest { + names: vec!["value".to_string()], + }, + Duration::from_secs(5), + ) + .await .expect("desc response"); assert_eq!(desc_resp.descriptors.len(), 1); @@ -689,15 +684,14 @@ mod service_tests { .build() .expect("atomic client"); - atomic_client - .send_request(&SetParametersAtomicallyRequest { - parameters: vec![make_int("a", 10), make_int("b", 20)], - }) - .await - .expect("send"); - let atomic_resp: SetParametersAtomicallyResponse = atomic_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &SetParametersAtomicallyRequest { + parameters: vec![make_int("a", 10), make_int("b", 20)], + }, + Duration::from_secs(5), + ) + .await .expect("atomic response"); assert!( @@ -716,15 +710,14 @@ mod service_tests { .build() .expect("get client"); - get_client - .send_request(&GetParametersRequest { - names: vec!["a".to_string(), "b".to_string()], - }) - .await - .expect("send"); - let get_resp: GetParametersResponse = get_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &GetParametersRequest { + names: vec!["a".to_string(), "b".to_string()], + }, + Duration::from_secs(5), + ) + .await .expect("get response"); assert_eq!(get_resp.values.len(), 2); @@ -777,15 +770,14 @@ mod service_tests { } }; - atomic_client - .send_request(&SetParametersAtomicallyRequest { - parameters: vec![make_int("a", 10), make_int("b", 20)], - }) - .await - .expect("send"); - let resp: SetParametersAtomicallyResponse = atomic_client - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout( + &SetParametersAtomicallyRequest { + parameters: vec![make_int("a", 10), make_int("b", 20)], + }, + Duration::from_secs(5), + ) + .await .expect("atomic response"); assert!( diff --git a/crates/ros-z-tests/tests/python_interop.rs b/crates/ros-z-tests/tests/python_interop.rs index 2d1c42e6..91b4ffde 100644 --- a/crates/ros-z-tests/tests/python_interop.rs +++ b/crates/ros-z-tests/tests/python_interop.rs @@ -243,11 +243,8 @@ fn test_python_server_rust_client() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { client - .send_request(&request) + .call_or_timeout(&request, Duration::from_secs(5)) .await - .expect("Failed to send request"); - client - .take_response_timeout(Duration::from_secs(5)) .map(|resp| resp.sum) }) }) @@ -295,13 +292,17 @@ fn test_rust_server_python_client() { // Handle one request with polling let start = std::time::Instant::now(); while start.elapsed() < Duration::from_secs(10) { - if let Ok((key, req)) = server.take_request() { - let sum = req.a + req.b; - println!(" [rust] Received: {} + {} = {}", req.a, req.b, sum); + if let Ok(req) = server.take_request() { + let sum = req.message().a + req.message().b; + println!( + " [rust] Received: {} + {} = {}", + req.message().a, + req.message().b, + sum + ); let response = AddTwoIntsResponse { sum }; - server - .send_response(&response, &key) + req.reply_blocking(&response) .expect("Failed to send response"); break; } diff --git a/crates/ros-z-tests/tests/service_interop.rs b/crates/ros-z-tests/tests/service_interop.rs index 7b17034e..2d8fc936 100644 --- a/crates/ros-z-tests/tests/service_interop.rs +++ b/crates/ros-z-tests/tests/service_interop.rs @@ -30,7 +30,6 @@ fn test_ros_z_server_ros_z_client() { .build() .expect("Failed to create node"); - // Correct API: use tuple type and take_request/send_response pattern let mut zsrv = node .create_service::("add_two_ints_test1") .build() @@ -39,12 +38,17 @@ fn test_ros_z_server_ros_z_client() { println!("Server ready, waiting for requests..."); // Handle one request - if let Ok((key, req)) = zsrv.take_request() { - println!("Received request: {} + {}", req.a, req.b); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; + if let Ok(req) = zsrv.take_request() { + println!( + "Received request: {} + {}", + req.message().a, + req.message().b + ); + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; println!("Sending response: {}", resp.sum); - zsrv.send_response(&resp, &key) - .expect("Failed to send response"); + req.reply_blocking(&resp).expect("Failed to send response"); } }); @@ -72,13 +76,9 @@ fn test_ros_z_server_ros_z_client() { println!("Sending request..."); - let req = AddTwoIntsRequest { a: 5, b: 3 }; - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout(&AddTwoIntsRequest { a: 5, b: 3 }, Duration::from_secs(5)) + .await .expect("Failed to receive response"); println!("Received response: {}", resp.sum); @@ -115,11 +115,16 @@ fn test_ros_z_server_ros_z_client_multipart_name() { println!("Server ready (multi-part name), waiting for requests..."); - if let Ok((key, req)) = zsrv.take_request() { - println!("Received request: {} + {}", req.a, req.b); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; - zsrv.send_response(&resp, &key) - .expect("Failed to send response"); + if let Ok(req) = zsrv.take_request() { + println!( + "Received request: {} + {}", + req.message().a, + req.message().b + ); + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; + req.reply_blocking(&resp).expect("Failed to send response"); } }); @@ -143,13 +148,9 @@ fn test_ros_z_server_ros_z_client_multipart_name() { println!("Client ready, waiting..."); tokio::time::sleep(Duration::from_millis(500)).await; - let req = AddTwoIntsRequest { a: 7, b: 5 }; - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(Duration::from_secs(5)) + .call_or_timeout(&AddTwoIntsRequest { a: 7, b: 5 }, Duration::from_secs(5)) + .await .expect("Failed to receive response"); println!("Received response: {}", resp.sum); @@ -192,12 +193,17 @@ fn test_ros_z_server_ros2_client() { println!("Server ready for ROS2 client..."); // Handle one request - if let Ok((key, req)) = zsrv.take_request() { - println!("Received request from ROS2: {} + {}", req.a, req.b); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; + if let Ok(req) = zsrv.take_request() { + println!( + "Received request from ROS2: {} + {}", + req.message().a, + req.message().b + ); + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; println!("Sending response: {}", resp.sum); - zsrv.send_response(&resp, &key) - .expect("Failed to send response"); + req.reply_blocking(&resp).expect("Failed to send response"); } }); @@ -260,12 +266,17 @@ fn test_ros_z_server_ros2_client_multipart() { println!("Server ready for ROS2 client (multi-part name)..."); - if let Ok((key, req)) = zsrv.take_request() { - println!("Received request from ROS2: {} + {}", req.a, req.b); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; + if let Ok(req) = zsrv.take_request() { + println!( + "Received request from ROS2: {} + {}", + req.message().a, + req.message().b + ); + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; println!("Sending response: {}", resp.sum); - zsrv.send_response(&resp, &key) - .expect("Failed to send response"); + req.reply_blocking(&resp).expect("Failed to send response"); } }); @@ -357,13 +368,12 @@ fn test_ros2_server_ros_z_client_multipart() { println!("Calling ROS2 multi-part server..."); - let req = AddTwoIntsRequest { a: 11, b: 13 }; - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(std::time::Duration::from_secs(5)) + .call_or_timeout( + &AddTwoIntsRequest { a: 11, b: 13 }, + std::time::Duration::from_secs(5), + ) + .await .expect("Failed to receive response"); println!("Received response from ROS2: {}", resp.sum); @@ -440,13 +450,12 @@ fn test_ros2_server_ros_z_client() { println!("Calling ROS2 server..."); - let req = AddTwoIntsRequest { a: 15, b: 9 }; - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(std::time::Duration::from_secs(5)) + .call_or_timeout( + &AddTwoIntsRequest { a: 15, b: 9 }, + std::time::Duration::from_secs(5), + ) + .await .expect("Failed to receive response"); println!("Received response from ROS2: {}", resp.sum); diff --git a/crates/ros-z-tests/tests/type_description_integration.rs b/crates/ros-z-tests/tests/type_description_integration.rs index a57d342e..33603d02 100644 --- a/crates/ros-z-tests/tests/type_description_integration.rs +++ b/crates/ros-z-tests/tests/type_description_integration.rs @@ -111,10 +111,13 @@ fn test_static_pub_dynamic_sub_with_type_discovery() { // Discover schema and create dynamic subscriber println!("Discovering schema from publisher..."); - let (subscriber, discovered_schema) = sub_node + let subscriber = sub_node .create_dyn_sub_auto("/test_topic", Duration::from_secs(15)) .await + .expect("Failed to create dynamic subscriber builder with auto-discovery") + .build() .expect("Failed to create dynamic subscriber with auto-discovery"); + let discovered_schema = subscriber.schema().expect("discovered schema"); println!("Discovered schema: {}", discovered_schema.type_name); assert_eq!(discovered_schema.type_name, "std_msgs/msg/String"); @@ -204,6 +207,7 @@ fn test_dynamic_pub_dynamic_sub_with_type_discovery() { // Create dynamic publisher (auto-registers schema) let publisher = pub_node .create_dyn_pub("/point_topic", schema.clone()) + .build() .expect("Failed to create dynamic publisher"); println!( @@ -252,10 +256,13 @@ fn test_dynamic_pub_dynamic_sub_with_type_discovery() { // Discover schema and create dynamic subscriber println!("Discovering schema from dynamic publisher..."); - let (subscriber, discovered_schema) = sub_node + let subscriber = sub_node .create_dyn_sub_auto("/point_topic", Duration::from_secs(15)) .await + .expect("Failed to create dynamic subscriber builder with auto-discovery") + .build() .expect("Failed to create dynamic subscriber with auto-discovery"); + let discovered_schema = subscriber.schema().expect("discovered schema"); println!("Discovered schema: {}", discovered_schema.type_name); assert_eq!(discovered_schema.type_name, "geometry_msgs/msg/Point"); @@ -341,6 +348,7 @@ fn test_dynamic_pub_static_sub() { // Create dynamic publisher let publisher = pub_node .create_dyn_pub("/dyn_to_static", schema.clone()) + .build() .expect("Failed to create dynamic publisher"); println!("Dynamic publisher created"); @@ -462,6 +470,7 @@ fn test_static_pub_dynamic_sub_known_schema() { let subscriber = sub_node .create_dyn_sub("/static_to_dyn", schema) + .build() .expect("Failed to create dynamic subscriber"); println!("Dynamic subscriber created with known schema"); @@ -555,6 +564,7 @@ fn test_multiple_publishers_schema_discovery() { let publisher1 = pub1_node .create_dyn_pub("/multi_pub_topic", schema.clone()) + .build() .expect("Failed to create publisher 1"); // Create second publisher with type description service @@ -568,6 +578,7 @@ fn test_multiple_publishers_schema_discovery() { let publisher2 = pub2_node .create_dyn_pub("/multi_pub_topic", schema.clone()) + .build() .expect("Failed to create publisher 2"); println!("Two publishers created on /multi_pub_topic"); @@ -584,10 +595,13 @@ fn test_multiple_publishers_schema_discovery() { .expect("Failed to create subscriber node"); println!("Discovering schema from publishers..."); - let (subscriber, discovered_schema) = sub_node + let subscriber = sub_node .create_dyn_sub_auto("/multi_pub_topic", Duration::from_secs(15)) .await + .expect("Failed to create subscriber builder with auto-discovery") + .build() .expect("Failed to create subscriber with auto-discovery"); + let discovered_schema = subscriber.schema().expect("discovered schema"); println!("Discovered schema: {}", discovered_schema.type_name); diff --git a/crates/ros-z-tests/tests/type_description_interop.rs b/crates/ros-z-tests/tests/type_description_interop.rs index 68814c89..a2b0c49b 100644 --- a/crates/ros-z-tests/tests/type_description_interop.rs +++ b/crates/ros-z-tests/tests/type_description_interop.rs @@ -143,14 +143,9 @@ fn test_get_type_description_from_ros2_talker() { include_type_sources: true, }; - println!("Calling GetTypeDescription for std_msgs/msg/String..."); - zcli.send_request(&req) - .await - .expect("Failed to send request"); - - println!("Waiting for response (timeout: 15s)..."); let resp = zcli - .take_response_timeout(Duration::from_secs(15)) + .call_or_timeout(&req, Duration::from_secs(15)) + .await .expect("Failed to receive response"); resp @@ -256,13 +251,9 @@ fn test_get_type_description_without_sources() { include_type_sources: false, }; - println!("Calling GetTypeDescription without hash..."); - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(Duration::from_secs(15)) + .call_or_timeout(&req, Duration::from_secs(15)) + .await .expect("Failed to receive response"); resp @@ -388,12 +379,9 @@ fn test_dynamic_subscriber_from_type_description() { include_type_sources: false, }; - zcli.send_request(&req) - .await - .expect("Failed to send request"); - let resp = zcli - .take_response_timeout(Duration::from_secs(15)) + .call_or_timeout(&req, Duration::from_secs(15)) + .await .expect("Failed to receive GetTypeDescription response"); assert!( diff --git a/crates/ros-z/Cargo.toml b/crates/ros-z/Cargo.toml index f4bdb281..a1e78dfd 100644 --- a/crates/ros-z/Cargo.toml +++ b/crates/ros-z/Cargo.toml @@ -34,6 +34,7 @@ prost = { workspace = true, optional = true } pyo3 = { workspace = true, optional = true } ros-z-cdr = { workspace = true } ros-z-codegen = { workspace = true, optional = true } +ros-z-derive = { path = "../ros-z-derive" } ros-z-protocol = { path = "../ros-z-protocol", default-features = false, features = [ "std", ] } @@ -47,6 +48,7 @@ paste = "1.0" ros-z-msgs = { path = "../ros-z-msgs" } ros-z-codegen = { path = "../ros-z-codegen" } serial_test = "3.0" +trybuild = "1.0" [build-dependencies] serde = { workspace = true, features = ["serde_derive"] } diff --git a/crates/ros-z/examples/demo_nodes/add_two_ints_client.rs b/crates/ros-z/examples/demo_nodes/add_two_ints_client.rs index 742a7318..c451856b 100644 --- a/crates/ros-z/examples/demo_nodes/add_two_ints_client.rs +++ b/crates/ros-z/examples/demo_nodes/add_two_ints_client.rs @@ -34,15 +34,13 @@ pub fn run_add_two_ints_client(ctx: ZContext, a: i64, b: i64, async_mode: bool) // Wait for the response let resp = if async_mode { - tokio::runtime::Runtime::new().unwrap().block_on(async { - client.send_request(&req).await?; - client.async_take_response().await - })? + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.call(&req).await })? } else { tokio::runtime::Runtime::new() .unwrap() - .block_on(async { client.send_request(&req).await })?; - client.take_response_timeout(Duration::from_secs(5))? + .block_on(async { client.call_or_timeout(&req, Duration::from_secs(5)).await })? }; println!("Received response: {}", resp.sum); diff --git a/crates/ros-z/examples/demo_nodes/add_two_ints_server.rs b/crates/ros-z/examples/demo_nodes/add_two_ints_server.rs index c99dcc4d..ac7c9043 100644 --- a/crates/ros-z/examples/demo_nodes/add_two_ints_server.rs +++ b/crates/ros-z/examples/demo_nodes/add_two_ints_server.rs @@ -25,11 +25,15 @@ pub fn run_add_two_ints_server(ctx: ZContext, max_requests: Option) -> Re // ANCHOR: request_loop loop { // Wait for a request - let (key, req) = service.take_request()?; - println!("Incoming request\na: {} b: {}", req.a, req.b); + let req = service.take_request()?; + println!( + "Incoming request\na: {} b: {}", + req.message().a, + req.message().b + ); // Compute the sum - let sum = req.a + req.b; + let sum = req.message().a + req.message().b; // Create the response let resp = AddTwoIntsResponse { sum }; @@ -37,7 +41,7 @@ pub fn run_add_two_ints_server(ctx: ZContext, max_requests: Option) -> Re println!("Sending response: {}", resp.sum); // Send the response - service.send_response(&resp, &key)?; + req.reply_blocking(&resp)?; request_count += 1; diff --git a/crates/ros-z/examples/protobuf_demo/src/lib.rs b/crates/ros-z/examples/protobuf_demo/src/lib.rs index caa6bea3..8e3c901b 100644 --- a/crates/ros-z/examples/protobuf_demo/src/lib.rs +++ b/crates/ros-z/examples/protobuf_demo/src/lib.rs @@ -123,8 +123,9 @@ pub fn run_service_client( // Send request and wait for response using the shared runtime let response = rt.block_on(async { - client.send_request(&request).await?; - client.take_response_timeout(Duration::from_secs(5)) + client + .call_or_timeout(&request, Duration::from_secs(5)) + .await })?; if response.success { @@ -172,47 +173,50 @@ pub fn run_service_server( loop { match server.take_request() { - Ok((key, request)) => { + Ok(request) => { consecutive_errors = 0; // Reset error counter on success count += 1; println!( "[Server] Request {}: {} {} {}", - count, request.a, request.operation, request.b + count, + request.message().a, + request.message().operation, + request.message().b ); - let response = match request.operation.as_str() { + let response = match request.message().operation.as_str() { "add" => types::CalculateResponse { success: true, - result: request.a + request.b, + result: request.message().a + request.message().b, message: format!( "{} + {} = {}", - request.a, - request.b, - request.a + request.b + request.message().a, + request.message().b, + request.message().a + request.message().b ), }, "subtract" => types::CalculateResponse { success: true, - result: request.a - request.b, + result: request.message().a - request.message().b, message: format!( "{} - {} = {}", - request.a, - request.b, - request.a - request.b + request.message().a, + request.message().b, + request.message().a - request.message().b ), }, "multiply" => types::CalculateResponse { success: true, - result: request.a * request.b, + result: request.message().a * request.message().b, message: format!( "{} * {} = {}", - request.a, - request.b, - request.a * request.b + request.message().a, + request.message().b, + request.message().a * request.message().b ), }, "divide" => { - if request.b == 0.0 { + if request.message().b == 0.0 { types::CalculateResponse { success: false, result: 0.0, @@ -221,12 +225,12 @@ pub fn run_service_server( } else { types::CalculateResponse { success: true, - result: request.a / request.b, + result: request.message().a / request.message().b, message: format!( "{} / {} = {}", - request.a, - request.b, - request.a / request.b + request.message().a, + request.message().b, + request.message().a / request.message().b ), } } @@ -234,11 +238,14 @@ pub fn run_service_server( _ => types::CalculateResponse { success: false, result: 0.0, - message: format!("Error: Unknown operation '{}'", request.operation), + message: format!( + "Error: Unknown operation '{}'", + request.message().operation + ), }, }; - if let Err(e) = server.send_response(&response, &key) { + if let Err(e) = request.reply_blocking(&response) { eprintln!("[Server] Failed to send response: {}", e); consecutive_errors += 1; } diff --git a/crates/ros-z/examples/z_custom_message.rs b/crates/ros-z/examples/z_custom_message.rs index cbb99b4e..b00cd243 100644 --- a/crates/ros-z/examples/z_custom_message.rs +++ b/crates/ros-z/examples/z_custom_message.rs @@ -9,7 +9,8 @@ use ros_z::{ use serde::{Deserialize, Serialize}; // Custom message for pub/sub example -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/RobotStatus")] pub struct RobotStatus { pub robot_id: String, pub battery_percentage: f64, @@ -18,66 +19,32 @@ pub struct RobotStatus { pub is_moving: bool, } -impl MessageTypeInfo for RobotStatus { - fn type_name() -> &'static str { - "custom_msgs::msg::dds_::RobotStatus_" - } - - fn type_hash() -> TypeHash { - TypeHash::zero() - } -} - -impl ros_z::WithTypeInfo for RobotStatus {} - impl ros_z::msg::ZMessage for RobotStatus { type Serdes = ros_z::msg::SerdeCdrSerdes; } // Custom service request -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/srv/NavigateTo_Request")] pub struct NavigateToRequest { pub target_x: f64, pub target_y: f64, pub max_speed: f64, } -impl MessageTypeInfo for NavigateToRequest { - fn type_name() -> &'static str { - "custom_msgs::srv::dds_::NavigateTo_Request_" - } - - fn type_hash() -> TypeHash { - TypeHash::zero() - } -} - -impl ros_z::WithTypeInfo for NavigateToRequest {} - impl ros_z::msg::ZMessage for NavigateToRequest { type Serdes = ros_z::msg::SerdeCdrSerdes; } // Custom service response -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/srv/NavigateTo_Response")] pub struct NavigateToResponse { pub success: bool, pub estimated_duration: f64, pub message: String, } -impl MessageTypeInfo for NavigateToResponse { - fn type_name() -> &'static str { - "custom_msgs::srv::dds_::NavigateTo_Response_" - } - - fn type_hash() -> TypeHash { - TypeHash::zero() - } -} - -impl ros_z::WithTypeInfo for NavigateToResponse {} - impl ros_z::msg::ZMessage for NavigateToResponse { type Serdes = ros_z::msg::SerdeCdrSerdes; } @@ -153,9 +120,14 @@ async fn run_status_publisher(robot_id: String) -> Result<()> { println!("Starting robot status publisher for robot: {robot_id}"); let ctx = ZContextBuilder::default().build()?; - let node = ctx.create_node("robot_status_publisher").build()?; + let node = ctx + .create_node("robot_status_publisher") + .with_type_description_service() + .build()?; let zpub = node.create_pub::("/robot_status").build()?; + println!("Type description service enabled for automatic schema registration"); + let mut position_x = 0.0; let mut position_y = 0.0; let mut battery = 100.0; @@ -233,19 +205,23 @@ pub fn run_navigation_server(ctx: ros_z::context::ZContext) -> Result<()> { println!("Navigation server ready, waiting for requests..."); loop { - if let Ok((request_id, request)) = zsrv.take_request() { + if let Ok(request) = zsrv.take_request() { println!( "Received navigation request: target=({:.1}, {:.1}), max_speed={:.1}", - request.target_x, request.target_y, request.max_speed + request.message().target_x, + request.message().target_y, + request.message().max_speed ); // Simulate path planning std::thread::sleep(Duration::from_millis(500)); - let distance = (request.target_x.powi(2) + request.target_y.powi(2)).sqrt(); - let duration = distance / request.max_speed; + let distance = + (request.message().target_x.powi(2) + request.message().target_y.powi(2)).sqrt(); + let duration = distance / request.message().max_speed; - let response = if request.max_speed > 0.0 && request.max_speed < 5.0 { + let response = if request.message().max_speed > 0.0 && request.message().max_speed < 5.0 + { NavigateToResponse { success: true, estimated_duration: duration, @@ -263,7 +239,7 @@ pub fn run_navigation_server(ctx: ros_z::context::ZContext) -> Result<()> { }; println!("Sending response: {:?}", response); - zsrv.send_response(&response, &request_id)?; + request.reply_blocking(&response)?; } std::thread::sleep(Duration::from_millis(100)); @@ -292,22 +268,14 @@ pub fn run_navigation_client( request.target_x, request.target_y, request.max_speed ); - tokio::runtime::Runtime::new() + let response = tokio::runtime::Runtime::new() .unwrap() - .block_on(async { zcli.send_request(&request).await })?; - - println!("Waiting for response..."); + .block_on(async { zcli.call(&request).await })?; - loop { - if let Ok(response) = zcli.take_response() { - println!("Received response:"); - println!("Success: {}", response.success); - println!("Duration: {:.2}s", response.estimated_duration); - println!("Message: {}", response.message); - break; - } - std::thread::sleep(Duration::from_millis(100)); - } + println!("Received response:"); + println!("Success: {}", response.success); + println!("Duration: {:.2}s", response.estimated_duration); + println!("Message: {}", response.message); Ok(()) } diff --git a/crates/ros-z/examples/z_pubsub_ros2dds.rs b/crates/ros-z/examples/z_pubsub_ros2dds.rs index 2429c9b7..482a1008 100644 --- a/crates/ros-z/examples/z_pubsub_ros2dds.rs +++ b/crates/ros-z/examples/z_pubsub_ros2dds.rs @@ -37,7 +37,7 @@ use ros_z::{ entity::{NodeEntity, TypeInfo}, }; use ros_z_cdr::{CdrSerializer, LittleEndian}; -use ros_z_protocol::entity::{EndpointEntity, EntityKind}; +use ros_z_protocol::entity::{EndpointEntity, EndpointKind}; use serde::{Deserialize, Serialize}; use zenoh::{Wait, key_expr::KeyExpr}; @@ -120,13 +120,13 @@ async fn main() -> ros_z::Result<()> { }; let entity_kind = match args.role.as_str() { - "talker" => EntityKind::Publisher, - _ => EntityKind::Subscription, + "talker" => EndpointKind::Publisher, + _ => EndpointKind::Subscription, }; let entity = EndpointEntity { id: 1, - node, + node: Some(node), kind: entity_kind, topic: topic.clone(), type_info: Some(type_info), diff --git a/crates/ros-z/examples/z_srvcli.rs b/crates/ros-z/examples/z_srvcli.rs index 2dda3b2a..95eb6938 100644 --- a/crates/ros-z/examples/z_srvcli.rs +++ b/crates/ros-z/examples/z_srvcli.rs @@ -66,13 +66,19 @@ pub fn run_server(ctx: ZContext) -> Result<()> { println!("AddTwoInts service server started, waiting for requests..."); loop { - let (key, req) = zsrv.take_request()?; - println!("Received request: {} + {}", req.a, req.b); + let req = zsrv.take_request()?; + println!( + "Received request: {} + {}", + req.message().a, + req.message().b + ); - let resp = AddTwoIntsResponse { sum: req.a + req.b }; + let resp = AddTwoIntsResponse { + sum: req.message().a + req.message().b, + }; println!("Sending response: {}", resp.sum); - zsrv.send_response(&resp, &key)?; + req.reply_blocking(&resp)?; } } @@ -85,8 +91,9 @@ pub async fn run_client(ctx: ZContext, a: i64, b: i64) -> Result<()> { let req = AddTwoIntsRequest { a, b }; println!("Sending request: {} + {}", req.a, req.b); - zcli.send_request(&req).await?; - let resp = zcli.take_response_timeout(std::time::Duration::from_secs(5))?; + let resp = zcli + .call_or_timeout(&req, std::time::Duration::from_secs(5)) + .await?; println!("Received response: {}", resp.sum); diff --git a/crates/ros-z/examples/z_srvcli_ros2dds.rs b/crates/ros-z/examples/z_srvcli_ros2dds.rs index 0b557bd3..7cdb4c9a 100644 --- a/crates/ros-z/examples/z_srvcli_ros2dds.rs +++ b/crates/ros-z/examples/z_srvcli_ros2dds.rs @@ -120,13 +120,13 @@ async fn run_server(ctx: ros_z::context::ZContext, args: &Args) -> ros_z::Result loop { // Wait for a request - let (key, req) = service.take_request()?; + let req = service.take_request()?; println!("Incoming request:"); - println!(" a: {}", req.a); - println!(" b: {}", req.b); + println!(" a: {}", req.message().a); + println!(" b: {}", req.message().b); // Compute the sum - let sum = req.a + req.b; + let sum = req.message().a + req.message().b; // Create the response let resp = AddTwoIntsResponse { sum }; @@ -134,7 +134,7 @@ async fn run_server(ctx: ros_z::context::ZContext, args: &Args) -> ros_z::Result println!("Sending response: {}\n", resp.sum); // Send the response - service.send_response(&resp, &key)?; + req.reply_blocking(&resp)?; request_count += 1; @@ -176,11 +176,7 @@ async fn run_client(ctx: ros_z::context::ZContext, args: &Args) -> ros_z::Result println!("Sending request #{}: a={}, b={}", i + 1, req.a, req.b); - // Send request - client.send_request(&req).await?; - - // Wait for response with timeout - match client.take_response_timeout(Duration::from_secs(5)) { + match client.call_or_timeout(&req, Duration::from_secs(5)).await { Ok(resp) => { println!("Received response: {} + {} = {}\n", req.a, req.b, resp.sum); } diff --git a/crates/ros-z/src/action/client.rs b/crates/ros-z/src/action/client.rs index 8d282e7c..872e722f 100644 --- a/crates/ros-z/src/action/client.rs +++ b/crates/ros-z/src/action/client.rs @@ -30,7 +30,7 @@ pub mod goal_state { /// /// # Examples /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # use ros_z::qos::QosProfile; /// # let node = todo!(); @@ -286,7 +286,7 @@ impl<'a, A: ZAction> Builder for ZActionClientBuilder<'a, A> { /// /// # Simple Goal Send/Receive /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # #[tokio::main] /// # async fn main() -> Result<()> { @@ -306,7 +306,7 @@ impl<'a, A: ZAction> Builder for ZActionClientBuilder<'a, A> { /// /// # Feedback Streaming /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # #[tokio::main] /// # async fn main() -> Result<()> { @@ -328,7 +328,7 @@ impl<'a, A: ZAction> Builder for ZActionClientBuilder<'a, A> { /// /// # Cancellation /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # #[tokio::main] /// # async fn main() -> Result<()> { @@ -403,7 +403,7 @@ impl ZActionClient { /// /// # Examples /// - /// ```no_run + /// ```ignore /// # use ros_z::action::*; /// # #[tokio::main] /// # async fn main() -> Result<()> { @@ -437,23 +437,13 @@ impl ZActionClient { // 3. Send goal request via service client let request = SendGoalRequest { goal_id, goal }; tracing::debug!("Sending goal request for goal_id: {:?}", goal_id); - if let Err(e) = self.goal_client.send_request(&request).await { - // Cleanup on send failure - self.goal_board.active_goals.remove(&goal_id); - return Err(e); - } - tracing::debug!("Goal request sent, waiting for response..."); - - // 4. Wait for response - let sample = self.goal_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - tracing::debug!( - "Received goal response payload: {} bytes: {:?}", - payload.len(), - payload - ); - let response = ::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string()))?; + let response = match self.goal_client.call(&request).await { + Ok(response) => response, + Err(error) => { + self.goal_board.active_goals.remove(&goal_id); + return Err(error); + } + }; // 5. Check if accepted if !response.accepted { @@ -476,12 +466,7 @@ impl ZActionClient { let goal_info = GoalInfo::new(goal_id); let request = CancelGoalServiceRequest { goal_info }; - self.cancel_client.send_request(&request).await?; - let sample = self.cancel_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - let response = ::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - Ok(response) + self.cancel_client.call(&request).await } pub async fn cancel_all_goals(&self) -> Result { @@ -493,12 +478,7 @@ impl ZActionClient { }; let request = CancelGoalServiceRequest { goal_info }; - self.cancel_client.send_request(&request).await?; - let sample = self.cancel_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - let response = ::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - Ok(response) + self.cancel_client.call(&request).await } pub fn feedback_stream(&self, goal_id: GoalId) -> Option> { @@ -523,53 +503,57 @@ impl ZActionClient { pub async fn get_result(&self, goal_id: GoalId) -> Result { let request = GetResultRequest { goal_id }; - self.result_client.send_request(&request).await?; - let sample = self.result_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - let response = as ZMessage>::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string()))?; + let response: GetResultResponse = self.result_client.call(&request).await?; Ok(response.result) } // FIXME: Check the necessity // Low-level methods for testing - pub async fn send_goal_request_low(&self, request: &SendGoalRequest) -> Result<()> { - self.goal_client.send_request(request).await + pub async fn send_goal_request_low( + &self, + request: &SendGoalRequest, + ) -> Result { + self.goal_client.call(request).await } // FIXME: Check the necessity pub async fn recv_goal_response_low(&self) -> Result { - let sample = self.goal_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - ::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string())) + Err(zenoh::Error::from( + "recv_goal_response_low is no longer available; use send_goal_request_low".to_string(), + )) } // FIXME: Check the necessity - pub async fn send_cancel_request_low(&self, request: &CancelGoalServiceRequest) -> Result<()> { - self.cancel_client.send_request(request).await + pub async fn send_cancel_request_low( + &self, + request: &CancelGoalServiceRequest, + ) -> Result { + self.cancel_client.call(request).await } // FIXME: Check the necessity pub async fn recv_cancel_response_low(&self) -> Result { - let sample = self.cancel_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - ::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string())) + Err(zenoh::Error::from( + "recv_cancel_response_low is no longer available; use send_cancel_request_low" + .to_string(), + )) } // FIXME: Check the necessity - pub async fn send_result_request_low(&self, request: &GetResultRequest) -> Result<()> { - self.result_client.send_request(request).await + pub async fn send_result_request_low( + &self, + request: &GetResultRequest, + ) -> Result> { + self.result_client.call(request).await } // FIXME: Check the necessity pub async fn recv_result_response_low(&self) -> Result> { - let sample = self.result_client.rx.recv_async().await?; - let payload = sample.payload().to_bytes(); - as ZMessage>::deserialize(&payload) - .map_err(|e| zenoh::Error::from(e.to_string())) + Err(zenoh::Error::from( + "recv_result_response_low is no longer available; use send_result_request_low" + .to_string(), + )) } } @@ -597,7 +581,7 @@ struct GoalChannels { /// /// # Examples /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # #[tokio::main] /// # async fn main() -> Result<()> { diff --git a/crates/ros-z/src/action/server.rs b/crates/ros-z/src/action/server.rs index 764f7296..9ca31153 100644 --- a/crates/ros-z/src/action/server.rs +++ b/crates/ros-z/src/action/server.rs @@ -123,7 +123,7 @@ impl Drop for ShutdownGuard { /// /// # Examples /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # use std::time::Duration; /// # let node = todo!(); @@ -676,7 +676,7 @@ impl ZActionServer { /// /// # Examples /// - /// ```no_run + /// ```ignore /// # use ros_z::action::*; /// # let server = todo!(); /// let server = server.with_handler(|executing| async move { @@ -717,7 +717,7 @@ impl ZActionServer { /// /// # Examples /// - /// ```no_run + /// ```ignore /// # use ros_z::action::*; /// # let server: ros_z::action::server::ZActionServer = todo!(); /// // Check and expire any goals that have passed their expiration time @@ -771,7 +771,7 @@ impl ZActionServer { /// /// # Examples /// - /// ```no_run + /// ```ignore /// # use ros_z::action::*; /// # use std::time::Duration; /// # let mut server: ros_z::action::server::ZActionServer = todo!(); @@ -827,7 +827,7 @@ pub type ExecutingGoal = GoalHandle; /// /// # Examples /// -/// ```no_run +/// ```ignore /// # use ros_z::action::*; /// # let server: std::sync::Arc> = todo!(); /// # async { diff --git a/crates/ros-z/src/config.rs b/crates/ros-z/src/config.rs index afe938c8..bb9dccb8 100644 --- a/crates/ros-z/src/config.rs +++ b/crates/ros-z/src/config.rs @@ -10,7 +10,10 @@ //! - Session-specific: 6 settings unique to peer mode //! //! # Example -//! ```rust +//! ```no_run +//! # use ros_z::config::{RouterConfigBuilder, router_config, session_config}; +//! # #[tokio::main] +//! # async fn main() -> zenoh::Result<()> { //! // Create router config //! let router_cfg = router_config()?; //! let router = zenoh::open(router_cfg).await?; @@ -23,6 +26,8 @@ //! let custom_router = RouterConfigBuilder::new() //! .with_listen_port(7448) //! .build_config()?; +//! # Ok(()) +//! # } //! ``` use serde::{Deserialize, Serialize}; @@ -286,9 +291,14 @@ fn build_config(overrides: &[ConfigOverride]) -> zenoh::Result { /// Create a router configuration matching rmw_zenoh_cpp defaults /// /// # Example -/// ```rust +/// ```no_run +/// # use ros_z::config::router_config; +/// # #[tokio::main] +/// # async fn main() -> zenoh::Result<()> { /// let config = router_config()?; /// let router = zenoh::open(config).await?; +/// # Ok(()) +/// # } /// ``` pub fn router_config() -> zenoh::Result { build_config(&router_overrides()) @@ -297,9 +307,14 @@ pub fn router_config() -> zenoh::Result { /// Create a session configuration matching rmw_zenoh_cpp defaults /// /// # Example -/// ```rust +/// ```no_run +/// # use ros_z::config::session_config; +/// # #[tokio::main] +/// # async fn main() -> zenoh::Result<()> { /// let config = session_config()?; /// let session = zenoh::open(config).await?; +/// # Ok(()) +/// # } /// ``` pub fn session_config() -> zenoh::Result { build_config(&session_overrides()) @@ -313,8 +328,10 @@ pub fn session_config() -> zenoh::Result { /// /// # Example /// ```rust +/// # use ros_z::config::{generate_json5, router_overrides}; /// let json5 = generate_json5(&router_overrides(), "Router Config"); /// std::fs::write("router_config.json5", json5)?; +/// # Ok::<(), std::io::Error>(()) /// ``` pub fn generate_json5(overrides: &[ConfigOverride], name: &str) -> String { use serde_json::Value as JsonValue; @@ -470,9 +487,11 @@ impl RouterConfigBuilder { /// /// # Example /// ```rust + /// # use ros_z::config::RouterConfigBuilder; /// let config = RouterConfigBuilder::new() /// .with_listen_port(7448) /// .build_config()?; + /// # Ok::<(), zenoh::Error>(()) /// ``` pub fn with_listen_port(mut self, port: u16) -> Self { if let Some(listen) = self @@ -489,9 +508,11 @@ impl RouterConfigBuilder { /// /// # Example /// ```rust + /// # use ros_z::config::RouterConfigBuilder; /// let config = RouterConfigBuilder::new() /// .with_listen_endpoint("tcp/0.0.0.0:7447") /// .build_config()?; + /// # Ok::<(), zenoh::Error>(()) /// ``` pub fn with_listen_endpoint(mut self, endpoint: &str) -> Self { if let Some(listen) = self @@ -510,6 +531,7 @@ impl RouterConfigBuilder { /// /// # Example /// ```rust + /// # use ros_z::config::RouterConfigBuilder; /// let config = RouterConfigBuilder::new() /// .with_override( /// "transport/unicast/max_sessions", @@ -517,6 +539,7 @@ impl RouterConfigBuilder { /// "Custom increased sessions" /// ) /// .build_config()?; + /// # Ok::<(), zenoh::Error>(()) /// ``` pub fn with_override(mut self, key: &'static str, value: Value, reason: &'static str) -> Self { if let Some(existing) = self.overrides.iter_mut().find(|o| o.key == key) { @@ -560,9 +583,11 @@ impl SessionConfigBuilder { /// /// # Example /// ```rust + /// # use ros_z::config::SessionConfigBuilder; /// let config = SessionConfigBuilder::new() /// .with_router_endpoint("tcp/192.168.1.100:7447") /// .build_config()?; + /// # Ok::<(), zenoh::Error>(()) /// ``` pub fn with_router_endpoint(mut self, endpoint: &str) -> Self { if let Some(connect) = self @@ -581,6 +606,7 @@ impl SessionConfigBuilder { /// /// # Example /// ```rust + /// # use ros_z::config::SessionConfigBuilder; /// let config = SessionConfigBuilder::new() /// .with_override( /// "queries_default_timeout", @@ -588,6 +614,7 @@ impl SessionConfigBuilder { /// "Increased timeout for slow network" /// ) /// .build_config()?; + /// # Ok::<(), zenoh::Error>(()) /// ``` pub fn with_override(mut self, key: &'static str, value: Value, reason: &'static str) -> Self { if let Some(existing) = self.overrides.iter_mut().find(|o| o.key == key) { diff --git a/crates/ros-z/src/context.rs b/crates/ros-z/src/context.rs index 0e177dba..d15d6285 100644 --- a/crates/ros-z/src/context.rs +++ b/crates/ros-z/src/context.rs @@ -95,10 +95,10 @@ impl ZContextBuilder { self } - /// Set the key expression format for ROS 2 entity mapping. + /// Set the key expression format for ROS 2 entity mapping and graph discovery. /// /// # Example - /// ``` + /// ```ignore /// use ros_z::context::ZContextBuilder; /// use ros_z::Builder; /// use ros_z_protocol::KeyExprFormat; @@ -231,10 +231,12 @@ impl ZContextBuilder { /// use ros_z::context::ZContextBuilder; /// use ros_z::Builder; /// + /// # fn main() -> zenoh::Result<()> { /// let ctx = ZContextBuilder::default() - /// .with_router_endpoint("tcp/192.168.1.1:7447") - /// .build() - /// .expect("Failed to build context"); + /// .with_router_endpoint("tcp/192.168.1.1:7447")? + /// .build()?; + /// # Ok(()) + /// # } /// ``` pub fn with_router_endpoint>(mut self, endpoint: S) -> Result { let session_config = crate::config::SessionConfigBuilder::new() @@ -531,7 +533,7 @@ impl Builder for ZContextBuilder { } let domain_id = builder.domain_id; - let graph = Arc::new(Graph::new(&session, domain_id)?); + let graph = Arc::new(Graph::new(&session, domain_id, builder.keyexpr_format)?); Ok(ZContext { session: Arc::new(session), @@ -617,6 +619,7 @@ impl ZContext { keyexpr_format: self.keyexpr_format, clock: self.clock.clone(), enable_type_desc_service: false, + enable_extended_type_desc_service: false, enable_parameters: true, parameter_overrides: std::collections::HashMap::new(), } diff --git a/crates/ros-z/src/dynamic/discovery.rs b/crates/ros-z/src/dynamic/discovery.rs new file mode 100644 index 00000000..78877448 --- /dev/null +++ b/crates/ros-z/src/dynamic/discovery.rs @@ -0,0 +1,191 @@ +use std::time::Duration; +use std::{collections::BTreeSet, sync::Arc}; + +use crate::{ + dynamic::{DynamicError, MessageSchema}, + entity::{Entity, EntityKind}, + graph::Graph, + node::ZNode, + topic_name::qualify_topic_name, +}; + +use super::type_info::{ros_type_name_from_dds, schema_type_info_with_hash}; + +#[derive(Debug, Clone)] +pub struct DiscoveredTopicSchema { + pub qualified_topic: String, + pub schema: Arc, + pub type_hash: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TopicSchemaCandidate { + pub node_name: String, + pub namespace: String, + pub type_name: String, + pub type_hash: String, +} + +pub(crate) fn collect_topic_schema_candidates_from_publishers( + publishers: &[Arc], + qualified_topic: &str, +) -> Result, DynamicError> { + let mut saw_missing_node_identity = false; + let mut saw_missing_type_info = false; + let mut candidates = BTreeSet::new(); + + for publisher in publishers { + let Entity::Endpoint(endpoint) = &**publisher else { + continue; + }; + let Some(node) = endpoint.node.as_ref() else { + saw_missing_node_identity = true; + continue; + }; + let Some(type_info) = endpoint.type_info.as_ref() else { + saw_missing_type_info = true; + continue; + }; + + candidates.insert(TopicSchemaCandidate { + node_name: node.name.clone(), + namespace: node.namespace.clone(), + type_name: ros_type_name_from_dds(&type_info.name), + type_hash: type_info.hash.to_rihs_string(), + }); + } + + if !candidates.is_empty() { + return Ok(candidates.into_iter().collect()); + } + + if saw_missing_node_identity { + return Err(DynamicError::MissingNodeIdentity { + topic: qualified_topic.to_string(), + }); + } + + if saw_missing_type_info { + return Err(DynamicError::SchemaNotFound(format!( + "No publishers with type information found for topic: {}", + qualified_topic + ))); + } + + Err(DynamicError::SchemaNotFound(format!( + "No usable publishers found for topic: {}", + qualified_topic + ))) +} + +pub(crate) fn collect_topic_schema_candidates( + graph: &Graph, + qualified_topic: &str, +) -> Result, DynamicError> { + let publishers = graph.get_entities_by_topic(EntityKind::Publisher, qualified_topic); + if publishers.is_empty() { + return Err(DynamicError::SchemaNotFound(format!( + "No publishers found for topic: {}", + qualified_topic + ))); + } + + collect_topic_schema_candidates_from_publishers(&publishers, qualified_topic) +} + +pub(crate) struct SchemaDiscovery<'a> { + node: &'a ZNode, + timeout: Duration, +} + +impl<'a> SchemaDiscovery<'a> { + pub(crate) fn new(node: &'a ZNode, timeout: Duration) -> Self { + Self { node, timeout } + } + + pub(crate) async fn discover( + &self, + topic: &str, + ) -> Result { + let qualified_topic = qualify_topic_name(topic, self.node.namespace(), self.node.name()) + .map_err(|error| { + DynamicError::SchemaNotFound(format!("Failed to qualify topic: {error}")) + })?; + let candidates = + collect_topic_schema_candidates(self.node.graph().as_ref(), &qualified_topic)?; + + let (schema, type_hash) = match self.try_standard(&candidates[..]).await { + Ok(result) => result, + Err(standard_error) => match self.try_extended(&candidates[..]).await { + Ok(result) => result, + Err(extended_error) => { + return Err(DynamicError::SchemaNotFound(format!( + "Schema discovery failed. Standard: {}. Extended: {}", + standard_error, extended_error + ))); + } + }, + }; + + Ok(DiscoveredTopicSchema { + qualified_topic, + schema, + type_hash, + }) + } + + async fn try_standard( + &self, + candidates: &[TopicSchemaCandidate], + ) -> Result<(Arc, String), DynamicError> { + let mut last_error = None; + + for candidate in candidates { + match super::type_description_query::query_type_description( + self.node, + candidate, + self.timeout, + false, + ) + .await + { + Ok(result) => return Ok(result), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + DynamicError::SchemaNotFound("No standard schema source succeeded".to_string()) + })) + } + + async fn try_extended( + &self, + candidates: &[TopicSchemaCandidate], + ) -> Result<(Arc, String), DynamicError> { + let mut last_error = None; + + for candidate in candidates { + match crate::extended_type_description_query::query_extended_type_description( + self.node, + candidate, + self.timeout, + ) + .await + { + Ok(result) => return Ok(result), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + DynamicError::SchemaNotFound("No extended schema source succeeded".to_string()) + })) + } +} + +pub(crate) fn discovered_schema_type_info( + discovered: &DiscoveredTopicSchema, +) -> crate::entity::TypeInfo { + schema_type_info_with_hash(&discovered.schema, &discovered.type_hash) +} diff --git a/crates/ros-z/src/dynamic/error.rs b/crates/ros-z/src/dynamic/error.rs index 80175412..4cce1415 100644 --- a/crates/ros-z/src/dynamic/error.rs +++ b/crates/ros-z/src/dynamic/error.rs @@ -41,6 +41,9 @@ pub enum DynamicError { /// Type description service call timed out — no response from the remote node. ServiceTimeout { node: String, service: String }, + /// Automatic topic-based schema discovery requires publisher node identity. + MissingNodeIdentity { topic: String }, + /// Invalid default value for field type InvalidDefaultValue { field: String, reason: String }, @@ -107,6 +110,13 @@ impl fmt::Display for DynamicError { node, service ) } + DynamicError::MissingNodeIdentity { topic } => { + write!( + f, + "automatic schema discovery for topic '{}' requires publisher node identity, which is unavailable from this backend/discovery format", + topic + ) + } DynamicError::InvalidDefaultValue { field, reason } => { write!(f, "Invalid default value for field '{}': {}", field, reason) } diff --git a/crates/ros-z/src/dynamic/mod.rs b/crates/ros-z/src/dynamic/mod.rs index 2e028604..c6fdf845 100644 --- a/crates/ros-z/src/dynamic/mod.rs +++ b/crates/ros-z/src/dynamic/mod.rs @@ -54,6 +54,7 @@ //! assert_eq!(decoded.get::("x")?, 1.0); //! ``` +pub(crate) mod discovery; pub mod error; pub mod message; pub mod registry; @@ -61,29 +62,39 @@ pub mod schema; pub mod serdes; pub mod serialization; pub mod type_description; -pub mod type_description_client; +pub mod type_description_query; pub mod type_description_service; +pub(crate) mod type_info; pub mod value; #[cfg(test)] mod tests; // Re-export main types +pub use discovery::DiscoveredTopicSchema; pub use error::DynamicError; pub use message::{DynamicMessage, DynamicMessageBuilder}; pub use registry::{SchemaRegistry, get_schema, has_schema, register_schema}; -pub use schema::{FieldSchema, FieldType, MessageSchema, MessageSchemaBuilder}; +pub use schema::{ + EnumPayloadSchema, EnumSchema, EnumVariantSchema, FieldSchema, FieldType, MessageSchema, + MessageSchemaBuilder, +}; pub use serdes::DynamicSerdeCdrSerdes; pub use serialization::SerializationFormat; pub use type_description::{MessageSchemaTypeDescription, type_description_msg_to_schema}; -pub use type_description_client::TypeDescriptionClient; +pub use type_description_query::schema_from_type_description_response; pub use type_description_service::{ GetTypeDescription, GetTypeDescriptionRequest, GetTypeDescriptionResponse, RegisteredSchema, TypeDescriptionService, TypeSource, WireField, WireFieldType, WireIndividualTypeDescription, WireKeyValue, WireTypeDescription, WireTypeSource, schema_to_wire_type_description, wire_to_schema_type_description, }; -pub use value::{DynamicValue, FromDynamic, IntoDynamic}; +pub use value::{ + DynamicNamedValue, DynamicValue, EnumPayloadValue, EnumValue, FromDynamic, IntoDynamic, +}; + +pub(crate) use discovery::{SchemaDiscovery, discovered_schema_type_info}; +pub(crate) use type_info::schema_type_info; use zenoh::sample::Sample; diff --git a/crates/ros-z/src/dynamic/schema.rs b/crates/ros-z/src/dynamic/schema.rs index ecef2af3..19ccab57 100644 --- a/crates/ros-z/src/dynamic/schema.rs +++ b/crates/ros-z/src/dynamic/schema.rs @@ -32,6 +32,10 @@ pub enum FieldType { // Compound /// Nested message type Message(Arc), + /// Optional value using serde/ros-z-cdr's `u32` presence tag encoding. + Optional(Box), + /// Tagged enum using serde/ros-z-cdr's `u32` variant index encoding. + Enum(Arc), // Collections /// Fixed-size array: `T[N]` @@ -52,6 +56,7 @@ impl FieldType { FieldType::Int64 | FieldType::Uint64 | FieldType::Float64 => Some(8), FieldType::Array(inner, len) => inner.fixed_size().map(|s| s * len), FieldType::Message(schema) => schema.fixed_cdr_size(), + FieldType::Optional(_) | FieldType::Enum(_) => None, // String, Sequence types are variable _ => None, } @@ -67,6 +72,7 @@ impl FieldType { FieldType::String | FieldType::BoundedString(_) => 4, // length prefix FieldType::Array(inner, _) => inner.alignment(), FieldType::Sequence(_) | FieldType::BoundedSequence(_, _) => 4, // length prefix + FieldType::Optional(_) | FieldType::Enum(_) => 4, FieldType::Message(schema) => schema.alignment(), } } @@ -113,14 +119,27 @@ impl FieldType { match self { FieldType::Array(inner, _) | FieldType::Sequence(inner) - | FieldType::BoundedSequence(inner, _) => Some(inner), + | FieldType::BoundedSequence(inner, _) + | FieldType::Optional(inner) => Some(inner), _ => None, } } + + /// Check whether this type uses ros-z extended schema features. + pub fn is_extended(&self) -> bool { + match self { + FieldType::Optional(_) | FieldType::Enum(_) => true, + FieldType::Message(schema) => schema.uses_extended_types(), + FieldType::Array(inner, _) + | FieldType::Sequence(inner) + | FieldType::BoundedSequence(inner, _) => inner.is_extended(), + _ => false, + } + } } /// Schema for a single message field. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct FieldSchema { /// Field name pub name: String, @@ -147,6 +166,79 @@ impl FieldSchema { } } +/// Schema for a serde enum. +#[derive(Clone, Debug, PartialEq)] +pub struct EnumSchema { + /// Canonical enum type name, usually matching the owning Rust type. + pub type_name: String, + /// Ordered list of enum variants in serde discriminant order. + pub variants: Vec, +} + +impl EnumSchema { + /// Create a new enum schema. + pub fn new(type_name: impl Into, variants: Vec) -> Self { + Self { + type_name: type_name.into(), + variants, + } + } + + /// Returns true if any payload uses ros-z extended schema features. + pub fn uses_extended_types(&self) -> bool { + self.variants + .iter() + .any(EnumVariantSchema::uses_extended_types) + } +} + +/// Schema for a single enum variant. +#[derive(Clone, Debug, PartialEq)] +pub struct EnumVariantSchema { + /// Variant name as exposed by serde. + pub name: String, + /// Payload shape for the variant. + pub payload: EnumPayloadSchema, +} + +impl EnumVariantSchema { + /// Create a new enum variant schema. + pub fn new(name: impl Into, payload: EnumPayloadSchema) -> Self { + Self { + name: name.into(), + payload, + } + } + + /// Returns true if the payload uses ros-z extended schema features. + pub fn uses_extended_types(&self) -> bool { + self.payload.uses_extended_types() + } +} + +/// Payload schema for a serde enum variant. +#[derive(Clone, Debug, PartialEq)] +pub enum EnumPayloadSchema { + Unit, + Newtype(Box), + Tuple(Vec), + Struct(Vec), +} + +impl EnumPayloadSchema { + /// Returns true if the payload uses ros-z extended schema features. + pub fn uses_extended_types(&self) -> bool { + match self { + EnumPayloadSchema::Unit => false, + EnumPayloadSchema::Newtype(field_type) => field_type.is_extended(), + EnumPayloadSchema::Tuple(field_types) => field_types.iter().any(FieldType::is_extended), + EnumPayloadSchema::Struct(fields) => { + fields.iter().any(|field| field.field_type.is_extended()) + } + } + } +} + /// Complete schema for a ROS 2 message type. #[derive(Clone, Debug)] pub struct MessageSchema { @@ -230,6 +322,13 @@ impl MessageSchema { pub fn field_names(&self) -> impl Iterator { self.fields.iter().map(|f| f.name.as_str()) } + + /// Returns true if any field relies on ros-z extended schema features. + pub fn uses_extended_types(&self) -> bool { + self.fields + .iter() + .any(|field| field.field_type.is_extended()) + } } impl PartialEq for MessageSchema { diff --git a/crates/ros-z/src/dynamic/serialization/cdr.rs b/crates/ros-z/src/dynamic/serialization/cdr.rs index 50529db4..e8005955 100644 --- a/crates/ros-z/src/dynamic/serialization/cdr.rs +++ b/crates/ros-z/src/dynamic/serialization/cdr.rs @@ -10,8 +10,8 @@ use zenoh_buffers::ZBuf; use crate::dynamic::error::DynamicError; use crate::dynamic::message::DynamicMessage; -use crate::dynamic::schema::{FieldType, MessageSchema}; -use crate::dynamic::value::DynamicValue; +use crate::dynamic::schema::{EnumPayloadSchema, EnumSchema, FieldType, MessageSchema}; +use crate::dynamic::value::{DynamicNamedValue, DynamicValue, EnumPayloadValue, EnumValue}; use super::CDR_HEADER_LE; @@ -86,6 +86,14 @@ fn serialize_value( (DynamicValue::Float64(v), FieldType::Float64) => writer.write_f64(*v), (DynamicValue::String(v), FieldType::String) => writer.write_string(v), (DynamicValue::String(v), FieldType::BoundedString(_)) => writer.write_string(v), + (DynamicValue::Optional(None), FieldType::Optional(_)) => writer.write_u32(0), + (DynamicValue::Optional(Some(inner)), FieldType::Optional(inner_type)) => { + writer.write_u32(1); + serialize_value(inner, inner_type, writer)?; + } + (DynamicValue::Enum(enum_value), FieldType::Enum(schema)) => { + serialize_enum_value(enum_value, schema, writer)?; + } // Fixed-size array (no length prefix) (DynamicValue::Array(values), FieldType::Array(inner, _len)) => { @@ -175,6 +183,19 @@ fn deserialize_value( FieldType::String | FieldType::BoundedString(_) => Ok(DynamicValue::String( reader.read_string().map_err(map_cdr_err)?, )), + FieldType::Optional(inner) => { + let tag = reader.read_u32().map_err(map_cdr_err)?; + match tag { + 0 => Ok(DynamicValue::Optional(None)), + 1 => Ok(DynamicValue::Optional(Some(Box::new(deserialize_value( + inner, reader, + )?)))), + other => Err(DynamicError::DeserializationError(format!( + "invalid option discriminant: {other}" + ))), + } + } + FieldType::Enum(schema) => Ok(DynamicValue::Enum(deserialize_enum_value(schema, reader)?)), // Fixed-size array FieldType::Array(inner, len) => { @@ -225,6 +246,131 @@ fn deserialize_value( } } +fn serialize_enum_value( + value: &EnumValue, + schema: &Arc, + writer: &mut CdrWriter, +) -> Result<(), DynamicError> { + let variant = schema + .variants + .get(value.variant_index as usize) + .ok_or_else(|| { + DynamicError::SerializationError(format!( + "enum variant index {} is out of bounds for {}", + value.variant_index, schema.type_name + )) + })?; + + if variant.name != value.variant_name { + return Err(DynamicError::SerializationError(format!( + "enum variant name mismatch for {}: schema={}, value={}", + schema.type_name, variant.name, value.variant_name + ))); + } + + writer.write_u32(value.variant_index); + serialize_enum_payload(&value.payload, &variant.payload, writer) +} + +fn serialize_enum_payload( + payload: &EnumPayloadValue, + schema: &EnumPayloadSchema, + writer: &mut CdrWriter, +) -> Result<(), DynamicError> { + match (payload, schema) { + (EnumPayloadValue::Unit, EnumPayloadSchema::Unit) => Ok(()), + (EnumPayloadValue::Newtype(value), EnumPayloadSchema::Newtype(field_type)) => { + serialize_value(value, field_type, writer) + } + (EnumPayloadValue::Tuple(values), EnumPayloadSchema::Tuple(field_types)) => { + if values.len() != field_types.len() { + return Err(DynamicError::SerializationError(format!( + "enum tuple payload length mismatch: expected {}, got {}", + field_types.len(), + values.len() + ))); + } + + for (value, field_type) in values.iter().zip(field_types.iter()) { + serialize_value(value, field_type, writer)?; + } + Ok(()) + } + (EnumPayloadValue::Struct(values), EnumPayloadSchema::Struct(fields)) => { + if values.len() != fields.len() { + return Err(DynamicError::SerializationError(format!( + "enum struct payload length mismatch: expected {}, got {}", + fields.len(), + values.len() + ))); + } + + for (value, field) in values.iter().zip(fields.iter()) { + if value.name != field.name { + return Err(DynamicError::SerializationError(format!( + "enum struct payload field mismatch: expected {}, got {}", + field.name, value.name + ))); + } + serialize_value(&value.value, &field.field_type, writer)?; + } + Ok(()) + } + _ => Err(DynamicError::SerializationError(format!( + "enum payload mismatch: payload={payload:?}, schema={schema:?}" + ))), + } +} + +fn deserialize_enum_value( + schema: &Arc, + reader: &mut CdrReader, +) -> Result { + let variant_index = reader.read_u32().map_err(map_cdr_err)?; + let variant = schema.variants.get(variant_index as usize).ok_or_else(|| { + DynamicError::DeserializationError(format!( + "enum variant index {} is out of bounds for {}", + variant_index, schema.type_name + )) + })?; + + let payload = deserialize_enum_payload(&variant.payload, reader)?; + Ok(EnumValue { + variant_index, + variant_name: variant.name.clone(), + payload, + }) +} + +fn deserialize_enum_payload( + schema: &EnumPayloadSchema, + reader: &mut CdrReader, +) -> Result { + match schema { + EnumPayloadSchema::Unit => Ok(EnumPayloadValue::Unit), + EnumPayloadSchema::Newtype(field_type) => Ok(EnumPayloadValue::Newtype(Box::new( + deserialize_value(field_type, reader)?, + ))), + EnumPayloadSchema::Tuple(field_types) => Ok(EnumPayloadValue::Tuple( + field_types + .iter() + .map(|field_type| deserialize_value(field_type, reader)) + .collect::, _>>()?, + )), + EnumPayloadSchema::Struct(fields) => Ok(EnumPayloadValue::Struct( + fields + .iter() + .map(|field| { + Ok(DynamicNamedValue { + name: field.name.clone(), + value: deserialize_value(&field.field_type, reader)?, + }) + }) + .collect::, DynamicError>>()?, + )), + } +} + /// Map ros-z-cdr errors to DynamicError. fn map_cdr_err(e: ros_z_cdr::Error) -> DynamicError { DynamicError::DeserializationError(e.to_string()) diff --git a/crates/ros-z/src/dynamic/tests/pubsub_tests.rs b/crates/ros-z/src/dynamic/tests/pubsub_tests.rs index 5b970ae5..f01714fb 100644 --- a/crates/ros-z/src/dynamic/tests/pubsub_tests.rs +++ b/crates/ros-z/src/dynamic/tests/pubsub_tests.rs @@ -164,9 +164,18 @@ fn test_zpub_builder_with_dyn_schema() { // Create a mock builder to test with_dyn_schema let session = zenoh::Wait::wait(zenoh::open(zenoh::Config::default())).unwrap(); - let graph = std::sync::Arc::new(crate::graph::Graph::new(&session, 0).unwrap()); + let graph = std::sync::Arc::new( + crate::graph::Graph::new(&session, 0, ros_z_protocol::KeyExprFormat::default()).unwrap(), + ); let builder: ZPubBuilder = ZPubBuilder { - entity: Default::default(), + entity: crate::entity::EndpointEntity { + id: 0, + node: None, + kind: crate::entity::EndpointKind::Publisher, + topic: String::new(), + type_info: None, + qos: ros_z_protocol::qos::QosProfile::default(), + }, session: std::sync::Arc::new(session), graph, clock: crate::time::ZClock::default(), @@ -197,9 +206,18 @@ fn test_zpub_builder_with_serdes_preserves_schema() { // Create builder with schema let session = zenoh::Wait::wait(zenoh::open(zenoh::Config::default())).unwrap(); - let graph = std::sync::Arc::new(crate::graph::Graph::new(&session, 0).unwrap()); + let graph = std::sync::Arc::new( + crate::graph::Graph::new(&session, 0, ros_z_protocol::KeyExprFormat::default()).unwrap(), + ); let builder: ZPubBuilder = ZPubBuilder { - entity: Default::default(), + entity: crate::entity::EndpointEntity { + id: 0, + node: None, + kind: crate::entity::EndpointKind::Publisher, + topic: String::new(), + type_info: None, + qos: ros_z_protocol::qos::QosProfile::default(), + }, session: std::sync::Arc::new(session), graph, clock: crate::time::ZClock::default(), diff --git a/crates/ros-z/src/dynamic/type_description.rs b/crates/ros-z/src/dynamic/type_description.rs index 0ee010ba..483a9813 100644 --- a/crates/ros-z/src/dynamic/type_description.rs +++ b/crates/ros-z/src/dynamic/type_description.rs @@ -66,6 +66,13 @@ impl MessageSchemaTypeDescription for MessageSchema { } fn to_type_description(&self) -> Result { + if self.uses_extended_types() { + return Err(DynamicError::SerializationError( + "optional and enum fields cannot be represented as standard ROS 2 type descriptions" + .to_string(), + )); + } + let fields = self .fields .iter() @@ -120,6 +127,7 @@ fn collect_field_type_references( | FieldType::BoundedSequence(inner, _) => { collect_field_type_references(inner, referenced, visited)?; } + FieldType::Optional(_) | FieldType::Enum(_) => {} _ => {} // Primitives have no references } Ok(()) @@ -164,6 +172,10 @@ fn field_type_to_description(field_type: &FieldType) -> Result Err(DynamicError::SerializationError( + "optional and enum fields are only available through ros-z extended schema discovery" + .to_string(), + )), // Fixed-size array FieldType::Array(inner, size) => { @@ -227,6 +239,10 @@ fn get_base_type_id(field_type: &FieldType) -> Result { FieldType::Float64 => Ok(TypeId::FLOAT64), FieldType::String | FieldType::BoundedString(_) => Ok(TypeId::STRING), FieldType::Message(_) => Ok(TypeId::NESTED_TYPE), + FieldType::Optional(_) | FieldType::Enum(_) => Err(DynamicError::SerializationError( + "optional and enum fields are only available through ros-z extended schema discovery" + .to_string(), + )), // For nested arrays/sequences, we get the innermost type FieldType::Array(inner, _) | FieldType::Sequence(inner) diff --git a/crates/ros-z/src/dynamic/type_description_client.rs b/crates/ros-z/src/dynamic/type_description_client.rs deleted file mode 100644 index 3779bcea..00000000 --- a/crates/ros-z/src/dynamic/type_description_client.rs +++ /dev/null @@ -1,609 +0,0 @@ -//! Type Description Client implementation. -//! -//! This module provides the `TypeDescriptionClient` for querying type descriptions -//! from remote nodes. It enables dynamic schema discovery by calling the -//! `GetTypeDescription` service on publisher nodes. -//! -//! # Architecture -//! -//! ```text -//! ┌──────────────────────────────────────────────────────────────┐ -//! │ TypeDescriptionClient │ -//! ├──────────────────────────────────────────────────────────────┤ -//! │ Methods: │ -//! │ ├── get_type_description(node, type_name) -> Response │ -//! │ ├── get_type_description_for_topic(topic) -> Schema │ -//! │ └── response_to_schema(response) -> MessageSchema │ -//! └──────────────────────────────────────────────────────────────┘ -//! │ -//! ▼ -//! ┌──────────────────────────────────────────────────────────────┐ -//! │ ZClient │ -//! │ (Zenoh Querier) │ -//! └──────────────────────────────────────────────────────────────┘ -//! ``` -//! -//! # Example -//! -//! ```rust,ignore -//! use ros_z::dynamic::TypeDescriptionClient; -//! -//! // Create client -//! let client = TypeDescriptionClient::new(node); -//! -//! // Query type description from a specific node -//! let response = client.get_type_description( -//! "talker", // node name -//! "", // namespace (root) -//! "std_msgs/msg/String", -//! "", // empty hash = no validation -//! false, // don't include sources -//! ).await?; -//! -//! // Convert to schema -//! let schema = TypeDescriptionClient::response_to_schema(&response)?; -//! ``` - -use std::sync::Arc; -use std::time::Duration; - -use tracing::{debug, warn}; -use zenoh::Session; - -use crate::context::GlobalCounter; -use crate::entity::{EndpointEntity, Entity, EntityKind, NodeEntity}; -use crate::graph::Graph; -use crate::service::{ZClient, ZClientBuilder}; -use crate::{Builder, ServiceTypeInfo}; - -use super::error::DynamicError; -use super::schema::MessageSchema; - -/// Normalize DDS type name to ROS 2 canonical format. -/// -/// Converts "std_msgs::msg::dds_::String_" to "std_msgs/msg/String" -fn normalize_type_name(dds_name: &str) -> String { - dds_name - .replace("::msg::dds_::", "/msg/") - .replace("::srv::dds_::", "/srv/") - .replace("::action::dds_::", "/action/") - .trim_end_matches('_') - .to_string() -} -use super::type_description::type_description_msg_to_schema; -use super::type_description_service::{ - GetTypeDescription, GetTypeDescriptionRequest, GetTypeDescriptionResponse, - wire_to_schema_type_description, -}; - -/// Client for querying type descriptions from remote nodes. -/// -/// This client enables dynamic schema discovery by: -/// - Querying specific nodes for their registered type descriptions -/// - Discovering publishers on a topic and querying their type descriptions -/// - Converting wire format responses to usable `MessageSchema` objects -pub struct TypeDescriptionClient { - session: Arc, - counter: Arc, - graph: Option>, - /// Default timeout for service calls - timeout: Duration, -} - -impl TypeDescriptionClient { - /// Create a new TypeDescriptionClient. - /// - /// # Arguments - /// - /// * `session` - The Zenoh session to use - /// * `counter` - Global counter for entity IDs - /// - /// # Returns - /// - /// A new `TypeDescriptionClient` instance. - pub fn new(session: Arc, counter: Arc) -> Self { - Self { - session, - counter, - graph: None, - timeout: Duration::from_secs(10), - } - } - - /// Create a TypeDescriptionClient with graph access for topic-based discovery. - /// - /// # Arguments - /// - /// * `session` - The Zenoh session to use - /// * `counter` - Global counter for entity IDs - /// * `graph` - The graph for entity discovery - pub fn with_graph( - session: Arc, - counter: Arc, - graph: Arc, - ) -> Self { - Self { - session, - counter, - graph: Some(graph), - timeout: Duration::from_secs(10), - } - } - - /// Set the default timeout for service calls. - pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } - - /// Query type description from a specific node. - /// - /// This method creates a service client for the target node's - /// `get_type_description` service and queries for the specified type. - /// - /// # Arguments - /// - /// * `node_name` - Name of the target node - /// * `namespace` - Namespace of the target node (empty string for root namespace) - /// * `type_name` - Fully qualified type name (e.g., "std_msgs/msg/String") - /// * `type_hash` - Expected type hash for validation (empty string to skip) - /// * `include_sources` - Whether to include source files in the response - /// - /// # Returns - /// - /// The `GetTypeDescriptionResponse` from the remote node. - pub async fn get_type_description( - &self, - node_name: &str, - namespace: &str, - type_name: &str, - type_hash: &str, - include_sources: bool, - ) -> Result { - debug!( - "[TDC] Querying type description: node={}/{}, type={}", - namespace, node_name, type_name - ); - - // Build the absolute service name for this node. - let service_name = if namespace.is_empty() || namespace == "/" { - format!("/{}/get_type_description", node_name) - } else { - format!("{}/{}/get_type_description", namespace, node_name) - }; - - debug!( - "[TDC] Creating client for absolute service: {}", - service_name - ); - - // Use empty namespace since we're using an absolute service name (starts with /) - let client = self.create_client(&service_name, "")?; - - self.query_with_client( - &client, - node_name, - namespace, - type_name, - type_hash, - include_sources, - ) - .await - } - - /// Send a type description request via an already-built client and wait for the response. - /// - /// Separating client creation from the actual query allows callers to hoist Querier - /// creation out of tight loops, mirroring rmw_zenoh_cpp where the Querier lives for - /// the lifetime of the service client rather than being freshly created per request. - async fn query_with_client( - &self, - client: &ZClient, - node_name: &str, - namespace: &str, - type_name: &str, - type_hash: &str, - include_sources: bool, - ) -> Result { - let request = GetTypeDescriptionRequest { - type_name: type_name.to_string(), - type_hash: type_hash.to_string(), - include_type_sources: include_sources, - }; - - debug!( - "[TDC] Sending request to get_type_description: node={}/{}", - namespace, node_name - ); - - client - .send_request(&request) - .await - .map_err(|e| DynamicError::SerializationError(e.to_string()))?; - - // Map a channel/recv timeout to the dedicated ServiceTimeout variant so callers - // can distinguish "service didn't respond" from actual CDR decode failures. - let node_display = if namespace.is_empty() || namespace == "/" { - node_name.to_string() - } else { - format!("{}/{}", namespace, node_name) - }; - let service_display = if namespace.is_empty() || namespace == "/" { - format!("/{}/get_type_description", node_name) - } else { - format!("{}/{}/get_type_description", namespace, node_name) - }; - - let response = client.take_response_timeout(self.timeout).map_err(|_| { - DynamicError::ServiceTimeout { - node: node_display, - service: service_display, - } - })?; - - if response.successful { - debug!( - "[TDC] Got type description for: {}", - response.type_description.type_description.type_name - ); - } else { - warn!( - "[TDC] Type description query failed: {}", - response.failure_reason - ); - } - - Ok(response) - } - - /// Query type description from any node publishing to a topic. - /// - /// This method uses the graph to discover publishers on the specified topic, - /// then queries each publisher's node for the type description until one - /// succeeds. - /// - /// # Arguments - /// - /// * `topic` - The topic to discover type information for - /// * `timeout` - Maximum time to wait for discovery and query - /// - /// # Returns - /// - /// A tuple of (MessageSchema, type_hash) on success. - pub async fn get_type_description_for_topic( - &self, - topic: &str, - timeout: Duration, - ) -> Result<(Arc, String), DynamicError> { - let graph = self.graph.as_ref().ok_or_else(|| { - DynamicError::SerializationError( - "TypeDescriptionClient requires graph for topic-based discovery".to_string(), - ) - })?; - - debug!("[TDC] Discovering type description for topic: {}", topic); - - // ── Phase 1: Publisher discovery ───────────────────────────────────── - // Retry up to 5 × 500 ms if the graph hasn't seen any publishers yet. - let mut publishers = graph.get_entities_by_topic(EntityKind::Publisher, topic); - debug!( - "[TDC] Initial discovery found {} publishers for topic {}", - publishers.len(), - topic - ); - - if publishers.is_empty() { - debug!("[TDC] No publishers found initially, waiting for discovery..."); - for attempt in 1..=5 { - tokio::time::sleep(Duration::from_millis(500)).await; - publishers = graph.get_entities_by_topic(EntityKind::Publisher, topic); - debug!( - "[TDC] Discovery attempt {}: found {} publishers", - attempt, - publishers.len() - ); - if !publishers.is_empty() { - break; - } - } - - if publishers.is_empty() { - warn!( - "[TDC] No publishers found for topic {} after retries", - topic - ); - return Err(DynamicError::SchemaNotFound(format!( - "No publishers found for topic: {}", - topic - ))); - } - } - - // Extract type name and hash from the first publisher. - // All publishers on a topic share the same type in ROS 2. - let first_pub = publishers.first().ok_or_else(|| { - DynamicError::SchemaNotFound(format!("No publishers found for topic: {}", topic)) - })?; - let first_ep = match &**first_pub { - Entity::Endpoint(e) => e, - _ => { - return Err(DynamicError::SerializationError( - "Expected endpoint entity".to_string(), - )); - } - }; - let type_info = first_ep.type_info.as_ref().ok_or_else(|| { - DynamicError::SchemaNotFound(format!("Publisher on {} has no type information", topic)) - })?; - let type_name = normalize_type_name(&type_info.name); - let type_hash = type_info.hash.to_rihs_string(); - - debug!( - "[TDC] Found publisher for {}: node={}/{}, type={} (normalized from: {})", - topic, first_ep.node.namespace, first_ep.node.name, type_name, type_info.name - ); - - // ── Phase 2: Create one ZClient (Querier) per publisher endpoint ───── - // - // This is the key fix for the flaky race condition. In rmw_zenoh_cpp the - // Querier is created once when rmw_create_client is called and then reused - // for every send_request. By the time user code calls a service the - // Querier has been alive long enough to have received queryable - // advertisements from the router. - // - // The old code called create_client() inside the retry loop, meaning a - // brand-new Querier was created and immediately used. With AllComplete a - // fresh Querier that hasn't received advertisements yet resolves the GET - // instantly with zero replies, causing the 10 s take_response_timeout to - // expire before a reply ever arrives. - // - // By creating all Queriers here, before any GET is sent, we give them time - // to settle in Phase 3 below. - // (node_name, namespace, client) - let mut pub_clients: Vec<(String, String, ZClient)> = Vec::new(); - for publisher in &publishers { - let Entity::Endpoint(ep) = &**publisher else { - continue; - }; - let service_name = if ep.node.namespace.is_empty() || ep.node.namespace == "/" { - format!("/{}/get_type_description", ep.node.name) - } else { - format!( - "{}/{}/get_type_description", - ep.node.namespace, ep.node.name - ) - }; - match self.create_client(&service_name, "") { - Ok(c) => pub_clients.push((ep.node.name.clone(), ep.node.namespace.clone(), c)), - Err(e) => warn!("[TDC] Could not create client for {}: {}", service_name, e), - } - } - - if pub_clients.is_empty() { - return Err(DynamicError::SchemaNotFound(format!( - "Could not create any service clients for topic: {}", - topic - ))); - } - - // ── Phase 3: Let Queriers settle ───────────────────────────────────── - // - // Allow the newly-declared Queriers to receive queryable advertisements - // from the router. This mirrors the implicit settling time rmw_zenoh_cpp - // benefits from because rmw_create_client and rmw_send_request are called - // in different stages of a node's lifecycle. - tokio::time::sleep(Duration::from_millis(100)).await; - - // ── Phase 4: Retry loop using the pre-built clients ────────────────── - // - // With settled Queriers the happy path succeeds on the first attempt. - // The retry loop is a safety net for loaded CI systems or slow networks. - let deadline = tokio::time::Instant::now() + timeout; - let mut last_error = None; - - 'retry: loop { - for (node_name, namespace, client) in &pub_clients { - if tokio::time::Instant::now() > deadline { - break 'retry; - } - - match self - .query_with_client(client, node_name, namespace, &type_name, &type_hash, false) - .await - { - Ok(response) if response.successful => { - let schema = Self::response_to_schema(&response)?; - return Ok((schema, type_hash.clone())); - } - Ok(response) => { - // Definitive service failure (e.g. hash mismatch): no point retrying. - last_error = - Some(DynamicError::SerializationError(response.failure_reason)); - break 'retry; - } - Err(e @ DynamicError::ServiceTimeout { .. }) => { - // Transient: Querier may not yet have received the advertisement. - debug!("[TDC] Type description query timed out, retrying..."); - last_error = Some(e); - } - Err(e) => { - last_error = Some(e); - break 'retry; - } - } - } - - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - break; - } - - let delay = Duration::from_millis(500).min(remaining); - tokio::time::sleep(delay).await; - - // (Publisher list is fixed to the clients built in Phase 2; - // re-discovery on each retry is not needed once Queriers are settled.) - } - - Err(last_error.unwrap_or_else(|| { - DynamicError::SchemaNotFound(format!( - "Failed to get type description from any publisher on {}", - topic - )) - })) - } - - /// Convert a GetTypeDescriptionResponse to a MessageSchema. - /// - /// This method takes the wire format TypeDescription from a service response - /// and converts it to a runtime MessageSchema that can be used for dynamic - /// message handling. - /// - /// # Arguments - /// - /// * `response` - The response from a GetTypeDescription service call - /// - /// # Returns - /// - /// An `Arc` representing the type. - pub fn response_to_schema( - response: &GetTypeDescriptionResponse, - ) -> Result, DynamicError> { - if !response.successful { - return Err(DynamicError::SerializationError(format!( - "Response indicates failure: {}", - response.failure_reason - ))); - } - - // Convert wire format to ros-z-schema types - let type_desc_msg = wire_to_schema_type_description(&response.type_description); - - // Convert to MessageSchema - type_description_msg_to_schema(&type_desc_msg) - } - - /// Create a service client for the given service name. - fn create_client( - &self, - service_name: &str, - namespace: &str, - ) -> Result, DynamicError> { - // Create a temporary node entity for the client - let node_entity = NodeEntity::new( - 0, // domain_id - self.session.zid(), - self.counter.increment(), - "type_desc_client".to_string(), - namespace.to_string(), - String::new(), // enclave (empty, normalized to "%" in liveliness token) - ); - - let entity = EndpointEntity { - id: self.counter.increment(), - node: node_entity, - kind: EntityKind::Client, - topic: service_name.to_string(), - type_info: Some(GetTypeDescription::service_type_info()), - ..Default::default() - }; - - let builder: ZClientBuilder = ZClientBuilder { - entity, - session: self.session.clone(), - clock: crate::time::ZClock::default(), - keyexpr_format: ros_z_protocol::KeyExprFormat::default(), - _phantom_data: Default::default(), - }; - - builder - .build() - .map_err(|e| DynamicError::SerializationError(e.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dynamic::schema::FieldType; - use crate::dynamic::type_description_service::{ - WireTypeDescription, schema_to_wire_type_description, - }; - - #[test] - fn test_response_to_schema_success() { - // Build a schema and convert to wire format - let original = MessageSchema::builder("std_msgs/msg/String") - .field("data", FieldType::String) - .build() - .unwrap(); - - let wire_td = schema_to_wire_type_description(&original).unwrap(); - - let response = GetTypeDescriptionResponse { - successful: true, - failure_reason: String::new(), - type_description: wire_td, - type_sources: vec![], - extra_information: vec![], - }; - - let schema = TypeDescriptionClient::response_to_schema(&response).unwrap(); - assert_eq!(schema.type_name, "std_msgs/msg/String"); - assert_eq!(schema.fields.len(), 1); - assert_eq!(schema.fields[0].name, "data"); - } - - #[test] - fn test_response_to_schema_failure() { - let response = GetTypeDescriptionResponse { - successful: false, - failure_reason: "Type not found".to_string(), - type_description: WireTypeDescription::default(), - type_sources: vec![], - extra_information: vec![], - }; - - let result = TypeDescriptionClient::response_to_schema(&response); - assert!(result.is_err()); - } - - #[test] - fn test_response_to_schema_nested() { - // Build a nested schema - let vector3 = MessageSchema::builder("geometry_msgs/msg/Vector3") - .field("x", FieldType::Float64) - .field("y", FieldType::Float64) - .field("z", FieldType::Float64) - .build() - .unwrap(); - - let twist = MessageSchema::builder("geometry_msgs/msg/Twist") - .field("linear", FieldType::Message(vector3.clone())) - .field("angular", FieldType::Message(vector3)) - .build() - .unwrap(); - - let wire_td = schema_to_wire_type_description(&twist).unwrap(); - - let response = GetTypeDescriptionResponse { - successful: true, - failure_reason: String::new(), - type_description: wire_td, - type_sources: vec![], - extra_information: vec![], - }; - - let schema = TypeDescriptionClient::response_to_schema(&response).unwrap(); - assert_eq!(schema.type_name, "geometry_msgs/msg/Twist"); - assert_eq!(schema.fields.len(), 2); - - // Verify nested types are resolved - if let FieldType::Message(nested) = &schema.fields[0].field_type { - assert_eq!(nested.type_name, "geometry_msgs/msg/Vector3"); - assert_eq!(nested.fields.len(), 3); - } else { - panic!("Expected Message type for linear field"); - } - } -} diff --git a/crates/ros-z/src/dynamic/type_description_query.rs b/crates/ros-z/src/dynamic/type_description_query.rs new file mode 100644 index 00000000..7eaf7d44 --- /dev/null +++ b/crates/ros-z/src/dynamic/type_description_query.rs @@ -0,0 +1,220 @@ +//! Standard type-description protocol helpers. + +use std::{sync::Arc, time::Duration}; + +use tracing::{debug, warn}; + +use crate::{Builder, node::ZNode}; + +#[cfg(test)] +use super::discovery::collect_topic_schema_candidates_from_publishers; +use super::type_description::type_description_msg_to_schema; +use super::type_description_service::{ + GetTypeDescription, GetTypeDescriptionRequest, GetTypeDescriptionResponse, + wire_to_schema_type_description, +}; +use super::{discovery::TopicSchemaCandidate, error::DynamicError, schema::MessageSchema}; + +pub(crate) async fn query_type_description( + node: &ZNode, + candidate: &TopicSchemaCandidate, + timeout: Duration, + include_sources: bool, +) -> Result<(Arc, String), DynamicError> { + debug!( + "[TDC] Querying type description: node={}/{}, type={}", + candidate.namespace, candidate.node_name, candidate.type_name + ); + + let service_name = if candidate.namespace.is_empty() || candidate.namespace == "/" { + format!("/{}/get_type_description", candidate.node_name) + } else { + format!( + "{}/{}/get_type_description", + candidate.namespace, candidate.node_name + ) + }; + + let client = node + .create_client::(&service_name) + .build() + .map_err(|e| DynamicError::SerializationError(e.to_string()))?; + let request = GetTypeDescriptionRequest { + type_name: candidate.type_name.clone(), + type_hash: candidate.type_hash.clone(), + include_type_sources: include_sources, + }; + + let response = client + .call_or_timeout(&request, timeout) + .await + .map_err(|_| DynamicError::ServiceTimeout { + node: if candidate.namespace.is_empty() || candidate.namespace == "/" { + candidate.node_name.clone() + } else { + format!("{}/{}", candidate.namespace, candidate.node_name) + }, + service: service_name, + })?; + + if response.successful { + let schema = schema_from_type_description_response(&response)?; + Ok((schema, candidate.type_hash.clone())) + } else { + warn!( + "[TDC] Type description query failed: {}", + response.failure_reason + ); + Err(DynamicError::SerializationError(response.failure_reason)) + } +} + +pub fn schema_from_type_description_response( + response: &GetTypeDescriptionResponse, +) -> Result, DynamicError> { + if !response.successful { + return Err(DynamicError::SerializationError(format!( + "Response indicates failure: {}", + response.failure_reason + ))); + } + + let type_desc_msg = wire_to_schema_type_description(&response.type_description); + type_description_msg_to_schema(&type_desc_msg) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::schema::FieldType; + use crate::dynamic::type_description_service::{ + WireTypeDescription, schema_to_wire_type_description, + }; + use crate::entity::{EndpointEntity, EndpointKind, Entity, NodeEntity, TypeHash, TypeInfo}; + + fn publisher_entity(node_name: Option<&str>, type_name: Option<&str>) -> Arc { + let node = node_name.map(|name| { + NodeEntity::new( + 1, + "1234567890abcdef1234567890abcdef".parse().unwrap(), + 0, + name.to_string(), + "/".to_string(), + String::new(), + ) + }); + + Arc::new(Entity::Endpoint(EndpointEntity { + id: 1, + node, + kind: EndpointKind::Publisher, + topic: "/chatter".to_string(), + type_info: type_name.map(|name| TypeInfo::new(name, TypeHash::zero())), + qos: Default::default(), + })) + } + + #[test] + fn test_response_to_schema_success() { + let original = MessageSchema::builder("std_msgs/msg/String") + .field("data", FieldType::String) + .build() + .unwrap(); + + let wire_td = schema_to_wire_type_description(&original).unwrap(); + + let response = GetTypeDescriptionResponse { + successful: true, + failure_reason: String::new(), + type_description: wire_td, + type_sources: vec![], + extra_information: vec![], + }; + + let schema = schema_from_type_description_response(&response).unwrap(); + assert_eq!(schema.type_name, "std_msgs/msg/String"); + assert_eq!(schema.fields.len(), 1); + assert_eq!(schema.fields[0].name, "data"); + } + + #[test] + fn test_response_to_schema_failure() { + let response = GetTypeDescriptionResponse { + successful: false, + failure_reason: "Type not found".to_string(), + type_description: WireTypeDescription::default(), + type_sources: vec![], + extra_information: vec![], + }; + + let result = schema_from_type_description_response(&response); + assert!(result.is_err()); + } + + #[test] + fn test_response_to_schema_nested() { + let vector3 = MessageSchema::builder("geometry_msgs/msg/Vector3") + .field("x", FieldType::Float64) + .field("y", FieldType::Float64) + .field("z", FieldType::Float64) + .build() + .unwrap(); + + let twist = MessageSchema::builder("geometry_msgs/msg/Twist") + .field("linear", FieldType::Message(vector3.clone())) + .field("angular", FieldType::Message(vector3)) + .build() + .unwrap(); + + let wire_td = schema_to_wire_type_description(&twist).unwrap(); + + let response = GetTypeDescriptionResponse { + successful: true, + failure_reason: String::new(), + type_description: wire_td, + type_sources: vec![], + extra_information: vec![], + }; + + let schema = schema_from_type_description_response(&response).unwrap(); + assert_eq!(schema.type_name, "geometry_msgs/msg/Twist"); + assert_eq!(schema.fields.len(), 2); + + if let FieldType::Message(nested) = &schema.fields[0].field_type { + assert_eq!(nested.type_name, "geometry_msgs/msg/Vector3"); + assert_eq!(nested.fields.len(), 3); + } else { + panic!("Expected Message type for linear field"); + } + } + + #[test] + fn test_topic_discovery_uses_type_info_from_any_publisher() { + let publishers = vec![ + publisher_entity(None, Some("std_msgs::msg::dds_::String_")), + publisher_entity(Some("talker"), Some("std_msgs::msg::dds_::String_")), + ]; + + let candidates = collect_topic_schema_candidates_from_publishers(&publishers, "/chatter") + .expect("expected type info to be discovered"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].node_name, "talker"); + assert_eq!(candidates[0].namespace, "/"); + assert_eq!(candidates[0].type_name, "std_msgs/msg/String"); + assert_eq!(candidates[0].type_hash, TypeHash::zero().to_rihs_string()); + } + + #[test] + fn test_topic_discovery_reports_missing_node_identity_only_when_all_publishers_lack_it() { + let publishers = vec![publisher_entity(None, Some("std_msgs::msg::dds_::String_"))]; + + let err = collect_topic_schema_candidates_from_publishers(&publishers, "/chatter") + .expect_err("expected missing node identity error"); + + assert!(matches!( + err, + DynamicError::MissingNodeIdentity { ref topic } if topic == "/chatter" + )); + } +} diff --git a/crates/ros-z/src/dynamic/type_description_service.rs b/crates/ros-z/src/dynamic/type_description_service.rs index 7ad9dbf5..9e60046c 100644 --- a/crates/ros-z/src/dynamic/type_description_service.rs +++ b/crates/ros-z/src/dynamic/type_description_service.rs @@ -45,6 +45,7 @@ use zenoh::query::Query; use zenoh::{Result as ZResult, Session}; use crate::ServiceTypeInfo; +use crate::attachment::Attachment; use crate::entity::{TypeHash, TypeInfo}; use crate::msg::ZService; use crate::service::{ZServer, ZServerBuilder}; @@ -466,11 +467,11 @@ impl TypeDescriptionService { let entity = crate::entity::EndpointEntity { id: counter.increment(), - node: node_entity, - kind: crate::entity::EntityKind::Service, + node: Some(node_entity), + kind: crate::entity::EndpointKind::Service, topic: service_name.to_string(), type_info: Some(GetTypeDescription::service_type_info()), - ..Default::default() + qos: Default::default(), }; // Build the service server with callback mode to avoid blocking tasks @@ -631,7 +632,13 @@ impl TypeDescriptionService { // Serialize and send the response let bytes = SerdeCdrSerdes::serialize(&response); use zenoh::Wait; - if let Err(e) = query.reply(query.key_expr().clone(), bytes).wait() { + let mut reply = query.reply(query.key_expr().clone(), bytes); + if let Some(att_bytes) = query.attachment() + && let Ok(att) = Attachment::try_from(att_bytes) + { + reply = reply.attachment(att); + } + if let Err(e) = reply.wait() { warn!("[TDS] Failed to send response: {}", e); } } diff --git a/crates/ros-z/src/dynamic/type_info.rs b/crates/ros-z/src/dynamic/type_info.rs new file mode 100644 index 00000000..e601b241 --- /dev/null +++ b/crates/ros-z/src/dynamic/type_info.rs @@ -0,0 +1,55 @@ +use tracing::warn; + +use crate::dynamic::{MessageSchema, MessageSchemaTypeDescription}; +use crate::entity::{TypeHash, TypeInfo}; + +pub(crate) fn dds_type_name_from_schema(schema: &MessageSchema) -> String { + schema + .type_name + .replace("/msg/", "::msg::dds_::") + .replace("/srv/", "::srv::dds_::") + .replace("/action/", "::action::dds_::") + + "_" +} + +pub(crate) fn ros_type_name_from_dds(dds_name: &str) -> String { + dds_name + .replace("::msg::dds_::", "/msg/") + .replace("::srv::dds_::", "/srv/") + .replace("::action::dds_::", "/action/") + .trim_end_matches('_') + .to_string() +} + +pub(crate) fn schema_hash(schema: &MessageSchema) -> TypeHash { + match schema.compute_type_hash() { + Ok(hash) => { + let rihs_string = hash.to_rihs_string(); + TypeHash::from_rihs_string(&rihs_string).unwrap_or_else(TypeHash::zero) + } + Err(error) => { + warn!( + "[NOD] Failed to compute type hash for {}: {}", + schema.type_name, error + ); + TypeHash::zero() + } + } +} + +pub(crate) fn schema_type_info(schema: &MessageSchema) -> TypeInfo { + TypeInfo { + name: dds_type_name_from_schema(schema), + hash: schema_hash(schema), + } +} + +pub(crate) fn schema_type_info_with_hash( + schema: &MessageSchema, + discovered_hash: &str, +) -> TypeInfo { + TypeInfo { + name: dds_type_name_from_schema(schema), + hash: TypeHash::from_rihs_string(discovered_hash).unwrap_or_else(TypeHash::zero), + } +} diff --git a/crates/ros-z/src/dynamic/value.rs b/crates/ros-z/src/dynamic/value.rs index e690b8af..fc25bced 100644 --- a/crates/ros-z/src/dynamic/value.rs +++ b/crates/ros-z/src/dynamic/value.rs @@ -3,8 +3,10 @@ //! This module provides the `DynamicValue` enum for representing any ROS 2 //! value at runtime, along with conversion traits. +use std::sync::Arc; + use super::message::DynamicMessage; -use super::schema::FieldType; +use super::schema::{EnumPayloadSchema, EnumSchema, FieldType}; /// Runtime representation of any ROS 2 value. #[derive(Clone, Debug, PartialEq)] @@ -28,11 +30,54 @@ pub enum DynamicValue { /// Nested message Message(Box), + /// Optional value encoded with a `u32` presence tag. + Optional(Option>), + /// Tagged enum encoded with a `u32` variant index. + Enum(EnumValue), /// Collections (homogeneous) Array(Vec), } +/// Runtime representation of a serde enum value. +#[derive(Clone, Debug, PartialEq)] +pub struct EnumValue { + pub variant_index: u32, + pub variant_name: String, + pub payload: EnumPayloadValue, +} + +impl EnumValue { + /// Create a new enum value. + pub fn new( + variant_index: u32, + variant_name: impl Into, + payload: EnumPayloadValue, + ) -> Self { + Self { + variant_index, + variant_name: variant_name.into(), + payload, + } + } +} + +/// Runtime payload value for a serde enum variant. +#[derive(Clone, Debug, PartialEq)] +pub enum EnumPayloadValue { + Unit, + Newtype(Box), + Tuple(Vec), + Struct(Vec), +} + +/// Named field value used by struct enum variants. +#[derive(Clone, Debug, PartialEq)] +pub struct DynamicNamedValue { + pub name: String, + pub value: DynamicValue, +} + /// Macro to generate accessor methods for primitive types. macro_rules! impl_primitive_accessors { ($($method:ident -> $variant:ident : $ty:ty),* $(,)?) => { @@ -97,6 +142,23 @@ impl DynamicValue { } } + /// Try to extract as an optional reference. + pub fn as_optional(&self) -> Option> { + match self { + DynamicValue::Optional(Some(value)) => Some(Some(value.as_ref())), + DynamicValue::Optional(None) => Some(None), + _ => None, + } + } + + /// Try to extract as an enum reference. + pub fn as_enum(&self) -> Option<&EnumValue> { + match self { + DynamicValue::Enum(value) => Some(value), + _ => None, + } + } + /// Try to extract as an array reference. pub fn as_array(&self) -> Option<&[DynamicValue]> { match self { @@ -210,6 +272,28 @@ impl IntoDynamic for Vec { } } +impl IntoDynamic for Option { + fn into_dynamic(self) -> DynamicValue { + DynamicValue::Optional(self.map(|value| Box::new(value.into_dynamic()))) + } +} + +impl FromDynamic for Option { + fn from_dynamic(value: &DynamicValue) -> Option { + match value { + DynamicValue::Optional(None) => Some(None), + DynamicValue::Optional(Some(inner)) => T::from_dynamic(inner.as_ref()).map(Some), + _ => None, + } + } +} + +impl IntoDynamic for EnumValue { + fn into_dynamic(self) -> DynamicValue { + DynamicValue::Enum(self) + } +} + /// Create the default value for a given field type. pub fn default_for_type(field_type: &FieldType) -> DynamicValue { match field_type { @@ -226,9 +310,45 @@ pub fn default_for_type(field_type: &FieldType) -> DynamicValue { FieldType::Float64 => DynamicValue::Float64(0.0), FieldType::String | FieldType::BoundedString(_) => DynamicValue::String(String::new()), FieldType::Message(schema) => DynamicValue::Message(Box::new(DynamicMessage::new(schema))), + FieldType::Optional(_) => DynamicValue::Optional(None), + FieldType::Enum(schema) => DynamicValue::Enum(default_enum_value(schema)), FieldType::Array(inner, len) => DynamicValue::Array(vec![default_for_type(inner); *len]), FieldType::Sequence(_) | FieldType::BoundedSequence(_, _) => { DynamicValue::Array(Vec::new()) } } } + +fn default_enum_value(schema: &Arc) -> EnumValue { + let variant = schema + .variants + .first() + .expect("enum schemas must have at least one variant"); + + EnumValue { + variant_index: 0, + variant_name: variant.name.clone(), + payload: default_enum_payload(&variant.payload), + } +} + +fn default_enum_payload(payload: &EnumPayloadSchema) -> EnumPayloadValue { + match payload { + EnumPayloadSchema::Unit => EnumPayloadValue::Unit, + EnumPayloadSchema::Newtype(field_type) => { + EnumPayloadValue::Newtype(Box::new(default_for_type(field_type))) + } + EnumPayloadSchema::Tuple(field_types) => { + EnumPayloadValue::Tuple(field_types.iter().map(default_for_type).collect()) + } + EnumPayloadSchema::Struct(fields) => EnumPayloadValue::Struct( + fields + .iter() + .map(|field| DynamicNamedValue { + name: field.name.clone(), + value: default_for_type(&field.field_type), + }) + .collect(), + ), + } +} diff --git a/crates/ros-z/src/encoding.rs b/crates/ros-z/src/encoding.rs index a591e5cd..f76e50c4 100644 --- a/crates/ros-z/src/encoding.rs +++ b/crates/ros-z/src/encoding.rs @@ -29,10 +29,6 @@ use std::fmt; /// // Protobuf with schema /// let proto = Encoding::protobuf() /// .with_schema("geometry_msgs/msg/Vector3"); -/// -/// // FlatBuffers with schema -/// let fb = Encoding::flatbuffers() -/// .with_schema("sensor_msgs/msg/Image"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub enum Encoding { diff --git a/crates/ros-z/src/entity.rs b/crates/ros-z/src/entity.rs index 37439236..57d5519d 100644 --- a/crates/ros-z/src/entity.rs +++ b/crates/ros-z/src/entity.rs @@ -42,9 +42,13 @@ pub fn node_lv_token_key_expr(entity: &NodeEntity) -> Result> { /// Get the GID (globally unique identifier) for this endpoint pub fn endpoint_gid(entity: &EndpointEntity) -> crate::attachment::GidArray { use sha2::Digest; + let node = entity + .node + .as_ref() + .expect("endpoint_gid requires endpoint node identity"); let mut hasher = sha2::Sha256::new(); // ZenohId has to_le_bytes() method - hasher.update(entity.node.z_id.to_le_bytes()); + hasher.update(node.z_id.to_le_bytes()); hasher.update(entity.id.to_le_bytes()); let hash = hasher.finalize(); let mut gid = [0u8; 16]; @@ -64,7 +68,12 @@ pub fn node_to_liveliness_ke(entity: &NodeEntity) -> Result { /// Convert an EndpointEntity to a LivelinessKE using the default format pub fn endpoint_to_liveliness_ke(entity: &EndpointEntity) -> Result { let format = ros_z_protocol::KeyExprFormat::default(); - format.liveliness_key_expr(entity, &entity.node.z_id) + let Some(node) = entity.node.as_ref() else { + return Err(zenoh::Error::from( + "endpoint liveliness requires node identity", + )); + }; + format.liveliness_key_expr(entity, &node.z_id) } /// Convert an Entity to a LivelinessKE using the default format @@ -79,7 +88,7 @@ pub fn entity_to_liveliness_ke(entity: &Entity) -> Result { pub fn entity_kind(entity: &Entity) -> EntityKind { match entity { Entity::Node(_) => EntityKind::Node, - Entity::Endpoint(e) => e.kind, + Entity::Endpoint(e) => e.entity_kind(), } } diff --git a/crates/ros-z/src/event.rs b/crates/ros-z/src/event.rs index 6729910f..de4307be 100644 --- a/crates/ros-z/src/event.rs +++ b/crates/ros-z/src/event.rs @@ -237,7 +237,7 @@ impl GraphEventManager { appeared: bool, _local_zid: zenoh::session::ZenohId, ) { - use crate::entity::EntityKind; + use crate::entity::EndpointKind; let change = if appeared { 1 } else { -1 }; @@ -255,11 +255,10 @@ impl GraphEventManager { // When a subscription appears/disappears, publishers get PublicationMatched events let event_type = match entity { crate::entity::Entity::Endpoint(endpoint) => match endpoint.kind { - EntityKind::Publisher => ZenohEventType::SubscriptionMatched, - EntityKind::Subscription => ZenohEventType::PublicationMatched, - EntityKind::Service => return, // TODO: Add service matched events - EntityKind::Client => return, // TODO: Add service matched events - EntityKind::Node => unreachable!("EndpointEntity should not have Node kind"), + EndpointKind::Publisher => ZenohEventType::SubscriptionMatched, + EndpointKind::Subscription => ZenohEventType::PublicationMatched, + EndpointKind::Service => return, // TODO: Add service matched events + EndpointKind::Client => return, // TODO: Add service matched events }, crate::entity::Entity::Node(_) => return, // Node changes don't trigger matched events }; diff --git a/crates/ros-z/src/extended_schema.rs b/crates/ros-z/src/extended_schema.rs new file mode 100644 index 00000000..e5dc969d --- /dev/null +++ b/crates/ros-z/src/extended_schema.rs @@ -0,0 +1,335 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +use crate::dynamic::{DynamicError, FieldType, MessageSchema}; +use crate::node::ZNode; + +pub trait ExtendedMessageTypeInfo: crate::MessageTypeInfo { + fn extended_message_schema() -> Arc; + + fn extended_field_type() -> FieldType { + FieldType::Message(Self::extended_message_schema()) + } +} + +#[derive(Serialize, Deserialize)] +struct ExtendedMessageSchema { + type_name: String, + package: String, + name: String, + fields: Vec, +} + +#[derive(Serialize, Deserialize)] +struct ExtendedFieldSchema { + name: String, + field_type: ExtendedFieldType, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ExtendedFieldType { + Bool, + Int8, + Int16, + Int32, + Int64, + Uint8, + Uint16, + Uint32, + Uint64, + Float32, + Float64, + String, + BoundedString { + capacity: usize, + }, + Message { + schema: Box, + }, + Optional { + inner: Box, + }, + Enum { + schema: Box, + }, + Array { + inner: Box, + len: usize, + }, + Sequence { + inner: Box, + }, + BoundedSequence { + inner: Box, + max: usize, + }, +} + +#[derive(Serialize, Deserialize)] +struct ExtendedEnumSchema { + type_name: String, + variants: Vec, +} + +#[derive(Serialize, Deserialize)] +struct ExtendedEnumVariantSchema { + name: String, + payload: ExtendedEnumPayloadSchema, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ExtendedEnumPayloadSchema { + Unit, + Newtype { field_type: Box }, + Tuple { field_types: Vec }, + Struct { fields: Vec }, +} + +pub fn compute_extended_type_hash( + schema: &MessageSchema, +) -> Result { + let extended = message_schema_to_extended(schema); + let json = ros_z_schema::to_ros2_json(&extended).map_err(|err| { + DynamicError::SerializationError(format!( + "failed to serialize extended schema hash view for {}: {}", + schema.type_name, err + )) + })?; + + let mut hasher = sha2::Sha256::new(); + hasher.update(json.as_bytes()); + Ok(ros_z_schema::TypeHash(hasher.finalize().into())) +} + +pub fn schema_to_extension_json(schema: &MessageSchema) -> Result { + let extended = message_schema_to_extended(schema); + serde_json::to_string(&extended).map_err(|err| { + DynamicError::SerializationError(format!( + "failed to serialize extended schema for {}: {}", + schema.type_name, err + )) + }) +} + +pub fn schema_from_extension_json(json: &str) -> Result, DynamicError> { + let extended: ExtendedMessageSchema = serde_json::from_str(json).map_err(|err| { + DynamicError::DeserializationError(format!("failed to parse extended schema JSON: {}", err)) + })?; + Ok(Arc::new(extended_to_message_schema(extended))) +} + +pub fn register_type(node: &ZNode) -> Result<(), String> { + let schema = T::extended_message_schema(); + if !schema.uses_extended_types() { + return Ok(()); + } + + let service = node.extended_type_description_service().ok_or_else(|| { + "extended type description service is not enabled on this node; call with_extended_type_description_service() to expose schemas for extended-only types".to_string() + })?; + + service + .register_schema(schema) + .map_err(|err| format!("failed to register extended schema: {}", err)) +} + +fn message_schema_to_extended(schema: &MessageSchema) -> ExtendedMessageSchema { + ExtendedMessageSchema { + type_name: schema.type_name.clone(), + package: schema.package.clone(), + name: schema.name.clone(), + fields: schema.fields.iter().map(field_schema_to_extended).collect(), + } +} + +fn field_schema_to_extended(field: &crate::dynamic::FieldSchema) -> ExtendedFieldSchema { + ExtendedFieldSchema { + name: field.name.clone(), + field_type: field_type_to_extended(&field.field_type), + } +} + +fn field_type_to_extended(field_type: &FieldType) -> ExtendedFieldType { + match field_type { + FieldType::Bool => ExtendedFieldType::Bool, + FieldType::Int8 => ExtendedFieldType::Int8, + FieldType::Int16 => ExtendedFieldType::Int16, + FieldType::Int32 => ExtendedFieldType::Int32, + FieldType::Int64 => ExtendedFieldType::Int64, + FieldType::Uint8 => ExtendedFieldType::Uint8, + FieldType::Uint16 => ExtendedFieldType::Uint16, + FieldType::Uint32 => ExtendedFieldType::Uint32, + FieldType::Uint64 => ExtendedFieldType::Uint64, + FieldType::Float32 => ExtendedFieldType::Float32, + FieldType::Float64 => ExtendedFieldType::Float64, + FieldType::String => ExtendedFieldType::String, + FieldType::BoundedString(capacity) => ExtendedFieldType::BoundedString { + capacity: *capacity, + }, + FieldType::Message(schema) => ExtendedFieldType::Message { + schema: Box::new(message_schema_to_extended(schema)), + }, + FieldType::Optional(inner) => ExtendedFieldType::Optional { + inner: Box::new(field_type_to_extended(inner)), + }, + FieldType::Enum(schema) => ExtendedFieldType::Enum { + schema: Box::new(enum_schema_to_extended(schema)), + }, + FieldType::Array(inner, len) => ExtendedFieldType::Array { + inner: Box::new(field_type_to_extended(inner)), + len: *len, + }, + FieldType::Sequence(inner) => ExtendedFieldType::Sequence { + inner: Box::new(field_type_to_extended(inner)), + }, + FieldType::BoundedSequence(inner, max) => ExtendedFieldType::BoundedSequence { + inner: Box::new(field_type_to_extended(inner)), + max: *max, + }, + } +} + +fn enum_schema_to_extended(schema: &crate::dynamic::EnumSchema) -> ExtendedEnumSchema { + ExtendedEnumSchema { + type_name: schema.type_name.clone(), + variants: schema + .variants + .iter() + .map(enum_variant_to_extended) + .collect(), + } +} + +fn enum_variant_to_extended( + variant: &crate::dynamic::EnumVariantSchema, +) -> ExtendedEnumVariantSchema { + ExtendedEnumVariantSchema { + name: variant.name.clone(), + payload: enum_payload_to_extended(&variant.payload), + } +} + +fn enum_payload_to_extended( + payload: &crate::dynamic::EnumPayloadSchema, +) -> ExtendedEnumPayloadSchema { + match payload { + crate::dynamic::EnumPayloadSchema::Unit => ExtendedEnumPayloadSchema::Unit, + crate::dynamic::EnumPayloadSchema::Newtype(field_type) => { + ExtendedEnumPayloadSchema::Newtype { + field_type: Box::new(field_type_to_extended(field_type)), + } + } + crate::dynamic::EnumPayloadSchema::Tuple(field_types) => ExtendedEnumPayloadSchema::Tuple { + field_types: field_types.iter().map(field_type_to_extended).collect(), + }, + crate::dynamic::EnumPayloadSchema::Struct(fields) => ExtendedEnumPayloadSchema::Struct { + fields: fields.iter().map(field_schema_to_extended).collect(), + }, + } +} + +fn extended_to_message_schema(schema: ExtendedMessageSchema) -> MessageSchema { + MessageSchema { + type_name: schema.type_name, + package: schema.package, + name: schema.name, + fields: schema + .fields + .into_iter() + .map(extended_to_field_schema) + .collect(), + type_hash: None, + } +} + +fn extended_to_field_schema(field: ExtendedFieldSchema) -> crate::dynamic::FieldSchema { + crate::dynamic::FieldSchema { + name: field.name, + field_type: extended_to_field_type(field.field_type), + default_value: None, + } +} + +fn extended_to_field_type(field_type: ExtendedFieldType) -> FieldType { + match field_type { + ExtendedFieldType::Bool => FieldType::Bool, + ExtendedFieldType::Int8 => FieldType::Int8, + ExtendedFieldType::Int16 => FieldType::Int16, + ExtendedFieldType::Int32 => FieldType::Int32, + ExtendedFieldType::Int64 => FieldType::Int64, + ExtendedFieldType::Uint8 => FieldType::Uint8, + ExtendedFieldType::Uint16 => FieldType::Uint16, + ExtendedFieldType::Uint32 => FieldType::Uint32, + ExtendedFieldType::Uint64 => FieldType::Uint64, + ExtendedFieldType::Float32 => FieldType::Float32, + ExtendedFieldType::Float64 => FieldType::Float64, + ExtendedFieldType::String => FieldType::String, + ExtendedFieldType::BoundedString { capacity } => FieldType::BoundedString(capacity), + ExtendedFieldType::Message { schema } => { + FieldType::Message(Arc::new(extended_to_message_schema(*schema))) + } + ExtendedFieldType::Optional { inner } => { + FieldType::Optional(Box::new(extended_to_field_type(*inner))) + } + ExtendedFieldType::Enum { schema } => { + FieldType::Enum(Arc::new(extended_to_enum_schema(*schema))) + } + ExtendedFieldType::Array { inner, len } => { + FieldType::Array(Box::new(extended_to_field_type(*inner)), len) + } + ExtendedFieldType::Sequence { inner } => { + FieldType::Sequence(Box::new(extended_to_field_type(*inner))) + } + ExtendedFieldType::BoundedSequence { inner, max } => { + FieldType::BoundedSequence(Box::new(extended_to_field_type(*inner)), max) + } + } +} + +fn extended_to_enum_schema(schema: ExtendedEnumSchema) -> crate::dynamic::EnumSchema { + crate::dynamic::EnumSchema { + type_name: schema.type_name, + variants: schema + .variants + .into_iter() + .map(extended_to_enum_variant) + .collect(), + } +} + +fn extended_to_enum_variant( + variant: ExtendedEnumVariantSchema, +) -> crate::dynamic::EnumVariantSchema { + crate::dynamic::EnumVariantSchema { + name: variant.name, + payload: extended_to_enum_payload(variant.payload), + } +} + +fn extended_to_enum_payload( + payload: ExtendedEnumPayloadSchema, +) -> crate::dynamic::EnumPayloadSchema { + match payload { + ExtendedEnumPayloadSchema::Unit => crate::dynamic::EnumPayloadSchema::Unit, + ExtendedEnumPayloadSchema::Newtype { field_type } => { + crate::dynamic::EnumPayloadSchema::Newtype(Box::new(extended_to_field_type( + *field_type, + ))) + } + ExtendedEnumPayloadSchema::Tuple { field_types } => { + crate::dynamic::EnumPayloadSchema::Tuple( + field_types + .into_iter() + .map(extended_to_field_type) + .collect(), + ) + } + ExtendedEnumPayloadSchema::Struct { fields } => crate::dynamic::EnumPayloadSchema::Struct( + fields.into_iter().map(extended_to_field_schema).collect(), + ), + } +} diff --git a/crates/ros-z/src/extended_type_description_query.rs b/crates/ros-z/src/extended_type_description_query.rs new file mode 100644 index 00000000..279da5c9 --- /dev/null +++ b/crates/ros-z/src/extended_type_description_query.rs @@ -0,0 +1,61 @@ +use std::{sync::Arc, time::Duration}; + +use crate::{Builder, dynamic::DynamicError, node::ZNode}; + +use crate::dynamic::{MessageSchema, discovery::TopicSchemaCandidate}; +use crate::extended_type_description_service::{ + GetExtendedTypeDescription, GetExtendedTypeDescriptionRequest, + GetExtendedTypeDescriptionResponse, +}; + +use crate::extended_schema::schema_from_extension_json; + +/// Query the extended type-description service for a single current topic candidate. +pub(crate) async fn query_extended_type_description( + node: &ZNode, + candidate: &TopicSchemaCandidate, + timeout: Duration, +) -> Result<(Arc, String), DynamicError> { + let service_name = if candidate.namespace.is_empty() || candidate.namespace == "/" { + format!("/{}/get_extended_type_description", candidate.node_name) + } else { + format!( + "{}/{}/get_extended_type_description", + candidate.namespace, candidate.node_name + ) + }; + + let client = node + .create_client::(&service_name) + .build() + .map_err(|e| DynamicError::SerializationError(e.to_string()))?; + let request = GetExtendedTypeDescriptionRequest { + type_name: candidate.type_name.clone(), + type_hash: candidate.type_hash.clone(), + }; + + let response = client + .call_or_timeout(&request, timeout) + .await + .map_err(|_| { + DynamicError::SerializationError( + "extended type description service timed out".to_string(), + ) + })?; + + let schema = schema_from_extended_type_description_response(&response)?; + Ok((schema, response.type_hash)) +} + +pub fn schema_from_extended_type_description_response( + response: &GetExtendedTypeDescriptionResponse, +) -> Result, DynamicError> { + if !response.successful { + return Err(DynamicError::SerializationError(format!( + "Response indicates failure: {}", + response.failure_reason + ))); + } + + schema_from_extension_json(&response.schema_json) +} diff --git a/crates/ros-z/src/extended_type_description_service.rs b/crates/ros-z/src/extended_type_description_service.rs new file mode 100644 index 00000000..2c2015ed --- /dev/null +++ b/crates/ros-z/src/extended_type_description_service.rs @@ -0,0 +1,241 @@ +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; +use zenoh::Session; + +use crate::ServiceTypeInfo; +use crate::attachment::Attachment; +use crate::context::GlobalCounter; +use crate::dynamic::{DynamicError, MessageSchema}; +use crate::entity::{EndpointEntity, EndpointKind, NodeEntity, TypeHash, TypeInfo}; +use crate::msg::{SerdeCdrSerdes, ZMessage, ZService}; +use crate::service::{ZServer, ZServerBuilder}; + +use crate::extended_schema::{compute_extended_type_hash, schema_to_extension_json}; + +const EXTENDED_SERVICE_NAME: &str = "~get_extended_type_description"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GetExtendedTypeDescriptionRequest { + pub type_name: String, + pub type_hash: String, +} + +impl ZMessage for GetExtendedTypeDescriptionRequest { + type Serdes = SerdeCdrSerdes; +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GetExtendedTypeDescriptionResponse { + pub successful: bool, + pub failure_reason: String, + pub type_hash: String, + pub schema_json: String, +} + +impl ZMessage for GetExtendedTypeDescriptionResponse { + type Serdes = SerdeCdrSerdes; +} + +pub struct GetExtendedTypeDescription; + +impl ZService for GetExtendedTypeDescription { + type Request = GetExtendedTypeDescriptionRequest; + type Response = GetExtendedTypeDescriptionResponse; +} + +impl ServiceTypeInfo for GetExtendedTypeDescription { + fn service_type_info() -> TypeInfo { + TypeInfo::new( + "ros_z::srv::dds_::GetExtendedTypeDescription_", + TypeHash::zero(), + ) + } +} + +#[derive(Clone)] +pub struct RegisteredExtendedSchema { + pub schema: Arc, + pub type_hash: String, +} + +impl RegisteredExtendedSchema { + pub fn new(schema: Arc) -> Result { + let type_hash = compute_extended_type_hash(&schema)?.to_rihs_string(); + Ok(Self { schema, type_hash }) + } +} + +#[derive(Clone)] +pub struct ExtendedTypeDescriptionService { + schemas: Arc>>, + _server: Arc>, +} + +impl ExtendedTypeDescriptionService { + pub fn new( + session: Arc, + node_name: &str, + namespace: &str, + node_id: usize, + counter: &GlobalCounter, + clock: &crate::time::ZClock, + ) -> zenoh::Result { + let schemas = Arc::new(RwLock::new(std::collections::HashMap::new())); + let node_entity = NodeEntity::new( + 0, + session.zid(), + node_id, + node_name.to_string(), + namespace.to_string(), + String::new(), + ); + + let entity = EndpointEntity { + id: counter.increment(), + node: Some(node_entity), + kind: EndpointKind::Service, + topic: EXTENDED_SERVICE_NAME.to_string(), + type_info: Some(GetExtendedTypeDescription::service_type_info()), + qos: Default::default(), + }; + + let server_builder: ZServerBuilder = ZServerBuilder { + entity, + session, + clock: clock.clone(), + keyexpr_format: ros_z_protocol::KeyExprFormat::default(), + _phantom_data: Default::default(), + }; + + let schemas_clone = schemas.clone(); + let server = server_builder.build_with_callback(move |query| { + Self::handle_query(&schemas_clone, &query); + })?; + + info!( + "[ETDS] ExtendedTypeDescriptionService created for node: {}/{}", + namespace, node_name + ); + + Ok(Self { + schemas, + _server: Arc::new(server), + }) + } + + pub fn register_schema(&self, schema: Arc) -> Result<(), DynamicError> { + let registered = RegisteredExtendedSchema::new(schema.clone())?; + let type_name = schema.type_name.clone(); + + let mut schemas = self + .schemas + .write() + .map_err(|_| DynamicError::RegistryLockPoisoned)?; + schemas.insert(type_name.clone(), registered); + + debug!("[ETDS] Registered extended schema: {}", type_name); + Ok(()) + } + + pub fn get_schema(&self, type_name: &str) -> Result>, DynamicError> { + let schemas = self + .schemas + .read() + .map_err(|_| DynamicError::RegistryLockPoisoned)?; + Ok(schemas + .get(type_name) + .map(|registered| registered.schema.clone())) + } + + fn handle_query( + schemas: &Arc>>, + query: &zenoh::query::Query, + ) { + let request = match query.payload() { + Some(payload) => match ::deserialize( + payload.to_bytes().as_ref(), + ) { + Ok(request) => request, + Err(err) => { + warn!("[ETDS] Failed to decode request: {}", err); + return; + } + }, + None => { + warn!("[ETDS] Missing request payload"); + return; + } + }; + + let response = Self::build_response(schemas, &request); + let bytes = ZMessage::serialize(&response); + use zenoh::Wait; + let mut reply = query.reply(query.key_expr().clone(), bytes); + if let Some(att_bytes) = query.attachment() + && let Ok(att) = Attachment::try_from(att_bytes) + { + reply = reply.attachment(att); + } + if let Err(err) = reply.wait() { + warn!("[ETDS] Failed to send response: {}", err); + } + } + + fn build_response( + schemas: &Arc>>, + request: &GetExtendedTypeDescriptionRequest, + ) -> GetExtendedTypeDescriptionResponse { + let schemas_guard = match schemas.read() { + Ok(guard) => guard, + Err(_) => { + return GetExtendedTypeDescriptionResponse { + successful: false, + failure_reason: "Internal error: registry lock poisoned".to_string(), + type_hash: String::new(), + schema_json: String::new(), + }; + } + }; + + let registered = match schemas_guard.get(&request.type_name) { + Some(schema) => schema, + None => { + return GetExtendedTypeDescriptionResponse { + successful: false, + failure_reason: format!("Type '{}' not registered", request.type_name), + type_hash: String::new(), + schema_json: String::new(), + }; + } + }; + + if !request.type_hash.is_empty() && request.type_hash != registered.type_hash { + return GetExtendedTypeDescriptionResponse { + successful: false, + failure_reason: format!( + "Type hash mismatch: expected {}, got {}", + registered.type_hash, request.type_hash + ), + type_hash: String::new(), + schema_json: String::new(), + }; + } + + match schema_to_extension_json(®istered.schema) { + Ok(schema_json) => GetExtendedTypeDescriptionResponse { + successful: true, + failure_reason: String::new(), + type_hash: registered.type_hash.clone(), + schema_json, + }, + Err(err) => GetExtendedTypeDescriptionResponse { + successful: false, + failure_reason: format!("Failed to serialize extended schema: {}", err), + type_hash: String::new(), + schema_json: String::new(), + }, + } + } +} diff --git a/crates/ros-z/src/graph.rs b/crates/ros-z/src/graph.rs index 3db2ba8f..cf78e15a 100644 --- a/crates/ros-z/src/graph.rs +++ b/crates/ros-z/src/graph.rs @@ -10,12 +10,15 @@ use tokio::sync::Notify; use tracing::debug; use crate::entity::{ - ADMIN_SPACE, EndpointEntity, Entity, EntityKind, LivelinessKE, NodeKey, Topic, + ADMIN_SPACE, EndpointEntity, EndpointKind, Entity, EntityKind, LivelinessKE, NodeKey, Topic, }; use crate::event::GraphEventManager; use tracing; use zenoh::{Result, Session, Wait, pubsub::Subscriber, sample::SampleKind, session::ZenohId}; +#[cfg(test)] +use zenoh::key_expr::KeyExpr; + /// A serializable snapshot of the ROS graph state #[derive(Debug, Clone, Serialize)] pub struct GraphSnapshot { @@ -26,6 +29,52 @@ pub struct GraphSnapshot { pub services: Vec, } +#[cfg(test)] +mod tests { + use super::*; + use crate::entity::{EndpointEntity, EndpointKind}; + + #[test] + fn test_key_expr_origin_zid_supports_ros2dds_tokens() { + let zid: ZenohId = "1234567890abcdef1234567890abcdef".parse().unwrap(); + let key_expr: KeyExpr<'static> = "@/1234567890abcdef1234567890abcdef/@ros2_lv/MP/chatter/std_msgs\u{00A7}msg\u{00A7}String" + .to_string() + .try_into() + .unwrap(); + + assert_eq!(key_expr_origin_zid(&key_expr), Some(zid)); + } + + #[test] + fn test_key_expr_origin_zid_supports_rmw_zenoh_tokens() { + let zid: ZenohId = "1234567890abcdef1234567890abcdef".parse().unwrap(); + let key_expr: KeyExpr<'static> = "@ros2_lv/0/1234567890abcdef1234567890abcdef/1/1/MP/%/%/talker/chatter/std_msgs%msg%String/RIHS01_00000000000000000000000000000000/Q" + .try_into() + .unwrap(); + + assert_eq!(key_expr_origin_zid(&key_expr), Some(zid)); + } + + #[test] + fn test_entity_matches_local_zid_for_ros2dds_endpoint_without_node_identity() { + let zid: ZenohId = "1234567890abcdef1234567890abcdef".parse().unwrap(); + let entity = Entity::Endpoint(EndpointEntity { + id: 1, + node: None, + kind: EndpointKind::Publisher, + topic: "/chatter".to_string(), + type_info: None, + qos: Default::default(), + }); + let key_expr: KeyExpr<'static> = "@/1234567890abcdef1234567890abcdef/@ros2_lv/MP/chatter/std_msgs\u{00A7}msg\u{00A7}String" + .to_string() + .try_into() + .unwrap(); + + assert!(entity_matches_local_zid(&entity, &key_expr, zid)); + } +} + #[derive(Debug, Clone, Serialize)] pub struct TopicSnapshot { pub name: String, @@ -53,6 +102,34 @@ const DEFAULT_SLAB_CAPACITY: usize = 128; /// Type alias for entity parser function type EntityParser = Arc Result + Send + Sync>; +#[cfg(test)] +fn key_expr_origin_zid(key_expr: &KeyExpr) -> Option { + let mut segments = key_expr.split('/'); + + match segments.next()? { + "@" => segments.next()?.parse().ok(), + ADMIN_SPACE => { + segments.next()?; + segments.next()?.parse().ok() + } + _ => None, + } +} + +#[cfg(test)] +fn entity_matches_local_zid(entity: &Entity, key_expr: &KeyExpr, local_zid: ZenohId) -> bool { + let owner_zid = match entity { + Entity::Node(node) => Some(node.z_id), + Entity::Endpoint(endpoint) => endpoint + .node + .as_ref() + .map(|node| node.z_id) + .or_else(|| key_expr_origin_zid(key_expr)), + }; + + owner_zid.is_some_and(|zid| zid == local_zid) +} + pub struct GraphData { cached: HashSet, parsed: HashMap>, @@ -165,28 +242,43 @@ impl GraphData { slab.insert(weak); } Entity::Endpoint(x) => { + let node_desc = x + .node + .as_ref() + .map(|node| format!("{}/{}", node.namespace, node.name)) + .unwrap_or_else(|| "".to_string()); debug!( - "[GRF] Parsed endpoint: kind={:?}, topic={}, node={}/{}", - x.kind, x.topic, x.node.namespace, x.node.name + "[GRF] Parsed endpoint: kind={:?}, topic={}, node={}", + x.kind, x.topic, node_desc ); - let node_key = crate::entity::node_key(&x.node); let type_str = x .type_info .as_ref() .map(|t| t.name.as_str()) .unwrap_or("unknown"); - tracing::debug!( - "parse: Storing Endpoint ({:?}) for node_key=({:?}, {:?}), topic={}, type={}, id={}", - x.kind, - node_key.0, - node_key.1, - x.topic, - type_str, - x.id - ); + if let Some(node) = x.node.as_ref() { + let node_key = crate::entity::node_key(node); + tracing::debug!( + "parse: Storing Endpoint ({:?}) for node_key=({:?}, {:?}), topic={}, type={}, id={}", + x.kind, + node_key.0, + node_key.1, + x.topic, + type_str, + x.id + ); + } else { + tracing::debug!( + "parse: Storing Endpoint ({:?}) without node identity, topic={}, type={}, id={}", + x.kind, + x.topic, + type_str, + x.id + ); + } // Index by topic for Publisher/Subscription entities - if matches!(x.kind, EntityKind::Publisher | EntityKind::Subscription) { + if matches!(x.kind, EndpointKind::Publisher | EndpointKind::Subscription) { // TODO: omit the clone of topic let topic_slab = self .by_topic @@ -202,7 +294,7 @@ impl GraphData { } // Index by service for Service/Client entities - if matches!(x.kind, EntityKind::Service | EntityKind::Client) { + if matches!(x.kind, EndpointKind::Service | EndpointKind::Client) { // TODO: omit the clone of service name (stored in topic field) let service_slab = self .by_service @@ -216,17 +308,19 @@ impl GraphData { service_slab.insert(weak.clone()); } - let node_slab = self - .by_node - .entry(node_key) - .or_insert_with(|| Slab::with_capacity(DEFAULT_SLAB_CAPACITY)); + if let Some(node) = x.node.as_ref() { + let node_slab = self + .by_node + .entry(crate::entity::node_key(node)) + .or_insert_with(|| Slab::with_capacity(DEFAULT_SLAB_CAPACITY)); - // If slab is full, remove failing weak pointers first - if node_slab.len() >= node_slab.capacity() { - node_slab.retain(|_, weak_ptr| weak_ptr.upgrade().is_some()); - } + // If slab is full, remove failing weak pointers first + if node_slab.len() >= node_slab.capacity() { + node_slab.retain(|_, weak_ptr| weak_ptr.upgrade().is_some()); + } - node_slab.insert(weak); + node_slab.insert(weak); + } } } self.parsed.insert(ke, arc); @@ -337,6 +431,33 @@ impl std::fmt::Debug for Graph { } impl Graph { + /// Create a new Graph using an explicit key expression format. + /// + /// The format determines both the liveliness subscription pattern and the + /// parser used to turn liveliness keys back into ROS entities. + pub fn new( + session: &Session, + domain_id: usize, + format: ros_z_protocol::KeyExprFormat, + ) -> Result { + let liveliness_pattern = match format { + ros_z_protocol::KeyExprFormat::RmwZenoh => { + format!("{ADMIN_SPACE}/{domain_id}/**") + } + ros_z_protocol::KeyExprFormat::Ros2Dds => "@/*/@ros2_lv/**".to_string(), + _ => { + return Err(zenoh::Error::from(format!( + "unsupported key expression format for graph construction: {:?}", + format + ))); + } + }; + + Self::new_with_pattern(session, domain_id, liveliness_pattern, move |ke| { + format.parse_liveliness(ke) + }) + } + async fn wait_until(&self, timeout: Duration, predicate: F) -> bool where F: Fn(&Self) -> bool, @@ -364,17 +485,6 @@ impl Graph { } } - pub fn new(session: &Session, domain_id: usize) -> Result { - // Default to RmwZenoh format - let format = ros_z_protocol::KeyExprFormat::default(); - Self::new_with_pattern( - session, - domain_id, - format!("{ADMIN_SPACE}/{domain_id}/**"), - move |ke| format.parse_liveliness(ke), - ) - } - /// Create a new Graph with a custom liveliness subscription pattern and parser /// /// # Arguments @@ -405,7 +515,6 @@ impl Graph { let c_change_notify = change_notify.clone(); let c_zid = zid; let c_liveliness_pattern = liveliness_pattern.clone(); - let c_parser = parser_arc.clone(); let callback_parser = parser_arc.clone(); tracing::debug!("Creating liveliness subscriber for {}", liveliness_pattern); let sub = session @@ -426,6 +535,18 @@ impl Graph { SampleKind::Put => { debug!("[GRF] Entity appeared: {}", ke.0); tracing::debug!("Graph subscriber: PUT {}", key_expr.as_str()); + let parsed_entity = match callback_parser(&key_expr) { + Ok(entity) => Some(entity), + Err(e) => { + tracing::warn!( + "Failed to parse liveliness token {}: {:?}", + key_expr, + e + ); + None + } + }; + // Only insert if not already parsed (avoid duplicates from liveliness query) let already_parsed = graph_data_guard.parsed.contains_key(&ke); let already_cached = graph_data_guard.cached.contains(&ke); @@ -444,19 +565,9 @@ impl Graph { tracing::debug!(" Adding to cached"); graph_data_guard.insert(ke.clone()); } - // Trigger graph change events using backend-specific parser - match callback_parser(&key_expr) { - Ok(entity) => { - tracing::debug!("Successfully parsed entity: {:?}", entity); - c_event_manager.trigger_graph_change(&entity, true, c_zid); - } - Err(e) => { - tracing::warn!( - "Failed to parse liveliness token {}: {:?}", - key_expr, - e - ); - } + if let Some(entity) = parsed_entity { + tracing::debug!("Successfully parsed entity: {:?}", entity); + c_event_manager.trigger_graph_change(&entity, true, c_zid); } // Wake any tasks waiting in wait_for_subscription / wait_for_publisher. c_change_notify.notify_waiters(); @@ -484,44 +595,21 @@ impl Graph { .timeout(std::time::Duration::from_secs(3)) .wait()?; - // Process all replies and add them to the graph - // IMPORTANT: Filter out entities from the current session to avoid duplicates - // Local entities are already added via add_local_entity() + // Process all replies and add them to the graph. + // At this point plain ros-z still relies on liveliness for local entity + // visibility, so do not filter current-session entities here. let mut reply_count = 0; - let mut filtered_count = 0; while let Ok(reply) = replies.recv() { reply_count += 1; if let Ok(sample) = reply.into_result() { let key_expr = sample.key_expr().to_owned(); let ke = LivelinessKE(key_expr.clone()); - // Parse entity to check if it's from current session using backend-specific parser - if let Ok(entity) = c_parser(&key_expr) { - // Skip entities from current session - let is_local = match &entity { - Entity::Node(node) => node.z_id == zid, - Entity::Endpoint(endpoint) => endpoint.node.z_id == zid, - }; - - if !is_local { - // Only insert entities from other sessions - tracing::debug!( - "Graph: Adding cross-context entity: {}", - key_expr.as_str() - ); - graph_data.lock().insert(ke); - } else { - filtered_count += 1; - tracing::debug!("Graph: Filtered local entity: {}", key_expr.as_str()); - } - } + tracing::debug!("Graph: Caching liveliness entity: {}", key_expr.as_str()); + graph_data.lock().insert(ke); } } - tracing::debug!( - "Graph: Liveliness query received {} replies, filtered {} local entities", - reply_count, - filtered_count - ); + tracing::debug!("Graph: Liveliness query received {} replies", reply_count); Ok(Self { _subscriber: sub, @@ -536,7 +624,10 @@ impl Graph { pub fn is_entity_local(&self, entity: &Entity) -> bool { match entity { Entity::Node(node) => node.z_id == self.zid, - Entity::Endpoint(endpoint) => endpoint.node.z_id == self.zid, + Entity::Endpoint(endpoint) => endpoint + .node + .as_ref() + .is_some_and(|node| node.z_id == self.zid), } } @@ -576,7 +667,7 @@ impl Graph { // Index by topic for Publisher/Subscription if matches!( endpoint.kind, - EntityKind::Publisher | EntityKind::Subscription + EndpointKind::Publisher | EndpointKind::Subscription ) { let topic_slab = data .by_topic @@ -590,7 +681,7 @@ impl Graph { } // Index by service for Service/Client - if matches!(endpoint.kind, EntityKind::Service | EntityKind::Client) { + if matches!(endpoint.kind, EndpointKind::Service | EndpointKind::Client) { let service_slab = data .by_service .entry(endpoint.topic.clone()) @@ -603,15 +694,17 @@ impl Graph { } // Index by node - let node_slab = data - .by_node - .entry(crate::entity::node_key(&endpoint.node)) - .or_insert_with(|| Slab::with_capacity(DEFAULT_SLAB_CAPACITY)); + if let Some(node) = endpoint.node.as_ref() { + let node_slab = data + .by_node + .entry(crate::entity::node_key(node)) + .or_insert_with(|| Slab::with_capacity(DEFAULT_SLAB_CAPACITY)); - if node_slab.len() >= node_slab.capacity() { - node_slab.retain(|_, weak_ptr| weak_ptr.upgrade().is_some()); + if node_slab.len() >= node_slab.capacity() { + node_slab.retain(|_, weak_ptr| weak_ptr.upgrade().is_some()); + } + node_slab.insert(weak); } - node_slab.insert(weak); } } @@ -656,7 +749,7 @@ impl Graph { // Remove from by_topic or by_service depending on kind if matches!( endpoint_entity.kind, - EntityKind::Publisher | EntityKind::Subscription + EndpointKind::Publisher | EndpointKind::Subscription ) && let Some(slab) = data.by_topic.get_mut(&endpoint_entity.topic) { slab.retain(|_, weak| { @@ -667,7 +760,7 @@ impl Graph { } if matches!( endpoint_entity.kind, - EntityKind::Service | EntityKind::Client + EndpointKind::Service | EndpointKind::Client ) && let Some(slab) = data.by_service.get_mut(&endpoint_entity.topic) { slab.retain(|_, weak| { @@ -677,9 +770,8 @@ impl Graph { }); } // Also remove from by_node (endpoints are indexed by their node) - if let Some(slab) = data - .by_node - .get_mut(&crate::entity::node_key(&endpoint_entity.node)) + if let Some(node) = endpoint_entity.node.as_ref() + && let Some(slab) = data.by_node.get_mut(&crate::entity::node_key(node)) { slab.retain(|_, weak| { weak.upgrade().is_some_and(|arc| { @@ -794,7 +886,7 @@ impl Graph { // Skip expensive get_endpoint() if we already found the type if let Some(enp) = crate::entity::entity_get_endpoint(&ent) && found_type.is_none() - && enp.kind == EntityKind::Service + && enp.kind == EndpointKind::Service { found_type = enp.type_info.as_ref().map(|x| x.name.clone()); } @@ -831,8 +923,10 @@ impl Graph { && let Some(enp) = crate::entity::entity_get_endpoint(&ent) { // Include both publishers and subscribers - if matches!(enp.kind, EntityKind::Publisher | EntityKind::Subscription) - && let Some(type_info) = &enp.type_info + if matches!( + enp.kind, + EndpointKind::Publisher | EndpointKind::Subscription + ) && let Some(type_info) = &enp.type_info { found_type = Some(type_info.name.clone()); } @@ -883,7 +977,7 @@ impl Graph { data.visit_by_node(node_key, |ent| { if let Some(enp) = crate::entity::entity_get_endpoint(&ent) - && enp.kind == kind + && enp.entity_kind() == kind && let Some(type_info) = &enp.type_info { // Insert into set for automatic deduplication diff --git a/crates/ros-z/src/lib.rs b/crates/ros-z/src/lib.rs index 9f28e8bd..2f8f426d 100644 --- a/crates/ros-z/src/lib.rs +++ b/crates/ros-z/src/lib.rs @@ -57,6 +57,10 @@ pub mod encoding; pub mod entity; /// Graph events emitted by the Zenoh network graph. pub mod event; +/// ros-z-specific extended schema discovery for enums, options, and other non-ROS shapes. +pub mod extended_schema; +pub(crate) mod extended_type_description_query; +pub(crate) mod extended_type_description_service; #[cfg(feature = "ffi")] pub mod ffi; /// ROS 2 graph introspection (node/topic/service discovery). @@ -102,7 +106,9 @@ pub mod parameter; pub use attachment::GidArray; pub use entity::{TypeHash, TypeInfo}; +pub use extended_schema::ExtendedMessageTypeInfo; pub use ros_msg::{ActionTypeInfo, MessageTypeInfo, ServiceTypeInfo, WithTypeInfo}; +pub use ros_z_derive::{ExtendedMessageTypeInfo, MessageTypeInfo}; pub use zbuf::ZBuf; pub use zenoh::Result; diff --git a/crates/ros-z/src/lifecycle/client.rs b/crates/ros-z/src/lifecycle/client.rs index 96468766..8940f047 100644 --- a/crates/ros-z/src/lifecycle/client.rs +++ b/crates/ros-z/src/lifecycle/client.rs @@ -128,8 +128,7 @@ impl ZLifecycleClient { label: String::new(), }, }; - self.change_state.send_request(&req).await?; - let resp = self.change_state.take_response_timeout(timeout)?; + let resp = self.change_state.call_or_timeout(&req, timeout).await?; Ok(resp.success) } @@ -139,17 +138,19 @@ impl ZLifecycleClient { /// Query the current state of the remote lifecycle node. pub async fn get_state(&self, timeout: Duration) -> Result { - self.get_state.send_request(&GetStateRequest {}).await?; - let resp = self.get_state.take_response_timeout(timeout)?; + let resp = self + .get_state + .call_or_timeout(&GetStateRequest {}, timeout) + .await?; Ok(state_from_lc(&resp.current_state)) } /// List all states in the lifecycle state machine. pub async fn get_available_states(&self, timeout: Duration) -> Result> { - self.get_available_states - .send_request(&GetAvailableStatesRequest {}) + let resp = self + .get_available_states + .call_or_timeout(&GetAvailableStatesRequest {}, timeout) .await?; - let resp = self.get_available_states.take_response_timeout(timeout)?; Ok(resp.available_states) } @@ -158,12 +159,10 @@ impl ZLifecycleClient { &self, timeout: Duration, ) -> Result> { - self.get_available_transitions - .send_request(&GetAvailableTransitionsRequest {}) - .await?; let resp = self .get_available_transitions - .take_response_timeout(timeout)?; + .call_or_timeout(&GetAvailableTransitionsRequest {}, timeout) + .await?; Ok(resp.available_transitions) } } diff --git a/crates/ros-z/src/lifecycle/node.rs b/crates/ros-z/src/lifecycle/node.rs index 4735c3e3..eb528e0a 100644 --- a/crates/ros-z/src/lifecycle/node.rs +++ b/crates/ros-z/src/lifecycle/node.rs @@ -409,7 +409,13 @@ fn encode_reply( resp: &T, ) { let bytes = NativeCdrSerdes::serialize(resp); - if let Err(e) = query.reply(query.key_expr().clone(), bytes).wait() { + let mut reply = query.reply(query.key_expr().clone(), bytes); + if let Some(att_bytes) = query.attachment() + && let Ok(att) = crate::attachment::Attachment::try_from(att_bytes) + { + reply = reply.attachment(att); + } + if let Err(e) = reply.wait() { warn!("failed to send lifecycle reply: {e}"); } } diff --git a/crates/ros-z/src/node.rs b/crates/ros-z/src/node.rs index 955c6101..e2ff794a 100644 --- a/crates/ros-z/src/node.rs +++ b/crates/ros-z/src/node.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use tracing::{debug, info, warn}; -use zenoh::{Result, Session, Wait, liveliness::LivelinessToken, sample::Sample}; +use zenoh::{Result, Session, Wait, liveliness::LivelinessToken}; #[cfg(feature = "ffi")] use crate::ffi::publisher::RawPublisher; @@ -11,17 +11,19 @@ use crate::{ cache::ZCacheBuilder, context::{GlobalCounter, RemapRules}, dynamic::{ - DynamicMessage, DynamicSerdeCdrSerdes, MessageSchema, TypeDescriptionClient, - TypeDescriptionService, + DiscoveredTopicSchema, DynPubBuilder, DynSubBuilder, DynamicMessage, DynamicSerdeCdrSerdes, + MessageSchema, SchemaDiscovery, TypeDescriptionService, discovered_schema_type_info, + schema_type_info, }, - entity::{EntityKind, *}, + entity::*, + extended_type_description_service::ExtendedTypeDescriptionService, graph::Graph, msg::{ZMessage, ZService}, parameter::{ Parameter, ParameterDescriptor, ParameterValue, SetParametersResult, service::{ParameterService, ParameterServiceConfig}, }, - pubsub::{ZPub, ZPubBuilder, ZSub, ZSubBuilder}, + pubsub::{ZPubBuilder, ZSubBuilder}, service::{ZClientBuilder, ZServerBuilder}, }; @@ -50,6 +52,9 @@ pub struct ZNode { /// Enabled via `ZNodeBuilder::with_type_description_service()`. /// The service uses callback mode and requires no background task. type_desc_service: Option, + /// Optional ros-z-specific extended type description service. + /// Enabled via `ZNodeBuilder::with_extended_type_description_service()`. + extended_type_desc_service: Option, /// Parameter service providing ROS 2-compatible parameter management. /// Enabled by default; disable via `ZNodeBuilder::without_parameters()`. parameter_service: Option, @@ -77,6 +82,8 @@ pub struct ZNodeBuilder { pub(crate) keyexpr_format: ros_z_protocol::KeyExprFormat, /// Whether to enable the type description service for this node. pub(crate) enable_type_desc_service: bool, + /// Whether to enable the extended type description service for this node. + pub(crate) enable_extended_type_desc_service: bool, /// Whether to enable parameter services for this node (default: true). pub(crate) enable_parameters: bool, /// Initial parameter overrides applied at declaration time. @@ -146,6 +153,15 @@ impl ZNodeBuilder { self } + /// Enable the ros-z-specific extended type description service for this node. + /// + /// When enabled, the node exposes `~get_extended_type_description` for + /// extended-only schemas such as enums and `Option` fields. + pub fn with_extended_type_description_service(mut self) -> Self { + self.enable_extended_type_desc_service = true; + self + } + /// Disable the parameter services for this node. /// /// By default, every node exposes the standard ROS 2 parameter services @@ -248,6 +264,22 @@ impl Builder for ZNodeBuilder { None }; + let extended_type_desc_service = if self.enable_extended_type_desc_service { + debug!("[NOD] Creating extended type description service"); + let service = ExtendedTypeDescriptionService::new( + self.session.clone(), + &self.name, + &self.namespace, + id, + &self.counter, + &self.clock, + )?; + info!("[NOD] ExtendedTypeDescriptionService created"); + Some(service) + } else { + None + }; + // Create parameter service if enabled (default) let parameter_service = if self.enable_parameters { debug!("[NOD] Creating parameter service"); @@ -280,6 +312,7 @@ impl Builder for ZNodeBuilder { shm_config: self.shm_config, keyexpr_format: self.keyexpr_format, type_desc_service, + extended_type_desc_service, parameter_service, }) } @@ -304,32 +337,27 @@ impl ZNode { debug!("[NOD] Creating publisher: topic={}", topic); let mut builder = self.create_pub_impl(topic, Some(T::type_info())); - if let Some(service) = &self.type_desc_service { - match T::message_schema() { - Some(schema) => { - if let Err(e) = service.register_schema(schema.clone()) { - warn!( - "[NOD] Failed to register static schema {} with type description service: {}", - schema.type_name, e - ); - } else { - debug!( - "[NOD] Registered static schema {} with type description service", - schema.type_name - ); - } - - builder = builder.with_dyn_schema(schema); - } - None => { - debug!( - "[NOD] No static schema provided for {}, skipping type description registration", - std::any::type_name::() - ); - } + match T::message_schema() { + Some(schema) => { + self.register_schema_with_type_description_service(&schema); + builder = builder.with_dyn_schema(schema); + } + None => { + debug!( + "[NOD] No static schema provided for {}, skipping type description registration", + std::any::type_name::() + ); } } + if let Err(e) = T::register_type_extensions(self) { + warn!( + "[NOD] Failed to register non-standard schema extensions for {}: {}", + std::any::type_name::(), + e + ); + } + builder } @@ -346,11 +374,11 @@ impl ZNode { // to allow error handling in the Result type let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Publisher, topic: topic.to_string(), - kind: EntityKind::Publisher, type_info, - ..Default::default() + qos: Default::default(), }; ZPubBuilder { entity, @@ -394,11 +422,11 @@ impl ZNode { // to allow error handling in the Result type let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Subscription, topic: topic.to_string(), - kind: EntityKind::Subscription, type_info, - ..Default::default() + qos: Default::default(), }; ZSubBuilder { entity, @@ -475,11 +503,11 @@ impl ZNode { // to allow error handling in the Result type let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Service, topic: name.to_string(), - kind: EntityKind::Service, type_info, - ..Default::default() + qos: Default::default(), }; ZServerBuilder { entity, @@ -515,11 +543,11 @@ impl ZNode { // to allow error handling in the Result type let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Client, topic: name.to_string(), - kind: EntityKind::Client, type_info, - ..Default::default() + qos: Default::default(), }; ZClientBuilder { entity, @@ -553,7 +581,7 @@ impl ZNode { use zenoh::qos::CongestionControl; use crate::{ - entity::{EndpointEntity, EntityKind}, + entity::{EndpointEntity, EndpointKind}, topic_name, }; @@ -565,9 +593,9 @@ impl ZNode { let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Publisher, topic: qualified_topic.clone(), - kind: EntityKind::Publisher, type_info: Some(TypeInfo { name: type_name.to_string(), hash: TypeHash::from_rihs_string(type_hash).unwrap_or(TypeHash::zero()), @@ -626,7 +654,7 @@ impl ZNode { F: Fn(&[u8]) + Send + Sync + 'static, { use crate::{ - entity::{EndpointEntity, EntityKind}, + entity::{EndpointEntity, EndpointKind}, topic_name, }; @@ -638,9 +666,9 @@ impl ZNode { let entity = EndpointEntity { id: self.counter.increment(), - node: self.entity.clone(), + node: Some(self.entity.clone()), + kind: EndpointKind::Subscription, topic: qualified_topic.clone(), - kind: EntityKind::Subscription, type_info: Some(TypeInfo { name: type_name.to_string(), hash: TypeHash::from_rihs_string(type_hash).unwrap_or(TypeHash::zero()), @@ -696,6 +724,23 @@ impl ZNode { self.type_desc_service.is_some() } + /// Get a reference to this node's extended type description service, if enabled. + pub fn extended_type_description_service(&self) -> Option<&ExtendedTypeDescriptionService> { + self.extended_type_desc_service.as_ref() + } + + /// Get a mutable reference to this node's extended type description service, if enabled. + pub fn extended_type_description_service_mut( + &mut self, + ) -> Option<&mut ExtendedTypeDescriptionService> { + self.extended_type_desc_service.as_mut() + } + + /// Check if this node has an extended type description service. + pub fn has_extended_type_description_service(&self) -> bool { + self.extended_type_desc_service.is_some() + } + /// Get access to the global counter for entity ID generation. pub fn counter(&self) -> &Arc { &self.counter @@ -834,76 +879,42 @@ impl ZNode { /// .field("data", FieldType::String) /// .build()?; /// - /// let publisher = node.create_dyn_pub("chatter", schema)?; + /// let publisher = node.create_dyn_pub("chatter", schema).build()?; /// /// let mut msg = DynamicMessage::new(publisher.schema()); /// msg.set("data", "Hello, world!")?; /// publisher.publish(&msg)?; /// ``` - pub fn create_dyn_pub( + pub fn create_dyn_pub(&self, topic: &str, schema: Arc) -> DynPubBuilder { + self.register_schema_with_type_description_service(&schema); + self.create_dyn_pub_impl(topic, Some(schema_type_info(&schema)), schema) + } + + /// Discover the schema that publishers currently expose on a topic. + /// + /// The topic name is qualified according to the same ROS 2 rules as the + /// regular publisher and subscriber builder APIs. + pub async fn discover_topic_schema( &self, topic: &str, - schema: Arc, - ) -> Result> { - // Register schema with type description service if enabled - if let Some(service) = &self.type_desc_service { - if let Err(e) = service.register_schema(schema.clone()) { - warn!( - "[NOD] Failed to register schema {} with type description service: {}", - schema.type_name, e - ); - } else { - debug!( - "[NOD] Registered schema {} with type description service", - schema.type_name - ); - } - } - - // Create TypeInfo from schema for proper key expression matching - // Convert ROS 2 canonical name to DDS name - // "std_msgs/msg/String" → "std_msgs::msg::dds_::String_" - let dds_name = schema - .type_name - .replace("/msg/", "::msg::dds_::") - .replace("/srv/", "::srv::dds_::") - .replace("/action/", "::action::dds_::") - + "_"; - - // Compute type hash and convert to entity::TypeHash format - use crate::dynamic::MessageSchemaTypeDescription; - let type_hash = match schema.compute_type_hash() { - Ok(hash) => { - let rihs_string = hash.to_rihs_string(); - crate::entity::TypeHash::from_rihs_string(&rihs_string) - .unwrap_or_else(crate::entity::TypeHash::zero) - } - Err(e) => { - warn!( - "[NOD] Failed to compute type hash for {}: {}", - schema.type_name, e - ); - crate::entity::TypeHash::zero() - } - }; - - let type_info = Some(crate::entity::TypeInfo { - name: dds_name, - hash: type_hash, - }); - - // Build the publisher - self.create_pub_impl::(topic, type_info) - .with_serdes::() - .with_dyn_schema(schema) - .build() + discovery_timeout: Duration, + ) -> std::result::Result { + SchemaDiscovery::new(self, discovery_timeout) + .discover(topic) + .await } /// Create a dynamic subscriber with automatic schema discovery. /// /// This method queries publishers on the topic for their type description - /// and creates a subscriber using the discovered schema. This is useful - /// when you don't know the message type at compile time. + /// and returns a preconfigured subscriber builder using the discovered + /// schema. This is useful when you don't know the message type at compile + /// time. + /// + /// The topic name will be qualified according to ROS 2 rules: + /// - Absolute topics (starting with '/') are used as-is + /// - Private topics (starting with '~') are expanded to /// + /// - Relative topics are expanded to // /// /// # Arguments /// @@ -912,19 +923,19 @@ impl ZNode { /// /// # Returns /// - /// A tuple of (subscriber, schema) on success. The schema is returned - /// so you can use it to create messages or inspect the type. + /// A preconfigured dynamic subscriber builder on success. /// /// # Example /// /// ```ignore /// // Discover schema from publishers and create subscriber - /// let (subscriber, schema) = node.create_dyn_sub_auto( + /// let subscriber = node.create_dyn_sub_auto( /// "chatter", /// Duration::from_secs(5), - /// ).await?; + /// ).await? + /// .build()?; /// - /// println!("Discovered type: {}", schema.type_name); + /// println!("Discovered type: {}", subscriber.schema().unwrap().type_name); /// /// // Receive messages /// let msg = subscriber.recv()?; @@ -934,61 +945,27 @@ impl ZNode { &self, topic: &str, discovery_timeout: Duration, - ) -> Result<( - ZSub, - Arc, - )> { + ) -> Result { debug!( "[NOD] Creating dynamic subscriber with auto-discovery for topic: {}", topic ); - // Create a TypeDescriptionClient to discover the schema. - // Use a short per-attempt timeout (3 s) so that transient Zenoh routing - // delays can be recovered by the retry loop inside get_type_description_for_topic - // rather than burning the entire discovery_timeout on a single query. - let client = TypeDescriptionClient::with_graph( - self.session.clone(), - self.counter.clone(), - self.graph.clone(), - ) - .with_timeout(Duration::from_secs(3)); - - // Discover schema from topic publishers - let (schema, type_hash) = client - .get_type_description_for_topic(topic, discovery_timeout) + let discovered = self + .discover_topic_schema(topic, discovery_timeout) .await - .map_err(|e| zenoh::Error::from(format!("Schema discovery failed: {}", e)))?; + .map_err(|error| zenoh::Error::from(error.to_string()))?; info!( "[NOD] Discovered schema for topic {}: {} (hash: {})", - topic, schema.type_name, type_hash + discovered.qualified_topic, discovered.schema.type_name, discovered.type_hash ); - // Create TypeInfo from discovered schema for proper key expression matching - // Convert ROS 2 canonical name to DDS name - // "std_msgs/msg/String" → "std_msgs::msg::dds_::String_" - let dds_name = schema - .type_name - .replace("/msg/", "::msg::dds_::") - .replace("/srv/", "::srv::dds_::") - .replace("/action/", "::action::dds_::") - + "_"; - - let type_info = Some(crate::entity::TypeInfo { - name: dds_name, - hash: crate::entity::TypeHash::from_rihs_string(&type_hash) - .unwrap_or_else(crate::entity::TypeHash::zero), - }); - - // Build the subscriber with the discovered schema - let subscriber = self - .create_sub_impl::(topic, type_info) - .with_serdes::() - .with_dyn_schema(schema.clone()) - .build()?; - - Ok((subscriber, schema)) + Ok(self.create_dyn_sub_impl( + topic, + Some(discovered_schema_type_info(&discovered)), + discovered.schema, + )) } /// Create a dynamic subscriber with a known schema. @@ -1001,6 +978,11 @@ impl ZNode { /// * `topic` - The topic name to subscribe to /// * `schema` - The message schema for deserialization /// + /// The topic name will be qualified according to ROS 2 rules: + /// - Absolute topics (starting with '/') are used as-is + /// - Private topics (starting with '~') are expanded to /// + /// - Relative topics are expanded to // + /// /// # Example /// /// ```ignore @@ -1008,51 +990,49 @@ impl ZNode { /// .field("data", FieldType::String) /// .build()?; /// - /// let subscriber = node.create_dyn_sub("chatter", schema)?; + /// let subscriber = node.create_dyn_sub("chatter", schema).build()?; /// let msg = subscriber.recv()?; /// ``` - pub fn create_dyn_sub( + pub fn create_dyn_sub(&self, topic: &str, schema: Arc) -> DynSubBuilder { + self.create_dyn_sub_impl(topic, Some(schema_type_info(&schema)), schema) + } + + fn create_dyn_pub_impl( &self, topic: &str, + type_info: Option, schema: Arc, - ) -> Result> { - // Create TypeInfo from schema for proper key expression matching - // Convert ROS 2 canonical name to DDS name - // "std_msgs/msg/String" → "std_msgs::msg::dds_::String_" - let dds_name = schema - .type_name - .replace("/msg/", "::msg::dds_::") - .replace("/srv/", "::srv::dds_::") - .replace("/action/", "::action::dds_::") - + "_"; - - // Compute type hash and convert to entity::TypeHash format - use crate::dynamic::MessageSchemaTypeDescription; - let type_hash = match schema.compute_type_hash() { - Ok(hash) => { - let rihs_string = hash.to_rihs_string(); - crate::entity::TypeHash::from_rihs_string(&rihs_string) - .unwrap_or_else(crate::entity::TypeHash::zero) - } - Err(e) => { - warn!( - "[NOD] Failed to compute type hash for {}: {}", - schema.type_name, e - ); - crate::entity::TypeHash::zero() - } - }; - - let type_info = Some(crate::entity::TypeInfo { - name: dds_name, - hash: type_hash, - }); + ) -> DynPubBuilder { + self.create_pub_impl::(topic, type_info) + .with_serdes::() + .with_dyn_schema(schema) + } - // Build the subscriber with proper type info + fn create_dyn_sub_impl( + &self, + topic: &str, + type_info: Option, + schema: Arc, + ) -> DynSubBuilder { self.create_sub_impl::(topic, type_info) .with_serdes::() .with_dyn_schema(schema) - .build() + } + + fn register_schema_with_type_description_service(&self, schema: &Arc) { + if let Some(service) = &self.type_desc_service { + if let Err(error) = service.register_schema(Arc::clone(schema)) { + warn!( + "[NOD] Failed to register schema {} with type description service: {}", + schema.type_name, error + ); + } else { + debug!( + "[NOD] Registered schema {} with type description service", + schema.type_name + ); + } + } } } @@ -1062,11 +1042,14 @@ mod tests { #[test] fn test_node_entity_name_namespace() { - let entity = NodeEntity { - name: "my_node".to_string(), - namespace: "/my_ns".to_string(), - ..Default::default() - }; + let entity = NodeEntity::new( + 0, + "1234567890abcdef1234567890abcdef".parse().unwrap(), + 0, + "my_node".to_string(), + "/my_ns".to_string(), + String::new(), + ); assert_eq!(entity.name, "my_node"); assert_eq!(entity.namespace, "/my_ns"); } diff --git a/crates/ros-z/src/parameter/client.rs b/crates/ros-z/src/parameter/client.rs index e4bc7992..174e04cf 100644 --- a/crates/ros-z/src/parameter/client.rs +++ b/crates/ros-z/src/parameter/client.rs @@ -135,12 +135,12 @@ impl ParameterClient { /// Describe remote parameters by name. pub async fn describe(&self, names: &[impl AsRef]) -> Result> { - self.describe_client - .send_request(&DescribeParametersRequest { + let response = self + .describe_client + .call(&DescribeParametersRequest { names: names.iter().map(|name| name.as_ref().to_string()).collect(), }) .await?; - let response = self.describe_client.async_take_response().await?; Ok(response .descriptors .iter() @@ -150,12 +150,12 @@ impl ParameterClient { /// Fetch remote parameter values by name. pub async fn get(&self, names: &[impl AsRef]) -> Result> { - self.get_client - .send_request(&GetParametersRequest { + let response = self + .get_client + .call(&GetParametersRequest { names: names.iter().map(|name| name.as_ref().to_string()).collect(), }) .await?; - let response = self.get_client.async_take_response().await?; Ok(response .values .iter() @@ -165,12 +165,12 @@ impl ParameterClient { /// Fetch remote parameter types by name. pub async fn get_types(&self, names: &[impl AsRef]) -> Result> { - self.get_types_client - .send_request(&GetParameterTypesRequest { + let response = self + .get_types_client + .call(&GetParameterTypesRequest { names: names.iter().map(|name| name.as_ref().to_string()).collect(), }) .await?; - let response = self.get_types_client.async_take_response().await?; Ok(response .types .into_iter() @@ -184,8 +184,9 @@ impl ParameterClient { prefixes: &[impl AsRef], depth: Option, ) -> Result { - self.list_client - .send_request(&ListParametersRequest { + let response = self + .list_client + .call(&ListParametersRequest { prefixes: prefixes .iter() .map(|prefix| prefix.as_ref().to_string()) @@ -193,7 +194,6 @@ impl ParameterClient { depth: depth.unwrap_or(DEPTH_RECURSIVE), }) .await?; - let response = self.list_client.async_take_response().await?; Ok(ParameterList { names: response.result.names, prefixes: response.result.prefixes, @@ -202,12 +202,12 @@ impl ParameterClient { /// Set one or more remote parameters non-atomically. pub async fn set(&self, parameters: &[Parameter]) -> Result> { - self.set_client - .send_request(&SetParametersRequest { + let response = self + .set_client + .call(&SetParametersRequest { parameters: parameters.iter().map(Parameter::to_wire).collect(), }) .await?; - let response = self.set_client.async_take_response().await?; Ok(response .results .into_iter() @@ -220,12 +220,12 @@ impl ParameterClient { /// Set remote parameters atomically. pub async fn set_atomically(&self, parameters: &[Parameter]) -> Result { - self.set_atomically_client - .send_request(&SetParametersAtomicallyRequest { + let response = self + .set_atomically_client + .call(&SetParametersAtomicallyRequest { parameters: parameters.iter().map(Parameter::to_wire).collect(), }) .await?; - let response = self.set_atomically_client.async_take_response().await?; Ok(SetParametersResult { successful: response.result.successful, reason: response.result.reason, diff --git a/crates/ros-z/src/parameter/service.rs b/crates/ros-z/src/parameter/service.rs index 334eb3a6..fd2b1d9f 100644 --- a/crates/ros-z/src/parameter/service.rs +++ b/crates/ros-z/src/parameter/service.rs @@ -24,7 +24,7 @@ use crate::{ Builder, ServiceTypeInfo, attachment::Attachment, context::GlobalCounter, - entity::{EndpointEntity, EntityKind, NodeEntity, TypeInfo}, + entity::{EndpointEntity, EndpointKind, NodeEntity, TypeInfo}, msg::{SerdeCdrSerdes, ZDeserializer, ZSerializer}, pubsub::{ZPub, ZPubBuilder}, qos::{QosDurability, QosHistory, QosProfile, QosReliability}, @@ -284,11 +284,11 @@ impl ParameterService { let make_entity = |counter: &GlobalCounter, service_name: &str, type_info: TypeInfo| EndpointEntity { id: counter.increment(), - node: node_entity.clone(), - kind: EntityKind::Service, + node: Some(node_entity.clone()), + kind: EndpointKind::Service, topic: service_name.to_string(), type_info: Some(type_info), - ..Default::default() + qos: Default::default(), }; let ke_format = ros_z_protocol::KeyExprFormat::default(); @@ -296,8 +296,8 @@ impl ParameterService { // ── /parameter_events publisher ─────────────────────────────────────── let pub_entity = EndpointEntity { id: counter.increment(), - node: node_entity.clone(), - kind: EntityKind::Publisher, + node: Some(node_entity.clone()), + kind: EndpointKind::Publisher, topic: "/parameter_events".to_string(), type_info: Some(parameter_event_type_info()), qos: { diff --git a/crates/ros-z/src/prelude.rs b/crates/ros-z/src/prelude.rs index e90413cb..f0b76bec 100644 --- a/crates/ros-z/src/prelude.rs +++ b/crates/ros-z/src/prelude.rs @@ -24,7 +24,7 @@ pub use crate::Builder; pub use crate::context::{ZContext, ZContextBuilder}; pub use crate::node::ZNode; pub use crate::pubsub::{ZPub, ZSub}; -pub use crate::service::{QueryKey, ZClient, ZServer}; +pub use crate::service::{RequestId, ServiceReply, ServiceRequest, ZClient, ZServer}; pub use crate::action::server::{Accepted, Executing, Requested}; /// Action types. @@ -39,7 +39,10 @@ pub use crate::qos::{ pub use crate::action::ZAction; /// Trait bounds for custom messages and services. -pub use crate::ros_msg::{ActionTypeInfo, MessageTypeInfo, ServiceTypeInfo, WithTypeInfo}; +pub use crate::{ + ExtendedMessageTypeInfo, + ros_msg::{ActionTypeInfo, MessageTypeInfo, ServiceTypeInfo, WithTypeInfo}, +}; /// Type identity helpers for custom message definitions. pub use crate::entity::{TypeHash, TypeInfo}; diff --git a/crates/ros-z/src/pubsub.rs b/crates/ros-z/src/pubsub.rs index 4b109e5c..f6e75c15 100644 --- a/crates/ros-z/src/pubsub.rs +++ b/crates/ros-z/src/pubsub.rs @@ -102,7 +102,7 @@ impl ZPubBuilder { /// let provider = Arc::new(ShmProviderBuilder::new(20 * 1024 * 1024).build()?); /// let config = ShmConfig::new(provider).with_threshold(5_000); /// - /// let pub = node.create_pub::("topic") + /// let publisher = node.create_pub::("topic") /// .with_shm_config(config) /// .build()?; /// # Ok(()) @@ -126,7 +126,7 @@ impl ZPubBuilder { /// # let ctx = ros_z::context::ZContextBuilder::default().with_shm_enabled()?.build()?; /// # let node = ctx.create_node("test").build()?; /// // Context has SHM enabled, but disable for this publisher - /// let pub = node.create_pub::("small_messages") + /// let publisher = node.create_pub::("small_messages") /// .without_shm() /// .build()?; /// # Ok(()) @@ -172,7 +172,7 @@ impl ZPubBuilder { /// # let ctx = ros_z::context::ZContextBuilder::default().build()?; /// # let node = ctx.create_node("test").build()?; /// // Publish with Protobuf encoding - /// let pub = node.create_pub::("/topic") + /// let publisher = node.create_pub::("/topic") /// .with_encoding(Encoding::protobuf().with_schema("geometry_msgs/msg/Point")) /// .build()?; /// # Ok(()) @@ -199,35 +199,10 @@ impl ZPubBuilder { /// .build()?; /// ``` pub fn with_dyn_schema(mut self, schema: Arc) -> Self { - use crate::dynamic::MessageSchemaTypeDescription; - // Only compute and set type_info if it hasn't been set already // (e.g., from create_dyn_sub_auto which provides the publisher's hash) if self.entity.type_info.is_none() { - // Compute TypeInfo from schema for proper key expression matching with ROS 2 - // Convert ROS 2 canonical name to DDS name - // "std_msgs/msg/String" → "std_msgs::msg::dds_::String_" - let dds_name = schema - .type_name - .replace("/msg/", "::msg::dds_::") - .replace("/srv/", "::srv::dds_::") - .replace("/action/", "::action::dds_::") - + "_"; - - // Convert schema TypeHash to entity TypeHash via RIHS string - let type_hash = match schema.compute_type_hash() { - Ok(hash) => { - let rihs_string = hash.to_rihs_string(); - crate::entity::TypeHash::from_rihs_string(&rihs_string) - .unwrap_or_else(crate::entity::TypeHash::zero) - } - Err(_) => crate::entity::TypeHash::zero(), - }; - - self.entity.type_info = Some(crate::entity::TypeInfo { - name: dds_name, - hash: type_hash, - }); + self.entity.type_info = Some(crate::dynamic::schema_type_info(&schema)); } self.dyn_schema = Some(schema); @@ -248,13 +223,13 @@ where qos_durability = ?self.entity.qos.durability ))] fn build(mut self) -> Result { + let Some(node) = self.entity.node.as_ref() else { + return Err(zenoh::Error::from("publisher build requires node identity")); + }; // Qualify the topic name according to ROS 2 rules - let qualified_topic = topic_name::qualify_topic_name( - &self.entity.topic, - &self.entity.node.namespace, - &self.entity.node.name, - ) - .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; + let qualified_topic = + topic_name::qualify_topic_name(&self.entity.topic, &node.namespace, &node.name) + .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; self.entity.topic = qualified_topic.clone(); debug!("[PUB] Qualified topic: {}", qualified_topic); @@ -660,35 +635,10 @@ where /// .build()?; /// ``` pub fn with_dyn_schema(mut self, schema: Arc) -> Self { - use crate::dynamic::MessageSchemaTypeDescription; - // Only compute and set type_info if it hasn't been set already // (e.g., from create_dyn_sub_auto which provides the publisher's hash) if self.entity.type_info.is_none() { - // Compute TypeInfo from schema for proper key expression matching with ROS 2 - // Convert ROS 2 canonical name to DDS name - // "std_msgs/msg/String" → "std_msgs::msg::dds_::String_" - let dds_name = schema - .type_name - .replace("/msg/", "::msg::dds_::") - .replace("/srv/", "::srv::dds_::") - .replace("/action/", "::action::dds_::") - + "_"; - - // Convert schema TypeHash to entity TypeHash via RIHS string - let type_hash = match schema.compute_type_hash() { - Ok(hash) => { - let rihs_string = hash.to_rihs_string(); - crate::entity::TypeHash::from_rihs_string(&rihs_string) - .unwrap_or_else(crate::entity::TypeHash::zero) - } - Err(_) => crate::entity::TypeHash::zero(), - }; - - self.entity.type_info = Some(crate::entity::TypeInfo { - name: dds_name, - hash: type_hash, - }); + self.entity.type_info = Some(crate::dynamic::schema_type_info(&schema)); } self.dyn_schema = Some(schema); @@ -713,12 +663,14 @@ where where F: Fn(Sample) + Send + Sync + 'static, { - let qualified_topic = crate::topic_name::qualify_topic_name( - &self.entity.topic, - &self.entity.node.namespace, - &self.entity.node.name, - ) - .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; + let Some(node) = self.entity.node.as_ref() else { + return Err(zenoh::Error::from( + "subscriber build requires node identity", + )); + }; + let qualified_topic = + crate::topic_name::qualify_topic_name(&self.entity.topic, &node.namespace, &node.name) + .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; self.entity.topic = qualified_topic.clone(); debug!("[CACHE] Qualified topic: {}", qualified_topic); @@ -754,12 +706,14 @@ where where S: ZDeserializer, { - let qualified_topic = topic_name::qualify_topic_name( - &self.entity.topic, - &self.entity.node.namespace, - &self.entity.node.name, - ) - .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; + let Some(node) = self.entity.node.as_ref() else { + return Err(zenoh::Error::from( + "subscriber build requires node identity", + )); + }; + let qualified_topic = + topic_name::qualify_topic_name(&self.entity.topic, &node.namespace, &node.name) + .map_err(|e| zenoh::Error::from(format!("Failed to qualify topic: {}", e)))?; self.entity.topic = qualified_topic.clone(); debug!("[SUB] Qualified topic: {}", qualified_topic); @@ -1270,8 +1224,12 @@ mod tests { #[test] fn test_endpoint_entity_topic_field() { let entity = ros_z_protocol::entity::EndpointEntity { + id: 0, + node: None, + kind: ros_z_protocol::entity::EndpointKind::Publisher, topic: "/my_topic".to_string(), - ..Default::default() + type_info: None, + qos: Default::default(), }; assert_eq!(entity.topic, "/my_topic"); } diff --git a/crates/ros-z/src/ros_msg.rs b/crates/ros-z/src/ros_msg.rs index 0c6caa6a..720cec5b 100644 --- a/crates/ros-z/src/ros_msg.rs +++ b/crates/ros-z/src/ros_msg.rs @@ -47,6 +47,15 @@ pub trait MessageTypeInfo { None } + /// Register any non-standard schema discovery hooks for this type on the node. + /// + /// Core ros-z keeps the standard type-description path separate, so the + /// default implementation is a no-op. Extended schema derives override this + /// to register with ros-z's parallel extended type description service. + fn register_type_extensions(_node: &crate::node::ZNode) -> std::result::Result<(), String> { + Ok(()) + } + // === Dynamic Methods (Runtime) === /// Returns the ROS message type name at runtime diff --git a/crates/ros-z/src/service.rs b/crates/ros-z/src/service.rs index 39a15a25..7d6777fc 100644 --- a/crates/ros-z/src/service.rs +++ b/crates/ros-z/src/service.rs @@ -1,19 +1,15 @@ #![allow(unused)] use std::{ - collections::HashMap, marker::PhantomData, - sync::{Arc, atomic::AtomicUsize}, + sync::{Arc, Mutex, atomic::AtomicUsize}, time::Duration, }; use serde::Deserialize; use tracing::{debug, error, info, trace, warn}; use zenoh::{ - Result, Session, Wait, bytes, - key_expr::KeyExpr, - liveliness::LivelinessToken, - query::{Query, Reply}, + Result, Session, Wait, bytes, key_expr::KeyExpr, liveliness::LivelinessToken, query::Query, sample::Sample, }; @@ -45,13 +41,10 @@ pub struct ZClientBuilder { impl_with_type_info!(ZClientBuilder); impl_with_type_info!(ZServerBuilder); -/// A ROS 2-style service client that sends typed requests and receives typed responses. +/// A ROS 2-style reusable service handle for typed request/response calls. /// /// Create a client via [`ZNode::create_client`](crate::node::ZNode::create_client). -/// Send a request with [`send_request`](ZClient::send_request) (async), then retrieve -/// the response with [`take_response`](ZClient::take_response) (non-blocking), -/// [`take_response_timeout`](ZClient::take_response_timeout) (waits up to a deadline), -/// or [`async_take_response`](ZClient::async_take_response) (async wait). +/// Invoke the service with [`call`](ZClient::call) or [`call_or_timeout`](ZClient::call_or_timeout). /// /// # Example /// @@ -60,8 +53,7 @@ impl_with_type_info!(ZServerBuilder); /// use std::time::Duration; /// /// // client: ZClient -/// client.send_request(&request).await?; -/// let response = client.take_response_timeout(Duration::from_secs(5))?; +/// let response = client.call_or_timeout(&request, Duration::from_secs(5)).await?; /// ``` pub struct ZClient { // TODO: replace this with the sample sn @@ -70,10 +62,12 @@ pub struct ZClient { gid: GidArray, inner: zenoh::query::Querier<'static>, lv_token: LivelinessToken, - tx: flume::Sender, - pub(crate) rx: flume::Receiver, topic: String, clock: crate::time::ZClock, + #[cfg(feature = "rmw")] + completed_tx: flume::Sender, + #[cfg(feature = "rmw")] + completed_rx: flume::Receiver, _phantom_data: PhantomData, } @@ -95,13 +89,13 @@ where service = %self.entity.topic ))] fn build(mut self) -> Result { + let Some(node) = self.entity.node.as_ref() else { + return Err(zenoh::Error::from("client build requires node identity")); + }; // Qualify the service name according to ROS 2 rules - let qualified_service = topic_name::qualify_service_name( - &self.entity.topic, - &self.entity.node.namespace, - &self.entity.node.name, - ) - .map_err(|e| zenoh::Error::from(format!("Failed to qualify service: {}", e)))?; + let qualified_service = + topic_name::qualify_service_name(&self.entity.topic, &node.namespace, &node.name) + .map_err(|e| zenoh::Error::from(format!("Failed to qualify service: {}", e)))?; self.entity.topic = qualified_service.clone(); debug!("[CLN] Qualified service: {}", qualified_service); @@ -125,12 +119,14 @@ where .liveliness() .declare_token((*lv_ke).clone()) .wait()?; - // Use bounded channel based on QoS depth - let depth = match self.entity.qos.history { - ros_z_protocol::qos::QosHistory::KeepLast(n) => n, - ros_z_protocol::qos::QosHistory::KeepAll => 1000, // Default reasonable limit for KeepAll + #[cfg(feature = "rmw")] + let (completed_tx, completed_rx) = { + let depth = match self.entity.qos.history { + ros_z_protocol::qos::QosHistory::KeepLast(n) => n, + ros_z_protocol::qos::QosHistory::KeepAll => 1000, + }; + flume::bounded(depth) }; - let (tx, rx) = flume::bounded(depth); debug!("[CLN] Client ready: service={}", self.entity.topic); Ok(ZClient { @@ -138,10 +134,12 @@ where inner, lv_token, gid: crate::entity::endpoint_gid(&self.entity), - tx, - rx, topic: self.entity.topic.clone(), clock: self.clock, + #[cfg(feature = "rmw")] + completed_tx, + #[cfg(feature = "rmw")] + completed_rx, _phantom_data: Default::default(), }) } @@ -159,69 +157,73 @@ where ) } - /// Access the raw response receiver channel. - pub fn rx(&self) -> &flume::Receiver { - &self.rx - } + async fn call_sample(&self, payload: impl Into) -> Result { + let attachment = self.new_attachment(); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + let response_tx = Arc::new(Mutex::new(Some(response_tx))); - pub fn take_sample(&self) -> Result { - match self.rx.try_recv() { - Ok(sample) => Ok(sample), - Err(flume::TryRecvError::Empty) => Err("No sample available".into()), - Err(flume::TryRecvError::Disconnected) => Err("Channel disconnected".into()), - } - } + self.inner + .get() + .payload(payload) + .attachment(attachment) + .callback(move |reply| match reply.into_result() { + Ok(sample) => { + let sender = response_tx + .lock() + .expect("service reply sender mutex poisoned") + .take(); + match sender { + Some(sender) => { + if sender.send(sample).is_err() { + tracing::warn!( + "Service call receiver dropped before reply delivery" + ); + } + } + None => { + tracing::warn!("Service call received extra reply after completion"); + } + } + } + Err(error) => { + tracing::debug!("Service reply error: {error:?}"); + } + }) + .await?; - pub fn take_sample_timeout(&self, timeout: Duration) -> Result { - Ok(self.rx.recv_timeout(timeout)?) - } + let sample = response_rx.await.map_err(|_| { + zenoh::Error::from("Service call ended before any response was received") + })?; - /// Retrieve the next response without blocking. - /// - /// Returns `Err` immediately if no response has arrived yet. Use - /// [`take_response_timeout`](ZClient::take_response_timeout) to wait up to a - /// deadline, or [`async_take_response`](ZClient::async_take_response) to await - /// indefinitely in an async context. - // For ROS-Z - pub fn take_response(&self) -> Result - where - T::Response: ZMessage, - for<'a> ::Serdes: - ZDeserializer = &'a [u8]>, - { - let sample = self.take_sample()?; - let msg = ::deserialize(&sample.payload().to_bytes()) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - Ok(msg) + Ok(sample) } - /// Wait for the next response, up to `timeout`. Returns `Err` if no response - /// arrives within the deadline. - pub fn take_response_timeout(&self, timeout: Duration) -> Result + /// Call the service and wait indefinitely for the first reply. + pub async fn call(&self, msg: &T::Request) -> Result where + T::Request: ZMessage, T::Response: ZMessage, for<'a> ::Serdes: ZDeserializer = &'a [u8]>, { - let sample = self.take_sample_timeout(timeout)?; + let sample = self.call_sample(msg.serialize()).await?; let payload_bytes = sample.payload().to_bytes(); let msg = ::deserialize(&payload_bytes[..]) .map_err(|e| zenoh::Error::from(e.to_string()))?; Ok(msg) } - /// Asynchronously wait for the next response. Awaits indefinitely until a - /// response arrives or the channel is disconnected. - pub async fn async_take_response(&self) -> Result + + /// Call the service and fail if no reply arrives before `timeout` elapses. + pub async fn call_or_timeout(&self, msg: &T::Request, timeout: Duration) -> Result where + T::Request: ZMessage, T::Response: ZMessage, for<'a> ::Serdes: ZDeserializer = &'a [u8]>, { - let sample = self.rx.recv_async().await?; - let payload_bytes = sample.payload().to_bytes(); - let msg = ::deserialize(&payload_bytes[..]) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - Ok(msg) + tokio::time::timeout(timeout, self.call(msg)) + .await + .map_err(|_| zenoh::Error::from(format!("Service call timed out after {timeout:?}")))? } } @@ -229,66 +231,17 @@ impl ZClient where T: ZService, { - /// Send a typed request to the service server. - /// - /// This is an `async fn` — it must be `.await`ed. The call resolves once the - /// Zenoh query is dispatched; it does **not** wait for a response. Retrieve the - /// response separately with [`take_response`](ZClient::take_response), - /// [`take_response_timeout`](ZClient::take_response_timeout), or - /// [`async_take_response`](ZClient::async_take_response). - /// - /// Succeeds even when no server is running (fire-and-forget dispatch). - #[tracing::instrument(name = "send_request", skip(self, msg), fields( + #[cfg(feature = "rmw")] + #[tracing::instrument(name = "rmw_send_request", skip(self, msg, notify), fields( service = %self.topic, sn = self.sn.load(Ordering::Acquire), payload_len = tracing::field::Empty ))] - pub async fn send_request(&self, msg: &T::Request) -> Result<()> { - let payload = msg.serialize(); - tracing::Span::current().record("payload_len", payload.len()); - - // Log the key expression being queried - let query_ke = self.inner.key_expr(); - info!("[CLN] Sending request to key expression: {}", query_ke); - debug!("[CLN] Sending request"); - - let tx = self.tx.clone(); - self.inner - .get() - .payload(payload) - .attachment(self.new_attachment()) - .callback(move |reply| { - match reply.into_result() { - Ok(sample) => { - info!( - "[CLN] Reply received: len={}, kind={:?}", - sample.payload().len(), - sample.kind() - ); - debug!("[CLN] Reply received: len={}", sample.payload().len()); - // Use try_send for bounded channel - if full, drop the response (QoS depth enforcement) - if tx.try_send(sample).is_err() { - tracing::warn!( - "Client response queue full, dropping response (QoS depth enforced)" - ); - } - } - Err(e) => { - warn!("[CLN] Reply error: {:?}", e); - } - } - }) - .await?; - - Ok(()) - } - - #[cfg(feature = "rmw")] pub fn rmw_send_request(&self, msg: &T::Request, notify: F) -> Result where F: Fn() + Send + Sync + 'static, { - let tx = self.tx.clone(); + let completed_tx = self.completed_tx.clone(); let attachment = self.new_attachment(); let sn = attachment.sequence_number; self.inner @@ -298,8 +251,7 @@ where .callback(move |reply| { match reply.into_result() { Ok(sample) => { - // Use try_send for bounded channel - if full, drop the response (QoS depth enforcement) - if tx.try_send(sample).is_err() { + if completed_tx.try_send(sample).is_err() { tracing::warn!( "Client response queue full, dropping response (QoS depth enforced)" ); @@ -316,6 +268,22 @@ where .wait()?; Ok(sn) } + + #[cfg(feature = "rmw")] + pub fn rmw_try_take_response_sample(&self) -> Result> { + match self.completed_rx.try_recv() { + Ok(sample) => Ok(Some(sample)), + Err(flume::TryRecvError::Empty) => Ok(None), + Err(flume::TryRecvError::Disconnected) => { + Err(zenoh::Error::from("Client response channel disconnected")) + } + } + } + + #[cfg(feature = "rmw")] + pub fn rmw_has_responses(&self) -> bool { + !self.completed_rx.is_empty() + } } #[derive(Debug)] @@ -354,17 +322,11 @@ impl ZServerBuilder { } pub struct ZServer { - // NOTE: This is biased toward RMW key_expr: KeyExpr<'static>, - // TODO: replace this with the sample sn - sn: AtomicUsize, - // TODO: replace this with zenoh's global entity id - gid: GidArray, inner: zenoh::query::Queryable<()>, lv_token: LivelinessToken, clock: crate::time::ZClock, pub(crate) queue: Option>>, - pub(crate) map: HashMap, _phantom_data: PhantomData, } @@ -396,16 +358,6 @@ where pub fn try_queue(&self) -> Option<&Arc>> { self.queue.as_ref() } - - /// Number of pending queries stored in the response map. - pub fn map_len(&self) -> usize { - self.map.len() - } - - /// Insert a query into the response map, returning any previously stored query for that key. - pub fn map_insert(&mut self, key: QueryKey, query: Query) -> Option { - self.map.insert(key, query) - } } impl ZServerBuilder @@ -418,12 +370,12 @@ where handler: DataHandler, queue: Option>>, ) -> Result> { - let qualified_service = topic_name::qualify_service_name( - &self.entity.topic, - &self.entity.node.namespace, - &self.entity.node.name, - ) - .map_err(|e| zenoh::Error::from(format!("Failed to qualify service: {}", e)))?; + let Some(node) = self.entity.node.as_ref() else { + return Err(zenoh::Error::from("service build requires node identity")); + }; + let qualified_service = + topic_name::qualify_service_name(&self.entity.topic, &node.namespace, &node.name) + .map_err(|e| zenoh::Error::from(format!("Failed to qualify service: {}", e)))?; self.entity.topic = qualified_service; @@ -472,13 +424,10 @@ where Ok(ZServer { key_expr, - sn: AtomicUsize::new(1), // Start at 1 for ROS compatibility inner, lv_token, clock: self.clock, - gid: crate::entity::endpoint_gid(&self.entity), queue, - map: HashMap::new(), _phantom_data: Default::default(), }) } @@ -527,36 +476,94 @@ where } #[derive(Debug, PartialEq, Eq, Hash, Clone)] -pub struct QueryKey { - pub sn: i64, - pub gid: GidArray, +pub struct RequestId { + pub sequence_number: i64, + pub writer_guid: GidArray, } -impl From for QueryKey { +impl From for RequestId { fn from(value: Attachment) -> Self { Self { - sn: value.sequence_number, - gid: value.source_gid, + sequence_number: value.sequence_number, + writer_guid: value.source_gid, } } } +pub struct ServiceReply { + request_id: RequestId, + key_expr: KeyExpr<'static>, + query: Query, + clock: crate::time::ZClock, + _phantom_data: PhantomData, +} + +impl ServiceReply { + pub fn id(&self) -> &RequestId { + &self.request_id + } + + pub fn reply_blocking(self, msg: &T::Response) -> Result<()> { + let attachment = Attachment::with_clock( + self.request_id.sequence_number, + self.request_id.writer_guid, + &self.clock, + ); + self.query + .reply(&self.key_expr, msg.serialize()) + .attachment(attachment) + .wait() + } + + pub async fn reply(self, msg: &T::Response) -> Result<()> { + let attachment = Attachment::with_clock( + self.request_id.sequence_number, + self.request_id.writer_guid, + &self.clock, + ); + self.query + .reply(&self.key_expr, msg.serialize()) + .attachment(attachment) + .await + } +} + +pub struct ServiceRequest { + message: T::Request, + reply: ServiceReply, +} + +impl ServiceRequest { + pub fn id(&self) -> &RequestId { + self.reply.id() + } + + pub fn message(&self) -> &T::Request { + &self.message + } + + pub fn into_message(self) -> T::Request { + self.message + } + + pub fn into_parts(self) -> (T::Request, ServiceReply) { + (self.message, self.reply) + } + + pub fn reply_blocking(self, response: &T::Response) -> Result<()> { + self.reply.reply_blocking(response) + } + + pub async fn reply(self, response: &T::Response) -> Result<()> { + self.reply.reply(response).await + } +} + impl ZServer where T: ZService, { - fn new_attachment(&self) -> Attachment { - Attachment::with_clock( - self.sn.fetch_add(1, Ordering::AcqRel) as _, - self.gid, - &self.clock, - ) - } - - /// Retrieve the next query on the service without deserializing the payload. - /// - /// This method is useful when custom deserialization logic is needed. - pub fn take_query(&self) -> Result { + fn take_query(&self) -> Result { let queue = self.queue.as_ref().ok_or_else(|| { zenoh::Error::from("Server was built with callback, no queue available") })?; @@ -565,6 +572,52 @@ where .ok_or_else(|| zenoh::Error::from("No query available")) } + fn decode_request(&self, query: Query) -> Result> + where + T::Request: ZMessage + Send + Sync + 'static, + for<'a> ::Serdes: + ZDeserializer = &'a [u8]>, + { + let attachment_bytes = query + .attachment() + .ok_or_else(|| zenoh::Error::from("Service request missing attachment"))?; + let attachment: Attachment = attachment_bytes.try_into()?; + let request_id: RequestId = attachment.into(); + + let payload_bytes = query + .payload() + .map(|payload| payload.to_bytes()) + .unwrap_or_default(); + let message = ::deserialize(&payload_bytes[..]) + .map_err(|e| zenoh::Error::from(e.to_string()))?; + + Ok(ServiceRequest { + message, + reply: ServiceReply { + request_id, + key_expr: self.key_expr.clone(), + query, + clock: self.clock.clone(), + _phantom_data: PhantomData, + }, + }) + } + + pub fn try_take_request(&mut self) -> Result>> + where + T::Request: ZMessage + Send + Sync + 'static, + for<'a> ::Serdes: + ZDeserializer = &'a [u8]>, + { + let queue = self.queue.as_ref().ok_or_else(|| { + zenoh::Error::from("Server was built with callback, no queue available") + })?; + match queue.try_recv() { + Some(query) => self.decode_request(query).map(Some), + None => Ok(None), + } + } + /// Blocks waiting to receive the next request on the service and then deserializes the payload. /// /// This method may fail if the message does not deserialize as the requested type. @@ -573,7 +626,7 @@ where sn = tracing::field::Empty, payload_len = tracing::field::Empty ))] - pub fn take_request(&mut self) -> Result<(QueryKey, T::Request)> + pub fn take_request(&mut self) -> Result> where T::Request: ZMessage + Send + Sync + 'static, for<'a> ::Serdes: @@ -585,32 +638,13 @@ where zenoh::Error::from("Server was built with callback, no queue available") })?; let query = queue.recv(); - let attachment: Attachment = query.attachment().unwrap().try_into()?; - let key: QueryKey = attachment.into(); - - tracing::Span::current().record("sn", key.sn); - - let payload_bytes = query.payload().unwrap().to_bytes(); - tracing::Span::current().record("payload_len", payload_bytes.len()); - - if self.map.contains_key(&key) { - warn!("[SRV] Duplicate request: sn={}", key.sn); - return Err("Existing query detected".into()); - } - - debug!("[SRV] Processing request"); - - let msg = ::deserialize(&payload_bytes[..]) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - self.map.insert(key.clone(), query); - - Ok((key, msg)) + self.decode_request(query) } /// Awaits the next request on the service and then deserializes the payload. /// /// This method may fail if the message does not deserialize as the requested type. - pub async fn async_take_request(&mut self) -> Result<(QueryKey, T::Request)> + pub async fn async_take_request(&mut self) -> Result> where T::Request: ZMessage + Send + Sync + 'static, for<'a> ::Serdes: @@ -620,70 +654,7 @@ where zenoh::Error::from("Server was built with callback, no queue available") })?; let query = queue.recv_async().await; - let attachment: Attachment = query.attachment().unwrap().try_into()?; - let key: QueryKey = attachment.into(); - if self.map.contains_key(&key) { - return Err("Existing query detected".into()); - } - let payload_bytes = query.payload().unwrap().to_bytes(); - let msg = ::deserialize(&payload_bytes[..]) - .map_err(|e| zenoh::Error::from(e.to_string()))?; - self.map.insert(key.clone(), query); - - Ok((key, msg)) - } - - /// Blocks sending the response to a service request. - /// - /// - `msg` is the response message to send. - /// - `key` is the query key of the request to reply to and is obtained from [take_request](Self::take_request) or [async_take_request](Self::async_take_request) - #[tracing::instrument(name = "send_response", skip(self, msg), fields( - service = %self.key_expr, - sn = %key.sn, - payload_len = tracing::field::Empty - ))] - pub fn send_response(&mut self, msg: &T::Response, key: &QueryKey) -> Result<()> { - debug!( - "[SRV] Looking for query with key sn:{}, gid:{:?}", - key.sn, key.gid - ); - match self.map.remove(key) { - Some(query) => { - let payload = msg.serialize(); - tracing::Span::current().record("payload_len", payload.len()); - - debug!("[SRV] Sending response"); - - // Use the sequence number and GID from the request - let attachment = Attachment::with_clock(key.sn, key.gid, &self.clock); - query - .reply(&self.key_expr, payload) - .attachment(attachment) - .wait() - } - None => { - error!("[SRV] No query found for sn={}", key.sn); - Err("Query map doesn't contain key".into()) - } - } - } - - /// Awaits sending the response to a service request. - /// - /// - `msg` is the response message to send. - /// - `key` is the query key of the request to reply to and is obtained from [take_request](Self::take_request) or [async_take_request](Self::async_take_request) - pub async fn async_send_response(&mut self, msg: &T::Response, key: &QueryKey) -> Result<()> { - match self.map.remove(key) { - Some(query) => { - // Use the sequence number and GID from the request - let attachment = Attachment::with_clock(key.sn, key.gid, &self.clock); - query - .reply(&self.key_expr, msg.serialize()) - .attachment(attachment) - .await - } - None => Err("Quey map doesn't contains {key}".into()), - } + self.decode_request(query) } } @@ -752,14 +723,14 @@ mod tests { } #[test] - fn test_query_key_clone_and_eq() { - let key = crate::service::QueryKey { - gid: [1u8; 16], - sn: 42, + fn test_request_id_clone_and_eq() { + let key = crate::service::RequestId { + writer_guid: [1u8; 16], + sequence_number: 42, }; let key2 = key.clone(); - assert_eq!(key.sn, key2.sn); - assert_eq!(key.gid, key2.gid); + assert_eq!(key.sequence_number, key2.sequence_number); + assert_eq!(key.writer_guid, key2.writer_guid); } #[test] diff --git a/crates/ros-z/src/shm.rs b/crates/ros-z/src/shm.rs index ee3645de..84ab38f9 100644 --- a/crates/ros-z/src/shm.rs +++ b/crates/ros-z/src/shm.rs @@ -49,7 +49,7 @@ //! let provider = Arc::new(ShmProviderBuilder::new(20_000_000).build()?); //! let config = ShmConfig::new(provider).with_threshold(10_000); //! -//! let pub = node.create_pub::("topic") +//! let publisher = node.create_pub::("topic") //! .with_shm_config(config) //! .build()?; //! # Ok(()) @@ -128,6 +128,7 @@ impl ShmConfig { /// /// ```rust,no_run /// use ros_z::shm::{ShmConfig, ShmProviderBuilder}; + /// use ros_z::Builder; /// use std::sync::Arc; /// /// # fn main() -> ros_z::Result<()> { diff --git a/crates/ros-z/tests/extended_message_type_info.rs b/crates/ros-z/tests/extended_message_type_info.rs new file mode 100644 index 00000000..65a5f802 --- /dev/null +++ b/crates/ros-z/tests/extended_message_type_info.rs @@ -0,0 +1,383 @@ +use std::time::Duration; + +use ros_z::{ + Builder, ExtendedMessageTypeInfo, MessageTypeInfo, + context::ZContextBuilder, + dynamic::{DynamicValue, EnumPayloadValue}, +}; +use serde::{Deserialize, Serialize}; +use zenoh::Wait; +use zenoh::config::WhatAmI; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ros_z::ExtendedMessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/TelemetryLite")] +struct TelemetryLite { + label: String, + temperatures: Vec, +} + +impl ros_z::msg::ZMessage for TelemetryLite { + type Serdes = ros_z::msg::SerdeCdrSerdes; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ros_z::ExtendedMessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/RobotState")] +enum RobotState { + Idle, + Error(String), + Charging { minutes_remaining: u32 }, +} + +impl ros_z::msg::ZMessage for RobotState { + type Serdes = ros_z::msg::SerdeCdrSerdes; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ros_z::ExtendedMessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/RobotEnvelope")] +struct RobotEnvelope { + label: String, + mission_id: Option, + state: RobotState, +} + +impl ros_z::msg::ZMessage for RobotEnvelope { + type Serdes = ros_z::msg::SerdeCdrSerdes; +} + +struct TestRouter { + endpoint: String, + _session: zenoh::Session, +} + +impl TestRouter { + fn new() -> Self { + let port = { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind port 0"); + listener.local_addr().unwrap().port() + }; + + let endpoint = format!("tcp/127.0.0.1:{port}"); + let mut config = zenoh::Config::default(); + config.set_mode(Some(WhatAmI::Router)).unwrap(); + config + .insert_json5("listen/endpoints", &format!("[\"{endpoint}\"]")) + .unwrap(); + config + .insert_json5("scouting/multicast/enabled", "false") + .unwrap(); + + let session = zenoh::open(config) + .wait() + .expect("failed to open test router"); + std::thread::sleep(Duration::from_millis(300)); + + Self { + endpoint, + _session: session, + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } +} + +fn create_context_with_router(router: &TestRouter) -> ros_z::Result { + ZContextBuilder::default() + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .build() +} + +#[test] +fn extended_derive_keeps_standard_schema_for_compatible_structs() { + let schema = TelemetryLite::message_schema().expect("standard-compatible schema"); + assert!(!schema.uses_extended_types()); + assert_eq!(schema.type_name, "custom_msgs/msg/TelemetryLite"); + + let extended = TelemetryLite::extended_message_schema(); + assert_eq!(extended.type_name, schema.type_name); + assert!(!extended.uses_extended_types()); + + assert!( + RobotEnvelope::message_schema().is_none(), + "extended-only structs should not expose a standard schema" + ); + assert!( + RobotState::message_schema().is_none(), + "extended enums should not expose a standard schema" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fallback_discovery_uses_standard_service_for_compatible_extended_types() { + let router = TestRouter::new(); + + let pub_ctx = create_context_with_router(&router).expect("publisher context"); + let pub_node = pub_ctx + .create_node("telemetry_talker") + .with_type_description_service() + .build() + .expect("publisher node"); + + let publisher = pub_node + .create_pub::("/extended_standard_topic") + .build() + .expect("publisher"); + + let registered = pub_node + .type_description_service() + .expect("standard type description service") + .get_schema("custom_msgs/msg/TelemetryLite") + .expect("schema lookup"); + assert!( + registered.is_some(), + "standard-compatible schema should register" + ); + + let sub_ctx = create_context_with_router(&router).expect("subscriber context"); + let sub_node = sub_ctx + .create_node("telemetry_listener") + .build() + .expect("subscriber node"); + + let publish_task = tokio::spawn(async move { + for _ in 0..20 { + let msg = TelemetryLite { + label: "robot-7".to_string(), + temperatures: vec![20.0, 20.5], + }; + publisher.publish(&msg).expect("publish"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let subscriber = sub_node + .create_dyn_sub_auto("/extended_standard_topic", Duration::from_secs(10)) + .await + .expect("fallback dynamic subscriber") + .build() + .expect("subscriber build"); + let schema = subscriber.schema().expect("discovered schema"); + + assert_eq!(schema.type_name, "custom_msgs/msg/TelemetryLite"); + assert!(!schema.uses_extended_types()); + + let message = subscriber + .recv_timeout(Duration::from_secs(3)) + .expect("dynamic message"); + assert_eq!( + message.get::("label").unwrap(), + "robot-7".to_string() + ); + + publish_task.await.expect("publisher task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn extended_only_types_require_explicit_extended_service_enablement() { + let router = TestRouter::new(); + + let pub_ctx = create_context_with_router(&router).expect("publisher context"); + let pub_node = pub_ctx + .create_node("extended_talker") + .build() + .expect("publisher node"); + + let publisher = pub_node + .create_pub::("/extended_robot_topic") + .build() + .expect("publisher"); + + let sub_ctx = create_context_with_router(&router).expect("subscriber context"); + let sub_node = sub_ctx + .create_node("extended_listener") + .build() + .expect("subscriber node"); + + let publish_task = tokio::spawn(async move { + for _ in 0..20 { + let msg = RobotEnvelope { + label: "robot-9".to_string(), + mission_id: Some(42), + state: RobotState::Charging { + minutes_remaining: 12, + }, + }; + publisher.publish(&msg).expect("publish"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let result = sub_node + .create_dyn_sub_auto("/extended_robot_topic", Duration::from_secs(3)) + .await; + assert!( + result.is_err(), + "extended discovery should fail when the publisher did not enable the extended service" + ); + + publish_task.await.expect("publisher task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn extended_only_types_use_extended_service_when_enabled() { + let router = TestRouter::new(); + + let pub_ctx = create_context_with_router(&router).expect("publisher context"); + let pub_node = pub_ctx + .create_node("extended_talker") + .with_extended_type_description_service() + .build() + .expect("publisher node"); + + let publisher = pub_node + .create_pub::("/extended_robot_topic") + .build() + .expect("publisher"); + + assert!( + pub_node.type_description_service().is_none(), + "extended-only discovery should not require the standard type description service" + ); + let extended_schema = pub_node + .extended_type_description_service() + .expect("extended type description service") + .get_schema("custom_msgs/msg/RobotEnvelope") + .expect("schema lookup"); + assert!(extended_schema.is_some(), "extended schema should register"); + + let sub_ctx = create_context_with_router(&router).expect("subscriber context"); + let sub_node = sub_ctx + .create_node("extended_listener") + .build() + .expect("subscriber node"); + + let publish_task = tokio::spawn(async move { + for _ in 0..20 { + let msg = RobotEnvelope { + label: "robot-9".to_string(), + mission_id: Some(42), + state: RobotState::Charging { + minutes_remaining: 12, + }, + }; + publisher.publish(&msg).expect("publish"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let subscriber = sub_node + .create_dyn_sub_auto("/extended_robot_topic", Duration::from_secs(10)) + .await + .expect("extended fallback subscriber") + .build() + .expect("subscriber build"); + let schema = subscriber.schema().expect("discovered schema"); + + assert!(schema.uses_extended_types()); + assert_eq!(schema.type_name, "custom_msgs/msg/RobotEnvelope"); + + let message = subscriber + .recv_timeout(Duration::from_secs(3)) + .expect("dynamic message"); + assert_eq!( + message.get::("label").unwrap(), + "robot-9".to_string() + ); + + match message.get_dynamic("mission_id").expect("mission_id") { + DynamicValue::Optional(Some(value)) => { + assert_eq!(value.as_ref().as_u32(), Some(42)); + } + other => panic!("expected Some mission_id, got {other:?}"), + } + + match message.get_dynamic("state").expect("state") { + DynamicValue::Enum(value) => { + assert_eq!(value.variant_name, "Charging"); + match value.payload { + EnumPayloadValue::Struct(fields) => { + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].name, "minutes_remaining"); + assert_eq!(fields[0].value.as_u32(), Some(12)); + } + other => panic!("expected struct payload, got {other:?}"), + } + } + other => panic!("expected enum value, got {other:?}"), + } + + publish_task.await.expect("publisher task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn top_level_enums_are_discoverable_through_the_extended_service() { + let router = TestRouter::new(); + + let pub_ctx = create_context_with_router(&router).expect("publisher context"); + let pub_node = pub_ctx + .create_node("state_talker") + .with_extended_type_description_service() + .build() + .expect("publisher node"); + + let publisher = pub_node + .create_pub::("/robot_state_topic") + .build() + .expect("publisher"); + + let sub_ctx = create_context_with_router(&router).expect("subscriber context"); + let sub_node = sub_ctx + .create_node("state_listener") + .build() + .expect("subscriber node"); + + let publish_task = tokio::spawn(async move { + for _ in 0..20 { + publisher + .publish(&RobotState::Error("battery low".to_string())) + .expect("publish"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let subscriber = sub_node + .create_dyn_sub_auto("/robot_state_topic", Duration::from_secs(10)) + .await + .expect("extended enum discovery") + .build() + .expect("subscriber build"); + let schema = subscriber.schema().expect("discovered schema"); + + assert_eq!(schema.field_count(), 1); + assert_eq!(schema.field("value").unwrap().name, "value"); + + let message = subscriber + .recv_timeout(Duration::from_secs(3)) + .expect("dynamic message"); + + match message.get_dynamic("value").expect("enum value") { + DynamicValue::Enum(value) => { + assert_eq!(value.variant_name, "Error"); + match value.payload { + EnumPayloadValue::Newtype(value) => { + assert_eq!(value.as_ref().as_str(), Some("battery low")); + } + other => panic!("expected newtype payload, got {other:?}"), + } + } + other => panic!("expected enum field, got {other:?}"), + } + + publish_task.await.expect("publisher task"); +} diff --git a/crates/ros-z/tests/graph.rs b/crates/ros-z/tests/graph.rs index 49658fe2..8ee363a3 100644 --- a/crates/ros-z/tests/graph.rs +++ b/crates/ros-z/tests/graph.rs @@ -15,7 +15,6 @@ use ros_z::{ entity::{EntityKind, NodeKey}, }; use ros_z_msgs::{example_interfaces::srv::AddTwoInts, std_msgs::String as RosString}; - /// Helper to create a test context and node async fn setup_test_node( node_name: &str, @@ -74,7 +73,6 @@ async fn wait_for_subscribers( #[cfg(test)] mod tests { use super::*; - /// Tests getting topic names and types from the graph #[tokio::test(flavor = "multi_thread")] async fn test_get_topic_names_and_types() -> Result<()> { @@ -678,6 +676,44 @@ mod tests { Ok(()) } + /// Tests that a Ros2Dds context uses a Ros2Dds graph for introspection and matching. + #[cfg(feature = "ros2dds")] + #[tokio::test(flavor = "multi_thread")] + async fn test_ros2dds_context_graph_tracks_local_entities() -> Result<()> { + let ctx = ZContextBuilder::default() + .keyexpr_format(ros_z_protocol::KeyExprFormat::Ros2Dds) + .build()?; + let pub_node = ctx.create_node("test_graph_pub_dds").build()?; + let sub_node = ctx.create_node("test_graph_sub_dds").build()?; + let topic_name = "/test_ros2dds_context_graph"; + + let publisher = pub_node.create_pub::(topic_name).build()?; + let subscriber = sub_node.create_sub::(topic_name).build()?; + + assert!( + publisher + .wait_for_subscription(1, Duration::from_secs(2)) + .await + ); + assert!( + subscriber + .wait_for_publisher(1, Duration::from_secs(2)) + .await + ); + + let graph = ctx.graph(); + assert!( + graph.count(EntityKind::Publisher, topic_name) >= 1, + "Expected Ros2Dds graph to discover local publisher" + ); + assert!( + graph.count(EntityKind::Subscription, topic_name) >= 1, + "Expected Ros2Dds graph to discover local subscriber" + ); + + Ok(()) + } + /// Tests getting action names and types from the graph #[tokio::test(flavor = "multi_thread")] async fn test_action_names_and_types() -> Result<()> { diff --git a/crates/ros-z/tests/message_type_info_derive.rs b/crates/ros-z/tests/message_type_info_derive.rs new file mode 100644 index 00000000..160b0177 --- /dev/null +++ b/crates/ros-z/tests/message_type_info_derive.rs @@ -0,0 +1,213 @@ +use std::time::Duration; + +use ros_z::{ + Builder, MessageTypeInfo, TypeHash, + context::ZContextBuilder, + dynamic::{FieldType, MessageSchemaTypeDescription}, +}; +use serde::{Deserialize, Serialize}; +use zenoh::Wait; +use zenoh::config::WhatAmI; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ros_z::MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/Position2D")] +struct Position2D { + x: f64, + y: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ros_z::MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/RobotTelemetry")] +struct RobotTelemetry { + label: String, + pose: Position2D, + temperatures: Vec, + flags: [bool; 2], + payload: Vec, +} + +impl ros_z::msg::ZMessage for RobotTelemetry { + type Serdes = ros_z::msg::SerdeCdrSerdes; +} + +struct TestRouter { + endpoint: String, + _session: zenoh::Session, +} + +impl TestRouter { + fn new() -> Self { + let port = { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind port 0"); + listener.local_addr().unwrap().port() + }; + + let endpoint = format!("tcp/127.0.0.1:{port}"); + let mut config = zenoh::Config::default(); + config.set_mode(Some(WhatAmI::Router)).unwrap(); + config + .insert_json5("listen/endpoints", &format!("[\"{endpoint}\"]")) + .unwrap(); + config + .insert_json5("scouting/multicast/enabled", "false") + .unwrap(); + + let session = zenoh::open(config) + .wait() + .expect("failed to open test router"); + std::thread::sleep(Duration::from_millis(300)); + + Self { + endpoint, + _session: session, + } + } + + fn endpoint(&self) -> &str { + &self.endpoint + } +} + +fn create_context_with_router(router: &TestRouter) -> ros_z::Result { + ZContextBuilder::default() + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .build() +} + +#[test] +fn derive_generates_type_info_and_schema() { + let schema = RobotTelemetry::message_schema().expect("schema should be generated"); + + assert_eq!( + RobotTelemetry::type_name(), + "custom_msgs::msg::dds_::RobotTelemetry_" + ); + assert_eq!(schema.type_name, "custom_msgs/msg/RobotTelemetry"); + assert_eq!(schema.field_count(), 5); + + let label = schema.field("label").expect("label field"); + assert!(matches!(label.field_type, FieldType::String)); + + let pose = schema.field("pose").expect("pose field"); + match &pose.field_type { + FieldType::Message(nested) => { + assert_eq!(nested.type_name, "custom_msgs/msg/Position2D"); + assert_eq!(nested.field_count(), 2); + } + other => panic!("expected nested message field, got {:?}", other), + } + + let temperatures = schema.field("temperatures").expect("temperatures field"); + match &temperatures.field_type { + FieldType::Sequence(inner) => { + assert!(matches!(inner.as_ref(), FieldType::Float32)); + } + other => panic!("expected sequence field, got {:?}", other), + } + + let flags = schema.field("flags").expect("flags field"); + match &flags.field_type { + FieldType::Array(inner, len) => { + assert_eq!(*len, 2); + assert!(matches!(inner.as_ref(), FieldType::Bool)); + } + other => panic!("expected fixed array field, got {:?}", other), + } + + let payload = schema.field("payload").expect("payload field"); + match &payload.field_type { + FieldType::Sequence(inner) => { + assert!(matches!(inner.as_ref(), FieldType::Uint8)); + } + other => panic!("expected byte sequence field, got {:?}", other), + } + + let expected_hash = TypeHash::from_rihs_string( + &schema + .compute_type_hash() + .expect("schema hash") + .to_rihs_string(), + ) + .expect("valid entity type hash"); + + let reported_hash = RobotTelemetry::type_hash(); + if TypeHash::zero().to_rihs_string() == "TypeHashNotSupported" { + assert_eq!(reported_hash, TypeHash::zero()); + } else { + assert_eq!(reported_hash, expected_hash); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn derived_message_schema_is_auto_registered_and_discoverable() { + let router = TestRouter::new(); + + let pub_ctx = create_context_with_router(&router).expect("publisher context"); + let pub_node = pub_ctx + .create_node("derived_talker") + .with_type_description_service() + .build() + .expect("publisher node"); + + let publisher = pub_node + .create_pub::("/derived_topic") + .build() + .expect("publisher"); + + let registered = pub_node + .type_description_service() + .expect("type description service") + .get_schema("custom_msgs/msg/RobotTelemetry") + .expect("query registered schema"); + assert!(registered.is_some(), "schema should be auto-registered"); + + let sub_ctx = create_context_with_router(&router).expect("subscriber context"); + let sub_node = sub_ctx + .create_node("derived_listener") + .build() + .expect("subscriber node"); + + let publish_task = tokio::spawn(async move { + for _ in 0..25 { + let msg = RobotTelemetry { + label: "robot-1".to_string(), + pose: Position2D { x: 1.25, y: -2.5 }, + temperatures: vec![20.5, 21.0, 21.5], + flags: [true, false], + payload: vec![1, 2, 3, 4], + }; + publisher.publish(&msg).expect("publish"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let subscriber = sub_node + .create_dyn_sub_auto("/derived_topic", Duration::from_secs(10)) + .await + .expect("dynamic subscriber with auto-discovery") + .build() + .expect("subscriber build"); + let discovered_schema = subscriber.schema().expect("discovered schema"); + + assert_eq!( + discovered_schema.type_name, + "custom_msgs/msg/RobotTelemetry" + ); + assert_eq!(discovered_schema.field_count(), 5); + + let msg = subscriber + .recv_timeout(Duration::from_secs(3)) + .expect("received dynamic message"); + assert_eq!( + msg.get::("label").expect("label field"), + "robot-1".to_string() + ); + assert_eq!(msg.get::("pose.x").expect("nested pose.x"), 1.25); + assert_eq!(msg.get::("pose.y").expect("nested pose.y"), -2.5); + + publish_task.await.expect("publisher task"); +} diff --git a/crates/ros-z/tests/message_type_info_derive_ui.rs b/crates/ros-z/tests/message_type_info_derive_ui.rs new file mode 100644 index 00000000..15ade070 --- /dev/null +++ b/crates/ros-z/tests/message_type_info_derive_ui.rs @@ -0,0 +1,5 @@ +#[test] +fn message_type_info_rejects_v2_only_shapes() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/message_type_info/*.rs"); +} diff --git a/crates/ros-z/tests/service.rs b/crates/ros-z/tests/service.rs index a3d177f8..6888de43 100644 --- a/crates/ros-z/tests/service.rs +++ b/crates/ros-z/tests/service.rs @@ -88,15 +88,15 @@ fn test_basic_service_request_response() { .expect("Failed to create server"); // Wait for request - let (key, request) = server.take_request().expect("Failed to take request"); - assert_eq!(request.a, 10); - assert_eq!(request.b, 32); + let request = server.take_request().expect("Failed to take request"); + assert_eq!(request.message().a, 10); + assert_eq!(request.message().b, 32); let response = AddTwoIntsResponse { - sum: request.a + request.b, + sum: request.message().a + request.message().b, }; - server - .send_response(&response, &key) + request + .reply_blocking(&response) .expect("Failed to send response"); } }); @@ -119,13 +119,13 @@ fn test_basic_service_request_response() { let request = AddTwoIntsRequest { a: 10, b: 32 }; - tokio::runtime::Runtime::new() + let response = tokio::runtime::Runtime::new() .unwrap() - .block_on(async { client.send_request(&request).await }) - .expect("Failed to send request"); - - let response = client - .take_response_timeout(Duration::from_secs(2)) + .block_on(async { + client + .call_or_timeout(&request, Duration::from_secs(2)) + .await + }) .expect("Failed to receive response"); assert_eq!(response.sum, 42); @@ -157,17 +157,16 @@ async fn test_async_service_request_response() { .expect("Failed to create server"); // Wait for request asynchronously - let (key, request) = server + let request = server .async_take_request() .await .expect("Failed to take request"); - let response = AddTwoIntsResponse { - sum: request.a + request.b, + sum: request.message().a + request.message().b, }; - server - .async_send_response(&response, &key) + request + .reply(&response) .await .expect("Failed to send response"); }); @@ -188,13 +187,8 @@ async fn test_async_service_request_response() { tokio::time::sleep(Duration::from_millis(100)).await; let request = AddTwoIntsRequest { a: 100, b: 23 }; - client - .send_request(&request) - .await - .expect("Failed to send request"); - let response = client - .async_take_response() + .call(&request) .await .expect("Failed to receive response"); @@ -229,15 +223,15 @@ fn test_multiple_service_requests() { // Handle 3 requests for expected_a in [1, 2, 3] { - let (key, request) = server.take_request().expect("Failed to take request"); - assert_eq!(request.a, expected_a); - assert_eq!(request.b, 10); + let request = server.take_request().expect("Failed to take request"); + assert_eq!(request.message().a, expected_a); + assert_eq!(request.message().b, 10); let response = AddTwoIntsResponse { - sum: request.a + request.b, + sum: request.message().a + request.message().b, }; - server - .send_response(&response, &key) + request + .reply_blocking(&response) .expect("Failed to send response"); } } @@ -265,11 +259,12 @@ fn test_multiple_service_requests() { for a in [1, 2, 3] { let request = AddTwoIntsRequest { a, b: 10 }; - rt.block_on(async { client.send_request(&request).await }) - .expect("Failed to send request"); - - let response = client - .take_response_timeout(Duration::from_secs(2)) + let response = rt + .block_on(async { + client + .call_or_timeout(&request, Duration::from_secs(2)) + .await + }) .expect("Failed to receive response"); assert_eq!(response.sum, a + 10); diff --git a/crates/ros-z/tests/ui/message_type_info/enum.rs b/crates/ros-z/tests/ui/message_type_info/enum.rs new file mode 100644 index 00000000..ab597f7b --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/enum.rs @@ -0,0 +1,11 @@ +use ros_z::MessageTypeInfo; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/RobotState")] +enum RobotState { + Idle, + Error(String), +} + +fn main() {} diff --git a/crates/ros-z/tests/ui/message_type_info/enum.stderr b/crates/ros-z/tests/ui/message_type_info/enum.stderr new file mode 100644 index 00000000..ddd5e768 --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/enum.stderr @@ -0,0 +1,9 @@ +error: MessageTypeInfo derive only supports named structs in v1 + --> tests/ui/message_type_info/enum.rs:5:1 + | +5 | / #[ros_msg(type_name = "custom_msgs/msg/RobotState")] +6 | | enum RobotState { +7 | | Idle, +8 | | Error(String), +9 | | } + | |_^ diff --git a/crates/ros-z/tests/ui/message_type_info/option_field.rs b/crates/ros-z/tests/ui/message_type_info/option_field.rs new file mode 100644 index 00000000..ecf6f2d3 --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/option_field.rs @@ -0,0 +1,10 @@ +use ros_z::MessageTypeInfo; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/OptionField")] +struct OptionField { + value: Option, +} + +fn main() {} diff --git a/crates/ros-z/tests/ui/message_type_info/option_field.stderr b/crates/ros-z/tests/ui/message_type_info/option_field.stderr new file mode 100644 index 00000000..7da97f16 --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/option_field.stderr @@ -0,0 +1,5 @@ +error: Option fields are not supported by MessageTypeInfo derive in v1 + --> tests/ui/message_type_info/option_field.rs:7:12 + | +7 | value: Option, + | ^^^^^^^^^^^ diff --git a/crates/ros-z/tests/ui/message_type_info/tuple_struct.rs b/crates/ros-z/tests/ui/message_type_info/tuple_struct.rs new file mode 100644 index 00000000..91346430 --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/tuple_struct.rs @@ -0,0 +1,8 @@ +use ros_z::MessageTypeInfo; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, MessageTypeInfo)] +#[ros_msg(type_name = "custom_msgs/msg/TupleStatus")] +struct TupleStatus(f32, f32); + +fn main() {} diff --git a/crates/ros-z/tests/ui/message_type_info/tuple_struct.stderr b/crates/ros-z/tests/ui/message_type_info/tuple_struct.stderr new file mode 100644 index 00000000..791e13ad --- /dev/null +++ b/crates/ros-z/tests/ui/message_type_info/tuple_struct.stderr @@ -0,0 +1,6 @@ +error: MessageTypeInfo derive does not support tuple structs in v1 + --> tests/ui/message_type_info/tuple_struct.rs:5:1 + | +5 | / #[ros_msg(type_name = "custom_msgs/msg/TupleStatus")] +6 | | struct TupleStatus(f32, f32); + | |_____________________________^