Compare commits

...
Author SHA1 Message Date
michele 5b2d3cd613 iPad top-bar shell, light theme default, Markdown report export (2.85.0).
Replace the left sidebar with a thin top toolbar and tab/sheet navigation.
Light is default; dark mode lives in Settings and persists. Report export
is Save as Markdown (.md) with the findings-tree hierarchy — not Excel.
2026-09-23 07:03:16 +02:00
michele 5ab69a835c English findings-tree labels and PCB stage timings (2.84.0).
Certifications, Protocols, domains, empty states, and the protocol empty message are English. Error / Warning / Info stay. Sidebar version is stamped 2.84.0. A PCB exam records extract, pcb_checks, protocol, af, and total seconds.
2026-09-22 22:15:23 +02:00
michele f79c722e68 Match exposed pads by land, cite ESD, follow CC to Rd (2.84.0).
PE-BOM-011 treats EP/EPAD/THERMAL PAD and the footprint exposed land as one pad when both sides have one. PE-ESD-001 quotes a datasheet only with part and section, and does not warn on a 3V3 jack rail, uncitable VBUS, or optical S/PDIF. CC Rd is the packed 5.1 kΩ on the Type-C CC pin net. Ethernet MDI pair Z is PASS, FAIL, or ERROR against the cited 10/100 or gigabit clause.
2026-09-22 21:56:09 +02:00
michele 7e872cfbc3 Drop .ts suffixes from frontend imports for Next typecheck.
Production next build rejects allowImportingTsExtensions-style paths.
2026-09-22 18:54:25 +02:00
michele 3046c4aeee Fix findings-tree TS: do not pass findings twice.
Spread FindingsTreeProps first, then override protocol findings for CertificationsBranch so Next production typecheck can build 2.83.0.
2026-09-22 18:53:13 +02:00
michele b32d581d27 Merge protocol-cert-m0 (vSafe UNKNOWN 2.83) into ui-apple-shell.
Keep Apple shell + findings tree; include USB-C vSafe UNKNOWN from catalog (no invented volts). Changelog 2.83.0 covers both.
2026-09-22 18:49:52 +02:00
michele 29e1a3033d Leave USB-C vSafe UNKNOWN; AF zip has no USB-IF PD (2.83.0).
Alta Frequenza / af-allegati only hold TI SI (SNLA027B, SDAA499), which do not state vSafe5V/vSafe0V. CabCon R2.5 §1.6 defers volts to USB PD. HDMI 1.4 TMDS stays UNKNOWN.
2026-09-22 18:38:33 +02:00
35 changed files with 1903 additions and 438 deletions
@@ -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
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
@@ -35,6 +37,198 @@ _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,
)
_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."""
m = _MDI_POL.match(name or "")
if m:
pol = m.group("pol").upper()
return m.group("stem") + ("N" if pol == "P" else "P")
return partner_net(name)
def _is_eth_mdi_net(net: str) -> bool:
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 names."""
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 out
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
"""10/100 or gbe from the PHY/jack actually on these nets. Else None."""
bits: list[str] = []
for net in nets:
for ref in refs_on_matched_net(graph, net):
comp = graph.components.get(ref)
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)))
text = " ".join(bits)
if _GBE_RE.search(text):
return "gbe"
if _FAST_RE.search(text):
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"ERROR: Ethernet pair {label} speed is not identified on the "
"PHY/jack, so no 10/100 or 1000 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 = (
f"ERROR: pair Z for {label} cannot be calculated from the PCB "
f"({miss} missing). Cited limit {limit:g} Ω, {cite_label}."
)
rec = (
f"Pair Z was not calculated ({miss} missing). "
f"Cited limit {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"PASS: {label} pair Z {measured:g} Ω is inside the cited limit "
f"{limit:g} Ω, {cite_label}."
)
rec = f"Pair Z matches {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"FAIL: {label} pair Z {measured:g} Ω is outside the cited limit "
f"{limit:g} Ω, {cite_label}."
)
rec = (
f"Pair Z {measured:g} Ω is outside {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 +244,10 @@ 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):
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
if unit.bus == "eth_mdi":
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))
@@ -116,7 +313,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 +331,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)),
"",
)
rec = (
f"Add a datasheet-specified ESD device on {net} between "
f"{j_ref} and {ic_ref}."
)
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"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 = (
"ConnectorIC 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 connectorIC 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,
+1 -1
View File
@@ -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
@@ -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",
@@ -582,9 +582,19 @@ DOC_USB_LSFS = (
"Universal Serial Bus Specification Revision 2.0 §7 (field not a single PCB number)"
)
DOC_USB_PD_VSAFE = (
"USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V "
"and tVBUSON as the minimum vSafe5V threshold without restating the volt range. "
"Type-C Current charger 4.755.5 V is not vSafe5V)"
"USB Power Delivery Specification (USB-IF; not on file). "
"USB Type-C Cable and Connector Specification Release 2.5, March 2026 "
"§1.6 p.35: vSafe5V is VBUS “5 volts” as defined by the USB PD specification "
"(no volt range restated). "
"Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027BMay 2004Revised May 2004) "
"and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 September 2026) "
"do not state vSafe5V. Type-C Current charger Figure 4-39 4.755.5 V is not vSafe5V."
)
DOC_USB_PD_VSAFE0 = (
"USB Power Delivery Specification (USB-IF; not on file). "
"USB Type-C Cable and Connector Specification Release 2.5, March 2026 "
"§1.6 p.35: vSafe0V is VBUS “0 volts” as defined by the USB PD specification "
"(no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
)
DOC_IEEE_8023 = "IEEE 802.3 (clause for this variant; not on file)"
DOC_IEEE_2012 = "IEEE Std 802.3-2012 IEEE Standard for Ethernet"
@@ -1173,6 +1183,10 @@ def build_m0_catalog() -> dict[str, Any]:
pid, "vsafe5v", "UNKNOWN",
needed_document=DOC_USB_PD_VSAFE,
))
out.append(_c(
pid, "vsafe0v", "UNKNOWN",
needed_document=DOC_USB_PD_VSAFE0,
))
return out
def _eth_cons(pid: str, lid: str) -> list[dict[str, Any]]:
@@ -1949,7 +1963,7 @@ def build_m0_catalog() -> dict[str, Any]:
hints=["USB_C", "TYPE_C", "CC1", "CC2"],
notes=(
"USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. "
"Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. VBUS volts still USB PD vSafe5V."
"Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. Figure 4-39 charger volts are not vSafe5V. USB PD vSafe still not on file."
),
))
usb2_over_c = _usb2_cons("usb-c-usb2-receptacle", speed="hs")
@@ -2930,10 +2930,25 @@
"conditions": [],
"value_origin": "SPEC",
"typical": false,
"needed_document": "USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V and tVBUSON as the minimum vSafe5V threshold without restating the volt range. Type-C Current charger 4.75\u20135.5 V is not vSafe5V)"
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe5V is VBUS \u201c5 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027B\u2013May 2004\u2013Revised May 2004) and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 \u2013 September 2026) do not state vSafe5V. Type-C Current charger Figure 4-39 4.75\u20135.5 V is not vSafe5V."
},
{
"id": "usb-c-receptacle-vsafe0v",
"parameter": "vsafe0v",
"value_kind": "UNKNOWN",
"mandatory": "MANDATORY",
"source_type": "STANDARD",
"source_class": "NORMATIVE",
"value": null,
"unit": null,
"source": null,
"conditions": [],
"value_origin": "SPEC",
"typical": false,
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe0V is VBUS \u201c0 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
}
],
"notes": "USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. VBUS volts still USB PD vSafe5V.",
"notes": "USB Type-C connector/interface system. Not USB 2.0/3.x/USB4 protocol. Rp/Rd/Ra from CabCon R2.5 Tables 4-27/4-28/4-29. Figure 4-39 charger volts are not vSafe5V. USB PD vSafe still not on file.",
"incomplete": false
},
{
@@ -3902,7 +3917,22 @@
"conditions": [],
"value_origin": "SPEC",
"typical": false,
"needed_document": "USB Power Delivery specification vSafe5V (not on file; Type-C CabCon R2.5 names vSafe5V and tVBUSON as the minimum vSafe5V threshold without restating the volt range. Type-C Current charger 4.75\u20135.5 V is not vSafe5V)"
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe5V is VBUS \u201c5 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza zip snla027b.pdf (AN-807 Reflections SNLA027B\u2013May 2004\u2013Revised May 2004) and sdaa499.pdf (Simple Methods to Reduce EMI from a PCB Trace, SDAA499 \u2013 September 2026) do not state vSafe5V. Type-C Current charger Figure 4-39 4.75\u20135.5 V is not vSafe5V."
},
{
"id": "usb-c-usb2-receptacle-vsafe0v",
"parameter": "vsafe0v",
"value_kind": "UNKNOWN",
"mandatory": "MANDATORY",
"source_type": "STANDARD",
"source_class": "NORMATIVE",
"value": null,
"unit": null,
"source": null,
"conditions": [],
"value_origin": "SPEC",
"typical": false,
"needed_document": "USB Power Delivery Specification (USB-IF; not on file). USB Type-C Cable and Connector Specification Release 2.5, March 2026 \u00a71.6 p.35: vSafe0V is VBUS \u201c0 volts\u201d as defined by the USB PD specification (no volt range restated). Alta Frequenza TI SI PDFs do not state vSafe0V."
},
{
"id": "usb-c-usb2-receptacle-cc_rd_rp",
+165 -8
View File
@@ -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))]
+41 -4
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -220,6 +221,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 +256,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 +318,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 +329,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 +338,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 +346,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 +357,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):
@@ -387,11 +407,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))
@@ -422,10 +445,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 +470,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)})
+30 -2
View File
@@ -2,14 +2,42 @@
What's new in Periscope.
## 2.83.0 — 2026-09-22Apple-like shell, findings tree, HDMI 1.4 UNKNOWN
## 2.85.0 — 2026-09-23iPad top-bar shell; Markdown report export
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. Catalog JSON untouched.
Replace the left sidebar with a thin top toolbar. Navigation is tabs (and sheets on narrow widths) from that bar. Light theme is the default; dark mode lives in Settings and persists on the device. Report stays the findings tree. Export is Save as Markdown (.md), not Excel.
- [Changed] App shell: `TopBar` instead of sidebar column (`layout`, `top-bar`).
- [Changed] Theme default `light`; `storageKey=periscope-theme`; Appearance in Settings sheet + project Settings tab.
- [Changed] Report export: `exportReportToMarkdown` / `buildReportMarkdown` (tree hierarchy as Markdown lists).
- [Changed] English chrome labels on the top bar (Projects, Exam, Report, Settings).
- [Changed] Frontend contract tests for shell, theme, and Markdown export.
## 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.755.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.
## 2.82.0 — 2026-09-22 — HDMI 2.0+ TI fields use source_type VENDOR
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.83.0",
"version": "2.85.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",
@@ -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,8 +12,8 @@ export default function AppShellLayout({
<TooltipProvider>
<CreditsProvider>
<AuthGate>
<div className="flex h-full">
<Sidebar />
<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>
@@ -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>
@@ -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";
@@ -260,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"
@@ -343,9 +346,9 @@ function ReportContent({ projectId }: { projectId: string }) {
{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">Nessun finding</p>
<p className="text-[15px] font-medium tracking-tight">No findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
Carica i datasheet degli IC sulla pagina progetto e rilancia lesame.
Upload the IC datasheets on the project page and run the exam again.
</p>
</div>
) : (
@@ -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 {
BookOpen,
ClipboardList,
Folder,
Library,
Loader2,
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 { 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="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{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-black/10 dark:border-white/10">
<SidebarCredits />
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="flex items-center gap-2 px-3 py-2.5">
<SidebarUserButton />
<Link
href="/changelog"
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
>
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 rounded-[8px] px-2 py-[6px] 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 DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 space-y-0.5 px-2 py-3">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<Folder className="h-3.5 w-3.5" />
Progetti
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-3.5 w-3.5" />
Libreria
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-3.5 w-3.5" />
Admin
</Link>
)}
</nav>
);
}
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: "Esame", icon: ScanLine },
{ type: "route", path: "/report", label: "Protocolli", 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: "Impostazioni", 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}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Protocolli") 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 === "Protocolli") 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="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] 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-1 space-y-1 px-2 py-3">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<Folder className="h-3.5 w-3.5" />
Progetti
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-3.5 w-3.5" />
Libreria
</NavLink>
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Esame in corso
</NavLink>
)}
{PRIMARY_NAV.map(renderItem)}
</div>
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</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>
);
}
@@ -19,6 +19,7 @@ import {
forestHasCertifications,
groupByWorstStatus,
isProtocolFinding,
protocolEmptyMessage,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
@@ -172,10 +173,10 @@ export function FindingsTree(props: FindingsTreeProps) {
))}
{cert ? (
<CertificationsBranch
{...props}
status={status}
instances={inst}
findings={protocolAtStatus}
{...props}
/>
) : null}
</TreeBranch>
@@ -267,9 +268,9 @@ function CertificationsBranch({
} & FindingsTreeProps) {
const all = [...findings, ...instances.map(() => ({ status }))];
return (
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch label="Certifications" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocolli"
label="Protocols"
findings={all.length ? all : [{ status }]}
defaultOpen
>
@@ -318,7 +319,7 @@ function CertificationsBranch({
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
{protocolEmptyMessage(props.protocolSection?.message)}
</p>
) : null}
</TreeBranch>
@@ -329,9 +330,9 @@ function CertificationsBranch({
export function FindingsTreeEmpty() {
return (
<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">Nessun finding visibile</p>
<p className="text-[15px] font-medium tracking-tight">No visible findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
I filtri nascondono lelenco. Togli un filtro, oppure apri i findings già rivisti sotto.
Filters are hiding the list. Clear a filter, or open the reviewed findings below.
</p>
</div>
);
@@ -10,6 +10,7 @@ import {
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolEmptyMessage,
protocolInstancesFromSection,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
@@ -28,7 +29,7 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
{fail ? (
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa non sotto Warning.</p>
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL is listed first not under Warning.</p>
) : null}
<div className="overflow-x-auto">
<table className="w-full text-[11px] tabular-nums">
@@ -57,7 +58,7 @@ export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInsta
</table>
</div>
{instance.chainExample ? (
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
<p className="break-all text-[11px] text-neutral-500">Chain: {instance.chainExample}</p>
) : null}
{checks
.filter((c) => c.result === "FAIL" || c.skip_visible)
@@ -113,12 +114,12 @@ export function ProtocolSection({
section?: ProtocolCertificationSection | null;
}) {
const instances = protocolInstancesFromSection(section);
const message = section?.message || "Nessun protocollo riconosciuto.";
const message = protocolEmptyMessage(section?.message);
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Livello massimo {maxLevel} (L0L3). HDMI 1.4 TMDS resta UNKNOWN niente Zdiff inventato.
Max level {maxLevel} (L0L3). HDMI 1.4 TMDS stays UNKNOWN no invented Zdiff.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
@@ -20,18 +20,18 @@ export function ReportSummary({
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
errors > 0 ? "Error" : warnings > 0 ? "Warning" : total > 0 ? "Complete" : "No findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} rivisti`,
`${reviewedCount} reviewed`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} crediti`);
bits.push(`${creditsSpent.toFixed(2)} credits`);
}
return (
@@ -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, groupDomains, isProtocolFinding, 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 {
@@ -72,6 +79,16 @@ test("Error folder lists domains and rules after status, protocol stays out of t
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,11 +1,11 @@
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
/** Group report findings for the tree: status folders, then domains/rules, then Certifications. */
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
import {
instanceWorstResult,
protocolResultToFindingStatus,
type ProtocolReportCheck,
} from "./protocol-report.ts";
} from "./protocol-report";
export const STATUS_FOLDER: Record<FindingStatus, string> = {
ERROR: "Error",
@@ -45,7 +45,7 @@ const DOMAIN_LABEL: Record<string, string> = {
PDN: "PDN",
PD: "USB-PD",
AF: "AF Board + AI",
PRT: "Protocolli",
PRT: "Protocols",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
@@ -112,6 +112,17 @@ 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;
@@ -122,7 +133,7 @@ export function isProtocolFinding(f: {
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocols" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
@@ -173,7 +184,7 @@ export function groupDomains(findings: Finding[]): DomainGroup[] {
/**
* 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 Certificazioni.
* Protocol findings (PE-PRT) are excluded from domain folders they sit under Certifications.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
@@ -9,9 +9,9 @@ import {
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 {
@@ -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);
});
+186 -47
View File
@@ -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,
);
}
+2 -2
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.83.0";
export const APP_VERSION_DATE = "2026-09-22";
export const APP_VERSION = "2.85.0";
export const APP_VERSION_DATE = "2026-09-23";
+2
View File
@@ -21,5 +21,7 @@ TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync
| `tmds1204.pdf` | TMDS1204 12Gbps HDMI Hybrid Redriver | SLLSF57A August 2022 / revised April 2024 | `tmds1204-hdmi-sink`, `tmds1204-hdmi-source`; HDMI **2.0 / FRL** common layout `source_type=VENDOR` | **VENDOR** |
| `slla633.pdf` | TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204 | SLLA633 May 2024 | HDMI 2.0+/`tmds1204-*` `sigdet_wakeup` PHY_DEPENDENT (no layout ohms) | **VENDOR** |
| `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 |
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
+371
View File
@@ -0,0 +1,371 @@
"""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 blob.startswith("ERROR:") or "ERROR:" in f.finding
assert "100" in blob and "25.4.9" in blob
assert "stackup" in blob
assert "Fornire" not in blob
assert "tr" 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("PASS:")
assert findings[0].status == "INFO"
assert "RJ45_TXP" in findings[0].finding and "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("FAIL:")
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
+2 -2
View File
@@ -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():
+11 -1
View File
@@ -123,7 +123,17 @@ def test_type_c_rp_rd_from_cabcon_r25_vbus_volts_unknown():
assert by["t_vbuson_max"].source.page == "247"
assert "2.5" in by["t_vbuson_max"].source.revision
assert by["vsafe5v"].value_kind == "UNKNOWN"
assert "vSafe5V" in (by["vsafe5v"].needed_document or "")
assert by["vsafe5v"].value is None
assert "USB Power Delivery" in (by["vsafe5v"].needed_document or "")
assert "snla027b" in (by["vsafe5v"].needed_document or "")
assert "Figure 4-39" in (by["vsafe5v"].needed_document or "")
assert by["vsafe0v"].value_kind == "UNKNOWN"
assert by["vsafe0v"].value is None
assert "vSafe0V" in (by["vsafe0v"].needed_document or "")
hdmi14 = next(p for p in cat.physical_interfaces if p.id == "hdmi-1.4-tmds-type-a")
hz14 = next(c for c in hdmi14.constraints if c.parameter == "differential_impedance")
assert hz14.value_kind == "UNKNOWN"
assert hz14.value is None
_cite_ok(by["gnd_ir_drop_cable"])
assert by["gnd_ir_drop_cable"].value == 250.0
assert by["gnd_ir_drop_cable"].source_type == "CABLE"
+2 -2
View File
@@ -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()
@@ -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():
+10 -1
View File
@@ -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