diff --git a/pixi.toml b/pixi.toml
index 24faec9bf..cb41ba1d2 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -14,6 +14,7 @@ version = "0.1.0"
zenoh = "ros2 run rmw_zenoh_cpp rmw_zenohd"
deploy = {cmd = "scripts/deploy_robots.py", description = "Deploys the current environment."}
format = {cmd = "pre-commit run --all-files", description = "Runs code formatting and linting."}
+teamplayer = { cmd = "cargo run --release --manifest-path src/bitbots_misc/bitbots_bringup/tui/Cargo.toml --", description = "Launch the main software components" }
[tasks.build]
# Skipping ZED-camera related packages as they require a local installation of the ZED SDK.
diff --git a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch b/src/bitbots_misc/bitbots_bringup/launch/visualization.launch
deleted file mode 100644
index 8b4bf73d5..000000000
--- a/src/bitbots_misc/bitbots_bringup/launch/visualization.launch
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock
new file mode 100644
index 000000000..f76f29d3f
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.lock
@@ -0,0 +1,869 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "ansi-to-tui"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b0e348dcd256ba06d44d5deabc88a7c0e80ee7303158253ca069bcd9e9b7f57"
+dependencies = [
+ "nom",
+ "ratatui",
+ "thiserror",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "compact_str"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "futures-core",
+ "libc",
+ "mio 0.8.11",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str",
+ "crossterm",
+ "itertools 0.12.1",
+ "lru",
+ "paste",
+ "stability",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio 0.8.11",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stability"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "teamplayer"
+version = "0.1.0"
+dependencies = [
+ "ansi-to-tui",
+ "anyhow",
+ "clap",
+ "crossterm",
+ "futures",
+ "nix",
+ "ratatui",
+ "tokio",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio 1.2.0",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
diff --git a/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml
new file mode 100644
index 000000000..d9754baf5
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "teamplayer"
+version = "0.1.0"
+edition = "2021"
+
+[[bin]]
+name = "teamplayer"
+path = "src/main.rs"
+
+[dependencies]
+ratatui = "0.26"
+crossterm = { version = "0.27", features = ["event-stream"] }
+tokio = { version = "1", features = ["full"] }
+anyhow = "1"
+futures = "0.3"
+nix = { version = "0.27", features = ["signal", "process"] }
+ansi-to-tui = "3"
+clap = { version = "~4.4", features = ["derive"] }
diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/app.rs b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs
new file mode 100644
index 000000000..675e959d9
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/src/app.rs
@@ -0,0 +1,427 @@
+use std::{
+ collections::VecDeque,
+ sync::{Arc, Mutex},
+};
+
+use tokio::{
+ io::{AsyncBufReadExt, BufReader},
+ process::Command,
+ sync::mpsc,
+};
+
+pub use crate::components::GlobalParams;
+use crate::components::{COMPONENT_DEFS, FIELDNAME_DEFAULT_HW, FIELDNAME_DEFAULT_SIM};
+
+// ─── State types ──────────────────────────────────────────────────────────────
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ProcState {
+ Idle,
+ Running,
+ /// SIGTERM sent, waiting for exit
+ Stopping,
+ /// SIGTERM sent, will restart automatically when the process exits
+ Restarting,
+ Stopped,
+ Crashed,
+}
+
+impl ProcState {
+ pub fn label(&self) -> &'static str {
+ match self {
+ ProcState::Idle => "IDLE",
+ ProcState::Running => "RUN ",
+ ProcState::Stopping => "STOP",
+ ProcState::Restarting => "↺ ",
+ ProcState::Stopped => "DONE",
+ ProcState::Crashed => "FAIL",
+ }
+ }
+}
+
+pub struct ComponentState {
+ pub comp_idx: usize,
+ pub enabled: bool,
+ pub state: ProcState,
+ pub logs: Arc>>,
+ pub pids: Vec,
+ pub task_handles: Vec>,
+}
+
+impl ComponentState {
+ pub fn new(comp_idx: usize, enabled: bool) -> Self {
+ Self {
+ comp_idx,
+ enabled,
+ state: ProcState::Idle,
+ logs: Arc::new(Mutex::new(VecDeque::with_capacity(2000))),
+ pids: Vec::new(),
+ task_handles: Vec::new(),
+ }
+ }
+}
+
+pub enum AppMsg {
+ ProcessExited { key: String, code: i32 },
+}
+
+// ─── Screen / focus ───────────────────────────────────────────────────────────
+
+#[derive(PartialEq, Clone)]
+pub enum Screen {
+ Config,
+ Runtime,
+ Logs(usize),
+}
+
+#[derive(Clone, PartialEq, Debug)]
+pub enum ConfigFocus {
+ Zenoh,
+ Sim,
+ Fieldname,
+ DsdFile,
+ Component(usize),
+ Start,
+}
+
+// ─── App ──────────────────────────────────────────────────────────────────────
+
+pub struct App {
+ pub screen: Screen,
+ pub params: GlobalParams,
+ pub components: Vec,
+ pub config_focus: ConfigFocus,
+ pub config_fieldname_input: String,
+ pub config_dsd_input: String,
+ pub runtime_selected: usize,
+ pub tick_count: u64,
+ pub msg_tx: mpsc::UnboundedSender,
+ pub msg_rx: mpsc::UnboundedReceiver,
+ pub log_scroll: usize,
+ pub all_logs: Arc>>,
+ pub show_log_panel: bool,
+}
+
+impl App {
+ pub fn new() -> Self {
+ let (msg_tx, msg_rx) = mpsc::unbounded_channel();
+ let components: Vec = COMPONENT_DEFS
+ .iter()
+ .enumerate()
+ .map(|(i, def)| {
+ ComponentState::new(i, if def.sim_component { false } else { def.default_enabled })
+ })
+ .collect();
+
+ App {
+ screen: Screen::Config,
+ params: GlobalParams::default(),
+ components,
+ config_focus: ConfigFocus::Zenoh,
+ config_fieldname_input: FIELDNAME_DEFAULT_HW.to_string(),
+ config_dsd_input: "main.dsd".to_string(),
+ runtime_selected: 0,
+ tick_count: 0,
+ msg_tx,
+ msg_rx,
+ log_scroll: 0,
+ all_logs: Arc::new(Mutex::new(VecDeque::with_capacity(5000))),
+ show_log_panel: true,
+ }
+ }
+
+ pub fn apply_cli_presets(
+ &mut self,
+ sim: bool,
+ no_zenoh: bool,
+ fieldname: Option<&str>,
+ dsd_file: &str,
+ enable: &[String],
+ disable: &[String],
+ ) {
+ if sim {
+ self.params.sim = true;
+ self.apply_sim_toggle();
+ }
+ if let Some(f) = fieldname {
+ self.params.fieldname = f.to_string();
+ self.config_fieldname_input = f.to_string();
+ }
+ self.params.dsd_file = dsd_file.to_string();
+ self.config_dsd_input = dsd_file.to_string();
+
+ if no_zenoh {
+ if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh") {
+ self.components[idx].enabled = false;
+ self.params.zenoh = false;
+ }
+ }
+ for key in enable {
+ if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == key.as_str()) {
+ self.components[idx].enabled = true;
+ }
+ }
+ for key in disable {
+ if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == key.as_str()) {
+ self.components[idx].enabled = false;
+ }
+ }
+ }
+
+ pub fn toggleable_indices(&self) -> Vec {
+ COMPONENT_DEFS
+ .iter()
+ .enumerate()
+ .filter(|(_, d)| {
+ !d.infrastructure && !d.sim_component && !(d.hardware_only && self.params.sim)
+ })
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ pub fn config_focus_items(&self) -> Vec {
+ let mut items = vec![
+ ConfigFocus::Zenoh,
+ ConfigFocus::Sim,
+ ConfigFocus::Fieldname,
+ ConfigFocus::DsdFile,
+ ];
+ for i in 0..self.toggleable_indices().len() {
+ items.push(ConfigFocus::Component(i));
+ }
+ items.push(ConfigFocus::Start);
+ items
+ }
+
+ pub fn config_focus_next(&mut self) {
+ let items = self.config_focus_items();
+ let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0);
+ self.config_focus = items[(pos + 1) % items.len()].clone();
+ }
+
+ pub fn config_focus_prev(&mut self) {
+ let items = self.config_focus_items();
+ let pos = items.iter().position(|f| f == &self.config_focus).unwrap_or(0);
+ self.config_focus = items[(pos + items.len() - 1) % items.len()].clone();
+ }
+
+ pub fn runtime_visible_indices(&self) -> Vec {
+ COMPONENT_DEFS
+ .iter()
+ .enumerate()
+ .filter(|(i, d)| {
+ if d.sim_component {
+ return self.params.sim;
+ }
+ if d.hardware_only && self.params.sim {
+ return false;
+ }
+ self.components[*i].enabled
+ })
+ .map(|(i, _)| i)
+ .collect()
+ }
+
+ pub fn apply_sim_toggle(&mut self) {
+ let sim = self.params.sim;
+ for (i, def) in COMPONENT_DEFS.iter().enumerate() {
+ if def.sim_component {
+ self.components[i].enabled = sim;
+ } else if def.hardware_only {
+ self.components[i].enabled = !sim && def.default_enabled;
+ }
+ }
+ self.config_fieldname_input =
+ if sim { FIELDNAME_DEFAULT_SIM } else { FIELDNAME_DEFAULT_HW }.to_string();
+ self.params.fieldname = self.config_fieldname_input.clone();
+ self.config_focus = ConfigFocus::Zenoh;
+ }
+
+ pub async fn start_component(&mut self, comp_idx: usize) {
+ let def = &COMPONENT_DEFS[comp_idx];
+ let cmds = (def.cmds)(&self.params);
+ let key = def.key.to_string();
+ let comp_name = def.name; // &'static str
+
+ let logs = self.components[comp_idx].logs.clone();
+ let all_logs = self.all_logs.clone();
+ self.components[comp_idx].state = ProcState::Running;
+ self.components[comp_idx].pids.clear();
+ self.components[comp_idx].task_handles.clear();
+
+ for cmd_args in cmds {
+ if cmd_args.is_empty() {
+ continue;
+ }
+ let tx = self.msg_tx.clone();
+ let logs = logs.clone();
+ let all_logs = all_logs.clone();
+ let key = key.clone();
+
+ let mut child = match Command::new(&cmd_args[0])
+ .args(&cmd_args[1..])
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .process_group(0)
+ .kill_on_drop(true)
+ .spawn()
+ {
+ Ok(c) => c,
+ Err(e) => {
+ let entry = format!("[ERROR] spawn failed: {e}");
+ logs.lock().unwrap().push_back(entry.clone());
+ all_logs.lock().unwrap().push_back(format!("[{comp_name}] {entry}"));
+ let _ = tx.send(AppMsg::ProcessExited { key, code: -1 });
+ return;
+ }
+ };
+
+ if let Some(pid) = child.id() {
+ self.components[comp_idx].pids.push(pid);
+ }
+
+ let stdout = child.stdout.take().map(BufReader::new);
+ let stderr = child.stderr.take().map(BufReader::new);
+
+ let handle = tokio::spawn(async move {
+ async fn pump(
+ mut r: BufReader,
+ logs: Arc>>,
+ all_logs: Arc>>,
+ prefix: &'static str,
+ comp_name: &'static str,
+ ) {
+ let mut line = String::new();
+ while let Ok(n) = r.read_line(&mut line).await {
+ if n == 0 {
+ break;
+ }
+ let entry = format!("{}{}", prefix, line.trim_end_matches('\n'));
+ {
+ let mut l = logs.lock().unwrap();
+ if l.len() >= 2000 {
+ l.pop_front();
+ }
+ l.push_back(entry.clone());
+ }
+ {
+ let mut l = all_logs.lock().unwrap();
+ if l.len() >= 5000 {
+ l.pop_front();
+ }
+ l.push_back(format!("[{comp_name}] {entry}"));
+ }
+ line.clear();
+ }
+ }
+ // Read stdout and stderr concurrently so neither blocks the other.
+ let (logs2, all2) = (logs.clone(), all_logs.clone());
+ tokio::join!(
+ async move {
+ if let Some(r) = stdout {
+ pump(r, logs2, all2, "", comp_name).await;
+ }
+ },
+ async move {
+ if let Some(r) = stderr {
+ pump(r, logs, all_logs, "[ERR] ", comp_name).await;
+ }
+ }
+ );
+ let code = child
+ .wait()
+ .await
+ .map(|s| s.code().unwrap_or(-1))
+ .unwrap_or(-1);
+ let _ = tx.send(AppMsg::ProcessExited { key, code });
+ });
+ self.components[comp_idx].task_handles.push(handle);
+ }
+ }
+
+ /// Send SIGTERM to the component's direct child processes and mark as Stopping.
+ pub async fn stop_component(&mut self, comp_idx: usize) {
+ match self.components[comp_idx].state {
+ ProcState::Running => {
+ sigterm_pids(&self.components[comp_idx].pids);
+ self.components[comp_idx].state = ProcState::Stopping;
+ }
+ ProcState::Restarting => {
+ // Already sent SIGTERM — cancel the pending restart
+ self.components[comp_idx].state = ProcState::Stopping;
+ }
+ _ => {}
+ }
+ }
+
+ /// Send SIGTERM and mark as Restarting — will auto-restart when the process exits.
+ pub fn restart_component(&mut self, comp_idx: usize) {
+ if self.components[comp_idx].state != ProcState::Running {
+ return;
+ }
+ sigterm_pids(&self.components[comp_idx].pids);
+ self.components[comp_idx].state = ProcState::Restarting;
+ }
+
+ pub async fn stop_all(&mut self) {
+ for i in 0..self.components.len() {
+ self.stop_component(i).await;
+ }
+ }
+
+ pub async fn start_all_enabled(&mut self) {
+ let enabled: Vec = self
+ .components
+ .iter()
+ .enumerate()
+ .filter(|(_, c)| c.enabled && c.state == ProcState::Idle)
+ .map(|(i, _)| i)
+ .collect();
+ for i in enabled {
+ self.start_component(i).await;
+ }
+ }
+
+ /// Drain the message queue and return the indices of components that need restarting.
+ pub fn drain_messages(&mut self) -> Vec {
+ let mut to_restart = Vec::new();
+ while let Ok(msg) = self.msg_rx.try_recv() {
+ match msg {
+ AppMsg::ProcessExited { key, code } => {
+ if let Some((idx, comp)) = self
+ .components
+ .iter_mut()
+ .enumerate()
+ .find(|(_, c)| COMPONENT_DEFS[c.comp_idx].key == key)
+ {
+ match comp.state {
+ ProcState::Restarting => {
+ comp.pids.clear();
+ comp.state = ProcState::Idle;
+ to_restart.push(idx);
+ }
+ ProcState::Stopping | ProcState::Running => {
+ comp.state = if code == 0 {
+ ProcState::Stopped
+ } else {
+ ProcState::Crashed
+ };
+ comp.pids.clear();
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+ to_restart
+ }
+}
+
+fn sigterm_pids(pids: &[u32]) {
+ use nix::sys::signal::{killpg, Signal};
+ use nix::unistd::Pid;
+ for &pid in pids {
+ let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGTERM);
+ }
+}
diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/components.rs b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs
new file mode 100644
index 000000000..86221896e
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/src/components.rs
@@ -0,0 +1,324 @@
+// ─── Global params ────────────────────────────────────────────────────────────
+
+pub const FIELDNAME_DEFAULT_HW: &str = "labor";
+pub const FIELDNAME_DEFAULT_SIM: &str = "hsl_kid";
+
+#[derive(Clone, Debug)]
+pub struct GlobalParams {
+ pub sim: bool,
+ pub zenoh: bool,
+ pub fieldname: String,
+ pub dsd_file: String,
+}
+
+impl Default for GlobalParams {
+ fn default() -> Self {
+ Self {
+ sim: false,
+ zenoh: true,
+ fieldname: FIELDNAME_DEFAULT_HW.to_string(),
+ dsd_file: "main.dsd".to_string(),
+ }
+ }
+}
+
+// ─── Component definitions ────────────────────────────────────────────────────
+
+pub struct ComponentDef {
+ pub name: &'static str,
+ pub key: &'static str,
+ pub default_enabled: bool,
+ pub infrastructure: bool,
+ /// Only valid on real hardware — hidden/disabled in sim
+ pub hardware_only: bool,
+ /// Only shown/started when sim mode is active (e.g. the simulator itself)
+ pub sim_component: bool,
+ /// Returns the list of commands to spawn for this component
+ pub cmds: fn(&GlobalParams) -> Vec>,
+}
+
+// ─── Command helpers ──────────────────────────────────────────────────────────
+
+pub fn ros2_launch(pkg: &str, file: &str, extra: &[&str]) -> Vec {
+ let mut v = vec![
+ "ros2".into(),
+ "launch".into(),
+ pkg.into(),
+ file.into(),
+ ];
+ v.extend(extra.iter().map(|s| s.to_string()));
+ v
+}
+
+pub fn ros2_run(pkg: &str, exec: &str, extra: &[&str]) -> Vec {
+ let mut v = vec![
+ "ros2".into(),
+ "run".into(),
+ pkg.into(),
+ exec.into(),
+ ];
+ v.extend(extra.iter().map(|s| s.to_string()));
+ v
+}
+
+fn sim(p: &GlobalParams) -> String {
+ format!("sim:={}", p.sim)
+}
+
+// ─── Static component registry ────────────────────────────────────────────────
+
+pub static COMPONENT_DEFS: &[ComponentDef] = &[
+ // ── Infrastructure (always started) ──────────────────────────────────────
+ ComponentDef {
+ name: "Zenoh",
+ key: "zenoh",
+ default_enabled: true,
+ infrastructure: true,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| vec![ros2_run("rmw_zenoh_cpp", "rmw_zenohd", &[])],
+ },
+ ComponentDef {
+ name: "Param Blackboard",
+ key: "blackboard",
+ default_enabled: true,
+ infrastructure: true,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_parameter_blackboard",
+ "parameter_blackboard.launch",
+ &[&sim(p), &format!("fieldname:={}", p.fieldname)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Robot Description",
+ key: "robot_description",
+ default_enabled: true,
+ infrastructure: true,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_robot_description",
+ "load_robot_description.launch",
+ &[&sim(p)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Diagnostics",
+ key: "diagnostics",
+ default_enabled: true,
+ infrastructure: true,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| vec![ros2_launch("bitbots_diagnostic", "aggregator.launch", &[])],
+ },
+ ComponentDef {
+ name: "Simulator",
+ key: "simulator",
+ default_enabled: false,
+ infrastructure: true,
+ hardware_only: false,
+ sim_component: true,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_mujoco_sim",
+ "simulator.launch",
+ &[&sim(p)],
+ )]
+ },
+ },
+ // ── User-toggleable ───────────────────────────────────────────────────────
+ ComponentDef {
+ name: "Lowlevel",
+ key: "lowlevel",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: true,
+ sim_component: false,
+ cmds: |_| vec![ros2_launch("livelybot_bringup", "lowlevel.launch", &[])],
+ },
+ ComponentDef {
+ name: "Motion",
+ key: "motion",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| vec![ros2_launch("bitbots_bringup", "motion.launch", &[&sim(p)])],
+ },
+ ComponentDef {
+ name: "Game Controller",
+ key: "game_controller",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "game_controller_hsl",
+ "game_controller.launch",
+ &[
+ &sim(p),
+ "use_parameter_blackboard:=true",
+ "parameter_blackboard_name:=parameter_blackboard",
+ "team_id_param_name:=team_id",
+ "bot_id_param_name:=bot_id",
+ ],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Vision",
+ key: "vision",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| vec![ros2_launch("bitbots_bringup", "vision.launch", &[&sim(p)])],
+ },
+ ComponentDef {
+ name: "IPM",
+ key: "ipm",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| vec![ros2_launch("bitbots_ipm", "ipm.launch", &[&sim(p)])],
+ },
+ ComponentDef {
+ name: "Localization",
+ key: "localization",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_localization",
+ "localization.launch",
+ &[&sim(p)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Path Planning",
+ key: "path_planning",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_path_planning",
+ "path_planning.launch",
+ &[&sim(p)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Behavior",
+ key: "behavior",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_body_behavior",
+ "behavior.launch",
+ &[&sim(p), &format!("dsd_file:={}", p.dsd_file)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Team Comm",
+ key: "teamcom",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_team_communication",
+ "team_comm.launch",
+ &[&sim(p)],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "World Model",
+ key: "world_model",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![
+ ros2_launch("bitbots_ball_filter", "ball_filter.launch", &[&sim(p)]),
+ ros2_launch("bitbots_robot_filter", "robot_filter.launch", &[&sim(p)]),
+ ]
+ },
+ },
+ ComponentDef {
+ name: "Whistle Det.",
+ key: "whistle_detector",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| {
+ vec![ros2_launch(
+ "bitbots_whistle_detector",
+ "whistle_detector.launch",
+ &[],
+ )]
+ },
+ },
+ ComponentDef {
+ name: "Audio",
+ key: "audio",
+ default_enabled: true,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| vec![ros2_launch("bitbots_bringup", "audio.launch", &[])],
+ },
+ ComponentDef {
+ name: "TTS",
+ key: "tts",
+ default_enabled: false,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| vec![ros2_launch("bitbots_tts", "tts.launch", &[])],
+ },
+ ComponentDef {
+ name: "Monitoring",
+ key: "monitoring",
+ default_enabled: false,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |_| vec![ros2_launch("bitbots_bringup", "monitoring.launch", &[])],
+ },
+ ComponentDef {
+ name: "Recording",
+ key: "record",
+ default_enabled: false,
+ infrastructure: false,
+ hardware_only: false,
+ sim_component: false,
+ cmds: |p| {
+ vec![ros2_launch(
+ "bitbots_bringup",
+ "rosbag_record.launch.py",
+ &[&sim(p)],
+ )]
+ },
+ },
+];
diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/input.rs b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs
new file mode 100644
index 000000000..2ee9e8ab9
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/src/input.rs
@@ -0,0 +1,165 @@
+use crossterm::event::{KeyCode, KeyModifiers};
+
+use crate::app::{App, ConfigFocus, ProcState, Screen};
+use crate::components::COMPONENT_DEFS;
+
+pub async fn handle_config_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> bool {
+ match (&app.config_focus.clone(), key) {
+ // Navigation
+ (_, KeyCode::Tab) => {
+ if modifiers.contains(KeyModifiers::SHIFT) {
+ app.config_focus_prev();
+ } else {
+ app.config_focus_next();
+ }
+ }
+ (_, KeyCode::Down) => app.config_focus_next(),
+ (_, KeyCode::Up) => app.config_focus_prev(),
+
+ // Zenoh toggle
+ (ConfigFocus::Zenoh, KeyCode::Char(' ') | KeyCode::Enter) => {
+ app.params.zenoh = !app.params.zenoh;
+ if let Some(idx) = COMPONENT_DEFS.iter().position(|d| d.key == "zenoh") {
+ app.components[idx].enabled = app.params.zenoh;
+ }
+ }
+
+ // Sim toggle
+ (ConfigFocus::Sim, KeyCode::Char(' ') | KeyCode::Enter) => {
+ app.params.sim = !app.params.sim;
+ app.apply_sim_toggle();
+ }
+
+ // Fieldname text input
+ (ConfigFocus::Fieldname, KeyCode::Char(c)) => {
+ if !modifiers.contains(KeyModifiers::CONTROL) {
+ app.config_fieldname_input.push(c);
+ app.params.fieldname = app.config_fieldname_input.clone();
+ }
+ }
+ (ConfigFocus::Fieldname, KeyCode::Backspace) => {
+ app.config_fieldname_input.pop();
+ app.params.fieldname = app.config_fieldname_input.clone();
+ }
+
+ // DSD file text input
+ (ConfigFocus::DsdFile, KeyCode::Char(c)) => {
+ if !modifiers.contains(KeyModifiers::CONTROL) {
+ app.config_dsd_input.push(c);
+ app.params.dsd_file = app.config_dsd_input.clone();
+ }
+ }
+ (ConfigFocus::DsdFile, KeyCode::Backspace) => {
+ app.config_dsd_input.pop();
+ app.params.dsd_file = app.config_dsd_input.clone();
+ }
+
+ // Component checkbox toggle
+ (ConfigFocus::Component(fi), KeyCode::Char(' ') | KeyCode::Enter) => {
+ let fi = *fi;
+ let toggleables = app.toggleable_indices();
+ if fi < toggleables.len() {
+ let comp_idx = toggleables[fi];
+ app.components[comp_idx].enabled = !app.components[comp_idx].enabled;
+ }
+ }
+
+ // Start / Continue button
+ (ConfigFocus::Start, KeyCode::Enter | KeyCode::Char(' ')) => {
+ app.params.fieldname = app.config_fieldname_input.clone();
+ app.params.dsd_file = app.config_dsd_input.clone();
+ app.screen = Screen::Runtime;
+ app.start_all_enabled().await;
+ }
+
+ // Quit
+ (_, KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc) => return true,
+
+ _ => {}
+ }
+ false
+}
+
+pub async fn handle_runtime_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> bool {
+ let visible = app.runtime_visible_indices();
+ let sel = app.runtime_selected.min(visible.len().saturating_sub(1));
+ let comp_idx = visible.get(sel).copied();
+
+ match key {
+ KeyCode::Down | KeyCode::Char('j') => {
+ app.runtime_selected = (sel + 1).min(visible.len().saturating_sub(1));
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ app.runtime_selected = sel.saturating_sub(1);
+ }
+
+ KeyCode::Char('s') => {
+ if let Some(idx) = comp_idx {
+ match app.components[idx].state {
+ ProcState::Running | ProcState::Stopping | ProcState::Restarting => {
+ app.stop_component(idx).await;
+ }
+ _ => {
+ app.components[idx].state = ProcState::Idle;
+ app.start_component(idx).await;
+ }
+ }
+ }
+ }
+
+ KeyCode::Char('r') => {
+ if let Some(idx) = comp_idx {
+ match app.components[idx].state {
+ ProcState::Running => app.restart_component(idx),
+ ProcState::Stopping | ProcState::Restarting => {} // already terminating
+ _ => app.start_component(idx).await,
+ }
+ }
+ }
+
+ KeyCode::Char('l') => {
+ if let Some(idx) = comp_idx {
+ app.log_scroll = app.components[idx].logs.lock().unwrap().len().saturating_sub(1);
+ app.screen = Screen::Logs(idx);
+ }
+ }
+
+ KeyCode::Char('v') => app.show_log_panel = !app.show_log_panel,
+ KeyCode::Char('a') => app.start_all_enabled().await,
+ KeyCode::Char('x') => app.stop_all().await,
+ KeyCode::Esc => app.screen = Screen::Config,
+ KeyCode::Char('q') | KeyCode::Char('Q') => return true,
+ _ => {}
+ }
+ false
+}
+
+pub async fn handle_logs_key(
+ app: &mut App,
+ key: KeyCode,
+ _modifiers: KeyModifiers,
+ comp_idx: usize,
+) -> bool {
+ let log_len = app.components[comp_idx].logs.lock().unwrap().len();
+ match key {
+ KeyCode::Down | KeyCode::Char('j') => {
+ app.log_scroll = (app.log_scroll + 1).min(log_len.saturating_sub(1));
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ app.log_scroll = app.log_scroll.saturating_sub(1);
+ }
+ KeyCode::PageDown => {
+ app.log_scroll = (app.log_scroll + 20).min(log_len.saturating_sub(1));
+ }
+ KeyCode::PageUp => {
+ app.log_scroll = app.log_scroll.saturating_sub(20);
+ }
+ KeyCode::Char('G') => app.log_scroll = log_len.saturating_sub(1),
+ KeyCode::Char('g') => app.log_scroll = 0,
+ KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('l') => {
+ app.screen = Screen::Runtime;
+ }
+ _ => {}
+ }
+ false
+}
diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/main.rs b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs
new file mode 100644
index 000000000..fc4ed7760
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/src/main.rs
@@ -0,0 +1,235 @@
+mod app;
+mod components;
+mod input;
+mod ui;
+
+use anyhow::Result;
+use clap::Parser;
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use futures::StreamExt;
+use ratatui::{backend::CrosstermBackend, Terminal};
+use std::time::Duration;
+use tokio::time;
+
+use app::{App, ProcState, Screen};
+use components::COMPONENT_DEFS;
+
+// ─── CLI ──────────────────────────────────────────────────────────────────────
+
+#[derive(Parser)]
+#[command(
+ name = "teamplayer",
+ about = "Bit-Bots teamplayer launcher — interactive TUI or headless mode",
+ long_about = None,
+)]
+struct Cli {
+ /// Run without TUI: start all enabled components and block until Ctrl-C
+ #[arg(long)]
+ headless: bool,
+
+ /// Enable simulation mode (also enables the simulator, disables hardware-only components)
+ #[arg(long)]
+ sim: bool,
+
+ /// Override the field name [default: labor, or hsl_kid in sim]
+ #[arg(long, value_name = "NAME")]
+ fieldname: Option,
+
+ /// Override the behavior DSD file [default: main.dsd]
+ #[arg(long, value_name = "FILE", default_value = "main.dsd")]
+ dsd_file: String,
+
+ /// Disable the Zenoh daemon (useful when zenoh is already running externally)
+ #[arg(long)]
+ no_zenoh: bool,
+
+ /// Enable a component by key; may be specified multiple times.
+ /// Available keys: zenoh, blackboard, robot_description, diagnostics,
+ /// simulator, lowlevel, motion, game_controller, vision, ipm, localization,
+ /// path_planning, behavior, teamcom, world_model, whistle_detector, audio,
+ /// tts, monitoring, record
+ #[arg(long = "enable", value_name = "KEY")]
+ enable: Vec,
+
+ /// Disable a component by key; may be specified multiple times
+ #[arg(long = "disable", value_name = "KEY")]
+ disable: Vec,
+}
+
+// ─── Terminal helpers ─────────────────────────────────────────────────────────
+
+fn setup_panic_hook() {
+ let original = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ let _ = disable_raw_mode();
+ let _ = execute!(
+ std::io::stdout(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ );
+ original(info);
+ }));
+}
+
+fn cleanup_terminal() {
+ let _ = disable_raw_mode();
+ let _ = execute!(
+ std::io::stdout(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ );
+}
+
+// ─── Entry point ──────────────────────────────────────────────────────────────
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ let mut app = App::new();
+ app.apply_cli_presets(
+ cli.sim,
+ cli.no_zenoh,
+ cli.fieldname.as_deref(),
+ &cli.dsd_file,
+ &cli.enable,
+ &cli.disable,
+ );
+
+ let result = if cli.headless {
+ run_headless(&mut app).await
+ } else {
+ setup_panic_hook();
+ enable_raw_mode()?;
+ let mut stdout = std::io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ let mut event_stream = EventStream::new();
+ let mut tick_interval = time::interval(Duration::from_millis(100));
+ let r = run_tui(&mut terminal, &mut app, &mut event_stream, &mut tick_interval).await;
+ cleanup_terminal();
+ r
+ };
+
+ // SIGTERM all remaining process groups on exit
+ {
+ use nix::sys::signal::{killpg, Signal};
+ use nix::unistd::Pid;
+ for comp in &app.components {
+ for &pid in &comp.pids {
+ let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGTERM);
+ }
+ }
+ }
+
+ result
+}
+
+// ─── Headless mode ────────────────────────────────────────────────────────────
+
+async fn run_headless(app: &mut App) -> Result<()> {
+ eprintln!("teamplayer [headless] — Ctrl-C to stop");
+ eprintln!();
+
+ // Print what will be started
+ for comp in &app.components {
+ let def = &COMPONENT_DEFS[comp.comp_idx];
+ if comp.enabled {
+ eprintln!(" [+] {}", def.name);
+ }
+ }
+ eprintln!();
+
+ app.start_all_enabled().await;
+
+ let mut tick = time::interval(Duration::from_millis(50));
+ loop {
+ tokio::select! {
+ _ = tick.tick() => {
+ let to_restart = app.drain_messages();
+ for idx in to_restart {
+ app.start_component(idx).await;
+ }
+ {
+ let mut logs = app.all_logs.lock().unwrap();
+ while let Some(entry) = logs.pop_front() {
+ println!("{entry}");
+ }
+ }
+ let any_running = app.components.iter().any(|c| {
+ matches!(c.state, ProcState::Running | ProcState::Stopping | ProcState::Restarting)
+ });
+ if !any_running {
+ eprintln!("All components have stopped.");
+ break;
+ }
+ }
+ _ = tokio::signal::ctrl_c() => {
+ eprintln!("\nInterrupted — stopping all components...");
+ break;
+ }
+ }
+ }
+ Ok(())
+}
+
+// ─── TUI event loop ───────────────────────────────────────────────────────────
+
+async fn run_tui(
+ terminal: &mut Terminal>,
+ app: &mut App,
+ event_stream: &mut EventStream,
+ tick_interval: &mut time::Interval,
+) -> Result<()> {
+ loop {
+ terminal.draw(|f| ui::draw(f, app))?;
+
+ tokio::select! {
+ _ = tick_interval.tick() => {
+ app.tick_count += 1;
+ let to_restart = app.drain_messages();
+ for idx in to_restart {
+ app.start_component(idx).await;
+ }
+ if let Screen::Logs(idx) = &app.screen {
+ let idx = *idx;
+ let len = app.components[idx].logs.lock().unwrap().len();
+ if app.log_scroll + 5 >= len.saturating_sub(1) {
+ app.log_scroll = len.saturating_sub(1);
+ }
+ }
+ }
+
+ maybe_event = event_stream.next() => {
+ let Some(Ok(event)) = maybe_event else { break };
+ if let Event::Key(key_event) = event {
+ let quit = match app.screen.clone() {
+ Screen::Config => {
+ input::handle_config_key(app, key_event.code, key_event.modifiers).await
+ }
+ Screen::Runtime => {
+ input::handle_runtime_key(app, key_event.code, key_event.modifiers).await
+ }
+ Screen::Logs(idx) => {
+ input::handle_logs_key(app, key_event.code, key_event.modifiers, idx).await
+ }
+ };
+ if quit {
+ return Ok(());
+ }
+ }
+ }
+
+ _ = tokio::signal::ctrl_c() => {
+ return Ok(());
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs
new file mode 100644
index 000000000..2b5e6c1af
--- /dev/null
+++ b/src/bitbots_misc/bitbots_bringup/tui/src/ui.rs
@@ -0,0 +1,501 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph, Wrap},
+ Frame,
+};
+
+use crate::app::{App, ConfigFocus, ProcState, Screen};
+use crate::components::COMPONENT_DEFS;
+
+// ─── Banner ───────────────────────────────────────────────────────────────────
+
+const BANNER: &str = r#"
+ ██████╗ ██╗████████╗ ██████╗ ██████╗ ████████╗███████╗
+ ██╔══██╗██║╚══██╔══╝ ██╔══██╗██╔═══██╗╚══██╔══╝██╔════╝
+ ██████╔╝██║ ██║ ██████╔╝██║ ██║ ██║ ███████╗
+ ██╔══██╗██║ ██║ ██╔══██╗██║ ██║ ██║ ╚════██║
+ ██████╔╝██║ ██║ ██████╔╝╚██████╔╝ ██║ ███████║
+ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝
+ Teamplayer Launcher
+"#;
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+pub fn proc_state_style(state: &ProcState) -> Style {
+ match state {
+ ProcState::Idle => Style::default().fg(Color::DarkGray),
+ ProcState::Running => Style::default().fg(Color::Green),
+ ProcState::Stopping => Style::default().fg(Color::Yellow),
+ ProcState::Restarting => Style::default().fg(Color::Cyan),
+ ProcState::Stopped => Style::default().fg(Color::Blue),
+ ProcState::Crashed => Style::default().fg(Color::Red),
+ }
+}
+
+fn spinner_char(tick: u64) -> char {
+ const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
+ FRAMES[(tick as usize) % FRAMES.len()]
+}
+
+// ─── Top-level dispatch ───────────────────────────────────────────────────────
+
+pub fn draw(f: &mut Frame, app: &App) {
+ match &app.screen {
+ Screen::Config => draw_config(f, app),
+ Screen::Runtime => draw_runtime(f, app),
+ Screen::Logs(idx) => draw_logs(f, app, *idx),
+ }
+}
+
+// ─── Config screen ────────────────────────────────────────────────────────────
+
+pub fn draw_config(f: &mut Frame, app: &App) {
+ let area = f.size();
+ let banner_lines = BANNER.lines().count() as u16;
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(banner_lines),
+ Constraint::Length(3),
+ Constraint::Min(0),
+ Constraint::Length(3),
+ Constraint::Length(1),
+ ])
+ .split(area);
+
+ f.render_widget(
+ Paragraph::new(BANNER)
+ .style(Style::default().fg(Color::Cyan))
+ .alignment(Alignment::Center),
+ chunks[0],
+ );
+
+ draw_config_flags(f, app, chunks[1]);
+ draw_config_components(f, app, chunks[2]);
+ draw_start_button(f, app, chunks[3]);
+
+ f.render_widget(
+ Paragraph::new(" Tab/↑↓: navigate Space/Enter: toggle/activate q: quit")
+ .style(Style::default().fg(Color::DarkGray)),
+ chunks[4],
+ );
+}
+
+fn draw_config_flags(f: &mut Frame, app: &App, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Length(18),
+ Constraint::Length(18),
+ Constraint::Min(20),
+ Constraint::Min(20),
+ ])
+ .split(area);
+
+ let focus_border = |focused: bool| {
+ if focused {
+ Style::default().fg(Color::Yellow)
+ } else {
+ Style::default()
+ }
+ };
+ let focus_text = |focused: bool| {
+ if focused {
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::White)
+ }
+ };
+
+ let zenoh_focused = app.config_focus == ConfigFocus::Zenoh;
+ let sim_focused = app.config_focus == ConfigFocus::Sim;
+ let field_focused = app.config_focus == ConfigFocus::Fieldname;
+ let dsd_focused = app.config_focus == ConfigFocus::DsdFile;
+
+ f.render_widget(
+ Paragraph::new(if app.params.zenoh { "[✓] Zenoh" } else { "[ ] Zenoh" })
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(focus_border(zenoh_focused)),
+ )
+ .style(focus_text(zenoh_focused)),
+ chunks[0],
+ );
+
+ f.render_widget(
+ Paragraph::new(if app.params.sim {
+ "[✓] Simulator"
+ } else {
+ "[ ] Simulator"
+ })
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(focus_border(sim_focused)),
+ )
+ .style(focus_text(sim_focused)),
+ chunks[1],
+ );
+
+ f.render_widget(
+ Paragraph::new(app.config_fieldname_input.as_str()).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title("Fieldname")
+ .border_style(focus_border(field_focused)),
+ ),
+ chunks[2],
+ );
+
+ f.render_widget(
+ Paragraph::new(app.config_dsd_input.as_str()).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title("DSD File")
+ .border_style(focus_border(dsd_focused)),
+ ),
+ chunks[3],
+ );
+}
+
+fn draw_config_components(f: &mut Frame, app: &App, area: Rect) {
+ let toggleables = app.toggleable_indices();
+ let col_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(area);
+
+ let left_count = (toggleables.len() + 1) / 2;
+ let (left, right) = toggleables.split_at(left_count);
+ draw_component_column(f, app, col_chunks[0], left, 0);
+ draw_component_column(f, app, col_chunks[1], right, left.len());
+}
+
+fn draw_component_column(
+ f: &mut Frame,
+ app: &App,
+ area: Rect,
+ comp_indices: &[usize],
+ focus_offset: usize,
+) {
+ if comp_indices.is_empty() {
+ return;
+ }
+ let rows: Vec = comp_indices.iter().map(|_| Constraint::Length(1)).collect();
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(rows)
+ .split(area);
+
+ for (local_i, &comp_idx) in comp_indices.iter().enumerate() {
+ let focused = app.config_focus == ConfigFocus::Component(focus_offset + local_i);
+ let comp = &app.components[comp_idx];
+ let def = &COMPONENT_DEFS[comp_idx];
+
+ let check = if comp.enabled { "✓" } else { " " };
+ let style = if focused {
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD)
+ } else if comp.enabled {
+ Style::default().fg(Color::White)
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+
+ if local_i < chunks.len() {
+ f.render_widget(
+ Paragraph::new(format!("[{check}] {}", def.name)).style(style),
+ chunks[local_i],
+ );
+ }
+ }
+}
+
+fn draw_start_button(f: &mut Frame, app: &App, area: Rect) {
+ let focused = app.config_focus == ConfigFocus::Start;
+ let has_running = app.components.iter().any(|c| c.state == ProcState::Running);
+ let label = if has_running {
+ " Continue → "
+ } else {
+ " Launch → "
+ };
+ let (border_style, text_style) = if focused {
+ (
+ Style::default().fg(Color::Yellow),
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ )
+ } else {
+ (
+ Style::default().fg(Color::Green),
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ )
+ };
+ f.render_widget(
+ Paragraph::new(label)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(border_style),
+ )
+ .style(text_style)
+ .alignment(Alignment::Center),
+ area,
+ );
+}
+
+// ─── Runtime screen ───────────────────────────────────────────────────────────
+
+pub fn draw_runtime(f: &mut Frame, app: &App) {
+ let area = f.size();
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
+ .split(area);
+
+ draw_runtime_header(f, app, chunks[0]);
+
+ if app.show_log_panel {
+ let cols = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
+ .split(chunks[1]);
+ draw_runtime_list(f, app, cols[0]);
+ draw_joint_logs(f, app, cols[1]);
+ } else {
+ draw_runtime_list(f, app, chunks[1]);
+ }
+
+ let hint = if app.show_log_panel {
+ " ↑↓/jk: select s: stop/start r: restart l: logs a: start all x: stop all v: hide logs Esc: config q: quit"
+ } else {
+ " ↑↓/jk: select s: stop/start r: restart l: logs a: start all x: stop all v: show logs Esc: config q: quit"
+ };
+ f.render_widget(
+ Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
+ chunks[2],
+ );
+}
+
+fn draw_runtime_header(f: &mut Frame, app: &App, area: Rect) {
+ let running = app
+ .components
+ .iter()
+ .filter(|c| c.state == ProcState::Running)
+ .count();
+ let total_enabled = app.components.iter().filter(|c| c.enabled).count();
+ let sim_badge = if app.params.sim { " [SIM]" } else { "" };
+ f.render_widget(
+ Paragraph::new(format!(
+ " Bit-Bots Teamplayer [{running}/{total_enabled} running]{sim_badge}"
+ ))
+ .block(Block::default().borders(Borders::ALL))
+ .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
+ area,
+ );
+}
+
+fn draw_runtime_list(f: &mut Frame, app: &App, area: Rect) {
+ let visible = app.runtime_visible_indices();
+ if visible.is_empty() {
+ f.render_widget(
+ Paragraph::new("No components enabled.").alignment(Alignment::Center),
+ area,
+ );
+ return;
+ }
+
+ let rows: Vec = visible.iter().map(|_| Constraint::Length(1)).collect();
+ let row_chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints(rows)
+ .split(area);
+
+ for (vis_i, &comp_idx) in visible.iter().enumerate() {
+ if vis_i >= row_chunks.len() {
+ break;
+ }
+ let selected = app.runtime_selected == vis_i;
+ let comp = &app.components[comp_idx];
+ let def = &COMPONENT_DEFS[comp_idx];
+
+ let row_style = if selected {
+ Style::default().bg(Color::DarkGray)
+ } else {
+ Style::default()
+ };
+ let spinner = match comp.state {
+ ProcState::Running => format!("{} ", spinner_char(app.tick_count)),
+ ProcState::Stopping => "… ".to_string(),
+ ProcState::Restarting => "↺ ".to_string(),
+ _ => " ".to_string(),
+ };
+
+ let line = Line::from(vec![
+ Span::styled(
+ if def.infrastructure { "·" } else { " " },
+ Style::default().fg(Color::DarkGray),
+ ),
+ Span::styled(spinner, Style::default().fg(Color::Green)),
+ Span::styled(
+ format!("[{}]", comp.state.label()),
+ proc_state_style(&comp.state).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ format!("{:<18}", def.name),
+ if selected {
+ Style::default()
+ .fg(Color::White)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ },
+ ),
+ Span::raw(" "),
+ Span::styled("s", Style::default().fg(Color::Yellow)),
+ Span::raw(":start/stop "),
+ Span::styled("r", Style::default().fg(Color::Yellow)),
+ Span::raw(":restart "),
+ Span::styled("l", Style::default().fg(Color::Yellow)),
+ Span::raw(":logs"),
+ ]);
+ f.render_widget(Paragraph::new(line).style(row_style), row_chunks[vis_i]);
+ }
+}
+
+fn tag_color(tag: &str) -> Color {
+ const PALETTE: &[Color] = &[
+ Color::Cyan,
+ Color::Green,
+ Color::Magenta,
+ Color::Yellow,
+ Color::LightBlue,
+ Color::LightGreen,
+ Color::LightMagenta,
+ Color::LightRed,
+ ];
+ let h = tag.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize));
+ PALETTE[h % PALETTE.len()]
+}
+
+/// Convert a raw log string (which may contain ANSI escape codes) into an owned ratatui Line.
+fn ansi_to_line(s: &str) -> Line<'static> {
+ use ansi_to_tui::IntoText;
+ match s.into_text() {
+ Ok(text) => Line::from(
+ text.lines
+ .into_iter()
+ .flat_map(|l| l.spans)
+ .map(|s| Span::styled(s.content.into_owned(), s.style))
+ .collect::>(),
+ ),
+ Err(_) => Line::from(s.to_owned()),
+ }
+}
+
+fn draw_joint_logs(f: &mut Frame, app: &App, area: Rect) {
+ let inner_h = area.height.saturating_sub(2) as usize;
+ let inner_w = area.width.saturating_sub(2) as usize;
+
+ let lines: Vec> = {
+ let logs = app.all_logs.lock().unwrap();
+ // Grab more entries than the panel height — wrapping will consume extra rows.
+ let skip = logs.len().saturating_sub(inner_h * 3);
+ logs.iter()
+ .skip(skip)
+ .map(|entry| {
+ if let Some(close) = entry.find("] ") {
+ let tag = &entry[..close + 1]; // "[CompName]"
+ let rest = &entry[close + 2..];
+ let mut spans: Vec> = vec![
+ Span::styled(
+ tag.to_owned(),
+ Style::default().fg(tag_color(tag)).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ ];
+ spans.extend(ansi_to_line(rest).spans);
+ Line::from(spans)
+ } else {
+ ansi_to_line(entry)
+ }
+ })
+ .collect()
+ };
+
+ // Estimate total visual rows so we can scroll to pin the view to the bottom.
+ let total_rows: usize = lines
+ .iter()
+ .map(|l| {
+ let chars: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
+ if inner_w > 0 { (chars + inner_w - 1) / inner_w } else { 1 }.max(1)
+ })
+ .sum();
+ let scroll = total_rows.saturating_sub(inner_h) as u16;
+
+ f.render_widget(
+ Paragraph::new(lines)
+ .block(Block::default().borders(Borders::ALL).title(" All Logs "))
+ .wrap(Wrap { trim: false })
+ .scroll((scroll, 0)),
+ area,
+ );
+}
+
+// ─── Logs screen ──────────────────────────────────────────────────────────────
+
+pub fn draw_logs(f: &mut Frame, app: &App, comp_idx: usize) {
+ let area = f.size();
+ let comp = &app.components[comp_idx];
+ let name = COMPONENT_DEFS[comp_idx].name;
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
+ .split(area);
+
+ f.render_widget(
+ Paragraph::new(format!(" Logs: {name} "))
+ .block(Block::default().borders(Borders::ALL))
+ .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
+ chunks[0],
+ );
+
+ let log_height = chunks[1].height as usize;
+ let lines: Vec> = {
+ let logs = comp.logs.lock().unwrap();
+ let total = logs.len();
+ let max_scroll = if total > log_height { total - log_height } else { 0 };
+ let scroll = app.log_scroll.min(max_scroll);
+ logs.iter()
+ .skip(scroll)
+ .take(log_height * 2) // extra entries so wrapped lines still fill the panel
+ .map(|s| ansi_to_line(s))
+ .collect()
+ };
+
+ f.render_widget(
+ Paragraph::new(lines)
+ .block(Block::default().borders(Borders::ALL))
+ .style(Style::default().fg(Color::White))
+ .wrap(Wrap { trim: false }),
+ chunks[1],
+ );
+
+ f.render_widget(
+ Paragraph::new(" ↑↓/jk/PgUp/PgDn: scroll g: top G: bottom Esc/q/l: back")
+ .style(Style::default().fg(Color::DarkGray)),
+ chunks[2],
+ );
+}
diff --git a/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py b/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py
index eeb6ef7ba..3091b1bcc 100644
--- a/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py
+++ b/src/bitbots_motion/bitbots_rl_walk/bitbots_rl_walk/walk.py
@@ -20,6 +20,7 @@
import numpy as np
import onnxruntime as rt
from ament_index_python import get_package_share_directory
+from rclpy.experimental.events_executor import EventsExecutor
from geometry_msgs.msg import Twist
from rclpy.node import Node
from sensor_msgs.msg import Imu, JointState
@@ -195,6 +196,10 @@ def main():
rclpy.init()
node = WalkNode()
- rclpy.spin(node)
- node.destroy_node()
- rclpy.try_shutdown()
+
+ executor = EventsExecutor()
+ executor.add_node(node)
+ try:
+ executor.spin()
+ except KeyboardInterrupt:
+ pass
diff --git a/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py b/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py
index d52c6e7a1..41c9d069c 100755
--- a/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py
+++ b/src/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication/bitbots_team_communication.py
@@ -3,6 +3,7 @@
import socket
import struct
import threading
+import time
from typing import Optional
import rclpy
@@ -63,24 +64,16 @@ def __init__(self):
self.tf_buffer = Buffer(node=self.node)
- self.run_spin_in_thread()
self.try_to_establish_connection()
+ self.logger.info("Successfully established connection.")
+
self.node.create_timer(1 / self.rate, self.send_message, callback_group=MutuallyExclusiveCallbackGroup())
- self.receive_forever()
- def spin(self):
- executor = EventsExecutor()
- executor.add_node(self.node)
- try:
- executor.spin()
- except KeyboardInterrupt:
- pass
+ receive_thread = threading.Thread(target=self.receive_forever, daemon=True)
+ receive_thread.start()
- def run_spin_in_thread(self):
- # Necessary in ROS2, else we are forever stuck receiving messages
- thread = threading.Thread(target=self.spin, daemon=True)
- thread.start()
+ self.logger.info("Initialization complete.")
def set_state_defaults(self):
self.gamestate: Optional[GameState] = None
@@ -100,8 +93,9 @@ def set_state_defaults(self):
def try_to_establish_connection(self):
# we will try multiple times till we manage to get a connection
while rclpy.ok() and not self.socket_communication.is_setup():
+ self.logger.info("Trying to establish connection...")
self.socket_communication.establish_connection()
- self.node.get_clock().sleep_for(Duration(seconds=1))
+ time.sleep(1)
def create_publishers(self):
self.team_data_publisher = self.node.create_publisher(TeamData, self.topics["team_data_topic"], qos_profile=1)
@@ -317,8 +311,14 @@ def convert_to_euler(self, quaternion: Quaternion):
def main():
rclpy.init(args=None)
- TeamCommunication()
-
+ tc = TeamCommunication()
+
+ executor = EventsExecutor()
+ executor.add_node(tc.node)
+ try:
+ executor.spin()
+ except KeyboardInterrupt:
+ pass
if __name__ == "__main__":
main()