From e6a6ac996c3a17a21b35ab90dcf728b7fa86fc40 Mon Sep 17 00:00:00 2001
From: mlisi1
Date: Wed, 24 Jun 2026 13:26:31 +0200
Subject: [PATCH 1/4] added color to ros2 param list with its variants and
flags
---
dendROS/dendROS.sh | 3 +
dendROS/dendros_param_list.py | 182 ++++++++++++++++++
install.sh | 3 +
test/unit/test_param_list.py | 334 ++++++++++++++++++++++++++++++++++
4 files changed, 522 insertions(+)
create mode 100644 dendROS/dendros_param_list.py
create mode 100644 test/unit/test_param_list.py
diff --git a/dendROS/dendROS.sh b/dendROS/dendROS.sh
index fbac6be..76c9d63 100644
--- a/dendROS/dendROS.sh
+++ b/dendROS/dendROS.sh
@@ -77,6 +77,9 @@ ros2() {
elif [[ "$1" == "action" && "$2" == "list" ]]; then
"$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_action_list.py"
return ${PIPESTATUS[0]}
+ elif [[ "$1" == "param" && "$2" == "list" ]]; then
+ "$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_param_list.py" "${@:3}"
+ return ${PIPESTATUS[0]}
else
"$_ROS2_BIN" "$@"
fi
diff --git a/dendROS/dendros_param_list.py b/dendROS/dendros_param_list.py
new file mode 100644
index 0000000..cd5c4d7
--- /dev/null
+++ b/dendROS/dendros_param_list.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""Colorize ros2 param list output using dendROS node colors."""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+try:
+ import yaml
+except ImportError:
+ yaml = None
+
+from lib.config_loader import load_config, merge_color_maps, resolve_node, resolve_node_style
+from lib.colors import RESET, _resolve_color
+from lib.global_config import load_global_config, get_node_colors_path
+
+
+def _load_shared_colors():
+ if yaml is None:
+ return {}, {}, {}
+ path = get_node_colors_path()
+ if not os.path.isfile(path):
+ return {}, {}, {}
+ try:
+ with open(path) as f:
+ data = yaml.safe_load(f) or {}
+ return (
+ data.get('color_map', {}),
+ data.get('tag_map', {}),
+ data.get('style_map', {}),
+ )
+ except Exception:
+ return {}, {}, {}
+
+
+def _scan_configs():
+ if yaml is None:
+ return []
+ seen, paths = set(), []
+ for prefix in os.environ.get('AMENT_PREFIX_PATH', '').split(':'):
+ share = os.path.join(prefix, 'share') if prefix else ''
+ if not share or not os.path.isdir(share):
+ continue
+ try:
+ for pkg in sorted(os.listdir(share)):
+ candidate = os.path.join(share, pkg, 'config', 'dendROS.yaml')
+ if os.path.isfile(candidate) and candidate not in seen:
+ seen.add(candidate)
+ paths.append(candidate)
+ except OSError:
+ pass
+ return paths
+
+
+def _badge(label, ansi_code, style):
+ if style == 'inverted':
+ return f'\033[{ansi_code};7m[{label}]{RESET}'
+ return f'\033[{ansi_code}m[{label}]{RESET}'
+
+
+def _parse_node_arg(argv):
+ """Return the first positional argument that looks like a node path (starts with '/')."""
+ for arg in argv:
+ if arg.startswith('/'):
+ return arg
+ return None
+
+
+def _split_param_type(param):
+ """Split 'name (type)' → (name, type_str) or (name, None)."""
+ if param.endswith(')') and ' (' in param:
+ name, rest = param.rsplit(' (', 1)
+ return name, rest[:-1]
+ return param, None
+
+
+def main():
+ cfg = load_global_config()
+ show_tag = cfg['show_tag_cli']
+ tag_style = cfg['tag_style']
+ unmatched_clr = cfg['unmatched_color']
+ unmatched_tag = cfg['unmatched_tag']
+ dim_unmatched = cfg['dim_unmatched']
+ unmatched_ansi = _resolve_color(unmatched_clr) if unmatched_clr else None
+
+ color_map, tag_map, style_map = _load_shared_colors()
+
+ if not color_map:
+ config_paths = _scan_configs()
+ tuples = []
+ for path in config_paths:
+ try:
+ c, t, m, s, k, _ = load_config(path)
+ tuples.append((c, t, m, s, k))
+ except Exception:
+ pass
+ if tuples:
+ c0, t0, m0, s0, k0 = tuples[0]
+ color_map, tag_map, _, style_map, _ = merge_color_maps(
+ c0, t0, m0, s0, k0, tuples[1:]
+ )
+
+ current_ansi = None # color code of the most recent node header
+ current_style = None # tag_style of the most recent node header
+
+ # Pre-seed color from the node argument so bare output (no header line) is colored.
+ # When a node header IS in the output the header parser will simply overwrite this.
+ node_arg = _parse_node_arg(sys.argv[1:])
+ if node_arg:
+ _ansi, _label = resolve_node(node_arg, color_map, tag_map)
+ if _ansi:
+ current_ansi = _ansi
+ current_style = resolve_node_style(node_arg, style_map) or tag_style
+ elif unmatched_ansi:
+ current_ansi = unmatched_ansi
+ current_style = tag_style
+
+ for line in sys.stdin:
+ raw = line.rstrip('\n')
+ stripped = raw.lstrip()
+
+ if not raw:
+ sys.stdout.write('\n')
+ continue
+
+ indent = raw[: len(raw) - len(stripped)]
+
+ # Node header: '/node_name:' — no leading whitespace, ends with ':'
+ if not indent and stripped.endswith(':'):
+ node_name = stripped[:-1] # drop the trailing ':'
+ ansi_code, label = resolve_node(node_name, color_map, tag_map)
+
+ if ansi_code:
+ current_ansi = ansi_code
+ node_style = resolve_node_style(node_name, style_map) or tag_style
+ current_style = node_style
+ colored_name = f'\033[{ansi_code}m{node_name}:{RESET}'
+ if show_tag and label:
+ badge = _badge(label, ansi_code, node_style)
+ out = f'{badge} {colored_name}'
+ else:
+ out = colored_name
+ elif unmatched_ansi:
+ current_ansi = unmatched_ansi
+ current_style = tag_style
+ colored_name = f'\033[{unmatched_ansi}m{node_name}:{RESET}'
+ if show_tag and unmatched_tag:
+ badge = _badge(unmatched_tag, unmatched_ansi, tag_style)
+ out = f'{badge} {colored_name}'
+ else:
+ out = colored_name
+ elif dim_unmatched:
+ current_ansi = None
+ current_style = None
+ out = f'\033[2m{node_name}:{RESET}'
+ else:
+ current_ansi = None
+ current_style = None
+ out = raw
+
+ sys.stdout.write(out + '\n')
+ sys.stdout.flush()
+ continue
+
+ # Param line: indented under a node header
+ param_name, type_str = _split_param_type(stripped)
+ type_part = f' (\033[2m{type_str}{RESET})' if type_str else ''
+
+ if current_ansi:
+ out = f'{indent}\033[{current_ansi}m\033[2m{param_name}{RESET}{type_part}'
+ elif dim_unmatched:
+ out = f'{indent}\033[2m{param_name}{RESET}{type_part}'
+ else:
+ out = f'{indent}{param_name}{type_part}'
+
+ sys.stdout.write(out + '\n')
+ sys.stdout.flush()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/install.sh b/install.sh
index d1c22cc..4024a6d 100755
--- a/install.sh
+++ b/install.sh
@@ -52,14 +52,17 @@ $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_node_info.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_node_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_service_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_action_list.py" "$INSTALL_DIR/"
+$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_param_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendROS.sh" "$INSTALL_DIR/"
$SUDO cp -r "${SCRIPT_DIR}/dendROS/lib" "$INSTALL_DIR/"
$SUDO chmod +x "$INSTALL_DIR/dendROS_pipe.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_config.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_init.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_node_list.py"
+$SUDO chmod +x "$INSTALL_DIR/dendros_node_info.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_service_list.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_action_list.py"
+$SUDO chmod +x "$INSTALL_DIR/dendros_param_list.py"
$SUDO chmod 644 "$INSTALL_DIR/dendROS.sh"
echo -e "${GREEN}[DendROS] Files installed to ${INSTALL_DIR}${RESET}"
diff --git a/test/unit/test_param_list.py b/test/unit/test_param_list.py
new file mode 100644
index 0000000..c207505
--- /dev/null
+++ b/test/unit/test_param_list.py
@@ -0,0 +1,334 @@
+"""Tests for dendros_param_list.py colorization."""
+
+import os
+import sys
+import subprocess
+
+import pytest
+import yaml
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+PARAM_LIST_PATH = os.path.join(REPO_ROOT, 'dendROS', 'dendros_param_list.py')
+
+from conftest import assert_segment_colored, assert_segment_uncolored, colored_segments, strip_ansi
+
+
+# ── Helper ────────────────────────────────────────────────────────────────────
+
+def run_param_list(tmp_prefix, lines, global_cfg=None, node_colors=None,
+ argv_extra=None, timeout=10):
+ """Run dendros_param_list.py with text lines as stdin; return (stdout, stderr, rc).
+
+ argv_extra: list of extra args appended to the command (mirrors ${@:3} from the shell).
+ """
+ env = os.environ.copy()
+ env['AMENT_PREFIX_PATH'] = tmp_prefix
+ env.pop('ROS_DISTRO', None)
+ env['HOME'] = tmp_prefix
+
+ cfg_dir = os.path.join(tmp_prefix, '.config', 'dendROS')
+ os.makedirs(cfg_dir, exist_ok=True)
+
+ if global_cfg:
+ with open(os.path.join(cfg_dir, 'defaults.yaml'), 'w') as f:
+ yaml.dump(global_cfg, f)
+ if node_colors:
+ with open(os.path.join(cfg_dir, 'node_colors.yaml'), 'w') as f:
+ yaml.dump(node_colors, f)
+
+ stdin = '\n'.join(lines) + '\n'
+ cmd = [sys.executable, PARAM_LIST_PATH] + (argv_extra or [])
+ result = subprocess.run(
+ cmd,
+ input=stdin.encode(),
+ capture_output=True,
+ env=env,
+ timeout=timeout,
+ )
+ return result.stdout.decode(), result.stderr.decode(), result.returncode
+
+
+# ── Node header colorization ──────────────────────────────────────────────────
+
+class TestParamNodeHeader:
+ """Node header lines (/node_name:) are colored with the group's color."""
+
+ def test_matched_node_header_colored(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'], node_colors=nc)
+ assert '\033[32m/talker:\033[0m' in stdout
+
+ def test_unmatched_node_header_passthrough(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/unknown_node:'], node_colors=nc)
+ assert_segment_uncolored(stdout, '/unknown_node:')
+
+ def test_multiple_node_headers(self, tmp_path):
+ nc = {'color_map': {'talker': '32', 'listener': '34'},
+ 'tag_map': {'talker': '', 'listener': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/talker:', ' p1', '/listener:', ' p2'], node_colors=nc)
+ assert '\033[32m/talker:\033[0m' in stdout
+ assert '\033[34m/listener:\033[0m' in stdout
+
+ def test_namespaced_node_header(self, tmp_path):
+ nc = {'color_map': {'talker': '33'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/my_ns/talker:'], node_colors=nc)
+ assert '\033[33m/my_ns/talker:\033[0m' in stdout
+
+ def test_wildcard_pattern(self, tmp_path):
+ nc = {'color_map': {'nav2_*': '35'}, 'tag_map': {'nav2_*': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/nav2_planner:'], node_colors=nc)
+ assert '\033[35m/nav2_planner:\033[0m' in stdout
+
+ def test_no_config_passthrough(self, tmp_path):
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'])
+ assert '\033[' not in stdout
+ assert '/talker:' in stdout
+
+ def test_empty_lines_preserved(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/talker:', '', ' p1'], node_colors=nc)
+ assert '' in stdout.splitlines()
+
+
+# ── Param line colorization (dimmed) ─────────────────────────────────────────
+
+class TestParamLines:
+ """Param lines (indented) use the current node's color, dimmed."""
+
+ def test_param_colored_dim_with_node_color(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:', ' use_sim_time'],
+ node_colors=nc)
+ assert '\033[32m\033[2muse_sim_time\033[0m' in stdout
+
+ def test_param_indent_preserved(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:', ' use_sim_time'],
+ node_colors=nc)
+ line = [l for l in stdout.splitlines() if 'use_sim_time' in l][0]
+ plain = strip_ansi(line)
+ assert plain.startswith(' ')
+
+ def test_param_under_unmatched_node_passthrough(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:', ' some_param'], node_colors=nc)
+ assert_segment_uncolored(stdout, 'some_param')
+
+ def test_params_inherit_node_color_change(self, tmp_path):
+ nc = {'color_map': {'talker': '32', 'listener': '34'},
+ 'tag_map': {'talker': '', 'listener': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path),
+ ['/talker:', ' p1', '/listener:', ' p2'],
+ node_colors=nc)
+ assert '\033[32m\033[2mp1\033[0m' in stdout
+ assert '\033[34m\033[2mp2\033[0m' in stdout
+
+ def test_multiple_params_same_node(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/talker:', ' param_a', ' param_b'], node_colors=nc)
+ assert '\033[32m\033[2mparam_a\033[0m' in stdout
+ assert '\033[32m\033[2mparam_b\033[0m' in stdout
+
+ def test_param_no_config_passthrough(self, tmp_path):
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:', ' use_sim_time'])
+ assert '\033[' not in stdout
+
+
+# ── --param-type flag: type annotation dimmed ─────────────────────────────────
+
+class TestParamListTypeFlag:
+ """--param-type appends ' (type)'; type content must be rendered dim."""
+
+ def test_type_dimmed_for_matched_param(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/talker:', ' use_sim_time (bool)'], node_colors=nc)
+ assert '(\033[2mbool\033[0m)' in stdout
+
+ def test_type_dimmed_for_unmatched_param(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:', ' foo (integer)'], node_colors=nc)
+ assert '(\033[2minteger\033[0m)' in stdout
+
+ def test_param_name_and_type_both_present(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/talker:', ' my_param (string)'], node_colors=nc)
+ assert '\033[32m\033[2mmy_param\033[0m' in stdout
+ assert '(\033[2mstring\033[0m)' in stdout
+
+ def test_no_type_no_parens(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:', ' my_param'],
+ node_colors=nc)
+ assert '(' not in strip_ansi(stdout)
+
+
+# ── Tag badge ─────────────────────────────────────────────────────────────────
+
+class TestParamListTag:
+ """Tag badge appears left of the node header when show_tag_cli is true."""
+
+ def test_tag_shown_before_node_header(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'], node_colors=nc)
+ assert '[TLK]' in stdout
+ line = [l for l in stdout.splitlines() if '/talker:' in strip_ansi(l)][0]
+ assert line.index('[TLK]') < line.index('/talker:')
+
+ def test_tag_hidden_when_show_tag_cli_false(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'],
+ node_colors=nc,
+ global_cfg={'show_tag_cli': False})
+ assert '[TLK]' not in stdout
+
+ def test_inverted_tag_style(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'},
+ 'style_map': {'talker': 'inverted'}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'], node_colors=nc)
+ assert '\033[32;7m[TLK]' in stdout
+
+ def test_unmatched_tag(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:'], node_colors=nc,
+ global_cfg={'unmatched_color': 'white', 'unmatched_tag': '?'})
+ assert '[?]' in stdout
+
+ def test_tag_not_shown_on_param_lines(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:', ' p1'], node_colors=nc)
+ param_line = [l for l in stdout.splitlines() if 'p1' in strip_ansi(l)][0]
+ assert '[TLK]' not in param_line
+
+
+# ── Unmatched / dim_unmatched ─────────────────────────────────────────────────
+
+class TestParamListUnmatched:
+
+ def test_unmatched_color_applied_to_header(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:'], node_colors=nc,
+ global_cfg={'unmatched_color': 'cyan'})
+ assert '\033[' in stdout
+
+ def test_dim_unmatched_header(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:'], node_colors=nc,
+ global_cfg={'dim_unmatched': True})
+ assert '\033[2m/unknown:\033[0m' in stdout
+
+ def test_dim_unmatched_param(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(
+ str(tmp_path), ['/unknown:', ' my_param'], node_colors=nc,
+ global_cfg={'dim_unmatched': True})
+ assert '\033[2mmy_param\033[0m' in stdout
+
+ def test_passthrough_when_no_config(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['/unknown:', ' p1'], node_colors=nc)
+ assert_segment_uncolored(stdout, '/unknown:')
+ assert_segment_uncolored(stdout, 'p1')
+
+
+# ── AMENT_PREFIX_PATH fallback ────────────────────────────────────────────────
+
+class TestParamListFallback:
+
+ def test_fallback_scan_colors_header(self, tmp_path):
+ cfg_dir = tmp_path / 'share' / 'my_pkg' / 'config'
+ cfg_dir.mkdir(parents=True)
+ (cfg_dir / 'dendROS.yaml').write_text(yaml.dump({
+ 'groups': {'loc': {'color': 'bold blue', 'label': 'LOC',
+ 'nodes': ['talker']}}
+ }))
+ stdout, _, _ = run_param_list(str(tmp_path), ['/talker:'])
+ assert '\033[' in stdout
+
+
+# ── ros2 param list /node_name — bare output format ──────────────────────────
+
+class TestParamListNodeArg:
+ """When a node path is passed as argv, bare param lines (no header, no indent)
+ are colored using that node's color, pre-seeded before reading stdin."""
+
+ def test_bare_params_colored_via_node_arg(self, tmp_path):
+ """Bare param names (no header, no indentation) colored when node arg given."""
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time', 'publish_rate'],
+ node_colors=nc, argv_extra=['/talker'])
+ assert '\033[32m\033[2muse_sim_time\033[0m' in stdout
+ assert '\033[32m\033[2mpublish_rate\033[0m' in stdout
+
+ def test_bare_params_with_type_flag(self, tmp_path):
+ """--param-type type dimming works for bare output."""
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time (bool)'],
+ node_colors=nc, argv_extra=['/talker'])
+ assert '\033[32m\033[2muse_sim_time\033[0m' in stdout
+ assert '(\033[2mbool\033[0m)' in stdout
+
+ def test_node_arg_with_namespace(self, tmp_path):
+ """Namespaced node path resolved by basename matching."""
+ nc = {'color_map': {'talker': '33'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time'],
+ node_colors=nc, argv_extra=['/my_ns/talker'])
+ assert '\033[33m\033[2muse_sim_time\033[0m' in stdout
+
+ def test_node_arg_unmatched_passthrough(self, tmp_path):
+ """Unknown node arg leaves bare params uncolored."""
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time'],
+ node_colors=nc, argv_extra=['/unknown_node'])
+ assert_segment_uncolored(stdout, 'use_sim_time')
+
+ def test_node_arg_unmatched_dim(self, tmp_path):
+ """dim_unmatched applies to bare params from an unmatched node arg."""
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time'],
+ node_colors=nc, argv_extra=['/unknown_node'],
+ global_cfg={'dim_unmatched': True})
+ assert '\033[2muse_sim_time\033[0m' in stdout
+
+ def test_node_arg_unmatched_color(self, tmp_path):
+ """unmatched_color pre-seeds bare params from an unmatched node arg."""
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time'],
+ node_colors=nc, argv_extra=['/unknown_node'],
+ global_cfg={'unmatched_color': 'cyan'})
+ assert '\033[' in stdout
+
+ def test_header_in_output_overrides_node_arg(self, tmp_path):
+ """When output includes a node header, header color wins (even if arg differs)."""
+ nc = {'color_map': {'talker': '32', 'listener': '34'},
+ 'tag_map': {'talker': '', 'listener': ''}, 'style_map': {}}
+ # arg says /talker but output has /listener: header — listener color should apply
+ stdout, _, _ = run_param_list(str(tmp_path), ['/listener:', ' p1'],
+ node_colors=nc, argv_extra=['/talker'])
+ assert '\033[34m\033[2mp1\033[0m' in stdout
+
+ def test_flag_args_ignored_for_node_detection(self, tmp_path):
+ """Flags like --param-type passed in argv_extra do not confuse node detection."""
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time (bool)'],
+ node_colors=nc,
+ argv_extra=['--param-type', '/talker'])
+ assert '\033[32m\033[2muse_sim_time\033[0m' in stdout
+
+ def test_no_node_arg_bare_params_passthrough(self, tmp_path):
+ """Without a node arg, bare param names are passed through without color."""
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_param_list(str(tmp_path), ['use_sim_time'], node_colors=nc)
+ assert_segment_uncolored(stdout, 'use_sim_time')
From f5a1921e485ce3211cb318e1f87fa0ff17034d49 Mon Sep 17 00:00:00 2001
From: mlisi1
Date: Wed, 24 Jun 2026 13:35:36 +0200
Subject: [PATCH 2/4] added formatting and color to ros2 param describe
---
dendROS/dendROS.sh | 3 +
dendROS/dendros_param_describe.py | 167 ++++++++++++++++++
install.sh | 2 +
test/unit/test_param_describe.py | 273 ++++++++++++++++++++++++++++++
4 files changed, 445 insertions(+)
create mode 100644 dendROS/dendros_param_describe.py
create mode 100644 test/unit/test_param_describe.py
diff --git a/dendROS/dendROS.sh b/dendROS/dendROS.sh
index 76c9d63..660d615 100644
--- a/dendROS/dendROS.sh
+++ b/dendROS/dendROS.sh
@@ -80,6 +80,9 @@ ros2() {
elif [[ "$1" == "param" && "$2" == "list" ]]; then
"$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_param_list.py" "${@:3}"
return ${PIPESTATUS[0]}
+ elif [[ "$1" == "param" && "$2" == "describe" ]]; then
+ "$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_param_describe.py" "${@:3}"
+ return ${PIPESTATUS[0]}
else
"$_ROS2_BIN" "$@"
fi
diff --git a/dendROS/dendros_param_describe.py b/dendROS/dendros_param_describe.py
new file mode 100644
index 0000000..5ecc4bf
--- /dev/null
+++ b/dendROS/dendros_param_describe.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""Colorize and format ros2 param describe output using dendROS node colors."""
+
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+try:
+ import yaml
+except ImportError:
+ yaml = None
+
+from lib.config_loader import load_config, merge_color_maps, resolve_node, resolve_node_style
+from lib.colors import RESET, _resolve_color
+from lib.global_config import load_global_config, get_node_colors_path
+
+_PARAM_NAME_RE = re.compile(r'^(Parameter name):\s+(.*)')
+
+
+def _load_shared_colors():
+ if yaml is None:
+ return {}, {}, {}
+ path = get_node_colors_path()
+ if not os.path.isfile(path):
+ return {}, {}, {}
+ try:
+ with open(path) as f:
+ data = yaml.safe_load(f) or {}
+ return (
+ data.get('color_map', {}),
+ data.get('tag_map', {}),
+ data.get('style_map', {}),
+ )
+ except Exception:
+ return {}, {}, {}
+
+
+def _scan_configs():
+ if yaml is None:
+ return []
+ seen, paths = set(), []
+ for prefix in os.environ.get('AMENT_PREFIX_PATH', '').split(':'):
+ share = os.path.join(prefix, 'share') if prefix else ''
+ if not share or not os.path.isdir(share):
+ continue
+ try:
+ for pkg in sorted(os.listdir(share)):
+ candidate = os.path.join(share, pkg, 'config', 'dendROS.yaml')
+ if os.path.isfile(candidate) and candidate not in seen:
+ seen.add(candidate)
+ paths.append(candidate)
+ except OSError:
+ pass
+ return paths
+
+
+def _badge(label, ansi_code, style):
+ if style == 'inverted':
+ return f'\033[{ansi_code};7m[{label}]{RESET}'
+ return f'\033[{ansi_code}m[{label}]{RESET}'
+
+
+def _parse_node_arg(argv):
+ """Return the first positional argument that looks like a node path (starts with '/')."""
+ for arg in argv:
+ if arg.startswith('/'):
+ return arg
+ return None
+
+
+def _format_key_value(indent, key, value):
+ """Dim the key label; bold the section header when there is no value."""
+ if value:
+ return f'{indent}\033[2m{key}:\033[0m {value}'
+ return f'{indent}\033[1m{key}:\033[0m'
+
+
+def _colorize_line(raw, ansi_code, label, node_style,
+ unmatched_ansi, unmatched_tag, tag_style,
+ show_tag, dim_unmatched):
+ """Return a formatted version of one output line."""
+ if not raw.strip():
+ return raw
+
+ # ── Parameter name block header ──────────────────────────────────────────
+ m = _PARAM_NAME_RE.match(raw)
+ if m:
+ param_name = m.group(2).strip()
+ dim_label = f'\033[2mParameter name:\033[0m'
+
+ if ansi_code:
+ colored = f'\033[{ansi_code};1m{param_name}\033[0m'
+ if show_tag and label:
+ return f'{_badge(label, ansi_code, node_style)} {dim_label} {colored}'
+ return f'{dim_label} {colored}'
+
+ if unmatched_ansi:
+ colored = f'\033[{unmatched_ansi};1m{param_name}\033[0m'
+ if show_tag and unmatched_tag:
+ return f'{_badge(unmatched_tag, unmatched_ansi, tag_style)} {dim_label} {colored}'
+ return f'{dim_label} {colored}'
+
+ if dim_unmatched:
+ return f'{dim_label} \033[2m{param_name}\033[0m'
+
+ return f'{dim_label} {param_name}'
+
+ # ── Key: value lines (all indented fields) ────────────────────────────────
+ stripped = raw.lstrip()
+ indent = raw[:len(raw) - len(stripped)]
+
+ if ':' in stripped:
+ key, _, rest = stripped.partition(':')
+ value = rest.lstrip()
+ return _format_key_value(indent, key, value)
+
+ return raw
+
+
+def main():
+ cfg = load_global_config()
+ show_tag = cfg['show_tag_cli']
+ tag_style = cfg['tag_style']
+ unmatched_clr = cfg['unmatched_color']
+ unmatched_tag = cfg['unmatched_tag']
+ dim_unmatched = cfg['dim_unmatched']
+ unmatched_ansi = _resolve_color(unmatched_clr) if unmatched_clr else None
+
+ color_map, tag_map, style_map = _load_shared_colors()
+
+ if not color_map:
+ config_paths = _scan_configs()
+ tuples = []
+ for path in config_paths:
+ try:
+ c, t, m, s, k, _ = load_config(path)
+ tuples.append((c, t, m, s, k))
+ except Exception:
+ pass
+ if tuples:
+ c0, t0, m0, s0, k0 = tuples[0]
+ color_map, tag_map, _, style_map, _ = merge_color_maps(
+ c0, t0, m0, s0, k0, tuples[1:]
+ )
+
+ node_arg = _parse_node_arg(sys.argv[1:])
+ if node_arg:
+ ansi_code, label = resolve_node(node_arg, color_map, tag_map)
+ node_style = (resolve_node_style(node_arg, style_map) or tag_style) if ansi_code else tag_style
+ else:
+ ansi_code, label, node_style = None, None, tag_style
+
+ for line in sys.stdin:
+ raw = line.rstrip('\n')
+ out = _colorize_line(
+ raw, ansi_code, label, node_style,
+ unmatched_ansi, unmatched_tag, tag_style,
+ show_tag, dim_unmatched,
+ )
+ sys.stdout.write(out + '\n')
+ sys.stdout.flush()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/install.sh b/install.sh
index 4024a6d..eeac30b 100755
--- a/install.sh
+++ b/install.sh
@@ -53,6 +53,7 @@ $SUDO cp "${SCRIPT_DIR}/dendROS/dendros_node_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_service_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_action_list.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_param_list.py" "$INSTALL_DIR/"
+$SUDO cp "${SCRIPT_DIR}/dendROS/dendros_param_describe.py" "$INSTALL_DIR/"
$SUDO cp "${SCRIPT_DIR}/dendROS/dendROS.sh" "$INSTALL_DIR/"
$SUDO cp -r "${SCRIPT_DIR}/dendROS/lib" "$INSTALL_DIR/"
$SUDO chmod +x "$INSTALL_DIR/dendROS_pipe.py"
@@ -63,6 +64,7 @@ $SUDO chmod +x "$INSTALL_DIR/dendros_node_info.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_service_list.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_action_list.py"
$SUDO chmod +x "$INSTALL_DIR/dendros_param_list.py"
+$SUDO chmod +x "$INSTALL_DIR/dendros_param_describe.py"
$SUDO chmod 644 "$INSTALL_DIR/dendROS.sh"
echo -e "${GREEN}[DendROS] Files installed to ${INSTALL_DIR}${RESET}"
diff --git a/test/unit/test_param_describe.py b/test/unit/test_param_describe.py
new file mode 100644
index 0000000..c4656f7
--- /dev/null
+++ b/test/unit/test_param_describe.py
@@ -0,0 +1,273 @@
+"""Tests for dendros_param_describe.py colorization and formatting."""
+
+import os
+import sys
+import subprocess
+
+import pytest
+import yaml
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+PARAM_DESCRIBE_PATH = os.path.join(REPO_ROOT, 'dendROS', 'dendros_param_describe.py')
+
+from conftest import assert_segment_colored, assert_segment_uncolored, colored_segments, strip_ansi
+
+# ── Fixtures ──────────────────────────────────────────────────────────────────
+
+_SIMPLE = [
+ 'Parameter name: use_sim_time',
+ ' Type: boolean',
+ ' Constraints:',
+]
+
+_WITH_DESC = [
+ 'Parameter name: my_param',
+ ' Type: string',
+ ' Description: Controls the output rate.',
+ ' Constraints:',
+ ' Read only: true',
+]
+
+_MULTI = [
+ 'Parameter name: use_sim_time',
+ ' Type: boolean',
+ ' Constraints:',
+ 'Parameter name: publish_rate',
+ ' Type: double',
+ ' Description: Rate in Hz.',
+ ' Constraints:',
+ ' Min value: 0.0',
+ ' Max value: 100.0',
+ ' Step: 0.0',
+]
+
+
+# ── Helper ────────────────────────────────────────────────────────────────────
+
+def run_describe(tmp_prefix, lines, global_cfg=None, node_colors=None,
+ argv_extra=None, timeout=10):
+ """Run dendros_param_describe.py with text lines as stdin."""
+ env = os.environ.copy()
+ env['AMENT_PREFIX_PATH'] = tmp_prefix
+ env.pop('ROS_DISTRO', None)
+ env['HOME'] = tmp_prefix
+
+ cfg_dir = os.path.join(tmp_prefix, '.config', 'dendROS')
+ os.makedirs(cfg_dir, exist_ok=True)
+
+ if global_cfg:
+ with open(os.path.join(cfg_dir, 'defaults.yaml'), 'w') as f:
+ yaml.dump(global_cfg, f)
+ if node_colors:
+ with open(os.path.join(cfg_dir, 'node_colors.yaml'), 'w') as f:
+ yaml.dump(node_colors, f)
+
+ stdin = '\n'.join(lines) + '\n'
+ cmd = [sys.executable, PARAM_DESCRIBE_PATH] + (argv_extra or [])
+ result = subprocess.run(
+ cmd, input=stdin.encode(), capture_output=True, env=env, timeout=timeout,
+ )
+ return result.stdout.decode(), result.stderr.decode(), result.returncode
+
+
+# ── Parameter name header ─────────────────────────────────────────────────────
+
+class TestParamDescribeHeader:
+ """'Parameter name: X' line: dim label, bold+node-colored param name."""
+
+ def test_param_name_label_dimmed(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[2mParameter name:\033[0m' in stdout
+
+ def test_param_name_value_bold_node_color(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[32;1muse_sim_time\033[0m' in stdout
+
+ def test_param_name_unmatched_passthrough(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/unknown'])
+ # label still dimmed, name not colored
+ assert '\033[2mParameter name:\033[0m' in stdout
+ assert '\033[32;1m' not in stdout
+
+ def test_param_name_no_node_arg(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc)
+ # no color on param name but label is still formatted
+ assert '\033[2mParameter name:\033[0m' in stdout
+
+ def test_namespaced_node_resolved_by_basename(self, tmp_path):
+ nc = {'color_map': {'talker': '33'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/my_ns/talker'])
+ assert '\033[33;1muse_sim_time\033[0m' in stdout
+
+ def test_wildcard_pattern(self, tmp_path):
+ nc = {'color_map': {'nav2_*': '35'}, 'tag_map': {'nav2_*': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/nav2_planner'])
+ assert '\033[35;1muse_sim_time\033[0m' in stdout
+
+ def test_multi_param_each_header_colored(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _MULTI, node_colors=nc,
+ argv_extra=['/talker'])
+ headers = [l for l in stdout.splitlines() if 'Parameter name:' in strip_ansi(l)]
+ assert len(headers) == 2
+ for h in headers:
+ assert '\033[32;1m' in h
+
+
+# ── Field formatting ──────────────────────────────────────────────────────────
+
+class TestParamDescribeFields:
+ """Indented field lines: key dimmed, value plain; section headers bold."""
+
+ def test_type_key_dimmed(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[2mType:\033[0m' in stdout
+
+ def test_type_value_plain(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ type_line = [l for l in stdout.splitlines() if 'Type:' in strip_ansi(l)][0]
+ # 'boolean' should appear outside any color code
+ segs = colored_segments(type_line)
+ assert any('boolean' in t and c is None for t, c in segs)
+
+ def test_description_key_dimmed(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _WITH_DESC, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[2mDescription:\033[0m' in stdout
+
+ def test_constraints_header_bold(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[1mConstraints:\033[0m' in stdout
+
+ def test_read_only_key_dimmed(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _WITH_DESC, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[2mRead only:\033[0m' in stdout
+
+ def test_range_constraints_formatted(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _MULTI, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[2mMin value:\033[0m' in stdout
+ assert '\033[2mMax value:\033[0m' in stdout
+ assert '\033[2mStep:\033[0m' in stdout
+
+ def test_formatting_applied_without_node_color(self, tmp_path):
+ """Field formatting applies even when the node has no config color."""
+ stdout, _, _ = run_describe(str(tmp_path), _WITH_DESC)
+ assert '\033[2mType:\033[0m' in stdout
+ assert '\033[1mConstraints:\033[0m' in stdout
+ assert '\033[2mDescription:\033[0m' in stdout
+
+ def test_indent_preserved(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _WITH_DESC, node_colors=nc,
+ argv_extra=['/talker'])
+ lines = stdout.splitlines()
+ type_line = [strip_ansi(l) for l in lines if 'Type:' in strip_ansi(l)][0]
+ constraint_line = [strip_ansi(l) for l in lines if 'Read only:' in strip_ansi(l)][0]
+ assert type_line.startswith(' ')
+ assert constraint_line.startswith(' ')
+
+ def test_empty_lines_preserved(self, tmp_path):
+ lines = _SIMPLE + ['', 'Parameter name: other_param', ' Type: integer', ' Constraints:']
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': ''}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), lines, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '' in stdout.splitlines()
+
+
+# ── Tag badge ─────────────────────────────────────────────────────────────────
+
+class TestParamDescribeTag:
+
+ def test_tag_shown_before_param_name(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ header = [l for l in stdout.splitlines() if 'Parameter name:' in strip_ansi(l)][0]
+ assert '[TLK]' in header
+ assert header.index('[TLK]') < header.index('Parameter name:')
+
+ def test_tag_hidden_when_show_tag_cli_false(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'],
+ global_cfg={'show_tag_cli': False})
+ assert '[TLK]' not in stdout
+
+ def test_inverted_tag_style(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'},
+ 'style_map': {'talker': 'inverted'}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/talker'])
+ assert '\033[32;7m[TLK]' in stdout
+
+ def test_tag_not_shown_on_field_lines(self, tmp_path):
+ nc = {'color_map': {'talker': '32'}, 'tag_map': {'talker': 'TLK'}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _WITH_DESC, node_colors=nc,
+ argv_extra=['/talker'])
+ for line in stdout.splitlines():
+ if 'Type:' in strip_ansi(line) or 'Description:' in strip_ansi(line):
+ assert '[TLK]' not in line
+
+ def test_unmatched_tag(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/unknown'],
+ global_cfg={'unmatched_color': 'white', 'unmatched_tag': '?'})
+ assert '[?]' in stdout
+
+
+# ── Unmatched / dim_unmatched ─────────────────────────────────────────────────
+
+class TestParamDescribeUnmatched:
+
+ def test_unmatched_color_applied_to_param_name(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/unknown'],
+ global_cfg={'unmatched_color': 'cyan'})
+ assert '\033[' in stdout
+ # at least the param name should be colored
+ header = [l for l in stdout.splitlines() if 'Parameter name:' in strip_ansi(l)][0]
+ assert '\033[' in header
+
+ def test_dim_unmatched_param_name(self, tmp_path):
+ nc = {'color_map': {}, 'tag_map': {}, 'style_map': {}}
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, node_colors=nc,
+ argv_extra=['/unknown'],
+ global_cfg={'dim_unmatched': True})
+ assert '\033[2muse_sim_time\033[0m' in stdout
+
+
+# ── AMENT_PREFIX_PATH fallback ────────────────────────────────────────────────
+
+class TestParamDescribeFallback:
+
+ def test_fallback_scan_colors_param_name(self, tmp_path):
+ cfg_dir = tmp_path / 'share' / 'my_pkg' / 'config'
+ cfg_dir.mkdir(parents=True)
+ (cfg_dir / 'dendROS.yaml').write_text(yaml.dump({
+ 'groups': {'loc': {'color': 'bold blue', 'label': 'LOC',
+ 'nodes': ['talker']}}
+ }))
+ stdout, _, _ = run_describe(str(tmp_path), _SIMPLE, argv_extra=['/talker'])
+ assert '\033[' in stdout
From 1e0ebaf74e7368316868e530fcbd1170adfa8db6 Mon Sep 17 00:00:00 2001
From: mlisi1
Date: Wed, 24 Jun 2026 16:22:01 +0200
Subject: [PATCH 3/4] added params change alert
---
dendROS/dendROS_pipe.py | 12 +
dendROS/dendros_config.py | 23 +-
dendROS/lib/global_config.py | 17 +-
dendROS/lib/param_watcher.py | 274 ++++++++++++++++++
test/unit/conftest.py | 4 +-
test/unit/test_global_config.py | 6 +
test/unit/test_param_watcher.py | 484 ++++++++++++++++++++++++++++++++
7 files changed, 808 insertions(+), 12 deletions(-)
create mode 100644 dendROS/lib/param_watcher.py
create mode 100644 test/unit/test_param_watcher.py
diff --git a/dendROS/dendROS_pipe.py b/dendROS/dendROS_pipe.py
index 0fe84a1..9d125bb 100755
--- a/dendROS/dendROS_pipe.py
+++ b/dendROS/dendROS_pipe.py
@@ -34,6 +34,7 @@
from lib.global_config import load_global_config, get_node_colors_path
import lib.crash_alert as ca
import lib.traceback_color as tc
+import lib.param_watcher as pw
_DEBUG = os.environ.get('DENDROS_DEBUG', '') not in ('', '0')
@@ -150,6 +151,12 @@ def main():
if config_path:
_save_node_colors(color_map, tag_map, style_map)
+ param_alert = bool(global_cfg.get('param_change_alert', False))
+ param_alert_scope = global_cfg.get('param_change_alert_scope', 'tracked')
+ param_alert_style = global_cfg.get('param_change_alert_style', 'inline')
+ if param_alert:
+ pw.setup(color_map, tag_map, param_alert_scope)
+
show_tag = defaults.get('show_group_tag', True)
color_mode = defaults.get('color_mode', 'tag_only')
tag_position = defaults.get('tag_position', 'after')
@@ -364,6 +371,11 @@ def _iter_stdin():
_emit(_colorize(line))
+ if param_alert:
+ for notif in pw.drain(color_map, tag_map, style_map, tag_style, show_tag,
+ param_alert_style):
+ _emit(notif)
+
if ca._crash_alert_enabled:
if new_death:
ca.print_alert_banner()
diff --git a/dendROS/dendros_config.py b/dendROS/dendros_config.py
index 3a8822e..798c0ee 100644
--- a/dendROS/dendros_config.py
+++ b/dendROS/dendros_config.py
@@ -43,10 +43,13 @@
("init_color", "Init: color", "cycle", ["palette", "null"]),
("init_color_bold", "Init: bold colors", "cycle", [False, True]),
("init_label", "Init: auto label", "cycle", [False, True]),
- ("crash_alert", "Crash alert", "cycle", [False, True]),
- ("crash_alert_color", "Alert color", "cycle", ["node", "red"]),
- ("crash_alert_interval", "Alert interval (s)", "text", None),
- ("traceback_color", "Traceback color", "cycle", ["fancy", "red", "off"]),
+ ("crash_alert", "Crash alert", "cycle", [False, True]),
+ ("crash_alert_color", "Alert color", "cycle", ["node", "red"]),
+ ("crash_alert_interval", "Alert interval (s)", "text", None),
+ ("traceback_color", "Traceback color", "cycle", ["fancy", "red", "off"]),
+ ("param_change_alert", "Param change alert", "cycle", [False, True]),
+ ("param_change_alert_scope", "Param alert scope", "cycle", ["tracked", "all"]),
+ ("param_change_alert_style", "Param alert style", "cycle", ["inline", "inverted"]),
]
_DESCS = {
@@ -140,6 +143,18 @@
"fancy — bold red header/exception, dim red frame lines (default)",
"red — entire traceback in bold red | off — no coloring (white)",
),
+ "param_change_alert": (
+ "on — print an inline notification in the launch terminal whenever a parameter changes at runtime",
+ "off — parameter changes are silent; use ros2 param get to check values manually",
+ ),
+ "param_change_alert_scope": (
+ "tracked — only notify for nodes that have a color group in a dendROS.yaml config",
+ "all — notify for every node on the ROS graph (includes nodes with no config entry)",
+ ),
+ "param_change_alert_style": (
+ "inline — compact single line: [dendROS] param [TAG] /node param_name → value",
+ "inverted — reverse-video block with node color as background; harder to miss",
+ ),
}
_VAL_LABEL = {True: "on", False: "off", None: "null"}
diff --git a/dendROS/lib/global_config.py b/dendROS/lib/global_config.py
index c12f432..3000924 100644
--- a/dendROS/lib/global_config.py
+++ b/dendROS/lib/global_config.py
@@ -27,12 +27,15 @@
"init_color": "palette",
"init_color_bold": False,
"init_label": False,
- "crash_alert": True,
- "crash_alert_color": "node",
- "crash_alert_interval": 30,
- "traceback_color": "fancy",
- "tag_style": "normal",
- "show_default_services": True,
+ "crash_alert": True,
+ "crash_alert_color": "node",
+ "crash_alert_interval": 30,
+ "traceback_color": "fancy",
+ "tag_style": "normal",
+ "show_default_services": True,
+ "param_change_alert": True,
+ "param_change_alert_scope": "tracked",
+ "param_change_alert_style": "inline",
}
@@ -72,4 +75,4 @@ def save_global_config(cfg):
path = get_global_config_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
- yaml.dump({k: cfg[k] for k in DEFAULTS}, f, default_flow_style=False)
+ yaml.dump({k: cfg.get(k, DEFAULTS[k]) for k in DEFAULTS}, f, default_flow_style=False)
diff --git a/dendROS/lib/param_watcher.py b/dendROS/lib/param_watcher.py
new file mode 100644
index 0000000..27fb528
--- /dev/null
+++ b/dendROS/lib/param_watcher.py
@@ -0,0 +1,274 @@
+"""Background watcher for /parameter_events — queues param change notifications.
+
+Architecture
+------------
+A single daemon thread runs `ros2 topic echo /parameter_events` as a subprocess
+and parses its YAML output (message chunks separated by `---` lines). Each
+chunk that represents a genuine runtime change (changed_parameters non-empty,
+not a CLI daemon node) is pushed to `_queue`.
+
+The main pipe thread calls `drain()` between stdin lines to pop all pending
+events and get back formatted notification strings. No locking is needed
+because only the daemon thread writes to _queue and only the main thread reads
+from it (queue.Queue is already thread-safe).
+"""
+
+import atexit
+import os
+import queue
+import shutil
+import subprocess
+import threading
+
+try:
+ import yaml
+except ImportError:
+ yaml = None
+
+_RESET = '\033[0m'
+# White-bg + black-text strip used in the inverted alert style
+_WB = '\033[107;30m'
+
+# [dendROS] header matches the logo title: "[dend" in logo-blue, "ROS]" in logo-orange
+_DENDROS = '\033[38;2;0;75;107;1m[dend\033[38;2;224;127;0;1mROS]\033[0m'
+
+# Inverted-style header: logo colors as BACKGROUNDS, black text (hollow/cutout letters).
+# \033[30m sets black fg once; subsequent bg changes leave fg unchanged.
+_DENDROS_INV = (
+ '\033[48;2;0;75;107;1m\033[30m[dend' # logo-blue bg + bold + black text
+ '\033[48;2;224;127;0;1mROS]' # logo-orange bg + bold (fg stays black)
+ '\033[0m\033[107;30m' # reset → white bg + black text for rest
+)
+
+
+def _fg_to_bg(code: str) -> str:
+ """Convert a foreground ANSI SGR code string to its background equivalent.
+
+ Standard 30-37 → 40-47, bright 90-97 → 100-107, 24-bit 38;2;R;G;B → 48;2;R;G;B.
+ Bold and other modifiers are dropped so the caller can add its own text attributes.
+ """
+ parts = code.split(';')
+ out = []
+ i = 0
+ while i < len(parts):
+ p = parts[i]
+ n = int(p) if p.isdigit() else -1
+ if 30 <= n <= 37:
+ out.append(str(n + 10)); i += 1
+ elif 90 <= n <= 97:
+ out.append(str(n + 10)); i += 1
+ elif n == 38 and i + 1 < len(parts):
+ if parts[i + 1] == '2' and i + 4 < len(parts):
+ out += ['48', '2'] + parts[i + 2:i + 5]; i += 5
+ elif parts[i + 1] == '5' and i + 2 < len(parts):
+ out += ['48', '5', parts[i + 2]]; i += 3
+ else:
+ i += 1
+ else:
+ i += 1 # drop bold and other non-color modifiers
+ return ';'.join(out) if out else '0'
+
+_queue = queue.Queue()
+_proc: 'subprocess.Popen | None' = None
+_param_cache: dict = {} # (node, param_name) → last-seen value string
+
+# ParameterType enum → value field name in the YAML message
+_TYPE_FIELDS = {
+ 1: 'bool_value',
+ 2: 'integer_value',
+ 3: 'double_value',
+ 4: 'string_value',
+ 5: 'byte_array_value',
+ 6: 'bool_array_value',
+ 7: 'integer_array_value',
+ 8: 'double_array_value',
+ 9: 'string_array_value',
+}
+_MAX_VALUE_LEN = 60
+
+
+def _find_ros2():
+ b = shutil.which('ros2')
+ if b:
+ return b
+ distro = os.environ.get('ROS_DISTRO', '')
+ if distro:
+ c = f'/opt/ros/{distro}/bin/ros2'
+ if os.path.isfile(c):
+ return c
+ return None
+
+
+def _extract_value(v):
+ """Return a human-readable string for a ParameterValue dict."""
+ if not isinstance(v, dict):
+ return str(v) if v is not None else ''
+ field = _TYPE_FIELDS.get(v.get('type', 0))
+ if not field:
+ return ''
+ val = v.get(field, '')
+ if isinstance(val, bool):
+ return 'true' if val else 'false'
+ if isinstance(val, list):
+ s = str(val)
+ return (s[:_MAX_VALUE_LEN] + '…') if len(s) > _MAX_VALUE_LEN else s
+ return str(val)
+
+
+def _process_chunk(lines, color_map, tag_map, scope, resolve_node_fn):
+ """Parse one YAML message chunk; enqueue any changed-parameter events."""
+ try:
+ msg = yaml.safe_load('\n'.join(lines))
+ except Exception:
+ return
+ if not isinstance(msg, dict):
+ return
+
+ node = msg.get('node', '')
+ # /_ros2cli_XXXXX nodes are transient CLI tool nodes — always skip
+ if not node or node.startswith('/_ros2cli_'):
+ return
+
+ changed = msg.get('changed_parameters') or []
+ if not changed:
+ return
+
+ if scope == 'tracked':
+ code, _ = resolve_node_fn(node, color_map, tag_map)
+ if not code:
+ return
+
+ for param in changed:
+ if not isinstance(param, dict):
+ continue
+ name = param.get('name', '')
+ if not name:
+ continue
+ new_val = _extract_value(param.get('value', {}))
+ cache_key = (node, name)
+ old_val = _param_cache.get(cache_key) # None on first ever change
+ _param_cache[cache_key] = new_val
+ _queue.put((node, name, old_val, new_val))
+
+
+def _watch(color_map, tag_map, scope):
+ """Background thread: stream /parameter_events and fill the queue."""
+ global _proc
+ ros2 = _find_ros2()
+ if not ros2:
+ return
+
+ try:
+ _proc = subprocess.Popen(
+ [ros2, 'topic', 'echo', '/parameter_events'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ except Exception:
+ return
+
+ from lib.config_loader import resolve_node # deferred — avoids import cycles
+
+ chunk: list[str] = []
+ try:
+ for raw in _proc.stdout:
+ line = raw.rstrip('\n')
+ if line == '---':
+ if chunk:
+ _process_chunk(chunk, color_map, tag_map, scope, resolve_node)
+ chunk = []
+ else:
+ chunk.append(line)
+ except Exception:
+ pass
+
+
+@atexit.register
+def _cleanup():
+ if _proc is not None and _proc.poll() is None:
+ try:
+ _proc.terminate()
+ except Exception:
+ pass
+
+
+def setup(color_map, tag_map, scope: str):
+ """Start the background watcher. No-op when yaml or ros2 binary is unavailable."""
+ if yaml is None or not _find_ros2():
+ return
+ t = threading.Thread(target=_watch, args=(color_map, tag_map, scope), daemon=True)
+ t.start()
+
+
+def _fmt_inline(node, name, old_val, value, code, label, node_style, show_tag):
+ """Rainbow header + plain 'param' + node color identity + bold param data."""
+ old_part = f'{old_val} → ' if old_val is not None else '? → '
+ if code:
+ node_str = f'\033[{code}m{node}{_RESET}'
+ if show_tag and label:
+ badge = (f'\033[{code};7m[{label}]{_RESET}'
+ if node_style == 'inverted'
+ else f'\033[{code}m[{label}]{_RESET}')
+ node_str = f'{badge} {node_str}'
+ else:
+ node_str = node # untracked in "all" mode
+
+ return (
+ f'{_DENDROS} param'
+ f' {node_str} \033[1m{name}\033[0m: {old_part}{value}\n'
+ )
+
+
+def _fmt_inverted(node, name, old_val, value, code, label, node_style, show_tag):
+ """[dendROS] header + continuous white-bg strip from 'param' to EOL.
+
+ Layout:
+ [blue/orange][dend|ROS][reset][WB] param [node-bg;black][TAG] /node[WB] [bold]name[/bold]: old→new [K][reset]
+
+ The white-bg (_WB) starts immediately after [dendROS] and is never interrupted
+ by a bare reset — the node-identity island switches to its explicit bg and then
+ returns to _WB, so every space in the line (including between sections) is white.
+ \033[K fills the remainder of the console line with the white bg.
+ """
+ old_part = f'{old_val} → ' if old_val is not None else '? → '
+
+ if code:
+ node_bg = _fg_to_bg(code)
+ if show_tag and label:
+ node_section = f'\033[{node_bg};30m[{label}] {node}{_WB}'
+ else:
+ node_section = f'\033[{node_bg};30m{node}{_WB}'
+ else:
+ node_section = node # untracked — plain black text on the white bg
+
+ return (
+ f'{_DENDROS_INV} param {node_section}'
+ f' \033[1m{name}\033[22m: {old_part}{value} \033[K{_RESET}\n'
+ )
+
+
+def drain(color_map, tag_map, style_map, tag_style: str, show_tag: bool,
+ alert_style: str = 'inline') -> list[str]:
+ """Pop all pending change events; return formatted notification lines (with \\n).
+
+ Must be called from the main thread only.
+ """
+ from lib.config_loader import resolve_node, resolve_node_style
+
+ lines: list[str] = []
+ while True:
+ try:
+ node, name, old_val, value = _queue.get_nowait()
+ except queue.Empty:
+ break
+
+ code, label = resolve_node(node, color_map, tag_map)
+ node_style = (resolve_node_style(node, style_map) or tag_style) if code else tag_style
+
+ if alert_style == 'inverted':
+ lines.append(_fmt_inverted(node, name, old_val, value, code, label, node_style, show_tag))
+ else:
+ lines.append(_fmt_inline(node, name, old_val, value, code, label, node_style, show_tag))
+
+ return lines
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index 15ac633..c84fc03 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -23,11 +23,13 @@
# ── ANSI helpers ─────────────────────────────────────────────────────────────
ANSI_RE = re.compile(r'\033\[([0-9;]*)m')
+# Broader regex that strips any CSI sequence (e.g. \033[K for erase-to-EOL)
+_FULL_ANSI_RE = re.compile(r'\033\[[^a-zA-Z]*[a-zA-Z]')
def strip_ansi(s: str) -> str:
"""Remove all ANSI escape sequences from s."""
- return ANSI_RE.sub('', s)
+ return _FULL_ANSI_RE.sub('', s)
def ansi_codes(s: str) -> list:
diff --git a/test/unit/test_global_config.py b/test/unit/test_global_config.py
index c63df2f..e4e4f70 100644
--- a/test/unit/test_global_config.py
+++ b/test/unit/test_global_config.py
@@ -90,6 +90,9 @@ def test_loads_existing_file(self, tmp_config):
"traceback_color": "red",
"tag_style": "inverted",
"show_default_services": False,
+ "param_change_alert": True,
+ "param_change_alert_scope": "all",
+ "param_change_alert_style": "inverted",
}
with open(tmp_config, "w") as f:
yaml.dump(data, f)
@@ -172,6 +175,9 @@ def test_roundtrip_custom_values(self, tmp_config):
"traceback_color": "off",
"tag_style": "inverted",
"show_default_services": False,
+ "param_change_alert": True,
+ "param_change_alert_scope": "all",
+ "param_change_alert_style": "inverted",
}
save_global_config(custom)
result = load_global_config()
diff --git a/test/unit/test_param_watcher.py b/test/unit/test_param_watcher.py
new file mode 100644
index 0000000..7f3e1b8
--- /dev/null
+++ b/test/unit/test_param_watcher.py
@@ -0,0 +1,484 @@
+"""Tests for lib/param_watcher.py — param change notification subsystem."""
+
+import os
+import queue
+import sys
+
+import pytest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+sys.path.insert(0, os.path.join(REPO_ROOT, 'dendROS'))
+
+import lib.param_watcher as pw
+from conftest import strip_ansi
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _resolve_node_mock(node, color_map, tag_map):
+ """Minimal stand-in for lib.config_loader.resolve_node (exact basename match)."""
+ basename = node.rsplit('/', 1)[-1]
+ code = color_map.get(node) or color_map.get(basename)
+ label = tag_map.get(node) or tag_map.get(basename) if code else None
+ return code, label
+
+
+def _make_param_event(node, changed=None, new=None, deleted=None):
+ """Build a list of YAML lines representing one ParameterEvent message."""
+ lines = [f'node: {node}', 'stamp:', ' sec: 0', ' nanosec: 0']
+ for section, params in [('new_parameters', new), ('changed_parameters', changed),
+ ('deleted_parameters', deleted)]:
+ if params:
+ lines.append(f'{section}:')
+ for name, type_id, val_field, val in params:
+ lines += [
+ f'- name: {name}',
+ f' value:',
+ f' type: {type_id}',
+ f' {val_field}: {val}',
+ ]
+ else:
+ lines.append(f'{section}: []')
+ return lines
+
+
+def _drain_all(color_map, tag_map, style_map=None, tag_style='normal', show_tag=True,
+ alert_style='inline'):
+ """Drain the queue using the real drain() but with a mock resolve_node."""
+ return pw.drain(color_map, tag_map, style_map or {}, tag_style, show_tag, alert_style)
+
+
+def _flush_queue():
+ while True:
+ try:
+ pw._queue.get_nowait()
+ except queue.Empty:
+ break
+
+
+# ── _extract_value ────────────────────────────────────────────────────────────
+
+class TestExtractValue:
+
+ def test_bool_true(self):
+ assert pw._extract_value({'type': 1, 'bool_value': True}) == 'true'
+
+ def test_bool_false(self):
+ assert pw._extract_value({'type': 1, 'bool_value': False}) == 'false'
+
+ def test_integer(self):
+ assert pw._extract_value({'type': 2, 'integer_value': 42}) == '42'
+
+ def test_double(self):
+ assert pw._extract_value({'type': 3, 'double_value': 3.14}) == '3.14'
+
+ def test_string(self):
+ assert pw._extract_value({'type': 4, 'string_value': 'hello'}) == 'hello'
+
+ def test_bool_array(self):
+ result = pw._extract_value({'type': 6, 'bool_array_value': [True, False]})
+ assert 'True' in result or 'False' in result
+
+ def test_long_array_truncated(self):
+ big = list(range(100))
+ result = pw._extract_value({'type': 7, 'integer_array_value': big})
+ assert '…' in result
+ assert len(result) <= pw._MAX_VALUE_LEN + 2
+
+ def test_unknown_type_returns_empty(self):
+ assert pw._extract_value({'type': 99}) == ''
+
+ def test_non_dict_input(self):
+ result = pw._extract_value('bare_string')
+ assert result == 'bare_string'
+
+
+# ── _process_chunk ────────────────────────────────────────────────────────────
+
+class TestProcessChunk:
+
+ def setup_method(self):
+ _flush_queue()
+ pw._param_cache.clear()
+
+ def _process(self, lines, color_map=None, tag_map=None, scope='tracked'):
+ cm = color_map or {}
+ tm = tag_map or {}
+ pw._process_chunk(lines, cm, tm, scope, _resolve_node_mock)
+
+ def test_changed_param_enqueued(self):
+ lines = _make_param_event(
+ '/talker',
+ changed=[('use_sim_time', 1, 'bool_value', 'true')],
+ )
+ color_map = {'talker': '32'}
+ self._process(lines, color_map)
+ node, name, old_val, value = pw._queue.get_nowait()
+ assert node == '/talker'
+ assert name == 'use_sim_time'
+ assert value == 'true'
+ assert old_val is None # first-ever change for this param
+
+ def test_new_params_not_enqueued(self):
+ """new_parameters (startup declarations) must be silently ignored."""
+ lines = _make_param_event(
+ '/talker',
+ new=[('use_sim_time', 1, 'bool_value', 'false')],
+ )
+ self._process(lines, {'talker': '32'})
+ assert pw._queue.empty()
+
+ def test_deleted_params_not_enqueued(self):
+ lines = _make_param_event(
+ '/talker',
+ deleted=[('old_param', 4, 'string_value', 'gone')],
+ )
+ self._process(lines, {'talker': '32'})
+ assert pw._queue.empty()
+
+ def test_cli_daemon_node_filtered(self):
+ lines = _make_param_event(
+ '/_ros2cli_12345',
+ changed=[('use_sim_time', 1, 'bool_value', 'false')],
+ )
+ self._process(lines, {})
+ assert pw._queue.empty()
+
+ def test_untracked_node_filtered_in_tracked_scope(self):
+ lines = _make_param_event(
+ '/unknown_node',
+ changed=[('my_param', 2, 'integer_value', '5')],
+ )
+ self._process(lines, {'talker': '32'}, scope='tracked')
+ assert pw._queue.empty()
+
+ def test_untracked_node_passes_in_all_scope(self):
+ lines = _make_param_event(
+ '/unknown_node',
+ changed=[('my_param', 2, 'integer_value', '5')],
+ )
+ self._process(lines, {'talker': '32'}, scope='all')
+ assert not pw._queue.empty()
+ node, name, old_val, value = pw._queue.get_nowait()
+ assert node == '/unknown_node'
+ assert name == 'my_param'
+
+ def test_multiple_changed_params_all_enqueued(self):
+ lines = _make_param_event(
+ '/talker',
+ changed=[
+ ('param_a', 1, 'bool_value', 'true'),
+ ('param_b', 2, 'integer_value', '7'),
+ ],
+ )
+ self._process(lines, {'talker': '32'})
+ assert pw._queue.qsize() == 2
+
+ def test_invalid_yaml_silently_ignored(self):
+ self._process(['not: valid: yaml: :::'], {'talker': '32'})
+ # should not raise; queue stays empty
+ assert pw._queue.empty()
+
+ def test_empty_node_field_filtered(self):
+ lines = ['node: ', 'changed_parameters:', '- name: p', ' value:', ' type: 1',
+ ' bool_value: true', 'new_parameters: []', 'deleted_parameters: []']
+ self._process(lines, {}, scope='all')
+ assert pw._queue.empty()
+
+ def test_namespaced_node_matched_by_basename(self):
+ lines = _make_param_event(
+ '/my_ns/talker',
+ changed=[('rate', 3, 'double_value', '10.0')],
+ )
+ self._process(lines, {'talker': '32'}, scope='tracked')
+ assert not pw._queue.empty()
+
+ def test_wildcard_pattern_matched(self):
+ """Wildcard patterns in color_map are resolved by _resolve_node_mock (exact only)
+ — this test verifies that a direct match reaches the queue."""
+ lines = _make_param_event(
+ '/nav2_planner',
+ changed=[('max_vel', 3, 'double_value', '1.5')],
+ )
+ self._process(lines, {'nav2_planner': '35'}, scope='tracked')
+ assert not pw._queue.empty()
+
+
+# ── drain() formatting ────────────────────────────────────────────────────────
+
+class TestDrain:
+
+ def setup_method(self):
+ _flush_queue()
+ pw._param_cache.clear()
+
+ def _push(self, node, name, value, old_val=None):
+ pw._queue.put((node, name, old_val, value))
+
+ def test_drain_empty_queue_returns_empty_list(self):
+ result = _drain_all({}, {})
+ assert result == []
+
+ def test_notification_contains_node_name(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert len(lines) == 1
+ assert '/talker' in strip_ansi(lines[0])
+
+ def test_notification_contains_param_name(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert 'use_sim_time' in strip_ansi(lines[0])
+
+ def test_notification_contains_value(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert 'true' in strip_ansi(lines[0])
+
+ def test_notification_contains_dendros_header(self):
+ self._push('/talker', 'rate', '10.0')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '[dendROS]' in strip_ansi(lines[0])
+
+ def test_dendros_header_logo_colors(self):
+ """[dendROS] must split on the logo palette: [dend in logo-blue, ROS] in logo-orange."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '[dendROS]' in strip_ansi(lines[0])
+ # Logo-blue (0,75,107) for '[dend'
+ assert '\033[38;2;0;75;107;1m[dend' in lines[0]
+ # Logo-orange (224,127,0) for 'ROS]'
+ assert '\033[38;2;224;127;0;1mROS]' in lines[0]
+
+ def test_node_colored(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '\033[32m/talker\033[0m' in lines[0]
+
+ def test_tag_shown_when_label_present(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': 'TLK'})
+ assert '[TLK]' in lines[0]
+
+ def test_tag_hidden_when_show_tag_false(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': 'TLK'}, show_tag=False)
+ assert '[TLK]' not in lines[0]
+
+ def test_inverted_tag_style(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all(
+ {'talker': '32'}, {'talker': 'TLK'},
+ style_map={'talker': 'inverted'}, tag_style='inverted',
+ )
+ assert '\033[32;7m[TLK]\033[0m' in lines[0]
+
+ def test_param_name_bold(self):
+ self._push('/talker', 'my_param', 'val')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '\033[1mmy_param\033[0m' in lines[0]
+
+ def test_param_keyword_plain_white(self):
+ """'param' keyword must appear without any ANSI color code (plain/white)."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert 'param' in strip_ansi(lines[0])
+ assert '\033[35mparam\033[0m' not in lines[0]
+ assert '\033[2mparam\033[0m' not in lines[0]
+
+ def test_untracked_node_no_color(self):
+ """In 'all' mode, untracked nodes appear without ANSI color wrapping the node name."""
+ self._push('/unknown_node', 'p', 'v')
+ lines = _drain_all({}, {})
+ assert '/unknown_node' in lines[0]
+ import re
+ assert not re.search(r'\033\[[0-9;]+m/unknown_node\033\[0m', lines[0])
+
+ def test_multiple_events_all_drained(self):
+ for i in range(5):
+ self._push('/talker', f'param_{i}', str(i))
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert len(lines) == 5
+
+ def test_line_ends_with_newline(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert lines[0].endswith('\n')
+
+ def test_arrow_separator_present(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '→' in lines[0]
+
+ def test_unknown_old_value_shown_as_question_mark(self):
+ """First-ever change (old_val=None) should show '? →' in output."""
+ self._push('/talker', 'p', 'new', old_val=None)
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ assert '? →' in strip_ansi(lines[0])
+
+ def test_known_old_value_shown(self):
+ self._push('/talker', 'p', 'new', old_val='old')
+ lines = _drain_all({'talker': '32'}, {'talker': ''})
+ plain = strip_ansi(lines[0])
+ assert 'old → new' in plain
+
+ def test_process_chunk_caches_and_reports_old_value(self):
+ """Second change via _process_chunk should carry the first change's value as old."""
+ import queue as _queue_mod
+ pw._param_cache.clear()
+ _flush_queue()
+
+ def _resolve(node, cm, tm):
+ return cm.get(node.rsplit('/', 1)[-1]), None
+
+ lines1 = _make_param_event('/talker', changed=[('rate', 3, 'double_value', '5.0')])
+ pw._process_chunk(lines1, {'talker': '32'}, {}, 'all', _resolve)
+ _, _, old1, val1 = pw._queue.get_nowait()
+ assert old1 is None
+ assert val1 == '5.0'
+
+ lines2 = _make_param_event('/talker', changed=[('rate', 3, 'double_value', '10.0')])
+ pw._process_chunk(lines2, {'talker': '32'}, {}, 'all', _resolve)
+ _, _, old2, val2 = pw._queue.get_nowait()
+ assert old2 == '5.0'
+ assert val2 == '10.0'
+
+
+# ── Inverted alert style ──────────────────────────────────────────────────────
+
+class TestInvertedAlertStyle:
+
+ def setup_method(self):
+ _flush_queue()
+ pw._param_cache.clear()
+
+ def _push(self, node, name, value, old_val=None):
+ pw._queue.put((node, name, old_val, value))
+
+ def test_inverted_sections_use_correct_backgrounds(self):
+ """Node identity uses explicit node-color bg; everything else on white bg."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ # Node identity: explicit green bg + black text (_fg_to_bg('32') == '42')
+ assert '\033[42;30m' in lines[0]
+ # White bg present (for param section and separating spaces)
+ assert '\033[107;30m' in lines[0]
+ # No reverse-video for node identity
+ assert '\033[32;7m' not in lines[0]
+
+ def test_inverted_contains_node_name(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '/talker' in strip_ansi(lines[0])
+
+ def test_inverted_contains_param_name(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert 'use_sim_time' in strip_ansi(lines[0])
+
+ def test_inverted_contains_value(self):
+ self._push('/talker', 'use_sim_time', 'true')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert 'true' in strip_ansi(lines[0])
+
+ def test_inverted_tag_and_node_use_explicit_node_bg(self):
+ """Tag and node name together in one colored-bg island (_fg_to_bg of node code)."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': 'TLK'}, alert_style='inverted')
+ assert '[TLK]' in strip_ansi(lines[0])
+ # _fg_to_bg('32') == '42' → green bg + black text; returns to white bg (_WB) after
+ assert '\033[42;30m[TLK] /talker\033[107;30m' in lines[0]
+
+ def test_inverted_tag_hidden_when_show_tag_false(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': 'TLK'},
+ show_tag=False, alert_style='inverted')
+ assert '[TLK]' not in strip_ansi(lines[0])
+
+ def test_inverted_no_param_bold_marker(self):
+ """Inverted block wraps everything; no standalone bold param marker needed."""
+ self._push('/talker', 'my_param', 'val')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ # Content must still contain the param name
+ assert 'my_param' in strip_ansi(lines[0])
+
+ def test_inverted_untracked_uses_explicit_white_bg(self):
+ """Untracked nodes (all mode) get the white bg strip; no reverse-video or color bg."""
+ self._push('/unknown', 'p', 'v')
+ lines = _drain_all({}, {}, alert_style='inverted')
+ assert '\033[107;30m' in lines[0] # white bg present
+ assert '\033[7m' not in lines[0] # no reverse-video at all
+ # No node-color bg escape (no fg→bg conversion applied for untracked)
+ import re
+ assert not re.search(r'\033\[4[0-9];30m', lines[0])
+
+ def test_inverted_block_reset_at_end(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '\033[0m' in lines[0]
+
+ def test_inverted_extends_to_eol_with_erase_sequence(self):
+ """\\033[K (erase to EOL) must be present to fill background to console edge."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '\033[K' in lines[0]
+
+ def test_inverted_dendros_header_present(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '[dendROS]' in strip_ansi(lines[0])
+
+ def test_inverted_dendros_header_logo_colors(self):
+ """[dend on logo-blue bg, ROS] on logo-orange bg; black text (hollow/cutout)."""
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '\033[48;2;0;75;107;1m' in lines[0] # logo-blue background
+ assert '\033[48;2;224;127;0;1m' in lines[0] # logo-orange background
+ # Black text applied before [dend (hollow cutout look)
+ assert '\033[48;2;0;75;107;1m\033[30m[dend' in lines[0]
+
+ def test_inverted_arrow_present(self):
+ self._push('/talker', 'p', 'v')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '→' in strip_ansi(lines[0])
+
+ def test_inverted_same_content_as_inline(self):
+ """Both styles must expose the same semantic content in plain text."""
+ self._push('/talker', 'use_sim_time', 'true', old_val='false')
+ inline_lines = _drain_all({'talker': '32'}, {'talker': 'CTR'}, alert_style='inline')
+ self._push('/talker', 'use_sim_time', 'true', old_val='false')
+ inv_lines = _drain_all({'talker': '32'}, {'talker': 'CTR'}, alert_style='inverted')
+ assert strip_ansi(inline_lines[0]).strip() == strip_ansi(inv_lines[0]).strip()
+
+ def test_inverted_old_value_unknown_shows_question_mark(self):
+ self._push('/talker', 'p', 'new', old_val=None)
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert '? →' in strip_ansi(lines[0])
+
+ def test_inverted_known_old_value_shown(self):
+ self._push('/talker', 'p', 'new', old_val='old')
+ lines = _drain_all({'talker': '32'}, {'talker': ''}, alert_style='inverted')
+ assert 'old → new' in strip_ansi(lines[0])
+
+
+# ── Global config defaults ────────────────────────────────────────────────────
+
+class TestGlobalConfigDefaults:
+
+ def test_param_change_alert_default_false(self):
+ from lib.global_config import DEFAULTS
+ assert DEFAULTS['param_change_alert'] is False
+
+ def test_param_change_alert_scope_default_tracked(self):
+ from lib.global_config import DEFAULTS
+ assert DEFAULTS['param_change_alert_scope'] == 'tracked'
+
+ def test_param_change_alert_style_default_inline(self):
+ from lib.global_config import DEFAULTS
+ assert DEFAULTS['param_change_alert_style'] == 'inline'
+
+ def test_all_keys_present_in_defaults(self):
+ from lib.global_config import DEFAULTS
+ assert 'param_change_alert' in DEFAULTS
+ assert 'param_change_alert_scope' in DEFAULTS
+ assert 'param_change_alert_style' in DEFAULTS
From 8dd1c1f0d8217aece76217893af269fc631e2152 Mon Sep 17 00:00:00 2001
From: mlisi1
Date: Wed, 24 Jun 2026 16:37:47 +0200
Subject: [PATCH 4/4] updated docs and readme
---
README.md | 24 +++++
.../images/screenshots/param_alert_inline.png | Bin 0 -> 31651 bytes
.../screenshots/param_alert_inverted.png | Bin 0 -> 32995 bytes
.../images/screenshots/param_describe.png | Bin 0 -> 28144 bytes
docs/assets/images/screenshots/param_list.png | Bin 0 -> 100662 bytes
docs/global-config.md | 13 ++-
docs/index.md | 15 +++
docs/param-change-alert.md | 93 ++++++++++++++++++
docs/param-describe.md | 37 +++++++
docs/param-list.md | 40 ++++++++
docs/reference.md | 5 +
mkdocs.yml | 3 +
test/unit/test_param_watcher.py | 4 +-
13 files changed, 231 insertions(+), 3 deletions(-)
create mode 100644 docs/assets/images/screenshots/param_alert_inline.png
create mode 100644 docs/assets/images/screenshots/param_alert_inverted.png
create mode 100644 docs/assets/images/screenshots/param_describe.png
create mode 100644 docs/assets/images/screenshots/param_list.png
create mode 100644 docs/param-change-alert.md
create mode 100644 docs/param-describe.md
create mode 100644 docs/param-list.md
diff --git a/README.md b/README.md
index 1a25499..f3f1b76 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,30 @@ It also features some quality of life improvements for ROS outputs.
+- ### **```ros2 param list``` coloring**
+ Node headers are colored with the group badge; parameter names are rendered dim so the structure is easy to scan. `--param-type` type annotations are dimmed automatically.
+
+
+
+
+
+- ### **```ros2 param describe``` coloring**
+ The group badge and parameter name are highlighted; field labels (`Type:`, `Description:`) are dimmed; section headers like `Constraints:` are bolded so the output structure is immediately readable.
+
+
+
+
+
+- ### **Parameter change alert**
+ When a node's parameter changes at runtime — via `ros2 param set` or any parameter service client — an inline notification appears in the launch terminal showing the node, parameter name, and old→new value. Two styles: compact inline and a full-width inverted block that's hard to miss in busy logs.
+
+
+
+
+
+
+
+
- ### **Truly non-invasive**
Shell-level pipe; you won't loose autocompletion or aliases for launch files
diff --git a/docs/assets/images/screenshots/param_alert_inline.png b/docs/assets/images/screenshots/param_alert_inline.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee49d547c8142b5d78da97a2b8ab2443596b992b
GIT binary patch
literal 31651
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