Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,30 @@ It also features some quality of life improvements for ROS outputs.
<img src="docs/assets/images/screenshots/action_list.png" width="600" alt="ros2 action list"/>
</p>

- ### **```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.

<p align="center">
<img src="docs/assets/images/screenshots/param_list.png" width="600" alt="ros2 param list"/>
</p>

- ### **```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.

<p align="center">
<img src="docs/assets/images/screenshots/param_describe.png" width="600" alt="ros2 param describe"/>
</p>

- ### **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.

<p align="center">
<img src="docs/assets/images/screenshots/param_alert_inline.png" width="600" alt="param change alert inline"/>
</p>
<p align="center">
<img src="docs/assets/images/screenshots/param_alert_inverted.png" width="600" alt="param change alert inverted"/>
</p>

- ### **Truly non-invasive**
Shell-level pipe; you won't loose autocompletion or aliases for launch files

Expand Down
6 changes: 6 additions & 0 deletions dendROS/dendROS.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ 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]}
elif [[ "$1" == "param" && "$2" == "describe" ]]; then
"$_ROS2_BIN" "$@" | python3 "${_DENDROS_DIR}/dendros_param_describe.py" "${@:3}"
return ${PIPESTATUS[0]}
else
"$_ROS2_BIN" "$@"
fi
Expand Down
12 changes: 12 additions & 0 deletions dendROS/dendROS_pipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down
23 changes: 19 additions & 4 deletions dendROS/dendros_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"}
Expand Down
167 changes: 167 additions & 0 deletions dendROS/dendros_param_describe.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading