_debug.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """Env-flagged wire-payload dump for VP MQTT debug (gated; off by default).
  2. Set ``BAMBUDDY_VP_DUMP_WIRE=1`` to enable two complementary capture modes:
  3. 1. ``dump_wire``: most recent inbound (bridge cache input) and outbound
  4. (slicer-facing 1Hz push) MQTT payloads, one file per VP per direction,
  5. overwritten each tick. Triages shape-of-payload bugs (e.g. #1622 round 1)
  6. where the question is "is the bridge missing fields in the cache, or is
  7. something else stripping them on the way out to the slicer?" Compare
  8. ``*_in.json`` and ``*_out.json`` for the failing VP against a known-good
  9. one (e.g. H2D vs P1S).
  10. 2. ``append_event``: time-ordered JSONL log of every slicer↔printer command
  11. payload that flows through the VP (excludes the cached-as-base 1Hz push,
  12. which dump_wire already covers). Triages command-flow bugs (e.g. #1622
  13. round 2) where the cached state looks right but a slicer-initiated
  14. write (ams_filament_setting / extrusion_cali_set / xcam / system) ends
  15. up corrupting state. One line per event with wall-clock timestamp.
  16. Layout:
  17. - snapshot: ``<log_dir>/vp_wire/<sanitized_vp_name>_<direction>.json``
  18. - events: ``<log_dir>/vp_wire/<sanitized_vp_name>_cmd.jsonl``
  19. Failure modes are swallowed at debug level — debug instrumentation must
  20. never break the bridge or slicer-facing 1Hz loop. Disable by unsetting the
  21. env var; the in-progress files stay on disk and can be deleted manually.
  22. ``_cmd.jsonl`` appends forever while enabled; for long debug sessions,
  23. delete between captures rather than relying on rotation.
  24. """
  25. from __future__ import annotations
  26. import json
  27. import logging
  28. import os
  29. import re
  30. from datetime import datetime, timezone
  31. from backend.app.core.config import settings as app_settings
  32. logger = logging.getLogger(__name__)
  33. _ENV_FLAG = "BAMBUDDY_VP_DUMP_WIRE"
  34. _NAME_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
  35. def _enabled() -> bool:
  36. return os.environ.get(_ENV_FLAG, "").strip().lower() in ("1", "true", "yes", "on")
  37. def _sanitize(name: str) -> str:
  38. safe = _NAME_SAFE.sub("_", name or "vp").strip("_")
  39. return safe or "vp"
  40. def dump_wire(vp_name: str, direction: str, payload: dict | bytes | str) -> None:
  41. """Write ``payload`` to ``<log_dir>/vp_wire/<vp_name>_<direction>.json``.
  42. No-op when the env flag is unset. Accepts dict (json-encoded with
  43. ``indent=2``), bytes (decoded as utf-8 with errors='replace'), or
  44. str (written verbatim).
  45. """
  46. if not _enabled():
  47. return
  48. try:
  49. target_dir = app_settings.log_dir / "vp_wire"
  50. target_dir.mkdir(parents=True, exist_ok=True)
  51. path = target_dir / f"{_sanitize(vp_name)}_{_sanitize(direction)}.json"
  52. if isinstance(payload, dict):
  53. text = json.dumps(payload, indent=2, default=str)
  54. elif isinstance(payload, bytes):
  55. text = payload.decode("utf-8", errors="replace")
  56. else:
  57. text = str(payload)
  58. tmp = path.with_suffix(path.suffix + ".tmp")
  59. tmp.write_text(text, encoding="utf-8")
  60. tmp.replace(path)
  61. except OSError as e:
  62. logger.debug("[%s] vp_wire dump (%s) failed: %s", vp_name, direction, e)
  63. def _command_label(payload: dict) -> str:
  64. """Best-effort one-word label for the command, used as a grep handle in the JSONL.
  65. Bambu's MQTT request/response shape is ``{"<channel>": {"command": "<name>", ...}}``
  66. where channel is ``print``/``pushing``/``info``/``system``/``xcam``/etc.
  67. Returns ``"<channel>.<command>"`` when we can find it, ``"?"`` otherwise.
  68. """
  69. if not isinstance(payload, dict):
  70. return "?"
  71. for channel, body in payload.items():
  72. if isinstance(body, dict):
  73. cmd = body.get("command")
  74. if isinstance(cmd, str) and cmd:
  75. return f"{channel}.{cmd}"
  76. return "?"
  77. def append_event(vp_name: str, direction: str, topic: str, payload: dict | bytes | str) -> None:
  78. """Append one event line to ``<log_dir>/vp_wire/<vp_name>_cmd.jsonl``.
  79. No-op when the env flag is unset. ``direction`` should be one of
  80. ``"slicer_to_bridge"`` or ``"printer_to_slicer"`` so a diff between
  81. a working VP and a broken VP can be read top-to-bottom in causal order.
  82. Bytes payloads are utf-8 decoded then json-parsed best-effort; un-parseable
  83. payloads are logged as ``{"raw": "<text>"}`` so the line is still valid JSON.
  84. """
  85. if not _enabled():
  86. return
  87. try:
  88. target_dir = app_settings.log_dir / "vp_wire"
  89. target_dir.mkdir(parents=True, exist_ok=True)
  90. path = target_dir / f"{_sanitize(vp_name)}_cmd.jsonl"
  91. if isinstance(payload, bytes):
  92. try:
  93. parsed: dict | str = json.loads(payload.decode("utf-8", errors="replace").rstrip("\x00 \r\n\t"))
  94. except (json.JSONDecodeError, UnicodeDecodeError):
  95. parsed = {"raw": payload.decode("utf-8", errors="replace")}
  96. elif isinstance(payload, str):
  97. try:
  98. parsed = json.loads(payload.rstrip("\x00 \r\n\t"))
  99. except json.JSONDecodeError:
  100. parsed = {"raw": payload}
  101. else:
  102. parsed = payload
  103. record = {
  104. "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
  105. "dir": direction,
  106. "topic": topic,
  107. "cmd": _command_label(parsed) if isinstance(parsed, dict) else "?",
  108. "payload": parsed,
  109. }
  110. line = json.dumps(record, default=str) + "\n"
  111. with path.open("a", encoding="utf-8") as fp:
  112. fp.write(line)
  113. except OSError as e:
  114. logger.debug("[%s] vp_wire append (%s) failed: %s", vp_name, direction, e)