host.controller_web_editor.routes.hitboxes

GET/POST handler logic for controller hitbox layouts.

Layouts live as JSON files under host/controller_web_editor/hitboxes/, one per controller type. v1 only ships xbox.json. Layout names are allowlisted so the URL parameter can't traverse the filesystem.

 1"""GET/POST handler logic for controller hitbox layouts.
 2
 3Layouts live as JSON files under ``host/controller_web_editor/hitboxes/``,
 4one per controller type.  v1 only ships ``xbox.json``.  Layout names are
 5allowlisted so the URL parameter can't traverse the filesystem.
 6"""
 7
 8from __future__ import annotations
 9
10import json
11from pathlib import Path
12
13
14HITBOXES_DIR = Path(__file__).resolve().parent.parent / "hitboxes"
15
16# Allowlist of layout names that may be read/written.  Extend with
17# "ps5", "switch", etc. when those layouts arrive.
18ALLOWED_LAYOUTS = frozenset({"xbox"})
19
20
21class LayoutNotAllowedError(ValueError):
22    """Raised when the client requests an unknown layout name."""
23
24
25def _layout_path(layout: str) -> Path:
26    if layout not in ALLOWED_LAYOUTS:
27        raise LayoutNotAllowedError(f"unknown layout: {layout!r}")
28    return HITBOXES_DIR / f"{layout}.json"
29
30
31def load_layout(layout: str) -> dict:
32    """Read a hitbox JSON file and return it as a dict."""
33    return json.loads(_layout_path(layout).read_text(encoding="utf-8"))
34
35
36def save_layout(layout: str, data: dict) -> None:
37    """Write a hitbox JSON file.  Validates the payload shape minimally.
38
39    A timestamp-free ``.bak`` of the previous contents is written next to
40    the file before overwriting, so an accidental overwrite is recoverable.
41    """
42    if not isinstance(data, dict):
43        raise ValueError("payload must be a JSON object")
44    if "regions" not in data or not isinstance(data["regions"], dict):
45        raise ValueError("payload missing 'regions' object")
46    if "viewBox" not in data or not isinstance(data["viewBox"], str):
47        raise ValueError("payload missing 'viewBox' string")
48
49    # Optional fields validated only if present.
50    labels = data.get("labels")
51    if labels is not None and not isinstance(labels, dict):
52        raise ValueError("'labels' must be an object")
53    fonts = data.get("fonts")
54    if fonts is not None and not isinstance(fonts, dict):
55        raise ValueError("'fonts' must be an object")
56
57    path = _layout_path(layout)
58    if path.exists():
59        backup = path.with_suffix(path.suffix + ".bak")
60        backup.write_bytes(path.read_bytes())
61
62    path.parent.mkdir(parents=True, exist_ok=True)
63    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
64
65
66def list_layouts() -> list[str]:
67    """Return all allowlisted layouts that currently have a file on disk."""
68    return sorted(
69        name for name in ALLOWED_LAYOUTS
70        if (HITBOXES_DIR / f"{name}.json").is_file()
71    )
HITBOXES_DIR = PosixPath('/home/runner/work/Robot-2026/Robot-2026/host/controller_web_editor/hitboxes')
ALLOWED_LAYOUTS = frozenset({'xbox'})
class LayoutNotAllowedError(builtins.ValueError):
22class LayoutNotAllowedError(ValueError):
23    """Raised when the client requests an unknown layout name."""

Raised when the client requests an unknown layout name.

def load_layout(layout: str) -> dict:
32def load_layout(layout: str) -> dict:
33    """Read a hitbox JSON file and return it as a dict."""
34    return json.loads(_layout_path(layout).read_text(encoding="utf-8"))

Read a hitbox JSON file and return it as a dict.

def save_layout(layout: str, data: dict) -> None:
37def save_layout(layout: str, data: dict) -> None:
38    """Write a hitbox JSON file.  Validates the payload shape minimally.
39
40    A timestamp-free ``.bak`` of the previous contents is written next to
41    the file before overwriting, so an accidental overwrite is recoverable.
42    """
43    if not isinstance(data, dict):
44        raise ValueError("payload must be a JSON object")
45    if "regions" not in data or not isinstance(data["regions"], dict):
46        raise ValueError("payload missing 'regions' object")
47    if "viewBox" not in data or not isinstance(data["viewBox"], str):
48        raise ValueError("payload missing 'viewBox' string")
49
50    # Optional fields validated only if present.
51    labels = data.get("labels")
52    if labels is not None and not isinstance(labels, dict):
53        raise ValueError("'labels' must be an object")
54    fonts = data.get("fonts")
55    if fonts is not None and not isinstance(fonts, dict):
56        raise ValueError("'fonts' must be an object")
57
58    path = _layout_path(layout)
59    if path.exists():
60        backup = path.with_suffix(path.suffix + ".bak")
61        backup.write_bytes(path.read_bytes())
62
63    path.parent.mkdir(parents=True, exist_ok=True)
64    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

Write a hitbox JSON file. Validates the payload shape minimally.

A timestamp-free .bak of the previous contents is written next to the file before overwriting, so an accidental overwrite is recoverable.

def list_layouts() -> list[str]:
67def list_layouts() -> list[str]:
68    """Return all allowlisted layouts that currently have a file on disk."""
69    return sorted(
70        name for name in ALLOWED_LAYOUTS
71        if (HITBOXES_DIR / f"{name}.json").is_file()
72    )

Return all allowlisted layouts that currently have a file on disk.