Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""The rig model: syntax-free semantic representation of a rig, plus the
shield-template model every instance's `shield:` reference resolves
against.
`Instance.shield` is a real, resolved `Shield`: `loader/library.py`
builds the shield library and resolves every reference against it
before an `Instance` ever exists, so nothing downstream carries an
unresolved reference."""
from __future__ import annotations
from dataclasses import dataclass, field
from .diag import SourceRef
# ---------------------------------------------------------------- connector types
@dataclass
class Position:
"""One claimable plug position ("D7", "CS") -- name from the plug
binding, index from the dt-bindings header (the single source of
truth position.index resolves against)."""
name: str
index: int
function: str # gpio | analog
optional: bool = False
@dataclass
class ConnectorType:
"""A connector type IS its binding pair + index header."""
name: str # "arduino-r3"
positions: dict[str, Position] # claimable positions, by name
index2name: dict[int, str] # ALL header indices (incl. bus copper)
bus_proxies: list[str] # allowed shield proxy nodes
stackable: bool # mating multiplicity N vs 1
# default ordered CS candidates, keyed by the QUALIFIED bus name a
# multi-bus connector type suffixes with a role ("spi" bare, or
# "spi-sensors"/"spi-motors" once a type offers more than one SPI
# bus); only spi-kind buses ever populate this, i2c/uart never read it
cs_pool: dict[str, list[int]]
def posname(self, index: int) -> str:
return self.index2name.get(index, f"position {index}")
# ---------------------------------------------------------------- shield side
@dataclass
class FunctionRef:
"""A gpio/pwm/adc-spec property on a shield device. Two shapes::
fixed position -- <&plug POSITION flags>: position is position.
deferred -- <&jumper flags>: position selected by a routing
jumper, jumper names it and position is None
until the analyzer resolves the rig's selection.
`plug` is the SLOT this reference resolves through -- the shield's own
plug node the phandle actually named, recorded PER-REFERENCE rather
than per-device: a device sitting on one plug's bus may still carry a
gpio ref that names a DIFFERENT plug. For a shield with one plug this
is that plug's own node name -- `"plug"` by convention, but its NAME,
not a default."""
prop: str
position: int | None
flags: int
src: SourceRef
jumper: str | None = None
function: str = "gpio" # gpio | pwm | adc
period: int | None = None # pwm only: the period cell, passed through
plug: str = "plug"
@dataclass
class Device:
name: str # node name without unit-address
label: str # shield-local label (dl_rtc)
compatible: str | None
bus: str | None # "i2c" | "spi" | "uart" | None (plain group)
group: str | None # non-bus group name ("gpio") for None-bus devices
reg: int | None # authored = 1-element domain (address authority rule)
addr_from: str | None # strap name -- deferred address, explicit not absent
cs_position: int | None # copper-fixed CS (shield,cs-position)
# the slot THIS device's own BUS group nests under -- None for a
# plain (non-bus) group device, which is plug-agnostic (its own gpio
# refs each carry their own plug instead); "plug" for any bus device
# of a single-plug shield. Never consulted for a plain-group device
# (nothing reads it without first checking `bus` is not None).
plug: str | None = "plug"
collect: str | None = None # collection compatible (gpio-keys/leds): this is an ENTRY
declared_params: list[str] = field(default_factory=list) # shield,params: names
# shield,param-includes: headers -- a macro-only header contributes no
# node/property of its own, so it cannot be recovered from this
# device's other DTS content and must be declared explicitly, on the
# SAME node as the parameter it backs.
declared_param_includes: list[str] = field(default_factory=list)
function_refs: list[FunctionRef] = field(default_factory=list)
extra_props: list[tuple[str, str]] = field(default_factory=list) # rendered passthrough
src: SourceRef | None = None
@dataclass
class Pad:
"""Arity-1 connector."""
name: str
label: str
role: str # driver | listener | bidir
of: str | None # device name it belongs to
src: SourceRef | None = None
@dataclass
class Strap:
"""Configuration element selecting from an ADDRESS domain."""
name: str
label: str
domain: list[tuple[int, int]] # (address, strap state) pairs
sheet_label: str
src: SourceRef | None = None
@dataclass
class Jumper:
"""Configuration element selecting from a POSITION domain -- the
position-side twin of Strap."""
name: str
label: str
domain: list[tuple[int, int]] # (connector-position index, jumper state)
sheet_label: str
src: SourceRef | None = None
def positions(self) -> list[int]:
return [p for p, _ in self.domain]
def state_of(self, position: int) -> int | None:
return next((s for p, s in self.domain if p == position), None)
@dataclass
class ExposedSocket:
"""A socket a carrier/interposer shield re-exports, potentially
composed from SEVERAL named parents: a plural carrier's exposed
socket may pass through or scope-create buses sourced from different
plugs, and each gpio-map row may resolve through a different plug
too."""
name: str # node name -- what the rig references after the dot
label: str
type_name: str # from compatible "socket,<type>"
# exposed position -> (parent SLOT, parent plug position, flags) --
# the phandle a gpio-map row carries names WHICH of the carrier's
# plugs the row resolves through; "plug" for every row of a
# single-plug carrier.
gpio_map: dict[int, tuple[str, int, int]]
# kind (bare, or role-suffixed per the multi-bus vocabulary) ->
# ("plug", parent SLOT) pass-through | ("scope", dev-label) new
# scope -- the scope root is a device, which already carries its
# own slot via Device.plug.
buses: dict[str, tuple[str, str]]
# PWM/ADC pass-through: SAME shape as gpio_map -- position -> (parent
# SLOT, parent plug position, trailing filler cell). The filler is
# always 0 (pwm's row carries an unused period placeholder here; adc's
# row has no such cell in the DTS source at all).
pwm_map: dict[int, tuple[str, int, int]] = field(default_factory=dict)
adc_map: dict[int, tuple[str, int, int]] = field(default_factory=dict)
# The carrier's OWN declared #pwm-cells / #io-channel-cells: mandatory
# alongside the corresponding map, read here so compose_socket can
# refuse a disagreement against the resolved parent's own count
# without re-deriving it. None when the socket carries no map for
# that function.
pwm_cells: int | None = None
adc_cells: int | None = None
# per-qualified-bus authored cs-pool override, keyed the same way
# BoardSocket.buses/ConnectorType.cs_pool are (kind, or kind-role) --
# a bare "socket,cs-pool" property parses into the "spi" entry, since
# CS only ever applies to SPI. Absent from the dict = no override.
cs_pool: dict[str, list[int]] = field(default_factory=dict)
channel: int | None = None # mux channel index (scope-creating interposer)
src: SourceRef | None = None
@dataclass
class Shield:
name: str # node name: "adafruit-data-logger"
label: str # DTS label: data_logger
# slot name -> consumed connector type, in AUTHORING order. One entry
# per plug NODE, keyed by that node's own name (conventionally
# `"plug"` for a shield with one). Every consumer keys through this
# rather than assuming a bare string, and `len(plugs) > 1` IS the
# plurality discriminator every rendering/refusal rule gates on.
plugs: dict[str, str]
devices: list[Device] = field(default_factory=list)
pads: dict[str, Pad] = field(default_factory=dict)
straps: dict[str, Strap] = field(default_factory=dict)
jumpers: dict[str, Jumper] = field(default_factory=dict)
exposes: dict[str, ExposedSocket] = field(default_factory=dict)
by_path: dict[str, object] = field(default_factory=dict) # dtlib path -> element
# shield.yml's declared revision axis, and which one THIS Shield
# represents -- both None for a shield with no revision: block.
# `revision` is the RESOLVED value (what constructed this Shield's own
# stems); `revision_requested` is the raw `<name>@<rev>` a reference
# actually named, kept only for provenance -- nearest-lower match
# means the two can differ, and every filename/RIG_SHIELD_REVISIONS
# entry is built from `revision`, never `revision_requested`.
revisions: AxisDecl | None = None
revision: str | None = None
revision_requested: str | None = None
src: SourceRef | None = None
def by_name(self, name: str) -> list[object]:
"""Dotted-reference scope for `wires:` endpoints: pads UNION
devices UNION straps, by DTS LABEL -- the naming authority every
rig->shield string reference shares with `config:` and
`params:` (a shield's own internal references are already
phandles, i.e. labels, so this makes the rig-typed string and
the phandle the same identifier). The node name never resolves
here, labelless or not: internal dict keys stay node-name
(`self.pads`/`self.straps` are keyed that way for
`by_path`/`config_element` lookups that never see a rig
string), this scan is the only rig-facing surface."""
hits: list[object] = [p for p in self.pads.values() if p.label == name]
hits += [d for d in self.devices if d.label == name]
hits += [s for s in self.straps.values() if s.label == name]
return hits
def config_element(self, name: str) -> Strap | Jumper | None:
"""A strap or jumper of this shield, by DTS LABEL (rig `config:`
targets) -- never by node name, which would let two spellings
(label and name) address the same element."""
for strap in self.straps.values():
if strap.label == name:
return strap
for jumper in self.jumpers.values():
if jumper.label == name:
return jumper
return None
def exposed_socket(self, name: str) -> ExposedSocket | None:
"""An exposed socket of this shield, by DTS LABEL (a rig `socket:
<carrier>.<exposed>` reference's dotted half) -- never by node
name, which would let two spellings (label and name) address the
same socket. `self.exposes` stays keyed by node name internally
(paths and nexus labels are built from `exposed.name`, never this
lookup) -- only this rig-facing lookup moves to the label."""
for exposed in self.exposes.values():
if exposed.label == name:
return exposed
return None
def names(self) -> list[str]:
"""The `by_name` scope's own labels, for a wire-ref diagnostic's
"valid names" listing -- pads UNION devices UNION straps, sorted,
matching `by_name`'s own resolution scope exactly."""
return sorted(
[p.label for p in self.pads.values()]
+ [d.label for d in self.devices]
+ [s.label for s in self.straps.values()]
)
@dataclass
class WireEnd:
"""One `<instance>.<node>` endpoint. `node` stays the raw string (the
instance's OWN `Shield.by_name(node)` is the resolution scope, checked
at the point a WireEnd is constructed -- loader/delta.py's
`resolve_dotted` -- not stored redundantly here)."""
instance_name: str
node: str
src: SourceRef
@dataclass
class Wire:
frm: WireEnd
to: WireEnd
route: str | int # "adhoc" | via: position name (raw)
src: SourceRef
@dataclass
class Instance:
name: str
shield: Shield
# slot name -> authored reference, each already resolved through a
# SocketBinding. A slot mapped to None means the author declared no
# reference for that slot: the analyzer infers it if and only if exactly one
# board socket mates the slot's own connector type. The loader
# carries the absence through unresolved rather than picking one
# itself, since it never sees the board. Keyed exactly as
# `Shield.plugs` is.
sockets: dict[str, str | None]
invert: bool = False # flip the active level of the module's gpio signals
straps: dict[str, int] = field(default_factory=dict) # strap name -> pinned address
strap_refs: dict[str, SourceRef] = field(default_factory=dict)
jumpers: dict[str, object] = field(default_factory=dict) # jumper name -> raw position
jumper_refs: dict[str, SourceRef] = field(default_factory=dict)
# rig params: -- per-instance property assignments, keyed by
# shield-local DEVICE LABEL then property name; raw value TEXT
# (emission is verbatim, never resolved -- resolution is a
# loader/config-sheet concern only).
params: dict[str, dict[str, str]] = field(default_factory=dict)
param_refs: dict[str, dict[str, SourceRef]] = field(default_factory=dict)
src: SourceRef | None = None
# ---------------------------------------------------------------- board side
# The board DT is analyzer input: the analyzer reads the board DT to find
# socket nodes by compatible. These are READ, never authored, facts --
# populated by board/project.py, never redefined there.
@dataclass
class BusRef:
label: str # "i2c1" -- emission target &i2c1
path: str # dtlib path, scope identity
# CS numbering is a fact of THIS bus, not of the socket as a whole (a
# socket offering two independent SPI buses gives each its own pool):
# authored override for this bus, else None (the connector type's own
# default is the caller's fallback, analyzer/cs.py's effective_cs_pool).
# Never read for an i2c/uart bus.
cs_pool: list[int] | None = None
# The board's OWN pre-authored `cs-gpios` array on this controller,
# read straight off edtlib's already-resolved graph (never
# reconstructed from raw DTS source): one (gpio controller label,
# pin, flags) tuple per array entry, in array-index order. Empty
# when the board authored no such property. SPI-only -- never
# populated for an i2c/uart bus, board/project.py's own read gates
# on is_bus_kind(qualified, "spi").
existing_cs_gpios: tuple[tuple[str, int, int], ...] = ()
# `reg` values already held by this controller's OWN pre-existing
# child device nodes -- independent of existing_cs_gpios's length,
# since a board may legally declare a child whose reg indexes past
# its cs-gpios array (e.g. a no-CS/software-controlled device).
# Empty when the board authored no children on this bus. SPI-only,
# same gate as existing_cs_gpios.
existing_child_regs: frozenset[int] = field(default_factory=frozenset)
@dataclass
class BoardSocket:
label: str # "nucleo_ard" -- what rig socket: names
path: str
type_name: str # from compatible "socket,<type>"
gpio_map: dict[int, tuple[str, int, int]] # position -> (ctrl label, pin, flags)
buses: dict[str, BusRef] # qualified bus name (kind, or kind-role) present = offered subset
pwm_map: dict[int, tuple[str, int]] = field(
default_factory=dict
) # position -> (ctrl label, channel)
adc_map: dict[int, tuple[str, int]] = field(
default_factory=dict
) # position -> (ctrl label, channel)
# This socket's OWN declared #pwm-cells / #io-channel-cells: for a
# real board socket, board/project.py's checked read (never the discarded
# period cell); for a synthesized carrier socket, compose_socket
# carries the parent's declared count forward once checked equal to
# it. A carrier never chooses its own count -- it inherits whatever
# the board it lands on declares.
pwm_cells: int | None = None
adc_cells: int | None = None
# Every socket is referenced through a nexus. Board sockets are real
# DT nodes (nexus_label=None -> use label, nothing to synthesize); a
# carrier's re-exported socket has no DT node of its own, so the
# analyzer/emitter SYNTHESIZE one that chains to its parent's.
nexus_label: str | None = None
nexus_rows: list[tuple[int, str, int]] | None = (
None # [(child_pos, parent_nexus_label, parent_pos)]
)
# PWM/ADC twins of nexus_rows above, kept SEPARATE (rather than
# widening nexus_rows with a function tag) because a gpio-less,
# analog-only exposed socket must still synthesize a nexus node --
# emitter/overlay.py's skip guard checks all three, never nexus_rows
# alone.
pwm_nexus_rows: list[tuple[int, str, int]] | None = None
adc_nexus_rows: list[tuple[int, str, int]] | None = None
# parent BoardSocket per SLOT of the carrier that synthesized this
# socket -- empty for a real board socket. A single-plug carrier's
# composition still produces exactly one entry; a plural carrier's
# composition carries one entry per slot it declares. Walked by
# emitter/overlay.py's transitive `visit` for nexus-chain synthesis
# (a carrier stacked on a carrier).
parents: dict[str, BoardSocket] = field(default_factory=dict)
src: SourceRef | None = None
@dataclass
class Board:
"""sockets is canonical: exactly one entry per physical socket, keyed
by its DEFINING label (node.labels[0]) -- analyzer/sockets.py iterates
board.sockets.values() to build the "sockets of <board>: ..." census
inside the phys-socket diagnostic (wording frozen by the
unmapped-socket golden), so a second key per socket would list every
aliased socket twice and churn it. aliases carries every ADDITIONAL
label a socket node declares (e.g. "arduino_r3" alongside a
board-prefixed "nucleo_ard"), mapped to that socket's defining label --
resolve() is the only thing that should widen with it; iteration over
sockets itself must not."""
name: str
sockets: dict[str, BoardSocket] = field(default_factory=dict) # by defining label
aliases: dict[str, str] = field(default_factory=dict) # additional label -> defining label
def resolve(self, ref: str) -> BoardSocket | None:
"""The board socket ref names, through the alias index if ref is
an additional (non-defining) label, else ref itself -- the same
lookup-else-identity shape loader/binding.py's SocketBinding.get
uses for abstract-socket names. Returns None when ref names no
socket of this board at all (a phys-socket finding is the
caller's job, not this method's). The board and its sockets are
read-only to this call; nothing is constructed or owned here."""
return self.sockets.get(self.aliases.get(ref, ref))
@dataclass
class AxisDecl:
"""One declared qualifier axis: a rig's `variants:` (unchanged
shape), a rig's `revision:` (hwmv2's own shape), or a shield's
`revisions:` (its OWN pre-hwmv2 shape, permanently -- `loader.axes.
parse_legacy_revision_decl`'s own docstring has the external reason).
`values`/`default` are the values a selection may take and the one a
bare (unqualified) target takes by default, shared by all three;
`format`/`exact` are a rig's revision axis only -- always None/False
for a `variants:` decl (no format concept) AND for a shield's own
revision axis (pinned to its pre-hwmv2 shape, which has no format:/
exact: keys either).
`format` (one of "letter"/"number"/"major.minor.patch"/"custom") and
`exact` govern `loader.axes.resolve_axis_selection`'s revision-only
behaviour (per-format id validation, nearest-lower match, the
`exact: true` opt-out) when set; `format is None` (a `variants:` decl
always, a shield's `revisions:` decl always) instead runs plain
exact-membership resolution, hwmv2 entirely uninvolved."""
values: list[str]
default: str | None = None
format: str | None = None
exact: bool = False
@dataclass
class Rig:
name: str
board: str = ""
instances: list[Instance] = field(default_factory=list)
wires: list[Wire] = field(default_factory=list)
revisions: AxisDecl | None = None
variants: AxisDecl | None = None
# `revision` is the RESOLVED value (nearest-lower match already
# applied) -- what every fragment filename, context.cmake entry and
# RIG_REVISION is built from. `revision_requested` is the raw
# --revision string a target actually asked for, kept only for
# provenance: None whenever no --revision was given, equal to
# `revision` whenever the request matched a declared value exactly.
revision: str | None = None
revision_requested: str | None = None
variant: str | None = None
src: SourceRef | None = None
55022eb50This file is pinned to the selected release. Download the original to open it in your design tool.