diff --git a/.github/workflows/deploy_canmsgbrowser.yml b/.github/workflows/deploy_canmsgbrowser.yml index e977ffb..0adecc6 100644 --- a/.github/workflows/deploy_canmsgbrowser.yml +++ b/.github/workflows/deploy_canmsgbrowser.yml @@ -12,7 +12,7 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages + # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write @@ -48,7 +48,17 @@ jobs: - name: Build with Next.js run: npx next build - + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install jinja2 + - name: Generate static browser + run: | + python ../code-gen/pdfgen.py --output can_spec_static.html - name: Upload artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/.gitignore b/.gitignore index 4f588a4..499a72c 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,5 @@ __marimo__/ # Code Generation code-gen/cgen + +*.html diff --git a/code-gen/README.md b/code-gen/README.md new file mode 100644 index 0000000..dc01655 --- /dev/null +++ b/code-gen/README.md @@ -0,0 +1,29 @@ +**The PDFGEN used AI for HTML development** + +Now here's the companion Python script to drive the template:Here's a breakdown of what was built and why each piece works the way it does. + +--- + +**Template structure (`can_spec_template.html.j2`)** + +The template is divided into four major sections: + +- **Cover page** — generated date and message count are injected via `{{ generation_date }}` and `{{ messages | length }}`, giving a printable title page automatically. +- **Table of contents** — a `{% for msg in messages %}` loop with a `selectattr` filter to count only active (non-padding) points per message, so the TOC is meaningful at a glance. +- **Per-message sections** — each message gets its own block with two tables: + - *MQTT Topics / Net Fields* — this is the human-focus table. It shows the full topic path (with runtime-embedded indices like `{1}` rendered in red), unit, `doc` string, the optional `desc` warning field, and a compact summary of which CANPoints feed it. + - *CAN Point Layout* — the raw bit-level table, showing size, c_type, endianness, formatter (e.g. `divide(10)`), signed flag, and a sim summary (sweep range or enum probability table). +- **Encodable defaults block** — only rendered when at least one point carries `default_value`, avoiding clutter on decode-only messages. + +**Key Jinja2 patterns used** + +| Pattern | Purpose | +|---|---| +| `{% set ns = namespace(has_defaults=false) %}` | Jinja2's scoping workaround — lets a flag set inside a loop be visible outside it | +| `msg.points[v - 1]` | Dereferences the 1-indexed `values` list from a NetField back to its Point, so the field table can inline the point's size/type without a separate loop | +| `selectattr("parse", "ne", false)` | Filters out padding points when counting active signals | +| `pt.formatter.key == "divide"` | Renders formatters like `divide(10)` vs just a raw key name | + +**Rendering pipeline (`render_spec.py`)** + +Pass one or more JSON files and they're merged into a single report. The `--pdf` flag hands the finished HTML to **WeasyPrint**, which respects the `@page` and `page-break-before: always` CSS rules so every message starts on a fresh page in the PDF. diff --git a/code-gen/logo.png b/code-gen/logo.png new file mode 100644 index 0000000..365f44b Binary files /dev/null and b/code-gen/logo.png differ diff --git a/code-gen/pdfgen.py b/code-gen/pdfgen.py new file mode 100644 index 0000000..2025be2 --- /dev/null +++ b/code-gen/pdfgen.py @@ -0,0 +1,161 @@ +""" +pdfgen.py +Render all CANGEN JSON spec files from ../can-messages/ into a single HTML (+ optional PDF). + +Usage: + python pdfgen.py + python pdfgen.py --pdf # requires weasyprint + python pdfgen.py --output out.html +""" + +import argparse +import base64 +import json +import subprocess +from datetime import datetime +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def load_specs(*paths: str) -> list[dict]: + """Load and merge any number of CANGEN JSON files into one message list.""" + messages = [] + for p in paths: + full = Path(p).resolve() + if not full.exists(): + print(f"⚠ Skipping missing file: {full}") + continue + data = json.loads(full.read_text()) + if isinstance(data, list): + messages.extend(data) + else: + raise ValueError(f"{p} must contain a JSON array at the top level") + return messages + + +def active_point_count(msg: dict) -> int: + """Count points where parse != false.""" + return sum( + 1 for pt in msg.get("points", []) + if pt.get("parse", True) is not False + ) + + +def enrich(messages: list[dict]) -> list[dict]: + """Attach derived fields useful in the template.""" + for msg in messages: + msg["_active_points"] = active_point_count(msg) + return messages + + +def git_hash(repo_path: Path) -> str: + """Return the short git hash of HEAD, or 'unknown' if unavailable.""" + try: + return subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo_path, stderr=subprocess.DEVNULL + ).decode().strip() + except Exception: + return "unknown" + + +def logo_data_uri(template_dir: Path): + """ + Look for a logo file in the templates directory and return a base64 + data URI so the PDF is fully self-contained. + Supported: logo.svg, logo.png, logo.jpg, logo.jpeg, logo.webp + Place your logo at: code-gen/templates/logo. + """ + mime_map = { + "svg": "image/svg+xml", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + } + for ext, mime in mime_map.items(): + path = template_dir / f"logo.{ext}" + if path.exists(): + b64 = base64.b64encode(path.read_bytes()).decode() + return f"data:{mime};base64,{b64}" + return None + + +# ── Rendering ───────────────────────────────────────────────────────────────── + +def render(template_path: str, messages: list[dict]) -> str: + tpl_file = Path(template_path).resolve() + logo = logo_data_uri(tpl_file.parent.parent) + + env = Environment( + loader=FileSystemLoader(str(tpl_file.parent)), + autoescape=select_autoescape(["html"]), + ) + + from markupsafe import Markup + import re + + def highlight_indices(value: str) -> Markup: + result = re.sub( + r"\{(\d+)\}", + r'{\1}', + str(value), + ) + return Markup(result) + + env.filters["highlight_indices"] = highlight_indices + + template = env.get_template(tpl_file.name) + return template.render( + messages=messages, + generation_date=datetime.now().strftime("%Y-%m-%d %H:%M"), + git_hash=git_hash(tpl_file.parent.parent), + logo_uri=logo, + ) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Render CANGEN spec to HTML/PDF") + parser.add_argument( + "--template", + default=str(Path(__file__).resolve().parent / "templates" / "can_spec_template.html.j2"), + help="Jinja2 template file (default: ./templates/can_spec_template.html.j2)", + ) + parser.add_argument( + "--output", default="can_spec.html", + help="Output HTML file (default: can_spec.html)", + ) + parser.add_argument( + "--pdf", action="store_true", + help="Also export a PDF alongside the HTML (requires weasyprint)", + ) + args = parser.parse_args() + + can_dir = Path(__file__).resolve().parent.parent / "can-messages" + spec_paths = sorted(str(f) for f in can_dir.glob("*.json")) + print(f"Loading {len(spec_paths)} spec file(s) from {can_dir}") + + messages = enrich(load_specs(*spec_paths)) + html = render(args.template, messages) + + out = Path(args.output) + out.write_text(html, encoding="utf-8") + print(f"✔ HTML written → {out}") + + if args.pdf: + try: + from weasyprint import HTML as WPHTML + pdf_path = out.with_suffix(".pdf") + WPHTML(filename=str(out)).write_pdf(str(pdf_path)) + print(f"✔ PDF written → {pdf_path}") + except ImportError: + print("✘ weasyprint not installed — run: pip install weasyprint") + + +if __name__ == "__main__": + main() diff --git a/code-gen/templates/can_spec_template.html.j2 b/code-gen/templates/can_spec_template.html.j2 new file mode 100644 index 0000000..807b7f9 --- /dev/null +++ b/code-gen/templates/can_spec_template.html.j2 @@ -0,0 +1,481 @@ + + + + + CAN Message Specification + + + + +{# ════════════════════════════════════════════════════════════ + COVER PAGE + ════════════════════════════════════════════════════════════ #} +
+ {% if logo_uri %} + Logo + {% endif %} +
Northeastern Electric Racing | Odyssey Telemetry System
+

CAN Message
Specification

+
+ MQTT topic reference, signal definitions, and simulation parameters +
+
+ Generated {{ generation_date | default("—") }}  |  + {{ messages | length }} message(s) defined  |  + git:{{ git_hash | default("unknown") }} +
+
+ +{# ════════════════════════════════════════════════════════════ + TABLE OF CONTENTS + ════════════════════════════════════════════════════════════ #} +
+

Contents

+ {% for msg in messages %} +
+ {{ msg.id }} + {{ msg.desc }} + + {{ msg.fields | length }} field(s)  ·  + {{ msg.points | selectattr("parse", "ne", false) | list | length }} active point(s) + +
+ {% endfor %} +
+ +{# ════════════════════════════════════════════════════════════ + ONE SECTION PER CAN MESSAGE + ════════════════════════════════════════════════════════════ #} +{% for msg in messages %} +
+ + {# ── Header bar ── #} +
+

{{ msg.desc }}

+ {{ msg.id }} +
+ +
+ + {# ── Quick-glance metadata ── #} +
+ + CAN ID: + {{ msg.id }} + {% if msg.is_ext is defined %} + {{ "Extended" if msg.is_ext else "Standard" }} + {% else %} + Extended (default) + {% endif %} + + {% if msg.sim_freq is defined %} + Sim freq: {{ msg.sim_freq }} ms + {% endif %} + {% if msg.key is defined %} + + Encodable key: + {{ msg.key }} +  {{ msg.bidir_mode | default("broadcast") }} + + {% endif %} + {% if msg.clients is defined %} + Extra MQTT ports: {{ msg.clients | join(", ") }} + {% endif %} +
+ + {# ════════════════════════════════ + MQTT FIELDS (NetFields) + ════════════════════════════════ #} + + + + + + + + + + + + + {% for field in msg.fields %} + + {# Topic — highlight any embedded runtime indices like {1} #} + + + {# Unit #} + + + {# Primary doc string #} + + + {# Optional extended desc / warnings #} + + + {# Which CANPoints feed this field (1-indexed) #} + + + {% endfor %} + +
TopicUnitDescriptionNotes / WarningsMapped Points
+ + {{ field['name'] | replace("{", "{") | replace("}", "}") }} + + + {{ field['unit'] if field['unit'] else "—" }} + {{ field.get('doc', '—') }} + {% if 'desc' in field %} + ⚠ {{ field['desc'] }} + {% else %} + + {% endif %} + + {% for v in field.get('values', []) %} + {# Look up the referenced point for a brief inline summary #} + {% set pt = msg.points[v - 1] %} + #{{ v }} + + ({{ pt.size }}b + {%- if pt.c_type is defined %}, {{ pt.c_type }}{% endif -%} + {%- if pt.formatter is defined %} + , ÷{{ pt.formatter.arg if pt.formatter.key == "divide" else pt.formatter.key }} + {%- endif -%} + ) + + {% if not loop.last %}
{% endif %} + {% endfor %} +
+ + {# ════════════════════════════════ + CAN POINTS (raw bit layout) + ════════════════════════════════ #} + + + + + + + + + + + + + + + + {% for pt in msg.points %} + + {# ── Padding / unparse point ── #} + {% if pt.parse is defined and pt.parse == false %} + + + + + + + {# ── Active point ── #} + {% else %} + + + + {# Size in bits #} + + + {# Optional name #} + + + {# C type #} + + + {# Endianness #} + + + {# Formatter #} + + + {# Signed #} + + + {# Sim description #} + + + {% endif %} + + {% endfor %} + +
#BitsNameC TypeEndianFormatterSignedSimulation
{{ loop.index }}{{ pt.size }}— padding / reserved (not decoded) —
{{ loop.index }}{{ pt.size }} bit{{ "s" if pt.size != 1 else "" }}{{ pt.name | default("—") }}{{ pt.c_type | default("—") }}{{ pt.endianness | default("big") }} + {% if pt.formatter is defined %} + {{ pt.formatter.key }} + {% if pt.formatter.arg is defined %}({{ pt.formatter.arg }}){% endif %} + {% elif pt.format is defined %} + {{ pt.format }} + {% elif pt.ieee754_f32 is defined and pt.ieee754_f32 %} + IEEE 754 f32 + {% else %} + — + {% endif %} + {{ "Yes" if pt.signed is defined and pt.signed else "No" }} + {% if pt.sim is defined %} + {% if pt.sim.options is defined %} + {# Enum sim #} + enum +
+ {% for opt in pt.sim.options %} + + {{ opt[0] }} → {{ (opt[1] * 100) | round(0) | int }}% + + {% if not loop.last %}  {% endif %} + {% endfor %} + {% else %} + {# Sweep sim #} + sweep + + [{{ pt.sim.min }}, {{ pt.sim.max }}] + Δ [{{ pt.sim.inc_min }}, {{ pt.sim.inc_max }}] + {% if pt.sim.round is defined and pt.sim.round %} · rounded{% endif %} + + {% endif %} + {% else %} + + {% endif %} +
+ + {# ── Encodable default values (if any) ── #} + {% set ns = namespace(has_defaults=false) %} + {% for pt in msg.get('points', []) %} + {% if 'default_value' in pt %}{% set ns.has_defaults = true %}{% endif %} + {% endfor %} + {% if ns.has_defaults %} + + + + + {% for pt in msg.get('points', []) %} + {% if 'default_value' in pt %} + + + + + + {% endif %} + {% endfor %} + +
#NameDefault Value
{{ loop.index }}{{ pt.name | default("—") }}{{ pt.default_value }}
+ {% endif %} + +
{# /msg-body #} +
{# /msg-section #} +{% endfor %} + +{# ════════════════════════════════════════════════════════════ + FOOTER + ════════════════════════════════════════════════════════════ #} + + + +