_debug.py 5.9 KB

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