tools/sgpiom: add AST10x0 JSON validate/merge/generate tooling

- Add sgpio_json_tool.py CLI with validate/merge/generate/check/report
- Add SGPIOM schema files for controller, banks, and signals
- Add AST1060 example manifests (dcscm, prot_dice) plus common defaults
- Validate example workflows end-to-end with generated/report checks
diff --git a/tools/sgpiom/examples/ast1060_dcscm.json b/tools/sgpiom/examples/ast1060_dcscm.json
new file mode 100644
index 0000000..93522cd
--- /dev/null
+++ b/tools/sgpiom/examples/ast1060_dcscm.json
@@ -0,0 +1,29 @@
+{
+  "board": "ast1060_dcscm",
+  "signals": [
+    {
+      "logical_name": "platform_state_out_0",
+      "bank": "sgpiom_a_d",
+      "pin": 0,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    },
+    {
+      "logical_name": "platform_state_out_7",
+      "bank": "sgpiom_a_d",
+      "pin": 7,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    },
+    {
+      "logical_name": "scm_rsu_hide_done_in",
+      "bank": "sgpiom_a_d",
+      "pin": 6,
+      "direction": "in",
+      "active_level": "high",
+      "safe_default": null
+    }
+  ]
+}
diff --git a/tools/sgpiom/examples/ast1060_prot_dice.json b/tools/sgpiom/examples/ast1060_prot_dice.json
new file mode 100644
index 0000000..3fcd5ac
--- /dev/null
+++ b/tools/sgpiom/examples/ast1060_prot_dice.json
@@ -0,0 +1,45 @@
+{
+  "board": "ast1060_prot_dice",
+  "signals": [
+    {
+      "logical_name": "bmc_srst_ctrl_out",
+      "bank": "sgpiom_a_d",
+      "pin": 8,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    },
+    {
+      "logical_name": "bmc_extrst_ctrl_out",
+      "bank": "sgpiom_a_d",
+      "pin": 9,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    },
+    {
+      "logical_name": "bmc_rst_ind_in",
+      "bank": "sgpiom_a_d",
+      "pin": 4,
+      "direction": "in",
+      "active_level": "high",
+      "safe_default": null
+    },
+    {
+      "logical_name": "platform_state_out_0",
+      "bank": "sgpiom_m_p",
+      "pin": 24,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    },
+    {
+      "logical_name": "platform_state_out_7",
+      "bank": "sgpiom_m_p",
+      "pin": 31,
+      "direction": "out",
+      "active_level": "high",
+      "safe_default": 0
+    }
+  ]
+}
diff --git a/tools/sgpiom/examples/common.json b/tools/sgpiom/examples/common.json
new file mode 100644
index 0000000..8ee8049
--- /dev/null
+++ b/tools/sgpiom/examples/common.json
@@ -0,0 +1,17 @@
+{
+  "board": "common",
+  "controller": {
+    "name": "sgpiom0",
+    "base_addr": "0x1E780000",
+    "bus_frequency_hz": 1000000,
+    "ngpios": 128,
+    "enabled": true
+  },
+  "banks": [
+    { "name": "sgpiom_a_d", "pin_offset": 0, "ngpios": 32, "reserved_pins": [] },
+    { "name": "sgpiom_e_h", "pin_offset": 32, "ngpios": 32, "reserved_pins": [] },
+    { "name": "sgpiom_i_l", "pin_offset": 64, "ngpios": 32, "reserved_pins": [] },
+    { "name": "sgpiom_m_p", "pin_offset": 96, "ngpios": 32, "reserved_pins": [] }
+  ],
+  "signals": []
+}
diff --git a/tools/sgpiom/schema/banks.schema.json b/tools/sgpiom/schema/banks.schema.json
new file mode 100644
index 0000000..b3927bef
--- /dev/null
+++ b/tools/sgpiom/schema/banks.schema.json
@@ -0,0 +1,20 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://gpio-port.local/sgpiom/banks.schema.json",
+  "title": "SGPIOM Banks",
+  "type": "array",
+  "items": {
+    "type": "object",
+    "required": ["name", "pin_offset", "ngpios", "reserved_pins"],
+    "properties": {
+      "name": { "type": "string", "minLength": 1 },
+      "pin_offset": { "type": "integer", "minimum": 0 },
+      "ngpios": { "type": "integer", "minimum": 1, "maximum": 32 },
+      "reserved_pins": {
+        "type": "array",
+        "items": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "additionalProperties": true
+  }
+}
diff --git a/tools/sgpiom/schema/controller.schema.json b/tools/sgpiom/schema/controller.schema.json
new file mode 100644
index 0000000..df13afa
--- /dev/null
+++ b/tools/sgpiom/schema/controller.schema.json
@@ -0,0 +1,20 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://gpio-port.local/sgpiom/controller.schema.json",
+  "title": "SGPIOM Controller",
+  "type": "object",
+  "required": ["name", "base_addr", "bus_frequency_hz", "ngpios", "enabled"],
+  "properties": {
+    "name": { "type": "string", "minLength": 1 },
+    "base_addr": {
+      "oneOf": [
+        { "type": "integer", "minimum": 0 },
+        { "type": "string", "pattern": "^0x[0-9a-fA-F]+$" }
+      ]
+    },
+    "bus_frequency_hz": { "type": "integer", "minimum": 0 },
+    "ngpios": { "type": "integer", "minimum": 1 },
+    "enabled": { "type": "boolean" }
+  },
+  "additionalProperties": true
+}
diff --git a/tools/sgpiom/schema/signals.schema.json b/tools/sgpiom/schema/signals.schema.json
new file mode 100644
index 0000000..7cdaa7e
--- /dev/null
+++ b/tools/sgpiom/schema/signals.schema.json
@@ -0,0 +1,31 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://gpio-port.local/sgpiom/signals.schema.json",
+  "title": "SGPIOM Signals",
+  "type": "array",
+  "items": {
+    "type": "object",
+    "required": [
+      "logical_name",
+      "bank",
+      "pin",
+      "direction",
+      "active_level",
+      "safe_default"
+    ],
+    "properties": {
+      "logical_name": { "type": "string", "minLength": 1 },
+      "bank": { "type": "string", "minLength": 1 },
+      "pin": { "type": "integer", "minimum": 0 },
+      "direction": { "type": "string", "enum": ["in", "out"] },
+      "active_level": { "type": "string", "enum": ["high", "low"] },
+      "safe_default": {
+        "oneOf": [
+          { "type": "null" },
+          { "type": "integer", "enum": [0, 1] }
+        ]
+      }
+    },
+    "additionalProperties": true
+  }
+}
diff --git a/tools/sgpiom/sgpio_json_tool.py b/tools/sgpiom/sgpio_json_tool.py
new file mode 100755
index 0000000..273985e
--- /dev/null
+++ b/tools/sgpiom/sgpio_json_tool.py
@@ -0,0 +1,603 @@
+#!/usr/bin/env python3
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+"""SGPIOM JSON tooling for validate/merge/generate/check/report workflows."""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import hashlib
+import json
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Tuple
+
+JsonMap = Dict[str, Any]
+
+
+class ValidationError(Exception):
+    """Raised when manifest validation fails."""
+
+
+@dataclass
+class ValidationResult:
+    errors: List[str]
+
+    @property
+    def ok(self) -> bool:
+        return not self.errors
+
+
+def load_json(path: Path) -> JsonMap:
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError as exc:
+        raise ValidationError(f"input not found: {path}") from exc
+    except json.JSONDecodeError as exc:
+        raise ValidationError(f"invalid JSON in {path}: {exc}") from exc
+
+    if not isinstance(data, dict):
+        raise ValidationError(f"top-level JSON must be an object: {path}")
+    return data
+
+
+def deep_merge(a: JsonMap, b: JsonMap) -> JsonMap:
+    """Merge b into a recursively for plain objects (non-list values)."""
+    out = copy.deepcopy(a)
+    for key, b_val in b.items():
+        a_val = out.get(key)
+        if isinstance(a_val, dict) and isinstance(b_val, dict):
+            out[key] = deep_merge(a_val, b_val)
+        else:
+            out[key] = copy.deepcopy(b_val)
+    return out
+
+
+def merge_named_list(
+    base: List[JsonMap], override: List[JsonMap], key_name: str
+) -> List[JsonMap]:
+    """Deterministically merge list entries by key, preserving base order first."""
+    result: List[JsonMap] = []
+    index: Dict[str, int] = {}
+
+    for item in base:
+        name = item.get(key_name)
+        if isinstance(name, str) and name not in index:
+            index[name] = len(result)
+            result.append(copy.deepcopy(item))
+
+    for item in override:
+        name = item.get(key_name)
+        if not isinstance(name, str):
+            # Validation handles this; keep deterministic behavior by append.
+            result.append(copy.deepcopy(item))
+            continue
+        if name in index:
+            old_item = result[index[name]]
+            if isinstance(old_item, dict) and isinstance(item, dict):
+                result[index[name]] = deep_merge(old_item, item)
+            else:
+                result[index[name]] = copy.deepcopy(item)
+        else:
+            index[name] = len(result)
+            result.append(copy.deepcopy(item))
+
+    return result
+
+
+def merge_manifests(inputs: Iterable[JsonMap]) -> JsonMap:
+    merged: JsonMap = {}
+    for incoming in inputs:
+        previous = copy.deepcopy(merged)
+        merged = deep_merge(merged, incoming)
+
+        prev_banks = previous.get("banks")
+        in_banks = incoming.get("banks")
+        if isinstance(prev_banks, list) and isinstance(in_banks, list):
+            merged["banks"] = merge_named_list(prev_banks, in_banks, "name")
+
+        prev_signals = previous.get("signals")
+        in_signals = incoming.get("signals")
+        if isinstance(prev_signals, list) and isinstance(in_signals, list):
+            merged["signals"] = merge_named_list(
+                prev_signals, in_signals, "logical_name"
+            )
+
+    return merged
+
+
+def _is_nonneg_int(value: Any) -> bool:
+    return isinstance(value, int) and value >= 0
+
+
+def _is_bool(value: Any) -> bool:
+    return isinstance(value, bool)
+
+
+def _is_hex_string(value: Any) -> bool:
+    return isinstance(value, str) and value.startswith("0x")
+
+
+def validate_manifest(manifest: JsonMap) -> ValidationResult:
+    errors: List[str] = []
+
+    board = manifest.get("board")
+    if not isinstance(board, str) or not board:
+        errors.append("board: required non-empty string")
+
+    controller = manifest.get("controller")
+    if not isinstance(controller, dict):
+        errors.append("controller: required object")
+        return ValidationResult(errors)
+
+    required_controller = [
+        "name",
+        "base_addr",
+        "bus_frequency_hz",
+        "ngpios",
+        "enabled",
+    ]
+    for key in required_controller:
+        if key not in controller:
+            errors.append(f"controller.{key}: required")
+
+    if "name" in controller and not isinstance(controller["name"], str):
+        errors.append("controller.name: must be string")
+
+    if "base_addr" in controller:
+        base_addr = controller["base_addr"]
+        if not (_is_hex_string(base_addr) or _is_nonneg_int(base_addr)):
+            errors.append("controller.base_addr: must be hex string or integer")
+
+    if "bus_frequency_hz" in controller and not _is_nonneg_int(
+        controller["bus_frequency_hz"]
+    ):
+        errors.append("controller.bus_frequency_hz: must be non-negative integer")
+
+    if "ngpios" in controller and (not _is_nonneg_int(controller["ngpios"]) or controller["ngpios"] == 0):
+        errors.append("controller.ngpios: must be positive integer")
+
+    if "enabled" in controller and not _is_bool(controller["enabled"]):
+        errors.append("controller.enabled: must be bool")
+
+    banks = manifest.get("banks")
+    if not isinstance(banks, list) or not banks:
+        errors.append("banks: required non-empty list")
+        return ValidationResult(errors)
+
+    bank_by_name: Dict[str, JsonMap] = {}
+    seen_bank_names: set[str] = set()
+    total_bank_gpios = 0
+
+    for i, bank in enumerate(banks):
+        prefix = f"banks[{i}]"
+        if not isinstance(bank, dict):
+            errors.append(f"{prefix}: must be object")
+            continue
+
+        for field in ["name", "pin_offset", "ngpios", "reserved_pins"]:
+            if field not in bank:
+                errors.append(f"{prefix}.{field}: required")
+
+        name = bank.get("name")
+        if not isinstance(name, str) or not name:
+            errors.append(f"{prefix}.name: must be non-empty string")
+        else:
+            if name in seen_bank_names:
+                errors.append(f"{prefix}.name: duplicate bank name '{name}'")
+            seen_bank_names.add(name)
+            bank_by_name[name] = bank
+
+        pin_offset = bank.get("pin_offset")
+        if not _is_nonneg_int(pin_offset):
+            errors.append(f"{prefix}.pin_offset: must be non-negative integer")
+
+        ngpios = bank.get("ngpios")
+        if not _is_nonneg_int(ngpios) or ngpios == 0 or ngpios > 32:
+            errors.append(f"{prefix}.ngpios: must be integer in range 1..32")
+        else:
+            total_bank_gpios += ngpios
+
+        reserved = bank.get("reserved_pins")
+        if not isinstance(reserved, list):
+            errors.append(f"{prefix}.reserved_pins: must be list")
+        else:
+            for j, pin in enumerate(reserved):
+                if not _is_nonneg_int(pin):
+                    errors.append(f"{prefix}.reserved_pins[{j}]: must be non-negative integer")
+                elif isinstance(ngpios, int) and pin >= ngpios:
+                    errors.append(
+                        f"{prefix}.reserved_pins[{j}]: pin {pin} out of range for ngpios {ngpios}"
+                    )
+
+    controller_ngpios = controller.get("ngpios")
+    if isinstance(controller_ngpios, int) and total_bank_gpios > controller_ngpios:
+        errors.append(
+            "controller.ngpios: smaller than sum of bank ngpios "
+            f"({controller_ngpios} < {total_bank_gpios})"
+        )
+
+    signals = manifest.get("signals")
+    if not isinstance(signals, list):
+        errors.append("signals: required list")
+        return ValidationResult(errors)
+
+    seen_signal_names: set[str] = set()
+    seen_pin_ownership: set[Tuple[str, int]] = set()
+
+    for i, signal in enumerate(signals):
+        prefix = f"signals[{i}]"
+        if not isinstance(signal, dict):
+            errors.append(f"{prefix}: must be object")
+            continue
+
+        for field in [
+            "logical_name",
+            "bank",
+            "pin",
+            "direction",
+            "active_level",
+            "safe_default",
+        ]:
+            if field not in signal:
+                errors.append(f"{prefix}.{field}: required")
+
+        logical_name = signal.get("logical_name")
+        if not isinstance(logical_name, str) or not logical_name:
+            errors.append(f"{prefix}.logical_name: must be non-empty string")
+        elif logical_name in seen_signal_names:
+            errors.append(f"{prefix}.logical_name: duplicate logical name '{logical_name}'")
+        else:
+            seen_signal_names.add(logical_name)
+
+        bank_name = signal.get("bank")
+        if not isinstance(bank_name, str) or not bank_name:
+            errors.append(f"{prefix}.bank: must be non-empty string")
+            continue
+
+        pin = signal.get("pin")
+        if not _is_nonneg_int(pin):
+            errors.append(f"{prefix}.pin: must be non-negative integer")
+            continue
+
+        direction = signal.get("direction")
+        if direction not in ("in", "out"):
+            errors.append(f"{prefix}.direction: must be 'in' or 'out'")
+
+        active_level = signal.get("active_level")
+        if active_level not in ("high", "low"):
+            errors.append(f"{prefix}.active_level: must be 'high' or 'low'")
+
+        safe_default = signal.get("safe_default")
+        if safe_default not in (None, 0, 1):
+            errors.append(f"{prefix}.safe_default: must be null, 0, or 1")
+
+        bank = bank_by_name.get(bank_name)
+        if bank is None:
+            errors.append(f"{prefix}.bank: unknown bank '{bank_name}'")
+            continue
+
+        bank_ngpios = bank.get("ngpios")
+        if isinstance(bank_ngpios, int) and pin >= bank_ngpios:
+            errors.append(
+                f"{prefix}.pin: pin {pin} out of range for bank '{bank_name}' ngpios {bank_ngpios}"
+            )
+
+        reserved_pins = bank.get("reserved_pins", [])
+        if isinstance(reserved_pins, list) and pin in reserved_pins:
+            errors.append(
+                f"{prefix}.pin: pin {pin} is reserved in bank '{bank_name}'"
+            )
+
+        owner_key = (bank_name, pin)
+        if owner_key in seen_pin_ownership:
+            errors.append(
+                f"{prefix}: overlaps existing signal ownership at {bank_name}[{pin}]"
+            )
+        else:
+            seen_pin_ownership.add(owner_key)
+
+        if direction == "in" and safe_default is not None:
+            errors.append(f"{prefix}.safe_default: input signal must use null")
+
+    return ValidationResult(errors)
+
+
+def canonical_json(data: JsonMap) -> str:
+    return json.dumps(data, sort_keys=True, separators=(",", ":"))
+
+
+def rust_ident(name: str) -> str:
+    out = []
+    for ch in name:
+        if ch.isalnum():
+            out.append(ch.upper())
+        else:
+            out.append("_")
+    ident = "".join(out).strip("_")
+    if not ident:
+        ident = "UNNAMED"
+    if ident[0].isdigit():
+        ident = f"_{ident}"
+    return ident
+
+
+def render_rust(manifest: JsonMap) -> str:
+    manifest_hash = hashlib.sha256(canonical_json(manifest).encode("utf-8")).hexdigest()
+
+    controller = manifest["controller"]
+    banks = sorted(manifest["banks"], key=lambda b: (b["pin_offset"], b["name"]))
+    signals = sorted(manifest["signals"], key=lambda s: s["logical_name"])
+
+    lines: List[str] = []
+    lines.append("// Licensed under the Apache-2.0 license")
+    lines.append("// SPDX-License-Identifier: Apache-2.0")
+    lines.append("")
+    lines.append("// @generated by tools/sgpiom/sgpio_json_tool.py; DO NOT EDIT.")
+    lines.append(f"pub const SGPIOM_MANIFEST_HASH: &str = \"{manifest_hash}\";")
+    lines.append("")
+    lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]")
+    lines.append("pub enum Direction { In, Out }")
+    lines.append("")
+    lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]")
+    lines.append("pub enum ActiveLevel { High, Low }")
+    lines.append("")
+    lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]")
+    lines.append("pub struct SgpiomControllerConfig {")
+    lines.append("    pub name: &'static str,")
+    lines.append("    pub base_addr: u32,")
+    lines.append("    pub bus_frequency_hz: u32,")
+    lines.append("    pub ngpios: u16,")
+    lines.append("    pub enabled: bool,")
+    lines.append("}")
+    lines.append("")
+    lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]")
+    lines.append("pub struct SgpiomBankConfig {")
+    lines.append("    pub name: &'static str,")
+    lines.append("    pub pin_offset: u8,")
+    lines.append("    pub ngpios: u8,")
+    lines.append("    pub reserved_pins: &'static [u8],")
+    lines.append("}")
+    lines.append("")
+    lines.append("#[derive(Debug, Copy, Clone, Eq, PartialEq)]")
+    lines.append("pub struct SgpiomSignalConfig {")
+    lines.append("    pub logical_name: &'static str,")
+    lines.append("    pub bank: &'static str,")
+    lines.append("    pub pin: u8,")
+    lines.append("    pub direction: Direction,")
+    lines.append("    pub active_level: ActiveLevel,")
+    lines.append("    pub safe_default: Option<bool>,")
+    lines.append("}")
+    lines.append("")
+
+    base_addr = controller["base_addr"]
+    if isinstance(base_addr, str):
+        base_addr_literal = base_addr
+    else:
+        base_addr_literal = hex(base_addr)
+
+    lines.append("pub const SGPIOM_CONTROLLER: SgpiomControllerConfig = SgpiomControllerConfig {")
+    lines.append(f"    name: \"{controller['name']}\",")
+    lines.append(f"    base_addr: {base_addr_literal},")
+    lines.append(f"    bus_frequency_hz: {controller['bus_frequency_hz']},")
+    lines.append(f"    ngpios: {controller['ngpios']},")
+    lines.append(f"    enabled: {'true' if controller['enabled'] else 'false'},")
+    lines.append("};")
+    lines.append("")
+
+    for bank in banks:
+        ident = rust_ident(bank["name"])
+        reserved = ", ".join(str(pin) for pin in sorted(bank.get("reserved_pins", [])))
+        lines.append(
+            f"const RESERVED_PINS_{ident}: [u8; {len(bank.get('reserved_pins', []))}] = [{reserved}];"
+        )
+    lines.append("")
+
+    lines.append(f"pub const SGPIOM_BANKS: [SgpiomBankConfig; {len(banks)}] = [")
+    for bank in banks:
+        ident = rust_ident(bank["name"])
+        lines.append("    SgpiomBankConfig {")
+        lines.append(f"        name: \"{bank['name']}\",")
+        lines.append(f"        pin_offset: {bank['pin_offset']},")
+        lines.append(f"        ngpios: {bank['ngpios']},")
+        lines.append(f"        reserved_pins: &RESERVED_PINS_{ident},")
+        lines.append("    },")
+    lines.append("];\n")
+
+    lines.append(f"pub const SGPIOM_SIGNALS: [SgpiomSignalConfig; {len(signals)}] = [")
+    for signal in signals:
+        dir_value = "Direction::In" if signal["direction"] == "in" else "Direction::Out"
+        lvl_value = (
+            "ActiveLevel::High" if signal["active_level"] == "high" else "ActiveLevel::Low"
+        )
+        safe_default = signal["safe_default"]
+        if safe_default is None:
+            safe_default_text = "None"
+        else:
+            safe_default_text = "Some(true)" if safe_default == 1 else "Some(false)"
+
+        lines.append("    SgpiomSignalConfig {")
+        lines.append(f"        logical_name: \"{signal['logical_name']}\",")
+        lines.append(f"        bank: \"{signal['bank']}\",")
+        lines.append(f"        pin: {signal['pin']},")
+        lines.append(f"        direction: {dir_value},")
+        lines.append(f"        active_level: {lvl_value},")
+        lines.append(f"        safe_default: {safe_default_text},")
+        lines.append("    },")
+    lines.append("];\n")
+
+    return "\n".join(lines)
+
+
+def load_and_merge(input_paths: List[Path]) -> JsonMap:
+    if not input_paths:
+        raise ValidationError("at least one --input is required")
+    manifests = [load_json(path) for path in input_paths]
+    return merge_manifests(manifests)
+
+
+def cmd_validate(args: argparse.Namespace) -> int:
+    merged = load_and_merge(args.input)
+    result = validate_manifest(merged)
+    if not result.ok:
+        for err in result.errors:
+            print(f"ERROR: {err}", file=sys.stderr)
+        return 1
+
+    if args.merged_out:
+        args.merged_out.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8")
+
+    print("validate: OK")
+    return 0
+
+
+def cmd_merge(args: argparse.Namespace) -> int:
+    merged = load_and_merge(args.input)
+    result = validate_manifest(merged)
+    if not result.ok:
+        for err in result.errors:
+            print(f"ERROR: {err}", file=sys.stderr)
+        return 1
+
+    args.output.write_text(json.dumps(merged, indent=2) + "\n", encoding="utf-8")
+    print(f"merge: wrote {args.output}")
+    return 0
+
+
+def cmd_generate(args: argparse.Namespace) -> int:
+    merged = load_and_merge(args.input)
+    result = validate_manifest(merged)
+    if not result.ok:
+        for err in result.errors:
+            print(f"ERROR: {err}", file=sys.stderr)
+        return 1
+
+    rust_text = render_rust(merged)
+    args.output.write_text(rust_text, encoding="utf-8")
+    print(f"generate: wrote {args.output}")
+    return 0
+
+
+def cmd_check(args: argparse.Namespace) -> int:
+    merged = load_and_merge(args.input)
+    result = validate_manifest(merged)
+    if not result.ok:
+        for err in result.errors:
+            print(f"ERROR: {err}", file=sys.stderr)
+        return 1
+
+    expected = render_rust(merged)
+    try:
+        actual = args.output.read_text(encoding="utf-8")
+    except FileNotFoundError:
+        print(f"ERROR: output file missing: {args.output}", file=sys.stderr)
+        return 1
+
+    if actual != expected:
+        print("ERROR: generated output is out of date", file=sys.stderr)
+        return 1
+
+    print("check: OK")
+    return 0
+
+
+def cmd_report(args: argparse.Namespace) -> int:
+    merged = load_and_merge(args.input)
+    result = validate_manifest(merged)
+    if not result.ok:
+        for err in result.errors:
+            print(f"ERROR: {err}", file=sys.stderr)
+        return 1
+
+    controller = merged["controller"]
+    banks = sorted(merged["banks"], key=lambda b: (b["pin_offset"], b["name"]))
+    signals = merged["signals"]
+
+    bank_counts: Dict[str, int] = {bank["name"]: 0 for bank in banks}
+    out_count = 0
+    in_count = 0
+    for signal in signals:
+        bank_counts[signal["bank"]] = bank_counts.get(signal["bank"], 0) + 1
+        if signal["direction"] == "out":
+            out_count += 1
+        else:
+            in_count += 1
+
+    print(f"board: {merged['board']}")
+    print(
+        "controller: "
+        f"{controller['name']} base={controller['base_addr']} "
+        f"freq={controller['bus_frequency_hz']}Hz ngpios={controller['ngpios']} "
+        f"enabled={controller['enabled']}"
+    )
+    print(f"banks: {len(banks)}  signals: {len(signals)} (in={in_count}, out={out_count})")
+    print("bank usage:")
+    for bank in banks:
+        name = bank["name"]
+        print(
+            f"  - {name}: offset={bank['pin_offset']} ngpios={bank['ngpios']} "
+            f"reserved={len(bank['reserved_pins'])} signals={bank_counts.get(name, 0)}"
+        )
+
+    return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="SGPIOM JSON tooling")
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    def add_inputs(sub: argparse.ArgumentParser) -> None:
+        sub.add_argument(
+            "--input",
+            type=Path,
+            action="append",
+            required=True,
+            help="Input manifest path (repeatable, merge order is declaration order)",
+        )
+
+    validate_parser = subparsers.add_parser("validate", help="Validate merged manifest")
+    add_inputs(validate_parser)
+    validate_parser.add_argument("--merged-out", type=Path)
+    validate_parser.set_defaults(func=cmd_validate)
+
+    merge_parser = subparsers.add_parser("merge", help="Merge manifests and emit JSON")
+    add_inputs(merge_parser)
+    merge_parser.add_argument("--output", type=Path, required=True)
+    merge_parser.set_defaults(func=cmd_merge)
+
+    generate_parser = subparsers.add_parser(
+        "generate", help="Generate Rust static configuration module"
+    )
+    add_inputs(generate_parser)
+    generate_parser.add_argument("--output", type=Path, required=True)
+    generate_parser.set_defaults(func=cmd_generate)
+
+    check_parser = subparsers.add_parser(
+        "check", help="Verify generated Rust output is up to date"
+    )
+    add_inputs(check_parser)
+    check_parser.add_argument("--output", type=Path, required=True)
+    check_parser.set_defaults(func=cmd_check)
+
+    report_parser = subparsers.add_parser("report", help="Print manifest summary report")
+    add_inputs(report_parser)
+    report_parser.set_defaults(func=cmd_report)
+
+    return parser
+
+
+def main(argv: List[str]) -> int:
+    parser = build_parser()
+    args = parser.parse_args(argv)
+    try:
+        return args.func(args)
+    except ValidationError as exc:
+        print(f"ERROR: {exc}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))