Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd03607ea | ||
|
|
78a7da2dbf | ||
|
|
5c87d706c2 | ||
|
|
299ffaf396 | ||
|
|
09a7b85770 | ||
|
|
065fb508a4 | ||
|
|
7ad5a692a7 | ||
|
|
085273e356 | ||
|
|
35cbc7ffd5 | ||
|
|
5b2d3cd613 | ||
|
|
627db2eba6 | ||
|
|
5ab69a835c | ||
|
|
f79c722e68 | ||
|
|
7e872cfbc3 | ||
|
|
3046c4aeee | ||
|
|
b32d581d27 | ||
|
|
d5fff7c6dd |
@@ -2,35 +2,39 @@
|
||||
|
||||
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
|
||||
|
||||
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints once into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
|
||||
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
|
||||
|
||||
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
|
||||
Live: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
|
||||
Version: **2.87.2** (see `periscope/src/frontend/content/changelog.md`)
|
||||
Operator: Michele Bigi
|
||||
|
||||
This tree is the Periscope product (operator: Michele Bigi). It is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope); Faradworks does not operate this instance.
|
||||
## What you get
|
||||
|
||||
## What it checks — and what it will not invent
|
||||
- **PCB + schematic review** — same finding object for `MODE=run` (schematic) and `MODE=pcb` (layout). Findings keep FACT, REQUIREMENT, and INFERENCE apart.
|
||||
- **Protocol-aware certification** — gated on parts that are present. USB 2.0 D+/D− (pair Z vs packed 90 Ω), Ethernet MDI / RJ45, and MAC–PHY buses (RMII / MII / RGMII / SGMII) with catalog cites from packed specs. Missing evidence is silence or `not certified — because …`, not invented ohms or timing.
|
||||
- **Findings tree** — Error / Warning / Info first, then domains, rules, Certifications, and Protocols. Report export is **Save as Markdown** (`.md`).
|
||||
- **Apple / iPad-style shell** — thin top bar, tab navigation, light theme by default, dark mode in Settings.
|
||||
|
||||
USB pair impedance, Ethernet class, and trace current versus width are **examples** of gated checks. They run when that bus, connector, or datasheet number exists on the board. They are not a catalog of every interface in electronics.
|
||||
## What it will not invent
|
||||
|
||||
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net, sitting inside a courtyard, or having nearby coordinates does not change the object type.
|
||||
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net or sitting inside a courtyard does not change the object type.
|
||||
|
||||
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips the check). It does not invent current, impedance, copper weight, geometry, IEC creepage, or “typical USB 500 mA”.
|
||||
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips). It does not invent current, impedance, copper weight, geometry, or “typical” bus amps.
|
||||
|
||||
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the datasheet reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip — not a guessed ampacity table, not via-IPC folklore.
|
||||
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the part reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip.
|
||||
|
||||
**Interface class certifiers are gated on parts that are present.** USB-C, RJ45/Ethernet, PoE, and DDR-style certifiers fire only if that connector or device is on the graph. No USB-C receptacle → no USB-C findings (silence, not N/A). A bare RJ45 is Ethernet, not PoE. No DDR device → no DDR findings.
|
||||
**Certifiers are gated on parts that are present.** No USB-C receptacle → no USB-C findings. A bare RJ45 is Ethernet, not PoE. RMII PHY–MAC nets are not treated as RJ45/MDI 100 Ω pairs. Recognition alone is not certification.
|
||||
|
||||
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
|
||||
|
||||
Findings keep FACT, REQUIREMENT, and INFERENCE apart. Recommended datasheet notes are not errors. The same finding object is used for schematic (`MODE=run`) and PCB (`MODE=pcb`).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
|
||||
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
|
||||
| `periscope/src/` | Product code — finding engine, PCB/placement, protocol catalog, DeepSeek pipelines, Next.js UI, Dockerfiles, KiCad plugin |
|
||||
| `periscope/dependency/` | Shared AGPL overlays (parsers, extraction shell, UI primitives) merged at build/runtime — do not delete |
|
||||
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
|
||||
| `standards/protocol-specs/` | On-disk protocol PDF archive (PDFs gitignored; `INDEX.md` committed) |
|
||||
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
|
||||
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
|
||||
| `LICENSE` | GNU AGPL v3 |
|
||||
@@ -50,7 +54,7 @@ cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
|
||||
```
|
||||
|
||||
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
|
||||
Frontend — materialize the UI tree, then start Next.js:
|
||||
|
||||
```bash
|
||||
./scripts/materialize-frontend.sh
|
||||
@@ -60,7 +64,7 @@ NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostnam
|
||||
|
||||
Open the frontend URL, create a project, and upload a netlist plus BOM. A `.kicad_pcb` is required for the PCB job; without it the project can complete schematic review only. `periscope/dependency/simple_project/` is a small bundled schematic fixture (MSPM0G3507 + CH340E + SPX3819).
|
||||
|
||||
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`. Do not run an Anthropic Console uploader.
|
||||
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`.
|
||||
|
||||
Tests from the repo root:
|
||||
|
||||
@@ -81,7 +85,7 @@ Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
|
||||
|
||||
### Live host
|
||||
|
||||
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
|
||||
The public site is rebuilt from **`/root/periscope`**:
|
||||
|
||||
```bash
|
||||
cd /root/periscope
|
||||
@@ -90,17 +94,24 @@ cd /root/periscope
|
||||
|
||||
`--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL. The script refuses to run unless the checkout is `/root/periscope` (override with `CANONICAL_ROOT` only if the host layout differs). It does not touch `data/`.
|
||||
|
||||
## Source
|
||||
## Source of truth
|
||||
|
||||
| Remote | URL |
|
||||
| --- | --- |
|
||||
| GitHub | [https://github.com/manvalan/pinscope](https://github.com/manvalan/pinscope) (`git@github.com:manvalan/pinscope.git`) |
|
||||
| Gitea | [http://192.168.1.71:3000/michele/periscope.git](http://192.168.1.71:3000/michele/periscope.git) |
|
||||
**GitHub** is the primary remote for this product. Push `main` there first.
|
||||
|
||||
Push both: `git push github && git push gitea` (no force).
|
||||
| Remote | URL | Role |
|
||||
| --- | --- | --- |
|
||||
| GitHub | [https://github.com/manvalan/periscope](https://github.com/manvalan/periscope) (`git@github.com:manvalan/periscope.git`) | **Primary** — source of truth |
|
||||
| Gitea | [http://192.168.1.71:3000/michele/periscope](http://192.168.1.71:3000/michele/periscope) (`git@gitea:michele/periscope.git`, SSH port **2223**) | Secondary / mirror — do not delete |
|
||||
|
||||
```bash
|
||||
git push github main
|
||||
git push gitea main # optional mirror
|
||||
```
|
||||
|
||||
Do not force-push `main`.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE) — GNU Affero General Public License v3.0.
|
||||
|
||||
This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). For a commercial license of **upstream Pinscope**, contact Faradworks as they publish it. For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
|
||||
For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
# Periscope tree layout
|
||||
|
||||
Physical split (not a rewrite). AGPL `LICENSE` stays at the git root. The GitHub fork is not detached.
|
||||
Physical split under the git root. AGPL `LICENSE` stays at the repository root.
|
||||
|
||||
| Tree | Path | Role |
|
||||
| --- | --- | --- |
|
||||
| Native Periscope | `periscope/src/` | Finding engine clamp, PCB/placement/antenna, DeepSeek, local auth, KiCad plugin, deploy Dockerfiles |
|
||||
| Inherited PinScope | `periscope/dependency/` | Graph/parsers/`validate.py`, pipeline/extraction, OSS Next.js shell, skills, taxonomy, `simple_project` |
|
||||
| Product | `periscope/src/` | Finding engine, PCB/placement, protocol catalog + certifiers, DeepSeek pipelines, local auth, Next.js UI, KiCad plugin, deploy Dockerfiles |
|
||||
| Overlays | `periscope/dependency/` | Shared AGPL packages (graph/parsers, extraction pipeline, UI primitives, skills, taxonomy, `simple_project`) merged at build/runtime |
|
||||
| Third party | `vendor/impedancefinder/` | ImpedenceFinder (license UNKNOWN) |
|
||||
| Glue | `backend/__init__.py` | Merges the two `backend` packages for local imports |
|
||||
|
||||
**Do not empty-delete `periscope/dependency/`.** Later phases replace PinScope modules incrementally in `periscope/src` (Fase C). `validate.py` stays until a native reviewer exists.
|
||||
**Do not empty-delete `periscope/dependency/`.** Native modules land in `periscope/src/` over time; overlays stay until each piece has a product replacement.
|
||||
|
||||
Docker overlays `dependency` then `src` into `/app`. Local frontend: `cd periscope/dependency/frontend && npm run dev` (native files are symlinked from `periscope/src/frontend`).
|
||||
Docker overlays `dependency` then `src` into `/app`. Local frontend materialization: `./scripts/materialize-frontend.sh` (see root `README.md`).
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coffee,
|
||||
Coins,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
@@ -248,23 +247,23 @@ export default function ProgressPage({
|
||||
)}
|
||||
|
||||
{isRunning && !isQueued && (
|
||||
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
|
||||
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
|
||||
<div className="relative shrink-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
|
||||
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
|
||||
/>
|
||||
<Coffee
|
||||
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
|
||||
<Loader2
|
||||
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
Feel free to grab a coffee
|
||||
Pipeline running…
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
You can close this tab — we'll email you when it's ready.
|
||||
Stages update below as the run proceeds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.periscopex.af_rlgc import cascade_sparam
|
||||
@@ -24,8 +25,9 @@ from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
|
||||
from backend.periscopex.impedance import GeometryError
|
||||
from backend.periscopex.impedance_traces import analyze_specified_nets
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.si_check import partner_net
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
|
||||
from backend.periscopex.protocol_catalog import load_catalog
|
||||
from backend.periscopex.si_check import bus_class, partner_net, phy_mac_kind, single_ended_eth_mac
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SOURCE = "af_trace_check"
|
||||
@@ -35,6 +37,288 @@ _WIDTH_STEP_MM = 0.05
|
||||
_PARALLEL_MIN_MM = 5.0
|
||||
|
||||
|
||||
_MDI_POL = re.compile(
|
||||
r"^(?P<stem>.*(?:TX|RX|TD|RD|TRD\d|TP))(?P<pol>[PN])$",
|
||||
re.I,
|
||||
)
|
||||
# RD≡RX, TD≡TX, +≡P, −≡N — same physical MDI pair under HubAudio-style dual names.
|
||||
_MDI_CHANNEL_RE = re.compile(
|
||||
r"(?P<ch>RX|RD|TX|TD|TRD\d|TP)(?P<pol>[+\-PN])$",
|
||||
re.I,
|
||||
)
|
||||
_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I)
|
||||
_FAST_RE = re.compile(r"LAN8720|10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I)
|
||||
|
||||
|
||||
def _mdi_mate(name: str) -> str | None:
|
||||
"""TXP/TXN and RXP/RXN, plus the existing _P/_N and +/- partners."""
|
||||
leaf = (name or "").rsplit("/", 1)[-1]
|
||||
m = _MDI_POL.match(leaf)
|
||||
if m:
|
||||
pol = m.group("pol").upper()
|
||||
stem = m.group("stem")
|
||||
mate_leaf = stem + ("N" if pol == "P" else "P")
|
||||
if "/" in (name or ""):
|
||||
return name.rsplit("/", 1)[0] + "/" + mate_leaf
|
||||
return mate_leaf
|
||||
return partner_net(name)
|
||||
|
||||
|
||||
def _mdi_channel_key(net: str) -> tuple[str, str] | None:
|
||||
"""Canonical (hierarchy, rx|tx|…) so RD+/RXP and TD−/TXN share one cert."""
|
||||
if not net:
|
||||
return None
|
||||
prefix, _, leaf = net.rpartition("/")
|
||||
m = _MDI_CHANNEL_RE.search(leaf)
|
||||
if not m:
|
||||
return None
|
||||
raw = m.group("ch").upper()
|
||||
if raw.startswith("TRD"):
|
||||
ch = f"trd{raw[3:]}"
|
||||
elif raw in ("RX", "RD"):
|
||||
ch = "rx"
|
||||
elif raw in ("TX", "TD"):
|
||||
ch = "tx"
|
||||
else:
|
||||
ch = raw.lower()
|
||||
return (prefix, ch)
|
||||
|
||||
|
||||
def _prefer_mdi_unit(a: AfUnit, b: AfUnit) -> AfUnit:
|
||||
"""Prefer PHY-side RXP/TXP names over magnetics RD+/TD+ when both exist."""
|
||||
def score(u: AfUnit) -> tuple[int, int]:
|
||||
blob = " ".join(u.nets).upper()
|
||||
phy = 1 if re.search(r"(?:RX|TX)[PN]\b", blob) else 0
|
||||
return (phy, len(u.nets))
|
||||
return a if score(a) >= score(b) else b
|
||||
|
||||
|
||||
def _is_eth_mdi_net(net: str) -> bool:
|
||||
if single_ended_eth_mac(net):
|
||||
return False
|
||||
return bus_class(net) == "eth_mdi"
|
||||
|
||||
|
||||
def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]:
|
||||
"""Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN and RD≡RX aliases."""
|
||||
by_net = {n: u for u in units for n in u.nets}
|
||||
out: list[AfUnit] = []
|
||||
seen: set[str] = set()
|
||||
for unit in units:
|
||||
if unit.nets[0] in seen:
|
||||
continue
|
||||
if len(unit.nets) == 1 and _is_eth_mdi_net(unit.nets[0]):
|
||||
mate = _mdi_mate(unit.nets[0])
|
||||
other = by_net.get(mate or "")
|
||||
if mate and other is not None and mate not in seen:
|
||||
nets = tuple(sorted((unit.nets[0], mate)))
|
||||
seen.update(nets)
|
||||
length = max(unit.length_mm, other.length_mm)
|
||||
out.append(AfUnit(nets=nets, length_mm=length, bus="eth_mdi"))
|
||||
continue
|
||||
if all(_is_eth_mdi_net(n) for n in unit.nets):
|
||||
seen.update(unit.nets)
|
||||
out.append(AfUnit(
|
||||
nets=tuple(sorted(unit.nets)),
|
||||
length_mm=unit.length_mm,
|
||||
bus="eth_mdi",
|
||||
))
|
||||
continue
|
||||
seen.update(unit.nets)
|
||||
out.append(unit)
|
||||
return _dedupe_mdi_aliases(out)
|
||||
|
||||
|
||||
def _dedupe_mdi_aliases(units: list[AfUnit]) -> list[AfUnit]:
|
||||
"""One unit per (sheet, rx|tx): RD+/RD- and RXP/RXN are the same physical pair."""
|
||||
best: dict[tuple[str, str], AfUnit] = {}
|
||||
passthrough: list[AfUnit] = []
|
||||
for unit in units:
|
||||
if unit.bus != "eth_mdi" and not all(_is_eth_mdi_net(n) for n in unit.nets):
|
||||
passthrough.append(unit)
|
||||
continue
|
||||
keys = {_mdi_channel_key(n) for n in unit.nets}
|
||||
keys.discard(None)
|
||||
if len(keys) != 1:
|
||||
# Mixed or unknown — keep as-is (still eth_mdi tagged).
|
||||
tagged = unit if unit.bus == "eth_mdi" else AfUnit(
|
||||
nets=unit.nets, length_mm=unit.length_mm, bus="eth_mdi",
|
||||
)
|
||||
passthrough.append(tagged)
|
||||
continue
|
||||
key = next(iter(keys))
|
||||
assert key is not None
|
||||
prev = best.get(key)
|
||||
if prev is None:
|
||||
best[key] = AfUnit(
|
||||
nets=tuple(sorted(unit.nets)),
|
||||
length_mm=unit.length_mm,
|
||||
bus="eth_mdi",
|
||||
)
|
||||
else:
|
||||
best[key] = _prefer_mdi_unit(prev, AfUnit(
|
||||
nets=tuple(sorted(unit.nets)),
|
||||
length_mm=max(prev.length_mm, unit.length_mm),
|
||||
bus="eth_mdi",
|
||||
))
|
||||
return passthrough + list(best.values())
|
||||
|
||||
|
||||
def _component_speed_bits(graph: DesignGraph, refs: list[str] | None = None) -> str:
|
||||
bits: list[str] = []
|
||||
comps = (
|
||||
{r: graph.components[r] for r in refs if r in graph.components}
|
||||
if refs is not None
|
||||
else graph.components
|
||||
)
|
||||
for ref, comp in comps.items():
|
||||
if comp is None:
|
||||
continue
|
||||
bits.extend([
|
||||
comp.mpn or "", comp.value or "", comp.footprint or "",
|
||||
comp.component_subtype or "",
|
||||
])
|
||||
row = (graph.bom_fields or {}).get(ref) or {}
|
||||
for key in ("description", "Description", "value", "Value"):
|
||||
if row.get(key):
|
||||
bits.append(str(row.get(key)))
|
||||
return " ".join(bits)
|
||||
|
||||
|
||||
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
|
||||
"""10/100 or gbe from the PHY/jack on these nets, else any board PHY (LAN8720A)."""
|
||||
local_refs = [ref for net in nets for ref in refs_on_matched_net(graph, net)]
|
||||
text = _component_speed_bits(graph, local_refs)
|
||||
if _GBE_RE.search(text):
|
||||
return "gbe"
|
||||
if _FAST_RE.search(text):
|
||||
return "10/100"
|
||||
# Magnetics-side RD+/TD+ often lack the PHY MPN — inherit board speed.
|
||||
board = _component_speed_bits(graph, None)
|
||||
if _GBE_RE.search(board):
|
||||
return "gbe"
|
||||
if _FAST_RE.search(board):
|
||||
return "10/100"
|
||||
return None
|
||||
|
||||
|
||||
def _eth_cite(speed: str) -> tuple[float, str] | None:
|
||||
"""Packed cable_channel ohm for the identified speed. Nothing invented."""
|
||||
want = "1000base-t-mdi" if speed == "gbe" else "100base-tx-mdi"
|
||||
cat = load_catalog()
|
||||
for iface in cat.physical_interfaces:
|
||||
if iface.id != want:
|
||||
continue
|
||||
for c in iface.constraints:
|
||||
if c.parameter != "cable_channel" or c.unit != "ohm" or c.value is None:
|
||||
continue
|
||||
src = c.source
|
||||
doc = (src.document if src and src.document else "IEEE Std 802.3")
|
||||
section = (src.section if src and src.section else "")
|
||||
if speed == "gbe":
|
||||
label = f"{doc} Clause 40 (§{section})" if section else f"{doc} Clause 40"
|
||||
else:
|
||||
label = (
|
||||
f"{doc} 100BASE-TX 25.4.9 (§{section})"
|
||||
if section else f"{doc} 100BASE-TX 25.4.9"
|
||||
)
|
||||
return float(c.value), label
|
||||
return None
|
||||
|
||||
|
||||
def _pair_z_ohm(layout: LayoutGraph, unit: AfUnit) -> float | None:
|
||||
"""One Z for the pair. None when stackup or geometry cannot produce it."""
|
||||
if layout.stackup is None or len(unit.nets) < 2:
|
||||
return None
|
||||
samples = _samples(layout, unit)
|
||||
vals = _z_vals(samples, True)
|
||||
if not vals:
|
||||
return None
|
||||
return sum(vals) / len(vals)
|
||||
|
||||
|
||||
def _ethernet_mdi_finding(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph,
|
||||
unit: AfUnit,
|
||||
) -> Finding:
|
||||
"""Calculated pair Z vs the cited clause for the speed on the board.
|
||||
|
||||
Inside → PASS. Outside → FAIL. Cannot calculate → ERROR.
|
||||
"""
|
||||
speed = _eth_speed(graph, unit.nets)
|
||||
cite = _eth_cite(speed) if speed else None
|
||||
measured = _pair_z_ohm(layout, unit)
|
||||
label = _label(unit)
|
||||
if cite is None:
|
||||
text = (
|
||||
f"Ethernet not certified — because the speed on {label} is not "
|
||||
"identified on the PHY/jack, so no cited clause was applied."
|
||||
)
|
||||
req = "Pair Z is certified only against the cited clause for the identified speed."
|
||||
rec = "Identify the PHY/jack speed. No ohm value is assumed."
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed=unidentified; measured=none.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
)
|
||||
limit, cite_label = cite
|
||||
req = f"Cited limit {limit:g} Ω, {cite_label}."
|
||||
if measured is None:
|
||||
missing: list[str] = []
|
||||
if len(unit.nets) < 2:
|
||||
missing.append("pair")
|
||||
if layout.stackup is None:
|
||||
missing.append("stackup")
|
||||
else:
|
||||
missing.append("geometry")
|
||||
miss = "/".join(missing)
|
||||
text = (
|
||||
"Ethernet not certified — because Z cannot be calculated from the "
|
||||
f"PCB stackup/geometry ({miss} missing). Cited {limit:g} Ω ({cite_label})."
|
||||
)
|
||||
rec = (
|
||||
f"Pair Z was not calculated ({miss} missing). "
|
||||
f"Cited {limit:g} Ω ({cite_label})."
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; missing={miss}; measured=none.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"no computed Z; cited {limit:g} Ω.",
|
||||
)
|
||||
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
|
||||
if inside:
|
||||
text = (
|
||||
f"Ethernet certified — calculated Z {measured:g} Ω vs cited "
|
||||
f"{limit:g} Ω ({cite_label})."
|
||||
)
|
||||
rec = f"Calculated pair Z matches cited {limit:g} Ω ({cite_label})."
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
||||
requirement=req, rec=rec, status="INFO", cls="INFO",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
||||
)
|
||||
text = (
|
||||
f"Ethernet not certified — because Z {measured:g} Ω is outside cited "
|
||||
f"{limit:g} Ω ({cite_label})."
|
||||
)
|
||||
rec = (
|
||||
f"Calculated pair Z {measured:g} Ω is outside cited {limit:g} Ω ({cite_label})."
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-002", unit=unit, finding=text,
|
||||
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
|
||||
requirement=req, rec=rec, status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
|
||||
)
|
||||
|
||||
|
||||
def check_af_traces(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
@@ -50,7 +334,27 @@ def check_af_traces(
|
||||
if f.rule_id == "PE-SI-002" and f.net
|
||||
}
|
||||
zrows = _z_rows(impedance_nets)
|
||||
for unit in iter_af_units(layout, graph):
|
||||
mac_phy_nets: list[str] = []
|
||||
sgmii_units: list[AfUnit] = []
|
||||
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
|
||||
# USB 2.0 D+/D−: protocol_l2 owns the 90 Ω cert line. Never PE-AF-002 tr/f.
|
||||
if unit.bus == "usb2" or any(bus_class(n) == "usb2" for n in unit.nets):
|
||||
continue
|
||||
if any(single_ended_eth_mac(n) or phy_mac_kind(n) in {"rmii", "mii", "rgmii"} for n in unit.nets):
|
||||
if not any(_is_eth_mdi_net(n) for n in unit.nets):
|
||||
mac_phy_nets.extend(unit.nets)
|
||||
continue
|
||||
if unit.bus == "sgmii" or any(
|
||||
(bus_class(n) == "sgmii") or ("SGMII" in (n or "").upper()) for n in unit.nets
|
||||
):
|
||||
sgmii_units.append(unit)
|
||||
continue
|
||||
if unit.bus == "eth_mdi":
|
||||
if any(single_ended_eth_mac(n) for n in unit.nets):
|
||||
mac_phy_nets.extend(unit.nets)
|
||||
continue
|
||||
out.append(_ethernet_mdi_finding(graph, layout, unit))
|
||||
continue
|
||||
trig = evaluate_trigger(graph, constraints_map, layout, unit)
|
||||
if trig.missing and not trig.af:
|
||||
out.append(_skip(unit, trig.missing))
|
||||
@@ -58,10 +362,266 @@ def check_af_traces(
|
||||
if not trig.af:
|
||||
continue
|
||||
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
|
||||
if mac_phy_nets:
|
||||
out.append(_mac_phy_cert_finding(graph, mac_phy_nets, layout))
|
||||
for unit in sgmii_units:
|
||||
out.append(_sgmii_cert_finding(graph, layout, unit))
|
||||
complete_findings(out)
|
||||
return out
|
||||
|
||||
|
||||
def _mac_phy_iface_label(nets: list[str]) -> str:
|
||||
kinds = {phy_mac_kind(n) or "rmii" for n in nets}
|
||||
if kinds == {"mii"}:
|
||||
return "MII"
|
||||
if kinds == {"rgmii"} or ("rgmii" in kinds and "rmii" not in kinds and "mii" not in kinds):
|
||||
return "RGMII"
|
||||
return "RMII"
|
||||
|
||||
|
||||
def _mac_phy_catalog_id(iface: str) -> str:
|
||||
return {
|
||||
"MII": "mii-pcb",
|
||||
"RMII": "rmii-pcb",
|
||||
"RGMII": "rgmii-pcb",
|
||||
"SGMII": "sgmii-pcb",
|
||||
}[iface]
|
||||
|
||||
|
||||
def _cite_label(c) -> str:
|
||||
src = c.source
|
||||
if src is None:
|
||||
return c.id
|
||||
bits = [src.document or "", src.revision or ""]
|
||||
if src.table:
|
||||
bits.append(src.table)
|
||||
elif src.section:
|
||||
bits.append(f"§{src.section}")
|
||||
if src.page:
|
||||
bits.append(f"p.{src.page}")
|
||||
return " ".join(b for b in bits if b)
|
||||
|
||||
|
||||
def _mac_phy_cert_finding(
|
||||
graph: DesignGraph,
|
||||
nets: list[str],
|
||||
layout: LayoutGraph | None,
|
||||
) -> Finding:
|
||||
"""Certified only when a cited calculable check passes — never on recognition alone."""
|
||||
uniq = sorted({n for n in nets if n})
|
||||
label = " / ".join(uniq[:6]) + (" / …" if len(uniq) > 6 else "")
|
||||
iface = _mac_phy_iface_label(uniq)
|
||||
catalog_id = _mac_phy_catalog_id(iface)
|
||||
phy_refs = sorted({
|
||||
ref
|
||||
for net in uniq
|
||||
for ref in refs_on_matched_net(graph, net)
|
||||
if (graph.components.get(ref) or None) is not None
|
||||
and "LAN8720" in (
|
||||
(graph.components[ref].mpn or "") + (graph.components[ref].value or "")
|
||||
).upper()
|
||||
})
|
||||
host = phy_refs[0] if phy_refs else "layout"
|
||||
identified = (
|
||||
f"Identified as single-ended {iface} PHY–MAC (not an RJ45/MDI 100 Ω "
|
||||
"differential pair)."
|
||||
)
|
||||
|
||||
cat = load_catalog()
|
||||
phys = next((p for p in cat.physical_interfaces if p.id == catalog_id), None)
|
||||
z_row = None
|
||||
if phys is not None:
|
||||
for c in phys.constraints:
|
||||
if c.parameter == "impedance":
|
||||
z_row = c
|
||||
break
|
||||
|
||||
# Calculable Z check — same rule as Ethernet: need cited NUMERIC + stackup/geometry.
|
||||
if z_row is not None and z_row.value_kind == "NUMERIC" and z_row.value is not None:
|
||||
cite = f"{z_row.value:g} Ω ({_cite_label(z_row)})"
|
||||
measured = _se_z_ohm(layout, uniq) if layout is not None else None
|
||||
if measured is None:
|
||||
miss = "stackup/geometry" if layout is None or layout.stackup is None else "geometry"
|
||||
text = (
|
||||
f"{iface} not certified — because Z cannot be calculated from the "
|
||||
f"PCB {miss}. Cited {cite}. {identified}"
|
||||
)
|
||||
status, cls, prov = "ERROR", "RULE", "MANDATORY"
|
||||
elif abs(measured - float(z_row.value)) <= 1e-6 * max(1.0, abs(float(z_row.value))):
|
||||
text = (
|
||||
f"{iface} certified — calculated Z {measured:g} Ω vs cited {cite}. "
|
||||
f"{identified}"
|
||||
)
|
||||
status, cls, prov = "INFO", "INFO", "MANDATORY"
|
||||
else:
|
||||
text = (
|
||||
f"{iface} not certified — because Z {measured:g} Ω is outside cited "
|
||||
f"{cite}. {identified}"
|
||||
)
|
||||
status, cls, prov = "ERROR", "RULE", "MANDATORY"
|
||||
rec = (
|
||||
f"Provide stackup/geometry and match cited {cite}."
|
||||
if status != "INFO"
|
||||
else f"Calculated single-ended Z matches cited {cite}."
|
||||
)
|
||||
else:
|
||||
needed = (z_row.needed_document if z_row else None) or (
|
||||
f"{catalog_id} impedance not packed as NUMERIC"
|
||||
)
|
||||
# Point at the primary pack source without inventing a Z0.
|
||||
src_note = ""
|
||||
if phys is not None:
|
||||
for c in phys.constraints:
|
||||
if c.value_kind == "NUMERIC" and c.source and c.source.document:
|
||||
src_note = f" Packed cite on file: {_cite_label(c)}."
|
||||
break
|
||||
text = (
|
||||
f"{iface} not certified — because PCB impedance is "
|
||||
f"{(z_row.value_kind if z_row else 'MISSING_SOURCE')} "
|
||||
f"({needed}).{src_note} {identified}"
|
||||
)
|
||||
status, cls, prov = "ERROR", "RULE", "MANDATORY"
|
||||
rec = (
|
||||
f"Do not treat recognition as certification. Pack a cited PCB Z0 "
|
||||
f"or provide the needed document. Keep {iface} off the RJ45/MDI 100 Ω clause."
|
||||
)
|
||||
|
||||
return Finding(
|
||||
designator=host,
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=text,
|
||||
facts=(
|
||||
f"nets={label}; interface={iface}; catalog={catalog_id}; "
|
||||
f"phy={','.join(phy_refs) or '—'}; "
|
||||
f"impedance_kind={(z_row.value_kind if z_row else 'missing')}."
|
||||
),
|
||||
requirement=(
|
||||
f"{iface} is certified only against a cited calculable check "
|
||||
f"(e.g. PCB Z). Recognition alone is not certification. "
|
||||
"Not an RJ45/MDI 100 Ω pair."
|
||||
),
|
||||
inference=(
|
||||
"Interface class from net names / PHY. Certification requires a "
|
||||
"packed NUMERIC cite and a measurable PCB quantity."
|
||||
),
|
||||
why=text,
|
||||
status=status,
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source=SOURCE,
|
||||
rule_id="PE-AF-003",
|
||||
finding_class=cls,
|
||||
provenance=prov,
|
||||
evidence_status="SUFFICIENT",
|
||||
net=uniq[0] if uniq else None,
|
||||
pins=[],
|
||||
)
|
||||
|
||||
|
||||
def _se_z_ohm(layout: LayoutGraph, nets: list[str]) -> float | None:
|
||||
"""Single-ended microstrip Z0 from layout samples. None if stackup/geometry missing."""
|
||||
if layout.stackup is None or not nets:
|
||||
return None
|
||||
unit = AfUnit(nets=tuple(nets[:1]), length_mm=0.0, bus="rmii")
|
||||
samples = _samples(layout, unit)
|
||||
vals = _z_vals(samples, False)
|
||||
if not vals:
|
||||
return None
|
||||
return sum(vals) / len(vals)
|
||||
|
||||
|
||||
def _sgmii_cert_finding(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph,
|
||||
unit: AfUnit,
|
||||
) -> Finding:
|
||||
"""SGMII pair Z vs Cisco ENG-46158 Rload 100 Ω / Rin 80–120 Ω. Recognition ≠ cert."""
|
||||
label = _label(unit)
|
||||
cat = load_catalog()
|
||||
phys = next((p for p in cat.physical_interfaces if p.id == "sgmii-pcb"), None)
|
||||
z_row = None
|
||||
rin_lo = rin_hi = None
|
||||
if phys is not None:
|
||||
for c in phys.constraints:
|
||||
if c.parameter == "differential_impedance" and c.value_kind == "NUMERIC":
|
||||
z_row = c
|
||||
if c.parameter == "receiver_differential_impedance_min" and c.value is not None:
|
||||
rin_lo = float(c.value)
|
||||
if c.parameter == "receiver_differential_impedance_max" and c.value is not None:
|
||||
rin_hi = float(c.value)
|
||||
identified = "Identified as SGMII MAC–PHY (not RJ45/MDI cabling)."
|
||||
if z_row is None or z_row.value is None:
|
||||
text = (
|
||||
f"SGMII not certified — because no packed differential_impedance cite "
|
||||
f"is available. {identified}"
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-003", unit=unit, finding=text,
|
||||
facts=f"nets={label}; catalog=sgmii-pcb; measured=none.",
|
||||
requirement="SGMII certification needs a cited Z and a calculated pair Z.",
|
||||
rec="Pack Cisco SGMII Rload/Rin cites before certifying.",
|
||||
status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
)
|
||||
cite = f"{z_row.value:g} Ω ({_cite_label(z_row)})"
|
||||
measured = _pair_z_ohm(layout, unit)
|
||||
if measured is None:
|
||||
missing: list[str] = []
|
||||
if len(unit.nets) < 2:
|
||||
missing.append("pair")
|
||||
if layout.stackup is None:
|
||||
missing.append("stackup")
|
||||
else:
|
||||
missing.append("geometry")
|
||||
miss = "/".join(missing)
|
||||
text = (
|
||||
"SGMII not certified — because Z cannot be calculated from the "
|
||||
f"PCB stackup/geometry ({miss} missing). Cited {cite}. {identified}"
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-003", unit=unit, finding=text,
|
||||
facts=f"nets={label}; missing={miss}; measured=none; cite={cite}.",
|
||||
requirement=f"Cited limit {cite}.",
|
||||
rec=f"Pair Z was not calculated ({miss} missing). Cited {cite}.",
|
||||
status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"no computed Z; cited {z_row.value:g} Ω.",
|
||||
)
|
||||
rload = float(z_row.value)
|
||||
match_rload = abs(measured - rload) <= 1e-6 * max(1.0, abs(rload))
|
||||
match_rin = (
|
||||
rin_lo is not None and rin_hi is not None and rin_lo <= measured <= rin_hi
|
||||
)
|
||||
if match_rload or match_rin:
|
||||
text = (
|
||||
f"SGMII certified — calculated Z {measured:g} Ω vs cited {cite}. "
|
||||
f"{identified}"
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-003", unit=unit, finding=text,
|
||||
facts=f"nets={label}; Z={measured:g} Ω; cite={cite}.",
|
||||
requirement=f"Cited limit {cite}.",
|
||||
rec=f"Calculated pair Z matches cited {cite}.",
|
||||
status="INFO", cls="INFO",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {rload:g} Ω.",
|
||||
)
|
||||
text = (
|
||||
f"SGMII not certified — because Z {measured:g} Ω is outside cited "
|
||||
f"{cite}. {identified}"
|
||||
)
|
||||
return _finding(
|
||||
rule_id="PE-AF-003", unit=unit, finding=text,
|
||||
facts=f"nets={label}; Z={measured:g} Ω; cite={cite}.",
|
||||
requirement=f"Cited limit {cite}.",
|
||||
rec=f"Calculated pair Z {measured:g} Ω is outside cited {cite}.",
|
||||
status="ERROR", cls="RULE",
|
||||
evidence="SUFFICIENT", provenance="MANDATORY",
|
||||
calculation=f"pair Z {measured:g} Ω compared to {rload:g} Ω.",
|
||||
)
|
||||
|
||||
|
||||
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
|
||||
raw = impedance_nets
|
||||
if isinstance(impedance_nets, dict):
|
||||
@@ -75,12 +635,15 @@ def _label(unit: AfUnit) -> str:
|
||||
|
||||
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
|
||||
miss = ", ".join(missing)
|
||||
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
|
||||
text = (
|
||||
f"HF not certified — because {miss} is missing on {_label(unit)}. "
|
||||
"No invented ohm."
|
||||
)
|
||||
if extra:
|
||||
text = f"{text} {extra}".strip()
|
||||
rec = (
|
||||
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
|
||||
"Non si assume 50 Ω o 90 Ω."
|
||||
f"Provide {miss} from the datasheet or KiCad stackup. "
|
||||
"50 Ω or 90 Ω is not assumed."
|
||||
)
|
||||
return Finding(
|
||||
designator="layout",
|
||||
@@ -116,7 +679,9 @@ def _finding(
|
||||
cls: str = "RISK",
|
||||
evidence: str = "SUFFICIENT",
|
||||
calculation: str = "",
|
||||
provenance: str = "",
|
||||
) -> Finding:
|
||||
prov = provenance or ("RECOMMENDED" if cls != "REVIEW" else "TYPICAL")
|
||||
return Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
@@ -132,7 +697,7 @@ def _finding(
|
||||
source=SOURCE,
|
||||
rule_id=rule_id,
|
||||
finding_class=cls, # type: ignore[arg-type]
|
||||
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
|
||||
provenance=prov,
|
||||
evidence_status=evidence, # type: ignore[arg-type]
|
||||
net=unit.nets[0],
|
||||
pins=[],
|
||||
|
||||
@@ -52,7 +52,8 @@ _MODULE_FP = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_EP_PAD = re.compile(
|
||||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
|
||||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|"
|
||||
r"THERMAL[\s_]?PAD|EXPOSED[\s_]?PAD)(?:[_-]?\d+)?$",
|
||||
re.I,
|
||||
)
|
||||
_EP_SPLIT = re.compile(r"^\d+_\d+$")
|
||||
@@ -112,6 +113,45 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
|
||||
return False
|
||||
|
||||
|
||||
def _is_exposed_alias(name: str) -> bool:
|
||||
"""EP, EPAD, THERMAL PAD, exposed pad. Not a signal pin number."""
|
||||
s = str(name or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
if _EP_PAD.match(s):
|
||||
return True
|
||||
compact = re.sub(r"[\s_\-]+", "", s).lower()
|
||||
return compact in {"thermalpad", "exposedpad", "epad", "thermal"}
|
||||
|
||||
|
||||
def _ids_have_exposed(
|
||||
ids: list[tuple[str, str]] | None,
|
||||
pin_count: int | None,
|
||||
) -> bool:
|
||||
if not ids:
|
||||
return False
|
||||
return any(_is_ep_land(n, pf, pin_count) for n, pf in ids)
|
||||
|
||||
|
||||
def _drop_exposed_name_mismatch(
|
||||
miss: list[str],
|
||||
extra: list[str],
|
||||
*,
|
||||
left_has: bool,
|
||||
right_has: bool,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Same exposed pad under two names is not a missing/extra pin.
|
||||
|
||||
Datasheet EP with no exposed land on the other side stays a miss.
|
||||
"""
|
||||
if not (left_has and right_has):
|
||||
return miss, extra
|
||||
return (
|
||||
[m for m in miss if not _is_exposed_alias(m)],
|
||||
[e for e in extra if not _is_exposed_alias(e)],
|
||||
)
|
||||
|
||||
|
||||
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
|
||||
s = str(number).strip()
|
||||
pf = str(pinfunction or "").strip()
|
||||
@@ -273,6 +313,16 @@ def resolve_kicad_mod(
|
||||
return None
|
||||
|
||||
|
||||
def _lib_pad_ids(
|
||||
footprint: str,
|
||||
search_dirs: list[Path] | None,
|
||||
) -> list[tuple[str, str]] | None:
|
||||
path = resolve_kicad_mod(footprint, search_dirs)
|
||||
if path is None:
|
||||
return None
|
||||
return parse_kicad_mod_pads(path)
|
||||
|
||||
|
||||
def _lib_join_keys(
|
||||
footprint: str,
|
||||
*,
|
||||
@@ -489,6 +539,10 @@ def _pinout_findings(
|
||||
declared = _declared_io_pins(pkg, cad)
|
||||
ep_count = declared or pin_count
|
||||
pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds)
|
||||
pcb_has_ep = _ids_have_exposed(pcb_ids, ep_count)
|
||||
ds_has_ep = any(_is_exposed_alias(k) for k in ds)
|
||||
lib_raw = _lib_pad_ids(fp_name, footprint_dirs)
|
||||
lib_has_ep = _ids_have_exposed(lib_raw, ep_count)
|
||||
lib, lib_path = _lib_join_keys(
|
||||
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
|
||||
)
|
||||
@@ -522,8 +576,10 @@ def _pinout_findings(
|
||||
)
|
||||
|
||||
if lib is not None:
|
||||
miss_lib = sorted(ds - lib)
|
||||
extra_lib = sorted(lib - ds)
|
||||
miss_lib, extra_lib = _drop_exposed_name_mismatch(
|
||||
sorted(ds - lib), sorted(lib - ds),
|
||||
left_has=ds_has_ep, right_has=lib_has_ep,
|
||||
)
|
||||
if miss_lib or extra_lib:
|
||||
rec = (
|
||||
f"Change {ref}'s KiCad footprint library so pad names match "
|
||||
@@ -547,8 +603,11 @@ def _pinout_findings(
|
||||
)
|
||||
if fnd:
|
||||
out.append(fnd)
|
||||
miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
|
||||
extra_emb = sorted(pcb - lib) if pcb else []
|
||||
miss_emb, extra_emb = _drop_exposed_name_mismatch(
|
||||
sorted(lib - pcb) if pcb else sorted(lib),
|
||||
sorted(pcb - lib) if pcb else [],
|
||||
left_has=lib_has_ep, right_has=pcb_has_ep,
|
||||
)
|
||||
if pcb and (miss_emb or extra_emb):
|
||||
rec = (
|
||||
f"Replace {ref}'s embedded PCB footprint so it matches the "
|
||||
@@ -571,8 +630,10 @@ def _pinout_findings(
|
||||
out.append(fnd)
|
||||
return out
|
||||
if pcb:
|
||||
miss = sorted(ds - pcb)
|
||||
extra = sorted(pcb - ds)
|
||||
miss, extra = _drop_exposed_name_mismatch(
|
||||
sorted(ds - pcb), sorted(pcb - ds),
|
||||
left_has=ds_has_ep, right_has=pcb_has_ep,
|
||||
)
|
||||
if miss or extra:
|
||||
rec = (
|
||||
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
|
||||
|
||||
@@ -27,7 +27,14 @@ _NC_NET_RE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_ONBOARD_POWER_RE = re.compile(
|
||||
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
|
||||
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|VCC\d*|VDD\d*|3V3|\+3V3|3\.3V)"
|
||||
r"(?:[_./-].*)?$",
|
||||
re.I,
|
||||
)
|
||||
_VBUS_LEAF_RE = re.compile(r"(?:^|[_./-])VBUS(?:$|[_./-])", re.I)
|
||||
_SPDIF_RE = re.compile(r"SP(?:DIF|IF)", re.I)
|
||||
_OPTICAL_RE = re.compile(
|
||||
r"TOSLINK|OPTOCOUPL|OPTICAL|OPTODEVICE|\bAFBR\b|PHOTO[-_ ]?COUPL",
|
||||
re.I,
|
||||
)
|
||||
|
||||
@@ -57,6 +64,50 @@ def _skip_esd_net(net: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _optical_evidence(graph: DesignGraph, comp) -> bool:
|
||||
"""Schematic/BOM/footprint shows TOSLINK, optocoupler, or optical."""
|
||||
parts = [
|
||||
comp.value or "",
|
||||
comp.footprint or "",
|
||||
comp.mpn or "",
|
||||
comp.component_subtype or "",
|
||||
]
|
||||
row = (graph.bom_fields or {}).get(comp.reference) or {}
|
||||
for key in ("description", "Description", "value", "Value", "footprint", "Footprint"):
|
||||
if row.get(key):
|
||||
parts.append(str(row.get(key)))
|
||||
return bool(_OPTICAL_RE.search(" ".join(parts)))
|
||||
|
||||
|
||||
def _esd_cite(cons, *, need_vbus: bool) -> str | None:
|
||||
"""Part + section/page quote. None when the datasheet is not cited."""
|
||||
if cons is None:
|
||||
return None
|
||||
for rule in cons.layout_rules or []:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
blob = " ".join(
|
||||
str(rule.get(k) or "")
|
||||
for k in ("kind", "parameter", "note", "net", "pin", "requirement")
|
||||
)
|
||||
if "esd" not in blob.lower():
|
||||
continue
|
||||
if need_vbus and not re.search(r"vbus", blob, re.I):
|
||||
continue
|
||||
doc = str(
|
||||
rule.get("document") or rule.get("part") or rule.get("source") or ""
|
||||
).strip()
|
||||
section = str(rule.get("section") or "").strip()
|
||||
page = str(rule.get("page") or "").strip()
|
||||
if not doc or not (section or page):
|
||||
continue
|
||||
loc = section
|
||||
if page:
|
||||
loc = f"{loc} p.{page}".strip()
|
||||
return f"{doc} {loc}".strip()
|
||||
return None
|
||||
|
||||
|
||||
def _is_j_connector(comp) -> bool:
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
return False
|
||||
@@ -68,7 +119,8 @@ def check_esd(
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
|
||||
_ = constraints_map
|
||||
from backend.periscopex.constraints_lookup import match_constraints
|
||||
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
@@ -106,28 +158,64 @@ def check_esd(
|
||||
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
|
||||
"",
|
||||
)
|
||||
leaf = _net_leaf(net)
|
||||
is_vbus = bool(_VBUS_LEAF_RE.search(leaf))
|
||||
is_rail = bool(_ONBOARD_POWER_RE.match(leaf))
|
||||
is_spdif = bool(_SPDIF_RE.search(leaf))
|
||||
optical = _optical_evidence(graph, j_comp)
|
||||
cons = match_constraints(ic.mpn or ic.value, constraints_map or {})
|
||||
quote = _esd_cite(cons, need_vbus=is_vbus)
|
||||
if is_spdif and optical:
|
||||
continue
|
||||
if is_vbus and not quote:
|
||||
continue
|
||||
if is_rail and not quote:
|
||||
continue
|
||||
if is_spdif and not optical:
|
||||
finding = (
|
||||
f"{net} ({j_ref}–{ic_ref}) was not verified as optical "
|
||||
"S/PDIF."
|
||||
)
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device on {net} between "
|
||||
f"{j_ref} and {ic_ref}."
|
||||
f"Confirm whether {net} is photo-coupled "
|
||||
"(TOSLINK/optocoupler). The path was not verified as optical."
|
||||
)
|
||||
requirement = (
|
||||
"Optical S/PDIF is not an external copper signal. "
|
||||
"Without TOSLINK/optocoupler evidence the path stays visible."
|
||||
)
|
||||
else:
|
||||
finding = (
|
||||
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||
"protection part on the net."
|
||||
)
|
||||
if quote:
|
||||
rec = (
|
||||
f"Add a datasheet-specified ESD device ({quote}) on "
|
||||
f"{net} between {j_ref} and {ic_ref}."
|
||||
)
|
||||
requirement = f"Cited ESD requirement: {quote}."
|
||||
else:
|
||||
rec = (
|
||||
f"Review board ESD on {net} between {j_ref} and {ic_ref}."
|
||||
)
|
||||
requirement = (
|
||||
"Connector–IC nets are reviewed for a board ESD part. "
|
||||
"No datasheet section is cited for this net."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=ic.mpn or "",
|
||||
aspect="esd",
|
||||
finding=(
|
||||
f"{net} is a {j_ref}–{ic_ref} path with no ESD/"
|
||||
"protection part on the net."
|
||||
),
|
||||
finding=finding,
|
||||
facts=(
|
||||
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
|
||||
"ESD parts on net: 0."
|
||||
+ (f" cite={quote}." if quote else " cite=none.")
|
||||
),
|
||||
requirement=(
|
||||
"External ESD is recommended on connector–IC nets unless "
|
||||
"the extraction lists a mandatory clamp with a measured FACT."
|
||||
),
|
||||
requirement=requirement,
|
||||
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
|
||||
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
|
||||
why="Only J* ∩ IC nets; NC, unconnected, onboard rails excluded.",
|
||||
status="WARNING",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
|
||||
@@ -385,7 +385,7 @@ class ValidationReport(BaseModel):
|
||||
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
|
||||
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
|
||||
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
|
||||
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
|
||||
# M0 protocol cert: empty instances / "No protocol recognized.". No Z.
|
||||
protocol_certification: dict[str, Any] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -149,6 +149,52 @@ def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
|
||||
return out
|
||||
|
||||
|
||||
def _expand_not_reviewed(rows: list) -> list[dict]:
|
||||
"""One designator per entry. Comma-joined refs become separate rows."""
|
||||
out: list[dict] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
raw = str(row.get("designator") or "").strip()
|
||||
reason = str(row.get("reason") or "").strip() or "not reviewed"
|
||||
if not raw:
|
||||
continue
|
||||
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
|
||||
if not parts:
|
||||
parts = [raw]
|
||||
for ref in parts:
|
||||
out.append({"designator": ref, "reason": reason})
|
||||
return out
|
||||
|
||||
|
||||
def _merge_not_reviewed(
|
||||
schema_nr: list,
|
||||
pcb_nr: list,
|
||||
covered: set[str],
|
||||
review_errors: dict[str, str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Dedupe by designator. Drop ICs that were reviewed. Count == list length.
|
||||
|
||||
Legacy ``pcb_review error`` rows move into ``review_errors`` when possible
|
||||
and are not kept as not-reviewed.
|
||||
"""
|
||||
errors = review_errors if review_errors is not None else {}
|
||||
merged: dict[str, str] = {}
|
||||
for row in _expand_not_reviewed(list(schema_nr or []) + list(pcb_nr or [])):
|
||||
ref = row["designator"]
|
||||
if ref in covered:
|
||||
continue
|
||||
reason = row["reason"]
|
||||
if reason == "pcb_review error":
|
||||
errors.setdefault(ref, "pcb_review error")
|
||||
continue
|
||||
prev = merged.get(ref)
|
||||
if prev and prev != "pcb_review error" and reason == "pcb_review error":
|
||||
continue
|
||||
merged[ref] = reason
|
||||
return [{"designator": k, "reason": merged[k]} for k in sorted(merged)]
|
||||
|
||||
|
||||
def merge_schema_pcb_reports(
|
||||
schema: dict | None, pcb: dict | None,
|
||||
) -> dict | None:
|
||||
@@ -189,10 +235,17 @@ def merge_schema_pcb_reports(
|
||||
if st in summary:
|
||||
summary[st] = summary.get(st, 0) + 1
|
||||
out["summary"] = summary
|
||||
covered = set((schema or {}).get("coverage") or {}) | set((pcb or {}).get("coverage") or {})
|
||||
schema_err = dict((schema or {}).get("review_errors") or {})
|
||||
pcb_err = dict((pcb or {}).get("review_errors") or {})
|
||||
merged_err = {**schema_err, **pcb_err}
|
||||
schema_nr = list((schema or {}).get("not_reviewed") or [])
|
||||
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
|
||||
if schema_nr or pcb_nr:
|
||||
out["not_reviewed"] = schema_nr + pcb_nr
|
||||
out["not_reviewed"] = _merge_not_reviewed(schema_nr, pcb_nr, covered, merged_err)
|
||||
if merged_err:
|
||||
out["review_errors"] = merged_err
|
||||
elif "review_errors" in out and not out["review_errors"]:
|
||||
out.pop("review_errors", None)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ CATALOG_DIR = Path(__file__).resolve().parent / "protocol_data"
|
||||
CATALOG_PATH = CATALOG_DIR / "catalog.json"
|
||||
SCHEMA_PATH = CATALOG_DIR / f"schema-{SCHEMA_VERSION}.json"
|
||||
|
||||
EMPTY_PROTOCOL_MESSAGE = "nessun protocollo riconosciuto"
|
||||
EMPTY_PROTOCOL_MESSAGE = "No protocol recognized."
|
||||
|
||||
BusType = Literal[
|
||||
"MEMORY",
|
||||
@@ -306,6 +306,7 @@ def _cite(
|
||||
section: str | None = None,
|
||||
table: str | None = None,
|
||||
page: str | None = None,
|
||||
url: str | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
return {
|
||||
"document": document,
|
||||
@@ -314,6 +315,7 @@ def _cite(
|
||||
"section": section,
|
||||
"table": table,
|
||||
"page": page,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
@@ -372,6 +374,7 @@ def _num(
|
||||
section: str | None = None,
|
||||
table: str | None = None,
|
||||
page: str | None = None,
|
||||
url: str | None = None,
|
||||
mandatory: MandatoryClass = "MANDATORY",
|
||||
source_type: SourceType = "STANDARD",
|
||||
source_class: SourceClass = "NORMATIVE",
|
||||
@@ -397,6 +400,7 @@ def _num(
|
||||
section=section,
|
||||
table=table,
|
||||
page=page,
|
||||
url=url,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -642,6 +646,30 @@ DOC_SLLA633 = (
|
||||
"TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204"
|
||||
)
|
||||
REV_SLLA633 = "SLLA633 – MAY 2024"
|
||||
DOC_MICREL_AN133 = "Application Note 133 Interfacing Fast Ethernet Transceivers to MAC Processors"
|
||||
REV_MICREL_AN133 = "Rev. 1.0, January 2006"
|
||||
ORG_MICREL = "Micrel"
|
||||
PATH_MICREL_AN133 = "standards/protocol-specs/micrel_ap_note_133_interfacing_to_mac.pdf"
|
||||
DOC_OPEN_RGMII = (
|
||||
"OPEN Alliance RGMII EPL (Electrical-Physical Layer) Recommendations"
|
||||
)
|
||||
REV_OPEN_RGMII = "V2.2, October 25, 2016"
|
||||
ORG_OPEN = "OPEN Alliance"
|
||||
PATH_OPEN_RGMII = "standards/protocol-specs/OPEN_RGMII_EPL_V_2_public.pdf"
|
||||
DOC_SGMII_CISCO = "Serial-GMII Specification"
|
||||
REV_SGMII_CISCO = "Revision 1.7, July 19, 2001"
|
||||
ORG_CISCO = "Cisco Systems"
|
||||
PATH_SGMII = "standards/protocol-specs/SGMII.pdf"
|
||||
DOC_SGMII_NEEDED = (
|
||||
"Cisco Serial-GMII Specification ENG-46158 Rev 1.7 on file — field not stated as a "
|
||||
"PCB routing number in that document"
|
||||
)
|
||||
DOC_OPEN_RGMII_NEEDED = (
|
||||
"OPEN Alliance RGMII EPL V2.2 on file — field not stated (no PCB Z0 / mm length in EPL)"
|
||||
)
|
||||
DOC_MICREL_AN133_NEEDED = (
|
||||
"Micrel AN-133 Rev 1.0 on file — field not stated (no PCB Z0 / setup-hold / skew mm)"
|
||||
)
|
||||
DDR_CHECKS = [
|
||||
"byte_lane_mapping",
|
||||
"dq_to_dqs_skew",
|
||||
@@ -710,10 +738,65 @@ CAN_CHECKS = [
|
||||
RGMII_CHECKS = [
|
||||
"timing",
|
||||
"clock_data_relationship",
|
||||
"clock_duty_high_min",
|
||||
"clock_duty_low_min",
|
||||
"data_clock_skew_dod_source_max",
|
||||
"data_clock_skew_dod_dest_max",
|
||||
"data_clock_skew_dos_source_min",
|
||||
"data_clock_skew_dos_dest",
|
||||
"clock_data_delay_mismatch",
|
||||
"receiver_load_cap_max",
|
||||
"rise_time_max",
|
||||
"fall_time_max",
|
||||
"impedance",
|
||||
"length",
|
||||
"delay",
|
||||
"topology",
|
||||
]
|
||||
RMII_CHECKS = [
|
||||
"refclk_frequency",
|
||||
"mdc_max_frequency",
|
||||
"source_termination_min",
|
||||
"source_termination_max",
|
||||
"impedance",
|
||||
"length",
|
||||
"skew",
|
||||
"setup_time",
|
||||
"hold_time",
|
||||
"topology",
|
||||
]
|
||||
MII_CHECKS = [
|
||||
"tx_rx_clock_frequency",
|
||||
"mdc_max_frequency",
|
||||
"source_termination_min",
|
||||
"source_termination_max",
|
||||
"impedance",
|
||||
"length",
|
||||
"skew",
|
||||
"setup_time",
|
||||
"hold_time",
|
||||
"topology",
|
||||
]
|
||||
SGMII_CHECKS = [
|
||||
"data_rate",
|
||||
"clock_frequency",
|
||||
"differential_impedance",
|
||||
"impedance_tolerance",
|
||||
"receiver_differential_impedance_min",
|
||||
"receiver_differential_impedance_max",
|
||||
"intra_pair_skew",
|
||||
"clock_to_data_min",
|
||||
"clock_to_data_max",
|
||||
"setup_time",
|
||||
"hold_time",
|
||||
"rise_time_min",
|
||||
"rise_time_max",
|
||||
"fall_time_min",
|
||||
"fall_time_max",
|
||||
"maximum_channel_length",
|
||||
"topology",
|
||||
"termination",
|
||||
]
|
||||
HYPERBUS_CHECKS = [
|
||||
"dq_to_rwds_skew",
|
||||
"ck_to_dq_relationship",
|
||||
@@ -755,7 +838,14 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
L("2.5gbase-t", "2.5GBASE-T", "SERIAL", "ETHERNET")
|
||||
L("5gbase-t", "5GBASE-T", "SERIAL", "ETHERNET")
|
||||
L("10gbase-t", "10GBASE-T", "SERIAL", "ETHERNET")
|
||||
L("sgmii", "SGMII", "SERIAL", "ETHERNET")
|
||||
L("sgmii", "SGMII", "SERIAL", "ETHERNET",
|
||||
"Serial GMII MAC–PHY (Cisco ENG-46158). Not IEEE 802.3 MDI / RJ45.")
|
||||
L("rgmii", "RGMII MAC-PHY", "PERIPHERAL", "ETHERNET",
|
||||
"Reduced Gigabit MII MAC–PHY. OPEN Alliance EPL V2.2 on file.")
|
||||
L("rmii", "RMII MAC-PHY", "PERIPHERAL", "ETHERNET",
|
||||
"Reduced MII MAC–PHY (10/100). Micrel AN-133 on file (VENDOR).")
|
||||
L("mii", "MII MAC-PHY", "PERIPHERAL", "ETHERNET",
|
||||
"Media Independent Interface MAC–PHY. Micrel AN-133 on file (VENDOR); IEEE 802.3 Clause 22 referenced there.")
|
||||
L("1000base-x", "1000BASE-X", "SERIAL", "ETHERNET")
|
||||
L("100base-fx", "100BASE-FX", "SERIAL", "ETHERNET")
|
||||
L("10gbase-r", "10GBASE-R", "SERIAL", "ETHERNET")
|
||||
@@ -822,7 +912,6 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
L("ucie", "UCIe (logical)", "CHIP_TO_CHIP", "UCIE")
|
||||
L("i2c", "I2C / SMBus", "PERIPHERAL", "I2C")
|
||||
L("can", "CAN / CAN FD", "PERIPHERAL", "CAN")
|
||||
L("rgmii", "RGMII MAC-PHY", "PERIPHERAL", "ETHERNET")
|
||||
|
||||
def _usb2_cons(pid: str, *, speed: str) -> list[dict[str, Any]]:
|
||||
"""speed: ls | fs | hs | lsfs. PCB Zdiff stays UNKNOWN (cable 90 Ω is CABLE; PCB is should)."""
|
||||
@@ -1508,8 +1597,374 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
source_type="CABLE",
|
||||
conditions=["maximum propagation delay of a link segment, 2–100 MHz"],
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
def _sgmii_cons(pid: str) -> list[dict[str, Any]]:
|
||||
"""Cisco Serial-GMII ENG-46158 Rev 1.7 — VENDOR, not IEEE MDI."""
|
||||
out: list[dict[str, Any]] = [
|
||||
_num(
|
||||
pid, "data_rate", 1.25, "Gbaud",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Overview", page="3",
|
||||
url=PATH_SGMII, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["1.25 Gbaud data; 625 MHz DDR clock"],
|
||||
),
|
||||
_num(
|
||||
pid, "clock_frequency", 625.0, "MHz",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Overview", page="3",
|
||||
url=PATH_SGMII, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["DDR interface"],
|
||||
),
|
||||
_num(
|
||||
pid, "differential_impedance", 100.0, "ohm",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver DC specification",
|
||||
table="table 2", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="RECOMMENDED",
|
||||
conditions=[
|
||||
"All parameters measured at Rload = 100 ohms +-1% load",
|
||||
"measurement load — not IEEE 802.3 MDI cabling 100 Ω",
|
||||
],
|
||||
),
|
||||
_num(
|
||||
pid, "impedance_tolerance", 1.0, "%",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver DC specification",
|
||||
table="table 2", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="RECOMMENDED",
|
||||
conditions=["Rload = 100 ohms +-1%"],
|
||||
),
|
||||
_num(
|
||||
pid, "receiver_differential_impedance_min", 80.0, "ohm",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Receiver DC specification",
|
||||
table="table 3", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["Rin receiver differential input impedance"],
|
||||
),
|
||||
_num(
|
||||
pid, "receiver_differential_impedance_max", 120.0, "ohm",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Receiver DC specification",
|
||||
table="table 3", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["Rin receiver differential input impedance"],
|
||||
),
|
||||
_num(
|
||||
pid, "intra_pair_skew", 20.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tskew1 max; skew measured at 50% of the transition"],
|
||||
),
|
||||
_num(
|
||||
pid, "clock_to_data_min", 250.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tclock2q min; clock to data at 0 V differential"],
|
||||
),
|
||||
_num(
|
||||
pid, "clock_to_data_max", 550.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tclock2q max"],
|
||||
),
|
||||
_num(
|
||||
pid, "setup_time", 100.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Receiver AC specification",
|
||||
table="table 5", page="10", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tsetup min; measured at 50% of the transition"],
|
||||
),
|
||||
_num(
|
||||
pid, "hold_time", 100.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Receiver AC specification",
|
||||
table="table 5", page="10", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["thold min"],
|
||||
),
|
||||
_num(
|
||||
pid, "rise_time_min", 100.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["trise Vod 20%-80%"],
|
||||
),
|
||||
_num(
|
||||
pid, "rise_time_max", 200.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["trise Vod 20%-80%"],
|
||||
),
|
||||
_num(
|
||||
pid, "fall_time_min", 100.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tfall Vod 20%-80%"],
|
||||
),
|
||||
_num(
|
||||
pid, "fall_time_max", 200.0, "ps",
|
||||
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
|
||||
revision=REV_SGMII_CISCO, section="Driver AC specification",
|
||||
table="table 4", page="9", url=PATH_SGMII,
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["tfall Vod 20%-80%"],
|
||||
),
|
||||
_c(pid, "maximum_channel_length", "UNKNOWN",
|
||||
needed_document=DOC_SGMII_NEEDED),
|
||||
_c(pid, "topology", "UNKNOWN",
|
||||
needed_document=DOC_SGMII_NEEDED),
|
||||
_c(pid, "termination", "UNKNOWN",
|
||||
needed_document=(
|
||||
DOC_SGMII_NEEDED + "; LVDS basis IEEE1596.3-1996 referenced, "
|
||||
"no separate PCB termination ohm stated beyond Rin/Rload"
|
||||
)),
|
||||
]
|
||||
return out
|
||||
|
||||
def _rgmii_cons(pid: str) -> list[dict[str, Any]]:
|
||||
"""OPEN Alliance RGMII EPL V2.2 — industry STANDARD cite."""
|
||||
out: list[dict[str, Any]] = [
|
||||
_num(
|
||||
pid, "clock_duty_high_min", 3.6, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
|
||||
page="11", url=PATH_OPEN_RGMII,
|
||||
conditions=["TdutyHigh; DoD source (also Tables 3–5)"],
|
||||
),
|
||||
_num(
|
||||
pid, "clock_duty_low_min", 3.6, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
|
||||
page="11", url=PATH_OPEN_RGMII,
|
||||
conditions=["TdutyLow"],
|
||||
),
|
||||
_num(
|
||||
pid, "data_clock_skew_dod_source_max", 0.5, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
|
||||
page="11", url=PATH_OPEN_RGMII,
|
||||
conditions=["TDCskewDoDTr / TCDskewDoDTr / Tf max at signal source"],
|
||||
),
|
||||
_num(
|
||||
pid, "data_clock_skew_dod_dest_max", 0.65, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.2", table="Table 3",
|
||||
page="12", url=PATH_OPEN_RGMII,
|
||||
conditions=["TDCskewDoDRr / TCDskewDoDRr max at signal destination"],
|
||||
),
|
||||
_num(
|
||||
pid, "data_clock_skew_dos_source_min", 1.2, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.3", table="Table 4",
|
||||
page="13", url=PATH_OPEN_RGMII,
|
||||
conditions=["TDCskewDoSTr / TCDskewDoSTr min at signal source"],
|
||||
),
|
||||
_num(
|
||||
pid, "data_clock_skew_dos_dest", 1.05, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.3", table="Table 5",
|
||||
page="14", url=PATH_OPEN_RGMII,
|
||||
conditions=["TDCskewDoSRr / TCDskewDoSRr at signal destination"],
|
||||
),
|
||||
_num(
|
||||
pid, "clock_data_delay_mismatch", 150.0, "ps",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="7.1", page="10",
|
||||
url=PATH_OPEN_RGMII, mandatory="INFORMATIONAL",
|
||||
conditions=[
|
||||
"Note: parameters assume a delay mismatch between clock and data of 150psec",
|
||||
],
|
||||
),
|
||||
_num(
|
||||
pid, "receiver_load_cap_max", 5.0, "pF",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="8", table="Table 6",
|
||||
page="15", url=PATH_OPEN_RGMII,
|
||||
conditions=["CloadR_max"],
|
||||
),
|
||||
_num(
|
||||
pid, "rise_time_max", 1.0, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="8", table="Table 6",
|
||||
page="15", url=PATH_OPEN_RGMII,
|
||||
conditions=["tR_max 20% to 80%; test load 5 pF at signal source"],
|
||||
),
|
||||
_num(
|
||||
pid, "fall_time_max", 1.0, "ns",
|
||||
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
|
||||
revision=REV_OPEN_RGMII, section="8", table="Table 6",
|
||||
page="15", url=PATH_OPEN_RGMII,
|
||||
conditions=["tF_max 80% to 20%; test load 5 pF"],
|
||||
),
|
||||
_c(pid, "impedance", "UNKNOWN",
|
||||
needed_document=DOC_OPEN_RGMII_NEEDED),
|
||||
_c(pid, "length", "UNKNOWN",
|
||||
needed_document=DOC_OPEN_RGMII_NEEDED),
|
||||
_c(pid, "delay", "UNKNOWN",
|
||||
needed_document=(
|
||||
DOC_OPEN_RGMII_NEEDED + "; skews are time (ns), not PCB mm delay"
|
||||
)),
|
||||
_c(pid, "topology", "UNKNOWN",
|
||||
needed_document=(
|
||||
"OPEN Alliance RGMII EPL V2.2 §7.1 defines DoD and DoS delay modes "
|
||||
"but no PCB topology mm rule"
|
||||
)),
|
||||
_c(pid, "timing", "UNKNOWN",
|
||||
needed_document=(
|
||||
"OPEN Alliance RGMII EPL V2.2 Tables 2–5 pack duty/skew as separate "
|
||||
"NUMERIC rows; no single timing scalar"
|
||||
)),
|
||||
_c(pid, "clock_data_relationship", "UNKNOWN",
|
||||
needed_document=(
|
||||
"OPEN Alliance RGMII EPL V2.2 §7.1 / Tables 2–5 — see skew NUMERIC rows "
|
||||
"and clock_data_delay_mismatch 150 ps note"
|
||||
)),
|
||||
]
|
||||
return out
|
||||
|
||||
def _rmii_cons(pid: str) -> list[dict[str, Any]]:
|
||||
"""Micrel AN-133 — VENDOR app note (not STANDARD)."""
|
||||
return [
|
||||
_num(
|
||||
pid, "refclk_frequency", 50.0, "MHz",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="RMII", page="2",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["Connect a 50 MHz clock source to REFCLK; also used by MAC"],
|
||||
),
|
||||
_num(
|
||||
pid, "mdc_max_frequency", 5.0, "MHz",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="MIIM", page="4",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["MDC clock input maximum 5 MHz (KS8721/KS8001)"],
|
||||
),
|
||||
_num(
|
||||
pid, "source_termination_min", 22.0, "ohm",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="RMII Example", page="7",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="OPTIONAL",
|
||||
conditions=[
|
||||
"Source termination (22-50 Ohms) maybe required on the RMII clock and data lines",
|
||||
],
|
||||
),
|
||||
_num(
|
||||
pid, "source_termination_max", 50.0, "ohm",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="RMII Example", page="7",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="OPTIONAL",
|
||||
conditions=[
|
||||
"Source termination (22-50 Ohms) maybe required on the RMII clock and data lines",
|
||||
],
|
||||
),
|
||||
_c(pid, "impedance", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=(
|
||||
DOC_MICREL_AN133_NEEDED + ". Optional PHY: LAN8720A DS00002165C has "
|
||||
"RMII setup/hold (Tables 5-10/5-11) but no PCB Z0"
|
||||
)),
|
||||
_c(pid, "length", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "skew", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "setup_time", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=(
|
||||
"PHY datasheet (optional HubAudio cite: LAN8720A/LAN8720Ai "
|
||||
"DS00002165C Table 5-11 REF_CLK In Mode tsu min 4.0 ns — not packed "
|
||||
"into generic rmii-pcb)"
|
||||
)),
|
||||
_c(pid, "hold_time", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=(
|
||||
"PHY datasheet (optional HubAudio cite: LAN8720A DS00002165C "
|
||||
"Table 5-11 tihold min 1.5 ns — not packed into generic rmii-pcb)"
|
||||
)),
|
||||
_c(pid, "topology", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
]
|
||||
|
||||
def _mii_cons(pid: str) -> list[dict[str, Any]]:
|
||||
"""Micrel AN-133 MII — VENDOR. IEEE Clause 22 referenced but no IEEE PCB ohms."""
|
||||
return [
|
||||
_num(
|
||||
pid, "tx_rx_clock_frequency", 25.0, "MHz",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="RMII", page="2",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=[
|
||||
"RMII 50 MHz reference clock replaces the 25 MHz transmit and receive clocks in MII",
|
||||
],
|
||||
),
|
||||
_num(
|
||||
pid, "mdc_max_frequency", 5.0, "MHz",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="MIIM", page="4",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
conditions=["MDC clock input maximum 5 MHz"],
|
||||
),
|
||||
_num(
|
||||
pid, "source_termination_min", 22.0, "ohm",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="MII Example", page="6",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="OPTIONAL",
|
||||
conditions=[
|
||||
"Source termination (22-50 Ohms) maybe required on the MII clock and data lines",
|
||||
],
|
||||
),
|
||||
_num(
|
||||
pid, "source_termination_max", 50.0, "ohm",
|
||||
document=DOC_MICREL_AN133, organization=ORG_MICREL,
|
||||
revision=REV_MICREL_AN133, section="MII Example", page="6",
|
||||
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
|
||||
mandatory="OPTIONAL",
|
||||
conditions=[
|
||||
"Source termination (22-50 Ohms) maybe required on the MII clock and data lines",
|
||||
],
|
||||
),
|
||||
_c(pid, "impedance", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "length", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "skew", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "setup_time", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "hold_time", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
_c(pid, "topology", "UNKNOWN",
|
||||
source_type="VENDOR", source_class="VENDOR",
|
||||
needed_document=DOC_MICREL_AN133_NEEDED),
|
||||
]
|
||||
|
||||
def _hdmi_cons(pid: str, *, hdmi20_plus: bool = False, frl: bool = False) -> list[dict[str, Any]]:
|
||||
# HDMI 2.0+ Michele exception: TI layout as source_type VENDOR.
|
||||
# TMDS1204-specific limits (role/chip) stay PHY_DEPENDENT with no number.
|
||||
@@ -2049,12 +2504,11 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
("2.5gbase-t", "2.5gbase-t-mdi", ["2.5GBASE", "NBASE"]),
|
||||
("5gbase-t", "5gbase-t-mdi", ["5GBASE"]),
|
||||
("10gbase-t", "10gbase-t-mdi", ["10GBASE-T"]),
|
||||
("sgmii", "sgmii-pcb", ["SGMII"]),
|
||||
("1000base-x", "1000base-x-pcb", ["1000BASE-X", "SFP"]),
|
||||
("100base-fx", "100base-fx-pcb", ["100BASE-FX", "FX"]),
|
||||
("10gbase-r", "10gbase-r-pcb", ["10GBASE-R", "SFI"]),
|
||||
):
|
||||
conn = "none" if lid in {"sgmii", "1000base-x", "100base-fx", "10gbase-r"} else "RJ45"
|
||||
conn = "none" if lid in {"1000base-x", "100base-fx", "10gbase-r"} else "RJ45"
|
||||
physical.append(_iface(
|
||||
pid, lid, "SERIAL", "SERIAL_DIFFERENTIAL", "YES",
|
||||
ETH_CHECKS, _eth_cons(pid, lid),
|
||||
@@ -2065,6 +2519,20 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
"IEEE 802.3-2012 Section Three (Clause 40 1000BASE-T; Clause 36 1000BASE-X) on file. "
|
||||
"100 Ω in Clause 14/25/40 is cabling/link-segment, not PCB Z. "
|
||||
"Not auto from RJ45. IEEE vs PHY vs PCB vs magnetics are distinct. "
|
||||
"SGMII is packed separately from Cisco ENG-46158 (not an IEEE MDI clause)."
|
||||
),
|
||||
incomplete=True,
|
||||
))
|
||||
physical.append(_iface(
|
||||
"sgmii-pcb", "sgmii", "SERIAL", "SERIAL_DIFFERENTIAL", "YES",
|
||||
SGMII_CHECKS,
|
||||
_sgmii_cons("sgmii-pcb"),
|
||||
connector="none",
|
||||
hints=["SGMII", "SGMII_TX", "SGMII_RX"],
|
||||
notes=(
|
||||
"SGMII MAC–PHY from Cisco Serial-GMII Specification ENG-46158 Rev 1.7. "
|
||||
"source_type=VENDOR (Cisco IP released for industry adoption) — not IEEE 802.3 MDI. "
|
||||
"Not RJ45 100 Ω cabling. Measurement Rload 100 Ω ±1%; Rin 80–120 Ω."
|
||||
),
|
||||
incomplete=True,
|
||||
))
|
||||
@@ -2421,20 +2889,39 @@ def build_m0_catalog() -> dict[str, Any]:
|
||||
physical.append(_iface(
|
||||
"rgmii-pcb", "rgmii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
|
||||
RGMII_CHECKS,
|
||||
[
|
||||
_c("rgmii-pcb", "timing", "PHY_DEPENDENT",
|
||||
source_type="PHY", source_class="VENDOR"),
|
||||
_c("rgmii-pcb", "clock_data_relationship", "PHY_DEPENDENT",
|
||||
source_type="PHY", source_class="VENDOR"),
|
||||
_c("rgmii-pcb", "length", "UNKNOWN"),
|
||||
_c("rgmii-pcb", "delay", "UNKNOWN"),
|
||||
_c("rgmii-pcb", "topology", "UNKNOWN"),
|
||||
],
|
||||
_rgmii_cons("rgmii-pcb"),
|
||||
hints=["RGMII", "TX_CTL", "RX_CTL", "GTX_CLK", "RX_CLK"],
|
||||
notes=(
|
||||
"RGMII MAC–PHY. Timing/delay UNKNOWN without PHY cite. "
|
||||
"Length is not delay. Not 100BASE-TX magnetics."
|
||||
"RGMII MAC–PHY from OPEN Alliance RGMII EPL V2.2 (STANDARD/industry). "
|
||||
"Timing skews NUMERIC; PCB Z0 and mm length UNKNOWN (not stated). "
|
||||
"Not RJ45/MDI 100 Ω. Not 100BASE-TX magnetics."
|
||||
),
|
||||
incomplete=True,
|
||||
))
|
||||
physical.append(_iface(
|
||||
"rmii-pcb", "rmii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
|
||||
RMII_CHECKS,
|
||||
_rmii_cons("rmii-pcb"),
|
||||
hints=["RMII", "ETH_RXD", "ETH_TXD", "ETH_TXEN", "CRS_DV", "REF_CLK"],
|
||||
notes=(
|
||||
"RMII MAC–PHY from Micrel AN-133 Rev 1.0 (VENDOR app note — not STANDARD). "
|
||||
"REFCLK 50 MHz and optional 22–50 Ω source termination packed. "
|
||||
"PCB Z0 / setup-hold / skew UNKNOWN in AN-133. Not RJ45/MDI 100 Ω. "
|
||||
"Optional PHY timing: LAN8720A datasheet (HubAudio) — not packed into this generic row."
|
||||
),
|
||||
incomplete=True,
|
||||
))
|
||||
physical.append(_iface(
|
||||
"mii-pcb", "mii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
|
||||
MII_CHECKS,
|
||||
_mii_cons("mii-pcb"),
|
||||
hints=["MII", "TX_CLK", "RX_CLK", "TX_EN", "RX_DV", "CRS", "COL"],
|
||||
notes=(
|
||||
"MII MAC–PHY from Micrel AN-133 Rev 1.0 (VENDOR). "
|
||||
"25 MHz TX/RX clocks cited via RMII contrast; optional 22–50 Ω source termination. "
|
||||
"PCB Z0 UNKNOWN. IEEE 802.3 Clause 22 referenced in AN-133 but no IEEE PCB ohms packed."
|
||||
),
|
||||
incomplete=True,
|
||||
))
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,18 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import (
|
||||
normalize_kicad_hierarchy_net,
|
||||
refs_on_matched_net,
|
||||
)
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
SCHEMA_VERSION,
|
||||
PhysicalInterface,
|
||||
@@ -47,7 +57,8 @@ L0_CHECKS = frozenset({
|
||||
"superspeed_pairs",
|
||||
})
|
||||
|
||||
_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
|
||||
_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I)
|
||||
_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I)
|
||||
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
|
||||
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
|
||||
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
|
||||
@@ -160,6 +171,156 @@ def certify_instance_l0(
|
||||
return out
|
||||
|
||||
|
||||
def _parse_ohms(raw: str | None) -> float | None:
|
||||
t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "")
|
||||
t = t.replace("Ω", "").replace(" ", "")
|
||||
if not t:
|
||||
return None
|
||||
m = _OHM_EMBEDDED.match(t)
|
||||
if m:
|
||||
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
|
||||
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult
|
||||
m = _OHM_SUFFIX.match(t)
|
||||
if m:
|
||||
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
|
||||
return float(m.group(1)) * mult
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _resistor_ohms(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
|
||||
return float(specs.value_ohms)
|
||||
return _parse_ohms(comp.value)
|
||||
|
||||
|
||||
def _rd_cite() -> tuple[float | None, str]:
|
||||
"""CabCon Rd already packed. No other USB number is introduced here."""
|
||||
cat = load_catalog()
|
||||
chosen = None
|
||||
fallback = None
|
||||
for iface in cat.physical_interfaces:
|
||||
if iface.id not in {"usb-c-usb2-receptacle", "usb-c-receptacle"}:
|
||||
continue
|
||||
for c in iface.constraints:
|
||||
if c.parameter == "rd" and c.value is not None:
|
||||
if iface.id == "usb-c-usb2-receptacle":
|
||||
chosen = c
|
||||
elif fallback is None:
|
||||
fallback = c
|
||||
chosen = chosen or fallback
|
||||
if chosen is None or chosen.value is None:
|
||||
return None, "CabCon Rd cite is not in the protocol pack."
|
||||
src = chosen.source
|
||||
bits = []
|
||||
if src and src.document:
|
||||
bits.append(src.document)
|
||||
if src and src.revision:
|
||||
bits.append(src.revision)
|
||||
if src and src.section:
|
||||
bits.append(f"§{src.section}")
|
||||
if src and src.table:
|
||||
bits.append(src.table)
|
||||
if src and src.page:
|
||||
bits.append(f"p.{src.page}")
|
||||
nominal = "nominal 5.1 kΩ" if any(
|
||||
"5.1" in (cond or "") for cond in (chosen.conditions or [])
|
||||
) else f"{chosen.value:g} {chosen.unit or 'ohm'}"
|
||||
cite = ", ".join(bits) if bits else chosen.id
|
||||
return float(chosen.value), f"{cite}; {nominal}"
|
||||
|
||||
|
||||
def _cc_pin_pairs(comp: Component) -> list[tuple[str, str | None]]:
|
||||
"""Receptacle CC pins. A5/B5 are the Type-C CC contacts, not net names."""
|
||||
pins = {str(k).upper(): v for k, v in comp.pins.items()}
|
||||
if "A5" in pins or "B5" in pins:
|
||||
return [("A5", pins.get("A5")), ("B5", pins.get("B5"))]
|
||||
if "CC1" in pins or "CC2" in pins:
|
||||
return [("CC1", pins.get("CC1")), ("CC2", pins.get("CC2"))]
|
||||
return []
|
||||
|
||||
|
||||
def _typec_receptacle(graph: DesignGraph, inst: PhysicalBusInstance) -> Component | None:
|
||||
found: list[Component] = []
|
||||
for comp in graph.components.values():
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
continue
|
||||
if _cc_pin_pairs(comp):
|
||||
found.append(comp)
|
||||
if not found:
|
||||
return None
|
||||
for comp in found:
|
||||
if comp.reference == inst.host_ref:
|
||||
return comp
|
||||
inst_nets = set(inst.nets)
|
||||
for g in inst.groups:
|
||||
inst_nets.update(g.nets)
|
||||
for comp in found:
|
||||
if inst_nets.intersection(v for v in comp.pins.values() if v):
|
||||
return comp
|
||||
return found[0]
|
||||
|
||||
|
||||
def _rd_refs_on_net(graph: DesignGraph, net: str, nominal: float) -> list[str]:
|
||||
"""Resistors on this net whose value is the packed Rd (float slack 1 Ω)."""
|
||||
hits: list[str] = []
|
||||
for ref in refs_on_matched_net(graph, net):
|
||||
comp = graph.components.get(ref)
|
||||
if comp is None or comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
ohms = _resistor_ohms(comp)
|
||||
if ohms is not None and abs(ohms - nominal) <= 1.0:
|
||||
hits.append(ref)
|
||||
return hits
|
||||
|
||||
|
||||
def _cc_rd_on_pins(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
mand: MandatoryClass,
|
||||
) -> L0CheckResult:
|
||||
"""Pin → net → Rd. The net does not have to be named CC1/CC2."""
|
||||
nominal, cite = _rd_cite()
|
||||
jack = _typec_receptacle(graph, inst)
|
||||
if jack is None or nominal is None:
|
||||
return _pack(
|
||||
"cc_rd_rp", "FAIL", mand,
|
||||
notes=(
|
||||
"Type-C CC pins were not followed to an Rd. "
|
||||
f"{cite}"
|
||||
),
|
||||
)
|
||||
pairs = _cc_pin_pairs(jack)
|
||||
nets: list[str] = []
|
||||
missing: list[str] = []
|
||||
present: list[str] = []
|
||||
for pin, net in pairs:
|
||||
if not net:
|
||||
missing.append(f"{jack.reference}.{pin} has no net")
|
||||
continue
|
||||
nets.append(net)
|
||||
refs = _rd_refs_on_net(graph, net, nominal)
|
||||
if refs:
|
||||
present.append(f"{jack.reference}.{pin} net {net} Rd {', '.join(refs)}")
|
||||
else:
|
||||
missing.append(
|
||||
f"{jack.reference}.{pin} net {net} has no {nominal:g} Ω Rd"
|
||||
)
|
||||
if missing or len(present) < 2:
|
||||
detail = "; ".join(missing) if missing else "both CC pins need an Rd"
|
||||
return _pack(
|
||||
"cc_rd_rp", "FAIL", mand, nets=nets,
|
||||
notes=f"{detail}. {cite}. Net name is not required.",
|
||||
)
|
||||
return _pack(
|
||||
"cc_rd_rp", "PASS", mand, nets=nets,
|
||||
notes=f"{'; '.join(present)}. {cite}.",
|
||||
)
|
||||
|
||||
|
||||
def _run_l0_check(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
@@ -212,11 +373,7 @@ def _run_l0_check(
|
||||
ok, msg = _structural_nets_ok(graph, clocks)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
|
||||
if check == "cc_rd_rp":
|
||||
ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
|
||||
if len(ccs) < 1:
|
||||
return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
|
||||
ok, msg = _structural_nets_ok(graph, ccs)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
|
||||
return _cc_rd_on_pins(graph, inst, mand)
|
||||
if check == "vbus_gnd":
|
||||
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
|
||||
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
|
||||
|
||||
@@ -4,7 +4,9 @@ Skew is compared only when the constraint is NUMERIC (length unit) or an
|
||||
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
|
||||
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
|
||||
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
|
||||
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
|
||||
Missing DQ/DQS grouping is a visible skip, not PASS. A geometric check
|
||||
with no NUMERIC or DESIGN millimetre limit does not warn: the standard
|
||||
did not set a mm limit, and one is not invented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -497,11 +499,14 @@ def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list
|
||||
finding = f"L1 geometric {r.check}: {r.notes}"
|
||||
elif skip_family:
|
||||
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
|
||||
if not grouping and r.limit_mm is None:
|
||||
# No NUMERIC/DESIGN millimetre limit in the pack. Silence.
|
||||
continue
|
||||
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
||||
f"per {r.check} ({r.result}). Not electrical."
|
||||
f"{inst.physical_interface_id} L1 {r.check} is {r.result}. "
|
||||
"Geometric only, not electrical."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
|
||||
|
||||
Z only from datasheet / stackup / fabricator / cited pack — never invented
|
||||
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
|
||||
max PCB × PHY subset when datasheet windows exist. Length is not delay.
|
||||
Not L3 / OpenEMS. L0/L1 are not electrical certification.
|
||||
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
|
||||
Visible skip (PE-AF-002 style) when Z cannot be obtained.
|
||||
Z only from datasheet / stackup / fabricator / cited pack. USB 2.0 HS
|
||||
D+/D− pair Z is compared to the packed ``pcb_trace_nominal_zdiff``
|
||||
(RECOMMENDED). That number is not invented here and is not a MANDATORY
|
||||
FAIL. Silicon cross is max PCB × PHY subset when datasheet windows exist.
|
||||
A missing field (termination cite, rise/fall, return path, voltage) does
|
||||
not emit a warning. Length is not delay. Not L3 / OpenEMS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.constraints_lookup import match_constraints
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.impedance import GeometryError, TraceGeometry, coupled_diff_z
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
@@ -23,7 +25,6 @@ from backend.periscopex.models import (
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
PhysicalInterface,
|
||||
ProtocolCatalog,
|
||||
@@ -31,8 +32,10 @@ from backend.periscopex.protocol_catalog import (
|
||||
load_catalog,
|
||||
map_protocol_outcome,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
from backend.periscopex.si_check import single_ended_eth_mac
|
||||
|
||||
PACK_MACROPHASE = "M4"
|
||||
SOURCE = "protocol_l2"
|
||||
@@ -81,6 +84,8 @@ class L2CheckResult(BaseModel):
|
||||
notes: str = ""
|
||||
finding_class: str = ""
|
||||
status: str = ""
|
||||
# "" legacy, "silent" no finding, "warn" one WARNING, "error" one ERROR.
|
||||
emit: str = ""
|
||||
|
||||
|
||||
def _pack(
|
||||
@@ -95,6 +100,7 @@ def _pack(
|
||||
limit_ohm_max: float | None = None,
|
||||
value_kind: str = "",
|
||||
notes: str = "",
|
||||
emit: str = "",
|
||||
) -> L2CheckResult:
|
||||
if result == "FAIL" and mandatory != "MANDATORY":
|
||||
result = "WARNING"
|
||||
@@ -120,6 +126,7 @@ def _pack(
|
||||
notes=notes,
|
||||
finding_class=cls,
|
||||
status=status,
|
||||
emit=emit,
|
||||
)
|
||||
|
||||
|
||||
@@ -286,6 +293,24 @@ def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | N
|
||||
return None
|
||||
|
||||
|
||||
_NOISE_CHECKS = frozenset({
|
||||
"differential_impedance",
|
||||
"impedance",
|
||||
"impedance_if_required",
|
||||
"termination",
|
||||
"cc_termination",
|
||||
"voltage",
|
||||
"levels",
|
||||
"rise_time",
|
||||
"fall_time",
|
||||
"timing",
|
||||
"return_path",
|
||||
"magnetics",
|
||||
"phy_requirements",
|
||||
"ac_coupling",
|
||||
})
|
||||
|
||||
|
||||
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
|
||||
return _pack(
|
||||
check, "UNKNOWN" if kind not in {
|
||||
@@ -294,10 +319,10 @@ def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: s
|
||||
} else kind,
|
||||
mand,
|
||||
value_kind=kind,
|
||||
emit="silent",
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
|
||||
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
|
||||
f"{inst.physical_interface_id} has no cited Z window "
|
||||
"(datasheet, stackup, fabricator, or pack). Not a warning."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -349,8 +374,8 @@ def _run_l2_check(
|
||||
"differential_impedance", "impedance", "impedance_if_required",
|
||||
}:
|
||||
return _z_check(
|
||||
check, mand, cons, kind, inst, graph, nets,
|
||||
constraints_map, impedance_nets,
|
||||
check, mand, cons, kind, inst, iface, graph, nets,
|
||||
constraints_map, impedance_nets, layout,
|
||||
)
|
||||
|
||||
if check in {"termination", "cc_termination"}:
|
||||
@@ -362,10 +387,10 @@ def _run_l2_check(
|
||||
if check in {"rise_time", "fall_time", "timing"}:
|
||||
return _pack(
|
||||
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
|
||||
nets=nets, value_kind=kind,
|
||||
nets=nets, value_kind=kind, emit="silent",
|
||||
notes=(
|
||||
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
|
||||
"time source (no tpd invented from mm). Not L3."
|
||||
"Length is not delay. Rise/fall/timing stays UNKNOWN without a cited "
|
||||
"time source (no tpd invented from mm). Not L3. Not a warning."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -374,27 +399,234 @@ def _run_l2_check(
|
||||
if verdict == "NUMERIC":
|
||||
verdict = "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, value_kind=kind,
|
||||
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
|
||||
notes=(
|
||||
f"{check} not certified without a cited electrical source. "
|
||||
f"Not invented. L0/L1 are not electrical certification."
|
||||
f"{check} has no cited electrical source. "
|
||||
"Not invented. Not a warning."
|
||||
),
|
||||
)
|
||||
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
|
||||
|
||||
|
||||
def _pcb_zdiff_cite(iface: PhysicalInterface) -> ProtocolConstraint | None:
|
||||
"""Packed board Zdiff recommendation. Never a number typed in this module."""
|
||||
for c in iface.constraints:
|
||||
if c.parameter != "pcb_trace_nominal_zdiff":
|
||||
continue
|
||||
if c.value_kind != "NUMERIC" or c.value is None:
|
||||
continue
|
||||
unit = (c.unit or "").strip().lower()
|
||||
if unit not in _OHM_UNITS:
|
||||
continue
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _usb_dp_dm(inst: PhysicalBusInstance) -> tuple[str, str] | None:
|
||||
for g in inst.groups:
|
||||
if g.kind != "DIFFERENTIAL_PAIR" or len(g.nets) < 2:
|
||||
continue
|
||||
dp = g.roles.get("D+") or g.nets[0]
|
||||
dm = g.roles.get("D-") or g.nets[1]
|
||||
if single_ended_eth_mac(dp) or single_ended_eth_mac(dm):
|
||||
return None
|
||||
return dp, dm
|
||||
return None
|
||||
|
||||
|
||||
def _cite_label(cons: ProtocolConstraint) -> str:
|
||||
src = cons.source
|
||||
bits: list[str] = []
|
||||
if src is not None:
|
||||
if src.document:
|
||||
bits.append(src.document)
|
||||
if src.section:
|
||||
bits.append(f"§{src.section}")
|
||||
if src.page:
|
||||
bits.append(f"p.{src.page}")
|
||||
return ", ".join(bits) if bits else "cited pack"
|
||||
|
||||
|
||||
def _zdiff_measured(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
|
||||
"""Pair Z only. Single-ended Z0 is not a differential result."""
|
||||
rows = _z_rows(impedance_nets)
|
||||
vals: list[float] = []
|
||||
for net in nets:
|
||||
row = _row_for(rows, net)
|
||||
if not row:
|
||||
continue
|
||||
for key in ("zdiff_ohm", "zdiff_avg_ohms", "zdiff_ohms"):
|
||||
v = _num(row.get(key))
|
||||
if v is not None and v > 0:
|
||||
vals.append(v)
|
||||
break
|
||||
if not vals:
|
||||
return None
|
||||
return sum(vals) / len(vals)
|
||||
|
||||
|
||||
def _median_width_mm(layout: LayoutGraph, net: str) -> float | None:
|
||||
widths = sorted(
|
||||
s.width for s in layout.segments
|
||||
if s.net and kicad_nets_match(s.net, net) and s.width > 0
|
||||
)
|
||||
if not widths:
|
||||
return None
|
||||
return widths[len(widths) // 2]
|
||||
|
||||
|
||||
def _pair_gap_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
|
||||
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a) and s.width > 0]
|
||||
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b) and s.width > 0]
|
||||
gaps: list[float] = []
|
||||
for x in sa:
|
||||
for y in sb:
|
||||
if x.layer and y.layer and x.layer != y.layer:
|
||||
continue
|
||||
for px, py in (
|
||||
(x.start, y.start), (x.start, y.end),
|
||||
(x.end, y.start), (x.end, y.end),
|
||||
):
|
||||
edge = math.hypot(px[0] - py[0], px[1] - py[1]) - x.width / 2.0 - y.width / 2.0
|
||||
if edge > 1e-6:
|
||||
gaps.append(edge)
|
||||
if not gaps:
|
||||
return None
|
||||
return min(gaps)
|
||||
|
||||
|
||||
def usb_pair_z_ohm(
|
||||
layout: LayoutGraph | None,
|
||||
nets: tuple[str, str],
|
||||
impedance_nets: list[dict] | dict | None,
|
||||
) -> tuple[float | None, str]:
|
||||
"""Calculated differential Z for one pair.
|
||||
|
||||
Returns (ohms, missing). missing is empty when ohms is set, otherwise
|
||||
``stackup``, ``geometry``, or ``stackup/geometry``.
|
||||
"""
|
||||
stored = _zdiff_measured(list(nets), impedance_nets)
|
||||
if stored is not None:
|
||||
return stored, ""
|
||||
has_copper = bool(
|
||||
layout is not None and any(
|
||||
s.net and s.width > 0 and (
|
||||
kicad_nets_match(s.net, nets[0]) or kicad_nets_match(s.net, nets[1])
|
||||
)
|
||||
for s in layout.segments
|
||||
)
|
||||
)
|
||||
stack = layout.stackup if layout is not None else None
|
||||
dielectric = stack.dielectrics[0] if stack and stack.dielectrics else None
|
||||
stack_ok = (
|
||||
stack is not None
|
||||
and dielectric is not None
|
||||
and dielectric.er > 0
|
||||
and dielectric.height_mm > 0
|
||||
and stack.copper_thickness_mm is not None
|
||||
and stack.copper_thickness_mm > 0
|
||||
)
|
||||
if not stack_ok:
|
||||
return None, "stackup/geometry" if not has_copper else "stackup"
|
||||
assert layout is not None and stack is not None and dielectric is not None
|
||||
wa = _median_width_mm(layout, nets[0])
|
||||
wb = _median_width_mm(layout, nets[1])
|
||||
gap = _pair_gap_mm(layout, nets[0], nets[1])
|
||||
if wa is None or wb is None or gap is None:
|
||||
return None, "geometry"
|
||||
try:
|
||||
_zodd, _zeven, zdiff = coupled_diff_z(TraceGeometry(
|
||||
h=dielectric.height_mm,
|
||||
er=dielectric.er,
|
||||
t=stack.copper_thickness_mm,
|
||||
w=(wa + wb) / 2.0,
|
||||
s=gap,
|
||||
))
|
||||
except GeometryError:
|
||||
return None, "geometry"
|
||||
if zdiff <= 0:
|
||||
return None, "geometry"
|
||||
return zdiff, ""
|
||||
|
||||
|
||||
def _matches_cited(measured: float, cited: float) -> bool:
|
||||
return abs(measured - cited) <= 1e-6 * max(1.0, abs(cited))
|
||||
|
||||
|
||||
def _usb_line(kind: str, *, measured: float | None, cited: float, label: str) -> str:
|
||||
"""One report line. The ohm value is the packed cite, not a second number."""
|
||||
if kind == "certified" and measured is not None:
|
||||
return (
|
||||
f"USB certified — calculated Z {measured:g} Ω vs cited {cited:g} Ω ({label})."
|
||||
)
|
||||
if measured is not None:
|
||||
return (
|
||||
f"USB not certified — because Z {measured:g} Ω is outside cited "
|
||||
f"{cited:g} Ω ({label})."
|
||||
)
|
||||
return (
|
||||
"USB not certified — because Z cannot be calculated from the PCB "
|
||||
f"stackup/geometry. Cited {cited:g} Ω ({label})."
|
||||
)
|
||||
|
||||
|
||||
def _usb_board_z(
|
||||
check: str,
|
||||
inst: PhysicalBusInstance,
|
||||
cite: ProtocolConstraint,
|
||||
pair: tuple[str, str],
|
||||
layout: LayoutGraph | None,
|
||||
impedance_nets: list[dict] | dict | None,
|
||||
) -> L2CheckResult:
|
||||
"""One line: calculated pair Z vs the packed board cite."""
|
||||
cited = float(cite.value) # type: ignore[arg-type]
|
||||
label = _cite_label(cite)
|
||||
measured, _missing = usb_pair_z_ohm(layout, pair, impedance_nets)
|
||||
mand: MandatoryClass = "RECOMMENDED"
|
||||
if measured is None:
|
||||
return L2CheckResult(
|
||||
check=check, result="FAIL", mandatory=mand, nets=list(pair),
|
||||
limit_ohm=cited, value_kind="NUMERIC", emit="not_certified",
|
||||
notes=_usb_line("missing", measured=None, cited=cited, label=label),
|
||||
finding_class="RULE", status="ERROR",
|
||||
)
|
||||
if _matches_cited(measured, cited):
|
||||
return L2CheckResult(
|
||||
check=check, result="PASS", mandatory=mand, nets=list(pair),
|
||||
measured_ohm=measured, limit_ohm=cited,
|
||||
limit_ohm_min=cited, limit_ohm_max=cited,
|
||||
value_kind="NUMERIC", emit="certified",
|
||||
notes=_usb_line("certified", measured=measured, cited=cited, label=label),
|
||||
finding_class="INFO", status="INFO",
|
||||
)
|
||||
return L2CheckResult(
|
||||
check=check, result="FAIL", mandatory=mand, nets=list(pair),
|
||||
measured_ohm=measured, limit_ohm=cited,
|
||||
limit_ohm_min=cited, limit_ohm_max=cited,
|
||||
value_kind="NUMERIC", emit="not_certified",
|
||||
notes=_usb_line("outside", measured=measured, cited=cited, label=label),
|
||||
finding_class="RULE", status="ERROR",
|
||||
)
|
||||
|
||||
|
||||
def _z_check(
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
cons: ProtocolConstraint | None,
|
||||
kind: str,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
graph: DesignGraph,
|
||||
nets: list[str],
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
impedance_nets: list[dict] | dict | None,
|
||||
layout: LayoutGraph | None,
|
||||
) -> L2CheckResult:
|
||||
pair = _usb_dp_dm(inst)
|
||||
cite = _pcb_zdiff_cite(iface)
|
||||
phy = _phy_z_windows(graph, inst, constraints_map)
|
||||
if cite is not None and pair is not None and not phy:
|
||||
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
|
||||
pack_w = _pack_ohm_window(cons)
|
||||
windows: list[tuple[float, float]] = []
|
||||
if pack_w:
|
||||
@@ -403,7 +635,6 @@ def _z_check(
|
||||
window = intersect_windows(windows) if windows else None
|
||||
measured = measured_z_ohm(nets, impedance_nets)
|
||||
if window is None:
|
||||
# no cited pack number and no datasheet PHY window → never invent 90 Ω
|
||||
if measured is None:
|
||||
return _skip_z(check, mand, inst, kind)
|
||||
return _pack(
|
||||
@@ -413,15 +644,15 @@ def _z_check(
|
||||
"VENDOR_DEPENDENT", "UNKNOWN",
|
||||
} else "UNKNOWN",
|
||||
mand,
|
||||
nets=nets, measured_ohm=measured, value_kind=kind,
|
||||
nets=nets, measured_ohm=measured, value_kind=kind, emit="silent",
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di Z target (cited pack or PHY datasheet). "
|
||||
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
|
||||
f"L0/L1 are not electrical certification."
|
||||
f"{inst.physical_interface_id} has measured Z and no cited target. "
|
||||
"Not compared to an invented ohm. Not a warning."
|
||||
),
|
||||
)
|
||||
if measured is None:
|
||||
if cite is not None and pair is not None:
|
||||
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
|
||||
return _skip_z(check, mand, inst, kind)
|
||||
verdict = electrical_verdict(cons, measured, window)
|
||||
source_note = (
|
||||
@@ -474,10 +705,10 @@ def _termination_check(
|
||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||
} else "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, value_kind=kind,
|
||||
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di termination cite. No invented Rd/ohm."
|
||||
f"{inst.physical_interface_id} has no termination cite. "
|
||||
"No invented resistor. Not a warning."
|
||||
),
|
||||
)
|
||||
measured = None
|
||||
@@ -517,8 +748,8 @@ def _levels_check(
|
||||
unit = (cons.unit or "").strip().lower()
|
||||
if unit in _VOLT_UNITS:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, value_kind="NUMERIC",
|
||||
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
|
||||
check, "UNKNOWN", mand, value_kind="NUMERIC", emit="silent",
|
||||
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V. Not a warning.",
|
||||
)
|
||||
if constraints_map:
|
||||
for ref in [inst.host_ref, *inst.peer_refs]:
|
||||
@@ -528,10 +759,10 @@ def _levels_check(
|
||||
ds = match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if ds and ds.absolute_maximum_ratings:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, value_kind=kind,
|
||||
check, "UNKNOWN", mand, value_kind=kind, emit="silent",
|
||||
notes=(
|
||||
"Silicon abs-max present; L2 levels need a cited operating "
|
||||
"window vs measured rail — not assumed. Not L3."
|
||||
"window vs measured rail — not assumed. Not L3. Not a warning."
|
||||
),
|
||||
)
|
||||
verdict = kind if kind in {
|
||||
@@ -539,15 +770,58 @@ def _levels_check(
|
||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||
} else "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, value_kind=kind,
|
||||
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
|
||||
check, verdict, mand, value_kind=kind, emit="silent",
|
||||
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented. Not a warning.",
|
||||
)
|
||||
|
||||
|
||||
def _pair_line_finding(inst: PhysicalBusInstance, r: L2CheckResult) -> Finding:
|
||||
"""Certified or not certified. English. Not the datasheet boilerplate."""
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
certified = r.emit == "certified"
|
||||
text = r.notes
|
||||
if certified:
|
||||
status = "INFO"
|
||||
cls = "INFO"
|
||||
action = "Calculated pair Z matches the cited limit."
|
||||
else:
|
||||
status = "ERROR"
|
||||
cls = "RULE"
|
||||
action = "Calculated pair Z is not certified. The finding states the reason."
|
||||
f = Finding(
|
||||
designator=designator,
|
||||
mpn="",
|
||||
aspect="protocol_l2",
|
||||
finding=text,
|
||||
why=text,
|
||||
status=status, # type: ignore[arg-type]
|
||||
source=SOURCE,
|
||||
net=r.nets[0] if r.nets else None,
|
||||
rule_id="PE-PRT-L2-002",
|
||||
facts=text,
|
||||
requirement=text,
|
||||
inference="Calculated differential Z compared with the packed cite.",
|
||||
provenance="MANDATORY",
|
||||
finding_class=cls, # type: ignore[arg-type]
|
||||
evidence_status="SUFFICIENT",
|
||||
recommendation=action,
|
||||
action=action,
|
||||
)
|
||||
return complete_finding(f)
|
||||
|
||||
|
||||
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
for r in results:
|
||||
if r.emit in {"certified", "not_certified"}:
|
||||
out.append(_pair_line_finding(inst, r))
|
||||
continue
|
||||
if r.emit == "silent" or (r.result in {
|
||||
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
} and r.check in _NOISE_CHECKS):
|
||||
continue
|
||||
if r.result == "PASS":
|
||||
continue
|
||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||
@@ -568,8 +842,7 @@ def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list
|
||||
rule_id = "PE-PRT-L2-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per {r.check} ({r.result})."
|
||||
f"{inst.physical_interface_id} L2 {r.check} is {r.result}."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
|
||||
@@ -509,9 +509,28 @@ def certify_instance_l3(
|
||||
if c in L3_CHECKS and c not in checks:
|
||||
checks.append(c)
|
||||
facts = lookup_channel_facts(inst, channel_data)
|
||||
if not _channel_fact_present(facts):
|
||||
return []
|
||||
return [_run_l3_check(inst, iface, c, facts) for c in checks]
|
||||
|
||||
|
||||
def _channel_fact_present(facts: dict[str, Any]) -> bool:
|
||||
"""True only when reduced channel FACT is already on hand."""
|
||||
if not facts:
|
||||
return False
|
||||
if _looks_like_raw_sparam(facts):
|
||||
return False
|
||||
if _blob_mentions_fem(facts.get("method") or facts.get("solver") or ""):
|
||||
return False
|
||||
if _il_measured(facts) is not None or _rl_measured(facts) is not None:
|
||||
return True
|
||||
if _xt_measured(facts) is not None:
|
||||
return True
|
||||
if _num(facts.get("total_budget_ps")) is not None:
|
||||
return True
|
||||
return facts.get("channel_complete") is True
|
||||
|
||||
|
||||
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
@@ -533,12 +552,8 @@ def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list
|
||||
status = "ERROR"
|
||||
finding = f"L3 channel {r.check}: {r.notes}"
|
||||
elif skip_family:
|
||||
rule_id = "PE-PRT-L3-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
|
||||
f"per {r.check} ({r.result})."
|
||||
)
|
||||
# No channel FACT, or a field with nothing to certify. Omit the warning.
|
||||
continue
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
status = "INFO"
|
||||
|
||||
@@ -114,6 +114,12 @@ def check_to_report_row(
|
||||
result = str(dump.get("result") or "")
|
||||
cons = _constraint_for(iface, check) if iface is not None else None
|
||||
doc, section, method = _cite(cons)
|
||||
if check == "differential_impedance" and not doc and iface is not None:
|
||||
for alt in iface.constraints:
|
||||
if alt.parameter == "pcb_trace_nominal_zdiff" and alt.source is not None:
|
||||
doc, section, method = _cite(alt)
|
||||
cons = alt
|
||||
break
|
||||
nets = list(dump.get("nets") or [])
|
||||
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
|
||||
group = _group_for_net(inst, net or None)
|
||||
@@ -130,6 +136,7 @@ def check_to_report_row(
|
||||
"source": doc,
|
||||
"method": method,
|
||||
"notes": dump.get("notes") or "",
|
||||
"emit": dump.get("emit") or "",
|
||||
"skip_visible": skip,
|
||||
"rank": result_rank(result),
|
||||
"chain": {
|
||||
@@ -181,6 +188,21 @@ def format_chain(chain: dict[str, Any]) -> str:
|
||||
return " → ".join(parts)
|
||||
|
||||
|
||||
def _noise_row(row: dict[str, Any]) -> bool:
|
||||
"""Missing-field skips are not a certification line."""
|
||||
if row.get("emit") == "silent":
|
||||
return True
|
||||
result = str(row.get("result") or "")
|
||||
if result not in _SKIP:
|
||||
return False
|
||||
notes = str(row.get("notes") or "").lower()
|
||||
if "grouping" in notes or "dq/dqs" in notes:
|
||||
return False
|
||||
if row.get("measured") is not None or row.get("limit") is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def attach_instance_report(
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface | None,
|
||||
@@ -195,9 +217,7 @@ def attach_instance_report(
|
||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l2]
|
||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
|
||||
)
|
||||
rows = [check_to_report_row(d, inst, iface) for d in dumps]
|
||||
if not any(r["level"] == "L3" for r in rows):
|
||||
rows.append(l3_not_run_row(inst))
|
||||
rows = [r for r in (check_to_report_row(d, inst, iface) for d in dumps) if not _noise_row(r)]
|
||||
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
|
||||
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
|
||||
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
|
||||
|
||||
@@ -35,6 +35,9 @@ _XTAL_RE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
|
||||
# MDIO/MDC management (often single-ended). Not RJ45/MDI 100 Ω differential.
|
||||
_MDIO_MGMT_RE = re.compile(r"(?:^|[_/.])(?:MDIO|MDC)$", re.I)
|
||||
|
||||
_HS_CLASS_RE = (
|
||||
("usb3", re.compile(
|
||||
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
|
||||
@@ -42,9 +45,15 @@ _HS_CLASS_RE = (
|
||||
("hdmi", re.compile(r"HDMI", re.I)),
|
||||
("pcie", re.compile(r"PCIE|PEX_", re.I)),
|
||||
("sgmii", re.compile(r"SGMII", re.I)),
|
||||
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII", re.I)),
|
||||
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", re.I)),
|
||||
("rmii", re.compile(
|
||||
r"(?:^|[_/])RMII|"
|
||||
r"(?:ETH|MAC).*(?:TXD|RXD|TXEN|RXDV|CRS_DV|REF_CLK)",
|
||||
re.I,
|
||||
)),
|
||||
("eth_mdi", re.compile(
|
||||
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
|
||||
# MDI(?!O) — RJ45 Media Dependent Interface, never MDIO management.
|
||||
r"(?:^|[_/])(MDI(?!O)|TRD[0-3]|TCT|RCT)|"
|
||||
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
|
||||
re.I,
|
||||
)),
|
||||
@@ -92,8 +101,10 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
|
||||
"magnetics": frozenset({"eth_mdi"}),
|
||||
"rgmii": frozenset({"rgmii"}),
|
||||
"gmii": frozenset({"rgmii"}),
|
||||
"mac_phy": frozenset({"rgmii", "sgmii"}),
|
||||
"mac": frozenset({"rgmii", "sgmii"}),
|
||||
"rmii": frozenset({"rmii"}),
|
||||
"mii": frozenset({"rmii"}),
|
||||
"mac_phy": frozenset({"rmii", "rgmii", "sgmii"}),
|
||||
"mac": frozenset({"rmii", "rgmii", "sgmii"}),
|
||||
"sgmii": frozenset({"sgmii"}),
|
||||
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||||
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
|
||||
@@ -143,6 +154,31 @@ def partner_net(name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# RMII/MII data and TX enable. Not an MDI pair. Not a 100 Ω differential net.
|
||||
_SINGLE_ENDED_ETH_RE = re.compile(
|
||||
r"(?:^|[^A-Za-z0-9])(?:ETH_|MAC_)?(?:RXD|TXD|TXEN|RXDV|CRS_DV)(?:\d+)?(?:[^A-Za-z0-9]|$)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def single_ended_eth_mac(net: str) -> bool:
|
||||
"""ETH_RXD / ETH_TXD / ETH_TXEN are single-ended. Not a differential pair."""
|
||||
leaf = _leaf(net)
|
||||
return bool(_SINGLE_ENDED_ETH_RE.search(leaf) or _SINGLE_ENDED_ETH_RE.search(net or ""))
|
||||
|
||||
|
||||
def phy_mac_kind(net: str) -> str | None:
|
||||
"""``rmii``, ``rgmii``, or ``mii`` for a PHY–MAC net. Never ``eth_mdi``."""
|
||||
leaf = _leaf(net).upper()
|
||||
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", leaf):
|
||||
return "rgmii"
|
||||
if single_ended_eth_mac(net) or "RMII" in leaf:
|
||||
if re.search(r"(?:^|[_/])MII(?:$|[_/])", leaf) and "RMII" not in leaf:
|
||||
return "mii"
|
||||
return "rmii"
|
||||
return None
|
||||
|
||||
|
||||
def _leaf(net: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(net)
|
||||
return n.split("/")[-1] if n else ""
|
||||
@@ -162,19 +198,29 @@ def skip_si_net(net: str) -> bool:
|
||||
return bool(_SKIP_RE.search(leaf))
|
||||
|
||||
|
||||
def is_mdio_mgmt(net: str) -> bool:
|
||||
"""ETH_MDIO / ETH_MDC / MDIO — management, not IEEE MDI cabling."""
|
||||
leaf = _leaf(net)
|
||||
return bool(_MDIO_MGMT_RE.search(leaf) or _MDIO_MGMT_RE.search(net or ""))
|
||||
|
||||
|
||||
def bus_class(net: str) -> str | None:
|
||||
if skip_si_net(net):
|
||||
return None
|
||||
leaf = _leaf(net)
|
||||
if _XTAL_RE.search(leaf):
|
||||
return None
|
||||
if is_mdio_mgmt(net):
|
||||
return None
|
||||
u = leaf.upper()
|
||||
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
|
||||
return "usb3"
|
||||
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
|
||||
return "usb2"
|
||||
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TX_CTL|RX_CTL|TXEN|RXDV|GTX)", u):
|
||||
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", u):
|
||||
return "rgmii"
|
||||
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TXEN|RXDV|CRS_DV|REF_CLK)|(?:^|[_/])RMII", u):
|
||||
return "rmii"
|
||||
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
|
||||
return "eth_mdi"
|
||||
for cls, cre in _HS_CLASS_RE:
|
||||
@@ -228,6 +274,7 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
|
||||
scans: tuple[tuple[str, str], ...] = (
|
||||
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
|
||||
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
|
||||
(r"\brmii\b|\bmii\b|txen|rxdv|crs_dv", "rmii"),
|
||||
(r"sgmii", "sgmii"),
|
||||
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
|
||||
(r"ddr3|\bddr\b", "ddr3"),
|
||||
@@ -358,6 +405,8 @@ def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]
|
||||
if targets:
|
||||
if not _bus_in_targets(bc, targets):
|
||||
continue
|
||||
if single_ended_eth_mac(net) and "eth_mdi" in targets:
|
||||
continue
|
||||
else:
|
||||
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
|
||||
if not pin:
|
||||
@@ -530,6 +579,8 @@ def check_si(
|
||||
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
|
||||
paired: set[tuple[str, str]] = set()
|
||||
for net in nets:
|
||||
if want_diff and single_ended_eth_mac(net):
|
||||
continue
|
||||
zrow = _z_row(rows, net)
|
||||
partner = _partner_on_board(layout, net, zrow) if want_diff else None
|
||||
key_net = _pair_key(net, partner) if partner else (net, "")
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -153,16 +154,15 @@ def si_extract_needed_skips(
|
||||
}
|
||||
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
|
||||
continue
|
||||
refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn
|
||||
out.append({
|
||||
"designator": refs,
|
||||
"reason": (
|
||||
refs = sorted(refs_by_mpn.get(mpn, [])) or [mpn]
|
||||
reason = (
|
||||
f"SI layout_rules empty at model_version="
|
||||
f"{cons.model_version}; re-run schematic review "
|
||||
f"to re-extract pintable {scan_ver} "
|
||||
"(PCB does not re-read the PDF)."
|
||||
),
|
||||
})
|
||||
)
|
||||
for ref in refs:
|
||||
out.append({"designator": ref, "reason": reason})
|
||||
return out
|
||||
|
||||
|
||||
@@ -220,6 +220,14 @@ async def run_pcb_pipeline(
|
||||
pcb_state=None,
|
||||
)
|
||||
|
||||
t_total = time.perf_counter()
|
||||
stage_seconds: dict[str, float] = {}
|
||||
|
||||
def _lap(name: str, started: float) -> float:
|
||||
seconds = round(time.perf_counter() - started, 3)
|
||||
stage_seconds[name] = seconds
|
||||
return seconds
|
||||
|
||||
try:
|
||||
async with PipelineWorkspace(storage, user_id, project_id) as ws:
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -247,7 +255,10 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "classify", "running", "domains and groups")
|
||||
t_extract = time.perf_counter()
|
||||
cmap = _load_constraints_map(ws.local_path("extracted"), storage)
|
||||
extract_s = _lap("extract", t_extract)
|
||||
_step(project_id, "extract", "complete", f"loaded stored extractions; {extract_s}s")
|
||||
plan = build_placement_plan(graph, cmap)
|
||||
fg_path = ws.local_path("functional_groups.json")
|
||||
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
|
||||
@@ -306,7 +317,9 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "checks", "running")
|
||||
t_checks = time.perf_counter()
|
||||
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
|
||||
checks_s = _lap("pcb_checks", t_checks)
|
||||
scan_ver = app_settings.get_default_model_version()
|
||||
si_skip = si_extract_needed_skips(graph, cmap, scan_ver)
|
||||
sipath = ws.local_path("si_extract_needed.json")
|
||||
@@ -315,7 +328,8 @@ async def run_pcb_pipeline(
|
||||
_step(
|
||||
project_id, "checks", "complete",
|
||||
f"{len(findings)} deterministic findings"
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else "")
|
||||
+ f"; {checks_s}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -323,6 +337,7 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
|
||||
t_af_trace = time.perf_counter()
|
||||
try:
|
||||
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
|
||||
findings.extend(extra_af)
|
||||
@@ -330,9 +345,10 @@ async def run_pcb_pipeline(
|
||||
except Exception:
|
||||
logger.exception("AF trace analysis failed — keeping PCB checks")
|
||||
n_trace = 0
|
||||
af_trace_s = _lap("af_trace", t_af_trace)
|
||||
_step(
|
||||
project_id, "af_trace", "complete",
|
||||
f"{n_trace} AF trace findings (SI/HF checks kept)",
|
||||
f"{n_trace} AF trace findings (SI/HF checks kept); {af_trace_s}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -340,10 +356,13 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
|
||||
t_af_ai = time.perf_counter()
|
||||
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
|
||||
af_ai_s = _lap("af_ai", t_af_ai)
|
||||
stage_seconds["af"] = round(stage_seconds.get("af_trace", 0) + af_ai_s, 3)
|
||||
_step(
|
||||
project_id, "af_ai", "complete",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept); {af_ai_s}s; af={stage_seconds['af']}s",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
@@ -353,6 +372,7 @@ async def run_pcb_pipeline(
|
||||
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
|
||||
coverage: dict[str, list[str]] = {}
|
||||
skipped: list[dict] = []
|
||||
review_errors: dict[str, str] = {}
|
||||
try:
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.pcb_validation import review_pcb_ics
|
||||
@@ -367,7 +387,7 @@ async def run_pcb_pipeline(
|
||||
f"{ref} {tool} {detail}".strip(),
|
||||
)
|
||||
|
||||
ai_findings, coverage, skipped = await review_pcb_ics(
|
||||
ai_findings, coverage, skipped, review_errors = await review_pcb_ics(
|
||||
graph, cmap, layout, plan, inventory,
|
||||
pdf_dir, storage=storage, api_logger=logger_api,
|
||||
on_progress=_prog,
|
||||
@@ -376,6 +396,7 @@ async def run_pcb_pipeline(
|
||||
logger_api.flush(storage, user_id, project_id)
|
||||
detail = (
|
||||
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
|
||||
+ (f", {len(review_errors)} review errors" if review_errors else "")
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("PCB AI review failed — keeping deterministic findings")
|
||||
@@ -387,11 +408,14 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "write_report", "running")
|
||||
t_protocol = time.perf_counter()
|
||||
proto_section, proto_findings = protocol_exam(
|
||||
graph, layout=layout,
|
||||
constraints_map=cmap, impedance_nets=zrep,
|
||||
channel_data=_load_channel_data(ws),
|
||||
)
|
||||
protocol_s = _lap("protocol", t_protocol)
|
||||
_step(project_id, "protocol", "complete", f"{protocol_s}s")
|
||||
findings.extend(proto_findings)
|
||||
assign_pcb_finding_ids(findings)
|
||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||
@@ -404,13 +428,20 @@ async def run_pcb_pipeline(
|
||||
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||
for f in findings:
|
||||
summary[f.status] = summary.get(f.status, 0) + 1
|
||||
# Drop not_reviewed entries for ICs that were actually reviewed.
|
||||
covered = set(coverage)
|
||||
not_reviewed = [
|
||||
row for row in (skipped + si_skip)
|
||||
if str(row.get("designator") or "") not in covered
|
||||
]
|
||||
report = ValidationReport(
|
||||
project=project_id,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
findings=findings,
|
||||
summary=summary,
|
||||
coverage=coverage,
|
||||
not_reviewed=skipped + si_skip,
|
||||
review_errors=review_errors,
|
||||
not_reviewed=not_reviewed,
|
||||
protocol_certification=proto_section.model_dump(),
|
||||
)
|
||||
report_path = ws.local_path("pcb_report.json")
|
||||
@@ -422,10 +453,22 @@ async def run_pcb_pipeline(
|
||||
ws._upload_file("periscope-findings.json")
|
||||
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
|
||||
|
||||
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
|
||||
_step(
|
||||
project_id, "timing", "complete",
|
||||
"extract={extract}s pcb_checks={pcb_checks}s protocol={protocol}s af={af}s total={total}s".format(
|
||||
extract=stage_seconds.get("extract"),
|
||||
pcb_checks=stage_seconds.get("pcb_checks"),
|
||||
protocol=stage_seconds.get("protocol"),
|
||||
af=stage_seconds.get("af"),
|
||||
total=stage_seconds.get("total"),
|
||||
),
|
||||
)
|
||||
_publish(project_id, "pcb_complete", {
|
||||
"findings": len(findings),
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"stage_seconds": stage_seconds,
|
||||
})
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
@@ -435,15 +478,17 @@ async def run_pcb_pipeline(
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"skipped": len(skipped),
|
||||
"stage_seconds": stage_seconds,
|
||||
},
|
||||
pcb_cancel_requested=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("pcb pipeline failed for %s", project_id)
|
||||
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_state={"error": str(e)},
|
||||
pcb_state={"error": str(e), "stage_seconds": stage_seconds},
|
||||
)
|
||||
_publish(project_id, "pcb_error", {"error": str(e)})
|
||||
|
||||
|
||||
@@ -54,15 +54,20 @@ async def review_pcb_ics(
|
||||
storage=None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
on_progress=None,
|
||||
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
|
||||
) -> tuple[list[Finding], dict[str, list[str]], list[dict], dict[str, str]]:
|
||||
"""Layout-only AI exam using ``library/extracted`` (or project extracted/).
|
||||
|
||||
Does not attach a datasheet PDF. ICs without a library pintable are skipped
|
||||
with a reason to run schematic review first.
|
||||
|
||||
Returns ``(findings, coverage, not_reviewed, review_errors)``.
|
||||
Real review exceptions go in ``review_errors`` — never as
|
||||
``not_reviewed`` with the blanket label ``pcb_review error``.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
coverage: dict[str, list[str]] = {}
|
||||
skipped: list[dict] = []
|
||||
review_errors: dict[str, str] = {}
|
||||
cache: dict = {}
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
@@ -102,15 +107,21 @@ async def review_pcb_ics(
|
||||
system_prompt=PCB_SYSTEM_PROMPT,
|
||||
log_stage="pcb_review",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("PCB AI review failed for %s — skipping", ref)
|
||||
skipped.append({"designator": ref, "reason": "pcb_review error"})
|
||||
except Exception as exc:
|
||||
msg = f"{type(exc).__name__}: {exc}"
|
||||
log.exception("PCB AI review failed for %s — recording review_errors", ref)
|
||||
review_errors[ref] = msg
|
||||
continue
|
||||
if not isinstance(result, ReviewResult):
|
||||
# Prefer a visible false positive over a silent miss.
|
||||
skipped.append({
|
||||
"designator": ref,
|
||||
"reason": "PCB review returned no result",
|
||||
})
|
||||
continue
|
||||
_ensure_recs(result.findings)
|
||||
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
|
||||
findings.extend(result.findings)
|
||||
if result.checked_areas:
|
||||
coverage[ref] = list(result.checked_areas)
|
||||
return findings, coverage, skipped
|
||||
# Empty checked_areas still means the IC was reviewed (no issues).
|
||||
coverage[ref] = list(result.checked_areas or ["layout"])
|
||||
return findings, coverage, skipped, review_errors
|
||||
|
||||
@@ -2,10 +2,86 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — USB-C vSafe still UNKNOWN (no USB-IF PD on file)
|
||||
## 2.87.3 — 2026-09-23 — HubAudio AF verify + re-run cue
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe. HDMI 1.4 TMDS stays UNKNOWN.
|
||||
Live already carried the 2.87.2 PE-AF-002 cleanup, but HubAudio's last PCB exam was still stamped **2.87.0** — so Michele still saw duplicate RD/TD “speed not identified”, ETH_MDIO 100 Ω, Italian RMII/USB tr/f. Replay of the saved HubAudio design/layout graphs confirms the fix. Soft-bump so the report version moves; **re-run HubAudio PCB exam** on live.
|
||||
|
||||
- [Changed] Regression: USB_D+ skips PE-AF-002; protocol owns the English 90 Ω cert line.
|
||||
- [Changed] Changelog cue: HubAudio must be re-examined after AF noise fixes.
|
||||
|
||||
## 2.87.2 — 2026-09-23 — HubAudio AF noise: MDIO≠MDI, RD≡RX, USB/RMII cert
|
||||
|
||||
HubAudio PE-AF-002 cleanup on live 2.87.x. Ethernet MDI aliases (`RD+/−` ≡ `RXP/RXN`, `TD+/−` ≡ `TXP/TXN`) are one cert per pair; LAN8720A supplies 10/100 when the magnetics-side nets lack the PHY MPN. `ETH_MDIO` / `MDC` are management — not IEEE 100BASE-TX 25.4.9. USB D+/D− leave the AF tr/f skip (protocol 90 Ω cert owns them). RMII stays on the AN-133 PE-AF-003 English line.
|
||||
|
||||
- [Changed] `si_check.py`: `is_mdio_mgmt`; `MDI(?!O)` so MDIO is not eth_mdi.
|
||||
- [Changed] `af_trace_check.py`: MDI channel dedupe; graph-wide PHY speed; USB skips PE-AF-002.
|
||||
- [Changed] pytest `test_hubaudio_af_noise.py`; AF geometry tests use HDMI (USB is cert-owned).
|
||||
|
||||
## 2.87.1 — 2026-09-23 — Remove coffee/email progress notice
|
||||
|
||||
Drop the running-pipeline “grab a coffee / we’ll email you” banner. Progress shows a neutral “Pipeline running…” status instead.
|
||||
|
||||
- [Removed] Coffee icon + email promise on project progress while a run is active.
|
||||
|
||||
## 2.87.0 — 2026-09-23 — Live integrate: iPad top-bar, MAC–PHY pack, USB/RMII cert
|
||||
|
||||
Combines the iPad top-bar shell (light default, Save as Markdown), the MAC–PHY bus catalog pack (Micrel AN-133, OPEN Alliance RGMII EPL V2.2, Cisco SGMII ENG-46158), USB 2.0 D+/D− cert lines versus the packed 90 Ω cite, RMII not-certified-on-recognition, and the `not_reviewed` count fix. One coherent live version above 2.86.0.
|
||||
|
||||
- [Changed] App shell: `TopBar` instead of sidebar; theme default `light`; Markdown report export.
|
||||
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
|
||||
- [Changed] PE-AF-003 / USB / Ethernet cert lines; J2 silence on missing L1/L2/L3 fields.
|
||||
- [Changed] `not_reviewed` one designator per row; AI failures → `review_errors`.
|
||||
- [Changed] pytest mac-phy / USB / RMII / not_reviewed; frontend shell + export tests.
|
||||
- [Changed] Root `README.md` (+ `periscope/README.md`): product copy for 2.87.0 — PCB analysis, protocol cert, findings tree, top-bar UI, Markdown export; Gitea as source of truth.
|
||||
|
||||
## 2.86.0 — 2026-09-23 — MAC–PHY bus pack (RMII/MII/RGMII/SGMII)
|
||||
|
||||
Packed Micrel AN-133, OPEN Alliance RGMII EPL V2.2, and Cisco SGMII ENG-46158 into the protocol catalog. Only explicitly stated NUMERIC fields with provenance. PCB Z0 / length stay UNKNOWN where the PDF does not state them.
|
||||
|
||||
PE-AF-003 no longer certifies on recognition alone. HubAudio-style RMII (LAN8720A ETH_RXD/TXD/TXEN) reports `RMII not certified — because …` with the AN-133 cite when PCB impedance is UNKNOWN. SGMII uses the Cisco 100 Ω Rload / Rin 80–120 Ω cite when geometry allows a pair Z. RJ45/MDI 100 Ω stays separate.
|
||||
|
||||
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
|
||||
- [Changed] `af_trace_check.py` PE-AF-003 cert lines.
|
||||
- [Changed] `standards/protocol-specs/INDEX.md` (+ on-disk PDFs, gitignored).
|
||||
- [Changed] pytest `test_mac_phy_pack.py`, `test_rmii_cert_line.py`.
|
||||
|
||||
## 2.85.0 — 2026-09-23 — USB / RMII cert lines; not_reviewed count
|
||||
|
||||
USB 2.0 D+/D− is one line: calculated pair Z versus the packed 90 Ω board cite. Certified names the measured ohm and the cite. Not certified says why — Z is outside that cite, or Z cannot be calculated from the PCB stackup/geometry. Missing length, stub, via, termination, rise/fall, and channel fields do not each warn. L3 is omitted when no channel fact is on file.
|
||||
|
||||
LAN8720A ETH_RXD / ETH_TXD / ETH_TXEN are RMII (single-ended PHY–MAC), not RJ45/MDI 100 Ω. Recognition alone is not certification (fixed in 2.86.0). RJ45 pairs keep `Ethernet certified / not certified`.
|
||||
|
||||
`not_reviewed` count matches the list (one designator per row). Real PCB AI failures go to `review_errors` with the exception message — not a blanket `pcb_review error` on every skip. ICs already in coverage are not listed as not-reviewed.
|
||||
|
||||
- [Changed] `protocol_l1.py`, `protocol_l2.py`, `protocol_l3.py`, `protocol_report.py`, `af_trace_check.py`, `si_check.py`.
|
||||
- [Changed] `pcb_validation.py`, `pcb_pipeline.py`, `pcb_checks.py` merge; report UI copy.
|
||||
- [Changed] Protocol tree shows the certification sentence.
|
||||
- [Changed] pytest `tests/pcb/test_j2_usb_cert_line.py`, `test_rmii_cert_line.py`, `test_not_reviewed_count.py`.
|
||||
|
||||
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
|
||||
|
||||
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
|
||||
|
||||
PE-ESD-001 says "datasheet-specified" only with a part and section/page. A 3V3 jack rail and VBUS are not warned without that cite. Optical S/PDIF (TOSLINK/optocoupler) is not an ESD warning; an unverified S/PDIF path stays a visible warning.
|
||||
|
||||
PE-PRT-L0-001 follows Type-C CC pins (A5/B5) to the net and looks for the packed CabCon Rd 5.1 kΩ. The net does not have to be named CC1/CC2.
|
||||
|
||||
PE-AF-002 on an Ethernet MDI pair compares one calculated pair Z to the packed cite for the speed on the board (LAN8720A 10/100 → 100 Ω, IEEE 100BASE-TX 25.4.9; Clause 40 only if gigabit). Inside is PASS, outside is FAIL, and no calculation is ERROR. No tr/f request.
|
||||
|
||||
- [Changed] `bom_pcb_check.py`, `esd_return_check.py`, `protocol_l0.py`, `af_trace_check.py`.
|
||||
- [Changed] pytest `tests/pcb/test_bom_esd_ep_cc_eth.py`.
|
||||
- [Changed] Findings tree labels are English: domains, Certifications, Protocols, actions, and empty states. Error / Warning / Info stay.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
|
||||
|
||||
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe.
|
||||
|
||||
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
|
||||
- [Changed] Findings tree grouping + protocol section inside the tree.
|
||||
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
|
||||
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
|
||||
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
|
||||
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.83.0",
|
||||
"version": "2.87.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
|
||||
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts src/lib/report-export.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
|
||||
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCredits } from "@/components/billing/credits-context";
|
||||
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
|
||||
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
|
||||
import { ProjectCard } from "@/components/dashboard/project-card";
|
||||
import { ProjectsTable } from "@/components/dashboard/projects-table";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -23,13 +21,10 @@ import {
|
||||
fetchProjects,
|
||||
reconcileCheckoutSession,
|
||||
} from "@/lib/api";
|
||||
import type { CreditSnapshot, Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project } from "@/lib/types";
|
||||
|
||||
type LayoutKind = "cards" | "table";
|
||||
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
|
||||
|
||||
const LAYOUT_KEY = "periscopex:dashboard:view";
|
||||
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -62,28 +57,9 @@ function DashboardBody() {
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
|
||||
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<LayoutKind>("cards");
|
||||
|
||||
const ordered = useMemo(() => newestFirst(projects), [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAYOUT_KEY);
|
||||
if (stored === "cards" || stored === "table") setLayout(stored);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, []);
|
||||
|
||||
function persistLayout(next: LayoutKind) {
|
||||
setLayout(next);
|
||||
try {
|
||||
localStorage.setItem(LAYOUT_KEY, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function stripCheckoutQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("topup");
|
||||
@@ -208,7 +184,7 @@ function DashboardBody() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
|
||||
<OnboardingSurvey />
|
||||
{bannerVisible && topupPhase !== "dismissed" && (
|
||||
<TopupBanner
|
||||
@@ -217,11 +193,10 @@ function DashboardBody() {
|
||||
onDismiss={hideBanner}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Projects</h1>
|
||||
<CreditsLine credits={credits} />
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Progetti"
|
||||
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
|
||||
actions={
|
||||
<CreateProjectDialog
|
||||
rerunProject={rerunProject}
|
||||
onRerunDone={() => setRerunProject(null)}
|
||||
@@ -229,104 +204,33 @@ function DashboardBody() {
|
||||
onCloneAsNewDone={() => setCloneAsNewProject(null)}
|
||||
onCreateProject={upsertProject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!loading && projects.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 mb-4">
|
||||
<div className="inline-flex rounded-lg border border-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistLayout("cards")}
|
||||
aria-pressed={layout === "cards"}
|
||||
title="Card view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
layout === "cards"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => persistLayout("table")}
|
||||
aria-pressed={layout === "table"}
|
||||
title="Table view"
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors",
|
||||
layout === "table"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 rounded-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No projects yet. Create one to get started.
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Crea un progetto e carica netlist, BOM e PCB. L’esame parte da lì.
|
||||
</p>
|
||||
) : layout === "table" ? (
|
||||
</div>
|
||||
) : (
|
||||
<ProjectsTable
|
||||
projects={ordered}
|
||||
onDeleted={dropProject}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{ordered.map((p) => (
|
||||
<ProjectCard
|
||||
key={p.id}
|
||||
project={p}
|
||||
onDeleted={() => dropProject(p.id)}
|
||||
onRerun={setRerunProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
|
||||
if (!credits) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Schematic validation projects
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{credits.balance.toFixed(2)} credits
|
||||
{" · "}
|
||||
<Link
|
||||
href="/billing"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
buy more
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link
|
||||
href="/credits"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
ledger
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function TopupBanner({
|
||||
phase,
|
||||
detail,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { CreditsProvider } from "@/components/billing/credits-context";
|
||||
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
|
||||
import { AuthGate } from "@/components/layout/auth-gate";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
export default function AppShellLayout({
|
||||
@@ -12,9 +12,9 @@ export default function AppShellLayout({
|
||||
<TooltipProvider>
|
||||
<CreditsProvider>
|
||||
<AuthGate>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-auto">
|
||||
<div className="flex h-full flex-col">
|
||||
<TopBar />
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { CollaboratorsSection } from "@/components/project/collaborators-section
|
||||
import { SkippedComponentsSection } from "@/components/project/skipped-components-section";
|
||||
import { ReportVersionSection } from "@/components/project/report-version-section";
|
||||
import { PipelineErrorBanner } from "@/components/project/pipeline-error-banner";
|
||||
import { AppearanceSettings } from "@/components/theme/appearance-settings";
|
||||
import {
|
||||
deratingOverridesKey,
|
||||
deratingSettingsKey,
|
||||
@@ -518,6 +519,9 @@ export default function ProjectDetailPage({
|
||||
|
||||
{tab === "settings" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[11px] border border-black/10 bg-white px-5 py-4 dark:border-white/10 dark:bg-[#2c2c2e]">
|
||||
<AppearanceSettings />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">KiCad PCB</CardTitle>
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coffee,
|
||||
Coins,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
@@ -237,21 +236,21 @@ export default function ProgressPage({
|
||||
)}
|
||||
|
||||
{isRunning && !isQueued && (
|
||||
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
|
||||
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
|
||||
<div className="relative shrink-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
|
||||
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
|
||||
/>
|
||||
<Coffee
|
||||
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
|
||||
<Loader2
|
||||
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
|
||||
strokeWidth={2.25}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-medium text-foreground">Feel free to grab a coffee</p>
|
||||
<p className="text-base font-medium text-foreground">Pipeline running…</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
You can close this tab — we'll email you when it's ready.
|
||||
Stages update below as the run proceeds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
||||
import { ReportSummary } from "@/components/report/report-summary";
|
||||
import { FindingsList } from "@/components/report/findings-list";
|
||||
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
||||
import { ProtocolSection } from "@/components/report/protocol-section";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toast, useToast } from "@/components/ui/toast";
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
signReport,
|
||||
downloadEcoCsv,
|
||||
} from "@/lib/api";
|
||||
import { exportReportToExcel } from "@/lib/report-export";
|
||||
import { exportReportToMarkdown } from "@/lib/report-export";
|
||||
import { cn, getFindingKey } from "@/lib/utils";
|
||||
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
|
||||
|
||||
@@ -240,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
|
||||
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
|
||||
>
|
||||
<div className={cn("space-y-6", focus && "hidden")}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Validation Report</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.findings.length} findings ·{" "}
|
||||
{new Date(report.timestamp).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("space-y-5", focus && "hidden")}>
|
||||
<PageHeader
|
||||
title={projectName || "Esame"}
|
||||
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -264,10 +260,13 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportReportToExcel(report, graph, projectName)}
|
||||
disabled={report.findings.length === 0}
|
||||
onClick={() => exportReportToMarkdown(report, graph, projectName)}
|
||||
disabled={
|
||||
report.findings.length === 0 &&
|
||||
!(report.protocol_certification?.recognized_instances?.length)
|
||||
}
|
||||
>
|
||||
<Download /> Export Excel
|
||||
<Download /> Save as Markdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -297,14 +296,14 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
Sign release
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ReportSummary
|
||||
summary={report.summary}
|
||||
reviewedCount={reviewedCount}
|
||||
creditsSpent={creditsSpent}
|
||||
totalCostUsd={totalCostUsd}
|
||||
/>
|
||||
<ProtocolSection section={report.protocol_certification} />
|
||||
{failedReviews > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
@@ -332,7 +331,10 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here — verify these manually.
|
||||
Each line is one designator. The reason is per part — missing
|
||||
datasheet, missing library extraction, or SI re-extract needed.
|
||||
Parts that were reviewed are not listed here. Review failures
|
||||
(with an error message) appear under failed reviews above.
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{report.not_reviewed.map((nr) => (
|
||||
@@ -344,12 +346,12 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{report.summary.total === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
|
||||
<p className="text-sm font-medium">No findings</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No IC datasheets were available for review. Upload datasheets for each IC
|
||||
on the project page and re-run the pipeline to get findings.
|
||||
{report.summary.total === 0 &&
|
||||
!(report.protocol_certification?.recognized_instances?.length) ? (
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
|
||||
<p className="text-[15px] font-medium tracking-tight">No findings</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Upload the IC datasheets on the project page and run the exam again.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -370,6 +372,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
protocolSection={report.protocol_certification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
system-ui, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-headline: var(--font-dm-serif-display), Georgia, serif;
|
||||
--font-headline: var(--font-sans);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -53,15 +54,15 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--radius: 0.6875rem;
|
||||
--background: #f5f5f7;
|
||||
--foreground: #1d1d1f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1d1d1f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1d1d1f;
|
||||
--primary: #007aff;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
@@ -69,22 +70,22 @@
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--input: rgba(0, 0, 0, 0.1);
|
||||
--ring: #007aff;
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: #f5f5f7;
|
||||
--sidebar-foreground: #1d1d1f;
|
||||
--sidebar-primary: #007aff;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: rgba(0, 122, 255, 0.12);
|
||||
--sidebar-accent-foreground: #007aff;
|
||||
--sidebar-border: rgba(0, 0, 0, 0.1);
|
||||
--sidebar-ring: #007aff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -129,7 +130,8 @@
|
||||
@apply font-sans;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
|
||||
@@ -13,22 +12,6 @@ import {
|
||||
TWITTER_HANDLE,
|
||||
} from "@/lib/site";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const dmSerifDisplay = DM_Serif_Display({
|
||||
variable: "--font-dm-serif-display",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
@@ -109,7 +92,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
|
||||
className="h-full antialiased"
|
||||
>
|
||||
<body className="h-full">
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
meta,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
meta?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
|
||||
{title}
|
||||
</h1>
|
||||
{meta ? (
|
||||
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function SidebarCredits() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SidebarUserButton() {
|
||||
export function SidebarUserButton({ compact = false }: { compact?: boolean }) {
|
||||
const { user, isLoaded } = useOptionalUser();
|
||||
const { signOut, isSignedIn } = useOptionalAuth();
|
||||
|
||||
@@ -26,6 +26,16 @@ export function SidebarUserButton() {
|
||||
}
|
||||
|
||||
if (!isSignedIn || !user) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "h-7 px-2 text-[11px]")}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<Link
|
||||
@@ -44,6 +54,22 @@ export function SidebarUserButton() {
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={user.email || user.name || "Account"}
|
||||
className="max-w-[120px] truncate rounded-[8px] px-2 py-1 text-[11px] text-neutral-600 hover:bg-black/[0.04] dark:text-neutral-300 dark:hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
signOut?.();
|
||||
window.location.href = "/sign-in";
|
||||
}}
|
||||
>
|
||||
{user.name || user.email}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-2 py-1">
|
||||
<div className="truncate text-xs font-medium">{user.name || user.email}</div>
|
||||
|
||||
@@ -1,294 +1,2 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
CircuitBoard,
|
||||
ClipboardList,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Loader2,
|
||||
MessageSquareWarning,
|
||||
Ruler,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
TableProperties,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { fetchProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function useProjectFromPath(pathname: string): {
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
} {
|
||||
const match = pathname.match(/^\/project\/([^/]+)/);
|
||||
const projectId = match ? match[1] : null;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => setProject(null));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || project?.status !== "running") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => {
|
||||
/* keep last known project */
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [projectId, project?.status]);
|
||||
|
||||
return { projectId, project };
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useOptionalUser();
|
||||
useAuthApi();
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const { projectId, project } = useProjectFromPath(pathname);
|
||||
const isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<PeriscopeMark className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold tracking-tight">Periscope</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
{projectId ? (
|
||||
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
|
||||
<ProjectNav
|
||||
pathname={pathname}
|
||||
projectId={projectId}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<DefaultNav pathname={pathname} isAdmin={isAdmin} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<SidebarCredits />
|
||||
<div className="px-2 py-1.5 border-b border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2">
|
||||
<SidebarUserButton />
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
<ThemeToggle className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function navItemClass(active: boolean) {
|
||||
return cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<Link href="/library" className={navItemClass(pathname === "/library")}>
|
||||
<Library className="h-4 w-4" />
|
||||
Library
|
||||
</Link>
|
||||
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
|
||||
<MessageSquareWarning className="h-4 w-4" />
|
||||
My Feedback
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type NavItem =
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
|
||||
|
||||
const PROJECT_NAV_ITEMS: NavItem[] = [
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
|
||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
|
||||
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
||||
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
|
||||
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
|
||||
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
|
||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
forceDocument,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
forceDocument?: boolean;
|
||||
}) {
|
||||
const className = navItemClass(Boolean(active));
|
||||
if (forceDocument) {
|
||||
return (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectNav({
|
||||
pathname,
|
||||
projectId,
|
||||
project,
|
||||
isAdmin,
|
||||
}: {
|
||||
pathname: string;
|
||||
projectId: string;
|
||||
project: Project | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const base = `/project/${projectId}`;
|
||||
const currentTab = searchParams.get("tab");
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
if (item.tab === "bom") {
|
||||
return pathname === base && (currentTab === "bom" || currentTab === null);
|
||||
}
|
||||
return pathname === base && currentTab === item.tab;
|
||||
}
|
||||
|
||||
function getHref(item: NavItem): string {
|
||||
if (item.type === "route") return `${base}${item.path}`;
|
||||
return `${base}?tab=${item.tab}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex-1 px-2 py-3 space-y-1">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink href="/library" forceDocument={onNestedRoute}>
|
||||
<Library className="h-4 w-4" />
|
||||
Library
|
||||
</NavLink>
|
||||
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<p className="text-xs font-semibold text-foreground truncate">
|
||||
{project?.name ?? "Loading..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing
|
||||
</NavLink>
|
||||
)}
|
||||
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
|
||||
const active = isActive(item);
|
||||
const href = getHref(item);
|
||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||
if (isRunning) {
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
{item.adminOnly && (
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
{item.adminOnly && (
|
||||
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
/** @deprecated Left sidebar removed — iPad top bar is the shell. Kept for import stability. */
|
||||
export { TopBar as Sidebar } from "./top-bar";
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Folder,
|
||||
Library,
|
||||
Loader2,
|
||||
Menu,
|
||||
MessageSquareWarning,
|
||||
ScanLine,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { PeriscopeMark } from "@/components/brand/periscope-mark";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
|
||||
import { AppearanceSettings } from "@/components/theme/appearance-settings";
|
||||
import { useAuthApi } from "@/hooks/use-auth-api";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { fetchProject } from "@/lib/api";
|
||||
import type { Project } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
function useProjectFromPath(pathname: string): {
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
} {
|
||||
const match = pathname.match(/^\/project\/([^/]+)/);
|
||||
const projectId = match ? match[1] : null;
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
return;
|
||||
}
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => setProject(null));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || project?.status !== "running") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchProject(projectId)
|
||||
.then(setProject)
|
||||
.catch(() => {
|
||||
/* keep last known project */
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [projectId, project?.status]);
|
||||
|
||||
return { projectId, project };
|
||||
}
|
||||
|
||||
type NavItem =
|
||||
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
|
||||
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
|
||||
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ type: "tab", tab: "bom", label: "Exam", icon: ScanLine },
|
||||
{ type: "route", path: "/report", label: "Protocols", icon: BookOpen },
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
];
|
||||
|
||||
const SECONDARY_NAV: NavItem[] = [
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
|
||||
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
function tabClass(active: boolean) {
|
||||
return cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-[#007aff]/12 text-[#007aff]"
|
||||
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
forceDocument,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
forceDocument?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const cls = className ?? tabClass(Boolean(active));
|
||||
if (forceDocument) {
|
||||
return (
|
||||
<a href={href} className={cls}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={cls}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin iPad-style top toolbar. Replaces the left sidebar column. */
|
||||
export function TopBar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useOptionalUser();
|
||||
useAuthApi();
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const { projectId, project } = useProjectFromPath(pathname);
|
||||
const isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 flex h-11 shrink-0 items-center gap-2 border-b border-black/10 bg-[#f5f5f7]/90 px-2 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90 sm:px-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex shrink-0 items-center gap-1.5 pr-1"
|
||||
aria-label="Periscope home"
|
||||
>
|
||||
<PeriscopeMark className="h-5 w-5" />
|
||||
<span className="hidden text-[13px] font-semibold tracking-tight sm:inline">
|
||||
Periscope
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
{projectId ? (
|
||||
<Suspense fallback={<div className="h-7" aria-hidden />}>
|
||||
<ProjectTabs
|
||||
pathname={pathname}
|
||||
projectId={projectId}
|
||||
project={project}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<DefaultTabs pathname={pathname} isAdmin={isAdmin} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<SidebarCredits />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackOpen(true)}
|
||||
className="hidden items-center gap-1 rounded-[8px] px-2 py-1 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900 sm:inline-flex dark:hover:bg-white/[0.06]"
|
||||
aria-label="Feedback"
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
<span className="hidden md:inline">Feedback</span>
|
||||
</button>
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="hidden text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900 sm:inline dark:hover:text-white"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={tabClass(false)}
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
<span className="hidden lg:inline">Settings</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={cn(tabClass(false), "sm:hidden")}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="hidden sm:block">
|
||||
<SidebarUserButton compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Settings</SheetTitle>
|
||||
<SheetDescription>Appearance and account.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="space-y-6 px-4 pb-6">
|
||||
<AppearanceSettings />
|
||||
<div className="sm:hidden">
|
||||
<SidebarUserButton />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[12px] text-neutral-500">
|
||||
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-white">
|
||||
Changelog
|
||||
</Link>
|
||||
<span className="tabular-nums">v{APP_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetContent side="top" className="max-h-[70vh]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription>Navigate Periscope.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6" onClick={() => setMenuOpen(false)}>
|
||||
<NavLink href="/dashboard" className={tabClass(pathname === "/dashboard")}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink href="/library" className={tabClass(pathname === "/library")}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Library
|
||||
</NavLink>
|
||||
<NavLink href="/feedback" className={tabClass(pathname === "/feedback")}>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
className={tabClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
{projectId ? (
|
||||
<>
|
||||
<p className="truncate px-2.5 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
|
||||
{project?.name ?? "…"}
|
||||
</p>
|
||||
<NavLink href={`/project/${projectId}?tab=bom`}>Exam</NavLink>
|
||||
<NavLink href={`/project/${projectId}/report`}>Report</NavLink>
|
||||
<NavLink href={`/project/${projectId}?tab=settings`}>Project settings</NavLink>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultTabs({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-0.5" aria-label="Main">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<NavLink href="/library" active={pathname === "/library"}>
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
Library
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href="/feedback"
|
||||
active={pathname === "/feedback"}
|
||||
className={cn(tabClass(pathname === "/feedback"), "hidden md:inline-flex")}
|
||||
>
|
||||
<MessageSquareWarning className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
active={pathname === "/admin" || pathname.startsWith("/admin/")}
|
||||
className={cn(
|
||||
tabClass(pathname === "/admin" || pathname.startsWith("/admin/")),
|
||||
"hidden lg:inline-flex",
|
||||
)}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTabs({
|
||||
pathname,
|
||||
projectId,
|
||||
project,
|
||||
isAdmin,
|
||||
}: {
|
||||
pathname: string;
|
||||
projectId: string;
|
||||
project: Project | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const base = `/project/${projectId}`;
|
||||
const currentTab = searchParams.get("tab");
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
const onReport = pathname === `${base}/report`;
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.label === "Protocols") return onReport;
|
||||
if (item.label === "Report") return onReport;
|
||||
if (item.type === "route") {
|
||||
return pathname === `${base}${item.path}` && !currentTab;
|
||||
}
|
||||
if (item.tab === "bom") {
|
||||
return pathname === base && (currentTab === "bom" || currentTab === null);
|
||||
}
|
||||
return pathname === base && currentTab === item.tab;
|
||||
}
|
||||
|
||||
function getHref(item: NavItem): string {
|
||||
if (item.label === "Protocols") return `${base}/report`;
|
||||
if (item.type === "route") return `${base}${item.path}`;
|
||||
return `${base}?tab=${item.tab}`;
|
||||
}
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const active = isActive(item);
|
||||
const href = getHref(item);
|
||||
const forceDocument = item.type === "tab" && onNestedRoute;
|
||||
if (isRunning) {
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] text-neutral-400"
|
||||
>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-0.5" aria-label="Project">
|
||||
<NavLink href="/dashboard" forceDocument={onNestedRoute} className={cn(tabClass(false), "hidden sm:inline-flex")}>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</NavLink>
|
||||
<span className="hidden max-w-[140px] truncate px-1 text-[11px] font-medium text-neutral-500 md:inline">
|
||||
{project?.name ?? "…"}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<NavLink href={`${base}/progress`} active={isOnProgress}>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Running
|
||||
</NavLink>
|
||||
)}
|
||||
{PRIMARY_NAV.map(renderItem)}
|
||||
<span className="mx-0.5 hidden h-4 w-px bg-black/10 sm:inline dark:bg-white/10" aria-hidden />
|
||||
{SECONDARY_NAV.map(renderItem)}
|
||||
{isAdmin ? (
|
||||
<NavLink
|
||||
href={`${base}?tab=logs`}
|
||||
forceDocument={onNestedRoute}
|
||||
className={cn(tabClass(pathname === base && currentTab === "logs"), "hidden xl:inline-flex")}
|
||||
>
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
Log
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
FindingStatus,
|
||||
DesignGraph,
|
||||
Collaborator,
|
||||
ProtocolCertificationSection,
|
||||
} from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
import { isLayoutFinding } from "@/lib/layout-finding";
|
||||
@@ -33,6 +34,7 @@ interface FindingsListProps {
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
protocolSection?: ProtocolCertificationSection | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
@@ -54,6 +56,7 @@ export function FindingsList({
|
||||
reportedFindingIds,
|
||||
reviews,
|
||||
onReviewSaved,
|
||||
protocolSection,
|
||||
}: FindingsListProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -161,8 +164,11 @@ export function FindingsList({
|
||||
reportedFindingIds,
|
||||
reviews,
|
||||
onReviewSaved,
|
||||
protocolSection,
|
||||
};
|
||||
|
||||
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReportFilters
|
||||
@@ -180,7 +186,7 @@ export function FindingsList({
|
||||
onDomainChange={(v) => updateParams({ domain: v })}
|
||||
designators={designators}
|
||||
/>
|
||||
{filtered.length > 0 ? (
|
||||
{showTree ? (
|
||||
<FindingsTree findings={filtered} {...treeProps} />
|
||||
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
||||
<FindingsTreeEmpty />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import { ProtocolInstanceBody } from "./protocol-section";
|
||||
import type {
|
||||
Finding,
|
||||
FindingComment,
|
||||
@@ -12,10 +10,22 @@ import type {
|
||||
Collaborator,
|
||||
Component,
|
||||
DesignGraph,
|
||||
FindingStatus,
|
||||
ProtocolCertificationSection,
|
||||
} from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
|
||||
import {
|
||||
STATUS_TINT,
|
||||
forestHasCertifications,
|
||||
groupByWorstStatus,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
protocolInstancesForStatus,
|
||||
worstStatus,
|
||||
type DomainGroup,
|
||||
type ProtocolTreeInstance,
|
||||
} from "@/lib/findings-forest";
|
||||
|
||||
interface FindingsTreeProps {
|
||||
findings: Finding[];
|
||||
@@ -35,6 +45,9 @@ interface FindingsTreeProps {
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
protocolSection?: ProtocolCertificationSection | null;
|
||||
selectedKey?: string | null;
|
||||
onSelect?: (key: string) => void;
|
||||
}
|
||||
|
||||
function pinNetLabel(f: Finding): string {
|
||||
@@ -46,73 +59,126 @@ function pinNetLabel(f: Finding): string {
|
||||
return f.rule_id || "Finding";
|
||||
}
|
||||
|
||||
function DisclosureMark({ open }: { open: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
|
||||
"border border-black/20 text-[9px] leading-none text-neutral-600",
|
||||
"dark:border-white/25 dark:text-neutral-300",
|
||||
)}
|
||||
>
|
||||
{open ? "−" : "+"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeBranch({
|
||||
label,
|
||||
extra,
|
||||
findings,
|
||||
children,
|
||||
tint,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
label: string;
|
||||
extra?: string;
|
||||
findings: Finding[];
|
||||
findings: { status: FindingStatus }[];
|
||||
children: ReactNode;
|
||||
tint?: string;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const worst = worstStatus(findings);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
|
||||
>
|
||||
<ChevronRight
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
|
||||
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
|
||||
tint,
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm truncate">{label}</span>
|
||||
>
|
||||
<DisclosureMark open={open} />
|
||||
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
|
||||
{extra ? (
|
||||
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<StatusBadge status={worst} />
|
||||
<span className="text-xs text-muted-foreground">{findings.length}</span>
|
||||
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
|
||||
{extra}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
|
||||
{findings.length}
|
||||
</span>
|
||||
<span className="sr-only">{worst}</span>
|
||||
</button>
|
||||
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTree(props: FindingsTreeProps) {
|
||||
const sorted = sortFindings(props.findings);
|
||||
const af = sorted.filter(isAfAiFinding);
|
||||
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
||||
const schematic = sorted.filter(
|
||||
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
||||
);
|
||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
|
||||
const protocolFindings = sorted.filter(isProtocolFinding);
|
||||
const rest = sorted.filter((f) => !isProtocolFinding(f));
|
||||
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
|
||||
const instances = protocolInstancesFromSection(props.protocolSection);
|
||||
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
|
||||
const showRoots = roots.length > 1;
|
||||
const merged = order
|
||||
.map((status) => {
|
||||
const folder = folders.find((g) => g.status === status);
|
||||
const cert = forestHasCertifications(instances, protocolFindings, status);
|
||||
if (!folder && !cert) return null;
|
||||
return { status, folder, cert };
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
status: FindingStatus;
|
||||
folder: ReturnType<typeof groupByWorstStatus>[number] | undefined;
|
||||
cert: boolean;
|
||||
}[];
|
||||
|
||||
if (merged.length === 0 && instances.length === 0) {
|
||||
return <FindingsTreeEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
|
||||
{roots.map((root) => {
|
||||
const body = (
|
||||
<DesignatorForest
|
||||
<nav
|
||||
aria-label="Findings tree"
|
||||
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
|
||||
>
|
||||
{merged.map(({ status, folder, cert }, index) => {
|
||||
const label =
|
||||
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
|
||||
const findings = [
|
||||
...(folder?.findings ?? []),
|
||||
...protocolFindings.filter((f) => f.status === status),
|
||||
];
|
||||
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
|
||||
const inst = protocolInstancesForStatus(instances, status);
|
||||
const count = Math.max(findings.length, inst.length);
|
||||
return (
|
||||
<TreeBranch
|
||||
key={status}
|
||||
label={label}
|
||||
findings={count ? findings : [{ status }]}
|
||||
tint={STATUS_TINT[status]}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
{(folder?.domains ?? []).map((domain) => (
|
||||
<DomainBranch key={domain.id} domain={domain} {...props} />
|
||||
))}
|
||||
{cert ? (
|
||||
<CertificationsBranch
|
||||
{...props}
|
||||
findings={root.items}
|
||||
status={status}
|
||||
instances={inst}
|
||||
findings={protocolAtStatus}
|
||||
/>
|
||||
);
|
||||
if (!showRoots) return <div key={root.id}>{body}</div>;
|
||||
return (
|
||||
<TreeBranch key={root.id} label={root.label} findings={root.items}>
|
||||
{body}
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
);
|
||||
})}
|
||||
@@ -120,25 +186,18 @@ export function FindingsTree(props: FindingsTreeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function DesignatorForest({
|
||||
findings,
|
||||
graph,
|
||||
...cardProps
|
||||
}: FindingsTreeProps) {
|
||||
const folders = groupByWorstStatus(findings);
|
||||
|
||||
function DomainBranch({
|
||||
domain,
|
||||
...props
|
||||
}: { domain: DomainGroup } & FindingsTreeProps) {
|
||||
return (
|
||||
<div>
|
||||
{folders.map((folder) => (
|
||||
<TreeBranch
|
||||
key={folder.status}
|
||||
label={folder.label}
|
||||
findings={folder.findings}
|
||||
>
|
||||
{folder.designators.map((row) => {
|
||||
<TreeBranch label={domain.label} findings={domain.findings}>
|
||||
{domain.rules.map((rule) => (
|
||||
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
|
||||
{rule.designators.map((row) => {
|
||||
const d = row.designator;
|
||||
const group = row.findings;
|
||||
const component: Component | undefined = graph.components[d];
|
||||
const component: Component | undefined = props.graph.components[d];
|
||||
const extra = [
|
||||
component?.mpn,
|
||||
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
||||
@@ -157,31 +216,31 @@ function DesignatorForest({
|
||||
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
||||
{leaves.map(([label, items]) => (
|
||||
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2 py-1.5">
|
||||
{sortFindings(items).map((item) => {
|
||||
const key = cardProps.findingKeys.get(item);
|
||||
const key = props.findingKeys.get(item);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={item}
|
||||
onViewReference={cardProps.onViewReference}
|
||||
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
||||
onViewReference={props.onViewReference}
|
||||
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && cardProps.onToggleReviewed
|
||||
? () => cardProps.onToggleReviewed!(key)
|
||||
key && props.onToggleReviewed
|
||||
? () => props.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
|
||||
projectId={cardProps.projectId}
|
||||
collaborators={cardProps.collaborators}
|
||||
currentUserId={cardProps.currentUserId}
|
||||
currentUserName={cardProps.currentUserName}
|
||||
onCommentAdded={cardProps.onCommentAdded}
|
||||
onCommentDeleted={cardProps.onCommentDeleted}
|
||||
onReportFinding={cardProps.onReportFinding}
|
||||
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={cardProps.onReviewSaved}
|
||||
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
|
||||
projectId={props.projectId}
|
||||
collaborators={props.collaborators}
|
||||
currentUserId={props.currentUserId}
|
||||
currentUserName={props.currentUserName}
|
||||
onCommentAdded={props.onCommentAdded}
|
||||
onCommentDeleted={props.onCommentDeleted}
|
||||
onReportFinding={props.onReportFinding}
|
||||
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={props.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -193,14 +252,88 @@ function DesignatorForest({
|
||||
})}
|
||||
</TreeBranch>
|
||||
))}
|
||||
</TreeBranch>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificationsBranch({
|
||||
status,
|
||||
instances,
|
||||
findings,
|
||||
...props
|
||||
}: {
|
||||
status: FindingStatus;
|
||||
instances: ProtocolTreeInstance[];
|
||||
findings: Finding[];
|
||||
} & FindingsTreeProps) {
|
||||
const all = [...findings, ...instances.map(() => ({ status }))];
|
||||
return (
|
||||
<TreeBranch label="Certifications" findings={all.length ? all : [{ status }]} defaultOpen>
|
||||
<TreeBranch
|
||||
label="Protocols"
|
||||
findings={all.length ? all : [{ status }]}
|
||||
defaultOpen
|
||||
>
|
||||
{instances.map((inst) => (
|
||||
<TreeBranch
|
||||
key={inst.instanceId}
|
||||
label={`${inst.logicalId || "protocol"} → ${inst.physicalId || "—"}`}
|
||||
extra={inst.recognition}
|
||||
findings={[{ status: inst.status }]}
|
||||
>
|
||||
<ProtocolInstanceBody instance={inst} />
|
||||
</TreeBranch>
|
||||
))}
|
||||
{findings.length > 0 ? (
|
||||
<TreeBranch label="Findings" findings={findings}>
|
||||
<div className="space-y-2 py-1.5">
|
||||
{sortFindings(findings).map((item) => {
|
||||
const key = props.findingKeys.get(item);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={item}
|
||||
onViewReference={props.onViewReference}
|
||||
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && props.onToggleReviewed
|
||||
? () => props.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
|
||||
projectId={props.projectId}
|
||||
collaborators={props.collaborators}
|
||||
currentUserId={props.currentUserId}
|
||||
currentUserName={props.currentUserName}
|
||||
onCommentAdded={props.onCommentAdded}
|
||||
onCommentDeleted={props.onCommentDeleted}
|
||||
onReportFinding={props.onReportFinding}
|
||||
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={props.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TreeBranch>
|
||||
) : null}
|
||||
{instances.length === 0 && findings.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[13px] text-neutral-500">
|
||||
{protocolEmptyMessage(props.protocolSection?.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</TreeBranch>
|
||||
</TreeBranch>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTreeEmpty() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No findings match your filters.
|
||||
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
|
||||
<p className="text-[15px] font-medium tracking-tight">No visible findings</p>
|
||||
<p className="mt-1 text-[13px] text-neutral-500">
|
||||
Filters are hiding the list. Clear a filter, or open the reviewed findings below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,176 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import type { ProtocolCertificationSection } from "@/lib/types";
|
||||
import { StatusBadge } from "@/components/report/status-badge";
|
||||
import {
|
||||
formatLimit,
|
||||
formatMargin,
|
||||
formatMeasured,
|
||||
formatProtocolChain,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
type ProtocolReportCheck,
|
||||
} from "@/lib/protocol-report";
|
||||
import {
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
type ProtocolTreeInstance,
|
||||
} from "@/lib/findings-forest";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
||||
const raw = row.report_checks;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return sortProtocolChecks(raw as ProtocolReportCheck[]);
|
||||
}
|
||||
const fallback: ProtocolReportCheck[] = [];
|
||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
||||
const list = row[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const rec = c as Record<string, unknown>;
|
||||
fallback.push({
|
||||
check: String(rec.check ?? ""),
|
||||
level: String(rec.level ?? ""),
|
||||
result: String(rec.result ?? ""),
|
||||
notes: String(rec.notes ?? ""),
|
||||
skip_visible:
|
||||
String(rec.result ?? "") !== "PASS" &&
|
||||
String(rec.result ?? "") !== "FAIL",
|
||||
});
|
||||
}
|
||||
}
|
||||
return sortProtocolChecks(fallback);
|
||||
}
|
||||
|
||||
export function ProtocolSection({
|
||||
section,
|
||||
}: {
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = section?.recognized_instances ?? [];
|
||||
const message = section?.message || "nessun protocollo riconosciuto";
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
|
||||
const checks = sortProtocolChecks(instance.checks);
|
||||
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
|
||||
const logical = instance.logicalId;
|
||||
const physical = instance.physicalId;
|
||||
const certLine = checks.find((c) =>
|
||||
/^(USB|Ethernet|RMII|MII|RGMII) (certified|not certified) —/.test(c.notes || ""),
|
||||
);
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Protocolli</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Livello massimo: <span className="font-mono text-foreground">{maxLevel}</span>
|
||||
{" "}
|
||||
(L0–L3; L3 skip unless channel FACT exists — no OpenEMS)
|
||||
<div className="space-y-2 py-2 pr-2">
|
||||
{certLine?.notes ? (
|
||||
<p className="text-[13px] font-medium tracking-tight text-neutral-900 dark:text-neutral-100">
|
||||
{certLine.notes}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Logical protocol vs physical interface. L0 structural, L1 geometric,
|
||||
L2 electrical (Z only from datasheet/stackup/fab or a cited pack).
|
||||
L3 channel (timing budget / IL / RL / crosstalk) only from existing FACT.
|
||||
L0/L1 are not electrical. Length is not delay. FAIL is listed first —
|
||||
it is not filed under Warning.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-sm">{message}</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{instances.map((row, i) => {
|
||||
const rec = row as Record<string, unknown>;
|
||||
const phys = String(rec.physical_interface_id ?? "");
|
||||
const log = String(rec.logical_protocol_id ?? "");
|
||||
const status = String(rec.recognition_status ?? "");
|
||||
const worst = String(rec.worst_result ?? "");
|
||||
const checks = asChecks(rec);
|
||||
const fail = Number(rec.fail_count ?? 0) > 0 || worst === "FAIL";
|
||||
return (
|
||||
<li
|
||||
key={String(rec.instance_id ?? i)}
|
||||
className={cn(
|
||||
"rounded-md border p-3 space-y-2",
|
||||
fail
|
||||
? "border-rose-500/50 bg-rose-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-mono text-xs">
|
||||
{log} → {phys}
|
||||
{status ? ` · ${status}` : ""}
|
||||
</p>
|
||||
{worst ? (
|
||||
<StatusBadge status={protocolResultToFindingStatus(worst)} />
|
||||
) : null}
|
||||
<p className="text-[11px] tabular-nums text-neutral-500">
|
||||
{logical} → {physical}
|
||||
{instance.recognition ? ` · ${instance.recognition}` : ""}
|
||||
{instance.worst ? ` · ${instance.worst}` : ""}
|
||||
</p>
|
||||
{fail ? (
|
||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
|
||||
FAIL visible — not under Warning
|
||||
</span>
|
||||
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL is listed first — not under Warning.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="pr-2 py-1 font-medium">Lv</th>
|
||||
<th className="pr-2 py-1 font-medium">Check</th>
|
||||
<th className="pr-2 py-1 font-medium">Result</th>
|
||||
<th className="pr-2 py-1 font-medium">Measured</th>
|
||||
<th className="pr-2 py-1 font-medium">Limit</th>
|
||||
<th className="pr-2 py-1 font-medium">Margin</th>
|
||||
<th className="pr-2 py-1 font-medium">Source</th>
|
||||
<tr className="text-left text-neutral-500">
|
||||
<th className="py-1 pr-2 font-medium">Lv</th>
|
||||
<th className="py-1 pr-2 font-medium">Check</th>
|
||||
<th className="py-1 pr-2 font-medium">Result</th>
|
||||
<th className="py-1 pr-2 font-medium">Measured</th>
|
||||
<th className="py-1 pr-2 font-medium">Limit</th>
|
||||
<th className="py-1 pr-2 font-medium">Margin</th>
|
||||
<th className="py-1 pr-2 font-medium">Source</th>
|
||||
<th className="py-1 font-medium">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c, j) => {
|
||||
const isFail = c.result === "FAIL";
|
||||
const skip = Boolean(c.skip_visible);
|
||||
return (
|
||||
<tr
|
||||
{checks.map((c, j) => (
|
||||
<ProtocolCheckRow
|
||||
key={`${c.level}-${c.check}-${j}`}
|
||||
className={cn(
|
||||
isFail && "text-rose-700 dark:text-rose-400 font-medium",
|
||||
skip && !isFail && "text-amber-800 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
<td className="pr-2 py-1 font-mono">{c.level}</td>
|
||||
<td className="pr-2 py-1">{c.check}</td>
|
||||
<td className="pr-2 py-1">
|
||||
{c.result}
|
||||
{skip ? " · skip" : ""}
|
||||
</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMeasured(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatLimit(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMargin(c)}</td>
|
||||
<td className="pr-2 py-1">{c.source || "—"}</td>
|
||||
<td className="py-1">{c.method || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
check={c}
|
||||
logicalId={logical}
|
||||
physicalId={physical}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{String(rec.chain_example || "") ? (
|
||||
<p className="text-[11px] text-muted-foreground break-all">
|
||||
Catena: {String(rec.chain_example)}
|
||||
</p>
|
||||
{instance.chainExample ? (
|
||||
<p className="break-all text-[11px] text-neutral-500">Chain: {instance.chainExample}</p>
|
||||
) : null}
|
||||
{checks
|
||||
.filter((c) => c.result === "FAIL" || c.skip_visible)
|
||||
.map((c, j) =>
|
||||
c.chain ? (
|
||||
<p
|
||||
key={`chain-${j}`}
|
||||
className="text-[11px] text-muted-foreground break-all"
|
||||
>
|
||||
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
|
||||
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
{checks.some((c) => c.skip_visible && c.notes) ? (
|
||||
<ul className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
|
||||
{checks
|
||||
.filter((c) => c.skip_visible && c.notes)
|
||||
.map((c, j) => (
|
||||
<li key={`skip-${j}`}>{c.notes}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
function ProtocolCheckRow({
|
||||
check: c,
|
||||
logicalId,
|
||||
physicalId,
|
||||
}: {
|
||||
check: ProtocolReportCheck;
|
||||
logicalId: string;
|
||||
physicalId: string;
|
||||
}) {
|
||||
const isFail = c.result === "FAIL";
|
||||
const unknown = c.result === "UNKNOWN";
|
||||
const measured = formatMeasured(c, logicalId, physicalId);
|
||||
const limit = formatLimit(c, logicalId, physicalId);
|
||||
const margin = formatMargin(c, logicalId, physicalId);
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-t border-black/[0.06] dark:border-white/10",
|
||||
isFail && "font-medium text-[rgb(180,40,35)]",
|
||||
unknown && !isFail && "text-neutral-600",
|
||||
)}
|
||||
>
|
||||
<td className="py-1 pr-2 font-mono">{c.level}</td>
|
||||
<td className="py-1 pr-2">{c.check}</td>
|
||||
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
|
||||
<td className="py-1 pr-2 font-mono">{measured}</td>
|
||||
<td className="py-1 pr-2 font-mono">{limit}</td>
|
||||
<td className="py-1 pr-2 font-mono">{margin}</td>
|
||||
<td className="py-1 pr-2">{c.source || "—"}</td>
|
||||
<td className="py-1">{c.method || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standalone block — used only if the tree is empty of findings. */
|
||||
export function ProtocolSection({
|
||||
section,
|
||||
}: {
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = protocolInstancesFromSection(section);
|
||||
const message = protocolEmptyMessage(section?.message);
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Max level {maxLevel} (L0–L3). HDMI 1.4 TMDS stays UNKNOWN — no invented Zdiff.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-[13px] text-neutral-600">{message}</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{instances.map((inst) => (
|
||||
<li key={inst.instanceId}>
|
||||
<ProtocolInstanceBody instance={inst} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportSummaryProps {
|
||||
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
|
||||
totalCostUsd?: number | null;
|
||||
}
|
||||
|
||||
const CELLS = [
|
||||
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
|
||||
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
|
||||
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
|
||||
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
|
||||
];
|
||||
|
||||
export function ReportSummary({
|
||||
summary,
|
||||
reviewedCount,
|
||||
creditsSpent,
|
||||
totalCostUsd,
|
||||
}: ReportSummaryProps) {
|
||||
const total = summary.total || 0;
|
||||
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
|
||||
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
|
||||
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
|
||||
const grid =
|
||||
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
|
||||
const errors = summary.ERROR ?? 0;
|
||||
const warnings = summary.WARNING ?? 0;
|
||||
const infos = summary.INFO ?? 0;
|
||||
const total = summary.total || errors + warnings + infos;
|
||||
const outcome =
|
||||
errors > 0 ? "Error" : warnings > 0 ? "Warning" : total > 0 ? "Complete" : "No findings";
|
||||
const bits = [
|
||||
`${errors} Error`,
|
||||
`${warnings} Warning`,
|
||||
`${infos} Info`,
|
||||
`${reviewedCount} reviewed`,
|
||||
];
|
||||
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
|
||||
bits.push(`$${totalCostUsd.toFixed(2)}`);
|
||||
}
|
||||
if (typeof creditsSpent === "number" && creditsSpent > 0) {
|
||||
bits.push(`${creditsSpent.toFixed(2)} credits`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={cn("grid gap-4", grid)}>
|
||||
{CELLS.map(({ key, label, color }) => (
|
||||
<Card key={key}>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
|
||||
{summary[key] ?? 0}
|
||||
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
|
||||
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
|
||||
{" · "}
|
||||
{bits.join(" · ")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">Checked</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
{reviewedCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{showCost && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">API cost</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
|
||||
${totalCostUsd!.toFixed(2)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{showCredits && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">Credits</p>
|
||||
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{creditsSpent!.toFixed(2)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
|
||||
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
|
||||
({ key, barColor }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", barColor)}
|
||||
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { FindingStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
|
||||
const TONE: Record<FindingStatus, string> = {
|
||||
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
|
||||
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
|
||||
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
|
||||
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
|
||||
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
|
||||
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: FindingStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
|
||||
TONE[status],
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Light/dark preference. Persisted by next-themes (localStorage). */
|
||||
export function AppearanceSettings({ className }: { className?: string }) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const current = mounted ? (theme ?? resolvedTheme ?? "light") : "light";
|
||||
|
||||
return (
|
||||
<section className={cn("space-y-3", className)}>
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Appearance</h2>
|
||||
<p className="mt-0.5 text-[13px] text-neutral-500">
|
||||
Light is the default. Dark mode is optional and remembered on this device.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Color theme"
|
||||
className="inline-flex rounded-[10px] border border-black/10 bg-black/[0.03] p-0.5 dark:border-white/10 dark:bg-white/[0.06]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "dark", label: "Dark" },
|
||||
] as const
|
||||
).map((opt) => {
|
||||
const active = current === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setTheme(opt.id)}
|
||||
className={cn(
|
||||
"rounded-[8px] px-3.5 py-1.5 text-[13px] transition-colors duration-150 ease-out",
|
||||
active
|
||||
? "bg-white text-neutral-900 shadow-sm dark:bg-[#2c2c2e] dark:text-neutral-50"
|
||||
: "text-neutral-500 hover:text-neutral-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider as NextThemes } from "next-themes";
|
||||
|
||||
/** Class-based color scheme. Default is dark; OS preference is ignored. */
|
||||
/** Class-based color scheme. Light by default; preference persisted in localStorage. */
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemes
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
storageKey="periscope-theme"
|
||||
>
|
||||
{children}
|
||||
</NextThemes>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
|
||||
import { isPcbExamFinding } from "./layout-finding.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
import {
|
||||
groupByWorstStatus,
|
||||
groupDomains,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
ruleDomain,
|
||||
worstStatus,
|
||||
} from "./findings-forest";
|
||||
import { isPcbExamFinding } from "./layout-finding";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
@@ -50,6 +57,38 @@ test("Error folder lists J2 connectors, not only U*", () => {
|
||||
assert.equal(groups[1].label, "Warning");
|
||||
});
|
||||
|
||||
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
|
||||
const groups = groupByWorstStatus([
|
||||
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
|
||||
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
|
||||
f({
|
||||
designator: "J1",
|
||||
status: "ERROR",
|
||||
rule_id: "PE-PRT-L0-001",
|
||||
source: "protocol_l0",
|
||||
}),
|
||||
]);
|
||||
assert.equal(groups[0].label, "Error");
|
||||
assert.deepEqual(
|
||||
groups[0].domains.map((d) => d.id),
|
||||
["PLC"],
|
||||
);
|
||||
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
|
||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
||||
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
|
||||
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
|
||||
});
|
||||
|
||||
test("protocol folder label is Protocols, including a stored Italian empty message", () => {
|
||||
const domain = ruleDomain(
|
||||
f({ designator: "J2", status: "ERROR", rule_id: "PE-PRT-L0-001", source: "protocol_l0" }),
|
||||
);
|
||||
assert.equal(domain.label, "Protocols");
|
||||
assert.equal(protocolEmptyMessage("nessun protocollo riconosciuto"), "No protocol recognized.");
|
||||
assert.equal(protocolEmptyMessage(""), "No protocol recognized.");
|
||||
assert.equal(protocolEmptyMessage("USB2 recognized"), "USB2 recognized");
|
||||
});
|
||||
|
||||
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
|
||||
assert.equal(
|
||||
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/** Group report findings for the tree: status folders, then every ref (J2 included). */
|
||||
/** Group report findings for the tree: status folders, then domains/rules, then Certifications. */
|
||||
|
||||
import type { Finding, FindingStatus } from "./types";
|
||||
|
||||
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
|
||||
import {
|
||||
instanceWorstResult,
|
||||
protocolResultToFindingStatus,
|
||||
type ProtocolReportCheck,
|
||||
} from "./protocol-report";
|
||||
|
||||
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
ERROR: "Error",
|
||||
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
INFO: "Info",
|
||||
};
|
||||
|
||||
export const STATUS_TINT: Record<FindingStatus, string> = {
|
||||
ERROR: "bg-[rgba(255,59,48,0.07)]",
|
||||
WARNING: "bg-[rgba(255,214,10,0.08)]",
|
||||
INFO: "bg-[rgba(142,142,147,0.08)]",
|
||||
};
|
||||
|
||||
const DOMAIN_LABEL: Record<string, string> = {
|
||||
PLC: "Placement",
|
||||
LAY: "Layout",
|
||||
SI: "Signal integrity",
|
||||
BOM: "BOM",
|
||||
PWR: "Power",
|
||||
THM: "Thermal",
|
||||
VIA: "Vias",
|
||||
KEL: "Kelvin",
|
||||
STCH: "Stitching",
|
||||
DDR: "DDR",
|
||||
CPU: "CPU",
|
||||
FPGA: "FPGA",
|
||||
DRT: "Derating",
|
||||
HIER: "Hierarchy",
|
||||
TIM: "Timing",
|
||||
PI: "Power integrity",
|
||||
ESD: "ESD",
|
||||
RET: "Return path",
|
||||
SPOF: "SPOF",
|
||||
EMI: "EMI",
|
||||
STK: "Stackup",
|
||||
ANT: "Antenna",
|
||||
PDN: "PDN",
|
||||
PD: "USB-PD",
|
||||
AF: "AF Board + AI",
|
||||
PRT: "Protocols",
|
||||
MUX: "Pin mux",
|
||||
NC: "NC pins",
|
||||
DEC: "Decoupling",
|
||||
SCH: "Schematic",
|
||||
PCB: "PCB exam",
|
||||
};
|
||||
|
||||
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
|
||||
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
||||
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
||||
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
|
||||
findings: Finding[];
|
||||
};
|
||||
|
||||
export type RuleGroup = {
|
||||
ruleId: string;
|
||||
findings: Finding[];
|
||||
designators: DesignatorGroup[];
|
||||
};
|
||||
|
||||
export type DomainGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
findings: Finding[];
|
||||
rules: RuleGroup[];
|
||||
};
|
||||
|
||||
export type StatusGroup = {
|
||||
status: FindingStatus;
|
||||
label: string;
|
||||
findings: Finding[];
|
||||
designators: DesignatorGroup[];
|
||||
domains: DomainGroup[];
|
||||
};
|
||||
|
||||
export type ProtocolTreeInstance = {
|
||||
instanceId: string;
|
||||
logicalId: string;
|
||||
physicalId: string;
|
||||
recognition: string;
|
||||
worst: string;
|
||||
status: FindingStatus;
|
||||
checks: ProtocolReportCheck[];
|
||||
notes: string;
|
||||
chainExample: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Sidebar rows: ICs, connectors (J*), and any other ref. Never U-only. */
|
||||
@@ -42,9 +112,79 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
|
||||
.map(([designator, items]) => ({ designator, findings: items }));
|
||||
}
|
||||
|
||||
export const PROTOCOL_EMPTY_MESSAGE = "No protocol recognized.";
|
||||
|
||||
/** Tree copy. Stored 2.83 reports still carry the Italian empty phrase. */
|
||||
export function protocolEmptyMessage(message?: string | null): string {
|
||||
const text = (message || "").trim();
|
||||
if (!text || text.toLowerCase() === "nessun protocollo riconosciuto") {
|
||||
return PROTOCOL_EMPTY_MESSAGE;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function isProtocolFinding(f: {
|
||||
source?: string | null;
|
||||
rule_id?: string | null;
|
||||
}): boolean {
|
||||
const src = f.source || "";
|
||||
const rid = f.rule_id || "";
|
||||
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
|
||||
}
|
||||
|
||||
export function ruleDomain(f: Finding): { id: string; label: string } {
|
||||
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocols" };
|
||||
const rid = f.rule_id || "";
|
||||
const m = rid.match(/^PE-([A-Z]+)/);
|
||||
if (m) {
|
||||
const id = m[1];
|
||||
return { id, label: DOMAIN_LABEL[id] || id };
|
||||
}
|
||||
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
|
||||
return { id: "PCB", label: DOMAIN_LABEL.PCB };
|
||||
}
|
||||
return { id: "SCH", label: DOMAIN_LABEL.SCH };
|
||||
}
|
||||
|
||||
export function groupRules(findings: Finding[]): RuleGroup[] {
|
||||
const byRule = new Map<string, Finding[]>();
|
||||
for (const f of findings) {
|
||||
const id = (f.rule_id || "").trim() || "(no rule)";
|
||||
const list = byRule.get(id) ?? [];
|
||||
list.push(f);
|
||||
byRule.set(id, list);
|
||||
}
|
||||
return [...byRule.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([ruleId, items]) => ({
|
||||
ruleId,
|
||||
findings: items,
|
||||
designators: groupByDesignator(items),
|
||||
}));
|
||||
}
|
||||
|
||||
export function groupDomains(findings: Finding[]): DomainGroup[] {
|
||||
const byId = new Map<string, Finding[]>();
|
||||
for (const f of findings) {
|
||||
const { id } = ruleDomain(f);
|
||||
const list = byId.get(id) ?? [];
|
||||
list.push(f);
|
||||
byId.set(id, list);
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([id, items]) => ({
|
||||
id,
|
||||
label: DOMAIN_LABEL[id] || id,
|
||||
findings: items,
|
||||
rules: groupRules(items),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
|
||||
* U18 with PE-PLC-002 ERROR + five WARNING → Error, badge ERROR, count 6.
|
||||
* Protocol findings (PE-PRT) are excluded from domain folders — they sit under Certifications.
|
||||
*/
|
||||
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
|
||||
@@ -60,11 +200,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
|
||||
.filter((status) => buckets[status].length > 0)
|
||||
.map((status) => {
|
||||
const designators = buckets[status];
|
||||
const folderFindings = designators.flatMap((d) => d.findings);
|
||||
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
|
||||
return {
|
||||
status,
|
||||
label: STATUS_FOLDER[status],
|
||||
findings: designators.flatMap((d) => d.findings),
|
||||
findings: folderFindings,
|
||||
designators,
|
||||
domains: groupDomains(nonProtocol),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
||||
const raw = row.report_checks;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw as ProtocolReportCheck[];
|
||||
}
|
||||
const fallback: ProtocolReportCheck[] = [];
|
||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
||||
const list = row[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const rec = c as Record<string, unknown>;
|
||||
fallback.push({
|
||||
check: String(rec.check ?? ""),
|
||||
level: String(rec.level ?? ""),
|
||||
result: String(rec.result ?? ""),
|
||||
notes: String(rec.notes ?? ""),
|
||||
skip_visible:
|
||||
String(rec.result ?? "") !== "PASS" &&
|
||||
String(rec.result ?? "") !== "FAIL",
|
||||
});
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function protocolInstancesFromSection(
|
||||
section?: ProtocolCertificationSection | null,
|
||||
): ProtocolTreeInstance[] {
|
||||
const instances = section?.recognized_instances ?? [];
|
||||
return instances.map((row, i) => {
|
||||
const rec = row as Record<string, unknown>;
|
||||
const worst = String(rec.worst_result ?? "");
|
||||
const checks = asChecks(rec);
|
||||
const results = checks.map((c) => c.result).filter(Boolean);
|
||||
const worstResolved = worst || instanceWorstResult(results);
|
||||
return {
|
||||
instanceId: String(rec.instance_id ?? i),
|
||||
logicalId: String(rec.logical_protocol_id ?? ""),
|
||||
physicalId: String(rec.physical_interface_id ?? ""),
|
||||
recognition: String(rec.recognition_status ?? ""),
|
||||
worst: worstResolved,
|
||||
status: protocolResultToFindingStatus(worstResolved),
|
||||
checks,
|
||||
notes: String(rec.notes ?? rec.message ?? ""),
|
||||
chainExample: String(rec.chain_example ?? ""),
|
||||
raw: rec,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function protocolInstancesForStatus(
|
||||
instances: ProtocolTreeInstance[],
|
||||
status: FindingStatus,
|
||||
): ProtocolTreeInstance[] {
|
||||
return instances.filter((row) => row.status === status);
|
||||
}
|
||||
|
||||
export function forestHasCertifications(
|
||||
instances: ProtocolTreeInstance[],
|
||||
protocolFindings: Finding[],
|
||||
status: FindingStatus,
|
||||
): boolean {
|
||||
if (protocolInstancesForStatus(instances, status).length > 0) return true;
|
||||
return protocolFindings.some((f) => f.status === status);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
formatLimit,
|
||||
formatMeasured,
|
||||
formatProtocolChain,
|
||||
isHdmi14TmdsElectrical,
|
||||
instanceWorstResult,
|
||||
protocolResultRank,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
} from "./protocol-report.ts";
|
||||
import { groupByWorstStatus } from "./findings-forest.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
} from "./protocol-report";
|
||||
import { groupByWorstStatus } from "./findings-forest";
|
||||
import type { Finding } from "./types";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
@@ -68,10 +71,44 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
|
||||
status: "WARNING",
|
||||
rule_id: "PE-PRT-L2-001",
|
||||
source: "protocol_l2",
|
||||
finding: "non certificata a L2 per mancanza di Z",
|
||||
finding: "USB not certified — because Z cannot be calculated from the PCB stackup/geometry. Cited 90 Ω.",
|
||||
}),
|
||||
]);
|
||||
assert.equal(groups[0].label, "Error");
|
||||
assert.equal(groups[0].status, "ERROR");
|
||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
||||
});
|
||||
|
||||
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
|
||||
const row = {
|
||||
check: "differential_impedance",
|
||||
level: "L2",
|
||||
result: "UNKNOWN",
|
||||
measured: 100,
|
||||
limit: 100,
|
||||
margin: 0,
|
||||
unit: "ohm",
|
||||
chain: {
|
||||
logical_protocol_id: "hdmi-1.4",
|
||||
physical_interface_id: "hdmi-1.4-tmds-type-a",
|
||||
},
|
||||
};
|
||||
assert.equal(isHdmi14TmdsElectrical(row), true);
|
||||
assert.equal(formatLimit(row), "UNKNOWN");
|
||||
assert.equal(formatMeasured(row), "UNKNOWN");
|
||||
assert.ok(!formatLimit(row).includes("100"));
|
||||
assert.ok(!formatMeasured(row).includes("Ω"));
|
||||
});
|
||||
|
||||
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
|
||||
const row = {
|
||||
check: "cec",
|
||||
level: "L2",
|
||||
result: "PASS",
|
||||
limit: 27000,
|
||||
unit: "ohm",
|
||||
chain: { logical_protocol_id: "hdmi-1.4" },
|
||||
};
|
||||
assert.equal(isHdmi14TmdsElectrical(row), false);
|
||||
assert.equal(formatLimit(row), "27000 ohm");
|
||||
});
|
||||
|
||||
@@ -94,19 +94,80 @@ export function formatProtocolChain(chain?: ProtocolChain | null): string {
|
||||
return keys.map((k) => chain[k] || "—").join(" → ");
|
||||
}
|
||||
|
||||
export function formatMeasured(row: ProtocolReportCheck): string {
|
||||
const HDMI14_TMDS_ELECTRICAL = new Set([
|
||||
"differential_impedance",
|
||||
"impedance_tolerance",
|
||||
"intra_pair_skew",
|
||||
"inter_lane_skew",
|
||||
"clock_data_relationship",
|
||||
"data_rate",
|
||||
"transition_rate",
|
||||
"channel_loss",
|
||||
"insertion_loss",
|
||||
"return_loss",
|
||||
"crosstalk",
|
||||
]);
|
||||
|
||||
export function isHdmi14Protocol(id?: string | null): boolean {
|
||||
const s = (id || "").toLowerCase();
|
||||
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
|
||||
}
|
||||
|
||||
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
|
||||
export function isHdmi14TmdsElectrical(
|
||||
row: Pick<ProtocolReportCheck, "check" | "chain">,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): boolean {
|
||||
const logical = logicalId || row.chain?.logical_protocol_id || "";
|
||||
const physical = physicalId || row.chain?.physical_interface_id || "";
|
||||
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
|
||||
const check = (row.check || "").toLowerCase();
|
||||
if (check === "cec" || check.startsWith("cec_")) return false;
|
||||
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
|
||||
return check.includes("impedance") || check.includes("zdiff");
|
||||
}
|
||||
|
||||
export function formatUnknownNotGuessed(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): boolean {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
|
||||
return row.result === "UNKNOWN";
|
||||
}
|
||||
|
||||
export function formatMeasured(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.measured == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.measured}${unit}`;
|
||||
}
|
||||
|
||||
export function formatLimit(row: ProtocolReportCheck): string {
|
||||
export function formatLimit(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.limit == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.limit}${unit}`;
|
||||
}
|
||||
|
||||
export function formatMargin(row: ProtocolReportCheck): string {
|
||||
export function formatMargin(
|
||||
row: ProtocolReportCheck,
|
||||
logicalId?: string | null,
|
||||
physicalId?: string | null,
|
||||
): string {
|
||||
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
|
||||
if (row.result === "UNKNOWN") return "UNKNOWN";
|
||||
if (row.margin == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.margin}${unit}`;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildReportMarkdown } from "./report-export";
|
||||
import type { Component, DesignGraph, Finding, ValidationReport } from "./types";
|
||||
|
||||
function comp(reference: string, mpn: string): Component {
|
||||
return {
|
||||
reference,
|
||||
value: "",
|
||||
footprint: "",
|
||||
component_type: "ic",
|
||||
component_subtype: null,
|
||||
mpn,
|
||||
pins: {},
|
||||
specs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status" | "finding">): Finding {
|
||||
return {
|
||||
finding_id: partial.finding_id ?? null,
|
||||
mpn: partial.mpn ?? "",
|
||||
aspect: null,
|
||||
why: partial.why ?? "Because",
|
||||
source_page: null,
|
||||
reference: "",
|
||||
rule_id: partial.rule_id ?? "PE-PLC-002",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
test("Markdown export has title, summary counts, and Error→Warning tree", () => {
|
||||
const findings: Finding[] = [
|
||||
f({
|
||||
designator: "U1",
|
||||
status: "ERROR",
|
||||
finding: "Missing decoupling",
|
||||
finding_id: "PCB-U1-001",
|
||||
rule_id: "PE-DEC-001",
|
||||
}),
|
||||
f({
|
||||
designator: "R2",
|
||||
status: "WARNING",
|
||||
finding: "Tolerance wide",
|
||||
finding_id: "PCB-R2-001",
|
||||
rule_id: "PE-BOM-011",
|
||||
}),
|
||||
];
|
||||
const report: ValidationReport = {
|
||||
project: "demo",
|
||||
timestamp: "2026-09-23T00:00:00Z",
|
||||
findings,
|
||||
summary: { ERROR: 1, WARNING: 1, INFO: 0, total: 2 },
|
||||
coverage: {},
|
||||
};
|
||||
const graph: DesignGraph = {
|
||||
components: {
|
||||
U1: comp("U1", "MSPM0"),
|
||||
R2: comp("R2", "10k"),
|
||||
},
|
||||
nets: {},
|
||||
};
|
||||
|
||||
const md = buildReportMarkdown(report, graph, "Demo Board");
|
||||
assert.match(md, /^# Demo Board/m);
|
||||
assert.match(md, /## Summary/);
|
||||
assert.match(md, /- Error: 1/);
|
||||
assert.match(md, /- Warning: 1/);
|
||||
assert.match(md, /## Error/);
|
||||
assert.match(md, /## Warning/);
|
||||
assert.match(md, /##### U1/);
|
||||
assert.match(md, /\*\*Missing decoupling\*\*/);
|
||||
assert.ok(md.indexOf("## Error") < md.indexOf("## Warning"));
|
||||
assert.doesNotMatch(md, /xlsx|\.xls\b/i);
|
||||
});
|
||||
@@ -1,60 +1,199 @@
|
||||
/** Client-side findings workbook (.xlsx) for a finished review. */
|
||||
/** Client-side findings report as readable Markdown (.md). Not a spreadsheet. */
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import type { DesignGraph, Finding, ValidationReport } from "./types";
|
||||
import { sortFindings } from "./utils";
|
||||
|
||||
const COLUMNS: string[] = [
|
||||
"Designator",
|
||||
"MPN",
|
||||
"ID",
|
||||
"Severity",
|
||||
"Title",
|
||||
"Description",
|
||||
"Recommendation",
|
||||
"Source",
|
||||
"Net",
|
||||
"Pins",
|
||||
"Rule",
|
||||
];
|
||||
|
||||
const WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14];
|
||||
|
||||
function sourceCell(f: Finding): string {
|
||||
if (f.source && f.source !== "review") return "Automated check";
|
||||
const who = f.source_designator || f.designator;
|
||||
return f.source_page ? `${who} datasheet p.${f.source_page}` : `${who} datasheet`;
|
||||
}
|
||||
import type {
|
||||
DesignGraph,
|
||||
Finding,
|
||||
FindingStatus,
|
||||
ProtocolCertificationSection,
|
||||
ValidationReport,
|
||||
} from "./types";
|
||||
import {
|
||||
STATUS_FOLDER,
|
||||
groupByWorstStatus,
|
||||
isProtocolFinding,
|
||||
protocolEmptyMessage,
|
||||
protocolInstancesFromSection,
|
||||
protocolInstancesForStatus,
|
||||
} from "./findings-forest";
|
||||
|
||||
function fileStem(name: string): string {
|
||||
const slug = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return slug || "report";
|
||||
}
|
||||
|
||||
export function exportReportToExcel(
|
||||
function escapeMd(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function findingBullet(f: Finding, mpn: string): string {
|
||||
const bits: string[] = [];
|
||||
const title = escapeMd(f.finding || f.finding_id || "Finding");
|
||||
bits.push(`- **${title}**`);
|
||||
if (f.status) bits.push(` - Severity: ${f.status}`);
|
||||
if (f.finding_id) bits.push(` - ID: \`${f.finding_id}\``);
|
||||
if (f.rule_id) bits.push(` - Rule: \`${f.rule_id}\``);
|
||||
if (mpn) bits.push(` - MPN: ${mpn}`);
|
||||
if (f.net) bits.push(` - Net: \`${f.net}\``);
|
||||
if (f.pins?.length) bits.push(` - Pins: ${(f.pins ?? []).join(", ")}`);
|
||||
if (f.why) bits.push(` - ${escapeMd(f.why)}`);
|
||||
if (f.recommendation) bits.push(` - Recommendation: ${escapeMd(f.recommendation)}`);
|
||||
return bits.join("\n");
|
||||
}
|
||||
|
||||
function renderStatusTree(
|
||||
findings: Finding[],
|
||||
graph: DesignGraph,
|
||||
protocolSection?: ProtocolCertificationSection | null,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const protocolFindings = findings.filter(isProtocolFinding);
|
||||
const nonProtocol = findings.filter((f) => !isProtocolFinding(f));
|
||||
const instances = protocolInstancesFromSection(protocolSection);
|
||||
const groups = groupByWorstStatus(nonProtocol.length ? nonProtocol : findings);
|
||||
|
||||
// Always walk Error → Warning → Info even if empty folders omitted by groupByWorstStatus
|
||||
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
|
||||
const byStatus = new Map(groups.map((g) => [g.status, g]));
|
||||
|
||||
for (const status of order) {
|
||||
const group = byStatus.get(status);
|
||||
const protoHere = protocolInstancesForStatus(instances, status);
|
||||
const protoFindingsHere = protocolFindings.filter((f) => f.status === status);
|
||||
if (!group && protoHere.length === 0 && protoFindingsHere.length === 0) continue;
|
||||
|
||||
lines.push(`## ${STATUS_FOLDER[status]}`);
|
||||
lines.push("");
|
||||
|
||||
if (group) {
|
||||
for (const domain of group.domains) {
|
||||
lines.push(`### ${domain.label}`);
|
||||
lines.push("");
|
||||
for (const rule of domain.rules) {
|
||||
lines.push(`#### ${rule.ruleId}`);
|
||||
lines.push("");
|
||||
for (const des of rule.designators) {
|
||||
lines.push(`##### ${des.designator}`);
|
||||
lines.push("");
|
||||
for (const f of des.findings) {
|
||||
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
|
||||
lines.push(findingBullet(f, mpn));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (protoHere.length > 0 || protoFindingsHere.length > 0) {
|
||||
lines.push("### Certifications");
|
||||
lines.push("");
|
||||
lines.push("#### Protocols");
|
||||
lines.push("");
|
||||
if (protoHere.length === 0 && protoFindingsHere.length === 0) {
|
||||
lines.push(`- ${protocolEmptyMessage(protocolSection?.message)}`);
|
||||
lines.push("");
|
||||
}
|
||||
for (const inst of protoHere) {
|
||||
lines.push(
|
||||
`- **${inst.logicalId || inst.physicalId || inst.instanceId}** — ${inst.worst || inst.recognition}`,
|
||||
);
|
||||
if (inst.notes) lines.push(` - ${escapeMd(inst.notes)}`);
|
||||
for (const c of inst.checks) {
|
||||
lines.push(
|
||||
` - [${c.level || "?"}] ${c.check || "check"}: ${c.result}${c.notes ? ` — ${escapeMd(c.notes)}` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
for (const f of protoFindingsHere) {
|
||||
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
|
||||
lines.push(findingBullet(f, mpn));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
instances.length === 0 &&
|
||||
protocolFindings.length === 0 &&
|
||||
protocolSection
|
||||
) {
|
||||
lines.push("## Certifications");
|
||||
lines.push("");
|
||||
lines.push("### Protocols");
|
||||
lines.push("");
|
||||
lines.push(`- ${protocolEmptyMessage(protocolSection.message)}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Build Markdown for the report tree (title, summary counts, hierarchy). */
|
||||
export function buildReportMarkdown(
|
||||
report: ValidationReport,
|
||||
graph: DesignGraph,
|
||||
projectName?: string,
|
||||
): string {
|
||||
const title = projectName || report.project || "Periscope report";
|
||||
const summary = report.summary ?? {};
|
||||
const errors = summary.ERROR ?? 0;
|
||||
const warnings = summary.WARNING ?? 0;
|
||||
const infos = summary.INFO ?? 0;
|
||||
const total = summary.total ?? report.findings.length;
|
||||
|
||||
const lines: string[] = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
`Generated ${report.timestamp || new Date().toISOString()}`,
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
`- Total: ${total}`,
|
||||
`- Error: ${errors}`,
|
||||
`- Warning: ${warnings}`,
|
||||
`- Info: ${infos}`,
|
||||
"",
|
||||
];
|
||||
|
||||
lines.push(
|
||||
...renderStatusTree(report.findings, graph, report.protocol_certification),
|
||||
);
|
||||
|
||||
if (report.not_reviewed?.length) {
|
||||
lines.push("## Not reviewed");
|
||||
lines.push("");
|
||||
for (const nr of report.not_reviewed) {
|
||||
lines.push(`- **${nr.designator}** — ${escapeMd(nr.reason)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, contents: string): void {
|
||||
const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Save the findings tree as a `.md` file (readable Markdown lists). */
|
||||
export function exportReportToMarkdown(
|
||||
report: ValidationReport,
|
||||
graph: DesignGraph,
|
||||
projectName?: string,
|
||||
): void {
|
||||
const body = sortFindings(report.findings).map((f) => [
|
||||
f.designator,
|
||||
f.mpn || graph.components[f.designator]?.mpn || "",
|
||||
f.finding_id ?? "",
|
||||
f.status,
|
||||
f.finding,
|
||||
f.why ?? "",
|
||||
f.recommendation ?? "",
|
||||
sourceCell(f),
|
||||
f.net ?? "",
|
||||
(f.pins ?? []).join(", "),
|
||||
f.rule_id ?? "",
|
||||
]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([COLUMNS, ...body]);
|
||||
sheet["!cols"] = WIDTHS.map((wch) => ({ wch }));
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, "Findings");
|
||||
XLSX.writeFile(
|
||||
book,
|
||||
`${fileStem(projectName || report.project || "report")}-findings.xlsx`,
|
||||
const md = buildReportMarkdown(report, graph, projectName);
|
||||
downloadTextFile(
|
||||
`${fileStem(projectName || report.project || "report")}-findings.md`,
|
||||
md,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||
export const APP_VERSION = "2.66.0";
|
||||
export const APP_VERSION_DATE = "2026-09-22";
|
||||
export const APP_VERSION = "2.87.0";
|
||||
export const APP_VERSION_DATE = "2026-09-23";
|
||||
|
||||
@@ -23,5 +23,10 @@ TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync
|
||||
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
|
||||
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B – May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
|
||||
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 – September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
|
||||
| `micrel_ap_note_133_interfacing_to_mac.pdf` | Application Note 133 Interfacing Fast Ethernet Transceivers to MAC Processors | Rev. 1.0, January 2006 | `rmii-pcb`, `mii-pcb` (REFCLK 50 MHz, MII 25 MHz, MDC ≤5 MHz, optional 22–50 Ω source termination). **VENDOR** app note — not STANDARD. PCB Z0 / setup-hold UNKNOWN | **VENDOR** |
|
||||
| `OPEN_RGMII_EPL_V_2_public.pdf` | OPEN Alliance RGMII EPL (Electrical-Physical Layer) Recommendations | V2.2, October 25, 2016 | `rgmii-pcb` duty/skew Tables 2–5, CloadR 5 pF, tR/tF 1 ns. PCB Z0 / mm length **UNKNOWN** (not stated). Industry STANDARD cite (OPEN Alliance) | **STANDARD** |
|
||||
| `SGMII.pdf` | Serial-GMII Specification (Cisco ENG-46158) | Revision 1.7, July 19, 2001 | `sgmii-pcb` 1.25 Gbaud, Rload 100 Ω ±1%, Rin 80–120 Ω, tskew1 20 ps, tsetup/thold 100 ps. **VENDOR** (Cisco IP) — not IEEE 802.3 MDI. Channel length UNKNOWN | **VENDOR** |
|
||||
|
||||
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
|
||||
|
||||
MAC–PHY note: RJ45/MDI 100 Ω (`100base-tx-mdi` / `1000base-t-mdi`) stays separate from RMII/MII/RGMII/SGMII bus packs.
|
||||
|
||||
@@ -47,6 +47,28 @@ def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
)
|
||||
|
||||
|
||||
# Generic AF geometry tests use HDMI (not USB): USB is owned by the 90 Ω cert path.
|
||||
_AF_P = "HDMI_D0+"
|
||||
_AF_N = "HDMI_D0-"
|
||||
|
||||
|
||||
def _af_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": _ic("U1", {"1": _AF_P, "2": _AF_N}),
|
||||
"J1": Component(
|
||||
reference="J1", value="HDMI", footprint="",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"1": _AF_P, "2": _AF_N},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
_AF_P: _net(_AF_P, ("U1", "1"), ("J1", "1")),
|
||||
_AF_N: _net(_AF_N, ("U1", "2"), ("J1", "2")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _usb_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
@@ -72,26 +94,26 @@ def _stack() -> LayoutStackup:
|
||||
)
|
||||
|
||||
|
||||
def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
|
||||
def _pair_layout(*, length: float = 10.0, elbow: bool = False, p: str = _AF_P, n: str = _AF_N) -> LayoutGraph:
|
||||
segs = [
|
||||
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
|
||||
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net=p),
|
||||
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net=n),
|
||||
]
|
||||
if elbow:
|
||||
segs.append(LayoutSegment(
|
||||
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net="USB_D+",
|
||||
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net=p,
|
||||
))
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="P", x=0, y=0,
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="USB_D+"),
|
||||
LayoutPad(number="2", x=0, y=0.4, net="USB_D-")],
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net=p),
|
||||
LayoutPad(number="2", x=0, y=0.4, net=n)],
|
||||
),
|
||||
"J2": LayoutFootprint(
|
||||
reference="J2", footprint="P", x=length, y=0,
|
||||
pads=[LayoutPad(number="A6", x=length, y=0, net="USB_D+"),
|
||||
LayoutPad(number="A7", x=length, y=0.4, net="USB_D-")],
|
||||
"J1": LayoutFootprint(
|
||||
reference="J1", footprint="P", x=length, y=0,
|
||||
pads=[LayoutPad(number="1", x=length, y=0, net=p),
|
||||
LayoutPad(number="2", x=length, y=0.4, net=n)],
|
||||
),
|
||||
},
|
||||
segments=segs,
|
||||
@@ -99,6 +121,10 @@ def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
|
||||
)
|
||||
|
||||
|
||||
def _usb_pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
|
||||
return _pair_layout(length=length, elbow=elbow, p="USB_D+", n="USB_D-")
|
||||
|
||||
|
||||
def _rules(*extra: dict) -> dict:
|
||||
rules = list(extra)
|
||||
return {
|
||||
@@ -118,9 +144,9 @@ def test_catalog_pe_af_ids():
|
||||
|
||||
|
||||
def test_pair_is_one_unit():
|
||||
units = iter_af_units(_pair_layout(), _usb_graph())
|
||||
units = iter_af_units(_pair_layout(), _af_graph())
|
||||
assert len(units) == 1
|
||||
assert set(units[0].nets) == {"USB_D+", "USB_D-"}
|
||||
assert set(units[0].nets) == {_AF_P, _AF_N}
|
||||
|
||||
|
||||
def test_gpio_is_not_a_candidate():
|
||||
@@ -137,31 +163,42 @@ def test_gpio_is_not_a_candidate():
|
||||
|
||||
def test_missing_tr_f_is_visible_skip_no_ohm():
|
||||
layout = _pair_layout()
|
||||
out = check_af_traces(_usb_graph(), {}, layout)
|
||||
out = check_af_traces(_af_graph(), {}, layout)
|
||||
assert out
|
||||
assert all(f.rule_id == "PE-AF-002" for f in out)
|
||||
assert len(out) == 1
|
||||
text = out[0].finding
|
||||
assert "Pista ad alta frequenza non controllata per mancanza di" in text
|
||||
assert text.startswith("HF not certified — because")
|
||||
assert "tr" in text and "f" in text
|
||||
assert "mancanza" not in text
|
||||
assert "Pista ad alta" not in text
|
||||
assert out[0].evidence_status == "INSUFFICIENT"
|
||||
assert out[0].finding_class == "REVIEW"
|
||||
blob = (out[0].finding + out[0].facts).lower()
|
||||
assert "90" not in blob and "50" not in blob and "ω" not in blob and "ohm" not in blob
|
||||
assert "90 Ω" not in blob and "50 Ω" not in blob
|
||||
assert "invented" in blob
|
||||
|
||||
|
||||
def test_usb_pair_skips_af_tr_f_pe_af_002():
|
||||
"""USB D+/D− must not get PE-AF-002 tr/f; protocol_l2 owns the 90 Ω cert line."""
|
||||
out = check_af_traces(_usb_graph(), {}, _usb_pair_layout(length=40.0))
|
||||
assert not any(f.rule_id == "PE-AF-002" for f in out)
|
||||
assert not any("mancanza" in (f.finding or "") for f in out)
|
||||
assert not any("Pista ad alta" in (f.finding or "") for f in out)
|
||||
|
||||
|
||||
def test_short_pair_with_tr_is_not_af():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 5.0})
|
||||
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 5.0})
|
||||
layout = _pair_layout(length=8.0)
|
||||
trig = evaluate_trigger(_usb_graph(), cons, layout, iter_af_units(layout, _usb_graph())[0])
|
||||
trig = evaluate_trigger(_af_graph(), cons, layout, iter_af_units(layout, _af_graph())[0])
|
||||
assert trig.af is False
|
||||
assert check_af_traces(_usb_graph(), cons, layout) == []
|
||||
assert check_af_traces(_af_graph(), cons, layout) == []
|
||||
|
||||
|
||||
def test_triggered_without_z_target_is_visible_skip():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
out = check_af_traces(_af_graph(), cons, layout)
|
||||
skips = [f for f in out if f.rule_id == "PE-AF-002"]
|
||||
assert skips
|
||||
assert "Z target datasheet" in skips[0].finding
|
||||
@@ -170,45 +207,45 @@ def test_triggered_without_z_target_is_visible_skip():
|
||||
|
||||
def test_triggered_zdiff_window_uses_datasheet_not_folklore():
|
||||
cons = _rules(
|
||||
{"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05},
|
||||
{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90, "tolerance_pct": 10},
|
||||
{"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05},
|
||||
{"kind": "impedance", "net_class": "hdmi", "zdiff_ohm": 100, "tolerance_pct": 10},
|
||||
)
|
||||
layout = _pair_layout(length=40.0)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
out = check_af_traces(_af_graph(), cons, layout)
|
||||
zhits = [f for f in out if f.rule_id == "PE-AF-050"]
|
||||
if zhits:
|
||||
assert zhits[0].net in {"USB_D+", "USB_D-"}
|
||||
assert "90" in zhits[0].finding or "90" in zhits[0].facts
|
||||
assert zhits[0].net in {_AF_P, _AF_N}
|
||||
assert "100" in zhits[0].finding or "100" in zhits[0].facts
|
||||
assert zhits[0].calculation == "" or "Z_ref" in zhits[0].calculation
|
||||
assert sum(1 for f in out if f.rule_id == "PE-AF-050") <= 1
|
||||
|
||||
|
||||
def test_right_angle_on_triggered_pair():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0, elbow=True)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
out = check_af_traces(_af_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-AF-020" for f in out)
|
||||
|
||||
|
||||
def test_via_without_span_is_visible_skip():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3)]
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3)]
|
||||
out = check_af_traces(_af_graph(), cons, layout)
|
||||
assert any("span via" in f.finding for f in out if f.rule_id == "PE-AF-002")
|
||||
|
||||
|
||||
def test_via_with_span_is_not_a_pad():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3, layers=("F.Cu", "B.Cu"))]
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3, layers=("F.Cu", "B.Cu"))]
|
||||
out = check_af_traces(_af_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-AF-060" for f in out)
|
||||
assert all(f.rule_id != "PE-VIA-001" for f in out)
|
||||
|
||||
|
||||
def test_si_stub_still_fires_without_af_module():
|
||||
layout = _pair_layout(length=10.0, elbow=True)
|
||||
layout = _usb_pair_layout(length=10.0, elbow=True)
|
||||
cons = _rules({"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, "note": "USB stub"})
|
||||
pcb = run_pcb_checks(_usb_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-SI-007" for f in pcb)
|
||||
@@ -231,10 +268,13 @@ def test_hubaudio_usb_skip_has_no_invented_ohm():
|
||||
"USB_D-": _net("USB_D-", ("U1", "2")),
|
||||
},
|
||||
)
|
||||
# Real net names may be hierarchical; still must not invent ohms on AF skips.
|
||||
out = check_af_traces(graph, {}, layout)
|
||||
# USB must not produce PE-AF-002 tr/f or invented ohms.
|
||||
for f in out:
|
||||
if f.net and "USB" in (f.net or "").upper():
|
||||
assert f.rule_id != "PE-AF-002"
|
||||
if f.rule_id == "PE-AF-002":
|
||||
low = f.finding.lower()
|
||||
assert "90 Ω" not in f.finding and "50 Ω" not in f.finding
|
||||
assert "90 ohm" not in low
|
||||
assert "mancanza" not in low
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""HubAudio false findings: exposed-pad names, ESD cites, CC-via-net, Ethernet Z."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
from backend.periscopex.esd_return_check import check_esd
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
Net,
|
||||
NetType,
|
||||
PackageInfo,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import _run_l0_check, l0_findings
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
|
||||
|
||||
def _ic(ref: str, mpn: str, pins: dict[str, str], *, footprint: str = "") -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint=footprint,
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _cons(mpn: str, numbers: list[str], pin_count: int) -> ComponentConstraints:
|
||||
return ComponentConstraints(
|
||||
mpn=mpn,
|
||||
package_info=PackageInfo(base_family="QFN", package=f"QFN-{pin_count}", pin_count=pin_count),
|
||||
pintable=[Pin(number=n, name=n) for n in numbers],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
|
||||
|
||||
def _layout(ref: str, pads: list[tuple[str, str]], *, footprint: str) -> LayoutGraph:
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
ref: LayoutFootprint(
|
||||
reference=ref, footprint=footprint, x=0, y=0,
|
||||
pads=[
|
||||
LayoutPad(number=n, x=0, y=0, net="GND", pinfunction=pf)
|
||||
for n, pf in pads
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _bom(ref: str, mpn: str, numbers: list[str], pads: list[tuple[str, str]], pin_count: int):
|
||||
graph = DesignGraph(components={
|
||||
ref: _ic(ref, mpn, {n: "N" for n, _pf in pads if n}, footprint="Lib:Part"),
|
||||
})
|
||||
# Schematic pins follow the pintable numbers that are real contacts.
|
||||
graph.components[ref].pins = {n: "N" for n in numbers if n not in {"EP", "THERMAL PAD"}}
|
||||
layout = _layout(ref, pads, footprint="Lib:Part")
|
||||
findings = check_bom_pcb_datasheet(graph, {mpn: _cons(mpn, numbers, pin_count)}, layout)
|
||||
return [f for f in findings if f.rule_id == "PE-BOM-011"]
|
||||
|
||||
|
||||
def test_u16_ep_matches_pad_41():
|
||||
"""AXP2101: pintable EP, footprint pad 41 / EP_41."""
|
||||
hits = _bom(
|
||||
"U16", "AXP2101", ["1", "EP"],
|
||||
[("1", "CHGLED_1"), ("41", "EP_41")],
|
||||
40,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u18_ep_matches_pad_89():
|
||||
"""ADAU1467: pintable EP, footprint pad 89 / EP_89."""
|
||||
hits = _bom(
|
||||
"U18", "ADAU1467", ["1", "EP"],
|
||||
[("1", "DGND_1_1"), ("89", "EP_89")],
|
||||
88,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u19_ep_matches_pad_25_vss():
|
||||
"""LAN8720A: pintable EP, VQFN-24 land is pad 25 / VSS_25."""
|
||||
hits = _bom(
|
||||
"U19", "LAN8720A", ["1", "EP"],
|
||||
[("1", "VDD2A_1"), ("25", "VSS_25")],
|
||||
24,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_u5_thermal_pad_matches_epad_29():
|
||||
"""TAC5212: pintable THERMAL PAD, footprint pad 29 / EPAD_29."""
|
||||
hits = _bom(
|
||||
"U5", "XTAC5212", ["1", "THERMAL PAD"],
|
||||
[("1", "DREG_1"), ("29", "EPAD_29")],
|
||||
24,
|
||||
)
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_exposed_pad_absent_from_footprint_stays_error():
|
||||
hits = _bom(
|
||||
"U9", "BARE", ["1", "EP"],
|
||||
[("1", "PIN_1")],
|
||||
24,
|
||||
)
|
||||
assert hits
|
||||
assert any("EP" in (f.finding or "") for f in hits)
|
||||
|
||||
|
||||
def _esd_graph(net: str, *, j_fp: str = "", j_value: str = "JACK") -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value=j_value, footprint=j_fp,
|
||||
component_type=ComponentType.CONNECTOR, mpn="",
|
||||
pins={"13": net},
|
||||
),
|
||||
"U19": _ic("U19", "LAN8720A", {"1": net}),
|
||||
},
|
||||
nets={net: _net(net, ("J1", "13"), ("U19", "1"))},
|
||||
)
|
||||
|
||||
|
||||
def test_esd_does_not_say_datasheet_specified_without_a_cite():
|
||||
findings = check_esd(_esd_graph("USB_DP"))
|
||||
assert findings and findings[0].rule_id == "PE-ESD-001"
|
||||
blob = f"{findings[0].action} {findings[0].recommendation}"
|
||||
assert "datasheet-specified" not in blob
|
||||
|
||||
|
||||
def test_esd_quotes_datasheet_when_part_and_section_exist():
|
||||
cons = ComponentConstraints(
|
||||
mpn="LAN8720A",
|
||||
pintable=[],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
layout_rules=[{
|
||||
"kind": "esd",
|
||||
"document": "LAN8720A datasheet",
|
||||
"section": "3.2",
|
||||
"page": "12",
|
||||
"note": "ESD on USB_DP",
|
||||
}],
|
||||
)
|
||||
findings = check_esd(_esd_graph("USB_DP"), {"LAN8720A": cons})
|
||||
assert findings
|
||||
blob = f"{findings[0].action} {findings[0].finding}"
|
||||
assert "datasheet-specified" in blob
|
||||
assert "LAN8720A datasheet" in blob
|
||||
assert "3.2" in blob
|
||||
|
||||
|
||||
def test_3v3_ethernet_rail_is_not_an_esd_warning():
|
||||
assert check_esd(_esd_graph("3V3_ETHERNET")) == []
|
||||
|
||||
|
||||
def test_vbus_without_cite_is_not_an_esd_warning():
|
||||
graph = _esd_graph("VBUS")
|
||||
graph.components["U16"] = _ic("U16", "AXP2101", {"37": "VBUS"})
|
||||
graph.components["J2"] = Component(
|
||||
reference="J2", value="USB_C", footprint="Connector_USB:USB_C_Receptacle",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A4": "VBUS"},
|
||||
)
|
||||
graph.nets = {"VBUS": _net("VBUS", ("J2", "A4"), ("U16", "37"))}
|
||||
findings = check_esd(graph)
|
||||
assert findings == []
|
||||
blob = " ".join(f.action or "" for f in findings)
|
||||
assert "datasheet-specified" not in blob
|
||||
|
||||
|
||||
def test_optical_spdif_is_not_an_esd_warning():
|
||||
findings = check_esd(_esd_graph(
|
||||
"SPIF_IN",
|
||||
j_fp="OptoDevice:Broadcom_AFBR-16xxZ_Horizontal",
|
||||
j_value="TOSLINK",
|
||||
))
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_unverified_spdif_warns_it_was_not_seen_as_optical():
|
||||
findings = check_esd(_esd_graph("SPIF_OUT", j_fp="Connector:PinHeader"))
|
||||
assert findings
|
||||
assert findings[0].status == "WARNING"
|
||||
assert "not verified as optical" in (findings[0].finding or "")
|
||||
assert "datasheet-specified" not in (findings[0].action or "")
|
||||
|
||||
|
||||
def _cc_inst() -> PhysicalBusInstance:
|
||||
return PhysicalBusInstance(
|
||||
instance_id="usb-c-usb2-receptacle:J2",
|
||||
logical_protocol_id="usb2-hs",
|
||||
physical_interface_id="usb-c-usb2-receptacle",
|
||||
pcb_relevant="YES",
|
||||
confidence=1.0,
|
||||
evidence_kind="pinout",
|
||||
recognition_status="RECOGNIZED",
|
||||
host_ref="J2",
|
||||
nets=["Net-(J2-A5)", "Net-(J2-B5)"],
|
||||
)
|
||||
|
||||
|
||||
def _cc_graph(*, both: bool) -> DesignGraph:
|
||||
comps = {
|
||||
"J2": Component(
|
||||
reference="J2",
|
||||
value="USB_C_Receptacle_USB2.0_16P",
|
||||
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A5": "Net-(J2-A5)", "B5": "Net-(J2-B5)", "A4": "VBUS"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="5.1k", footprint="R_0603",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "Net-(J2-A5)", "2": "GND"},
|
||||
),
|
||||
}
|
||||
nets = {
|
||||
"Net-(J2-A5)": _net("Net-(J2-A5)", ("J2", "A5"), ("R1", "1")),
|
||||
"Net-(J2-B5)": _net("Net-(J2-B5)", ("J2", "B5")),
|
||||
"GND": _net("GND", ("R1", "2")),
|
||||
}
|
||||
if both:
|
||||
comps["R2"] = Component(
|
||||
reference="R2", value="5.1k", footprint="R_0603",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "Net-(J2-B5)", "2": "GND"},
|
||||
)
|
||||
nets["Net-(J2-B5)"] = _net("Net-(J2-B5)", ("J2", "B5"), ("R2", "1"))
|
||||
nets["GND"] = _net("GND", ("R1", "2"), ("R2", "2"))
|
||||
return DesignGraph(components=comps, nets=nets)
|
||||
|
||||
|
||||
def test_cc_rd_present_on_unnamed_nets_does_not_fail():
|
||||
row = _run_l0_check(_cc_graph(both=True), _cc_inst(), "cc_rd_rp", "MANDATORY")
|
||||
assert row.result == "PASS"
|
||||
assert "5.1" in row.notes
|
||||
assert "CC1/CC2 net not present" not in row.notes
|
||||
findings = l0_findings(_cc_inst(), [row])
|
||||
assert not any(f.rule_id == "PE-PRT-L0-001" for f in findings)
|
||||
|
||||
|
||||
def test_cc_rd_missing_on_pin_net_fails():
|
||||
row = _run_l0_check(_cc_graph(both=False), _cc_inst(), "cc_rd_rp", "MANDATORY")
|
||||
assert row.result == "FAIL"
|
||||
assert "B5" in row.notes
|
||||
assert "CC1/CC2 net not present" not in row.notes
|
||||
assert "5.1" in row.notes
|
||||
findings = l0_findings(_cc_inst(), [row])
|
||||
assert any(f.rule_id == "PE-PRT-L0-001" and f.status == "ERROR" for f in findings)
|
||||
|
||||
|
||||
def _eth_graph(mpn: str, *, value: str = "") -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45",
|
||||
footprint="Connector_RJ:RJ45_Abracon_ARJP11A-MA_Horizontal",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
|
||||
),
|
||||
"U19": _ic("U19", mpn, {"21": "RJ45_TXP", "20": "RJ45_TXN"}, footprint="VQFN-24"),
|
||||
},
|
||||
nets={
|
||||
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
|
||||
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
|
||||
},
|
||||
) if not value else DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45",
|
||||
footprint="Connector_RJ:RJ45",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
|
||||
),
|
||||
"U19": Component(
|
||||
reference="U19", value=value, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn,
|
||||
pins={"21": "RJ45_TXP", "20": "RJ45_TXN"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
|
||||
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _eth_layout() -> LayoutGraph:
|
||||
return LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_TXP"),
|
||||
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_TXN"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _eth_findings(graph: DesignGraph, monkeypatch, z: float | None):
|
||||
if z is not None:
|
||||
monkeypatch.setattr(
|
||||
"backend.periscopex.af_trace_check._pair_z_ohm",
|
||||
lambda layout, unit: z,
|
||||
)
|
||||
return [f for f in check_af_traces(graph, {}, _eth_layout()) if f.rule_id == "PE-AF-002"]
|
||||
|
||||
|
||||
def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, None)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f.status == "ERROR"
|
||||
blob = f"{f.finding} {f.action} {f.requirement}"
|
||||
assert f.finding.startswith("Ethernet not certified — because Z cannot be calculated")
|
||||
assert "100" in blob and "25.4.9" in blob
|
||||
assert "stackup" in blob
|
||||
assert "Fornire" not in blob
|
||||
assert "mancanza di tr" not in blob.lower()
|
||||
assert "mancanza di f" not in blob.lower()
|
||||
assert "90" not in blob
|
||||
assert "Clause 40" not in blob
|
||||
assert "1000BASE" not in blob
|
||||
assert "PASS" not in f.finding
|
||||
|
||||
|
||||
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].finding.startswith("Ethernet certified —")
|
||||
assert findings[0].status == "INFO"
|
||||
assert "RJ45_TXP" in (findings[0].facts or "") or "RJ45_TXP" in findings[0].finding
|
||||
assert "RJ45_TXN" in (findings[0].facts or "") or "RJ45_TXN" in findings[0].finding
|
||||
assert "25.4.9" in findings[0].finding
|
||||
|
||||
|
||||
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
|
||||
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].finding.startswith("Ethernet not certified — because Z")
|
||||
assert findings[0].status == "ERROR"
|
||||
assert "PASS" not in findings[0].finding
|
||||
assert "25.4.9" in findings[0].finding
|
||||
|
||||
|
||||
def test_gigabit_phy_uses_clause_40_not_100base(monkeypatch):
|
||||
findings = _eth_findings(
|
||||
_eth_graph("RTL8211F", value="1000BASE-T"),
|
||||
monkeypatch,
|
||||
None,
|
||||
)
|
||||
assert len(findings) == 1
|
||||
blob = findings[0].finding
|
||||
assert findings[0].status == "ERROR"
|
||||
assert "Clause 40" in blob
|
||||
assert "25.4.9" not in blob
|
||||
assert "PASS" not in blob
|
||||
@@ -0,0 +1,258 @@
|
||||
"""HubAudio AF noise: MDIO≠MDI, RD≡RX pair dedup, RMII/USB not Italian tr/f."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutDielectric,
|
||||
LayoutGraph,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
Net,
|
||||
NetType,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.si_check import bus_class, is_mdio_mgmt, single_ended_eth_mac
|
||||
|
||||
|
||||
def _ic(ref: str, mpn: str, pins: dict[str, str]) -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _stack() -> LayoutStackup:
|
||||
return LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||
copper_thickness_mm=0.035,
|
||||
)
|
||||
|
||||
|
||||
def test_eth_mdio_is_not_mdi():
|
||||
assert is_mdio_mgmt("ETH_MDIO")
|
||||
assert is_mdio_mgmt("ETH_MDC")
|
||||
assert is_mdio_mgmt("MDIO")
|
||||
assert bus_class("ETH_MDIO") is None
|
||||
assert bus_class("ETH_MDC") is None
|
||||
assert bus_class("/Ethernet/RJ45_RXP") == "eth_mdi"
|
||||
|
||||
|
||||
def test_mdio_does_not_get_100ohm_ieee_mdi(monkeypatch):
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U19": _ic("U19", "LAN8720A", {"12": "ETH_MDIO", "11": "ETH_MDC"}),
|
||||
"U3": _ic("U3", "MCU", {"20": "ETH_MDIO", "21": "ETH_MDC"}),
|
||||
},
|
||||
nets={
|
||||
"ETH_MDIO": _net("ETH_MDIO", ("U19", "12"), ("U3", "20")),
|
||||
"ETH_MDC": _net("ETH_MDC", ("U19", "11"), ("U3", "21")),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(30, 0), width=0.15, layer="F.Cu", net="ETH_MDIO"),
|
||||
LayoutSegment(start=(0, 0.5), end=(30, 0.5), width=0.15, layer="F.Cu", net="ETH_MDC"),
|
||||
],
|
||||
stackup=_stack(),
|
||||
)
|
||||
findings = check_af_traces(graph, {}, layout)
|
||||
blob = " ".join(f.finding for f in findings)
|
||||
assert "25.4.9" not in blob
|
||||
assert "100BASE-TX" not in blob
|
||||
assert not any(
|
||||
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
|
||||
for f in findings
|
||||
)
|
||||
|
||||
|
||||
def test_rd_plus_and_rxn_are_one_mdi_pair(monkeypatch):
|
||||
"""HubAudio-style dual names: RD+/RD- ≡ RXP/RXN — one cert, keep real Z FAIL."""
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45", footprint="RJ45",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={
|
||||
"1": "/Ethernet/RJ45_RD+", "2": "/Ethernet/RJ45_RD-",
|
||||
"3": "/Ethernet/RJ45_TD+", "4": "/Ethernet/RJ45_TD-",
|
||||
},
|
||||
),
|
||||
"U19": _ic("U19", "LAN8720A", {
|
||||
"23": "/Ethernet/RJ45_RXP", "22": "/Ethernet/RJ45_RXN",
|
||||
"21": "/Ethernet/RJ45_TXP", "20": "/Ethernet/RJ45_TXN",
|
||||
}),
|
||||
},
|
||||
nets={
|
||||
"/Ethernet/RJ45_RD+": _net("/Ethernet/RJ45_RD+", ("J1", "1")),
|
||||
"/Ethernet/RJ45_RD-": _net("/Ethernet/RJ45_RD-", ("J1", "2")),
|
||||
"/Ethernet/RJ45_TD+": _net("/Ethernet/RJ45_TD+", ("J1", "3")),
|
||||
"/Ethernet/RJ45_TD-": _net("/Ethernet/RJ45_TD-", ("J1", "4")),
|
||||
"/Ethernet/RJ45_RXP": _net("/Ethernet/RJ45_RXP", ("U19", "23")),
|
||||
"/Ethernet/RJ45_RXN": _net("/Ethernet/RJ45_RXN", ("U19", "22")),
|
||||
"/Ethernet/RJ45_TXP": _net("/Ethernet/RJ45_TXP", ("U19", "21")),
|
||||
"/Ethernet/RJ45_TXN": _net("/Ethernet/RJ45_TXN", ("U19", "20")),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD+"),
|
||||
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD-"),
|
||||
LayoutSegment(start=(0, 1), end=(20, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD+"),
|
||||
LayoutSegment(start=(0, 1.2), end=(20, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD-"),
|
||||
LayoutSegment(start=(30, 0), end=(50, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXP"),
|
||||
LayoutSegment(start=(30, 0.2), end=(50, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXN"),
|
||||
LayoutSegment(start=(30, 1), end=(50, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXP"),
|
||||
LayoutSegment(start=(30, 1.2), end=(50, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXN"),
|
||||
],
|
||||
stackup=_stack(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.periscopex.af_trace_check._pair_z_ohm",
|
||||
lambda layout, unit: 46.0,
|
||||
)
|
||||
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
|
||||
assert len(eth) == 2 # one RX, one TX — not four
|
||||
for f in eth:
|
||||
assert f.status == "ERROR"
|
||||
assert f.finding.startswith("Ethernet not certified — because Z")
|
||||
assert "46" in f.finding
|
||||
assert "100" in f.finding
|
||||
assert "25.4.9" in f.finding
|
||||
assert "speed" not in f.finding.lower() or "not identified" not in f.finding.lower()
|
||||
assert "mancanza" not in f.finding
|
||||
# Prefer PHY-side RXP/TXP naming in the kept unit.
|
||||
facts = " ".join((f.facts or "") + f.finding for f in eth)
|
||||
assert "RXP" in facts or "RXN" in facts
|
||||
assert "TXP" in facts or "TXN" in facts
|
||||
|
||||
|
||||
def test_rd_pair_alone_inherits_lan8720_speed(monkeypatch):
|
||||
"""Magnetics RD+/RD- with LAN8720 elsewhere: no 'speed not identified' ERROR."""
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"J1": Component(
|
||||
reference="J1", value="RJ45", footprint="RJ45",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"1": "RJ45_RD+", "2": "RJ45_RD-"},
|
||||
),
|
||||
"U19": _ic("U19", "LAN8720A", {"1": "ETH_RXD0"}),
|
||||
},
|
||||
nets={
|
||||
"RJ45_RD+": _net("RJ45_RD+", ("J1", "1")),
|
||||
"RJ45_RD-": _net("RJ45_RD-", ("J1", "2")),
|
||||
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1")),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_RD+"),
|
||||
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_RD-"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.periscopex.af_trace_check._pair_z_ohm",
|
||||
lambda layout, unit: None,
|
||||
)
|
||||
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
|
||||
assert len(eth) == 1
|
||||
assert "not identified" not in eth[0].finding
|
||||
assert "25.4.9" in eth[0].finding
|
||||
assert eth[0].finding.startswith("Ethernet not certified — because Z cannot be calculated")
|
||||
|
||||
|
||||
def test_rmii_no_italian_tr_f_pe_af_002():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U19": _ic("U19", "LAN8720A", {
|
||||
"1": "ETH_RXD0", "2": "ETH_RXD1",
|
||||
"3": "ETH_TXD0", "4": "ETH_TXD1", "5": "ETH_TXEN",
|
||||
}),
|
||||
"U3": _ic("U3", "MCU", {
|
||||
"10": "ETH_RXD0", "11": "ETH_RXD1",
|
||||
"12": "ETH_TXD0", "13": "ETH_TXD1", "14": "ETH_TXEN",
|
||||
}),
|
||||
},
|
||||
nets={
|
||||
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1"), ("U3", "10")),
|
||||
"ETH_RXD1": _net("ETH_RXD1", ("U19", "2"), ("U3", "11")),
|
||||
"ETH_TXD0": _net("ETH_TXD0", ("U19", "3"), ("U3", "12")),
|
||||
"ETH_TXD1": _net("ETH_TXD1", ("U19", "4"), ("U3", "13")),
|
||||
"ETH_TXEN": _net("ETH_TXEN", ("U19", "5"), ("U3", "14")),
|
||||
},
|
||||
)
|
||||
segs = []
|
||||
y = 0.0
|
||||
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
|
||||
assert single_ended_eth_mac(net)
|
||||
segs.append(LayoutSegment(start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net))
|
||||
y += 0.5
|
||||
layout = LayoutGraph(segments=segs, stackup=_stack())
|
||||
findings = check_af_traces(graph, {}, layout)
|
||||
assert not any(f.rule_id == "PE-AF-002" for f in findings)
|
||||
rmii = [
|
||||
f for f in findings
|
||||
if (f.finding or "").startswith("RMII certified —")
|
||||
or (f.finding or "").startswith("RMII not certified —")
|
||||
]
|
||||
assert len(rmii) == 1
|
||||
assert rmii[0].rule_id == "PE-AF-003"
|
||||
assert "AN-133" in rmii[0].finding or "impedance" in rmii[0].finding.lower()
|
||||
assert "mancanza" not in rmii[0].finding
|
||||
assert "Pista ad alta" not in rmii[0].finding
|
||||
assert "tr" not in rmii[0].finding.lower() or "because" in rmii[0].finding
|
||||
|
||||
|
||||
def test_usb_d_plus_skips_pe_af_002_italian_tr_f():
|
||||
"""USB_D+ must not get PE-AF-002 tr/f; protocol L2 owns the 90 Ω cert line."""
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"J2": Component(
|
||||
reference="J2", value="USB_C", footprint="USB_C",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A6": "USB_D+", "A7": "USB_D-"},
|
||||
),
|
||||
"U11": _ic("U11", "USB_PHY", {"1": "USB_D+", "2": "USB_D-"}),
|
||||
},
|
||||
nets={
|
||||
"USB_D+": _net("USB_D+", ("J2", "A6"), ("U11", "1")),
|
||||
"USB_D-": _net("USB_D-", ("J2", "A7"), ("U11", "2")),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(40, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
LayoutSegment(start=(0, 0.2), end=(40, 0.2), width=0.2, layer="F.Cu", net="USB_D-"),
|
||||
],
|
||||
stackup=_stack(),
|
||||
)
|
||||
findings = check_af_traces(graph, {}, layout)
|
||||
assert not any(f.rule_id == "PE-AF-002" for f in findings)
|
||||
assert not any("mancanza" in (f.finding or "") for f in findings)
|
||||
assert not any("Pista ad alta" in (f.finding or "") for f in findings)
|
||||
|
||||
from backend.periscopex.protocol_l2 import certify_l2
|
||||
from backend.periscopex.protocol_recognize import recognize_physical_buses
|
||||
|
||||
_l2, cert = certify_l2(
|
||||
graph, recognize_physical_buses(graph), layout=layout, impedance_nets=None,
|
||||
)
|
||||
usb = [
|
||||
f for f in cert
|
||||
if (f.finding or "").startswith("USB certified —")
|
||||
or (f.finding or "").startswith("USB not certified —")
|
||||
]
|
||||
assert len(usb) == 1
|
||||
assert "90" in usb[0].finding
|
||||
assert "mancanza" not in usb[0].finding
|
||||
@@ -0,0 +1,134 @@
|
||||
"""USB Type-C D+/D− one-line certification; J2 has no WARNING wall."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutDielectric,
|
||||
LayoutGraph,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
Net,
|
||||
NetType,
|
||||
PinConnection,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import protocol_exam
|
||||
from backend.periscopex.protocol_l2 import certify_l2, usb_pair_z_ohm
|
||||
from backend.periscopex.protocol_recognize import recognize_physical_buses
|
||||
|
||||
|
||||
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "") -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn or ref, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn or None, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _usb_c_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}, mpn="CH340E"),
|
||||
"J2": Component(
|
||||
reference="J2", value="USB_C",
|
||||
footprint="Connector_USB:USB_C_Receptacle",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A6": "USB_D+", "A7": "USB_D-", "A5": "CC1", "B5": "CC2",
|
||||
"A4": "VBUS", "A1": "GND"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="5.1k", footprint="",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "CC1", "2": "GND"},
|
||||
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
|
||||
),
|
||||
"R2": Component(
|
||||
reference="R2", value="5.1k", footprint="",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "CC2", "2": "GND"},
|
||||
specs=ResistorSpecs(value_ohms=5100.0, value_formatted="5.1k"),
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
||||
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
||||
"CC1": _net("CC1", ("J2", "A5"), ("R1", "1")),
|
||||
"CC2": _net("CC2", ("J2", "B5"), ("R2", "1")),
|
||||
"VBUS": _net("VBUS", ("J2", "A4")),
|
||||
"GND": Net(
|
||||
name="GND", net_type=NetType.GROUND,
|
||||
pins=[
|
||||
PinConnection(component_ref="J2", pin_number="A1"),
|
||||
PinConnection(component_ref="R1", pin_number="2"),
|
||||
PinConnection(component_ref="R2", pin_number="2"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _stack() -> LayoutStackup:
|
||||
return LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||
copper_thickness_mm=0.035,
|
||||
)
|
||||
|
||||
|
||||
def test_usb_missing_z_is_one_not_certified_line():
|
||||
sec, findings = protocol_exam(_usb_c_graph())
|
||||
usb = [i for i in sec.recognized_instances if "usb" in (i.get("logical_protocol_id") or "")]
|
||||
assert usb
|
||||
notes = [
|
||||
c.get("notes") or ""
|
||||
for row in usb
|
||||
for c in (row.get("report_checks") or row.get("l2_checks") or [])
|
||||
]
|
||||
cert = [n for n in notes if n.startswith("USB not certified —") or n.startswith("USB certified —")]
|
||||
assert len(cert) == 1
|
||||
assert cert[0].startswith("USB not certified — because Z cannot be calculated")
|
||||
assert "90" in cert[0]
|
||||
# No Italian warning wall on missing fields.
|
||||
j2 = [f for f in findings if f.designator == "J2" and (f.rule_id or "").startswith("PE-PRT-L")]
|
||||
assert not any("mancanza" in (f.finding or "") for f in j2)
|
||||
assert not any(f.rule_id == "PE-PRT-L1-001" for f in j2)
|
||||
assert not any(f.rule_id == "PE-PRT-L3-001" for f in j2)
|
||||
# At most one L2 cert finding for the pair (optional L0 FAIL allowed separately).
|
||||
l2 = [f for f in j2 if f.rule_id == "PE-PRT-L2-002"]
|
||||
assert len(l2) <= 1
|
||||
|
||||
|
||||
def test_usb_calculated_z_inside_cite_is_certified(monkeypatch):
|
||||
graph = _usb_c_graph()
|
||||
layout = LayoutGraph(
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
LayoutSegment(start=(0, 0.15), end=(20, 0.15), width=0.2, layer="F.Cu", net="USB_D-"),
|
||||
],
|
||||
stackup=_stack(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.periscopex.protocol_l2.usb_pair_z_ohm",
|
||||
lambda layout, nets, impedance_nets: (90.0, ""),
|
||||
)
|
||||
insts = recognize_physical_buses(graph)
|
||||
_by, findings = certify_l2(graph, insts, layout=layout)
|
||||
lines = [f.finding for f in findings if (f.finding or "").startswith("USB certified —")]
|
||||
assert len(lines) == 1
|
||||
assert "90" in lines[0]
|
||||
assert "not invented" not in lines[0].lower()
|
||||
|
||||
|
||||
def test_usb_pair_z_reports_missing_geometry():
|
||||
z, missing = usb_pair_z_ohm(None, ("USB_D+", "USB_D-"), None)
|
||||
assert z is None
|
||||
assert "stackup" in missing or "geometry" in missing
|
||||
@@ -0,0 +1,100 @@
|
||||
"""MAC–PHY pack from Micrel AN-133, OPEN Alliance RGMII EPL, Cisco SGMII."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.protocol_catalog import load_catalog
|
||||
|
||||
|
||||
def _by(pid: str) -> dict:
|
||||
cat = load_catalog()
|
||||
iface = next(p for p in cat.physical_interfaces if p.id == pid)
|
||||
return {c.parameter: c for c in iface.constraints}
|
||||
|
||||
|
||||
def _cite_ok(c) -> None:
|
||||
assert c.value_kind == "NUMERIC"
|
||||
assert c.value is not None
|
||||
src = c.source
|
||||
assert src is not None
|
||||
assert src.document
|
||||
assert src.organization
|
||||
assert src.revision
|
||||
assert src.page
|
||||
assert src.section or src.table
|
||||
assert src.url # archive path under standards/protocol-specs/
|
||||
|
||||
|
||||
def test_rmii_an133_vendor_numeric_and_z_unknown():
|
||||
by = _by("rmii-pcb")
|
||||
_cite_ok(by["refclk_frequency"])
|
||||
assert by["refclk_frequency"].value == 50.0
|
||||
assert by["refclk_frequency"].unit == "MHz"
|
||||
assert by["refclk_frequency"].source_type == "VENDOR"
|
||||
assert by["refclk_frequency"].source_class == "VENDOR"
|
||||
assert by["refclk_frequency"].source.organization == "Micrel"
|
||||
assert "AN-133" in by["refclk_frequency"].source.document or "133" in by["refclk_frequency"].source.document
|
||||
assert by["mdc_max_frequency"].value == 5.0
|
||||
assert by["source_termination_min"].value == 22.0
|
||||
assert by["source_termination_max"].value == 50.0
|
||||
assert by["source_termination_min"].mandatory == "OPTIONAL"
|
||||
assert by["impedance"].value_kind == "UNKNOWN"
|
||||
assert by["setup_time"].value_kind == "UNKNOWN"
|
||||
assert by["hold_time"].value_kind == "UNKNOWN"
|
||||
assert by["length"].value_kind == "UNKNOWN"
|
||||
# Must not invent a PCB Z0 from blogs or "typical".
|
||||
assert by["impedance"].value is None
|
||||
|
||||
|
||||
def test_mii_an133_vendor_25mhz():
|
||||
by = _by("mii-pcb")
|
||||
_cite_ok(by["tx_rx_clock_frequency"])
|
||||
assert by["tx_rx_clock_frequency"].value == 25.0
|
||||
assert by["tx_rx_clock_frequency"].source_type == "VENDOR"
|
||||
assert by["impedance"].value_kind == "UNKNOWN"
|
||||
|
||||
|
||||
def test_rgmii_open_alliance_standard_skews_z_unknown():
|
||||
by = _by("rgmii-pcb")
|
||||
_cite_ok(by["clock_duty_high_min"])
|
||||
assert by["clock_duty_high_min"].value == 3.6
|
||||
assert by["clock_duty_high_min"].source_type == "STANDARD"
|
||||
assert by["clock_duty_high_min"].source_class == "NORMATIVE"
|
||||
assert by["clock_duty_high_min"].source.organization == "OPEN Alliance"
|
||||
assert by["data_clock_skew_dod_source_max"].value == 0.5
|
||||
assert by["data_clock_skew_dod_dest_max"].value == 0.65
|
||||
assert by["data_clock_skew_dos_source_min"].value == 1.2
|
||||
assert by["data_clock_skew_dos_dest"].value == 1.05
|
||||
assert by["clock_data_delay_mismatch"].value == 150.0
|
||||
assert by["clock_data_delay_mismatch"].mandatory == "INFORMATIONAL"
|
||||
assert by["receiver_load_cap_max"].value == 5.0
|
||||
assert by["rise_time_max"].value == 1.0
|
||||
assert by["impedance"].value_kind == "UNKNOWN"
|
||||
assert by["length"].value_kind == "UNKNOWN"
|
||||
assert by["impedance"].value is None
|
||||
|
||||
|
||||
def test_sgmii_cisco_vendor_100ohm_rin_not_ieee_mdi():
|
||||
by = _by("sgmii-pcb")
|
||||
_cite_ok(by["differential_impedance"])
|
||||
assert by["differential_impedance"].value == 100.0
|
||||
assert by["differential_impedance"].source_type == "VENDOR"
|
||||
assert by["differential_impedance"].source_class == "VENDOR"
|
||||
assert by["differential_impedance"].source.organization == "Cisco Systems"
|
||||
assert by["differential_impedance"].mandatory == "RECOMMENDED"
|
||||
assert by["impedance_tolerance"].value == 1.0
|
||||
assert by["receiver_differential_impedance_min"].value == 80.0
|
||||
assert by["receiver_differential_impedance_max"].value == 120.0
|
||||
assert by["intra_pair_skew"].value == 20.0
|
||||
assert by["setup_time"].value == 100.0
|
||||
assert by["hold_time"].value == 100.0
|
||||
assert by["data_rate"].value == 1.25
|
||||
assert by["maximum_channel_length"].value_kind == "UNKNOWN"
|
||||
# Honest: not IEEE STANDARD / not RJ45 MDI clause.
|
||||
assert by["differential_impedance"].source_class != "NORMATIVE"
|
||||
assert by["differential_impedance"].source_type != "STANDARD"
|
||||
|
||||
|
||||
def test_logical_mii_rmii_present():
|
||||
cat = load_catalog()
|
||||
lids = {p.id for p in cat.logical_protocols}
|
||||
assert {"mii", "rmii", "rgmii", "sgmii"} <= lids
|
||||
@@ -0,0 +1,131 @@
|
||||
"""not_reviewed count matches list; pcb_review error is not a blanket label."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.periscopex.models import Component, ComponentConstraints, ComponentType, DesignGraph, Pin
|
||||
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||
from backend.periscopex.review_parse import ReviewResult
|
||||
from backend.services.pcb_validation import review_pcb_ics
|
||||
|
||||
|
||||
def test_merge_expands_joined_designators_and_matches_count():
|
||||
schema = {
|
||||
"findings": [],
|
||||
"coverage": {"U1": ["power"]},
|
||||
"not_reviewed": [
|
||||
{"designator": "U2,U3,U4", "reason": "no datasheet PDF"},
|
||||
],
|
||||
}
|
||||
pcb = {
|
||||
"findings": [],
|
||||
"coverage": {"U5": ["layout"]},
|
||||
"not_reviewed": [
|
||||
{"designator": "U6", "reason": "no library extraction — run schematic review first"},
|
||||
{"designator": "U1", "reason": "pcb_review error"}, # reviewed → drop
|
||||
{"designator": "U7", "reason": "pcb_review error"}, # → review_errors
|
||||
],
|
||||
"review_errors": {},
|
||||
}
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
nr = merged["not_reviewed"]
|
||||
refs = [r["designator"] for r in nr]
|
||||
assert refs == ["U2", "U3", "U4", "U6"]
|
||||
assert len(nr) == len(refs)
|
||||
assert "U1" not in refs # covered
|
||||
assert "U7" not in refs
|
||||
assert merged["review_errors"]["U7"] == "pcb_review error"
|
||||
assert "pcb_review error" not in {r["reason"] for r in nr}
|
||||
|
||||
|
||||
def test_merge_does_not_list_reviewed_as_not_reviewed():
|
||||
schema = {
|
||||
"findings": [],
|
||||
"coverage": {"U10": ["abs_max"], "U11": ["pinout"]},
|
||||
"not_reviewed": [{"designator": "U10", "reason": "no datasheet PDF"}],
|
||||
}
|
||||
pcb = {
|
||||
"findings": [],
|
||||
"coverage": {"U11": ["layout"]},
|
||||
"not_reviewed": [{"designator": "U11", "reason": "pcb_review error"}],
|
||||
}
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
assert merged["not_reviewed"] == []
|
||||
|
||||
|
||||
def test_pcb_review_exception_goes_to_review_errors_not_not_reviewed():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="LAN8720A", footprint="",
|
||||
component_type=ComponentType.IC, mpn="LAN8720A",
|
||||
pins={"1": "TXEN"},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
cons = ComponentConstraints(
|
||||
mpn="LAN8720A",
|
||||
pintable=[Pin(number="1", name="TXEN")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
|
||||
async def _boom(*_a, **_k):
|
||||
raise RuntimeError("model timeout")
|
||||
|
||||
async def _run():
|
||||
with patch(
|
||||
"backend.services.pcb_validation.review_ic_async",
|
||||
new=AsyncMock(side_effect=_boom),
|
||||
):
|
||||
return await review_pcb_ics(
|
||||
graph, {"LAN8720A": cons}, None, None, None, Path("/tmp"),
|
||||
)
|
||||
|
||||
findings, coverage, skipped, errors = asyncio.run(_run())
|
||||
assert findings == []
|
||||
assert coverage == {}
|
||||
assert skipped == []
|
||||
assert "U1" in errors
|
||||
assert "RuntimeError" in errors["U1"]
|
||||
assert "pcb_review error" not in errors["U1"]
|
||||
|
||||
|
||||
def test_successful_pcb_review_is_covered_not_skipped():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="X", footprint="",
|
||||
component_type=ComponentType.IC, mpn="HASPINTABLE",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
cons = ComponentConstraints(
|
||||
mpn="HASPINTABLE",
|
||||
pintable=[Pin(number="1", name="GND")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
|
||||
async def _ok(*_a, **_k):
|
||||
return ReviewResult([], []), {}
|
||||
|
||||
async def _run():
|
||||
with patch(
|
||||
"backend.services.pcb_validation.review_ic_async",
|
||||
new=AsyncMock(side_effect=_ok),
|
||||
):
|
||||
return await review_pcb_ics(
|
||||
graph, {"HASPINTABLE": cons}, None, None, None, Path("/tmp"),
|
||||
)
|
||||
|
||||
_f, coverage, skipped, errors = asyncio.run(_run())
|
||||
assert coverage == {"U1": ["layout"]}
|
||||
assert skipped == []
|
||||
assert errors == {}
|
||||
@@ -619,8 +619,9 @@ def test_pcb_ai_skips_without_library_extraction():
|
||||
graph, {}, None, None, None, Path("/tmp"),
|
||||
)
|
||||
|
||||
_f, _c, skipped = asyncio.run(_run())
|
||||
_f, _c, skipped, errors = asyncio.run(_run())
|
||||
assert skipped
|
||||
assert errors == {}
|
||||
assert "library extraction" in skipped[0]["reason"]
|
||||
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ def test_reject_same_logical_and_physical_id():
|
||||
def test_empty_report_section_has_no_z():
|
||||
section = empty_protocol_section()
|
||||
dumped = section.model_dump()
|
||||
assert section.message == "nessun protocollo riconosciuto"
|
||||
assert section.message == "No protocol recognized."
|
||||
assert section.recognized_instances == []
|
||||
assert dumped.get("max_level_reached") is None
|
||||
blob = json.dumps(dumped)
|
||||
@@ -238,7 +238,7 @@ def test_empty_report_section_has_no_z():
|
||||
summary={"ERROR": 0, "WARNING": 0, "INFO": 0},
|
||||
protocol_certification=dumped,
|
||||
)
|
||||
assert report.protocol_certification["message"] == "nessun protocollo riconosciuto"
|
||||
assert report.protocol_certification["message"] == "No protocol recognized."
|
||||
|
||||
|
||||
def test_pcie_cxl_physical_packs_are_empty_numbers():
|
||||
|
||||
@@ -62,7 +62,7 @@ def test_m10_high_speed_processor_ids_present():
|
||||
assert all(p.id != pid for p in ifaces)
|
||||
|
||||
|
||||
def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
|
||||
def test_m10_i2c_can_skeletons_named_checks_no_numbers():
|
||||
cat = load_catalog()
|
||||
i2c = next(p for p in cat.physical_interfaces if p.id == "i2c-pcb")
|
||||
can = next(p for p in cat.physical_interfaces if p.id == "can-pcb")
|
||||
@@ -76,7 +76,7 @@ def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
|
||||
assert set(i2c.required_checks) == set(I2C_CHECKS)
|
||||
assert set(can.required_checks) == set(CAN_CHECKS)
|
||||
assert set(rgmii.required_checks) == set(RGMII_CHECKS)
|
||||
for iface in (i2c, can, rgmii):
|
||||
for iface in (i2c, can):
|
||||
for c in iface.constraints:
|
||||
assert c.value is None
|
||||
assert c.value_kind in {
|
||||
@@ -84,6 +84,16 @@ def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
|
||||
"CONTROLLER_DEPENDENT", "VENDOR_DEPENDENT", "NOT_APPLICABLE",
|
||||
}
|
||||
assert c.value_kind != "NUMERIC"
|
||||
# RGMII is packed from OPEN Alliance EPL V2.2 — NUMERIC skews with cites;
|
||||
# PCB Z0 / mm length stay UNKNOWN (not invented).
|
||||
z = next(c for c in rgmii.constraints if c.parameter == "impedance")
|
||||
assert z.value_kind == "UNKNOWN"
|
||||
assert z.value is None
|
||||
skew = next(c for c in rgmii.constraints if c.parameter == "data_clock_skew_dod_source_max")
|
||||
assert skew.value_kind == "NUMERIC"
|
||||
assert skew.value == 0.5
|
||||
assert skew.source is not None
|
||||
assert skew.source.organization == "OPEN Alliance"
|
||||
|
||||
|
||||
def test_m10_no_invented_ohms_and_no_usbif_fill():
|
||||
@@ -96,9 +106,10 @@ def test_m10_no_invented_ohms_and_no_usbif_fill():
|
||||
else:
|
||||
assert c.value is None
|
||||
assert c.unit is None
|
||||
# i2c/can/HS processor skeletons must not invent ohms; RGMII NUMERIC is cited OPEN Alliance.
|
||||
blob = json.dumps([
|
||||
c.model_dump() for p in loaded.physical_interfaces for c in p.constraints
|
||||
if p.logical_protocol_id in HS_LOGICAL | {"i2c", "can", "rgmii"}
|
||||
if p.logical_protocol_id in HS_LOGICAL | {"i2c", "can"}
|
||||
])
|
||||
for token in NO_INVENTED:
|
||||
assert token not in blob
|
||||
|
||||
@@ -155,8 +155,7 @@ def test_usb_no_layout_is_visible_skip_not_pass():
|
||||
assert rows
|
||||
assert all(r.result != "PASS" or r.check == "never" for r in rows)
|
||||
assert any(r.result == "UNKNOWN" for r in rows)
|
||||
assert any(f.rule_id == "PE-PRT-L1-001" for f in findings)
|
||||
assert any("non certificata a L1" in (f.finding or "") for f in findings)
|
||||
assert not any(f.rule_id == "PE-PRT-L1-001" for f in findings)
|
||||
|
||||
|
||||
def test_axi_internal_l1_not_applicable():
|
||||
|
||||
@@ -109,21 +109,25 @@ def test_usb_z_missing_source_never_invents_90():
|
||||
rows, findings, _ = _l2_for(_usb_connected(), "usb2", impedance_nets=zrep)
|
||||
z = [r for r in rows if r.check == "differential_impedance"]
|
||||
assert z
|
||||
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
|
||||
assert z[0].result != "PASS"
|
||||
blob = json.dumps(z[0].model_dump())
|
||||
assert "90" not in blob
|
||||
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||
assert any("non certificata a L2" in (f.finding or "") for f in findings)
|
||||
assert z[0].result == "FAIL"
|
||||
assert z[0].limit_ohm == 90.0
|
||||
assert z[0].measured_ohm is None
|
||||
line = z[0].notes
|
||||
assert line.startswith("USB not certified — because Z cannot be calculated")
|
||||
assert "90" in line
|
||||
assert "not invented" not in line.lower()
|
||||
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||
assert any(f.finding == line for f in findings)
|
||||
|
||||
|
||||
def test_usb_z_skip_when_no_stackup_measurement():
|
||||
rows, findings, _ = _l2_for(_usb_connected(), "usb2")
|
||||
z = [r for r in rows if r.check == "differential_impedance"]
|
||||
assert z
|
||||
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
|
||||
assert z[0].result == "FAIL"
|
||||
assert z[0].measured_ohm is None
|
||||
assert any("mancanza di Z" in (f.finding or "") for f in findings)
|
||||
assert z[0].limit_ohm == 90.0
|
||||
assert any(f.finding.startswith("USB not certified —") for f in findings)
|
||||
|
||||
|
||||
def test_cited_pack_z_pass_and_fail():
|
||||
@@ -220,7 +224,7 @@ def test_ddr_impedance_phy_dependent_not_invented_ohm():
|
||||
blob = json.dumps(z[0].model_dump())
|
||||
assert "40" not in blob
|
||||
assert "90" not in blob
|
||||
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
|
||||
|
||||
|
||||
def test_length_is_not_delay_timing_unknown():
|
||||
|
||||
@@ -173,6 +173,7 @@ def test_remaining_margin_is_total_minus_used():
|
||||
|
||||
|
||||
def test_usb_hdmi_pcie_skip_without_channel_data():
|
||||
"""No channel FACT → no L3 warning wall (silence, not Italian mancanza)."""
|
||||
for graph, token in ((_usb_connected(), "usb"), (_hdmi_graph(), "hdmi"), (_pcie_graph(), "pcie")):
|
||||
sec, findings = protocol_exam(graph)
|
||||
assert sec.max_level_reached == "L3"
|
||||
@@ -183,17 +184,9 @@ def test_usb_hdmi_pcie_skip_without_channel_data():
|
||||
or token in str(r.get("physical_interface_id")).lower()
|
||||
)
|
||||
l3 = row["l3_checks"]
|
||||
assert l3
|
||||
names = {c["check"] for c in l3}
|
||||
assert {"timing_budget", "insertion_loss", "return_loss", "crosstalk", "channel"} <= names
|
||||
assert all(c["result"] != "PASS" for c in l3)
|
||||
notes = " ".join(c.get("notes") or "" for c in l3)
|
||||
assert "non certificata a L3 per mancanza" in notes
|
||||
assert "OpenEMS" in notes
|
||||
blob = json.dumps(l3)
|
||||
assert "90" not in blob
|
||||
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||
assert "s21" not in blob.lower() or "invent" in notes.lower()
|
||||
assert l3 == []
|
||||
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||
assert not any("mancanza" in (f.finding or "") for f in findings if f.source == "protocol_l3")
|
||||
|
||||
|
||||
def test_axi_l3_not_applicable_not_fail():
|
||||
@@ -269,13 +262,8 @@ def test_raw_touchstone_without_fact_is_skip_not_invented():
|
||||
inst = _inst()
|
||||
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
|
||||
rows = certify_instance_l3(inst, iface, channel_data=data)
|
||||
assert all(r.result != "PASS" for r in rows)
|
||||
notes = " ".join(r.notes for r in rows)
|
||||
assert "non certificata a L3" in notes
|
||||
assert "invent" in notes.lower() or "non inventati" in notes.lower() or "not invented" in notes.lower()
|
||||
blob = json.dumps([r.model_dump() for r in rows])
|
||||
# no synthesized S21 numbers
|
||||
assert '"measured_db": null' in blob or all(r.measured_db is None for r in rows)
|
||||
# Raw S-param file is not reduced channel FACT — omit L3 (no warning wall).
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_openems_method_is_explicit_skip():
|
||||
@@ -289,8 +277,8 @@ def test_openems_method_is_explicit_skip():
|
||||
}
|
||||
}
|
||||
rows = certify_instance_l3(inst, iface, channel_data=data)
|
||||
notes = " ".join(r.notes for r in rows)
|
||||
assert "non certificata a L3" in notes
|
||||
# OpenEMS/FEM is out of product — omit L3 rather than invent S-parameters.
|
||||
assert rows == []
|
||||
assert all(r.result != "PASS" for r in rows)
|
||||
|
||||
|
||||
@@ -298,19 +286,16 @@ def test_no_sparam_invention_on_empty_channel_data():
|
||||
insts = recognize_physical_buses(_usb_connected())
|
||||
by, findings = certify_l3(insts, channel_data=None)
|
||||
rows = [r for lst in by.values() for r in lst]
|
||||
blob = json.dumps([r.model_dump() for r in rows])
|
||||
assert "s21" not in blob.lower()
|
||||
assert all(r.measured_db is None for r in rows)
|
||||
assert any("mancanza" in r.notes for r in rows)
|
||||
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||
assert rows == []
|
||||
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
|
||||
|
||||
|
||||
def test_protocol_exam_attaches_l3_and_does_not_call_l0_electrical():
|
||||
sec, _ = protocol_exam(_usb_connected())
|
||||
row = sec.recognized_instances[0]
|
||||
assert row.get("l3_checks")
|
||||
assert all(c.get("level") == "L3" for c in row["l3_checks"])
|
||||
notes = " ".join(c.get("notes") or "" for c in row["l3_checks"])
|
||||
assert "L0/L1 are not electrical" in notes
|
||||
# L3 omitted without channel FACT; L0/L1/L2 still attached.
|
||||
assert row.get("l3_checks") == []
|
||||
assert row.get("l0_checks")
|
||||
assert row.get("l2_checks")
|
||||
l0notes = json.dumps(row.get("l0_checks") or [])
|
||||
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
|
||||
|
||||
@@ -138,9 +138,9 @@ def test_axi4_internal_does_not_create_pcb_nets():
|
||||
assert "AXI_WDATA" not in one.nets
|
||||
|
||||
|
||||
def test_empty_graph_keeps_nessun_protocollo():
|
||||
def test_empty_graph_keeps_no_protocol_message():
|
||||
sec = protocol_section_for_graph(DesignGraph())
|
||||
assert sec.message == "nessun protocollo riconosciuto"
|
||||
assert sec.message == "No protocol recognized."
|
||||
assert sec.recognized_instances == []
|
||||
blob = json.dumps(sec.model_dump())
|
||||
assert "ohm" not in blob.lower()
|
||||
|
||||
@@ -93,7 +93,7 @@ def test_result_rank_fail_before_warning():
|
||||
assert instance_worst_result(["UNKNOWN", "FAIL", "PASS"]) == "FAIL"
|
||||
|
||||
|
||||
def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
|
||||
def test_usb2_pair_l0_pass_l2_z_cert_line():
|
||||
sec, _ = protocol_exam(_usb_connected())
|
||||
assert sec.max_level_reached == "L3"
|
||||
assert sec.macrophase == "M9"
|
||||
@@ -106,20 +106,19 @@ def test_usb2_pair_l0_pass_l2_z_unknown_no_invented_ohm():
|
||||
assert l0_topo and l0_topo[0]["result"] == "PASS"
|
||||
z = [c for c in checks if c["check"] == "differential_impedance"]
|
||||
assert z
|
||||
assert z[0]["result"] in {"MISSING_SOURCE", "UNKNOWN"}
|
||||
assert z[0]["skip_visible"] is True
|
||||
assert z[0]["measured"] is None
|
||||
assert z[0]["limit"] is None
|
||||
blob = json.dumps(z[0])
|
||||
assert "90" not in blob
|
||||
assert z[0]["result"] == "FAIL"
|
||||
assert z[0]["limit"] == 90.0 or (z[0].get("notes") or "").find("90") >= 0
|
||||
notes = z[0].get("notes") or ""
|
||||
assert notes.startswith("USB not certified —")
|
||||
assert "90" in notes
|
||||
assert "not invented" not in notes.lower()
|
||||
chain = z[0]["chain"]
|
||||
assert chain["physical_interface_id"]
|
||||
assert chain["logical_protocol_id"]
|
||||
assert chain["physical_interface_id"] != chain["logical_protocol_id"]
|
||||
# No L3 warning wall without channel FACT.
|
||||
l3 = [c for c in checks if c["level"] == "L3"]
|
||||
assert l3 and l3[0]["skip_visible"]
|
||||
assert l3[0]["result"] != "PASS"
|
||||
assert "OpenEMS" in l3[0]["notes"]
|
||||
assert l3 == []
|
||||
|
||||
|
||||
def test_fail_listed_before_warning_in_report_checks():
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""RMII PHY–MAC is not RJ45 100 Ω; recognition alone is not certified."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutDielectric,
|
||||
LayoutGraph,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
Net,
|
||||
NetType,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.si_check import bus_class, phy_mac_kind, single_ended_eth_mac
|
||||
|
||||
|
||||
def _ic(ref: str, mpn: str, pins: dict[str, str]) -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _rmii_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U19": _ic("U19", "LAN8720A", {
|
||||
"1": "ETH_RXD0", "2": "ETH_RXD1",
|
||||
"3": "ETH_TXD0", "4": "ETH_TXD1", "5": "ETH_TXEN",
|
||||
}),
|
||||
"U3": _ic("U3", "MCU", {
|
||||
"10": "ETH_RXD0", "11": "ETH_RXD1",
|
||||
"12": "ETH_TXD0", "13": "ETH_TXD1", "14": "ETH_TXEN",
|
||||
}),
|
||||
},
|
||||
nets={
|
||||
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1"), ("U3", "10")),
|
||||
"ETH_RXD1": _net("ETH_RXD1", ("U19", "2"), ("U3", "11")),
|
||||
"ETH_TXD0": _net("ETH_TXD0", ("U19", "3"), ("U3", "12")),
|
||||
"ETH_TXD1": _net("ETH_TXD1", ("U19", "4"), ("U3", "13")),
|
||||
"ETH_TXEN": _net("ETH_TXEN", ("U19", "5"), ("U3", "14")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _layout() -> LayoutGraph:
|
||||
segs = []
|
||||
y = 0.0
|
||||
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
|
||||
segs.append(LayoutSegment(
|
||||
start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net,
|
||||
))
|
||||
y += 0.5
|
||||
return LayoutGraph(
|
||||
segments=segs,
|
||||
stackup=LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||
copper_thickness_mm=0.035,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_eth_rxd_is_rmii_not_mdi():
|
||||
for n in ("ETH_RXD0", "ETH_TXD0", "ETH_TXEN"):
|
||||
assert single_ended_eth_mac(n)
|
||||
assert bus_class(n) == "rmii"
|
||||
assert phy_mac_kind(n) == "rmii"
|
||||
assert bus_class(n) != "eth_mdi"
|
||||
|
||||
|
||||
def test_rmii_recognition_is_not_certified():
|
||||
findings = check_af_traces(_rmii_graph(), {}, _layout())
|
||||
rmii = [
|
||||
f for f in findings
|
||||
if (f.finding or "").startswith("RMII certified —")
|
||||
or (f.finding or "").startswith("RMII not certified —")
|
||||
]
|
||||
assert len(rmii) == 1
|
||||
blob = rmii[0].finding
|
||||
assert blob.startswith("RMII not certified — because")
|
||||
assert "Identified as single-ended RMII" in blob
|
||||
assert "not an RJ45/MDI" in blob or "not an RJ45" in blob
|
||||
# Old fake line certified on recognition alone — must never reappear.
|
||||
assert not blob.startswith("RMII certified —")
|
||||
assert "identified as single-ended RMII PHY–MAC" not in blob.split("—", 1)[0]
|
||||
assert "AN-133" in blob or "UNKNOWN" in blob or "impedance" in blob.lower()
|
||||
assert rmii[0].status == "ERROR"
|
||||
assert rmii[0].rule_id == "PE-AF-003"
|
||||
assert "mancanza" not in blob
|
||||
assert not any(
|
||||
"mancanza di tr" in (f.finding or "") or "mancanza di f" in (f.finding or "")
|
||||
for f in findings
|
||||
)
|
||||
|
||||
|
||||
def test_rmii_not_given_100_ohm_mdi_finding():
|
||||
findings = check_af_traces(_rmii_graph(), {}, _layout())
|
||||
assert not any("25.4.9" in (f.finding or "") for f in findings)
|
||||
assert not any(
|
||||
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
|
||||
for f in findings
|
||||
if f.net and single_ended_eth_mac(f.net)
|
||||
)
|
||||
@@ -25,6 +25,7 @@ def test_rewritten_pages_are_src():
|
||||
"app/(marketing)/file-guide/page.tsx",
|
||||
"app/layout.tsx",
|
||||
"components/layout/sidebar.tsx",
|
||||
"components/layout/top-bar.tsx",
|
||||
"hooks/use-pipeline-progress.ts",
|
||||
"hooks/use-report.ts",
|
||||
]
|
||||
@@ -35,6 +36,20 @@ def test_rewritten_pages_are_src():
|
||||
assert "Native Periscope overlay" not in head
|
||||
|
||||
|
||||
def test_app_shell_uses_top_bar():
|
||||
text = (SRC / "app/(app)/layout.tsx").read_text(encoding="utf-8")
|
||||
assert "TopBar" in text
|
||||
assert "flex-col" in text
|
||||
assert "<Sidebar" not in text
|
||||
|
||||
|
||||
def test_report_save_as_markdown():
|
||||
text = (SRC / "app/(app)/project/[id]/report/page.tsx").read_text(encoding="utf-8")
|
||||
assert "exportReportToMarkdown" in text
|
||||
assert "Save as Markdown" in text
|
||||
assert "Export Excel" not in text
|
||||
|
||||
|
||||
def test_landing_keeps_pricing_seam_import():
|
||||
text = (SRC / "app/(marketing)/page.tsx").read_text(encoding="utf-8")
|
||||
assert "PricingSection" in text
|
||||
|
||||
@@ -37,8 +37,12 @@ def test_site_exports():
|
||||
|
||||
def test_report_export_entry():
|
||||
text = (SRC / "src/lib/report-export.ts").read_text(encoding="utf-8")
|
||||
assert "export function exportReportToExcel" in text
|
||||
assert "Findings" in text
|
||||
assert "export function exportReportToMarkdown" in text
|
||||
assert "export function buildReportMarkdown" in text
|
||||
assert "Save as Markdown" not in text # UI copy lives on the report page
|
||||
assert ".md" in text
|
||||
assert "xlsx" not in text.lower()
|
||||
assert "spreadsheet" in text.lower() or "Markdown" in text
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
|
||||
@@ -21,13 +21,22 @@ def test_theme_provider_contract():
|
||||
text = (SRC / "components/theme/theme-provider.tsx").read_text(encoding="utf-8")
|
||||
assert "export function ThemeProvider" in text
|
||||
assert 'attribute="class"' in text
|
||||
assert 'defaultTheme="dark"' in text
|
||||
assert 'defaultTheme="light"' in text
|
||||
assert "enableSystem={false}" in text
|
||||
assert "disableTransitionOnChange" in text
|
||||
assert 'storageKey="periscope-theme"' in text
|
||||
clerk = SRC / "components/theme/clerk-theme-provider.tsx"
|
||||
assert not clerk.exists()
|
||||
|
||||
|
||||
def test_appearance_settings_contract():
|
||||
text = (SRC / "components/theme/appearance-settings.tsx").read_text(encoding="utf-8")
|
||||
assert "export function AppearanceSettings" in text
|
||||
assert "setTheme" in text
|
||||
assert "Light" in text
|
||||
assert "Dark" in text
|
||||
|
||||
|
||||
def test_theme_toggle_contract():
|
||||
text = (SRC / "components/theme/theme-toggle.tsx").read_text(encoding="utf-8")
|
||||
assert "export function ThemeToggle" in text
|
||||
|
||||
Reference in New Issue
Block a user