diff --git a/docs/architecture.md b/docs/architecture.md index 8db87417..0e80d7d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,7 @@ bridge core or the shared crates. ## Where the contracts are -- **The physical stack.** `hardware/shared/dimensions.py` decides the heights +- **The physical stack.** `hardware/shared/dimensions/case.py` decides the heights that let the plate sit flush over the board, and validates that they sum correctly on import. diff --git a/docs/cad.md b/docs/cad.md index 42cc81ca..c35f0eb3 100644 --- a/docs/cad.md +++ b/docs/cad.md @@ -47,13 +47,13 @@ carries `CASE_CENTER_OFFSET_Y_MM`. Both printable parts are generated in **assembly coordinates**: the case floor at z = 0, the plate occupying the top 3 mm. `board-assembly` therefore moves neither of them. That is deliberate — if the plate ever stops meeting the case, it is a -real error in `dimensions.py` rather than a positioning mistake in a view, and +real error in `hardware/shared/dimensions/` rather than a positioning mistake in a view, and the render shows it. ## Shared measurements -Shared CAD measurements live in `hardware/shared/dimensions.py`, which -validates itself on import. Among other things it checks that the internal stack +Shared CAD measurements live in `hardware/shared/dimensions/`, grouped by +board, case, panel, and tile plate. The package validates itself on import. Among other things it checks that the internal stack — floor, Pi cavity, board, gap, plate — sums exactly to the case height, that every plate screw lands on the case ledge rather than over the PCB, that no support boss collides with an LED or a Hall sensor, and that every control-panel feature diff --git a/docs/hardware.md b/docs/hardware.md index c8592bad..8bbd09e4 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -7,10 +7,11 @@ Pi Zero 2 W. There is no microcontroller and no separate schematic source tree. - [`hardware/shared/wiring.py`](../hardware/shared/wiring.py) owns net names, GPIO assignments, expander mapping, and LED chain order. -- [`hardware/shared/components.py`](../hardware/shared/components.py) owns stable - component identities and physical package metadata. -- [`hardware/shared/dimensions.py`](../hardware/shared/dimensions.py) owns the - board envelope and feature positions shared with CAD. +- [`hardware/shared/components/`](../hardware/shared/components/) contains one + approved-product file per part, with its identity and physical package metadata. +- [`hardware/shared/dimensions/board.py`](../hardware/shared/dimensions/board.py) owns + the playing grid and PCB envelope; the neighboring case, panel, and tile-plate + modules own their physical measurements. - [`hardware/pcb/definition/board.py`](../hardware/pcb/definition/board.py) composes the reviewed typed component assemblies and connectivity. - [`hardware/pcb/generated/bom.md`](../hardware/pcb/generated/bom.md) is the diff --git a/hardware/cad/README.md b/hardware/cad/README.md index 01a1df23..5b38d8f6 100644 --- a/hardware/cad/README.md +++ b/hardware/cad/README.md @@ -48,8 +48,8 @@ per view, named `.png` or `-.png`. ## Shared modules -`../shared/dimensions.py` is the authoritative source for measurements shared across -projects. It derives the playing span from square size and grid count, derives +`../shared/dimensions/` owns measurements shared across projects, with one +module per physical object. It derives the playing span from square size and grid count, derives the plate span from fit clearance, and validates the vertical stack, the control panel layout, the board support positions and the plate fixings. Project READMEs describe intent rather than duplicating those values. Dimension tests run the diff --git a/hardware/pcb/README.md b/hardware/pcb/README.md index 6e4fa41a..431e16e7 100644 --- a/hardware/pcb/README.md +++ b/hardware/pcb/README.md @@ -44,10 +44,10 @@ footprint paths link to generated schematic symbols. No frozen identity ledger e ## Component registry -`definition/parts/catalog.py` owns one `PcbPart` entry per native product. Each entry -ties an approved `ComponentSpec` to its typed electronic-model constructor, native -footprint, library label, nominal value, and default purpose. Add or update common -PCB metadata there; `PCB_PARTS` is the keyed lookup for schematic and validation code. +`definition/parts/.py` owns each native footprint and its `PcbPart` +binding: approved product, typed electronic model, library label, nominal value, +and purpose. `parts/catalog.py` only enumerates those bindings as `PCB_PARTS` for +schematic and validation code; `parts/part.py` defines the checked binding type. Assemblies pass a typed `*_PART` entry, an explicit stable reference, position, rotation, and assembly name to `place()`. They override `purpose` only when the role diff --git a/hardware/pcb/definition/assemblies/controls.py b/hardware/pcb/definition/assemblies/controls.py index 88859b97..977c30fc 100644 --- a/hardware/pcb/definition/assemblies/controls.py +++ b/hardware/pcb/definition/assemblies/controls.py @@ -9,7 +9,9 @@ from pcb.definition.parts import catalog as parts from shared import dimensions, wiring from shared import electronics as p -from shared.panel import PANEL_BUTTONS +from shared.panel_buttons import PANEL_BUTTONS + +ASSEMBLY_PART_COUNT = 22 def add_controls(board: pcbnew.BOARD) -> None: @@ -150,7 +152,7 @@ def add_controls(board: pcbnew.BOARD) -> None: assembly="controls", extras={"Function": button.name}, ) - pin = p.RaspberryPiHeaderPin[f"BUTTON_{button.name}_GPIO{button.gpio}"] + pin = button.header_pin connect( board, button.net_name, diff --git a/hardware/pcb/definition/assemblies/led_link_names.py b/hardware/pcb/definition/assemblies/led_link_names.py new file mode 100644 index 00000000..1b1af186 --- /dev/null +++ b/hardware/pcb/definition/assemblies/led_link_names.py @@ -0,0 +1,75 @@ +"""Published names for existing LED links; new links get endpoint names.""" + +from shared import wiring +from shared.hall_banks import SquarePosition + +LEGACY_NAMES: dict[tuple[str, str], tuple[str, str]] = { + ("A1", "B1"): ("N$207", "N$208"), + ("B1", "C1"): ("N$225", "N$226"), + ("C1", "D1"): ("N$227", "N$228"), + ("D1", "E1"): ("N$115", "N$116"), + ("E1", "F1"): ("N$117", "N$118"), + ("F1", "G1"): ("N$119", "N$120"), + ("G1", "H1"): ("N$121", "N$122"), + ("H1", "H2"): ("LED_D8", "LED_C8"), + ("H2", "G2"): ("N$123", "N$124"), + ("G2", "F2"): ("N$125", "N$126"), + ("F2", "E2"): ("N$127", "N$128"), + ("E2", "D2"): ("N$129", "N$130"), + ("D2", "C2"): ("N$131", "N$132"), + ("C2", "B2"): ("N$133", "N$134"), + ("B2", "A2"): ("N$135", "N$136"), + ("A2", "A3"): ("LED_D16", "LED_C16"), + ("A3", "B3"): ("N$137", "N$138"), + ("B3", "C3"): ("N$139", "N$140"), + ("C3", "D3"): ("N$141", "N$142"), + ("D3", "E3"): ("N$143", "N$144"), + ("E3", "F3"): ("N$145", "N$146"), + ("F3", "G3"): ("N$147", "N$148"), + ("G3", "H3"): ("N$149", "N$150"), + ("H3", "H4"): ("LED_D24", "LED_C24"), + ("H4", "G4"): ("N$151", "N$152"), + ("G4", "F4"): ("N$153", "N$154"), + ("F4", "E4"): ("N$155", "N$156"), + ("E4", "D4"): ("N$157", "N$158"), + ("D4", "C4"): ("N$159", "N$160"), + ("C4", "B4"): ("N$161", "N$162"), + ("B4", "A4"): ("N$163", "N$164"), + ("A4", "A5"): ("LED_D32", "LED_C32"), + ("A5", "B5"): ("N$165", "N$166"), + ("B5", "C5"): ("N$167", "N$168"), + ("C5", "D5"): ("N$169", "N$170"), + ("D5", "E5"): ("N$171", "N$172"), + ("E5", "F5"): ("N$173", "N$174"), + ("F5", "G5"): ("N$175", "N$176"), + ("G5", "H5"): ("N$177", "N$178"), + ("H5", "H6"): ("LED_D40", "LED_C40"), + ("H6", "G6"): ("N$179", "N$180"), + ("G6", "F6"): ("N$181", "N$182"), + ("F6", "E6"): ("N$183", "N$184"), + ("E6", "D6"): ("N$185", "N$186"), + ("D6", "C6"): ("N$189", "N$190"), + ("C6", "B6"): ("N$191", "N$192"), + ("B6", "A6"): ("N$193", "N$194"), + ("A6", "A7"): ("LED_D48", "LED_C48"), + ("A7", "B7"): ("N$195", "N$196"), + ("B7", "C7"): ("N$197", "N$198"), + ("C7", "D7"): ("N$199", "N$200"), + ("D7", "E7"): ("N$201", "N$202"), + ("E7", "F7"): ("N$203", "N$204"), + ("F7", "G7"): ("N$205", "N$206"), + ("G7", "H7"): ("N$209", "N$210"), + ("H7", "H8"): ("LED_D56", "LED_C56"), + ("H8", "G8"): ("N$211", "N$212"), + ("G8", "F8"): ("N$213", "N$214"), + ("F8", "E8"): ("N$215", "N$216"), + ("E8", "D8"): ("N$217", "N$218"), + ("D8", "C8"): ("N$219", "N$220"), + ("C8", "B8"): ("N$221", "N$222"), + ("B8", "A8"): ("N$223", "N$224"), +} + + +def for_squares(left: SquarePosition, right: SquarePosition) -> tuple[str, str]: + """Keep an existing net identity or name a new physical connection.""" + return LEGACY_NAMES.get((left.name, right.name), wiring.led_link_nets(left, right)) diff --git a/hardware/pcb/definition/assemblies/power.py b/hardware/pcb/definition/assemblies/power.py index 70d404b6..d1888996 100644 --- a/hardware/pcb/definition/assemblies/power.py +++ b/hardware/pcb/definition/assemblies/power.py @@ -6,14 +6,17 @@ from pcb.definition.native import connect, place from pcb.definition.parts import catalog as parts +from pcb.definition.parts.part import PcbPart from shared import dimensions from shared import electronics as p from shared.electronics import EndpointResolver +ASSEMBLY_PART_COUNT = 9 + def add_strip[Part: EndpointResolver]( board: pcbnew.BOARD, - part: parts.PcbPart[Part], + part: PcbPart[Part], reference: str, *, assembly: str, diff --git a/hardware/pcb/definition/assemblies/sensing.py b/hardware/pcb/definition/assemblies/sensing.py index 115062fe..32a9696e 100644 --- a/hardware/pcb/definition/assemblies/sensing.py +++ b/hardware/pcb/definition/assemblies/sensing.py @@ -14,6 +14,8 @@ from shared.electronics import CapacitorPin, HallSensorPin, Tca9554Pin from shared.electronics import Tca9554Component as Tca9554 +ASSEMBLY_PART_COUNT = 2 + def add_sensor_banks( board: pcbnew.BOARD, diff --git a/hardware/pcb/definition/assemblies/square.py b/hardware/pcb/definition/assemblies/square.py index 06d89868..503ca223 100644 --- a/hardware/pcb/definition/assemblies/square.py +++ b/hardware/pcb/definition/assemblies/square.py @@ -8,6 +8,7 @@ import pcbnew +from pcb.definition.assemblies import led_link_names from pcb.definition.native import connect, no_connect, place from pcb.definition.parts import catalog as parts from shared import dimensions, wiring @@ -16,6 +17,8 @@ from shared.electronics import Sk9822Component as Sk9822 from shared.squares import BoardSquare +ASSEMBLY_PART_COUNT = 4 + @dataclass(frozen=True) class Square: @@ -24,6 +27,10 @@ class Square: hall_sensor: HallSensor +LED_BYPASS_OFFSET_MM = (0.0, -8.0) +HALL_BYPASS_OFFSET_MM = (0.0, -3.0) + + def add_square(board: pcbnew.BOARD, *, board_square: BoardSquare) -> Square: name = board_square.name rank = board_square.position.rank @@ -54,8 +61,8 @@ def add_square(board: pcbnew.BOARD, *, board_square: BoardSquare) -> Square: parts.CAP_100N_PART, f"C{8 + chain_index}", at=( - lx + parts.CAPACITOR_LED_BYPASS_OFFSET_MM[0], - ly + parts.CAPACITOR_LED_BYPASS_OFFSET_MM[1], + lx + LED_BYPASS_OFFSET_MM[0], + ly + LED_BYPASS_OFFSET_MM[1], ), assembly=assembly, purpose="Local LED decoupling capacitor", @@ -66,8 +73,8 @@ def add_square(board: pcbnew.BOARD, *, board_square: BoardSquare) -> Square: parts.CAP_100N_PART, f"C{72 + sensor_index}", at=( - x + parts.CAPACITOR_HALL_BYPASS_OFFSET_MM[0], - y + parts.CAPACITOR_HALL_BYPASS_OFFSET_MM[1], + x + HALL_BYPASS_OFFSET_MM[0], + y + HALL_BYPASS_OFFSET_MM[1], ), assembly=assembly, purpose="Local Hall-sensor decoupling capacitor", @@ -100,80 +107,15 @@ def connect_led_chain(board: pcbnew.BOARD, squares: Mapping[str, Square]) -> Non chain = [squares[square.name].led for square in dimensions.BOARD_SQUARES.led_chain] connect(board, wiring.LED_DATA_NET, chain[0].pin(Sk9822Pin.DATA_IN)) connect(board, wiring.LED_CLOCK_NET, chain[0].pin(Sk9822Pin.CLOCK_IN)) - for (left, right), (data, clock) in zip( - pairwise(chain), LED_LINK_NAMES, strict=True - ): + for left_square, right_square in pairwise(dimensions.BOARD_SQUARES.led_chain): + left = squares[left_square.name].led + right = squares[right_square.name].led + data, clock = led_link_names.for_squares( + left_square.position, right_square.position + ) connect(board, data, left.pin(Sk9822Pin.DATA_OUT), right.pin(Sk9822Pin.DATA_IN)) connect( board, clock, left.pin(Sk9822Pin.CLOCK_OUT), right.pin(Sk9822Pin.CLOCK_IN) ) no_connect(board, chain[-1].pin(Sk9822Pin.DATA_OUT)) no_connect(board, chain[-1].pin(Sk9822Pin.CLOCK_OUT)) - - -# Fixed link names preserve stable netlist identities across builds. -LED_LINK_NAMES = ( - ("N$207", "N$208"), - ("N$225", "N$226"), - ("N$227", "N$228"), - ("N$115", "N$116"), - ("N$117", "N$118"), - ("N$119", "N$120"), - ("N$121", "N$122"), - ("LED_D8", "LED_C8"), - ("N$123", "N$124"), - ("N$125", "N$126"), - ("N$127", "N$128"), - ("N$129", "N$130"), - ("N$131", "N$132"), - ("N$133", "N$134"), - ("N$135", "N$136"), - ("LED_D16", "LED_C16"), - ("N$137", "N$138"), - ("N$139", "N$140"), - ("N$141", "N$142"), - ("N$143", "N$144"), - ("N$145", "N$146"), - ("N$147", "N$148"), - ("N$149", "N$150"), - ("LED_D24", "LED_C24"), - ("N$151", "N$152"), - ("N$153", "N$154"), - ("N$155", "N$156"), - ("N$157", "N$158"), - ("N$159", "N$160"), - ("N$161", "N$162"), - ("N$163", "N$164"), - ("LED_D32", "LED_C32"), - ("N$165", "N$166"), - ("N$167", "N$168"), - ("N$169", "N$170"), - ("N$171", "N$172"), - ("N$173", "N$174"), - ("N$175", "N$176"), - ("N$177", "N$178"), - ("LED_D40", "LED_C40"), - ("N$179", "N$180"), - ("N$181", "N$182"), - ("N$183", "N$184"), - ("N$185", "N$186"), - ("N$189", "N$190"), - ("N$191", "N$192"), - ("N$193", "N$194"), - ("LED_D48", "LED_C48"), - ("N$195", "N$196"), - ("N$197", "N$198"), - ("N$199", "N$200"), - ("N$201", "N$202"), - ("N$203", "N$204"), - ("N$205", "N$206"), - ("N$209", "N$210"), - ("LED_D56", "LED_C56"), - ("N$211", "N$212"), - ("N$213", "N$214"), - ("N$215", "N$216"), - ("N$217", "N$218"), - ("N$219", "N$220"), - ("N$221", "N$222"), - ("N$223", "N$224"), -) diff --git a/hardware/pcb/definition/board.py b/hardware/pcb/definition/board.py index ba4f4f54..636bf32e 100644 --- a/hardware/pcb/definition/board.py +++ b/hardware/pcb/definition/board.py @@ -4,21 +4,14 @@ import pcbnew -from pcb.definition.bank_assemblies import BANK_ASSEMBLIES from pcb.definition.native import ( add_mechanical_features, connections, - endpoint_pads, - logical_pin, new_board, parts, - point, ) -from pcb.definition.parts.catalog import PCB_PARTS -from shared import dimensions, wiring -from shared.components import COMPONENTS -from shared.electronics import Endpoint -from shared.electronics.hall_sensor import HallSensorPin +from pcb.definition.validation import validate +from shared import dimensions def load() -> pcbnew.BOARD: @@ -38,85 +31,6 @@ def load() -> pcbnew.BOARD: return board -def validate(board: pcbnew.BOARD) -> None: - from pcb.definition import rules - from shared.electronics.tca9554 import Tca9554Component, Tca9554Pin - - rules.validate() - footprints = parts(board) - graph = connections(board) - if len(footprints) != 303 or len(graph) != 237: - raise ValueError("D-PROTOTYPE requires 303 components and 237 connections") - for footprint in footprints: - ref = footprint.GetReference() - key = footprint.GetFieldText("PartKey") - spec = COMPONENTS[key] - model = PCB_PARTS[key].new_model(ref) - if ( - footprint.GetValue() != spec.mpn - or footprint.GetFieldText("Package") != spec.package - ): - raise ValueError(f"{ref}: unapproved product/package") - physical = {logical_pin(p) for p in footprint.Pads()} - if physical != {p.endpoint.pin for p in model.pins} or any( - p.GetNetCode() == 0 for p in footprint.Pads() - ): - raise ValueError(f"{ref}: incomplete physical/logical pin assignment") - if any( - name.startswith("unconnected-") and len(nodes) != 1 - for name, nodes in graph.items() - ): - raise ValueError("no-connect nets must contain one logical pin") - pads = endpoint_pads(board) - for bank_assembly in BANK_ASSEMBLIES: - bank = bank_assembly.bank - ref = bank_assembly.expander_reference - expected = { - Tca9554Pin.SUPPLY: "+3V3", - Tca9554Pin.GROUND: "GND", - Tca9554Pin.I2C_CLOCK: wiring.SCL_NET, - Tca9554Pin.I2C_DATA: wiring.SDA_NET, - Tca9554Pin.INTERRUPT: f"unconnected-({ref}-Pad13)", - } - expected.update( - zip( - (Tca9554Pin.ADDRESS_0, Tca9554Pin.ADDRESS_1, Tca9554Pin.ADDRESS_2), - ("+3V3" if high else "GND" for high in bank.straps), - strict=True, - ) - ) - for pin, name in expected.items(): - if pads[Endpoint(ref, pin)].GetNetname() != name: - raise ValueError(f"{ref}: incorrect {pin.name} assignment") - for pin, member in zip( - Tca9554Component.input_pins(), bank.members, strict=True - ): - name = wiring.sense_net(member.name) - if set(graph[name]) != { - Endpoint(ref, pin), - Endpoint( - f"HS{dimensions.BOARD_SQUARES.by_position(member).sensor_number}", - HallSensorPin.ACTIVE_LOW_OUTPUT, - ), - }: - raise ValueError(f"{bank.label}: incorrect Hall mapping") - for board_square in dimensions.BOARD_SQUARES: - name = board_square.name - members = [ - f for f in footprints if f.GetFieldText("Assembly") == f"square/{name}" - ] - if sorted(f.GetFieldText("PartKey") for f in members) != [ - "CAP_100N", - "CAP_100N", - "HALL_SENSOR", - "SK9822", - ]: - raise ValueError(f"{name}: incomplete square assembly") - sensor = next(f for f in members if f.GetFieldText("PartKey") == "HALL_SENSOR") - if sensor.GetPosition() != point(*board_square.hall_position_mm): - raise ValueError(f"{name}: sensor is not at the shared square centre") - - def netlist(board: pcbnew.BOARD | None = None) -> dict[str, object]: """Expanded output derived from native fields and actual pad-to-net assignment.""" board = board if board is not None else load() diff --git a/hardware/pcb/definition/native.py b/hardware/pcb/definition/native.py index a6b39211..61f32a7a 100644 --- a/hardware/pcb/definition/native.py +++ b/hardware/pcb/definition/native.py @@ -10,7 +10,7 @@ from pcb.definition import rules from pcb.definition.output.symbols import ROOT_UUID, uid -from pcb.definition.parts.catalog import PcbPart +from pcb.definition.parts.part import PcbPart from pcb.definition.rules import Net from shared import dimensions, wiring from shared.electronics import BoundPin, Endpoint, EndpointResolver diff --git a/hardware/pcb/definition/output/exports.py b/hardware/pcb/definition/output/exports.py index 25150f7e..e0a495d8 100644 --- a/hardware/pcb/definition/output/exports.py +++ b/hardware/pcb/definition/output/exports.py @@ -51,7 +51,7 @@ def render_bom(design: pcbnew.BOARD | None = None) -> str: text = [ "# Approved bill of materials", "", - "Generated from `hardware/shared/components.py` and `definition/board.py`.", + "Generated from `hardware/shared/components/` and `definition/board.py`.", "Every row names an exact manufacturer part number; substitutions require", "updating the shared catalog and passing footprint/package validation.", "", diff --git a/hardware/pcb/definition/output/markings.py b/hardware/pcb/definition/output/markings.py index b0e7c262..3795b485 100644 --- a/hardware/pcb/definition/output/markings.py +++ b/hardware/pcb/definition/output/markings.py @@ -8,13 +8,11 @@ from pcb.definition import native, rules from pcb.definition.bank_assemblies import BANK_ASSEMBLIES -from pcb.definition.parts.catalog import ( - TACTILESWITCH_LABEL_OFFSET_MM, - TCA9554_SILKSCREEN_CLEARANCE_MM, -) +from pcb.definition.parts.button import TACTILESWITCH_LABEL_OFFSET_MM +from pcb.definition.parts.tca9554 import TCA9554_SILKSCREEN_CLEARANCE_MM from shared import dimensions from shared.electronics import RaspberryPiHeaderComponent as RaspberryPiHeader -from shared.panel import PANEL_BUTTONS +from shared.panel_buttons import PANEL_BUTTONS SQUARE_LABEL_OFFSET_MM = (-12.0, 0.0) diff --git a/hardware/pcb/definition/parts/ahct125.py b/hardware/pcb/definition/parts/ahct125.py new file mode 100644 index 00000000..c53431a0 --- /dev/null +++ b/hardware/pcb/definition/parts/ahct125.py @@ -0,0 +1,25 @@ +"""KiCad footprint and approved PCB binding for ahct125.""" + +from pcb.definition.parts.land_patterns import soic +from pcb.definition.parts.part import PcbPart +from shared.components import AHCT125 +from shared.electronics.ahct125 import Ahct125Component as Ahct125 +from shared.electronics.ahct125 import Ahct125Pin + +AHCT125_FOOTPRINT = soic( + "SOIC-14 1.27 mm", + "SN74AHCT125DR narrow SOIC", + 14, + 5.4, + (6.2, 8.7), + tuple(Ahct125Pin), +) + +AHCT125_PART = PcbPart( + AHCT125, + Ahct125, + AHCT125_FOOTPRINT, + "AHCT125", + "SN74AHCT125DR", + "Quad 5 V buffer accepts 3.3 V SPI clock and data", +) diff --git a/hardware/pcb/definition/parts/barrel_jack.py b/hardware/pcb/definition/parts/barrel_jack.py new file mode 100644 index 00000000..8049f3e8 --- /dev/null +++ b/hardware/pcb/definition/parts/barrel_jack.py @@ -0,0 +1,59 @@ +"""KiCad footprint and approved PCB binding for barrel jack.""" + +import pcbnew + +from pcb.definition.parts.land_patterns import courtyard_for, footprint, pad +from pcb.definition.parts.part import PcbPart +from shared.components import BARREL_JACK +from shared.electronics import ComponentReference +from shared.electronics.barrel_jack import BarrelJackComponent as BarrelJack +from shared.electronics.barrel_jack import BarrelJackPad + +BARRELJACK_SLOT_MM = (1.0, 1.6) + +BARRELJACK_PAD_MM = (2.0, 2.6) + +BARRELJACK_PADS = ( + pad( + BarrelJackPad.CENTRE_POSITIVE, + 0.0, + -3.0, + *BARRELJACK_PAD_MM, + pcbnew.PAD_SHAPE_RECT, + *BARRELJACK_SLOT_MM, + ), + pad( + BarrelJackPad.SLEEVE_GROUND, + 0.0, + 3.0, + *BARRELJACK_PAD_MM, + pcbnew.PAD_SHAPE_CIRCLE, + *BARRELJACK_SLOT_MM, + ), + pad( + BarrelJackPad.SWITCHED_SLEEVE_GROUND, + -4.7, + 0.0, + *BARRELJACK_PAD_MM, + pcbnew.PAD_SHAPE_CIRCLE, + *BARRELJACK_SLOT_MM, + ), +) + +BARRELJACK_FOOTPRINT = footprint( + "5.5x2.0 mm THT", + "Same Sky PJ-102A 5.5 x 2.0 mm DC jack, centre positive", + BARRELJACK_PADS, + courtyard_for(BARRELJACK_PADS, (14.4, 11.0)), +) + +BARREL_JACK_PART = PcbPart( + BARREL_JACK, + BarrelJack, + BARRELJACK_FOOTPRINT, + "BARREL_JACK", + "DC 5.5x2.0", + "5 V DC input jack, centre positive", +) + +DC_INPUT_JACK = BarrelJack(ComponentReference.DC_INPUT_JACK) diff --git a/hardware/pcb/definition/parts/button.py b/hardware/pcb/definition/parts/button.py new file mode 100644 index 00000000..fa05c1d9 --- /dev/null +++ b/hardware/pcb/definition/parts/button.py @@ -0,0 +1,71 @@ +"""KiCad footprint and approved PCB binding for button.""" + +import pcbnew + +from pcb.definition import rules +from pcb.definition.parts.land_patterns import courtyard_for, footprint, pad +from pcb.definition.parts.part import PcbPart +from shared.components import BUTTON +from shared.electronics.tactile_switch import TactileSwitchComponent as TactileSwitch +from shared.electronics.tactile_switch import TactileSwitchPad + +TACTILESWITCH_DRILL = rules.drill_for_lead(0.7) + +TACTILESWITCH_PAD = rules.pad_for_drill(TACTILESWITCH_DRILL) + +TACTILESWITCH_PADS = ( + pad( + TactileSwitchPad.SIGNAL_PRIMARY, + -3.25, + 2.25, + TACTILESWITCH_PAD, + TACTILESWITCH_PAD, + pcbnew.PAD_SHAPE_RECT, + TACTILESWITCH_DRILL, + ), + pad( + TactileSwitchPad.SIGNAL_DUPLICATE, + -3.25, + -2.25, + TACTILESWITCH_PAD, + TACTILESWITCH_PAD, + pcbnew.PAD_SHAPE_CIRCLE, + TACTILESWITCH_DRILL, + ), + pad( + TactileSwitchPad.GROUND_PRIMARY, + 3.25, + 2.25, + TACTILESWITCH_PAD, + TACTILESWITCH_PAD, + pcbnew.PAD_SHAPE_CIRCLE, + TACTILESWITCH_DRILL, + ), + pad( + TactileSwitchPad.GROUND_DUPLICATE, + 3.25, + -2.25, + TACTILESWITCH_PAD, + TACTILESWITCH_PAD, + pcbnew.PAD_SHAPE_CIRCLE, + TACTILESWITCH_DRILL, + ), +) + +TACTILESWITCH_FOOTPRINT = footprint( + "6x6 mm THT", + "6 mm tactile panel switch, 9.5 mm actuator", + TACTILESWITCH_PADS, + courtyard_for(TACTILESWITCH_PADS, (6.2, 6.2)), +) + +TACTILESWITCH_LABEL_OFFSET_MM = 6.5 + +BUTTON_PART = PcbPart( + BUTTON, + TactileSwitch, + TACTILESWITCH_FOOTPRINT, + "BUTTON", + "TACT 6mm", + "Momentary panel button, 9.5 mm actuator", +) diff --git a/hardware/pcb/definition/parts/capacitors.py b/hardware/pcb/definition/parts/capacitors.py new file mode 100644 index 00000000..c3023937 --- /dev/null +++ b/hardware/pcb/definition/parts/capacitors.py @@ -0,0 +1,59 @@ +"""KiCad footprint and approved PCB binding for capacitors.""" + +from pcb.definition.parts.land_patterns import two_pad_axial, two_terminal_smd +from pcb.definition.parts.part import PcbPart +from shared.components import CAP_10U, CAP_100N, CAP_1000U +from shared.electronics.passives import CapacitorComponent as Capacitor +from shared.electronics.passives import CapacitorPin + +CAPACITOR_0603_FOOTPRINT = two_terminal_smd( + "0603 (1608 metric)", + "100 nF X7R MLCC", + 1.5, + (0.9, 0.95), + (1.6, 0.8), + tuple(CapacitorPin), +) + +CAPACITOR_0805_FOOTPRINT = two_terminal_smd( + "0805 (2012 metric)", + "10 uF X5R MLCC", + 1.9, + (1.0, 1.4), + (2.0, 1.25), + tuple(CapacitorPin), +) + +CAPACITOR_ELECTROLYTIC_10MM = two_pad_axial( + "radial 10 mm", + "1000 uF radial electrolytic", + pitch=5.0, + lead_diameter=0.8, + body=(10.5, 10.5), + pin_numbers=tuple(CapacitorPin), +) + +CAP_100N_PART = PcbPart( + CAP_100N, + Capacitor, + CAPACITOR_0603_FOOTPRINT, + "C", + "100nF", + CAP_100N.description, +) +CAP_10U_PART = PcbPart( + CAP_10U, + Capacitor, + CAPACITOR_0805_FOOTPRINT, + "C", + "10uF 10V", + CAP_10U.description, +) +CAP_1000U_PART = PcbPart( + CAP_1000U, + Capacitor, + CAPACITOR_ELECTROLYTIC_10MM, + "C", + "1000uF 10V", + CAP_1000U.description, +) diff --git a/hardware/pcb/definition/parts/catalog.py b/hardware/pcb/definition/parts/catalog.py index 5355a0ff..c55941df 100644 --- a/hardware/pcb/definition/parts/catalog.py +++ b/hardware/pcb/definition/parts/catalog.py @@ -1,537 +1,29 @@ -"""Approved native footprint templates and component-specific routing data.""" +"""Explicit registry of approved native PCB parts.""" -from collections.abc import Callable -from dataclasses import dataclass - -import pcbnew - -from pcb.definition import rules -from pcb.definition.parts.land_patterns import ( - courtyard_for, - footprint, - pad, - pin_header, - soic, - two_pad_axial, - two_terminal_smd, -) -from shared.components import ( - AHCT125, - BARREL_JACK, - BUTTON, - CAP_10U, - CAP_100N, - CAP_1000U, - FUSE_2A, - HALL_SENSOR, - OLED_HEADER, - PI_ZERO_HEADER, - POWER_SWITCH, - RES_4K7, - SK9822, - TCA9554, - TEST_POINT, - TVS_6V8, - ComponentSpec, -) -from shared.electronics import ComponentReference, EndpointResolver -from shared.electronics.ahct125 import Ahct125Component as Ahct125 -from shared.electronics.ahct125 import Ahct125Pin -from shared.electronics.barrel_jack import BarrelJackComponent as BarrelJack -from shared.electronics.barrel_jack import BarrelJackPad -from shared.electronics.connectors import OledHeaderComponent as OledHeader -from shared.electronics.connectors import OledHeaderPin -from shared.electronics.hall_sensor import HallSensorComponent as HallSensor -from shared.electronics.hall_sensor import HallSensorPin -from shared.electronics.passives import CapacitorComponent as Capacitor -from shared.electronics.passives import ( - CapacitorPin, - FusePin, - PowerSwitchPin, - ResistorPin, - TvsDiodePin, -) -from shared.electronics.passives import FuseComponent as Fuse -from shared.electronics.passives import PowerSwitchComponent as PowerSwitch -from shared.electronics.passives import ResistorComponent as Resistor -from shared.electronics.passives import TvsDiodeComponent as TvsDiode -from shared.electronics.raspberry_pi_header import ( - RaspberryPiHeaderComponent as RaspberryPiHeader, -) -from shared.electronics.raspberry_pi_header import ( - RaspberryPiHeaderPin, -) -from shared.electronics.sk9822 import Sk9822Component as Sk9822 -from shared.electronics.sk9822 import Sk9822Pin -from shared.electronics.tactile_switch import TactileSwitchComponent as TactileSwitch -from shared.electronics.tactile_switch import TactileSwitchPad -from shared.electronics.tca9554 import Tca9554Component as Tca9554 -from shared.electronics.tca9554 import Tca9554Pin -from shared.electronics.test_point import TestPointComponent as TestPoint -from shared.electronics.test_point import TestPointPin - -AHCT125_FOOTPRINT = soic( - "SOIC-14 1.27 mm", - "SN74AHCT125DR narrow SOIC", - 14, - 5.4, - (6.2, 8.7), - tuple(Ahct125Pin), -) - -BARRELJACK_SLOT_MM = (1.0, 1.6) - -BARRELJACK_PAD_MM = (2.0, 2.6) - -BARRELJACK_PADS = ( - pad( - BarrelJackPad.CENTRE_POSITIVE, - 0.0, - -3.0, - *BARRELJACK_PAD_MM, - pcbnew.PAD_SHAPE_RECT, - *BARRELJACK_SLOT_MM, - ), - pad( - BarrelJackPad.SLEEVE_GROUND, - 0.0, - 3.0, - *BARRELJACK_PAD_MM, - pcbnew.PAD_SHAPE_CIRCLE, - *BARRELJACK_SLOT_MM, - ), - pad( - BarrelJackPad.SWITCHED_SLEEVE_GROUND, - -4.7, - 0.0, - *BARRELJACK_PAD_MM, - pcbnew.PAD_SHAPE_CIRCLE, - *BARRELJACK_SLOT_MM, - ), -) - -BARRELJACK_FOOTPRINT = footprint( - "5.5x2.0 mm THT", - "Same Sky PJ-102A 5.5 x 2.0 mm DC jack, centre positive", - BARRELJACK_PADS, - courtyard_for(BARRELJACK_PADS, (14.4, 11.0)), -) - - -CAPACITOR_0603_FOOTPRINT = two_terminal_smd( - "0603 (1608 metric)", - "100 nF X7R MLCC", - 1.5, - (0.9, 0.95), - (1.6, 0.8), - tuple(CapacitorPin), -) - -CAPACITOR_0805_FOOTPRINT = two_terminal_smd( - "0805 (2012 metric)", - "10 uF X5R MLCC", - 1.9, - (1.0, 1.4), - (2.0, 1.25), - tuple(CapacitorPin), -) - -CAPACITOR_ELECTROLYTIC_10MM = two_pad_axial( - "radial 10 mm", - "1000 uF radial electrolytic", - pitch=5.0, - lead_diameter=0.8, - body=(10.5, 10.5), - pin_numbers=tuple(CapacitorPin), -) - -CAPACITOR_LED_BYPASS_OFFSET_MM = (0.0, -8.0) - -CAPACITOR_HALL_BYPASS_OFFSET_MM = (0.0, -3.0) - - -FUSE_FOOTPRINT = two_terminal_smd( - "2410 fuse", - "2 A time-delay surface-mount fuse", - 6.6, - (2.7, 3.2), - (6.1, 2.7), - tuple(FusePin), -) - - -HALLSENSOR_PADS = ( - pad(HallSensorPin.SUPPLY, -0.95, 0.95, 1.0, 1.1, pcbnew.PAD_SHAPE_RECT), - pad(HallSensorPin.ACTIVE_LOW_OUTPUT, -0.95, -0.95, 1.0, 1.1, pcbnew.PAD_SHAPE_OVAL), - pad(HallSensorPin.GROUND, 0.95, 0.0, 1.0, 1.1, pcbnew.PAD_SHAPE_OVAL), -) - -HALLSENSOR_FOOTPRINT = footprint( - "SOT-23-3", - "DRV5032FC omnipolar Hall sensor", - HALLSENSOR_PADS, - courtyard_for(HALLSENSOR_PADS, (2.9, 2.8)), -) - - -OLEDHEADER_FOOTPRINT = pin_header( - "1x4 2.54 mm THT", - "Four-pin SSD1306 I2C OLED module connector", - columns=4, - rows=1, - pin_numbers=tuple(OledHeaderPin), -) - -POWERSWITCH_FOOTPRINT = two_pad_axial( - "SPST rocker THT", - "Latching rocker power switch", - pitch=12.7, - lead_diameter=1.2, - body=(19.5, 13.0), - pin_numbers=tuple(PowerSwitchPin), -) - -RASPBERRYPIHEADER_FOOTPRINT = pin_header( - "2x20 2.54 mm THT", - "Raspberry Pi Zero 2 W GPIO socket", - columns=20, - rows=2, - pin_numbers=tuple(RaspberryPiHeaderPin), -) - -RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_HALF_WIDTH_MM = 1.2 - -RASPBERRYPIHEADER_POWER_ESCAPE_MM = 6.0 - -RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_LENGTH_MM = 6.0 - - -RESISTOR_FOOTPRINT = two_terminal_smd( - "0603 (1608 metric)", - "4.7 kΩ thick-film resistor", - 1.5, - (0.9, 0.95), - (1.6, 0.8), - tuple(ResistorPin), -) - - -SK9822_BODY_MM = (5.0, 5.0) - -SK9822_PAD_LONG_MM = 1.5 - -SK9822_PAD_SHORT_MM = 1.0 - -SK9822_PAD_EDGE_MM = 2.5 - -SK9822_SIGNAL_PITCH_MM = 1.6 - -SK9822_PADS = ( - pad( - Sk9822Pin.DATA_IN, - -SK9822_PAD_EDGE_MM, - SK9822_SIGNAL_PITCH_MM / 2.0, - SK9822_PAD_LONG_MM, - SK9822_PAD_SHORT_MM, - pcbnew.PAD_SHAPE_RECT, - ), - pad( - Sk9822Pin.CLOCK_IN, - -SK9822_PAD_EDGE_MM, - -SK9822_SIGNAL_PITCH_MM / 2.0, - SK9822_PAD_LONG_MM, - SK9822_PAD_SHORT_MM, - pcbnew.PAD_SHAPE_OVAL, - ), - pad( - Sk9822Pin.DATA_OUT, - SK9822_PAD_EDGE_MM, - SK9822_SIGNAL_PITCH_MM / 2.0, - SK9822_PAD_LONG_MM, - SK9822_PAD_SHORT_MM, - pcbnew.PAD_SHAPE_OVAL, - ), - pad( - Sk9822Pin.CLOCK_OUT, - SK9822_PAD_EDGE_MM, - -SK9822_SIGNAL_PITCH_MM / 2.0, - SK9822_PAD_LONG_MM, - SK9822_PAD_SHORT_MM, - pcbnew.PAD_SHAPE_OVAL, - ), - pad( - Sk9822Pin.FIVE_VOLTS, - 0.0, - SK9822_PAD_EDGE_MM, - SK9822_PAD_SHORT_MM, - SK9822_PAD_LONG_MM, - pcbnew.PAD_SHAPE_OVAL, - ), - pad( - Sk9822Pin.GROUND, - 0.0, - -SK9822_PAD_EDGE_MM, - SK9822_PAD_SHORT_MM, - SK9822_PAD_LONG_MM, - pcbnew.PAD_SHAPE_OVAL, - ), -) - -SK9822_FOOTPRINT = footprint( - "PLCC-6 5050", - "SK9822 clocked addressable RGB LED", - SK9822_PADS, - courtyard_for(SK9822_PADS, SK9822_BODY_MM), -) - - -TACTILESWITCH_DRILL = rules.drill_for_lead(0.7) - -TACTILESWITCH_PAD = rules.pad_for_drill(TACTILESWITCH_DRILL) - -TACTILESWITCH_PADS = ( - pad( - TactileSwitchPad.SIGNAL_PRIMARY, - -3.25, - 2.25, - TACTILESWITCH_PAD, - TACTILESWITCH_PAD, - pcbnew.PAD_SHAPE_RECT, - TACTILESWITCH_DRILL, - ), - pad( - TactileSwitchPad.SIGNAL_DUPLICATE, - -3.25, - -2.25, - TACTILESWITCH_PAD, - TACTILESWITCH_PAD, - pcbnew.PAD_SHAPE_CIRCLE, - TACTILESWITCH_DRILL, - ), - pad( - TactileSwitchPad.GROUND_PRIMARY, - 3.25, - 2.25, - TACTILESWITCH_PAD, - TACTILESWITCH_PAD, - pcbnew.PAD_SHAPE_CIRCLE, - TACTILESWITCH_DRILL, - ), - pad( - TactileSwitchPad.GROUND_DUPLICATE, - 3.25, - -2.25, - TACTILESWITCH_PAD, - TACTILESWITCH_PAD, - pcbnew.PAD_SHAPE_CIRCLE, - TACTILESWITCH_DRILL, - ), -) - -TACTILESWITCH_FOOTPRINT = footprint( - "6x6 mm THT", - "6 mm tactile panel switch, 9.5 mm actuator", - TACTILESWITCH_PADS, - courtyard_for(TACTILESWITCH_PADS, (6.2, 6.2)), -) - -TACTILESWITCH_LABEL_OFFSET_MM = 6.5 - - -TCA9554_FOOTPRINT = soic( - "SOIC-16W 1.27 mm", - "TCA9554DWR wide SOIC, TI DW0016A", - 16, - 9.3, - (7.6, 10.5), - tuple(Tca9554Pin), - pad_size_mm=(2.0, 0.6), -) - - -TCA9554_SILKSCREEN_CLEARANCE_MM = 2.0 - - -TESTPOINT_PADS = (pad(TestPointPin.PROBE, 0.0, 0.0, 2.5, 1.25, pcbnew.PAD_SHAPE_RECT),) - -TESTPOINT_FOOTPRINT = footprint( - "SMD test point", - "Low-profile SMT probe loop", - TESTPOINT_PADS, - courtyard_for(TESTPOINT_PADS, (2.0, 1.2)), -) - - -TVSDIODE_FOOTPRINT = two_terminal_smd( - "SMB (DO-214AA)", - "6 V unidirectional TVS diode", - 5.1, - (2.2, 2.4), - (4.6, 3.6), - tuple(TvsDiodePin), -) - -DC_INPUT_JACK = BarrelJack(ComponentReference.DC_INPUT_JACK) -INPUT_FUSE = Fuse(ComponentReference.INPUT_FUSE) -MAIN_POWER_SWITCH = PowerSwitch(ComponentReference.MAIN_POWER_SWITCH) - - -@dataclass(frozen=True) -class PcbPart[Part: EndpointResolver]: - """One approved product's logical model, land pattern, and display defaults.""" - - spec: ComponentSpec - model: Callable[[str], Part] - template: pcbnew.FOOTPRINT - library: str - nominal_value: str - default_purpose: str - - def __post_init__(self) -> None: - if not all((self.library, self.nominal_value, self.default_purpose)): - raise ValueError(f"{self.spec.key}: PCB display defaults must not be empty") - if self.template.GetFieldText("Package") != self.spec.package: - raise ValueError( - f"{self.spec.key}: footprint package does not match product" - ) - if not self.model("REGISTRY_CHECK").supports_part_key(self.spec.key): - raise ValueError( - f"{self.spec.key}: electronic model does not support product" - ) - - def new_model(self, reference: str) -> Part: - """Construct the typed logical model selected by this registry entry.""" - return self.model(reference) - - -AHCT125_PART = PcbPart( - AHCT125, - Ahct125, - AHCT125_FOOTPRINT, - "AHCT125", - "SN74AHCT125DR", - "Quad 5 V buffer accepts 3.3 V SPI clock and data", -) -BARREL_JACK_PART = PcbPart( - BARREL_JACK, - BarrelJack, - BARRELJACK_FOOTPRINT, - "BARREL_JACK", - "DC 5.5x2.0", - "5 V DC input jack, centre positive", -) -BUTTON_PART = PcbPart( - BUTTON, - TactileSwitch, - TACTILESWITCH_FOOTPRINT, - "BUTTON", - "TACT 6mm", - "Momentary panel button, 9.5 mm actuator", -) -CAP_100N_PART = PcbPart( - CAP_100N, - Capacitor, - CAPACITOR_0603_FOOTPRINT, - "C", - "100nF", - CAP_100N.description, -) -CAP_10U_PART = PcbPart( - CAP_10U, - Capacitor, - CAPACITOR_0805_FOOTPRINT, - "C", - "10uF 10V", - CAP_10U.description, -) -CAP_1000U_PART = PcbPart( - CAP_1000U, - Capacitor, - CAPACITOR_ELECTROLYTIC_10MM, - "C", - "1000uF 10V", - CAP_1000U.description, -) -FUSE_2A_PART = PcbPart( - FUSE_2A, - Fuse, - FUSE_FOOTPRINT, - "FUSE", - "2 A time-delay", - "Input over-current protection matched to the 2.5 A jack", -) -HALL_SENSOR_PART = PcbPart( - HALL_SENSOR, - HallSensor, - HALLSENSOR_FOOTPRINT, - "HALL", - "DRV5032FC", - "Omnipolar active-low Hall-effect square sensor", -) -TCA9554_PART = PcbPart( - TCA9554, - Tca9554, - TCA9554_FOOTPRINT, - "TCA9554", - "TCA9554DWR", - "8-bit I2C GPIO expander with input pull-ups", -) -OLED_HEADER_PART = PcbPart( - OLED_HEADER, - OledHeader, - OLEDHEADER_FOOTPRINT, - "OLED_HEADER", - "1x4 header", - "SSD1306 OLED module connector", -) -PI_ZERO_HEADER_PART = PcbPart( - PI_ZERO_HEADER, - RaspberryPiHeader, - RASPBERRYPIHEADER_FOOTPRINT, - "PI_HEADER", - "2x20 header", - "Raspberry Pi Zero 2 W GPIO socket", -) -POWER_SWITCH_PART = PcbPart( - POWER_SWITCH, - PowerSwitch, - POWERSWITCH_FOOTPRINT, - "SWITCH", - "POWER", - "Latching power switch", -) -RES_4K7_PART = PcbPart( - RES_4K7, - Resistor, - RESISTOR_FOOTPRINT, - "R", - "4.7k", - RES_4K7.description, +from pcb.definition.parts.ahct125 import AHCT125_PART +from pcb.definition.parts.barrel_jack import BARREL_JACK_PART +from pcb.definition.parts.button import ( + BUTTON_PART, ) -SK9822_PART = PcbPart( - SK9822, - Sk9822, - SK9822_FOOTPRINT, - "SK9822", - "SK9822", - SK9822.description, +from pcb.definition.parts.capacitors import ( + CAP_10U_PART, + CAP_100N_PART, + CAP_1000U_PART, ) -TEST_POINT_PART = PcbPart( - TEST_POINT, - TestPoint, - TESTPOINT_FOOTPRINT, - "TESTPOINT", - "TEST", - TEST_POINT.description, +from pcb.definition.parts.fuse import FUSE_2A_PART +from pcb.definition.parts.hall_sensor import HALL_SENSOR_PART +from pcb.definition.parts.oled_header import OLED_HEADER_PART +from pcb.definition.parts.power_switch import POWER_SWITCH_PART +from pcb.definition.parts.raspberry_pi_header import ( + PI_ZERO_HEADER_PART, ) -TVS_6V8_PART = PcbPart( - TVS_6V8, - TvsDiode, - TVSDIODE_FOOTPRINT, - "TVS", - "SMBJ6.0A", - "Input transient suppressor on the 5 V rail", +from pcb.definition.parts.resistor import RES_4K7_PART +from pcb.definition.parts.sk9822 import SK9822_PART +from pcb.definition.parts.tca9554 import ( + TCA9554_PART, ) +from pcb.definition.parts.test_point import TEST_POINT_PART +from pcb.definition.parts.tvs_diode import TVS_6V8_PART _PCB_PART_ENTRIES = ( AHCT125_PART, @@ -554,30 +46,3 @@ def new_model(self, reference: str) -> Part: PCB_PARTS = {part.spec.key: part for part in _PCB_PART_ENTRIES} if len(PCB_PARTS) != len(_PCB_PART_ENTRIES): raise ValueError("PCB part keys must be unique") - - -SMD_MPNS = { - part.spec.mpn - for part in PCB_PARTS.values() - if any(p.GetAttribute() == pcbnew.PAD_ATTRIB_SMD for p in part.template.Pads()) -} - - -def signal_escape_distance_mm(mpn: str, pin_number: str) -> float: - if mpn not in SMD_MPNS: - raise KeyError(f"no signal escape for {mpn}") - if mpn in (AHCT125.mpn, TCA9554.mpn): - return 2.0 + (int(pin_number) - 1) % 4 - return 3.0 if mpn == HALL_SENSOR.mpn else 2.0 - - -def power_escape_policy(mpn: str, pin_number: str) -> tuple[float, bool]: - if mpn not in SMD_MPNS: - raise KeyError(f"no power escape for {mpn}") - if mpn == TCA9554.mpn: - return signal_escape_distance_mm(mpn, pin_number), True - return (1.2, True) if mpn == AHCT125.mpn else (0.4, False) - - -def uses_horizontal_signal_escape(mpn: str) -> bool: - return mpn in (AHCT125.mpn, TCA9554.mpn) diff --git a/hardware/pcb/definition/parts/fuse.py b/hardware/pcb/definition/parts/fuse.py new file mode 100644 index 00000000..02a21a2d --- /dev/null +++ b/hardware/pcb/definition/parts/fuse.py @@ -0,0 +1,28 @@ +"""KiCad footprint and approved PCB binding for fuse.""" + +from pcb.definition.parts.land_patterns import two_terminal_smd +from pcb.definition.parts.part import PcbPart +from shared.components import FUSE_2A +from shared.electronics import ComponentReference +from shared.electronics.passives import FuseComponent as Fuse +from shared.electronics.passives import FusePin + +FUSE_FOOTPRINT = two_terminal_smd( + "2410 fuse", + "2 A time-delay surface-mount fuse", + 6.6, + (2.7, 3.2), + (6.1, 2.7), + tuple(FusePin), +) + +FUSE_2A_PART = PcbPart( + FUSE_2A, + Fuse, + FUSE_FOOTPRINT, + "FUSE", + "2 A time-delay", + "Input over-current protection matched to the 2.5 A jack", +) + +INPUT_FUSE = Fuse(ComponentReference.INPUT_FUSE) diff --git a/hardware/pcb/definition/parts/hall_sensor.py b/hardware/pcb/definition/parts/hall_sensor.py new file mode 100644 index 00000000..952c00d6 --- /dev/null +++ b/hardware/pcb/definition/parts/hall_sensor.py @@ -0,0 +1,31 @@ +"""KiCad footprint and approved PCB binding for hall sensor.""" + +import pcbnew + +from pcb.definition.parts.land_patterns import courtyard_for, footprint, pad +from pcb.definition.parts.part import PcbPart +from shared.components import HALL_SENSOR +from shared.electronics.hall_sensor import HallSensorComponent as HallSensor +from shared.electronics.hall_sensor import HallSensorPin + +HALLSENSOR_PADS = ( + pad(HallSensorPin.SUPPLY, -0.95, 0.95, 1.0, 1.1, pcbnew.PAD_SHAPE_RECT), + pad(HallSensorPin.ACTIVE_LOW_OUTPUT, -0.95, -0.95, 1.0, 1.1, pcbnew.PAD_SHAPE_OVAL), + pad(HallSensorPin.GROUND, 0.95, 0.0, 1.0, 1.1, pcbnew.PAD_SHAPE_OVAL), +) + +HALLSENSOR_FOOTPRINT = footprint( + "SOT-23-3", + "DRV5032FC omnipolar Hall sensor", + HALLSENSOR_PADS, + courtyard_for(HALLSENSOR_PADS, (2.9, 2.8)), +) + +HALL_SENSOR_PART = PcbPart( + HALL_SENSOR, + HallSensor, + HALLSENSOR_FOOTPRINT, + "HALL", + "DRV5032FC", + "Omnipolar active-low Hall-effect square sensor", +) diff --git a/hardware/pcb/definition/parts/oled_header.py b/hardware/pcb/definition/parts/oled_header.py new file mode 100644 index 00000000..1f736710 --- /dev/null +++ b/hardware/pcb/definition/parts/oled_header.py @@ -0,0 +1,24 @@ +"""KiCad footprint and approved PCB binding for oled header.""" + +from pcb.definition.parts.land_patterns import pin_header +from pcb.definition.parts.part import PcbPart +from shared.components import OLED_HEADER +from shared.electronics.connectors import OledHeaderComponent as OledHeader +from shared.electronics.connectors import OledHeaderPin + +OLEDHEADER_FOOTPRINT = pin_header( + "1x4 2.54 mm THT", + "Four-pin SSD1306 I2C OLED module connector", + columns=4, + rows=1, + pin_numbers=tuple(OledHeaderPin), +) + +OLED_HEADER_PART = PcbPart( + OLED_HEADER, + OledHeader, + OLEDHEADER_FOOTPRINT, + "OLED_HEADER", + "1x4 header", + "SSD1306 OLED module connector", +) diff --git a/hardware/pcb/definition/parts/part.py b/hardware/pcb/definition/parts/part.py new file mode 100644 index 00000000..f6661240 --- /dev/null +++ b/hardware/pcb/definition/parts/part.py @@ -0,0 +1,37 @@ +"""Typed native PCB part binding and registry validation.""" + +from collections.abc import Callable +from dataclasses import dataclass + +import pcbnew + +from shared.components import ComponentSpec +from shared.electronics import EndpointResolver + + +@dataclass(frozen=True) +class PcbPart[Part: EndpointResolver]: + """One approved product's logical model, land pattern, and display defaults.""" + + spec: ComponentSpec + model: Callable[[str], Part] + template: pcbnew.FOOTPRINT + library: str + nominal_value: str + default_purpose: str + + def __post_init__(self) -> None: + if not all((self.library, self.nominal_value, self.default_purpose)): + raise ValueError(f"{self.spec.key}: PCB display defaults must not be empty") + if self.template.GetFieldText("Package") != self.spec.package: + raise ValueError( + f"{self.spec.key}: footprint package does not match product" + ) + if not self.model("REGISTRY_CHECK").supports_part_key(self.spec.key): + raise ValueError( + f"{self.spec.key}: electronic model does not support product" + ) + + def new_model(self, reference: str) -> Part: + """Construct the typed logical model selected by this registry entry.""" + return self.model(reference) diff --git a/hardware/pcb/definition/parts/power_switch.py b/hardware/pcb/definition/parts/power_switch.py new file mode 100644 index 00000000..7fe7b430 --- /dev/null +++ b/hardware/pcb/definition/parts/power_switch.py @@ -0,0 +1,28 @@ +"""KiCad footprint and approved PCB binding for power switch.""" + +from pcb.definition.parts.land_patterns import two_pad_axial +from pcb.definition.parts.part import PcbPart +from shared.components import POWER_SWITCH +from shared.electronics import ComponentReference +from shared.electronics.passives import PowerSwitchComponent as PowerSwitch +from shared.electronics.passives import PowerSwitchPin + +POWERSWITCH_FOOTPRINT = two_pad_axial( + "SPST rocker THT", + "Latching rocker power switch", + pitch=12.7, + lead_diameter=1.2, + body=(19.5, 13.0), + pin_numbers=tuple(PowerSwitchPin), +) + +POWER_SWITCH_PART = PcbPart( + POWER_SWITCH, + PowerSwitch, + POWERSWITCH_FOOTPRINT, + "SWITCH", + "POWER", + "Latching power switch", +) + +MAIN_POWER_SWITCH = PowerSwitch(ComponentReference.MAIN_POWER_SWITCH) diff --git a/hardware/pcb/definition/parts/raspberry_pi_header.py b/hardware/pcb/definition/parts/raspberry_pi_header.py new file mode 100644 index 00000000..7943a817 --- /dev/null +++ b/hardware/pcb/definition/parts/raspberry_pi_header.py @@ -0,0 +1,32 @@ +"""KiCad footprint and approved PCB binding for raspberry pi header.""" + +from pcb.definition.parts.land_patterns import pin_header +from pcb.definition.parts.part import PcbPart +from shared.components import PI_ZERO_HEADER +from shared.electronics.raspberry_pi_header import ( + RaspberryPiHeaderComponent as RaspberryPiHeader, +) +from shared.electronics.raspberry_pi_header import RaspberryPiHeaderPin + +RASPBERRYPIHEADER_FOOTPRINT = pin_header( + "2x20 2.54 mm THT", + "Raspberry Pi Zero 2 W GPIO socket", + columns=20, + rows=2, + pin_numbers=tuple(RaspberryPiHeaderPin), +) + +RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_HALF_WIDTH_MM = 1.2 + +RASPBERRYPIHEADER_POWER_ESCAPE_MM = 6.0 + +RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_LENGTH_MM = 6.0 + +PI_ZERO_HEADER_PART = PcbPart( + PI_ZERO_HEADER, + RaspberryPiHeader, + RASPBERRYPIHEADER_FOOTPRINT, + "PI_HEADER", + "2x20 header", + "Raspberry Pi Zero 2 W GPIO socket", +) diff --git a/hardware/pcb/definition/parts/resistor.py b/hardware/pcb/definition/parts/resistor.py new file mode 100644 index 00000000..9f2986cc --- /dev/null +++ b/hardware/pcb/definition/parts/resistor.py @@ -0,0 +1,25 @@ +"""KiCad footprint and approved PCB binding for resistor.""" + +from pcb.definition.parts.land_patterns import two_terminal_smd +from pcb.definition.parts.part import PcbPart +from shared.components import RES_4K7 +from shared.electronics.passives import ResistorComponent as Resistor +from shared.electronics.passives import ResistorPin + +RESISTOR_FOOTPRINT = two_terminal_smd( + "0603 (1608 metric)", + "4.7 kΩ thick-film resistor", + 1.5, + (0.9, 0.95), + (1.6, 0.8), + tuple(ResistorPin), +) + +RES_4K7_PART = PcbPart( + RES_4K7, + Resistor, + RESISTOR_FOOTPRINT, + "R", + "4.7k", + RES_4K7.description, +) diff --git a/hardware/pcb/definition/parts/sk9822.py b/hardware/pcb/definition/parts/sk9822.py new file mode 100644 index 00000000..45d6ef87 --- /dev/null +++ b/hardware/pcb/definition/parts/sk9822.py @@ -0,0 +1,86 @@ +"""KiCad footprint and approved PCB binding for sk9822.""" + +import pcbnew + +from pcb.definition.parts.land_patterns import courtyard_for, footprint, pad +from pcb.definition.parts.part import PcbPart +from shared.components import SK9822 +from shared.electronics.sk9822 import Sk9822Component as Sk9822 +from shared.electronics.sk9822 import Sk9822Pin + +SK9822_BODY_MM = (5.0, 5.0) + +SK9822_PAD_LONG_MM = 1.5 + +SK9822_PAD_SHORT_MM = 1.0 + +SK9822_PAD_EDGE_MM = 2.5 + +SK9822_SIGNAL_PITCH_MM = 1.6 + +SK9822_PADS = ( + pad( + Sk9822Pin.DATA_IN, + -SK9822_PAD_EDGE_MM, + SK9822_SIGNAL_PITCH_MM / 2.0, + SK9822_PAD_LONG_MM, + SK9822_PAD_SHORT_MM, + pcbnew.PAD_SHAPE_RECT, + ), + pad( + Sk9822Pin.CLOCK_IN, + -SK9822_PAD_EDGE_MM, + -SK9822_SIGNAL_PITCH_MM / 2.0, + SK9822_PAD_LONG_MM, + SK9822_PAD_SHORT_MM, + pcbnew.PAD_SHAPE_OVAL, + ), + pad( + Sk9822Pin.DATA_OUT, + SK9822_PAD_EDGE_MM, + SK9822_SIGNAL_PITCH_MM / 2.0, + SK9822_PAD_LONG_MM, + SK9822_PAD_SHORT_MM, + pcbnew.PAD_SHAPE_OVAL, + ), + pad( + Sk9822Pin.CLOCK_OUT, + SK9822_PAD_EDGE_MM, + -SK9822_SIGNAL_PITCH_MM / 2.0, + SK9822_PAD_LONG_MM, + SK9822_PAD_SHORT_MM, + pcbnew.PAD_SHAPE_OVAL, + ), + pad( + Sk9822Pin.FIVE_VOLTS, + 0.0, + SK9822_PAD_EDGE_MM, + SK9822_PAD_SHORT_MM, + SK9822_PAD_LONG_MM, + pcbnew.PAD_SHAPE_OVAL, + ), + pad( + Sk9822Pin.GROUND, + 0.0, + -SK9822_PAD_EDGE_MM, + SK9822_PAD_SHORT_MM, + SK9822_PAD_LONG_MM, + pcbnew.PAD_SHAPE_OVAL, + ), +) + +SK9822_FOOTPRINT = footprint( + "PLCC-6 5050", + "SK9822 clocked addressable RGB LED", + SK9822_PADS, + courtyard_for(SK9822_PADS, SK9822_BODY_MM), +) + +SK9822_PART = PcbPart( + SK9822, + Sk9822, + SK9822_FOOTPRINT, + "SK9822", + "SK9822", + SK9822.description, +) diff --git a/hardware/pcb/definition/parts/tca9554.py b/hardware/pcb/definition/parts/tca9554.py new file mode 100644 index 00000000..7e1b6377 --- /dev/null +++ b/hardware/pcb/definition/parts/tca9554.py @@ -0,0 +1,29 @@ +"""KiCad footprint and approved PCB binding for tca9554.""" + +from pcb.definition.parts.land_patterns import soic +from pcb.definition.parts.part import PcbPart +from shared.components import TCA9554 +from shared.electronics.tca9554 import Tca9554Component as Tca9554 +from shared.electronics.tca9554 import Tca9554Pin + +TCA9554_FOOTPRINT = soic( + "SOIC-16W 1.27 mm", + "TCA9554DWR wide SOIC, TI DW0016A", + 16, + 9.3, + (7.6, 10.5), + tuple(Tca9554Pin), + pad_size_mm=(2.0, 0.6), +) + + +TCA9554_SILKSCREEN_CLEARANCE_MM = 2.0 + +TCA9554_PART = PcbPart( + TCA9554, + Tca9554, + TCA9554_FOOTPRINT, + "TCA9554", + "TCA9554DWR", + "8-bit I2C GPIO expander with input pull-ups", +) diff --git a/hardware/pcb/definition/parts/test_point.py b/hardware/pcb/definition/parts/test_point.py new file mode 100644 index 00000000..6867c446 --- /dev/null +++ b/hardware/pcb/definition/parts/test_point.py @@ -0,0 +1,27 @@ +"""KiCad footprint and approved PCB binding for test point.""" + +import pcbnew + +from pcb.definition.parts.land_patterns import courtyard_for, footprint, pad +from pcb.definition.parts.part import PcbPart +from shared.components import TEST_POINT +from shared.electronics.test_point import TestPointComponent as TestPoint +from shared.electronics.test_point import TestPointPin + +TESTPOINT_PADS = (pad(TestPointPin.PROBE, 0.0, 0.0, 2.5, 1.25, pcbnew.PAD_SHAPE_RECT),) + +TESTPOINT_FOOTPRINT = footprint( + "SMD test point", + "Low-profile SMT probe loop", + TESTPOINT_PADS, + courtyard_for(TESTPOINT_PADS, (2.0, 1.2)), +) + +TEST_POINT_PART = PcbPart( + TEST_POINT, + TestPoint, + TESTPOINT_FOOTPRINT, + "TESTPOINT", + "TEST", + TEST_POINT.description, +) diff --git a/hardware/pcb/definition/parts/tvs_diode.py b/hardware/pcb/definition/parts/tvs_diode.py new file mode 100644 index 00000000..fda4dcbc --- /dev/null +++ b/hardware/pcb/definition/parts/tvs_diode.py @@ -0,0 +1,25 @@ +"""KiCad footprint and approved PCB binding for tvs diode.""" + +from pcb.definition.parts.land_patterns import two_terminal_smd +from pcb.definition.parts.part import PcbPart +from shared.components import TVS_6V8 +from shared.electronics.passives import TvsDiodeComponent as TvsDiode +from shared.electronics.passives import TvsDiodePin + +TVSDIODE_FOOTPRINT = two_terminal_smd( + "SMB (DO-214AA)", + "6 V unidirectional TVS diode", + 5.1, + (2.2, 2.4), + (4.6, 3.6), + tuple(TvsDiodePin), +) + +TVS_6V8_PART = PcbPart( + TVS_6V8, + TvsDiode, + TVSDIODE_FOOTPRINT, + "TVS", + "SMBJ6.0A", + "Input transient suppressor on the 5 V rail", +) diff --git a/hardware/pcb/definition/routing/escape_policy.py b/hardware/pcb/definition/routing/escape_policy.py new file mode 100644 index 00000000..cadee555 --- /dev/null +++ b/hardware/pcb/definition/routing/escape_policy.py @@ -0,0 +1,32 @@ +"""Per-product signal and power escape choices for PCB routing.""" + +import pcbnew + +from pcb.definition.parts.catalog import PCB_PARTS +from shared.components import AHCT125, HALL_SENSOR, TCA9554 + +SMD_MPNS = { + part.spec.mpn + for part in PCB_PARTS.values() + if any(p.GetAttribute() == pcbnew.PAD_ATTRIB_SMD for p in part.template.Pads()) +} + + +def signal_escape_distance_mm(mpn: str, pin_number: str) -> float: + if mpn not in SMD_MPNS: + raise KeyError(f"no signal escape for {mpn}") + if mpn in (AHCT125.mpn, TCA9554.mpn): + return 2.0 + (int(pin_number) - 1) % 4 + return 3.0 if mpn == HALL_SENSOR.mpn else 2.0 + + +def power_escape_policy(mpn: str, pin_number: str) -> tuple[float, bool]: + if mpn not in SMD_MPNS: + raise KeyError(f"no power escape for {mpn}") + if mpn == TCA9554.mpn: + return signal_escape_distance_mm(mpn, pin_number), True + return (1.2, True) if mpn == AHCT125.mpn else (0.4, False) + + +def uses_horizontal_signal_escape(mpn: str) -> bool: + return mpn in (AHCT125.mpn, TCA9554.mpn) diff --git a/hardware/pcb/definition/routing/policies.py b/hardware/pcb/definition/routing/policies.py index bbd62f9a..3ef226ac 100644 --- a/hardware/pcb/definition/routing/policies.py +++ b/hardware/pcb/definition/routing/policies.py @@ -13,15 +13,15 @@ import pcb.definition.routing.paths as grid_router from pcb.definition import native, rules from pcb.definition.bank_assemblies import BANK_ASSEMBLIES -from pcb.definition.parts import catalog -from pcb.definition.parts.catalog import ( - DC_INPUT_JACK, - INPUT_FUSE, - MAIN_POWER_SWITCH, +from pcb.definition.parts.barrel_jack import DC_INPUT_JACK +from pcb.definition.parts.fuse import INPUT_FUSE +from pcb.definition.parts.power_switch import MAIN_POWER_SWITCH +from pcb.definition.parts.raspberry_pi_header import ( RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_HALF_WIDTH_MM, RASPBERRYPIHEADER_BUTTON_VIA_KEEPOUT_LENGTH_MM, RASPBERRYPIHEADER_POWER_ESCAPE_MM, ) +from pcb.definition.routing import escape_policy from pcb.definition.routing.paths import RoutingOptions from pcb.definition.rules import Net from shared import wiring @@ -38,7 +38,7 @@ from shared.electronics import Sk9822Component as Sk9822 from shared.electronics import Tca9554Component as Tca9554 from shared.hall_banks import BANK_FILES, BANK_RANKS, HallBank -from shared.panel import PANEL_BUTTONS +from shared.panel_buttons import PANEL_BUTTONS INTERNAL_SIGNAL_LAYERS = (pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.In6_Cu) @@ -146,8 +146,8 @@ def signal_escape( centre = footprint.GetPosition() dx, dy = (at.x - centre.x, at.y - centre.y) component_mpn = footprint.GetValue() - escape_mm = catalog.signal_escape_distance_mm(component_mpn, pad.GetNumber()) - force_horizontal = catalog.uses_horizontal_signal_escape(component_mpn) + escape_mm = escape_policy.signal_escape_distance_mm(component_mpn, pad.GetNumber()) + force_horizontal = escape_policy.uses_horizontal_signal_escape(component_mpn) distance = pcbnew.FromMM(escape_mm) if force_horizontal or abs(dx) >= abs(dy): escaped = pcbnew.VECTOR2I(at.x + (distance if dx >= 0 else -distance), at.y) @@ -520,7 +520,7 @@ def _power_escape_position( if reference == ComponentReference.HOST_GPIO_HEADER: escape_mm, horizontal = (RASPBERRYPIHEADER_POWER_ESCAPE_MM, True) else: - escape_mm, horizontal = catalog.power_escape_policy( + escape_mm, horizontal = escape_policy.power_escape_policy( module.GetValue(), pad.GetNumber() ) distance = pcbnew.FromMM(escape_mm) diff --git a/hardware/pcb/definition/validation.py b/hardware/pcb/definition/validation.py new file mode 100644 index 00000000..7af22c5c --- /dev/null +++ b/hardware/pcb/definition/validation.py @@ -0,0 +1,111 @@ +"""Physical, product, and logical-connectivity checks for the native board.""" + +from collections import Counter + +import pcbnew + +from pcb.definition.assemblies import controls, power, sensing, square +from pcb.definition.bank_assemblies import BANK_ASSEMBLIES +from pcb.definition.native import connections, endpoint_pads, logical_pin, parts, point +from pcb.definition.parts.catalog import PCB_PARTS +from shared import dimensions, wiring +from shared.components import COMPONENTS +from shared.electronics import Endpoint +from shared.electronics.hall_sensor import HallSensorPin + + +def validate(board: pcbnew.BOARD) -> None: + from pcb.definition import rules + from shared.electronics.tca9554 import Tca9554Component, Tca9554Pin + + rules.validate() + footprints = parts(board) + graph = connections(board) + actual = Counter(f.GetFieldText("Assembly") for f in footprints) + expected = { + "power": power.ASSEMBLY_PART_COUNT, + "controls": controls.ASSEMBLY_PART_COUNT, + **{ + f"square/{board_square.name}": square.ASSEMBLY_PART_COUNT + for board_square in dimensions.BOARD_SQUARES + }, + **{ + bank_assembly.assembly_name: sensing.ASSEMBLY_PART_COUNT + for bank_assembly in BANK_ASSEMBLIES + }, + } + for name, count in expected.items(): + if actual[name] != count: + raise ValueError( + f"{name}: expected {count} components, found {actual[name]}" + ) + if unexpected := actual.keys() - expected.keys(): + raise ValueError(f"unexpected assemblies: {sorted(unexpected)}") + for footprint in footprints: + ref = footprint.GetReference() + key = footprint.GetFieldText("PartKey") + spec = COMPONENTS[key] + model = PCB_PARTS[key].new_model(ref) + if ( + footprint.GetValue() != spec.mpn + or footprint.GetFieldText("Package") != spec.package + ): + raise ValueError(f"{ref}: unapproved product/package") + physical = {logical_pin(p) for p in footprint.Pads()} + if physical != {p.endpoint.pin for p in model.pins} or any( + p.GetNetCode() == 0 for p in footprint.Pads() + ): + raise ValueError(f"{ref}: incomplete physical/logical pin assignment") + if any( + name.startswith("unconnected-") and len(nodes) != 1 + for name, nodes in graph.items() + ): + raise ValueError("no-connect nets must contain one logical pin") + pads = endpoint_pads(board) + for bank_assembly in BANK_ASSEMBLIES: + bank = bank_assembly.bank + ref = bank_assembly.expander_reference + expected = { + Tca9554Pin.SUPPLY: "+3V3", + Tca9554Pin.GROUND: "GND", + Tca9554Pin.I2C_CLOCK: wiring.SCL_NET, + Tca9554Pin.I2C_DATA: wiring.SDA_NET, + Tca9554Pin.INTERRUPT: f"unconnected-({ref}-Pad13)", + } + expected.update( + zip( + (Tca9554Pin.ADDRESS_0, Tca9554Pin.ADDRESS_1, Tca9554Pin.ADDRESS_2), + ("+3V3" if high else "GND" for high in bank.straps), + strict=True, + ) + ) + for pin, name in expected.items(): + if pads[Endpoint(ref, pin)].GetNetname() != name: + raise ValueError(f"{ref}: incorrect {pin.name} assignment") + for pin, member in zip( + Tca9554Component.input_pins(), bank.members, strict=True + ): + name = wiring.sense_net(member.name) + if set(graph[name]) != { + Endpoint(ref, pin), + Endpoint( + f"HS{dimensions.BOARD_SQUARES.by_position(member).sensor_number}", + HallSensorPin.ACTIVE_LOW_OUTPUT, + ), + }: + raise ValueError(f"{bank.label}: incorrect Hall mapping") + for board_square in dimensions.BOARD_SQUARES: + name = board_square.name + members = [ + f for f in footprints if f.GetFieldText("Assembly") == f"square/{name}" + ] + if sorted(f.GetFieldText("PartKey") for f in members) != [ + "CAP_100N", + "CAP_100N", + "HALL_SENSOR", + "SK9822", + ]: + raise ValueError(f"{name}: incomplete square assembly") + sensor = next(f for f in members if f.GetFieldText("PartKey") == "HALL_SENSOR") + if sensor.GetPosition() != point(*board_square.hall_position_mm): + raise ValueError(f"{name}: sensor is not at the shared square centre") diff --git a/hardware/pcb/generated/board-3d.png b/hardware/pcb/generated/board-3d.png index bf32a2bd..fc792379 100644 Binary files a/hardware/pcb/generated/board-3d.png and b/hardware/pcb/generated/board-3d.png differ diff --git a/hardware/pcb/generated/board-bottom.png b/hardware/pcb/generated/board-bottom.png index 369cb44a..2d7398a3 100644 Binary files a/hardware/pcb/generated/board-bottom.png and b/hardware/pcb/generated/board-bottom.png differ diff --git a/hardware/pcb/generated/board-top.png b/hardware/pcb/generated/board-top.png index 41c397a1..bd9df556 100644 Binary files a/hardware/pcb/generated/board-top.png and b/hardware/pcb/generated/board-top.png differ diff --git a/hardware/pcb/generated/bom.md b/hardware/pcb/generated/bom.md index d5d095f2..4929b960 100644 --- a/hardware/pcb/generated/bom.md +++ b/hardware/pcb/generated/bom.md @@ -1,6 +1,6 @@ # Approved bill of materials -Generated from `hardware/shared/components.py` and `definition/board.py`. +Generated from `hardware/shared/components/` and `definition/board.py`. Every row names an exact manufacturer part number; substitutions require updating the shared catalog and passing footprint/package validation. diff --git a/hardware/pcb/generated/drc.json b/hardware/pcb/generated/drc.json index 1f4317ff..ab218480 100644 --- a/hardware/pcb/generated/drc.json +++ b/hardware/pcb/generated/drc.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.kicad.org/drc.v1.json", "coordinate_units": "mm", - "date": "2026-09-18T17:33:41+0000", + "date": "2026-09-30T10:50:59+0000", "included_severities": [ "error", "warning", diff --git a/hardware/pcb/generated/drc.rpt b/hardware/pcb/generated/drc.rpt index 660346e5..d7d42138 100644 --- a/hardware/pcb/generated/drc.rpt +++ b/hardware/pcb/generated/drc.rpt @@ -1,5 +1,5 @@ ** Drc report for chess-board.kicad_pcb ** -** Created on 2026-09-18T17:33:42+0000 ** +** Created on 2026-09-30T10:51:01+0000 ** ** Report includes: Errors, Warnings, Exclusions ** ** Found 0 DRC violations ** diff --git a/hardware/pcb/generated/erc.json b/hardware/pcb/generated/erc.json index 9601acc4..3effa498 100644 --- a/hardware/pcb/generated/erc.json +++ b/hardware/pcb/generated/erc.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.kicad.org/erc.v1.json", "coordinate_units": "mm", - "date": "2026-09-18T17:33:39+0000", + "date": "2026-09-30T10:50:58+0000", "included_severities": [ "error", "warning", diff --git a/hardware/pcb/generated/manifest.json b/hardware/pcb/generated/manifest.json index 0797f460..f5e06deb 100644 --- a/hardware/pcb/generated/manifest.json +++ b/hardware/pcb/generated/manifest.json @@ -9,37 +9,37 @@ "bank-E3-H4.kicad_sch": "82931e8b2c1dd14ba52a82822b75156567ba039dc1c5bb9bce63adf556825cca", "bank-E5-H6.kicad_sch": "ecc479262f310f7bad2f69b947d3b32e12496be73aef8249a7b33a2c88d66456", "bank-E7-H8.kicad_sch": "b412c3cc2b837a19945f7b109028289dd9b1926deba99b4eb30bf2b81f9cb529", - "board-3d.png": "764cef5a8d2172565f6830919808e40eff3bd59a93efccc5e8bcabf82a673fdc", - "board-bottom.png": "f4047c14b9eecde47066eded49385da549f160af1bbb0a24198edebe022d1dd5", + "board-3d.png": "e221ff99f9a7d28d2b79a60b89d637005cce24c61e8baa2c2426bced5ba44ab9", + "board-bottom.png": "7ec985bb47fa2e938f365bfae1439bdf891fbd9c8e79446196968bddf7b5de1c", "board-bottom.svg": "079c9a77db31202646f4d2ca18d9816e9c418f730ed2e8ffa2babd890d8b8e12", - "board-top.png": "e1ffa3b3d3b921566e5f5fc6cfbcc445ea6516650ff991f526b6db5848d332ba", + "board-top.png": "bd43fd7d0111b7515ec3253d844719c713add270567f4581435086681cbe3b79", "board-top.svg": "235483a9562d1d64714dd0f11202e472056f9458587d977df66de156b0d4a7ad", - "bom.md": "6da07fc7cf94cc32d3a282aa5a8d510da4d507474af044a096cfeab1d6fbeac0", + "bom.md": "43585b5ea50d22df84713365d30b9cb729fbab21add4532abcf8dd26294d2cfe", "chess-board.dsn": "7b11d448c9698a4ff74f3b9cecad9ac53769f9ba976f3adce61c303f7631c030", "chess-board.kicad_pcb": "959346cbe1dd5c112e57a2b579b3b460ca54ec2bc49069c3ab5361577beed1c8", "chess-board.kicad_pro": "a2b7e8fa68048012b295b5d9a16b9841e7da2e9ed732c796f6aafab97ab00246", "chess-board.kicad_sch": "37a1162d443f431d0091a41117a0485ca2aceac2865af9a3e961aa75422820ea", "controls.kicad_sch": "f49ec1cb647bfa60328ca067e6527856faaf2689143d7e297311b7558419f195", - "drc.json": "d4c0bec8f1f00cf9a85cf3a3806dd086622b1da724858820da92a4c1d8568868", - "drc.rpt": "ebd079d6d8b25da34537e477df7e5b6e39fc6b56b51f8e187d5264794ad52c52", - "erc.json": "47d5ecea9e6e27ba536d17b769192f349ab4edb596100773d1b878ba882553fc", + "drc.json": "bdfe4cec705f819d146f7b727f2a9fd3516fea0c51a9f1eafb687a2de1699ec1", + "drc.rpt": "197cc7ec8d29e9b2b0c8dcfad1c23817a9b30075f147bdf4059872a6a86b74ba", + "erc.json": "0d8f9a421a370e52c535c101f909aef5ffb130cf8a9421a1d48782c69717c3e9", "generated-symbols.kicad_sym": "d636ea8b51bf78426812274ddfbcf83f055bd7f5d76d01d7641d45757fd28093", "layout.json": "e2af689ab8e2dc6111043a25982e7ff424ff5627a00cdeb31011fd741a7c1608", "netlist.json": "7f4bde5a726a55e43840cc31a515c621f545995f144b28277876bbe4c7a17da1", "positions.csv": "58e807c9c0b8386349b117057b339a09e4c07c9e31d25295e8a4b77faad63795", "power.kicad_sch": "67f4f7d7ccdfc7b1379ba81ea65b077e0055c5b77ee959cabb4b9fdfcc0ef6f6", "review.md": "4965cd4c6e64e7548675a52858727f956c7e00cb147e23d24a958eaf74231813", - "schematic/chess-board-bank-A1-D2.svg": "93174a9183aec17d7a97d72f1d4ee6383809f368cfa48b631f79e70953774c4a", - "schematic/chess-board-bank-A3-D4.svg": "748cd56272306e3bd4b3f80935eb4eb97bff8c1d8f71535a9c0f6da1a0eac729", - "schematic/chess-board-bank-A5-D6.svg": "45d90746a8f62efa84321c616761ccb0a2fde61b15b4c26f5e4373a37b8aee0d", - "schematic/chess-board-bank-A7-D8.svg": "42c94bc44de200f2e7baa502cd6246df421fe02067cd4a0c6b82f08b02cab11b", - "schematic/chess-board-bank-E1-H2.svg": "d6b896eea60f2bb9a527ddec5a0acdc823bcb5c104b3197b13d39deebbb207c4", - "schematic/chess-board-bank-E3-H4.svg": "6eb3a4a2960ab03f9e97e5097742c7373b0426fb89b372a40fa22e434ca22233", - "schematic/chess-board-bank-E5-H6.svg": "2c0621c7efa39fceb9fb444b8837fbb57be0abe67aabf0b910cb5dd3347b75a7", - "schematic/chess-board-bank-E7-H8.svg": "b1b22aa88f368a65924376f6d3cb80af174532776ddc65c40c58960f0111fbd4", - "schematic/chess-board-controls.svg": "68309b381afeb2ad8da36dc0d015eb3296b72e3ace5c0d15fd1dbfd843a64ab8", - "schematic/chess-board-power.svg": "36c148240ca1214d73c098d4649077e2758739cf5db6ae1056c4ac206dca56c6", - "schematic/chess-board.svg": "521d8a0682701e998531cf113c7cd5c38b4a9b89f1630d1ae5564f7db9262b8c", + "schematic/chess-board-bank-A1-D2.svg": "9e81637f4f1d06d248fbd43775da93728f3d3fa6a8acc4d3e4c5f887bade338a", + "schematic/chess-board-bank-A3-D4.svg": "ec793f26ded59db884bea3938b20d25e8ad81fa99492ba5ff04b987daba1801a", + "schematic/chess-board-bank-A5-D6.svg": "6e90d69686a4592415ee7ed03f0fe5f770495b4a6630a1905abc8a9611b01de5", + "schematic/chess-board-bank-A7-D8.svg": "3c83deda54f72ba39dec31e1be5274ea56a196e6457b0d1c3684dbf666f1ab60", + "schematic/chess-board-bank-E1-H2.svg": "ba77af15370686aa838d76ad4454aed7c112247b10d74e17f25d4aee863425f5", + "schematic/chess-board-bank-E3-H4.svg": "d3219a39b9b1a6d03d99625f20321652e8e402049d805d6fed332b696e9c9f56", + "schematic/chess-board-bank-E5-H6.svg": "f0226a4105c7ea7bec916ea1deaed41edf3c65d90d4c3447f7b2a2b4e20ed14d", + "schematic/chess-board-bank-E7-H8.svg": "f244c191d92ca6c1bf240da848d90bbd7808eb912422fd611f55f11afd1aabfb", + "schematic/chess-board-controls.svg": "0499521ace5fe49cecb4fe2a7e35807f6df45af3fbdf890cb58524135de5d416", + "schematic/chess-board-power.svg": "c928778bce5d9318dde991ed01d59f1e67bf0cc59028fa43c8de9bd2a3af1756", + "schematic/chess-board.svg": "94186715a5f5ba9e8a462e738602e8f8f3f9180e325ae91076a94364841fc65c", "spice/test_buttons.cir": "0280994281e8c8b70c8cefb201afa35b5416dfa1ef7e95ba4aafa6cc6d74c413", "spice/test_hall_all_squares.cir": "26c6aa61f813f465861ebed11566f472d2498708aa3a175afe6e059be00c8c16", "spice/test_level_shifter.cir": "050d6a156b072c656fc8952d347f9ab965d505e30fd9019abbbf36a616c28015", @@ -54,7 +54,7 @@ "spice/test_power_off.cir": "1a03bff921cd9fb8ebbba12f380d187b6052aef977a7c913dacc601d43b58ae8", "spice/test_power_startup.cir": "9c1aa97681353709604fd8d9d1bd106d64c1d76e43d978127286aecb8159fada", "sym-lib-table": "5500c9ae1a32e48ee6b49e127006ea390063200b9db56294514a5be5facee3fd", - "tests.log": "0ad61a515e54c69612f99f954d82af14b980b22fd97ebac70081d28b6832e9e1" + "tests.log": "ce8270513c065f401e0a0e2c3862f1cf052c137f227a6bb620e7300cffe2c54a" }, "checks": [ "generation", @@ -73,40 +73,59 @@ "hardware/pcb/build.py": "1cdd6c207832ee9f20f9e97df9ce63770342819d40e047eec298fae48b9e672c", "hardware/pcb/definition/__init__.py": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", "hardware/pcb/definition/assemblies/__init__.py": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", - "hardware/pcb/definition/assemblies/controls.py": "7634b8126460f8708244b2ee58663c6b3567da99abe0b53f740e86359cccd360", - "hardware/pcb/definition/assemblies/power.py": "9a52208607b3d25382305ef3567de9036caf553cb2b1a3b51e656c88e425cab1", - "hardware/pcb/definition/assemblies/sensing.py": "646abb575861dd025799c3b899849dbdd8415dfde1ce4162379ec8dc9e9f6487", - "hardware/pcb/definition/assemblies/square.py": "6838728297b9c8ce49e7825848f320e177ace32ab6189d17984854e26040f952", + "hardware/pcb/definition/assemblies/controls.py": "e904e5faf5578eaa24169c8f2626352536ada7327159923bf9026eee8156cffd", + "hardware/pcb/definition/assemblies/led_link_names.py": "803a63a8f6e452c840113e5734ff41e54d5fff16728a1f9e974d6ea502b242c9", + "hardware/pcb/definition/assemblies/power.py": "c14995dfa8835a17d1f0ad5ed1a178815ee4ed81db31158b4d2a46643238629e", + "hardware/pcb/definition/assemblies/sensing.py": "831fb35443e3d66f5801086bc25b9dcc2d62206c33701cda29f31d101e15d7ee", + "hardware/pcb/definition/assemblies/square.py": "990b1d748ec78e5a27275f08cd8a6da7d6e16cc8154716069cb89a4f38f74546", "hardware/pcb/definition/bank_assemblies.py": "ff5591851844f2e344741fdca5d9cc33ba8116f36e967665dc3d324a8b99ef57", - "hardware/pcb/definition/board.py": "8b113dcbe0f9f2fc591ca5e5e2e587485608dda7b2ad87570e6aac0c94d204aa", + "hardware/pcb/definition/board.py": "66e42e994f392a7de082a4d16fb8eb99f4692c4f053af36d8260a7ec0a54f10a", "hardware/pcb/definition/manufacturing.json": "d9d382ec1385e65e3b35d1536666411b497a1d0cf2202f0f44d7ed36964d1ae6", - "hardware/pcb/definition/native.py": "c009261cf5eb1a34657ea4ddb44f1d3249cc46e5e86d226c935bc33ccc1785fd", + "hardware/pcb/definition/native.py": "ad02f6f51cb72de6db73dfcc04d27bf9c5a9ad4b887f6e914cca215cb0334405", "hardware/pcb/definition/output/__init__.py": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", - "hardware/pcb/definition/output/exports.py": "55eba91159b66a24d18122d706180f4795cc255db9fac6cf68f6ed2e617889d1", - "hardware/pcb/definition/output/markings.py": "ff203b946ffaec1938e25f3e8e00330437f47ff83f93bddd483aba07eb344a96", + "hardware/pcb/definition/output/exports.py": "4c1c1f799e253ba2bbb3e7c0efd3cda855c0128bc2271d5acce7a8baafeeb9dd", + "hardware/pcb/definition/output/markings.py": "15941dcbb94a72e6be9e16cb3bcfa1fa263ff6da68c299526c9f8a624a9f726b", "hardware/pcb/definition/output/schematic.py": "79e366e35bd32def292427878609490ac2e08cb63d151e2f20232f347b517052", "hardware/pcb/definition/output/symbols.py": "f6171b408239765c22ffff84bd7af528270d7395f88ed71a20aa20d5d1afd43d", "hardware/pcb/definition/parts/__init__.py": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", - "hardware/pcb/definition/parts/catalog.py": "59481e98e3af9315a90343a7054ec79adcbc93e974c8a0cb943fcefe067f868d", + "hardware/pcb/definition/parts/ahct125.py": "61d523afaa510b419a31b17d5f1e048997c42255d6fbb0f5e0a66f7dc6020f76", + "hardware/pcb/definition/parts/barrel_jack.py": "5dbae19e25ceac98b2941902c9fedb3a953d73d764938231b12733db39b2d366", + "hardware/pcb/definition/parts/button.py": "28f557ef6dcc0eac6983888c9523c60d232ea0e3727d53873f60a7e6a274a842", + "hardware/pcb/definition/parts/capacitors.py": "be2f66cb2958a782bf0b525fed4679608e021c1c7571eed5013690c95c76abf2", + "hardware/pcb/definition/parts/catalog.py": "641dfc77992af44638f074492fd21bf0c15cbbfd911aa409fc43e33a990f7473", + "hardware/pcb/definition/parts/fuse.py": "62b59711e0b0ca5048d8a28c548402ea363b36f5f5e1e429a77ab5fe07e97d9c", + "hardware/pcb/definition/parts/hall_sensor.py": "c21d17da19bf72540c46c92a7f20dab2dbce9494fecf2e19bd9f7473235f9e49", "hardware/pcb/definition/parts/land_patterns.py": "28cce38e0c486333f31f2e8026d4d02da53f0577f904e3cba85e6ad34b65699b", + "hardware/pcb/definition/parts/oled_header.py": "02b5bd12ba7deb25ef1ef191dd9bcdbe3448188c24e76c268cb9639bbbed3548", + "hardware/pcb/definition/parts/part.py": "01ff37aa45dd59d15efdcc34e2e96f123e2c37865a5690acbd148bcd2015890c", + "hardware/pcb/definition/parts/power_switch.py": "a78601a28dc1960e6aeac9aca96c9378ea266252694e336864d1368400d24849", + "hardware/pcb/definition/parts/raspberry_pi_header.py": "ef7a75ee0a25b3f15c63f890fd75b3b3d23e5a3116bf83988669e482e53c21e4", + "hardware/pcb/definition/parts/resistor.py": "b2f7c01e36931940ec83fa6e523e1870aba47ab4640a5bbf44b483f4f8e32116", + "hardware/pcb/definition/parts/sk9822.py": "29301e4ea9891cf92ff281d19bade60842b448a56842e225edc8fb8553a17eef", + "hardware/pcb/definition/parts/tca9554.py": "294464729e7019602c9fd943037cdde48525a5c47cdbd5829c41129d2a968394", + "hardware/pcb/definition/parts/test_point.py": "25e82c7a289b86cb5333a564d5e7d8c88bf14b4210f3fc093e43e9258ae99237", + "hardware/pcb/definition/parts/tvs_diode.py": "1ad29d1afd259969422ce5936ef4005e217e6d1577a1b4ce9e59efe550f4c028", "hardware/pcb/definition/project-template.json": "216c007b0031c84e93ff2a7ed4df8a14a503513cf3d7e9dc97fb4f337c5013d4", "hardware/pcb/definition/routing/__init__.py": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + "hardware/pcb/definition/routing/escape_policy.py": "66ce3022e3e93d3eb879031b3881a12585bad8550febc87c98be6ebe76920124", "hardware/pcb/definition/routing/paths.py": "5e02172bcefa266ada54e0440e141023f7b49b0041245e592330127c9d111204", - "hardware/pcb/definition/routing/policies.py": "3fed910ccc7d4d0d64bee4bd1663df0c39b9f050ef52557c014c8a2cd95d1311", + "hardware/pcb/definition/routing/policies.py": "03114cfd9d6d3ae86d188ce469b6cc4b552a758fd7890a59c25ca46ebcaffcfc", "hardware/pcb/definition/rules.py": "84f977c6f5f90e3aa73b0bb863abd4b419b594c02d989eb776db4efd93964e0b", - "hardware/pcb/justfile": "2f861b6975e3645e36213e700d4dbb08f0db50bac3088758303f12e3a99f1103", + "hardware/pcb/definition/validation.py": "1e553dd70d0567738857def7caf521d4b4759fdb2b19387abe99957e1afebb7e", + "hardware/pcb/justfile": "c48b67e775d2aec2cebe81e2b33efa92f39880b5141fafc081be5fffad8c9aed", "hardware/pcb/pr_report.py": "269c7c03a5ba110e75432de24a788f4eb16865066356bc3acd1aeee8c353fb6e", - "hardware/pcb/pyrightconfig.json": "4e167f344586fb5dad82ec73fde40b7e2985e3cc3fd83463475b01aa2f568d6d", + "hardware/pcb/pyrightconfig.json": "e3d9da67ec6e4af54b34aea7e998083784a4770c7415634143dc5a85791c509f", "hardware/pcb/tests/board/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "hardware/pcb/tests/board/test_dimensions.py": "c613be6b92917564479bbda512f05588d236be82ce4d6823991d17ed82f19dd1", - "hardware/pcb/tests/board/test_native.py": "da23945c825869e74a44bb89057bf342ba322abfca1405362004854edd4b56a3", + "hardware/pcb/tests/board/test_native.py": "b17666bf11c5b9eea442591a5cc039a5e101d0155fcbee9a500de0488e37c9d3", "hardware/pcb/tests/board/test_routing.py": "19e7b20680a3f6c0f331ad17ecc005ce1f9c8d3f6f39852189707bb1d3fbc55b", "hardware/pcb/tests/pipeline/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "hardware/pcb/tests/pipeline/test_bank_assemblies.py": "64fb907ef21e10420c897fd864eadf50a7f6ed1ccba3270b53c738f4aacb2bb3", "hardware/pcb/tests/pipeline/test_build.py": "3c9b052dd72a77c3f0aa28c2f786c3ed3babde99d1f664bdce8478cdafcf413f", + "hardware/pcb/tests/pipeline/test_led_link_names.py": "68cd693240012edbce1c7f3af128d13e138baa270478b4ac1da54f052e60b057", "hardware/pcb/tests/pipeline/test_pr_report.py": "85f379747db7c25feb9213a044c60406a052c1485d9d394e4974a7e2bf771bd8", "hardware/pcb/tests/spice/__init__.py": "20246f2c014fde00e7d265a8a49723723dd1921c502d45e427ea09e72dd8db3e", - "hardware/pcb/tests/spice/board_harness.py": "6c6227e592e95552529abc6339275e467b0d0bb44b3fbd976cb4db69cfc9594a", + "hardware/pcb/tests/spice/board_harness.py": "acc2b2c91d6f4ac9f45f8ea5278bf478b618396c543af7a5391a08be6ebc8a2d", "hardware/pcb/tests/spice/circuit.py": "34079ccc0adb8e4d2a247f79e300bf798878e707b12a4e579a048cac949d0293", "hardware/pcb/tests/spice/electrical.py": "a724df696f08b8318da285008861377d429eb21097b074d34e6ad2353f3fb14b", "hardware/pcb/tests/spice/movement.py": "2f86f633f39576bfc5cf25aacee781695285530ddcf76c7c04817cf492c7ba26", @@ -116,8 +135,37 @@ "hardware/pcb/tests/spice/test_signals.py": "6de69d9071ff3c4bd326d0b02e9593a2f1135252df86aae7b1cbb21a5f6bef21", "hardware/pcb/typings/pcbnew/__init__.pyi": "98ae266aedfa17b0d596e0a1e4063c7ce1dd6e5ccb03e9ec92adbbddca145844", "hardware/shared/__init__.py": "72e3037bdfd828f46bc13c865a7f58f940ee5c13385c50b22e98a7cb7a19e920", - "hardware/shared/components.py": "1d91c454a1cf0d8972c84c93800a7a50babf4db4e707d0c5d5ea7c6ca1e2c2ed", - "hardware/shared/dimensions.py": "fd48ecf3eef20a9467a8637b23422f87ab86e308d8b16e0a1b4e06895b90d78e", + "hardware/shared/components/__init__.py": "572655376dd4897b43b82976104a45562a0007a4e784c75f315dc34c4f7b93a2", + "hardware/shared/components/ahct125.py": "431f66974eb590d2a45badd124cf8dafafda0a10d6652afeb6e9427e472a9300", + "hardware/shared/components/barrel_jack.py": "0c1fef09490e7669e710297020dd4fa98e6dca660df71da342a9cf4e72d5dfe1", + "hardware/shared/components/button.py": "038228542ddde58b54e6788b9390b9bfa0620e5db9139e8c5fdd7524d0c50a95", + "hardware/shared/components/cap_1000u.py": "ee62506dc5c9ba40f29ee33f66cea066fb67799335f42db32dbe9fb140f6c0b6", + "hardware/shared/components/cap_100n.py": "d125c5d567fb77260f3f9b51cc36331697ad7197fd2e938aea6911bce0d49f23", + "hardware/shared/components/cap_10u.py": "364973bce7c3266ab984940d1d9e87ccec6811e356e2582418537d9aeb0b35a4", + "hardware/shared/components/fuse_2a.py": "c4b8e49210d0a7ada56150470e00b86b579100db343efa311fde9f9310e0a9e3", + "hardware/shared/components/hall_sensor.py": "db46f7c7979ef5f5dfe0f6db6f50cd3a5e2394727cc9f8950e142dfc3745a5fa", + "hardware/shared/components/micro_sd.py": "c8f18062c8d9b9e8db699a4ff856230e75570607b507cd0f4d9aa0b497e950eb", + "hardware/shared/components/oled_header.py": "93b45daf73466da3b92d22e26ec6c688a36bd1718c36c93686922417153bd1de", + "hardware/shared/components/oled_module.py": "84ab46e20594924fccdf0a32b2f9669a2c342c10be1b9d992e068c40a69391e1", + "hardware/shared/components/pi_zero_2_w.py": "af6efd8a97f27bd5130d2e4a9072f3e5458ec6774b40cd487a21add22f5bbeb3", + "hardware/shared/components/pi_zero_header.py": "dbaef5bb91fbc762f0ac6e07ea028a9a137baa308605cbf2a5dc3edabd6c2af4", + "hardware/shared/components/power_supply.py": "e2495ebba6636c5fe0ec8a60b774801093cd0529fa5d2f81080a3d52d3b4108c", + "hardware/shared/components/power_switch.py": "dc819a9825180e6d1123aad00f60c7bdacf82092e8d7efbd1fcec2a41d0a3e74", + "hardware/shared/components/res_4k7.py": "0c11ee42e2a198e94dc491d890a51cb982ae25c0568ceba4ccf21e357e99dfe0", + "hardware/shared/components/sk9822.py": "6ff0dc1692e283103d42618ceba7a54f828a5e5032c3f70f5b1b6ecf933bed60", + "hardware/shared/components/spec.py": "b9fc2515023d43a788f1e23fe240f93ca801ad110c551b3f3819391685f46d9f", + "hardware/shared/components/tca9554.py": "3fd5bf378ea839bf8c91b249e6c6eaabcdb2432a9aa7948bf8b2adbe648708c4", + "hardware/shared/components/test_point.py": "a9c64c01061d8566295df453464ddb6cbc968f07993c8ecf8118ef0779b40509", + "hardware/shared/components/tvs_6v8.py": "4d726264f06d7dc2cdd78e94765f4c37a62e17919fed129ef4e3bd6705813c8e", + "hardware/shared/dimensions/__init__.py": "2ad3a9dbdb4f14509a90fbe8f129cf49952cb43b7528171bb7739c3020e6b2df", + "hardware/shared/dimensions/__main__.py": "5bde786b9f453e377cc8dd4e7c131f0174a1559a80973507c0a2d47d190050b2", + "hardware/shared/dimensions/board.py": "aa1afe988df927cbf357a2421aec5b2ae88383cb348a393ab3d396bf139688e1", + "hardware/shared/dimensions/case.py": "db1a10dcf455fe4a915571b8f9c91338812585ed19faf58ef08a8bac734f094d", + "hardware/shared/dimensions/panel.py": "e79b45a41e3434e4d38da47e4e6e2422a90d9be26c802a7715d9b22bafdf4b97", + "hardware/shared/dimensions/printing.py": "d1db21a45d05c631829015d4ba3c3e6ad0d9430d0e1fc7a4edf62b5e8dc9b7a8", + "hardware/shared/dimensions/tile_plate.py": "22d448595546d71dbac908c0f667d231dd84d1575a726a403c6574d5b5e34980", + "hardware/shared/dimensions/units.py": "9fc45673eaa6a067fd782f503f53aef4a1276f9fc3b8d1e71837d247e835457f", + "hardware/shared/dimensions/validation.py": "fe85082022992772a58dd79f364a8f3be4eb11d77274162f739565413694a3a1", "hardware/shared/electronics/__init__.py": "27fb0032963648992f4d5b3e52f5f5e32cea269e55c538296b0b8ee7f3e9c557", "hardware/shared/electronics/ahct125.py": "b4dfc59817f8c741e910363838894b320a6ef31d1844b91f30e3369b694c9307", "hardware/shared/electronics/barrel_jack.py": "a1511ded2182b4a99726348610fe43eb0e052b411dad68ac103a1594da2b30c8", @@ -132,21 +180,20 @@ "hardware/shared/electronics/test_point.py": "943e4e14f32ffee4e7b1613ed4b4133895c6b646fedae701164156b4d15ffffe", "hardware/shared/hall_banks.py": "a7985857a0b967acd599c575c0284ba8389b8fa17aec5d9be91c1c103c6e2bb3", "hardware/shared/json_values.py": "c6c2395d54e02ea7320685f60b12f8508d40c8f1135b31a0625ba289d3efc9d3", - "hardware/shared/panel.py": "a8eed961354f4f8f8d381ccba7fb9f2158229d8d005f5058dfca894afdbf0a0f", - "hardware/shared/placements.py": "00d983c100ec124332000be92622cf3a8ceccf0e99cb87772d077d75ae086710", - "hardware/shared/pyrightconfig.json": "962964866dba2564e7ad13c2e1dd5a7ddbe69341a3b4410bf29ba48a579bffc7", + "hardware/shared/panel_buttons.py": "ef728cb3c0311220a8f73107fc12371aaccd8fc58d8b34a532e1ba90f1849518", + "hardware/shared/pyrightconfig.json": "4cf524213e8dc170933241b4b4af5d0f1bd8d21cb234d0319c254be6bf2017cf", "hardware/shared/squares.py": "21cc875d4c5d5de8245ea947e4c8dc5c0562568967fb45a2d191c4b5c0d8ad0e", "hardware/shared/tests/test_components.py": "67841f7f6c8e15a67b0c0035bc78e369f811d550c0369bd4d307268a0b0be8cb", "hardware/shared/tests/test_dimensions.py": "8e5b72cf1aed4128afa8fb6612b9c9089bc662e3e340da326e040ff7ac3578f1", "hardware/shared/tests/test_electronics.py": "1d1bd0eff98d139f48d75ff04c234836a6b7f2ad81c3c317a8ede00981522e67", - "hardware/shared/tests/test_panel.py": "dfebf4ece041f34b37355bc2340ac33a7106440f58fd4e421b49f8549c9359b2", + "hardware/shared/tests/test_panel_buttons.py": "9fcd52b7c044a80c552edb1a2b652a92f074b0905bee4388f34aeac018834d50", "hardware/shared/tests/test_squares.py": "0121b97c292572e5239388f20f14871990fba3a4052ccf2844d4a7acb3b3ff65", - "hardware/shared/tests/test_wiring.py": "182e28c4b8956e43e15be230df7fb5a4ff0934f6af67c509158f5176517e193c", - "hardware/shared/wiring.py": "a03674ba414f24e44bb2d4ac186cd41230cbd1c0c920e425f40b56dad733cad5", + "hardware/shared/tests/test_wiring.py": "6f5fb2a91dfeb358a99ad134615508bb445b4ad4ec3fe61ba3ddc3ff31d1cd45", + "hardware/shared/wiring.py": "deafed4de236d88f57bfabf62bbd86c929efda5c33ad414825ba227408822788", "pyproject.toml": "3713f488905ab198a13d43735312925277e41e011b63bb602281c70479f00f48" }, "toolchain": { - "kicad-cli": "9.0.9\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)\n17:32:52: Error: Directory '/home/vscode/.local/share/kicad' couldn't be created (error 13: Permission denied)", + "kicad-cli": "9.0.9", "ngspice": "******\n** ngspice-42 : Circuit level simulation program\n** Compiled with KLU Direct Linear Solver\n** The U. C. Berkeley CAD Group\n** Copyright 1985-1994, Regents of the University of California.\n** Copyright 2001-2023, The ngspice team.\n** Please get your ngspice manual from https://ngspice.sourceforge.io/docs.html\n** Please file your bug-reports at http://ngspice.sourceforge.net/bugrep.html\n** Creation Date: Sun Mar 31 20:15:14 UTC 2024\n******", "pcbnew": "9.0.9-9.0.9~ubuntu24.04.1", "python": "3.12.3" diff --git a/hardware/pcb/generated/schematic/chess-board-bank-A1-D2.svg b/hardware/pcb/generated/schematic/chess-board-bank-A1-D2.svg index 1ed40e19..0b101a35 100644 --- a/hardware/pcb/generated/schematic/chess-board-bank-A1-D2.svg +++ b/hardware/pcb/generated/schematic/chess-board-bank-A1-D2.svg @@ -7,7 +7,7 @@ xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="210.0072mm" height="297.0022mm" viewBox="0.0000 0.0000 210.0072 297.0022"> -SVG Image created as chess-board-bank-A1-D2.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-bank-A1-D2.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-A3-D4.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-bank-A3-D4.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-A5-D6.svg date 2026/09/18 17:33:46 +SVG Image created as chess-board-bank-A5-D6.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-A7-D8.svg date 2026/09/18 17:33:46 +SVG Image created as chess-board-bank-A7-D8.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-E1-H2.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-bank-E1-H2.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-E3-H4.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-bank-E3-H4.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-E5-H6.svg date 2026/09/18 17:33:46 +SVG Image created as chess-board-bank-E5-H6.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-bank-E7-H8.svg date 2026/09/18 17:33:46 +SVG Image created as chess-board-bank-E7-H8.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-controls.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-controls.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board-power.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board-power.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG -SVG Image created as chess-board.svg date 2026/09/18 17:33:45 +SVG Image created as chess-board.svg date 2026/09/30 10:51:03 Image generated by Eeschema-SVG None: + self.assertEqual( + led_link_names.for_squares( + SquarePosition.parse("A1"), SquarePosition.parse("B1") + ), + ("N$207", "N$208"), + ) + + def test_new_link_has_readable_names(self) -> None: + self.assertEqual( + led_link_names.for_squares( + SquarePosition.parse("A1"), SquarePosition.parse("H8") + ), + ("LED_DATA_A1_TO_H8", "LED_CLOCK_A1_TO_H8"), + ) diff --git a/hardware/pcb/tests/spice/board_harness.py b/hardware/pcb/tests/spice/board_harness.py index ee07bb85..e1c3259e 100644 --- a/hardware/pcb/tests/spice/board_harness.py +++ b/hardware/pcb/tests/spice/board_harness.py @@ -13,7 +13,9 @@ import pcbnew from pcb.definition.native import connections, parts -from pcb.definition.parts.catalog import DC_INPUT_JACK, INPUT_FUSE, MAIN_POWER_SWITCH +from pcb.definition.parts.barrel_jack import DC_INPUT_JACK +from pcb.definition.parts.fuse import INPUT_FUSE +from pcb.definition.parts.power_switch import MAIN_POWER_SWITCH from shared import wiring from shared.electronics import ( Ahct125Pin, @@ -28,7 +30,7 @@ Tca9554Pin, TestPointPin, ) -from shared.panel import PANEL_BUTTONS +from shared.panel_buttons import PANEL_BUTTONS from spice.circuit import SpiceCircuit from spice.electrical import ( AHCT125, diff --git a/hardware/shared/README.md b/hardware/shared/README.md index d0887908..dfa675af 100644 --- a/hardware/shared/README.md +++ b/hardware/shared/README.md @@ -2,13 +2,15 @@ This directory is the tool-independent contract between hardware domains. -- `dimensions.py` owns physical dimensions and coordinates genuinely shared by - PCB and CAD: enclosure padding and clearances, populated-board placement, - connector access references, and square-subassembly offsets. PCB-only routing - rules and CAD-only presentation settings stay in their respective domains. -- `components.py` is the approved-parts catalog: manufacturer, exact MPN, - package, body metadata, and datasheet links. +- `dimensions/` separates shared measurements by physical object: `board.py`, + `case.py`, `panel.py`, and `tile_plate.py`. `printing.py` owns process limits; + `validation.py` checks fit across those objects. PCB routing rules and CAD + presentation settings remain in their own domains. +- `components/.py` owns each approved product: manufacturer, exact MPN, + package, body metadata, and datasheet link. The package registry enumerates them. - `electronics/` owns tool-independent component families and typed pin semantics. +- `panel_buttons.py` owns each control button's position, typed header pin, + switch reference, and routing priority. - `hall_banks.py` owns compact bank membership, P-port order, labels, and address straps; dimensions and wiring consume it. - `wiring.py` owns net names, host GPIO assignments, square-to-sensor mapping, diff --git a/hardware/shared/components.py b/hardware/shared/components.py deleted file mode 100644 index 7b5ce6c4..00000000 --- a/hardware/shared/components.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Approved physical parts shared by KiCad, CAD, assembly, and purchasing. - -Every production component has one stable key and an explicit manufacturer part -number. Domain implementations describe how that real part is represented; they -do not invent package names or substitute anonymous parts. -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ComponentSpec: - """Approved-part identity and nominal manufacturer L/W/H envelope.""" - - key: str - description: str - package: str - manufacturer: str - mpn: str - body_mm: tuple[float, float, float] | None = None - datasheet: str = "" - - def __post_init__(self) -> None: - """Keep invalid purchasing and envelope data out of every domain.""" - for field_name in ("key", "description", "package", "manufacturer", "mpn"): - if not getattr(self, field_name): - raise ValueError(f"component {field_name} must not be empty") - if self.body_mm is not None and any(axis <= 0.0 for axis in self.body_mm): - raise ValueError(f"{self.key}: body dimensions must be positive") - - def require_body_mm(self) -> tuple[float, float, float]: - """Return the body envelope, failing clearly when geometry is unspecified.""" - if self.body_mm is None: - raise ValueError(f"{self.key} has no body dimensions") - return self.body_mm - - -def part( - key: str, - description: str, - package: str, - manufacturer: str, - mpn: str, - body_mm: tuple[float, float, float] | None = None, - datasheet: str = "", -) -> ComponentSpec: - """Define one exact, purchasable component.""" - return ComponentSpec( - key, - description, - package, - manufacturer, - mpn, - body_mm, - datasheet, - ) - - -SK9822 = part( - "SK9822", - "Clocked 5050 RGB LED", - "PLCC-6 5050", - "Opsco Optoelectronics", - "SK9822-EC20", - (5.4, 5.0, 1.57), -) -HALL_SENSOR = part( - "HALL_SENSOR", - "20 Hz omnipolar active-low Hall-effect sensor", - "SOT-23-3", - "Texas Instruments", - "DRV5032FCDBZR", - (2.92, 1.30, 1.12), - "https://www.ti.com/lit/ds/symlink/drv5032.pdf", -) -TCA9554 = part( - "TCA9554", - "8-bit I2C GPIO expander with input pull-ups", - "SOIC-16W 1.27 mm", - "Texas Instruments", - "TCA9554DWR", - (10.3, 7.5, 2.65), - "https://www.ti.com/lit/ds/symlink/tca9554.pdf", -) -AHCT125 = part( - "AHCT125", - "Quad 3.3 V to 5 V logic buffer", - "SOIC-14 1.27 mm", - "Texas Instruments", - "SN74AHCT125DR", - (8.7, 6.2, 1.75), - "https://www.ti.com/lit/ds/symlink/sn74ahct125.pdf", -) -CAP_100N = part( - "CAP_100N", - "100 nF 50 V X7R MLCC", - "0603 (1608 metric)", - "Yageo", - "CC0603KRX7R9BB104", - (1.6, 0.8, 0.8), -) -CAP_10U = part( - "CAP_10U", - "10 uF 10 V X5R MLCC", - "0805 (2012 metric)", - "Yageo", - "CC0805KKX5R6BB106", - (2.0, 1.25, 1.25), -) -CAP_1000U = part( - "CAP_1000U", - "1000 uF 10 V low-ESR electrolytic", - "radial 10 mm", - "Rubycon", - "10ZLJ1000M10X16", - (10.0, 10.0, 16.0), -) -RES_4K7 = part( - "RES_4K7", - "4.7 kohm 0.1 W thick-film resistor", - "0603 (1608 metric)", - "Yageo", - "RC0603FR-074K7L", - (1.6, 0.8, 0.55), -) -BUTTON = part( - "BUTTON", - "6 mm tactile switch, 9.5 mm actuator", - "6x6 mm THT", - "E-Switch", - "TL1105SPF100QG", - (6.0, 6.0, 9.5), -) -PI_ZERO_HEADER = part( - "PI_ZERO_HEADER", - "Raspberry Pi Zero 2 W 2x20 socket", - "2x20 2.54 mm THT", - "Sullins Connector Solutions", - "PPPC202LFBN-RC", -) -OLED_HEADER = part( - "OLED_HEADER", - "1x4 OLED socket", - "1x4 2.54 mm THT", - "Sullins Connector Solutions", - "PPPC041LFBN-RC", -) -FUSE_2A = part( - "FUSE_2A", - "2 A time-delay surface-mount fuse", - "2410 fuse", - "Littelfuse", - "0453002.MR", - (6.1, 2.7, 2.7), - "https://www.littelfuse.com/assetdocs/littelfuse-fuse-453-datasheet", -) -BARREL_JACK = part( - "BARREL_JACK", - "5.5x2.0 mm centre-positive DC jack, 2.5 A rated", - "5.5x2.0 mm THT", - "Same Sky", - "PJ-102A", - (14.4, 11.0, 11.0), - "https://www.sameskydevices.com/product/resource/pj-102a.pdf", -) -TVS_6V8 = part( - "TVS_6V8", - "6 V unidirectional TVS diode", - "SMB (DO-214AA)", - "Littelfuse", - "SMBJ6.0A", - (4.6, 3.6, 2.3), -) -POWER_SWITCH = part( - "POWER_SWITCH", - "PCB SPST rocker switch", - "SPST rocker THT", - "E-Switch", - "RA11131100", -) -TEST_POINT = part( - "TEST_POINT", - "Low-profile surface-mount test point", - "SMD test point", - "Harwin", - "S1751-46R", - (2.0, 1.2, 1.0), -) -PI_ZERO_2_W = part( - "PI_ZERO_2_W", - "Raspberry Pi Zero 2 W host", - "65x30 mm module", - "Raspberry Pi", - "SC0510", - (65.0, 30.0, 5.2), -) -# AZ-Delivery's stable SKU for the single-module product-page variant is A 1-9. -OLED_MODULE = part( - "OLED_MODULE", - "0.96 inch 128x64 SSD1306 four-pin I2C OLED module", - "27x27 mm module", - "AZ-Delivery", - "A 1-9", - (27.0, 27.0, 4.1), - "https://www.az-delivery.de/products/0-96zolldisplay", -) -POWER_SUPPLY = part( - "POWER_SUPPLY", - "5 V 2 A regulated desktop supply with 5.5x2.1 mm plug", - "external PSU", - "MEAN WELL", - "GST12A05-P1J", - datasheet="https://www.meanwell.com/Upload/PDF/GST12A/GST12A-SPEC.PDF", -) -MICRO_SD = part( - "MICRO_SD", - "32 GB high-endurance microSD card", - "microSD", - "SanDisk", - "SDSQQNR-032G-GN6IA", -) - -APPROVED_COMPONENTS = ( - SK9822, - HALL_SENSOR, - TCA9554, - AHCT125, - CAP_100N, - CAP_10U, - CAP_1000U, - RES_4K7, - BUTTON, - PI_ZERO_HEADER, - OLED_HEADER, - FUSE_2A, - BARREL_JACK, - TVS_6V8, - POWER_SWITCH, - TEST_POINT, - PI_ZERO_2_W, - OLED_MODULE, - POWER_SUPPLY, - MICRO_SD, -) - -_component_keys = [spec.key for spec in APPROVED_COMPONENTS] -if len(_component_keys) != len(set(_component_keys)): - raise ValueError("Approved component keys must be unique") - -COMPONENTS = {spec.key: spec for spec in APPROVED_COMPONENTS} diff --git a/hardware/shared/components/__init__.py b/hardware/shared/components/__init__.py new file mode 100644 index 00000000..2c61964c --- /dev/null +++ b/hardware/shared/components/__init__.py @@ -0,0 +1,79 @@ +"""Approved physical products shared by PCB, CAD, and purchasing.""" + +from .ahct125 import AHCT125 +from .barrel_jack import BARREL_JACK +from .button import BUTTON +from .cap_10u import CAP_10U +from .cap_100n import CAP_100N +from .cap_1000u import CAP_1000U +from .fuse_2a import FUSE_2A +from .hall_sensor import HALL_SENSOR +from .micro_sd import MICRO_SD +from .oled_header import OLED_HEADER +from .oled_module import OLED_MODULE +from .pi_zero_2_w import PI_ZERO_2_W +from .pi_zero_header import PI_ZERO_HEADER +from .power_supply import POWER_SUPPLY +from .power_switch import POWER_SWITCH +from .res_4k7 import RES_4K7 +from .sk9822 import SK9822 +from .spec import ComponentSpec, part +from .tca9554 import TCA9554 +from .test_point import TEST_POINT +from .tvs_6v8 import TVS_6V8 + +APPROVED_COMPONENTS = ( + SK9822, + HALL_SENSOR, + TCA9554, + AHCT125, + CAP_100N, + CAP_10U, + CAP_1000U, + RES_4K7, + BUTTON, + PI_ZERO_HEADER, + OLED_HEADER, + FUSE_2A, + BARREL_JACK, + TVS_6V8, + POWER_SWITCH, + TEST_POINT, + PI_ZERO_2_W, + OLED_MODULE, + POWER_SUPPLY, + MICRO_SD, +) + +_component_keys = [spec.key for spec in APPROVED_COMPONENTS] +if len(_component_keys) != len(set(_component_keys)): + raise ValueError("Approved component keys must be unique") + +COMPONENTS = {spec.key: spec for spec in APPROVED_COMPONENTS} + +__all__ = ( + "AHCT125", + "APPROVED_COMPONENTS", + "BARREL_JACK", + "BUTTON", + "CAP_10U", + "CAP_100N", + "CAP_1000U", + "COMPONENTS", + "FUSE_2A", + "HALL_SENSOR", + "MICRO_SD", + "OLED_HEADER", + "OLED_MODULE", + "PI_ZERO_2_W", + "PI_ZERO_HEADER", + "POWER_SUPPLY", + "POWER_SWITCH", + "RES_4K7", + "SK9822", + "TCA9554", + "TEST_POINT", + "TVS_6V8", + "ComponentSpec", + "part", +) diff --git a/hardware/shared/components/ahct125.py b/hardware/shared/components/ahct125.py new file mode 100644 index 00000000..a7fbd35a --- /dev/null +++ b/hardware/shared/components/ahct125.py @@ -0,0 +1,13 @@ +"""Approved ahct125 product.""" + +from .spec import part + +AHCT125 = part( + "AHCT125", + "Quad 3.3 V to 5 V logic buffer", + "SOIC-14 1.27 mm", + "Texas Instruments", + "SN74AHCT125DR", + (8.7, 6.2, 1.75), + "https://www.ti.com/lit/ds/symlink/sn74ahct125.pdf", +) diff --git a/hardware/shared/components/barrel_jack.py b/hardware/shared/components/barrel_jack.py new file mode 100644 index 00000000..bc8b30ed --- /dev/null +++ b/hardware/shared/components/barrel_jack.py @@ -0,0 +1,13 @@ +"""Approved barrel jack product.""" + +from .spec import part + +BARREL_JACK = part( + "BARREL_JACK", + "5.5x2.0 mm centre-positive DC jack, 2.5 A rated", + "5.5x2.0 mm THT", + "Same Sky", + "PJ-102A", + (14.4, 11.0, 11.0), + "https://www.sameskydevices.com/product/resource/pj-102a.pdf", +) diff --git a/hardware/shared/components/button.py b/hardware/shared/components/button.py new file mode 100644 index 00000000..e5d15ef9 --- /dev/null +++ b/hardware/shared/components/button.py @@ -0,0 +1,12 @@ +"""Approved button product.""" + +from .spec import part + +BUTTON = part( + "BUTTON", + "6 mm tactile switch, 9.5 mm actuator", + "6x6 mm THT", + "E-Switch", + "TL1105SPF100QG", + (6.0, 6.0, 9.5), +) diff --git a/hardware/shared/components/cap_1000u.py b/hardware/shared/components/cap_1000u.py new file mode 100644 index 00000000..d5d748bf --- /dev/null +++ b/hardware/shared/components/cap_1000u.py @@ -0,0 +1,12 @@ +"""Approved cap 1000u product.""" + +from .spec import part + +CAP_1000U = part( + "CAP_1000U", + "1000 uF 10 V low-ESR electrolytic", + "radial 10 mm", + "Rubycon", + "10ZLJ1000M10X16", + (10.0, 10.0, 16.0), +) diff --git a/hardware/shared/components/cap_100n.py b/hardware/shared/components/cap_100n.py new file mode 100644 index 00000000..a66e7519 --- /dev/null +++ b/hardware/shared/components/cap_100n.py @@ -0,0 +1,12 @@ +"""Approved cap 100n product.""" + +from .spec import part + +CAP_100N = part( + "CAP_100N", + "100 nF 50 V X7R MLCC", + "0603 (1608 metric)", + "Yageo", + "CC0603KRX7R9BB104", + (1.6, 0.8, 0.8), +) diff --git a/hardware/shared/components/cap_10u.py b/hardware/shared/components/cap_10u.py new file mode 100644 index 00000000..9b0c2ac1 --- /dev/null +++ b/hardware/shared/components/cap_10u.py @@ -0,0 +1,12 @@ +"""Approved cap 10u product.""" + +from .spec import part + +CAP_10U = part( + "CAP_10U", + "10 uF 10 V X5R MLCC", + "0805 (2012 metric)", + "Yageo", + "CC0805KKX5R6BB106", + (2.0, 1.25, 1.25), +) diff --git a/hardware/shared/components/fuse_2a.py b/hardware/shared/components/fuse_2a.py new file mode 100644 index 00000000..0ea0f6c4 --- /dev/null +++ b/hardware/shared/components/fuse_2a.py @@ -0,0 +1,13 @@ +"""Approved fuse 2a product.""" + +from .spec import part + +FUSE_2A = part( + "FUSE_2A", + "2 A time-delay surface-mount fuse", + "2410 fuse", + "Littelfuse", + "0453002.MR", + (6.1, 2.7, 2.7), + "https://www.littelfuse.com/assetdocs/littelfuse-fuse-453-datasheet", +) diff --git a/hardware/shared/components/hall_sensor.py b/hardware/shared/components/hall_sensor.py new file mode 100644 index 00000000..ebb64bed --- /dev/null +++ b/hardware/shared/components/hall_sensor.py @@ -0,0 +1,13 @@ +"""Approved hall sensor product.""" + +from .spec import part + +HALL_SENSOR = part( + "HALL_SENSOR", + "20 Hz omnipolar active-low Hall-effect sensor", + "SOT-23-3", + "Texas Instruments", + "DRV5032FCDBZR", + (2.92, 1.30, 1.12), + "https://www.ti.com/lit/ds/symlink/drv5032.pdf", +) diff --git a/hardware/shared/components/micro_sd.py b/hardware/shared/components/micro_sd.py new file mode 100644 index 00000000..4076252e --- /dev/null +++ b/hardware/shared/components/micro_sd.py @@ -0,0 +1,11 @@ +"""Approved micro sd product.""" + +from .spec import part + +MICRO_SD = part( + "MICRO_SD", + "32 GB high-endurance microSD card", + "microSD", + "SanDisk", + "SDSQQNR-032G-GN6IA", +) diff --git a/hardware/shared/components/oled_header.py b/hardware/shared/components/oled_header.py new file mode 100644 index 00000000..e7ebc6e5 --- /dev/null +++ b/hardware/shared/components/oled_header.py @@ -0,0 +1,11 @@ +"""Approved oled header product.""" + +from .spec import part + +OLED_HEADER = part( + "OLED_HEADER", + "1x4 OLED socket", + "1x4 2.54 mm THT", + "Sullins Connector Solutions", + "PPPC041LFBN-RC", +) diff --git a/hardware/shared/components/oled_module.py b/hardware/shared/components/oled_module.py new file mode 100644 index 00000000..1b8d6cc6 --- /dev/null +++ b/hardware/shared/components/oled_module.py @@ -0,0 +1,14 @@ +"""Approved oled module product.""" + +from .spec import part + +# AZ-Delivery product-page SKU for the single-module variant. +OLED_MODULE = part( + "OLED_MODULE", + "0.96 inch 128x64 SSD1306 four-pin I2C OLED module", + "27x27 mm module", + "AZ-Delivery", + "A 1-9", + (27.0, 27.0, 4.1), + "https://www.az-delivery.de/products/0-96zolldisplay", +) diff --git a/hardware/shared/components/pi_zero_2_w.py b/hardware/shared/components/pi_zero_2_w.py new file mode 100644 index 00000000..67a01358 --- /dev/null +++ b/hardware/shared/components/pi_zero_2_w.py @@ -0,0 +1,12 @@ +"""Approved pi zero 2 w product.""" + +from .spec import part + +PI_ZERO_2_W = part( + "PI_ZERO_2_W", + "Raspberry Pi Zero 2 W host", + "65x30 mm module", + "Raspberry Pi", + "SC0510", + (65.0, 30.0, 5.2), +) diff --git a/hardware/shared/components/pi_zero_header.py b/hardware/shared/components/pi_zero_header.py new file mode 100644 index 00000000..8cc97c65 --- /dev/null +++ b/hardware/shared/components/pi_zero_header.py @@ -0,0 +1,11 @@ +"""Approved pi zero header product.""" + +from .spec import part + +PI_ZERO_HEADER = part( + "PI_ZERO_HEADER", + "Raspberry Pi Zero 2 W 2x20 socket", + "2x20 2.54 mm THT", + "Sullins Connector Solutions", + "PPPC202LFBN-RC", +) diff --git a/hardware/shared/components/power_supply.py b/hardware/shared/components/power_supply.py new file mode 100644 index 00000000..626a6654 --- /dev/null +++ b/hardware/shared/components/power_supply.py @@ -0,0 +1,12 @@ +"""Approved power supply product.""" + +from .spec import part + +POWER_SUPPLY = part( + "POWER_SUPPLY", + "5 V 2 A regulated desktop supply with 5.5x2.1 mm plug", + "external PSU", + "MEAN WELL", + "GST12A05-P1J", + datasheet="https://www.meanwell.com/Upload/PDF/GST12A/GST12A-SPEC.PDF", +) diff --git a/hardware/shared/components/power_switch.py b/hardware/shared/components/power_switch.py new file mode 100644 index 00000000..1b4f347f --- /dev/null +++ b/hardware/shared/components/power_switch.py @@ -0,0 +1,11 @@ +"""Approved power switch product.""" + +from .spec import part + +POWER_SWITCH = part( + "POWER_SWITCH", + "PCB SPST rocker switch", + "SPST rocker THT", + "E-Switch", + "RA11131100", +) diff --git a/hardware/shared/components/res_4k7.py b/hardware/shared/components/res_4k7.py new file mode 100644 index 00000000..20553f62 --- /dev/null +++ b/hardware/shared/components/res_4k7.py @@ -0,0 +1,12 @@ +"""Approved res 4k7 product.""" + +from .spec import part + +RES_4K7 = part( + "RES_4K7", + "4.7 kohm 0.1 W thick-film resistor", + "0603 (1608 metric)", + "Yageo", + "RC0603FR-074K7L", + (1.6, 0.8, 0.55), +) diff --git a/hardware/shared/components/sk9822.py b/hardware/shared/components/sk9822.py new file mode 100644 index 00000000..5fcba5cd --- /dev/null +++ b/hardware/shared/components/sk9822.py @@ -0,0 +1,12 @@ +"""Approved sk9822 product.""" + +from .spec import part + +SK9822 = part( + "SK9822", + "Clocked 5050 RGB LED", + "PLCC-6 5050", + "Opsco Optoelectronics", + "SK9822-EC20", + (5.4, 5.0, 1.57), +) diff --git a/hardware/shared/components/spec.py b/hardware/shared/components/spec.py new file mode 100644 index 00000000..1442935c --- /dev/null +++ b/hardware/shared/components/spec.py @@ -0,0 +1,51 @@ +"""Validated identity and body envelope for one approved product.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ComponentSpec: + """Approved-part identity and nominal manufacturer L/W/H envelope.""" + + key: str + description: str + package: str + manufacturer: str + mpn: str + body_mm: tuple[float, float, float] | None = None + datasheet: str = "" + + def __post_init__(self) -> None: + """Keep invalid purchasing and envelope data out of every domain.""" + for field_name in ("key", "description", "package", "manufacturer", "mpn"): + if not getattr(self, field_name): + raise ValueError(f"component {field_name} must not be empty") + if self.body_mm is not None and any(axis <= 0.0 for axis in self.body_mm): + raise ValueError(f"{self.key}: body dimensions must be positive") + + def require_body_mm(self) -> tuple[float, float, float]: + """Return the body envelope, failing clearly when geometry is unspecified.""" + if self.body_mm is None: + raise ValueError(f"{self.key} has no body dimensions") + return self.body_mm + + +def part( + key: str, + description: str, + package: str, + manufacturer: str, + mpn: str, + body_mm: tuple[float, float, float] | None = None, + datasheet: str = "", +) -> ComponentSpec: + """Define one exact, purchasable component.""" + return ComponentSpec( + key, + description, + package, + manufacturer, + mpn, + body_mm, + datasheet, + ) diff --git a/hardware/shared/components/tca9554.py b/hardware/shared/components/tca9554.py new file mode 100644 index 00000000..b3e181ce --- /dev/null +++ b/hardware/shared/components/tca9554.py @@ -0,0 +1,13 @@ +"""Approved tca9554 product.""" + +from .spec import part + +TCA9554 = part( + "TCA9554", + "8-bit I2C GPIO expander with input pull-ups", + "SOIC-16W 1.27 mm", + "Texas Instruments", + "TCA9554DWR", + (10.3, 7.5, 2.65), + "https://www.ti.com/lit/ds/symlink/tca9554.pdf", +) diff --git a/hardware/shared/components/test_point.py b/hardware/shared/components/test_point.py new file mode 100644 index 00000000..62f779fb --- /dev/null +++ b/hardware/shared/components/test_point.py @@ -0,0 +1,12 @@ +"""Approved test point product.""" + +from .spec import part + +TEST_POINT = part( + "TEST_POINT", + "Low-profile surface-mount test point", + "SMD test point", + "Harwin", + "S1751-46R", + (2.0, 1.2, 1.0), +) diff --git a/hardware/shared/components/tvs_6v8.py b/hardware/shared/components/tvs_6v8.py new file mode 100644 index 00000000..6e3e35fc --- /dev/null +++ b/hardware/shared/components/tvs_6v8.py @@ -0,0 +1,12 @@ +"""Approved tvs 6v8 product.""" + +from .spec import part + +TVS_6V8 = part( + "TVS_6V8", + "6 V unidirectional TVS diode", + "SMB (DO-214AA)", + "Littelfuse", + "SMBJ6.0A", + (4.6, 3.6, 2.3), +) diff --git a/hardware/shared/dimensions.py b/hardware/shared/dimensions.py deleted file mode 100644 index 4a444977..00000000 --- a/hardware/shared/dimensions.py +++ /dev/null @@ -1,543 +0,0 @@ -"""Authoritative millimetre dimensions and physical guardrails for Chess CAD. - -Revision B is two printed parts: a case that holds one PCB and the Raspberry Pi, -and a single tile plate that lays the checkerboard over the whole playing area. -The 64 individually printed two-part tiles of revision A are gone, and with them -every tile enclosure, wire port and Velcro pocket dimension. - -Coordinates are centred on the playing area, not on the case. The control strip -extends in negative Y, so the case is offset by `CASE_CENTER_OFFSET_Y_MM` while -square centres, LED positions and Hall-sensor positions stay symmetric about -that origin. -""" - -from math import ceil, isclose -from types import MappingProxyType - -from . import placements as _placements -from .components import BUTTON, HALL_SENSOR, OLED_MODULE, SK9822, TCA9554 -from .hall_banks import banks -from .panel import PANEL_BUTTONS -from .squares import SquareLayout - -# Unit contract. One Blender unit represents one millimetre in generated files. -MILLIMETRES_PER_METRE = 1_000.0 -MILLIMETRES_PER_INCH = 25.4 -BLENDER_SCALE_LENGTH = 1.0 / MILLIMETRES_PER_METRE - -# Project form factor. This is a compact electronic board, not a FIDE-sized board. -BOARD_FORMAT = "compact electronic" -COMPACT_SQUARE_MIN_MM = 35.0 -COMPACT_SQUARE_MAX_MM = 45.0 -COMPACT_BOARD_MIN_SPAN_MM = 300.0 -COMPACT_BOARD_MAX_SPAN_MM = 400.0 -COMPACT_BOARD_MAX_HEIGHT_MM = 35.0 -FIDE_REFERENCE_SQUARE_MIN_MM = 50.0 -FIDE_REFERENCE_SQUARE_MAX_MM = 60.0 - -# Prototype FDM guardrails. These catch implausible geometry, but do not replace -# printer-, material-, and orientation-specific slicer validation. -# -# Both parts are larger than a desktop printer bed, so they are quoted from an -# FDM print service. The desktop figure is kept to document that fact rather than -# to gate anything: a 380 mm case does not fit a 256 mm bed and never will. -REFERENCE_DESKTOP_BUILD_VOLUME_MM = (256.0, 256.0, 256.0) -REFERENCE_SERVICE_BUILD_VOLUME_MM = (420.0, 420.0, 420.0) -PRINT_BED_EDGE_MARGIN_MM = 5.0 -FDM_REFERENCE_NOZZLE_MM = 0.4 -FDM_MIN_FEATURE_MM = 2.0 * FDM_REFERENCE_NOZZLE_MM -FDM_MIN_FLOOR_MM = 1.0 -FDM_MIN_FIT_CLEARANCE_MM = 0.2 -FDM_MAX_FIT_CLEARANCE_MM = 0.5 - -# Boolean robustness. Cutters overhang the surface they break so coplanar faces -# never meet, which is what keeps the exact solver from leaving holes. -BOOLEAN_RECESS_OVERLAP_MM = 0.2 -BOOLEAN_THROUGH_OVERLAP_MM = 0.4 - -# Board grid. Unchanged from revision A: 40 mm squares suit a chess set with a -# king base of 32 mm or less, which covers most ordinary club sets. -GRID_COUNT = 8 -SQUARE_SIZE_MM = 40.0 -PLAYING_SPAN_MM = SQUARE_SIZE_MM * GRID_COUNT - -# SK9822 5050 addressable RGB LED. Mechanically interchangeable with the -# WS2812B this design replaced, so the pocket dimensions are unchanged; the -# difference is electrical, a separate clock line the host can drive from SPI. -# https://www.ledyilighting.com/wp-content/uploads/2025/02/WS2812B-datasheet.pdf -LED_PACKAGE_REFERENCE = SK9822.description -LED_PACKAGE_NOMINAL_SIZE_MM = SK9822.require_body_mm() -LED_PACKAGE_TOLERANCE_MM = 0.05 -LED_PACKAGE_MAX_SIZE_MM = tuple( - axis + LED_PACKAGE_TOLERANCE_MM for axis in LED_PACKAGE_NOMINAL_SIZE_MM -) -LED_PACKAGE_CLEARANCE_PER_SIDE_MM = 0.2 -LED_EMITTER_WINDOW_MM = (4.0, 4.0) -LED_POSITION_MM = (13.0, 13.0) - -# SOT-23 omnipolar Hall sensor at each square centre. Either magnet pole drives -# its active-low output, preserving the old occupied-square electrical polarity. -HALL_SENSOR_PACKAGE_MM = HALL_SENSOR.require_body_mm() -HALL_SENSOR_BODY_MM = HALL_SENSOR_PACKAGE_MM[:2] -HALL_SENSOR_HEIGHT_MM = HALL_SENSOR_PACKAGE_MM[2] -HALL_SENSOR_POSITION_MM = (0.0, 0.0) -HALL_SENSOR_STANDOFF_MM = 0.0 -# Expander package orientation and centres are shared because the CAD proxy must -# depict the same physical obstructions that PCB placement and routing use. -_EXPANDER_PACKAGE_MM = TCA9554.require_body_mm() -EXPANDER_BODY_MM = ( - _EXPANDER_PACKAGE_MM[1], - _EXPANDER_PACKAGE_MM[0], - _EXPANDER_PACKAGE_MM[2], -) -HALL_BANKS = banks(GRID_COUNT) -# Lift the package above the nearest LED row, including its body and a 1 mm gap. -# This keeps the unchanged horizontal LED links out of the SOIC fanout. -EXPANDER_OFFSET_MM = ( - 0.0, - float( - ceil( - LED_POSITION_MM[1] - - SQUARE_SIZE_MM / 2 - + LED_PACKAGE_MAX_SIZE_MM[1] / 2 - + EXPANDER_BODY_MM[1] / 2 - + 1.0 - ) - ), -) -EXPANDER_POSITIONS_BY_BANK_MM = MappingProxyType( - { - bank.label: tuple( - a + b - for a, b in zip( - bank.centre(SQUARE_SIZE_MM, PLAYING_SPAN_MM), - EXPANDER_OFFSET_MM, - strict=True, - ) - ) - for bank in HALL_BANKS - } -) - -# --- Printed circuit board -------------------------------------------------- -# One board spans the playing area plus a 40 mm control strip along the front, -# so the buttons and display face up and solder flat like everything else. -PCB_THICKNESS_MM = 1.6 -PANEL_STRIP_DEPTH_MM = 40.0 -PCB_SIZE_MM = ( - PLAYING_SPAN_MM, - PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM, - PCB_THICKNESS_MM, -) -PCB_CENTER_OFFSET_Y_MM = -PANEL_STRIP_DEPTH_MM / 2.0 - -# --- Case ------------------------------------------------------------------- -CASE_FRAME_WIDTH_MM = 10.0 -CASE_WALL_MM = 3.0 -CASE_FLOOR_MM = 3.0 -# The plate rests on a ledge around the cavity, and its screws land in that -# ledge. They cannot land anywhere inboard of it, because the PCB is there. -CASE_PLATE_LEDGE_MM = 8.0 -CASE_HEIGHT_MM = 30.0 -CASE_OUTER_RADIUS_MM = 2.2 -CASE_WIDTH_MM = PLAYING_SPAN_MM + 2.0 * CASE_FRAME_WIDTH_MM -CASE_DEPTH_MM = PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM + 2.0 * CASE_FRAME_WIDTH_MM -CASE_CENTER_OFFSET_Y_MM = PCB_CENTER_OFFSET_Y_MM -CASE_OUTER_SIZE_MM = (CASE_WIDTH_MM, CASE_DEPTH_MM, CASE_HEIGHT_MM) - -# Vertical stack, measured from the outside of the case floor. -TILE_PLATE_THICKNESS_MM = 3.0 -PCB_TO_PLATE_GAP_MM = 4.0 -PCB_TOP_Z_MM = CASE_HEIGHT_MM - TILE_PLATE_THICKNESS_MM - PCB_TO_PLATE_GAP_MM -PCB_UNDERSIDE_Z_MM = PCB_TOP_Z_MM - PCB_THICKNESS_MM -PI_BAY_HEIGHT_MM = PCB_UNDERSIDE_Z_MM - CASE_FLOOR_MM - -# A 320 mm board flexes badly on perimeter support alone, so bosses stand on the -# grid lines, where no LED or Hall sensor sits. Seven millimetres clears both. -PCB_SUPPORT_BOSS_DIAMETER_MM = 7.0 -PCB_SUPPORT_PILOT_DIAMETER_MM = 2.5 -PCB_MOUNTING_HOLE_DIAMETER_MM = 3.4 -PCB_SUPPORT_PILOT_DEPTH_MM = 6.0 -PCB_SUPPORT_GRID_OFFSETS_MM = (-120.0, -40.0, 40.0, 120.0) -PCB_SUPPORT_POSITIONS_MM = tuple( - (x, y) for y in PCB_SUPPORT_GRID_OFFSETS_MM for x in PCB_SUPPORT_GRID_OFFSETS_MM -) + tuple( - (x, -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM / 2.0) - for x in PCB_SUPPORT_GRID_OFFSETS_MM -) - -# Raspberry Pi Zero 2 W hangs under the board on its header. -PI_BOARD_SIZE_MM = (65.0, 30.0, 1.4) -PI_HEADER_HEIGHT_MM = 8.5 -PI_HEADER_BODY_MM = (51.0, 5.0, PI_HEADER_HEIGHT_MM) -PI_HEADER_ROTATION_DEG = 90.0 -PI_CLEARANCE_MM = 2.0 -PI_BAY_CENTER_MM = (0.0, -PLAYING_SPAN_MM / 2.0 + 40.0) -CASE_SD_SLOT_MM = (14.0, 3.5) -CASE_VENT_SLOT_MM = (40.0, 3.0) - -# Rear wall apertures for the power input. -CASE_JACK_APERTURE_DIAMETER_MM = 8.0 -CASE_ROCKER_APERTURE_MM = (19.0, 13.0) -CASE_REAR_APERTURE_CENTER_Z_MM = CASE_FLOOR_MM + PI_BAY_HEIGHT_MM / 2.0 - -# --- Control panel ---------------------------------------------------------- -PANEL_ORIGIN_Y_MM = -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM / 2.0 -PANEL_BUTTON_COUNT = 12 -PANEL_BUTTON_HOLE_DIAMETER_MM = 7.0 - -# AZ-Delivery 0.96 in SSD1306 module. The window exposes its approximately -# 23.7 x 12.9 mm viewing area; the recess holds the 27 x 27 mm carrier board, -# connected to J2 by four short wires. -PANEL_OLED_MODULE_MM = OLED_MODULE.require_body_mm() -PANEL_OLED_WINDOW_MM = (23.7, 12.9) -# Per-side XY clearance for printed-part tolerance and hand assembly. -PANEL_OLED_RECESS_CLEARANCE_XY_MM = 0.5 -PANEL_OLED_RECESS_MM = tuple( - dimension + 2.0 * PANEL_OLED_RECESS_CLEARANCE_XY_MM - for dimension in PANEL_OLED_MODULE_MM[:2] -) -PANEL_OLED_RECESS_DEPTH_MM = 2.0 -PANEL_OLED_CENTER_MM = (-110.0, PANEL_ORIGIN_Y_MM) -PANEL_BUTTON_BODY_MM = (*BUTTON.require_body_mm()[:2], 5.0) -PANEL_BUTTON_ACTUATOR_DIAMETER_MM = 3.5 -PCB_STRIP_PLACEMENTS = _placements.PCB_STRIP_PLACEMENTS - -# --- Tile plate ------------------------------------------------------------- -# One flat overlay with the checkerboard engraved into it, sitting in a rebate -# over the playing area. The control strip is not covered; it shows through the -# case bezel. -TILE_PLATE_CLEARANCE_MM = 0.4 -TILE_PLATE_SPAN_MM = PLAYING_SPAN_MM - TILE_PLATE_CLEARANCE_MM -TILE_PLATE_SIZE_MM = ( - TILE_PLATE_SPAN_MM, - TILE_PLATE_SPAN_MM, - TILE_PLATE_THICKNESS_MM, -) -TILE_PLATE_REBATE_DEPTH_MM = TILE_PLATE_THICKNESS_MM -# Thick enough that a dark-square recess cut into the top still leaves two -# nozzle widths of material over the LED pocket below. -TILE_PLATE_DIFFUSER_SKIN_MM = 1.2 -TILE_PLATE_LED_POCKET_MM = ( - 6.2, - 6.2, - TILE_PLATE_THICKNESS_MM - TILE_PLATE_DIFFUSER_SKIN_MM, -) -# Two nozzle widths: a narrower slot will not resolve when printed. -TILE_PLATE_GROOVE_WIDTH_MM = 0.8 -TILE_PLATE_GROOVE_DEPTH_MM = 0.6 -TILE_PLATE_DARK_SQUARE_DEPTH_MM = 0.4 -# One pocket per square on the underside, leaving ribs on the grid lines. It -# does two jobs: it removes most of a 320 mm solid sheet's volume, which is a -# real line on a print-service quote and a warping risk, and it is also the -# clearance over the Hall sensors and nearby bypass capacitors. -TILE_PLATE_RIB_WIDTH_MM = 3.0 -TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM = 1.2 -TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM = SQUARE_SIZE_MM - 2.0 * TILE_PLATE_RIB_WIDTH_MM -TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM = 3.4 -TILE_PLATE_SCREW_HEAD_DIAMETER_MM = 6.4 -TILE_PLATE_SCREW_HEAD_DEPTH_MM = 1.6 -TILE_PLATE_SCREW_INSET_MM = 4.0 -_PLATE_SCREW_OFFSET_MM = TILE_PLATE_SPAN_MM / 2.0 - TILE_PLATE_SCREW_INSET_MM -# Four corners and four edge midpoints: every one lands on the case ledge. -TILE_PLATE_SCREW_POSITIONS_MM = ( - (-_PLATE_SCREW_OFFSET_MM, -_PLATE_SCREW_OFFSET_MM), - (0.0, -_PLATE_SCREW_OFFSET_MM), - (_PLATE_SCREW_OFFSET_MM, -_PLATE_SCREW_OFFSET_MM), - (-_PLATE_SCREW_OFFSET_MM, 0.0), - (_PLATE_SCREW_OFFSET_MM, 0.0), - (-_PLATE_SCREW_OFFSET_MM, _PLATE_SCREW_OFFSET_MM), - (0.0, _PLATE_SCREW_OFFSET_MM), - (_PLATE_SCREW_OFFSET_MM, _PLATE_SCREW_OFFSET_MM), -) -TILE_PLATE_ORIENTATION_NOTCH_MM = (6.0, 6.0, TILE_PLATE_THICKNESS_MM) - -# --- Derived per-square layout ---------------------------------------------- - -BOARD_SQUARES = SquareLayout.build( - grid_count=GRID_COUNT, - square_size=SQUARE_SIZE_MM, - playing_span=PLAYING_SPAN_MM, - led_offset_mm=LED_POSITION_MM, - hall_offset_mm=HALL_SENSOR_POSITION_MM, -) - -PRINTED_PART_SIZES_MM = (CASE_OUTER_SIZE_MM, TILE_PLATE_SIZE_MM) -LONGEST_PRINTED_PART_MM = max(max(part) for part in PRINTED_PART_SIZES_MM) -BOARD_ASSEMBLED_ENVELOPE_MM = CASE_OUTER_SIZE_MM - - -def meets(value: float, minimum: float) -> bool: - """Floating-point safe "value is at least minimum". - - Derived dimensions are sums and differences of decimals, so an exact - comparison rejects geometry that is on the limit by one part in 10^16. - """ - return value > minimum or isclose(value, minimum, abs_tol=1e-9) - - -def usable_build_volume( - build_volume_mm: tuple[float, float, float], -) -> tuple[float, float, float]: - """Return the build volume after reserving an edge margin on every side.""" - return ( - build_volume_mm[0] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, - build_volume_mm[1] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, - build_volume_mm[2] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, - ) - - -def fits_build_volume( - part_dimensions_mm: tuple[float, float, float], - build_volume_mm: tuple[float, float, float], -) -> bool: - """Check whether an axis-aligned part fits after rotation and bed margins.""" - part = sorted(part_dimensions_mm) - usable = sorted(usable_build_volume(build_volume_mm)) - return all(part_axis <= bed_axis for part_axis, bed_axis in zip(part, usable)) - - -def validate() -> None: - """Reject dimension sets that are inconsistent or physically implausible.""" - if GRID_COUNT != 8: - raise ValueError("A chessboard must contain eight rows and eight columns") - if BOARD_FORMAT != "compact electronic": - raise ValueError("Review all scale guardrails when changing the board format") - if not COMPACT_SQUARE_MIN_MM <= SQUARE_SIZE_MM <= COMPACT_SQUARE_MAX_MM: - raise ValueError("Square size is outside the compact-board design range") - if not isclose(PLAYING_SPAN_MM, SQUARE_SIZE_MM * GRID_COUNT): - raise ValueError("Playing span must equal square size multiplied by grid count") - for span in (CASE_WIDTH_MM, CASE_DEPTH_MM): - if not COMPACT_BOARD_MIN_SPAN_MM <= span <= COMPACT_BOARD_MAX_SPAN_MM: - raise ValueError("Case span is outside the compact product range") - if CASE_HEIGHT_MM > COMPACT_BOARD_MAX_HEIGHT_MM: - raise ValueError("Finished board is too tall for the compact product range") - if not isclose(CASE_WIDTH_MM, PLAYING_SPAN_MM + 2.0 * CASE_FRAME_WIDTH_MM): - raise ValueError("Case width must include the printed frame on both sides") - if not isclose( - CASE_DEPTH_MM, - PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM + 2.0 * CASE_FRAME_WIDTH_MM, - ): - raise ValueError("Case depth must include the control strip and the frame") - - # The internal stack has to add up, or the plate will not sit flush. - if not isclose( - CASE_HEIGHT_MM, - TILE_PLATE_THICKNESS_MM - + PCB_TO_PLATE_GAP_MM - + PCB_THICKNESS_MM - + PI_BAY_HEIGHT_MM - + CASE_FLOOR_MM, - ): - raise ValueError("Case height must equal the sum of the internal stack") - if PI_BAY_HEIGHT_MM < PI_HEADER_HEIGHT_MM + PI_BOARD_SIZE_MM[2] + PI_CLEARANCE_MM: - raise ValueError( - "Cavity below the board is too shallow for the Pi on its header" - ) - tallest_playing_area_component = max( - HALL_SENSOR_HEIGHT_MM + HALL_SENSOR_STANDOFF_MM, - LED_PACKAGE_MAX_SIZE_MM[2], - EXPANDER_BODY_MM[2], - ) - if PCB_TO_PLATE_GAP_MM < tallest_playing_area_component: - raise ValueError("Plate would foul a component in the playing area") - - if ( - not FDM_MIN_FIT_CLEARANCE_MM - <= TILE_PLATE_CLEARANCE_MM - <= (FDM_MAX_FIT_CLEARANCE_MM) - ): - raise ValueError("Tile plate clearance is outside the prototype fit range") - - minimum_features = { - "case wall": CASE_WALL_MM, - "case floor": CASE_FLOOR_MM, - "case frame": CASE_FRAME_WIDTH_MM, - "plate thickness": TILE_PLATE_THICKNESS_MM, - "plate diffuser skin": TILE_PLATE_DIFFUSER_SKIN_MM, - "plate rib": TILE_PLATE_RIB_WIDTH_MM, - "plate pocket floor": TILE_PLATE_THICKNESS_MM - - TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM - - TILE_PLATE_DARK_SQUARE_DEPTH_MM, - "plate groove": TILE_PLATE_GROOVE_WIDTH_MM, - "support boss wall": ( - PCB_SUPPORT_BOSS_DIAMETER_MM - PCB_SUPPORT_PILOT_DIAMETER_MM - ) - / 2.0, - } - for name, dimension in minimum_features.items(): - minimum = FDM_MIN_FLOOR_MM if name.endswith("floor") else FDM_MIN_FEATURE_MM - if not meets(dimension, minimum): - raise ValueError(f"{name} is below the prototype printable minimum") - - if TILE_PLATE_LED_POCKET_MM[2] >= TILE_PLATE_THICKNESS_MM: - raise ValueError("LED pocket must leave a closed diffuser skin") - if TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM >= ( - TILE_PLATE_THICKNESS_MM - FDM_MIN_FLOOR_MM - ): - raise ValueError("Underside pockets must leave a printable plate floor") - if TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM <= HALL_SENSOR_BODY_MM[0]: - raise ValueError("Underside pocket must span the Hall sensor it clears") - if TILE_PLATE_DARK_SQUARE_DEPTH_MM >= TILE_PLATE_DIFFUSER_SKIN_MM: - raise ValueError("Dark-square recess must not reach the diffuser skin") - required_led_pocket_xy = tuple( - package + 2.0 * LED_PACKAGE_CLEARANCE_PER_SIDE_MM - for package in LED_PACKAGE_MAX_SIZE_MM[:2] - ) - if any( - pocket < required - for pocket, required in zip( - TILE_PLATE_LED_POCKET_MM[:2], required_led_pocket_xy - ) - ): - raise ValueError("LED pocket does not clear the maximum 5050 package") - if any( - pocket < emitter + LED_PACKAGE_TOLERANCE_MM - for pocket, emitter in zip(TILE_PLATE_LED_POCKET_MM[:2], LED_EMITTER_WINDOW_MM) - ): - raise ValueError("LED pocket does not clear the emitter window") - if not meets( - TILE_PLATE_DIFFUSER_SKIN_MM - TILE_PLATE_DARK_SQUARE_DEPTH_MM, - FDM_MIN_FEATURE_MM, - ): - raise ValueError( - "A dark square over an LED pocket would leave too thin a diffuser" - ) - if TILE_PLATE_SCREW_HEAD_DIAMETER_MM <= TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM: - raise ValueError("Screw head recess must be wider than its clearance hole") - if TILE_PLATE_SCREW_HEAD_DEPTH_MM >= TILE_PLATE_THICKNESS_MM: - raise ValueError("Screw head recess must leave a bearing shoulder") - - # Every plate screw has to land on the case ledge. Anywhere inboard of it is - # over the PCB, and a boss there would collide with the board. - ledge_inner = PLAYING_SPAN_MM / 2.0 - CASE_PLATE_LEDGE_MM - ledge_outer = PLAYING_SPAN_MM / 2.0 - screw_radius = TILE_PLATE_SCREW_HEAD_DIAMETER_MM / 2.0 - for screw_x, screw_y in TILE_PLATE_SCREW_POSITIONS_MM: - reach = max(abs(screw_x), abs(screw_y)) - if not ledge_inner + screw_radius <= reach <= ledge_outer - screw_radius: - raise ValueError("A tile plate screw does not land on the case ledge") - if len(set(TILE_PLATE_SCREW_POSITIONS_MM)) != len(TILE_PLATE_SCREW_POSITIONS_MM): - raise ValueError("Tile plate screw positions must be unique") - if CASE_PLATE_LEDGE_MM <= TILE_PLATE_CLEARANCE_MM: - raise ValueError("The plate ledge must be wider than the plate's own clearance") - - # Per-square features must stay inside their own square, or two squares - # would light or sense as one. - half = SQUARE_SIZE_MM / 2.0 - for axis, position in enumerate(LED_POSITION_MM): - margin = half - abs(position) - TILE_PLATE_LED_POCKET_MM[axis] / 2.0 - if margin < FDM_MIN_FEATURE_MM: - raise ValueError("LED pocket crosses into the neighbouring square") - if HALL_SENSOR_BODY_MM[0] > SQUARE_SIZE_MM - 2.0 * FDM_MIN_FEATURE_MM: - raise ValueError("Hall sensor body does not fit within one square") - - # Support bosses stand on the grid lines; nothing else may be there. - boss_radius = PCB_SUPPORT_BOSS_DIAMETER_MM / 2.0 - for boss_x, boss_y in PCB_SUPPORT_POSITIONS_MM: - for led_x, led_y in (square.led_position_mm for square in BOARD_SQUARES): - gap = max(abs(boss_x - led_x), abs(boss_y - led_y)) - if gap < boss_radius + TILE_PLATE_LED_POCKET_MM[0] / 2.0: - raise ValueError("A support boss collides with an LED position") - for sensor_x, sensor_y in (square.hall_position_mm for square in BOARD_SQUARES): - if ( - abs(boss_x - sensor_x) < boss_radius + HALL_SENSOR_BODY_MM[0] / 2.0 - and abs(boss_y - sensor_y) < boss_radius + HALL_SENSOR_BODY_MM[1] / 2.0 - ): - raise ValueError("A support boss collides with a Hall sensor") - if len(set(PCB_SUPPORT_POSITIONS_MM)) != len(PCB_SUPPORT_POSITIONS_MM): - raise ValueError("Support boss positions must be unique") - if PCB_SUPPORT_PILOT_DEPTH_MM >= PCB_SUPPORT_BOSS_DIAMETER_MM * 3.0: - raise ValueError("Support pilot is too deep for its boss") - - # Control panel features must stay on the control strip. - strip_min_y = -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM - strip_max_y = -PLAYING_SPAN_MM / 2.0 - panel_features: tuple[tuple[str, float, float, float, float], ...] = tuple( - ( - "button", - x, - y, - PANEL_BUTTON_HOLE_DIAMETER_MM / 2.0, - PANEL_BUTTON_HOLE_DIAMETER_MM / 2.0, - ) - for x, y in (button.position_mm for button in PANEL_BUTTONS) - ) + ( - ( - "display", - PANEL_OLED_CENTER_MM[0], - PANEL_OLED_CENTER_MM[1], - PANEL_OLED_RECESS_MM[0] / 2.0, - PANEL_OLED_RECESS_MM[1] / 2.0, - ), - ) - for name, x, y, half_x, half_y in panel_features: - if not strip_min_y <= y - half_y and y + half_y <= strip_max_y: - raise ValueError(f"Control panel {name} extends off the control strip") - if abs(x) + half_x > PLAYING_SPAN_MM / 2.0: - raise ValueError(f"Control panel {name} extends off the board") - if len(PANEL_BUTTONS) != PANEL_BUTTON_COUNT: - raise ValueError("Control panel must place every button") - if len({button.position_mm for button in PANEL_BUTTONS}) != PANEL_BUTTON_COUNT: - raise ValueError("Button positions must be unique") - if PANEL_OLED_RECESS_CLEARANCE_XY_MM <= 0.0: - raise ValueError("Display recess must include positive XY assembly clearance") - if any( - recess <= module - for recess, module in zip(PANEL_OLED_RECESS_MM, PANEL_OLED_MODULE_MM[:2]) - ): - raise ValueError("Display recess must be larger than the module") - if any( - window >= module - for window, module in zip(PANEL_OLED_WINDOW_MM, PANEL_OLED_MODULE_MM[:2]) - ): - raise ValueError("Display window must be smaller than the module behind it") - - if len(EXPANDER_POSITIONS_BY_BANK_MM) != 8: - raise ValueError("Board composition must place eight GPIO expanders") - if len(set(EXPANDER_POSITIONS_BY_BANK_MM.values())) != 8: - raise ValueError("GPIO expander positions must be unique") - - BOARD_SQUARES.validate_topology() - - if not isclose(PCB_SIZE_MM[0], PLAYING_SPAN_MM): - raise ValueError("Board must be exactly as wide as the playing area") - if not isclose(PCB_SIZE_MM[1], PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM): - raise ValueError("Board must carry the playing area and the control strip") - - for part_size in PRINTED_PART_SIZES_MM: - if not fits_build_volume(part_size, REFERENCE_SERVICE_BUILD_VOLUME_MM): - raise ValueError( - "A printed part exceeds the print-service reference volume" - ) - - -def describe(domain: str = "Shared hardware") -> str: - """Return a compact summary suitable for domain validation commands.""" - return ( - f"{domain} dimensions valid: " - f"{GRID_COUNT} x {SQUARE_SIZE_MM:g} mm = {PLAYING_SPAN_MM:g} mm playing span; " - f"case {CASE_WIDTH_MM:g} x {CASE_DEPTH_MM:g} x {CASE_HEIGHT_MM:g} mm " - f"({CASE_DEPTH_MM / MILLIMETRES_PER_INCH:.1f} in deep); " - f"plate {TILE_PLATE_SPAN_MM:g} mm; " - f"board {PCB_SIZE_MM[0]:g} x {PCB_SIZE_MM[1]:g} mm" - ) - - -# Re-export public measurements through thin domain adapters without leaking -# implementation imports such as ``isclose``. -__all__ = tuple( - name for name in globals() if name.isupper() and not name.startswith("_") -) + ( - "describe", - "fits_build_volume", - "meets", - "usable_build_volume", - "validate", -) - -validate() - - -if __name__ == "__main__": - print(describe()) diff --git a/hardware/shared/dimensions/__init__.py b/hardware/shared/dimensions/__init__.py new file mode 100644 index 00000000..35034dde --- /dev/null +++ b/hardware/shared/dimensions/__init__.py @@ -0,0 +1,251 @@ +"""Shared physical dimensions, grouped by the object being edited.""" + +from shared.panel_buttons import PANEL_BUTTONS + +from .board import ( + BOARD_FORMAT, + BOARD_SQUARES, + COMPACT_BOARD_MAX_HEIGHT_MM, + COMPACT_BOARD_MAX_SPAN_MM, + COMPACT_BOARD_MIN_SPAN_MM, + COMPACT_SQUARE_MAX_MM, + COMPACT_SQUARE_MIN_MM, + EXPANDER_BODY_MM, + EXPANDER_OFFSET_MM, + EXPANDER_POSITIONS_BY_BANK_MM, + FIDE_REFERENCE_SQUARE_MAX_MM, + FIDE_REFERENCE_SQUARE_MIN_MM, + GRID_COUNT, + HALL_BANKS, + HALL_SENSOR_BODY_MM, + HALL_SENSOR_HEIGHT_MM, + HALL_SENSOR_PACKAGE_MM, + HALL_SENSOR_POSITION_MM, + HALL_SENSOR_STANDOFF_MM, + LED_EMITTER_WINDOW_MM, + LED_PACKAGE_CLEARANCE_PER_SIDE_MM, + LED_PACKAGE_MAX_SIZE_MM, + LED_PACKAGE_NOMINAL_SIZE_MM, + LED_PACKAGE_REFERENCE, + LED_PACKAGE_TOLERANCE_MM, + LED_POSITION_MM, + PANEL_STRIP_DEPTH_MM, + PCB_CENTER_OFFSET_Y_MM, + PCB_SIZE_MM, + PCB_THICKNESS_MM, + PLAYING_SPAN_MM, + SQUARE_SIZE_MM, +) +from .case import ( + CASE_CENTER_OFFSET_Y_MM, + CASE_DEPTH_MM, + CASE_FLOOR_MM, + CASE_FRAME_WIDTH_MM, + CASE_HEIGHT_MM, + CASE_JACK_APERTURE_DIAMETER_MM, + CASE_OUTER_RADIUS_MM, + CASE_OUTER_SIZE_MM, + CASE_PLATE_LEDGE_MM, + CASE_REAR_APERTURE_CENTER_Z_MM, + CASE_ROCKER_APERTURE_MM, + CASE_SD_SLOT_MM, + CASE_VENT_SLOT_MM, + CASE_WALL_MM, + CASE_WIDTH_MM, + PCB_MOUNTING_HOLE_DIAMETER_MM, + PCB_SUPPORT_BOSS_DIAMETER_MM, + PCB_SUPPORT_GRID_OFFSETS_MM, + PCB_SUPPORT_PILOT_DEPTH_MM, + PCB_SUPPORT_PILOT_DIAMETER_MM, + PCB_SUPPORT_POSITIONS_MM, + PCB_TO_PLATE_GAP_MM, + PCB_TOP_Z_MM, + PCB_UNDERSIDE_Z_MM, + PI_BAY_CENTER_MM, + PI_BAY_HEIGHT_MM, + PI_BOARD_SIZE_MM, + PI_CLEARANCE_MM, + PI_HEADER_BODY_MM, + PI_HEADER_HEIGHT_MM, + PI_HEADER_ROTATION_DEG, +) +from .panel import ( + PANEL_BUTTON_ACTUATOR_DIAMETER_MM, + PANEL_BUTTON_BODY_MM, + PANEL_BUTTON_COUNT, + PANEL_BUTTON_HOLE_DIAMETER_MM, + PANEL_OLED_CENTER_MM, + PANEL_OLED_MODULE_MM, + PANEL_OLED_RECESS_CLEARANCE_XY_MM, + PANEL_OLED_RECESS_DEPTH_MM, + PANEL_OLED_RECESS_MM, + PANEL_OLED_WINDOW_MM, + PANEL_ORIGIN_Y_MM, + PCB_STRIP_PLACEMENTS, +) +from .printing import ( + BOARD_ASSEMBLED_ENVELOPE_MM, + BOOLEAN_RECESS_OVERLAP_MM, + BOOLEAN_THROUGH_OVERLAP_MM, + FDM_MAX_FIT_CLEARANCE_MM, + FDM_MIN_FEATURE_MM, + FDM_MIN_FIT_CLEARANCE_MM, + FDM_MIN_FLOOR_MM, + FDM_REFERENCE_NOZZLE_MM, + LONGEST_PRINTED_PART_MM, + PRINT_BED_EDGE_MARGIN_MM, + PRINTED_PART_SIZES_MM, + REFERENCE_DESKTOP_BUILD_VOLUME_MM, + REFERENCE_SERVICE_BUILD_VOLUME_MM, + fits_build_volume, + meets, + usable_build_volume, +) +from .tile_plate import ( + TILE_PLATE_CLEARANCE_MM, + TILE_PLATE_DARK_SQUARE_DEPTH_MM, + TILE_PLATE_DIFFUSER_SKIN_MM, + TILE_PLATE_GROOVE_DEPTH_MM, + TILE_PLATE_GROOVE_WIDTH_MM, + TILE_PLATE_LED_POCKET_MM, + TILE_PLATE_ORIENTATION_NOTCH_MM, + TILE_PLATE_REBATE_DEPTH_MM, + TILE_PLATE_RIB_WIDTH_MM, + TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM, + TILE_PLATE_SCREW_HEAD_DEPTH_MM, + TILE_PLATE_SCREW_HEAD_DIAMETER_MM, + TILE_PLATE_SCREW_INSET_MM, + TILE_PLATE_SCREW_POSITIONS_MM, + TILE_PLATE_SIZE_MM, + TILE_PLATE_SPAN_MM, + TILE_PLATE_THICKNESS_MM, + TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM, + TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM, +) +from .units import ( + BLENDER_SCALE_LENGTH, + MILLIMETRES_PER_INCH, + MILLIMETRES_PER_METRE, +) +from .validation import describe, validate + +__all__ = ( + "BLENDER_SCALE_LENGTH", + "BOARD_ASSEMBLED_ENVELOPE_MM", + "BOARD_FORMAT", + "BOARD_SQUARES", + "BOOLEAN_RECESS_OVERLAP_MM", + "BOOLEAN_THROUGH_OVERLAP_MM", + "CASE_CENTER_OFFSET_Y_MM", + "CASE_DEPTH_MM", + "CASE_FLOOR_MM", + "CASE_FRAME_WIDTH_MM", + "CASE_HEIGHT_MM", + "CASE_JACK_APERTURE_DIAMETER_MM", + "CASE_OUTER_RADIUS_MM", + "CASE_OUTER_SIZE_MM", + "CASE_PLATE_LEDGE_MM", + "CASE_REAR_APERTURE_CENTER_Z_MM", + "CASE_ROCKER_APERTURE_MM", + "CASE_SD_SLOT_MM", + "CASE_VENT_SLOT_MM", + "CASE_WALL_MM", + "CASE_WIDTH_MM", + "COMPACT_BOARD_MAX_HEIGHT_MM", + "COMPACT_BOARD_MAX_SPAN_MM", + "COMPACT_BOARD_MIN_SPAN_MM", + "COMPACT_SQUARE_MAX_MM", + "COMPACT_SQUARE_MIN_MM", + "EXPANDER_BODY_MM", + "EXPANDER_OFFSET_MM", + "EXPANDER_POSITIONS_BY_BANK_MM", + "FDM_MAX_FIT_CLEARANCE_MM", + "FDM_MIN_FEATURE_MM", + "FDM_MIN_FIT_CLEARANCE_MM", + "FDM_MIN_FLOOR_MM", + "FDM_REFERENCE_NOZZLE_MM", + "FIDE_REFERENCE_SQUARE_MAX_MM", + "FIDE_REFERENCE_SQUARE_MIN_MM", + "GRID_COUNT", + "HALL_BANKS", + "HALL_SENSOR_BODY_MM", + "HALL_SENSOR_HEIGHT_MM", + "HALL_SENSOR_PACKAGE_MM", + "HALL_SENSOR_POSITION_MM", + "HALL_SENSOR_STANDOFF_MM", + "LED_EMITTER_WINDOW_MM", + "LED_PACKAGE_CLEARANCE_PER_SIDE_MM", + "LED_PACKAGE_MAX_SIZE_MM", + "LED_PACKAGE_NOMINAL_SIZE_MM", + "LED_PACKAGE_REFERENCE", + "LED_PACKAGE_TOLERANCE_MM", + "LED_POSITION_MM", + "LONGEST_PRINTED_PART_MM", + "MILLIMETRES_PER_INCH", + "MILLIMETRES_PER_METRE", + "PANEL_BUTTONS", + "PANEL_BUTTON_ACTUATOR_DIAMETER_MM", + "PANEL_BUTTON_BODY_MM", + "PANEL_BUTTON_COUNT", + "PANEL_BUTTON_HOLE_DIAMETER_MM", + "PANEL_OLED_CENTER_MM", + "PANEL_OLED_MODULE_MM", + "PANEL_OLED_RECESS_CLEARANCE_XY_MM", + "PANEL_OLED_RECESS_DEPTH_MM", + "PANEL_OLED_RECESS_MM", + "PANEL_OLED_WINDOW_MM", + "PANEL_ORIGIN_Y_MM", + "PANEL_STRIP_DEPTH_MM", + "PCB_CENTER_OFFSET_Y_MM", + "PCB_MOUNTING_HOLE_DIAMETER_MM", + "PCB_SIZE_MM", + "PCB_STRIP_PLACEMENTS", + "PCB_SUPPORT_BOSS_DIAMETER_MM", + "PCB_SUPPORT_GRID_OFFSETS_MM", + "PCB_SUPPORT_PILOT_DEPTH_MM", + "PCB_SUPPORT_PILOT_DIAMETER_MM", + "PCB_SUPPORT_POSITIONS_MM", + "PCB_THICKNESS_MM", + "PCB_TOP_Z_MM", + "PCB_TO_PLATE_GAP_MM", + "PCB_UNDERSIDE_Z_MM", + "PI_BAY_CENTER_MM", + "PI_BAY_HEIGHT_MM", + "PI_BOARD_SIZE_MM", + "PI_CLEARANCE_MM", + "PI_HEADER_BODY_MM", + "PI_HEADER_HEIGHT_MM", + "PI_HEADER_ROTATION_DEG", + "PLAYING_SPAN_MM", + "PRINTED_PART_SIZES_MM", + "PRINT_BED_EDGE_MARGIN_MM", + "REFERENCE_DESKTOP_BUILD_VOLUME_MM", + "REFERENCE_SERVICE_BUILD_VOLUME_MM", + "SQUARE_SIZE_MM", + "TILE_PLATE_CLEARANCE_MM", + "TILE_PLATE_DARK_SQUARE_DEPTH_MM", + "TILE_PLATE_DIFFUSER_SKIN_MM", + "TILE_PLATE_GROOVE_DEPTH_MM", + "TILE_PLATE_GROOVE_WIDTH_MM", + "TILE_PLATE_LED_POCKET_MM", + "TILE_PLATE_ORIENTATION_NOTCH_MM", + "TILE_PLATE_REBATE_DEPTH_MM", + "TILE_PLATE_RIB_WIDTH_MM", + "TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM", + "TILE_PLATE_SCREW_HEAD_DEPTH_MM", + "TILE_PLATE_SCREW_HEAD_DIAMETER_MM", + "TILE_PLATE_SCREW_INSET_MM", + "TILE_PLATE_SCREW_POSITIONS_MM", + "TILE_PLATE_SIZE_MM", + "TILE_PLATE_SPAN_MM", + "TILE_PLATE_THICKNESS_MM", + "TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM", + "TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM", + "describe", + "fits_build_volume", + "meets", + "usable_build_volume", + "validate", +) + +validate() diff --git a/hardware/shared/dimensions/__main__.py b/hardware/shared/dimensions/__main__.py new file mode 100644 index 00000000..4934672f --- /dev/null +++ b/hardware/shared/dimensions/__main__.py @@ -0,0 +1,3 @@ +from .validation import describe + +print(describe()) diff --git a/hardware/shared/dimensions/board.py b/hardware/shared/dimensions/board.py new file mode 100644 index 00000000..b9da9dba --- /dev/null +++ b/hardware/shared/dimensions/board.py @@ -0,0 +1,104 @@ +"""Playing grid, populated-board geometry, and sensor-bank positions.""" + +from math import ceil +from types import MappingProxyType + +from shared.components import HALL_SENSOR, SK9822, TCA9554 +from shared.hall_banks import banks +from shared.squares import SquareLayout + +# Project form factor. This is a compact electronic board, not a FIDE-sized board. +BOARD_FORMAT = "compact electronic" +COMPACT_SQUARE_MIN_MM = 35.0 +COMPACT_SQUARE_MAX_MM = 45.0 +COMPACT_BOARD_MIN_SPAN_MM = 300.0 +COMPACT_BOARD_MAX_SPAN_MM = 400.0 +COMPACT_BOARD_MAX_HEIGHT_MM = 35.0 +FIDE_REFERENCE_SQUARE_MIN_MM = 50.0 +FIDE_REFERENCE_SQUARE_MAX_MM = 60.0 + +# Board grid. Unchanged from revision A: 40 mm squares suit a chess set with a +# king base of 32 mm or less, which covers most ordinary club sets. +GRID_COUNT = 8 +SQUARE_SIZE_MM = 40.0 +PLAYING_SPAN_MM = SQUARE_SIZE_MM * GRID_COUNT + +# SK9822 5050 addressable RGB LED. Mechanically interchangeable with the +# WS2812B this design replaced, so the pocket dimensions are unchanged; the +# difference is electrical, a separate clock line the host can drive from SPI. +# https://www.ledyilighting.com/wp-content/uploads/2025/02/WS2812B-datasheet.pdf +LED_PACKAGE_REFERENCE = SK9822.description +LED_PACKAGE_NOMINAL_SIZE_MM = SK9822.require_body_mm() +LED_PACKAGE_TOLERANCE_MM = 0.05 +LED_PACKAGE_MAX_SIZE_MM = tuple( + axis + LED_PACKAGE_TOLERANCE_MM for axis in LED_PACKAGE_NOMINAL_SIZE_MM +) +LED_PACKAGE_CLEARANCE_PER_SIDE_MM = 0.2 +LED_EMITTER_WINDOW_MM = (4.0, 4.0) +LED_POSITION_MM = (13.0, 13.0) + +# SOT-23 omnipolar Hall sensor at each square centre. Either magnet pole drives +# its active-low output, preserving the old occupied-square electrical polarity. +HALL_SENSOR_PACKAGE_MM = HALL_SENSOR.require_body_mm() +HALL_SENSOR_BODY_MM = HALL_SENSOR_PACKAGE_MM[:2] +HALL_SENSOR_HEIGHT_MM = HALL_SENSOR_PACKAGE_MM[2] +HALL_SENSOR_POSITION_MM = (0.0, 0.0) +HALL_SENSOR_STANDOFF_MM = 0.0 +# Expander package orientation and centres are shared because the CAD proxy must +# depict the same physical obstructions that PCB placement and routing use. +_EXPANDER_PACKAGE_MM = TCA9554.require_body_mm() +EXPANDER_BODY_MM = ( + _EXPANDER_PACKAGE_MM[1], + _EXPANDER_PACKAGE_MM[0], + _EXPANDER_PACKAGE_MM[2], +) +HALL_BANKS = banks(GRID_COUNT) +# Lift the package above the nearest LED row, including its body and a 1 mm gap. +# This keeps the unchanged horizontal LED links out of the SOIC fanout. +EXPANDER_OFFSET_MM = ( + 0.0, + float( + ceil( + LED_POSITION_MM[1] + - SQUARE_SIZE_MM / 2 + + LED_PACKAGE_MAX_SIZE_MM[1] / 2 + + EXPANDER_BODY_MM[1] / 2 + + 1.0 + ) + ), +) +EXPANDER_POSITIONS_BY_BANK_MM = MappingProxyType( + { + bank.label: tuple( + a + b + for a, b in zip( + bank.centre(SQUARE_SIZE_MM, PLAYING_SPAN_MM), + EXPANDER_OFFSET_MM, + strict=True, + ) + ) + for bank in HALL_BANKS + } +) + +# --- Printed circuit board -------------------------------------------------- +# One board spans the playing area plus a 40 mm control strip along the front, +# so the buttons and display face up and solder flat like everything else. +PCB_THICKNESS_MM = 1.6 +PANEL_STRIP_DEPTH_MM = 40.0 +PCB_SIZE_MM = ( + PLAYING_SPAN_MM, + PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM, + PCB_THICKNESS_MM, +) +PCB_CENTER_OFFSET_Y_MM = -PANEL_STRIP_DEPTH_MM / 2.0 + +# --- Derived per-square layout ---------------------------------------------- + +BOARD_SQUARES = SquareLayout.build( + grid_count=GRID_COUNT, + square_size=SQUARE_SIZE_MM, + playing_span=PLAYING_SPAN_MM, + led_offset_mm=LED_POSITION_MM, + hall_offset_mm=HALL_SENSOR_POSITION_MM, +) diff --git a/hardware/shared/dimensions/case.py b/hardware/shared/dimensions/case.py new file mode 100644 index 00000000..998f85f1 --- /dev/null +++ b/hardware/shared/dimensions/case.py @@ -0,0 +1,58 @@ +"""Case envelope, vertical stack, Pi bay, and PCB supports.""" + +from .board import ( + PANEL_STRIP_DEPTH_MM, + PCB_CENTER_OFFSET_Y_MM, + PCB_THICKNESS_MM, + PLAYING_SPAN_MM, +) +from .tile_plate import TILE_PLATE_THICKNESS_MM + +# --- Case ------------------------------------------------------------------- +CASE_FRAME_WIDTH_MM = 10.0 +CASE_WALL_MM = 3.0 +CASE_FLOOR_MM = 3.0 +# The plate rests on a ledge around the cavity, and its screws land in that +# ledge. They cannot land anywhere inboard of it, because the PCB is there. +CASE_PLATE_LEDGE_MM = 8.0 +CASE_HEIGHT_MM = 30.0 +CASE_OUTER_RADIUS_MM = 2.2 +CASE_WIDTH_MM = PLAYING_SPAN_MM + 2.0 * CASE_FRAME_WIDTH_MM +CASE_DEPTH_MM = PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM + 2.0 * CASE_FRAME_WIDTH_MM +CASE_CENTER_OFFSET_Y_MM = PCB_CENTER_OFFSET_Y_MM +CASE_OUTER_SIZE_MM = (CASE_WIDTH_MM, CASE_DEPTH_MM, CASE_HEIGHT_MM) + +# Vertical stack, measured from the outside of the case floor. +PCB_TO_PLATE_GAP_MM = 4.0 +PCB_TOP_Z_MM = CASE_HEIGHT_MM - TILE_PLATE_THICKNESS_MM - PCB_TO_PLATE_GAP_MM +PCB_UNDERSIDE_Z_MM = PCB_TOP_Z_MM - PCB_THICKNESS_MM +PI_BAY_HEIGHT_MM = PCB_UNDERSIDE_Z_MM - CASE_FLOOR_MM + +# A 320 mm board flexes badly on perimeter support alone, so bosses stand on the +# grid lines, where no LED or Hall sensor sits. Seven millimetres clears both. +PCB_SUPPORT_BOSS_DIAMETER_MM = 7.0 +PCB_SUPPORT_PILOT_DIAMETER_MM = 2.5 +PCB_MOUNTING_HOLE_DIAMETER_MM = 3.4 +PCB_SUPPORT_PILOT_DEPTH_MM = 6.0 +PCB_SUPPORT_GRID_OFFSETS_MM = (-120.0, -40.0, 40.0, 120.0) +PCB_SUPPORT_POSITIONS_MM = tuple( + (x, y) for y in PCB_SUPPORT_GRID_OFFSETS_MM for x in PCB_SUPPORT_GRID_OFFSETS_MM +) + tuple( + (x, -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM / 2.0) + for x in PCB_SUPPORT_GRID_OFFSETS_MM +) + +# Raspberry Pi Zero 2 W hangs under the board on its header. +PI_BOARD_SIZE_MM = (65.0, 30.0, 1.4) +PI_HEADER_HEIGHT_MM = 8.5 +PI_HEADER_BODY_MM = (51.0, 5.0, PI_HEADER_HEIGHT_MM) +PI_HEADER_ROTATION_DEG = 90.0 +PI_CLEARANCE_MM = 2.0 +PI_BAY_CENTER_MM = (0.0, -PLAYING_SPAN_MM / 2.0 + 40.0) +CASE_SD_SLOT_MM = (14.0, 3.5) +CASE_VENT_SLOT_MM = (40.0, 3.0) + +# Rear wall apertures for the power input. +CASE_JACK_APERTURE_DIAMETER_MM = 8.0 +CASE_ROCKER_APERTURE_MM = (19.0, 13.0) +CASE_REAR_APERTURE_CENTER_Z_MM = CASE_FLOOR_MM + PI_BAY_HEIGHT_MM / 2.0 diff --git a/hardware/shared/placements.py b/hardware/shared/dimensions/panel.py similarity index 54% rename from hardware/shared/placements.py rename to hardware/shared/dimensions/panel.py index 9f09cc71..99507df5 100644 --- a/hardware/shared/placements.py +++ b/hardware/shared/dimensions/panel.py @@ -1,8 +1,32 @@ -"""Physical placement of one-off components on the PCB control strip.""" +"""Control-strip openings, display fit, and shared PCB placements.""" from dataclasses import dataclass from types import MappingProxyType +from shared.components import BUTTON, OLED_MODULE + +from .board import PANEL_STRIP_DEPTH_MM, PLAYING_SPAN_MM + +# --- Control panel ---------------------------------------------------------- +PANEL_ORIGIN_Y_MM = -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM / 2.0 +PANEL_BUTTON_COUNT = 12 +PANEL_BUTTON_HOLE_DIAMETER_MM = 7.0 + +# AZ-Delivery 0.96 in SSD1306 module. The window exposes its approximately +# 23.7 x 12.9 mm viewing area; the recess holds the 27 x 27 mm carrier board, +# connected to J2 by four short wires. +PANEL_OLED_MODULE_MM = OLED_MODULE.require_body_mm() +PANEL_OLED_WINDOW_MM = (23.7, 12.9) +# Per-side XY clearance for printed-part tolerance and hand assembly. +PANEL_OLED_RECESS_CLEARANCE_XY_MM = 0.5 +PANEL_OLED_RECESS_MM = tuple( + dimension + 2.0 * PANEL_OLED_RECESS_CLEARANCE_XY_MM + for dimension in PANEL_OLED_MODULE_MM[:2] +) +PANEL_OLED_RECESS_DEPTH_MM = 2.0 +PANEL_OLED_CENTER_MM = (-110.0, PANEL_ORIGIN_Y_MM) +PANEL_BUTTON_BODY_MM = (*BUTTON.require_body_mm()[:2], 5.0) +PANEL_BUTTON_ACTUATOR_DIAMETER_MM = 3.5 Point = tuple[float, float] diff --git a/hardware/shared/dimensions/printing.py b/hardware/shared/dimensions/printing.py new file mode 100644 index 00000000..6e0f5134 --- /dev/null +++ b/hardware/shared/dimensions/printing.py @@ -0,0 +1,60 @@ +"""FDM guardrails and build-volume checks for printed parts.""" + +from math import isclose + +from .case import CASE_OUTER_SIZE_MM +from .tile_plate import TILE_PLATE_SIZE_MM + +# Prototype FDM guardrails. These catch implausible geometry, but do not replace +# printer-, material-, and orientation-specific slicer validation. +# +# Both parts are larger than a desktop printer bed, so they are quoted from an +# FDM print service. The desktop figure is kept to document that fact rather than +# to gate anything: a 380 mm case does not fit a 256 mm bed and never will. +REFERENCE_DESKTOP_BUILD_VOLUME_MM = (256.0, 256.0, 256.0) +REFERENCE_SERVICE_BUILD_VOLUME_MM = (420.0, 420.0, 420.0) +PRINT_BED_EDGE_MARGIN_MM = 5.0 +FDM_REFERENCE_NOZZLE_MM = 0.4 +FDM_MIN_FEATURE_MM = 2.0 * FDM_REFERENCE_NOZZLE_MM +FDM_MIN_FLOOR_MM = 1.0 +FDM_MIN_FIT_CLEARANCE_MM = 0.2 +FDM_MAX_FIT_CLEARANCE_MM = 0.5 + +# Boolean robustness. Cutters overhang the surface they break so coplanar faces +# never meet, which is what keeps the exact solver from leaving holes. +BOOLEAN_RECESS_OVERLAP_MM = 0.2 +BOOLEAN_THROUGH_OVERLAP_MM = 0.4 + +PRINTED_PART_SIZES_MM = (CASE_OUTER_SIZE_MM, TILE_PLATE_SIZE_MM) +LONGEST_PRINTED_PART_MM = max(max(part) for part in PRINTED_PART_SIZES_MM) +BOARD_ASSEMBLED_ENVELOPE_MM = CASE_OUTER_SIZE_MM + + +def meets(value: float, minimum: float) -> bool: + """Floating-point safe "value is at least minimum". + + Derived dimensions are sums and differences of decimals, so an exact + comparison rejects geometry that is on the limit by one part in 10^16. + """ + return value > minimum or isclose(value, minimum, abs_tol=1e-9) + + +def usable_build_volume( + build_volume_mm: tuple[float, float, float], +) -> tuple[float, float, float]: + """Return the build volume after reserving an edge margin on every side.""" + return ( + build_volume_mm[0] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, + build_volume_mm[1] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, + build_volume_mm[2] - 2.0 * PRINT_BED_EDGE_MARGIN_MM, + ) + + +def fits_build_volume( + part_dimensions_mm: tuple[float, float, float], + build_volume_mm: tuple[float, float, float], +) -> bool: + """Check whether an axis-aligned part fits after rotation and bed margins.""" + part = sorted(part_dimensions_mm) + usable = sorted(usable_build_volume(build_volume_mm)) + return all(part_axis <= bed_axis for part_axis, bed_axis in zip(part, usable)) diff --git a/hardware/shared/dimensions/tile_plate.py b/hardware/shared/dimensions/tile_plate.py new file mode 100644 index 00000000..adea6826 --- /dev/null +++ b/hardware/shared/dimensions/tile_plate.py @@ -0,0 +1,54 @@ +"""Printable tile-plate thickness, pockets, grooves, and fixings.""" + +from .board import PLAYING_SPAN_MM, SQUARE_SIZE_MM + +TILE_PLATE_THICKNESS_MM = 3.0 + +# --- Tile plate ------------------------------------------------------------- +# One flat overlay with the checkerboard engraved into it, sitting in a rebate +# over the playing area. The control strip is not covered; it shows through the +# case bezel. +TILE_PLATE_CLEARANCE_MM = 0.4 +TILE_PLATE_SPAN_MM = PLAYING_SPAN_MM - TILE_PLATE_CLEARANCE_MM +TILE_PLATE_SIZE_MM = ( + TILE_PLATE_SPAN_MM, + TILE_PLATE_SPAN_MM, + TILE_PLATE_THICKNESS_MM, +) +TILE_PLATE_REBATE_DEPTH_MM = TILE_PLATE_THICKNESS_MM +# Thick enough that a dark-square recess cut into the top still leaves two +# nozzle widths of material over the LED pocket below. +TILE_PLATE_DIFFUSER_SKIN_MM = 1.2 +TILE_PLATE_LED_POCKET_MM = ( + 6.2, + 6.2, + TILE_PLATE_THICKNESS_MM - TILE_PLATE_DIFFUSER_SKIN_MM, +) +# Two nozzle widths: a narrower slot will not resolve when printed. +TILE_PLATE_GROOVE_WIDTH_MM = 0.8 +TILE_PLATE_GROOVE_DEPTH_MM = 0.6 +TILE_PLATE_DARK_SQUARE_DEPTH_MM = 0.4 +# One pocket per square on the underside, leaving ribs on the grid lines. It +# does two jobs: it removes most of a 320 mm solid sheet's volume, which is a +# real line on a print-service quote and a warping risk, and it is also the +# clearance over the Hall sensors and nearby bypass capacitors. +TILE_PLATE_RIB_WIDTH_MM = 3.0 +TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM = 1.2 +TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM = SQUARE_SIZE_MM - 2.0 * TILE_PLATE_RIB_WIDTH_MM +TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM = 3.4 +TILE_PLATE_SCREW_HEAD_DIAMETER_MM = 6.4 +TILE_PLATE_SCREW_HEAD_DEPTH_MM = 1.6 +TILE_PLATE_SCREW_INSET_MM = 4.0 +_PLATE_SCREW_OFFSET_MM = TILE_PLATE_SPAN_MM / 2.0 - TILE_PLATE_SCREW_INSET_MM +# Four corners and four edge midpoints: every one lands on the case ledge. +TILE_PLATE_SCREW_POSITIONS_MM = ( + (-_PLATE_SCREW_OFFSET_MM, -_PLATE_SCREW_OFFSET_MM), + (0.0, -_PLATE_SCREW_OFFSET_MM), + (_PLATE_SCREW_OFFSET_MM, -_PLATE_SCREW_OFFSET_MM), + (-_PLATE_SCREW_OFFSET_MM, 0.0), + (_PLATE_SCREW_OFFSET_MM, 0.0), + (-_PLATE_SCREW_OFFSET_MM, _PLATE_SCREW_OFFSET_MM), + (0.0, _PLATE_SCREW_OFFSET_MM), + (_PLATE_SCREW_OFFSET_MM, _PLATE_SCREW_OFFSET_MM), +) +TILE_PLATE_ORIENTATION_NOTCH_MM = (6.0, 6.0, TILE_PLATE_THICKNESS_MM) diff --git a/hardware/shared/dimensions/units.py b/hardware/shared/dimensions/units.py new file mode 100644 index 00000000..aefbcc30 --- /dev/null +++ b/hardware/shared/dimensions/units.py @@ -0,0 +1,6 @@ +"""Millimetre and Blender unit conversion.""" + +# Unit contract. One Blender unit represents one millimetre in generated files. +MILLIMETRES_PER_METRE = 1_000.0 +MILLIMETRES_PER_INCH = 25.4 +BLENDER_SCALE_LENGTH = 1.0 / MILLIMETRES_PER_METRE diff --git a/hardware/shared/dimensions/validation.py b/hardware/shared/dimensions/validation.py new file mode 100644 index 00000000..5e0c7417 --- /dev/null +++ b/hardware/shared/dimensions/validation.py @@ -0,0 +1,314 @@ +"""Cross-part physical validation and dimension summaries.""" + +from math import isclose + +from shared.panel_buttons import PANEL_BUTTONS + +from .board import ( + BOARD_FORMAT, + BOARD_SQUARES, + COMPACT_BOARD_MAX_HEIGHT_MM, + COMPACT_BOARD_MAX_SPAN_MM, + COMPACT_BOARD_MIN_SPAN_MM, + COMPACT_SQUARE_MAX_MM, + COMPACT_SQUARE_MIN_MM, + EXPANDER_BODY_MM, + EXPANDER_POSITIONS_BY_BANK_MM, + GRID_COUNT, + HALL_SENSOR_BODY_MM, + HALL_SENSOR_HEIGHT_MM, + HALL_SENSOR_STANDOFF_MM, + LED_EMITTER_WINDOW_MM, + LED_PACKAGE_CLEARANCE_PER_SIDE_MM, + LED_PACKAGE_MAX_SIZE_MM, + LED_PACKAGE_TOLERANCE_MM, + LED_POSITION_MM, + PANEL_STRIP_DEPTH_MM, + PCB_SIZE_MM, + PCB_THICKNESS_MM, + PLAYING_SPAN_MM, + SQUARE_SIZE_MM, +) +from .case import ( + CASE_DEPTH_MM, + CASE_FLOOR_MM, + CASE_FRAME_WIDTH_MM, + CASE_HEIGHT_MM, + CASE_PLATE_LEDGE_MM, + CASE_WALL_MM, + CASE_WIDTH_MM, + PCB_SUPPORT_BOSS_DIAMETER_MM, + PCB_SUPPORT_PILOT_DEPTH_MM, + PCB_SUPPORT_PILOT_DIAMETER_MM, + PCB_SUPPORT_POSITIONS_MM, + PCB_TO_PLATE_GAP_MM, + PI_BAY_HEIGHT_MM, + PI_BOARD_SIZE_MM, + PI_CLEARANCE_MM, + PI_HEADER_HEIGHT_MM, +) +from .panel import ( + PANEL_BUTTON_COUNT, + PANEL_BUTTON_HOLE_DIAMETER_MM, + PANEL_OLED_CENTER_MM, + PANEL_OLED_MODULE_MM, + PANEL_OLED_RECESS_CLEARANCE_XY_MM, + PANEL_OLED_RECESS_MM, + PANEL_OLED_WINDOW_MM, +) +from .printing import ( + FDM_MAX_FIT_CLEARANCE_MM, + FDM_MIN_FEATURE_MM, + FDM_MIN_FIT_CLEARANCE_MM, + FDM_MIN_FLOOR_MM, + PRINTED_PART_SIZES_MM, + REFERENCE_SERVICE_BUILD_VOLUME_MM, + fits_build_volume, + meets, +) +from .tile_plate import ( + TILE_PLATE_CLEARANCE_MM, + TILE_PLATE_DARK_SQUARE_DEPTH_MM, + TILE_PLATE_DIFFUSER_SKIN_MM, + TILE_PLATE_GROOVE_WIDTH_MM, + TILE_PLATE_LED_POCKET_MM, + TILE_PLATE_RIB_WIDTH_MM, + TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM, + TILE_PLATE_SCREW_HEAD_DEPTH_MM, + TILE_PLATE_SCREW_HEAD_DIAMETER_MM, + TILE_PLATE_SCREW_POSITIONS_MM, + TILE_PLATE_SPAN_MM, + TILE_PLATE_THICKNESS_MM, + TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM, + TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM, +) +from .units import ( + MILLIMETRES_PER_INCH, +) + + +def validate() -> None: + """Reject dimension sets that are inconsistent or physically implausible.""" + if GRID_COUNT != 8: + raise ValueError("A chessboard must contain eight rows and eight columns") + if BOARD_FORMAT != "compact electronic": + raise ValueError("Review all scale guardrails when changing the board format") + if not COMPACT_SQUARE_MIN_MM <= SQUARE_SIZE_MM <= COMPACT_SQUARE_MAX_MM: + raise ValueError("Square size is outside the compact-board design range") + if not isclose(PLAYING_SPAN_MM, SQUARE_SIZE_MM * GRID_COUNT): + raise ValueError("Playing span must equal square size multiplied by grid count") + for span in (CASE_WIDTH_MM, CASE_DEPTH_MM): + if not COMPACT_BOARD_MIN_SPAN_MM <= span <= COMPACT_BOARD_MAX_SPAN_MM: + raise ValueError("Case span is outside the compact product range") + if CASE_HEIGHT_MM > COMPACT_BOARD_MAX_HEIGHT_MM: + raise ValueError("Finished board is too tall for the compact product range") + if not isclose(CASE_WIDTH_MM, PLAYING_SPAN_MM + 2.0 * CASE_FRAME_WIDTH_MM): + raise ValueError("Case width must include the printed frame on both sides") + if not isclose( + CASE_DEPTH_MM, + PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM + 2.0 * CASE_FRAME_WIDTH_MM, + ): + raise ValueError("Case depth must include the control strip and the frame") + + # The internal stack has to add up, or the plate will not sit flush. + if not isclose( + CASE_HEIGHT_MM, + TILE_PLATE_THICKNESS_MM + + PCB_TO_PLATE_GAP_MM + + PCB_THICKNESS_MM + + PI_BAY_HEIGHT_MM + + CASE_FLOOR_MM, + ): + raise ValueError("Case height must equal the sum of the internal stack") + if PI_BAY_HEIGHT_MM < PI_HEADER_HEIGHT_MM + PI_BOARD_SIZE_MM[2] + PI_CLEARANCE_MM: + raise ValueError( + "Cavity below the board is too shallow for the Pi on its header" + ) + tallest_playing_area_component = max( + HALL_SENSOR_HEIGHT_MM + HALL_SENSOR_STANDOFF_MM, + LED_PACKAGE_MAX_SIZE_MM[2], + EXPANDER_BODY_MM[2], + ) + if PCB_TO_PLATE_GAP_MM < tallest_playing_area_component: + raise ValueError("Plate would foul a component in the playing area") + + if ( + not FDM_MIN_FIT_CLEARANCE_MM + <= TILE_PLATE_CLEARANCE_MM + <= (FDM_MAX_FIT_CLEARANCE_MM) + ): + raise ValueError("Tile plate clearance is outside the prototype fit range") + + minimum_features = { + "case wall": CASE_WALL_MM, + "case floor": CASE_FLOOR_MM, + "case frame": CASE_FRAME_WIDTH_MM, + "plate thickness": TILE_PLATE_THICKNESS_MM, + "plate diffuser skin": TILE_PLATE_DIFFUSER_SKIN_MM, + "plate rib": TILE_PLATE_RIB_WIDTH_MM, + "plate pocket floor": TILE_PLATE_THICKNESS_MM + - TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM + - TILE_PLATE_DARK_SQUARE_DEPTH_MM, + "plate groove": TILE_PLATE_GROOVE_WIDTH_MM, + "support boss wall": ( + PCB_SUPPORT_BOSS_DIAMETER_MM - PCB_SUPPORT_PILOT_DIAMETER_MM + ) + / 2.0, + } + for name, dimension in minimum_features.items(): + minimum = FDM_MIN_FLOOR_MM if name.endswith("floor") else FDM_MIN_FEATURE_MM + if not meets(dimension, minimum): + raise ValueError(f"{name} is below the prototype printable minimum") + + if TILE_PLATE_LED_POCKET_MM[2] >= TILE_PLATE_THICKNESS_MM: + raise ValueError("LED pocket must leave a closed diffuser skin") + if TILE_PLATE_UNDERSIDE_POCKET_DEPTH_MM >= ( + TILE_PLATE_THICKNESS_MM - FDM_MIN_FLOOR_MM + ): + raise ValueError("Underside pockets must leave a printable plate floor") + if TILE_PLATE_UNDERSIDE_POCKET_SPAN_MM <= HALL_SENSOR_BODY_MM[0]: + raise ValueError("Underside pocket must span the Hall sensor it clears") + if TILE_PLATE_DARK_SQUARE_DEPTH_MM >= TILE_PLATE_DIFFUSER_SKIN_MM: + raise ValueError("Dark-square recess must not reach the diffuser skin") + required_led_pocket_xy = tuple( + package + 2.0 * LED_PACKAGE_CLEARANCE_PER_SIDE_MM + for package in LED_PACKAGE_MAX_SIZE_MM[:2] + ) + if any( + pocket < required + for pocket, required in zip( + TILE_PLATE_LED_POCKET_MM[:2], required_led_pocket_xy + ) + ): + raise ValueError("LED pocket does not clear the maximum 5050 package") + if any( + pocket < emitter + LED_PACKAGE_TOLERANCE_MM + for pocket, emitter in zip(TILE_PLATE_LED_POCKET_MM[:2], LED_EMITTER_WINDOW_MM) + ): + raise ValueError("LED pocket does not clear the emitter window") + if not meets( + TILE_PLATE_DIFFUSER_SKIN_MM - TILE_PLATE_DARK_SQUARE_DEPTH_MM, + FDM_MIN_FEATURE_MM, + ): + raise ValueError( + "A dark square over an LED pocket would leave too thin a diffuser" + ) + if TILE_PLATE_SCREW_HEAD_DIAMETER_MM <= TILE_PLATE_SCREW_CLEARANCE_DIAMETER_MM: + raise ValueError("Screw head recess must be wider than its clearance hole") + if TILE_PLATE_SCREW_HEAD_DEPTH_MM >= TILE_PLATE_THICKNESS_MM: + raise ValueError("Screw head recess must leave a bearing shoulder") + + # Every plate screw has to land on the case ledge. Anywhere inboard of it is + # over the PCB, and a boss there would collide with the board. + ledge_inner = PLAYING_SPAN_MM / 2.0 - CASE_PLATE_LEDGE_MM + ledge_outer = PLAYING_SPAN_MM / 2.0 + screw_radius = TILE_PLATE_SCREW_HEAD_DIAMETER_MM / 2.0 + for screw_x, screw_y in TILE_PLATE_SCREW_POSITIONS_MM: + reach = max(abs(screw_x), abs(screw_y)) + if not ledge_inner + screw_radius <= reach <= ledge_outer - screw_radius: + raise ValueError("A tile plate screw does not land on the case ledge") + if len(set(TILE_PLATE_SCREW_POSITIONS_MM)) != len(TILE_PLATE_SCREW_POSITIONS_MM): + raise ValueError("Tile plate screw positions must be unique") + if CASE_PLATE_LEDGE_MM <= TILE_PLATE_CLEARANCE_MM: + raise ValueError("The plate ledge must be wider than the plate's own clearance") + + # Per-square features must stay inside their own square, or two squares + # would light or sense as one. + half = SQUARE_SIZE_MM / 2.0 + for axis, position in enumerate(LED_POSITION_MM): + margin = half - abs(position) - TILE_PLATE_LED_POCKET_MM[axis] / 2.0 + if margin < FDM_MIN_FEATURE_MM: + raise ValueError("LED pocket crosses into the neighbouring square") + if HALL_SENSOR_BODY_MM[0] > SQUARE_SIZE_MM - 2.0 * FDM_MIN_FEATURE_MM: + raise ValueError("Hall sensor body does not fit within one square") + + # Support bosses stand on the grid lines; nothing else may be there. + boss_radius = PCB_SUPPORT_BOSS_DIAMETER_MM / 2.0 + for boss_x, boss_y in PCB_SUPPORT_POSITIONS_MM: + for led_x, led_y in (square.led_position_mm for square in BOARD_SQUARES): + gap = max(abs(boss_x - led_x), abs(boss_y - led_y)) + if gap < boss_radius + TILE_PLATE_LED_POCKET_MM[0] / 2.0: + raise ValueError("A support boss collides with an LED position") + for sensor_x, sensor_y in (square.hall_position_mm for square in BOARD_SQUARES): + if ( + abs(boss_x - sensor_x) < boss_radius + HALL_SENSOR_BODY_MM[0] / 2.0 + and abs(boss_y - sensor_y) < boss_radius + HALL_SENSOR_BODY_MM[1] / 2.0 + ): + raise ValueError("A support boss collides with a Hall sensor") + if len(set(PCB_SUPPORT_POSITIONS_MM)) != len(PCB_SUPPORT_POSITIONS_MM): + raise ValueError("Support boss positions must be unique") + if PCB_SUPPORT_PILOT_DEPTH_MM >= PCB_SUPPORT_BOSS_DIAMETER_MM * 3.0: + raise ValueError("Support pilot is too deep for its boss") + + # Control panel features must stay on the control strip. + strip_min_y = -PLAYING_SPAN_MM / 2.0 - PANEL_STRIP_DEPTH_MM + strip_max_y = -PLAYING_SPAN_MM / 2.0 + panel_features: tuple[tuple[str, float, float, float, float], ...] = tuple( + ( + "button", + x, + y, + PANEL_BUTTON_HOLE_DIAMETER_MM / 2.0, + PANEL_BUTTON_HOLE_DIAMETER_MM / 2.0, + ) + for x, y in (button.position_mm for button in PANEL_BUTTONS) + ) + ( + ( + "display", + PANEL_OLED_CENTER_MM[0], + PANEL_OLED_CENTER_MM[1], + PANEL_OLED_RECESS_MM[0] / 2.0, + PANEL_OLED_RECESS_MM[1] / 2.0, + ), + ) + for name, x, y, half_x, half_y in panel_features: + if not strip_min_y <= y - half_y and y + half_y <= strip_max_y: + raise ValueError(f"Control panel {name} extends off the control strip") + if abs(x) + half_x > PLAYING_SPAN_MM / 2.0: + raise ValueError(f"Control panel {name} extends off the board") + if len(PANEL_BUTTONS) != PANEL_BUTTON_COUNT: + raise ValueError("Control panel must place every button") + if len({button.position_mm for button in PANEL_BUTTONS}) != PANEL_BUTTON_COUNT: + raise ValueError("Button positions must be unique") + if PANEL_OLED_RECESS_CLEARANCE_XY_MM <= 0.0: + raise ValueError("Display recess must include positive XY assembly clearance") + if any( + recess <= module + for recess, module in zip(PANEL_OLED_RECESS_MM, PANEL_OLED_MODULE_MM[:2]) + ): + raise ValueError("Display recess must be larger than the module") + if any( + window >= module + for window, module in zip(PANEL_OLED_WINDOW_MM, PANEL_OLED_MODULE_MM[:2]) + ): + raise ValueError("Display window must be smaller than the module behind it") + + if len(EXPANDER_POSITIONS_BY_BANK_MM) != 8: + raise ValueError("Board composition must place eight GPIO expanders") + if len(set(EXPANDER_POSITIONS_BY_BANK_MM.values())) != 8: + raise ValueError("GPIO expander positions must be unique") + + BOARD_SQUARES.validate_topology() + + if not isclose(PCB_SIZE_MM[0], PLAYING_SPAN_MM): + raise ValueError("Board must be exactly as wide as the playing area") + if not isclose(PCB_SIZE_MM[1], PLAYING_SPAN_MM + PANEL_STRIP_DEPTH_MM): + raise ValueError("Board must carry the playing area and the control strip") + + for part_size in PRINTED_PART_SIZES_MM: + if not fits_build_volume(part_size, REFERENCE_SERVICE_BUILD_VOLUME_MM): + raise ValueError( + "A printed part exceeds the print-service reference volume" + ) + + +def describe(domain: str = "Shared hardware") -> str: + """Return a compact summary suitable for domain validation commands.""" + return ( + f"{domain} dimensions valid: " + f"{GRID_COUNT} x {SQUARE_SIZE_MM:g} mm = {PLAYING_SPAN_MM:g} mm playing span; " + f"case {CASE_WIDTH_MM:g} x {CASE_DEPTH_MM:g} x {CASE_HEIGHT_MM:g} mm " + f"({CASE_DEPTH_MM / MILLIMETRES_PER_INCH:.1f} in deep); " + f"plate {TILE_PLATE_SPAN_MM:g} mm; " + f"board {PCB_SIZE_MM[0]:g} x {PCB_SIZE_MM[1]:g} mm" + ) diff --git a/hardware/shared/panel.py b/hardware/shared/panel.py deleted file mode 100644 index 9772dc3f..00000000 --- a/hardware/shared/panel.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Stable physical, electrical, and routing identities for panel buttons.""" - -from __future__ import annotations - -from collections.abc import Iterator -from dataclasses import dataclass - -Point = tuple[float, float] - - -@dataclass(frozen=True, slots=True) -class PanelButton: - name: str - gpio: int - position_mm: Point - switch_reference: str - routing_priority: int - header_launch_x_offset_mm: float - fallback_layer_index: int - - @property - def net_name(self) -> str: - return f"BTN_{self.name}" - - @property - def x_mm(self) -> float: - return self.position_mm[0] - - @property - def y_mm(self) -> float: - return self.position_mm[1] - - -@dataclass(frozen=True, slots=True) -class PanelLayout: - buttons: tuple[PanelButton, ...] - - def __iter__(self) -> Iterator[PanelButton]: - return iter(self.buttons) - - def __len__(self) -> int: - return len(self.buttons) - - def by_name(self, name: str) -> PanelButton: - try: - return next(button for button in self if button.name == name) - except StopIteration as error: - raise KeyError(name) from error - - @property - def routing_order(self) -> tuple[PanelButton, ...]: - return tuple(sorted(self, key=lambda button: button.routing_priority)) - - def validate(self) -> None: - if len(self) != 12: - raise ValueError("Control panel must contain twelve buttons") - for values, label in ( - ({button.name for button in self}, "names"), - ({button.gpio for button in self}, "GPIOs"), - ({button.position_mm for button in self}, "positions"), - ({button.switch_reference for button in self}, "switch references"), - ): - if len(values) != len(self): - raise ValueError(f"Panel button {label} must be unique") - if {button.routing_priority for button in self} != set(range(len(self))): - raise ValueError("Panel button routing priorities must be contiguous") - if any(button.fallback_layer_index not in range(3) for button in self): - raise ValueError("Panel fallback layer indices must select layers 0-2") - - -PANEL_BUTTONS = PanelLayout( - ( - PanelButton("UP", 5, (0.0, -172.0), "SW1", 11, 0.8, 2), - PanelButton("DOWN", 6, (16.0, -172.0), "SW2", 10, -0.8, 1), - PanelButton("LEFT", 12, (32.0, -172.0), "SW3", 9, 0.8, 0), - PanelButton("RIGHT", 13, (48.0, -172.0), "SW4", 8, -0.8, 2), - PanelButton("OK", 16, (64.0, -172.0), "SW5", 7, 0.8, 1), - PanelButton("RESET", 17, (80.0, -172.0), "SW6", 3, 0.8, 0), - PanelButton("PASS", 19, (0.0, -188.0), "SW7", 4, -0.8, 1), - PanelButton("F1", 20, (16.0, -188.0), "SW8", 5, 0.8, 0), - PanelButton("F2", 21, (32.0, -188.0), "SW9", 6, -0.8, 0), - PanelButton("F3", 22, (48.0, -188.0), "SW10", 0, 0.8, 2), - PanelButton("F4", 23, (64.0, -188.0), "SW11", 1, 0.8, 1), - PanelButton("F5", 24, (80.0, -188.0), "SW12", 2, -0.8, 2), - ) -) -PANEL_BUTTONS.validate() diff --git a/hardware/shared/panel_buttons.py b/hardware/shared/panel_buttons.py new file mode 100644 index 00000000..15b939b7 --- /dev/null +++ b/hardware/shared/panel_buttons.py @@ -0,0 +1,190 @@ +"""Stable physical, electrical, and routing identities for panel buttons.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +from shared.electronics.raspberry_pi_header import RaspberryPiHeaderPin + +Point = tuple[float, float] + + +@dataclass(frozen=True, slots=True) +class PanelButton: + name: str + position_mm: Point + switch_reference: str + routing_priority: int + header_launch_x_offset_mm: float + fallback_layer_index: int + header_pin: RaspberryPiHeaderPin + + @property + def gpio(self) -> int: + return int(self.header_pin.name.rsplit("GPIO", maxsplit=1)[1]) + + @property + def net_name(self) -> str: + return f"BTN_{self.name}" + + @property + def x_mm(self) -> float: + return self.position_mm[0] + + @property + def y_mm(self) -> float: + return self.position_mm[1] + + +@dataclass(frozen=True, slots=True) +class PanelLayout: + buttons: tuple[PanelButton, ...] + + def __iter__(self) -> Iterator[PanelButton]: + return iter(self.buttons) + + def __len__(self) -> int: + return len(self.buttons) + + def by_name(self, name: str) -> PanelButton: + try: + return next(button for button in self if button.name == name) + except StopIteration as error: + raise KeyError(name) from error + + @property + def routing_order(self) -> tuple[PanelButton, ...]: + return tuple(sorted(self, key=lambda button: button.routing_priority)) + + def validate(self) -> None: + if len(self) != 12: + raise ValueError("Control panel must contain twelve buttons") + for values, label in ( + ({button.name for button in self}, "names"), + ({button.gpio for button in self}, "GPIOs"), + ({button.header_pin for button in self}, "header pins"), + ({button.position_mm for button in self}, "positions"), + ({button.switch_reference for button in self}, "switch references"), + ): + if len(values) != len(self): + raise ValueError(f"Panel button {label} must be unique") + if {button.routing_priority for button in self} != set(range(len(self))): + raise ValueError("Panel button routing priorities must be contiguous") + if any(button.fallback_layer_index not in range(3) for button in self): + raise ValueError("Panel fallback layer indices must select layers 0-2") + + +PANEL_BUTTONS = PanelLayout( + ( + PanelButton( + name="UP", + position_mm=(0.0, -172.0), + switch_reference="SW1", + routing_priority=11, + header_launch_x_offset_mm=0.8, + fallback_layer_index=2, + header_pin=RaspberryPiHeaderPin.BUTTON_UP_GPIO5, + ), + PanelButton( + name="DOWN", + position_mm=(16.0, -172.0), + switch_reference="SW2", + routing_priority=10, + header_launch_x_offset_mm=-0.8, + fallback_layer_index=1, + header_pin=RaspberryPiHeaderPin.BUTTON_DOWN_GPIO6, + ), + PanelButton( + name="LEFT", + position_mm=(32.0, -172.0), + switch_reference="SW3", + routing_priority=9, + header_launch_x_offset_mm=0.8, + fallback_layer_index=0, + header_pin=RaspberryPiHeaderPin.BUTTON_LEFT_GPIO12, + ), + PanelButton( + name="RIGHT", + position_mm=(48.0, -172.0), + switch_reference="SW4", + routing_priority=8, + header_launch_x_offset_mm=-0.8, + fallback_layer_index=2, + header_pin=RaspberryPiHeaderPin.BUTTON_RIGHT_GPIO13, + ), + PanelButton( + name="OK", + position_mm=(64.0, -172.0), + switch_reference="SW5", + routing_priority=7, + header_launch_x_offset_mm=0.8, + fallback_layer_index=1, + header_pin=RaspberryPiHeaderPin.BUTTON_OK_GPIO16, + ), + PanelButton( + name="RESET", + position_mm=(80.0, -172.0), + switch_reference="SW6", + routing_priority=3, + header_launch_x_offset_mm=0.8, + fallback_layer_index=0, + header_pin=RaspberryPiHeaderPin.BUTTON_RESET_GPIO17, + ), + PanelButton( + name="PASS", + position_mm=(0.0, -188.0), + switch_reference="SW7", + routing_priority=4, + header_launch_x_offset_mm=-0.8, + fallback_layer_index=1, + header_pin=RaspberryPiHeaderPin.BUTTON_PASS_GPIO19, + ), + PanelButton( + name="F1", + position_mm=(16.0, -188.0), + switch_reference="SW8", + routing_priority=5, + header_launch_x_offset_mm=0.8, + fallback_layer_index=0, + header_pin=RaspberryPiHeaderPin.BUTTON_F1_GPIO20, + ), + PanelButton( + name="F2", + position_mm=(32.0, -188.0), + switch_reference="SW9", + routing_priority=6, + header_launch_x_offset_mm=-0.8, + fallback_layer_index=0, + header_pin=RaspberryPiHeaderPin.BUTTON_F2_GPIO21, + ), + PanelButton( + name="F3", + position_mm=(48.0, -188.0), + switch_reference="SW10", + routing_priority=0, + header_launch_x_offset_mm=0.8, + fallback_layer_index=2, + header_pin=RaspberryPiHeaderPin.BUTTON_F3_GPIO22, + ), + PanelButton( + name="F4", + position_mm=(64.0, -188.0), + switch_reference="SW11", + routing_priority=1, + header_launch_x_offset_mm=0.8, + fallback_layer_index=1, + header_pin=RaspberryPiHeaderPin.BUTTON_F4_GPIO23, + ), + PanelButton( + name="F5", + position_mm=(80.0, -188.0), + switch_reference="SW12", + routing_priority=2, + header_launch_x_offset_mm=-0.8, + fallback_layer_index=2, + header_pin=RaspberryPiHeaderPin.BUTTON_F5_GPIO24, + ), + ) +) +PANEL_BUTTONS.validate() diff --git a/hardware/shared/pyrightconfig.json b/hardware/shared/pyrightconfig.json index cb2fdbc9..31c55bf9 100644 --- a/hardware/shared/pyrightconfig.json +++ b/hardware/shared/pyrightconfig.json @@ -1,6 +1,8 @@ { "include": [ "*.py", + "components", + "dimensions", "electronics", "tests" ], diff --git a/hardware/shared/tests/test_panel.py b/hardware/shared/tests/test_panel_buttons.py similarity index 61% rename from hardware/shared/tests/test_panel.py rename to hardware/shared/tests/test_panel_buttons.py index 17ffd598..ba17a440 100644 --- a/hardware/shared/tests/test_panel.py +++ b/hardware/shared/tests/test_panel_buttons.py @@ -1,8 +1,10 @@ """Golden tests for the unified control-panel button contract.""" import unittest +from dataclasses import replace -from shared.panel import PANEL_BUTTONS +from shared.electronics.raspberry_pi_header import RaspberryPiHeaderPin +from shared.panel_buttons import PANEL_BUTTONS class PanelLayoutTest(unittest.TestCase): @@ -56,3 +58,27 @@ def test_all_button_records_are_stable(self) -> None: [button.net_name for button in PANEL_BUTTONS], [f"BTN_{record[0]}" for record in expected], ) + + def test_gpio_follows_typed_header_pin(self) -> None: + button = replace( + PANEL_BUTTONS.by_name("UP"), + header_pin=RaspberryPiHeaderPin.BUTTON_DOWN_GPIO6, + ) + self.assertEqual(button.gpio, 6) + + def test_button_header_pins_are_explicit(self) -> None: + expected = ( + RaspberryPiHeaderPin.BUTTON_UP_GPIO5, + RaspberryPiHeaderPin.BUTTON_DOWN_GPIO6, + RaspberryPiHeaderPin.BUTTON_LEFT_GPIO12, + RaspberryPiHeaderPin.BUTTON_RIGHT_GPIO13, + RaspberryPiHeaderPin.BUTTON_OK_GPIO16, + RaspberryPiHeaderPin.BUTTON_RESET_GPIO17, + RaspberryPiHeaderPin.BUTTON_PASS_GPIO19, + RaspberryPiHeaderPin.BUTTON_F1_GPIO20, + RaspberryPiHeaderPin.BUTTON_F2_GPIO21, + RaspberryPiHeaderPin.BUTTON_F3_GPIO22, + RaspberryPiHeaderPin.BUTTON_F4_GPIO23, + RaspberryPiHeaderPin.BUTTON_F5_GPIO24, + ) + self.assertEqual(tuple(button.header_pin for button in PANEL_BUTTONS), expected) diff --git a/hardware/shared/tests/test_wiring.py b/hardware/shared/tests/test_wiring.py index 657206f9..1b2b273d 100644 --- a/hardware/shared/tests/test_wiring.py +++ b/hardware/shared/tests/test_wiring.py @@ -43,6 +43,14 @@ def test_compact_banks_cover_all_inputs_and_addresses(self): bank.index, ) + def test_led_link_nets_identify_both_squares(self): + left = SquarePosition.parse("A1") + right = SquarePosition.parse("B1") + self.assertEqual( + wiring.led_link_nets(left, right), + ("LED_DATA_A1_TO_B1", "LED_CLOCK_A1_TO_B1"), + ) + def test_square_mappings_are_bijective(self): positions = [ SquarePosition(file_index, rank) diff --git a/hardware/shared/wiring.py b/hardware/shared/wiring.py index 43533b36..fca2cd6e 100644 --- a/hardware/shared/wiring.py +++ b/hardware/shared/wiring.py @@ -10,7 +10,7 @@ from .dimensions import HALL_BANKS from .hall_banks import HallBank, SquarePosition -from .panel import PANEL_BUTTONS +from .panel_buttons import PANEL_BUTTONS # --- I2C bus ---------------------------------------------------------------- # Eight compact Hall banks share the bus with the display. Acquisition is polled. @@ -26,6 +26,13 @@ LED_DATA_NET = "LED_DATA_5V" LED_CLOCK_NET = "LED_CLK_5V" + +def led_link_nets(left: SquarePosition, right: SquarePosition) -> tuple[str, str]: + """Name the data and clock link by its two physical squares.""" + link = f"{left.name}_TO_{right.name}" + return (f"LED_DATA_{link}", f"LED_CLOCK_{link}") + + # --- Pi line assignment ----------------------------------------------------- SDA_GPIO = 2 SCL_GPIO = 3