#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Generate Markdown documentation from an MSP message definitions JSON. Strict + Index: - STRICT: If a code exists in one (MSPCodes vs JSON) but not the other, crash with details. - Index items link to headings via GitHub-style auto-anchors. - Tight layout; identical Request/Reply tables; skip complex=true with a stub. - Default input: msp_messages.json ; default output: MSP_Doc.md """ import sys import json import re import unicodedata from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Type import enum def build_msp_codes_enum(defs: Dict[str, Any]) -> Type[enum.IntEnum]: members: Dict[str, int] = {} for name, body in defs.items(): try: code = int(body.get("code", -1)) except (TypeError, ValueError): continue members[name] = code return enum.IntEnum("MSPCodes", members) # ---- C type size helpers ---------------------------------------------------- BASE_SIZES = { "uint8_t": 1, "int8_t": 1, "char": 1, "uint16_t": 2, "int16_t": 2, "uint32_t": 4, "int32_t": 4, "uint64_t": 8, "int64_t": 8, "float": 4, "double": 8, } array_brackets_re = re.compile(r"^(?P[A-Za-z_0-9]+)\[(?P.*)\]$") def parse_ctype(ctype: str) -> Tuple[str, Optional[str]]: m = array_brackets_re.match(ctype.strip()) if not m: return ctype.strip(), None return m.group("base").strip(), m.group("size").strip() def format_ctype(field: Dict[str, Any]) -> str: raw = (field.get("ctype") or "").strip() if not raw: return "-" base, bracket = parse_ctype(raw) has_array_meta = bool(field.get("array", False)) is_array = has_array_meta or (bracket is not None) if not is_array: return raw size_define = (field.get("array_size_define") or "").strip() array_size = field.get("array_size") size_expr = "" if size_define: size_expr = size_define else: if isinstance(array_size, int): if array_size > 0: size_expr = str(array_size) elif isinstance(array_size, str): cleaned = array_size.strip() if cleaned and cleaned != "0": size_expr = cleaned if not size_expr and bracket is not None: size_expr = bracket.strip() if size_expr == "0": size_expr = "" base_part = base or raw return f"{base_part}[{size_expr}]" def describe_array_bytes(array_size_meta: Any, base_bytes: Optional[int], base_name: str) -> str: """ Returns a printable byte-count (or symbolic string) for an array entry. """ if isinstance(array_size_meta, int): if array_size_meta <= 0: return "array" if base_bytes is None: return str(array_size_meta) return str(array_size_meta * base_bytes) if isinstance(array_size_meta, str): expr = array_size_meta.strip() if not expr: return "array" if base_bytes is None or base_name == "char": return expr return f"{expr} * {base_bytes}" return "array" def sizeof_entry(field: Dict[str, Any]) -> str: ctype = field.get("ctype", "").strip() base, bracket = parse_ctype(ctype) is_array = bool(field.get("array", False)) array_size_meta = field.get("array_size", None) array_size_define = (field.get("array_size_define") or "").strip() if is_array or bracket is not None: base_for_size = base if (base and is_array) else (base or ctype) base_bytes = BASE_SIZES.get(base_for_size, None) if is_array: size_str = describe_array_bytes(array_size_meta, base_bytes, base_for_size) if array_size_define: if size_str in {"array", "-"}: size_str = array_size_define else: size_str = f"{size_str} ({array_size_define})" return size_str if bracket is not None: if bracket == "": return "array" if bracket.isdigit(): n = int(bracket) return str(n * base_bytes) if base_bytes is not None else str(n) if base_bytes is None or base == "char": return bracket return f"{bracket} * {base_bytes}" return "array" base_bytes = BASE_SIZES.get(base, None) return str(base_bytes) if base_bytes is not None else "-" # ---- Markdown rendering ----------------------------------------------------- #inav_wiki_url = "https://github.com/xznhj8129/msp_documentation/blob/master/docs/inav_enums_ref.md" inav_wiki_url = "https://github.com/iNavFlight/inav/wiki/Enums-reference" def units_cell(field: Dict[str, Any]) -> str: if "enum" in field: if field["enum"]=="?_e": return "[ENUM_NAME](LINK_TO_ENUM)" else: return f"[{field['enum']}]({inav_wiki_url}#enum-{field['enum'].lower()})" u = (field.get("units") or "").strip() return u if u else "-" def has_fields(section: Any) -> bool: if not isinstance(section, dict): return False payload = section.get("payload") return isinstance(payload, list) and len(payload) > 0 def get_fields(section: Any) -> List[Dict[str, Any]]: if not isinstance(section, dict): return [] payload = section.get("payload") return payload if isinstance(payload, list) else [] def flatten_fields_with_repeats(fields: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Flattens one level of partially repeating payload blocks: Items with {"repeating": "SOME_SYMBOL", "payload": [...]} are expanded so each child field gets a symbolic multiplier in the size column. """ out: List[Dict[str, Any]] = [] for f in fields: if isinstance(f, dict) and "repeating" in f and isinstance(f.get("payload"), list): repeat_sym = str(f["repeating"]) for child in f["payload"]: if isinstance(child, dict): c = dict(child) # Mark repeat multiplier for the size column c["_repeat_multiplier"] = repeat_sym out.append(c) else: out.append(f) return out def table_with_units(fields: List[Dict[str, Any]], label: str) -> str: flat_fields = flatten_fields_with_repeats(fields) has_repeats = any(isinstance(f, dict) and f.get("_repeat_multiplier") for f in flat_fields) has_units = any( isinstance(f, dict) and ( ((f.get("units") or "").strip()) or ("enum" in f) ) for f in flat_fields ) # Build dynamic header cols = ["Field", "C Type"] if has_repeats: cols.append("Repeats") cols.append("Size (Bytes)") if has_units: cols.append("Units") cols.append("Description") header = " \n**{label}:**\n".format(label=label) header += "|" + "|".join(cols) + "|\n" header += "|" + "|".join(["---"] * len(cols)) + "|\n" # Rows rows: List[str] = [] for f in flat_fields: name = f.get("name", "") size = sizeof_entry(f) if size == "0": size = "-" row_cells = [f"`{name}`", f"`{format_ctype(f)}`"] if has_repeats: repeats = f.get("_repeat_multiplier") or "-" row_cells.append(repeats) row_cells.append(size) if has_units: units = units_cell(f) row_cells.append(units) desc = (f.get("desc") or "").strip() row_cells.append(desc) rows.append("| " + " | ".join(row_cells) + " |") return header + "\n".join(rows) + "\n" def render_variant(parent_name: str, variant_name: str, variant_def: Dict[str, Any]) -> str: """ Renders a single variant block (subsection header, description, request/reply tables). """ out: List[str] = [] vdesc = (variant_def.get("description") or "").strip() # GitHub auto-anchors will work off this header text out.append(f"#### Variant: `{variant_name}`\n\n") if vdesc: out.append(f"**Description:** {vdesc} \n") req = variant_def.get("request", None) rep = variant_def.get("reply", None) if has_fields(req): out.append(table_with_units(get_fields(req), "Request Payload")) else: out.append("\n**Request Payload:** **None** \n") if has_fields(rep): out.append(table_with_units(get_fields(rep), "Reply Payload")) else: out.append("\n**Reply Payload:** **None** \n") out.append("\n") return "".join(out) def render_message(name: str, msg: Dict[str, Any]) -> Tuple[str, str]: """ Returns (section_markdown, heading_text_for_anchor) """ code = msg.get("code", 0) hex_str = msg.get("hex", hex(code)) description = (msg.get("description") or "").strip() notes = (msg.get("notes") or "").strip() complex_flag = bool(msg.get("complex", False)) heading = f'## `{name} ({code} / {hex_str})`' out = [heading + "\n"] if description: out.append(f"**Description:** {description} \n") #if complex_flag: # out.append("**Special case, skipped for now**\n\n") # return "".join(out), heading # NEW: variant-aware rendering variants = msg.get("variants") if isinstance(variants, dict) and variants: # For variant messages, render a compact per-variant table set for vname, vdef in variants.items(): out.append(render_variant(name, vname, vdef)) else: # Fallback: single request/reply like before req = msg.get("request", None) rep = msg.get("reply", None) if has_fields(req): out.append(table_with_units(get_fields(req), "Request Payload")) else: out.append("\n**Request Payload:** **None** \n") if has_fields(rep): out.append(table_with_units(get_fields(rep), "Reply Payload")) else: out.append("\n**Reply Payload:** **None** \n") if notes: out.append(f"\n**Notes:** {notes}\n") out.append("\n") return "".join(out), heading # ---- Index + strict consistency -------------------------------------------- def build_maps(defs: Dict[str, Any], codes_cls: Type[enum.IntEnum]) -> Tuple[Dict[int, str], Dict[int, str]]: """ Returns: json_by_code: {code -> message_name_from_json} mw_by_code: {code -> enum_name_from codes_cls} Only for codes in the enforced ranges (v1 and v2). """ v1_range = range(0, 255) v2_range = range(4096, 20001) # JSON: build by code (restrict to ranges) json_by_code: Dict[int, str] = {} for name, body in defs.items(): code = int(body.get("code", -1)) if code in v1_range or code in v2_range: json_by_code[code] = name # MSPCodes: probe the same ranges mw_by_code: Dict[int, str] = {} def try_get(code: int) -> Optional[str]: try: e = codes_cls(code) return e.name except Exception: return None for code in list(v1_range) + list(v2_range): ename = try_get(code) if ename is not None: mw_by_code[code] = ename return json_by_code, mw_by_code def enforce_strict_match(json_by_code: Dict[int, str], mw_by_code: Dict[int, str]) -> None: json_codes = set(json_by_code.keys()) mw_codes = set(mw_by_code.keys()) only_in_json = sorted(json_codes - mw_codes) only_in_mw = sorted(mw_codes - json_codes) if only_in_json or only_in_mw: lines = ["MSP code mismatch detected:"] if only_in_json: lines.append(" Present in JSON but missing in MSPCodes:") for c in only_in_json: lines.append(f" {c}\t{json_by_code[c]}") if only_in_mw: lines.append(" Present in MSPCodes but missing in JSON:") for c in only_in_mw: lines.append(f" {c}\t{mw_by_code[c]}") raise SystemExit("\n".join(lines)) def build_index(json_by_code: Dict[int, str]) -> str: """ Build a compact index linking to each heading. """ v1 = [] v2 = [] for code, name in sorted(json_by_code.items()): hex_str = hex(code) item = f"[{code} - {name}](#{name.lower()}) " if 0 <= code <= 255: v1.append(item) elif 4096 <= code <= 20000: v2.append(item) parts = ["## Index", "### MSPv1"] parts.extend(v1) parts.append("\n### MSPv2") parts.extend(v2) parts.append("") # trailing newline return "\n".join(parts) # ---- Orchestration ---------------------------------------------------------- def generate_markdown(defs: Dict[str, Any]) -> str: # Strict maps & check codes_enum = build_msp_codes_enum(defs) json_by_code, mw_by_code = build_maps(defs, codes_enum) enforce_strict_match(json_by_code, mw_by_code) # Build sections, remembering headings for slugging (already handled in index) items = sorted(((int(body.get("code", 0)), name, body) for name, body in defs.items()), key=lambda t: t[0]) sections = [] for _, name, body in items: sec, _heading = render_message(name, body) sections.append(sec) with open("docs_v2_header.md", "r", encoding="utf-8") as f: header = f.read() with open("format.md", "r", encoding="utf-8") as f: fmt = f.read() with open("msp_messages.checksum", "r", encoding="utf-8") as f: chksum = f.read().split(' ')[0] with open("rev", "r", encoding="utf-8") as f: rev = f.read() header = header.replace('',fmt) header = header.replace('',rev) header = header.replace('',chksum) index_md = build_index(json_by_code) return header + "\n" + index_md + "\n" + "".join(sections) def main(): in_path = Path(sys.argv[1]) if len(sys.argv) >= 2 else Path("msp_messages.json") out_path = Path(sys.argv[2]) if len(sys.argv) >= 3 else Path("README.md") with in_path.open("r", encoding="utf-8") as f: defs = json.load(f) md = generate_markdown(defs) out_path.write_text(md, encoding="utf-8") print(f"Wrote {out_path}") if __name__ == "__main__": main()