breadkit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codespellignore +2 -0
- data/.yamllint +16 -0
- data/README.md +109 -6
- data/data/boards/double_full.yml +18 -0
- data/data/boards/stripboard.yml +9 -0
- data/data/boards/universal.yml +8 -0
- data/data/parts/2n3904.yml +11 -0
- data/data/parts/2n7000_onsemi_to92.yml +10 -0
- data/data/parts/74hc00.yml +22 -0
- data/data/parts/74hc04.yml +22 -0
- data/data/parts/74hc08.yml +22 -0
- data/data/parts/74hc14.yml +22 -0
- data/data/parts/74hc165.yml +24 -0
- data/data/parts/74hc32.yml +22 -0
- data/data/parts/74hc595.yml +24 -0
- data/data/parts/active_buzzer.yml +8 -0
- data/data/parts/ams1117_3v3.yml +12 -0
- data/data/parts/arduino_nano.yml +70 -0
- data/data/parts/arduino_uno.yml +29 -26
- data/data/parts/atmega328p.yml +37 -0
- data/data/parts/attiny85.yml +16 -0
- data/data/parts/battery_box.yml +8 -0
- data/data/parts/cd4017.yml +24 -0
- data/data/parts/ck_bd04.yml +29 -0
- data/data/parts/crystal.yml +7 -0
- data/data/parts/dc_jack_2wire.yml +8 -0
- data/data/parts/diode.yml +1 -0
- data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
- data/data/parts/irlz44n.yml +10 -0
- data/data/parts/l293d.yml +24 -0
- data/data/parts/led.yml +1 -0
- data/data/parts/lm358.yml +16 -0
- data/data/parts/lm393.yml +16 -0
- data/data/parts/ne555.yml +2 -2
- data/data/parts/ntc_thermistor.yml +7 -0
- data/data/parts/photoresistor.yml +8 -0
- data/data/parts/pico.yml +89 -0
- data/data/parts/resistor.yml +1 -0
- data/data/parts/sc56_11ewa.yml +30 -0
- data/data/parts/seeed_xiao_samd21.yml +37 -0
- data/data/parts/shillehtek_mb102_4pin.yml +23 -0
- data/data/parts/slide_switch_spst.yml +8 -0
- data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
- data/data/parts/transistor.yml +0 -1
- data/data/parts/ua7805.yml +10 -0
- data/data/parts/uln2003a.yml +23 -0
- data/data/parts/wp154a4sureqbfzgc.yml +11 -0
- data/docs/CLI.md +108 -0
- data/docs/COMPATIBILITY.md +60 -0
- data/docs/DECLARATIVE.md +138 -0
- data/docs/IR.md +42 -0
- data/docs/JUMPER_KIT.md +45 -0
- data/docs/LOCK.md +75 -0
- data/docs/LSP.md +59 -0
- data/docs/MB102_POWER.md +11 -0
- data/docs/MCP.md +44 -0
- data/docs/RAKE.md +19 -0
- data/docs/dsl.md +197 -6
- data/exe/breadkit-lsp +13 -0
- data/exe/breadkit-mcp +12 -0
- data/lib/breadkit/analysis.rb +174 -50
- data/lib/breadkit/board.rb +152 -4
- data/lib/breadkit/cli.rb +229 -13
- data/lib/breadkit/color.rb +38 -0
- data/lib/breadkit/dc_analysis.rb +261 -0
- data/lib/breadkit/dsl.rb +205 -24
- data/lib/breadkit/exporters.rb +202 -0
- data/lib/breadkit/formatter.rb +52 -0
- data/lib/breadkit/fritzing_export.rb +256 -0
- data/lib/breadkit/ir.rb +154 -22
- data/lib/breadkit/jumper_kit.rb +127 -0
- data/lib/breadkit/lsp_server.rb +248 -0
- data/lib/breadkit/mcp_server.rb +133 -0
- data/lib/breadkit/model.rb +47 -5
- data/lib/breadkit/part_library.rb +156 -3
- data/lib/breadkit/part_lock.rb +107 -0
- data/lib/breadkit/patterns.rb +119 -0
- data/lib/breadkit/rake_task.rb +25 -0
- data/lib/breadkit/resolver.rb +160 -28
- data/lib/breadkit/structured_input.rb +275 -0
- data/lib/breadkit/templates/555.bk.rb +34 -0
- data/lib/breadkit/templates/arduino.bk.rb +17 -0
- data/lib/breadkit/templates/led.bk.rb +21 -0
- data/lib/breadkit/value.rb +42 -3
- data/lib/breadkit/version.rb +1 -1
- data/lib/breadkit/wire_suggestions.rb +106 -0
- data/lib/breadkit.rb +28 -3
- data/package.json +1 -1
- data/schema/board-v1.json +38 -0
- data/schema/ir-v1.json +56 -11
- data/schema/ir-v2.json +173 -0
- data/schema/part-v1.json +52 -4
- data/sig/breadkit.rbs +107 -5
- metadata +119 -14
- data/docs/assets/01_led_button.svg +0 -12
- data/site/assets/01_led_button.svg +0 -14
- data/site/assets/03_arduino_blink.svg +0 -14
- data/site/favicon.svg +0 -7
- data/site/guide/components/index.html +0 -113
- data/site/guide/dsl/index.html +0 -132
- data/site/guide/index.html +0 -163
- data/site/index.html +0 -95
- data/site/styles.css +0 -14
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../breadkit"
|
|
5
|
+
|
|
6
|
+
module Breadkit
|
|
7
|
+
class MCPServer
|
|
8
|
+
EXTENSIONS = %w[.bk.yml .bk.yaml .bk.toml .json].freeze
|
|
9
|
+
MAX_INPUT_BYTES = 8 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
def initialize(root: Dir.pwd)
|
|
12
|
+
@root = File.realpath(root)
|
|
13
|
+
raise ArgumentError, "MCP root must be a directory" unless File.directory?(@root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def server
|
|
17
|
+
@server ||= build_server
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
MCP::Server::Transports::StdioTransport.new(server).open
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_server
|
|
27
|
+
result = MCP::Server.new(name: "breadkit", version: VERSION,
|
|
28
|
+
instructions: "Read-only circuit tools. Inputs must be declarative YAML, TOML, or JSON IR files inside the configured root.")
|
|
29
|
+
responder, resolver, nets, valid = method(:respond), method(:resolve), method(:inspect_nets), method(:valid_circuit)
|
|
30
|
+
path_schema = { type: "object", properties: { path: { type: "string", minLength: 1 } },
|
|
31
|
+
required: ["path"], additionalProperties: false }
|
|
32
|
+
result.define_tool(name: "breadkit_resolve", description: "Resolve a circuit and report its parts, wiring, and diagnostics.",
|
|
33
|
+
input_schema: path_schema, annotations: read_only_annotations) do |path:|
|
|
34
|
+
responder.call { resolver.call(path) }
|
|
35
|
+
end
|
|
36
|
+
result.define_tool(name: "breadkit_nets", description: "List the resolved electrical nets and potentials of a circuit.",
|
|
37
|
+
input_schema: { type: "object", properties: path_schema[:properties].merge(state: { type: "string" }),
|
|
38
|
+
required: ["path"], additionalProperties: false }, annotations: read_only_annotations) do |path:, state: nil|
|
|
39
|
+
responder.call { nets.call(path, state) }
|
|
40
|
+
end
|
|
41
|
+
result.define_tool(name: "breadkit_ir", description: "Export a valid circuit as Breadkit JSON IR.",
|
|
42
|
+
input_schema: path_schema, annotations: read_only_annotations) do |path:|
|
|
43
|
+
responder.call { valid.call(path).to_ir }
|
|
44
|
+
end
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def read_only_annotations
|
|
49
|
+
{ read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def respond
|
|
53
|
+
value = yield
|
|
54
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(value) }], structured_content: value)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve(path)
|
|
60
|
+
circuit = load_circuit(path)
|
|
61
|
+
{ valid: circuit.diagnostics.none? { |item| item.severity == "error" }, title: circuit.title,
|
|
62
|
+
boards: board_summary(circuit), components: circuit.components.keys, wires: circuit.wires.length,
|
|
63
|
+
supplies: circuit.supplies.map(&:name), diagnostics: circuit.diagnostics.map { |item| diagnostic_data(item) } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inspect_nets(path, state_name)
|
|
67
|
+
circuit = valid_circuit(path)
|
|
68
|
+
state = circuit.state(state_name) unless state_name == "base"
|
|
69
|
+
{ state: state_name || "base", nets: circuit.nets(state).map do |net|
|
|
70
|
+
{ name: net.name, members: net.members, holes: net.holes, potential: net.potential }
|
|
71
|
+
end }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_circuit(path)
|
|
75
|
+
circuit = load_circuit(path)
|
|
76
|
+
errors = circuit.diagnostics.select { |item| item.severity == "error" }
|
|
77
|
+
raise DSLError, errors.map(&:message).join("; ") unless errors.empty?
|
|
78
|
+
|
|
79
|
+
circuit
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def load_circuit(path)
|
|
83
|
+
file = circuit_path(path)
|
|
84
|
+
return IR::Reader.new.read_file(file) if file.end_with?(".json")
|
|
85
|
+
|
|
86
|
+
document = StructuredInput.load_file(file)
|
|
87
|
+
(document.part_paths + document.board_paths).each { |dependency| checked_path(dependency) }
|
|
88
|
+
PartLock.verify(document, file)
|
|
89
|
+
Resolver.new.call(document)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def circuit_path(path)
|
|
93
|
+
raise ArgumentError, "path must be text" unless path.is_a?(String) && !path.empty?
|
|
94
|
+
raise ArgumentError, "MCP accepts only YAML, TOML, or JSON IR circuit files" unless EXTENSIONS.any? { |extension| path.end_with?(extension) }
|
|
95
|
+
|
|
96
|
+
file = checked_path(path)
|
|
97
|
+
unless EXTENSIONS.any? { |extension| file.end_with?(extension) }
|
|
98
|
+
raise ArgumentError, "MCP accepts only YAML, TOML, or JSON IR circuit files"
|
|
99
|
+
end
|
|
100
|
+
file
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checked_path(path)
|
|
104
|
+
expanded = File.expand_path(path, @root)
|
|
105
|
+
raise ArgumentError, "path is outside the MCP root" unless inside_root?(expanded)
|
|
106
|
+
|
|
107
|
+
real = File.realpath(expanded)
|
|
108
|
+
raise ArgumentError, "path is outside the MCP root" unless inside_root?(real)
|
|
109
|
+
raise ArgumentError, "path must be a file" unless File.file?(real)
|
|
110
|
+
raise ArgumentError, "input file exceeds 8 MiB" if File.size(real) > MAX_INPUT_BYTES
|
|
111
|
+
|
|
112
|
+
real
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inside_root?(path)
|
|
116
|
+
prefix = @root == File::SEPARATOR ? @root : "#{@root}#{File::SEPARATOR}"
|
|
117
|
+
path.start_with?(prefix)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def board_summary(circuit)
|
|
121
|
+
if circuit.multi_board?
|
|
122
|
+
circuit.boards.map { |name, board| { name: name, type: board.definition.id } }
|
|
123
|
+
else
|
|
124
|
+
[{ type: circuit.board.definition.id }]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def diagnostic_data(item)
|
|
129
|
+
location = item.location && { path: item.location.path, line: item.location.line }
|
|
130
|
+
{ code: item.code, severity: item.severity, message: item.message, targets: item.targets, location: location }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/breadkit/model.rb
CHANGED
|
@@ -1,31 +1,73 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Breadkit
|
|
4
|
+
module Geometry
|
|
5
|
+
def self.transform(offset, rotate: 0, mirror: false)
|
|
6
|
+
x, y = offset
|
|
7
|
+
x = -x if mirror
|
|
8
|
+
case rotate
|
|
9
|
+
when 90 then [-y, x]
|
|
10
|
+
when 180 then [-x, -y]
|
|
11
|
+
when 270 then [y, -x]
|
|
12
|
+
else [x, y]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
4
17
|
SourceLocation = Struct.new(:path, :line, keyword_init: true)
|
|
5
18
|
Diagnostic = Data.define(:code, :severity, :message, :location, :targets)
|
|
6
19
|
Hole = Struct.new(:id, :kind, :row, :col, :rail, :x, :y, :strip_id, keyword_init: true)
|
|
7
20
|
Strip = Struct.new(:id, :hole_ids, keyword_init: true)
|
|
8
21
|
Pin = Struct.new(:name, :number, :hole_id, :node_id, :role, keyword_init: true)
|
|
9
|
-
Component = Struct.new(:ref, :part, :value, :attrs, :pins, :unused, :location, keyword_init: true) do
|
|
22
|
+
Component = Struct.new(:ref, :part, :value, :attrs, :pins, :unused, :location, :step, keyword_init: true) do
|
|
10
23
|
def pin(reference)
|
|
11
24
|
definition = part.pin(reference)
|
|
12
25
|
pins[(definition["name"] || definition["num"]).to_s] if definition
|
|
13
26
|
end
|
|
27
|
+
|
|
28
|
+
def provided_sources
|
|
29
|
+
Array(part.data["provides"]).select do |source|
|
|
30
|
+
(source["when"] || {}).all? do |key, expected|
|
|
31
|
+
actual = attrs.key?(key.to_sym) ? attrs[key.to_sym] : attrs[key]
|
|
32
|
+
!actual.nil? && actual.to_s == expected.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def body_bounds(board)
|
|
38
|
+
render = part.data["render"] || {}
|
|
39
|
+
return unless render["size_mm"]
|
|
40
|
+
|
|
41
|
+
holes = pins.values.filter_map { |pin| board.hole(pin.hole_id) if pin.hole_id }
|
|
42
|
+
return if holes.empty?
|
|
43
|
+
|
|
44
|
+
width, height = render.fetch("size_mm").map { |value| Float(value) / 2.54 }
|
|
45
|
+
offset = (render["body_offset_mm"] || [0, 0]).map { |value| Float(value) / 2.54 }
|
|
46
|
+
rotate = attrs[:rotate] || 0
|
|
47
|
+
offset_x, offset_y = Geometry.transform(offset, rotate: rotate, mirror: attrs[:mirror] == true)
|
|
48
|
+
width, height = height, width if [90, 270].include?(rotate)
|
|
49
|
+
center_x = (holes.map(&:x).min + holes.map(&:x).max) / 2.0 + offset_x
|
|
50
|
+
center_y = (holes.map(&:y).min + holes.map(&:y).max) / 2.0 + offset_y
|
|
51
|
+
[center_x - width / 2, center_y - height / 2, width, height]
|
|
52
|
+
end
|
|
14
53
|
end
|
|
15
|
-
Wire = Struct.new(:id, :from, :to, :color, :route, :layer, :electrical, :dashed, :location, keyword_init: true)
|
|
16
|
-
Supply = Struct.new(:name, :voltage, :plus, :minus, :location, keyword_init: true)
|
|
17
|
-
Label = Struct.new(:name, :at, :location, keyword_init: true)
|
|
54
|
+
Wire = Struct.new(:id, :from, :to, :color, :route, :layer, :electrical, :dashed, :location, :step, keyword_init: true)
|
|
55
|
+
Supply = Struct.new(:name, :voltage, :plus, :minus, :location, :isolated, :voltage_range, :current_limit, :step, keyword_init: true)
|
|
56
|
+
Label = Struct.new(:name, :at, :location, :step, keyword_init: true)
|
|
18
57
|
Net = Struct.new(:name, :members, :holes, :labels, :potential, keyword_init: true)
|
|
19
58
|
|
|
20
59
|
class Document
|
|
21
60
|
attr_accessor :title, :board, :supplies, :labels, :components, :wires, :expectations,
|
|
22
|
-
:lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions
|
|
61
|
+
:lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions, :diagnostics, :source_root, :steps, :boards
|
|
23
62
|
|
|
24
63
|
def initialize
|
|
25
64
|
@title = nil
|
|
26
65
|
@board = { type: "full", options: {} }
|
|
27
66
|
@supplies, @labels, @components, @wires = [], [], [], []
|
|
28
67
|
@expectations, @lint_disables, @part_paths, @part_definitions, @board_paths, @board_definitions = [], [], [], [], [], []
|
|
68
|
+
@steps = []
|
|
69
|
+
@boards = []
|
|
70
|
+
@diagnostics = []
|
|
29
71
|
end
|
|
30
72
|
end
|
|
31
73
|
end
|
|
@@ -1,16 +1,77 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module Breadkit
|
|
4
6
|
class PartDef
|
|
5
7
|
attr_reader :data
|
|
6
8
|
|
|
9
|
+
KEYS = %w[id aliases category placement pins polarity footprint footprint_mm internal switch independent_switches same_strip_ok straddle package render flags supply_range transistor_polarity attributes required_attributes provides extends override max_reverse_voltage forward_voltage on_resistance max_forward_current max_lead_span_mm datasheet_url].freeze
|
|
10
|
+
PIN_KEYS = %w[num name aliases type role label max_voltage max_current output_capable mount].freeze
|
|
11
|
+
|
|
7
12
|
def initialize(data)
|
|
13
|
+
if data["pins"].is_a?(Array)
|
|
14
|
+
data = data.merge("pins" => data["pins"].map do |pin|
|
|
15
|
+
next pin unless pin.is_a?(Hash) && pin.key?("role")
|
|
16
|
+
raise ArgumentError, "part #{data['id']} pin #{pin['num']} has both type and role" if pin.key?("type")
|
|
17
|
+
pin.merge("type" => pin["role"]).reject { |key, _value| key == "role" }
|
|
18
|
+
end)
|
|
19
|
+
end
|
|
8
20
|
@data = data
|
|
21
|
+
unknown = data.keys - KEYS
|
|
22
|
+
raise ArgumentError, "part #{data['id']} has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
9
23
|
raise ArgumentError, "part definition needs an id" unless data["id"]
|
|
10
24
|
raise ArgumentError, "part #{data['id']} needs pins" unless data["pins"].is_a?(Array)
|
|
25
|
+
if data.key?("datasheet_url")
|
|
26
|
+
valid_url = begin
|
|
27
|
+
url = data["datasheet_url"]
|
|
28
|
+
parsed = URI.parse(url) if url.is_a?(String)
|
|
29
|
+
parsed.is_a?(URI::HTTPS) && !parsed.host.to_s.empty? && !parsed.userinfo
|
|
30
|
+
rescue URI::InvalidURIError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
raise ArgumentError, "part #{id} has invalid datasheet_url" unless valid_url
|
|
34
|
+
end
|
|
35
|
+
if data.key?("attributes")
|
|
36
|
+
attributes = data["attributes"]
|
|
37
|
+
unless attributes.is_a?(Hash) && attributes.all? { |key, schema| key.is_a?(String) &&
|
|
38
|
+
(schema == "css_color" || (schema.is_a?(Array) && !schema.empty? && schema.all? { |value| value.is_a?(String) || value.is_a?(Numeric) })) }
|
|
39
|
+
raise ArgumentError, "part #{id} has invalid attributes schema"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
if data.key?("required_attributes")
|
|
43
|
+
required = data["required_attributes"]
|
|
44
|
+
unless required.is_a?(Array) && required.uniq.length == required.length &&
|
|
45
|
+
required.all? { |key| key.is_a?(String) && data.fetch("attributes", {}).key?(key) }
|
|
46
|
+
raise ArgumentError, "part #{id} has invalid required_attributes"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
11
49
|
identities = {}
|
|
12
50
|
pins.each_with_index do |pin, index|
|
|
13
51
|
raise ArgumentError, "part #{id} has invalid pin" unless pin.is_a?(Hash) && pin["num"]
|
|
52
|
+
unknown_pin_keys = pin.keys - PIN_KEYS
|
|
53
|
+
raise ArgumentError, "part #{id} has unknown pin keys: #{unknown_pin_keys.join(', ')}" unless unknown_pin_keys.empty?
|
|
54
|
+
role = pin["type"]
|
|
55
|
+
if role && !%w[input output gpio power ground data clock interrupt address nc passive analog reset open_drain].include?(role.to_s)
|
|
56
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid type #{role}"
|
|
57
|
+
end
|
|
58
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid mount" if pin.key?("mount") && pin["mount"] != "rail"
|
|
59
|
+
unless !pin.key?("label") || pin["label"].is_a?(String) || [true, false].include?(pin["label"])
|
|
60
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid label"
|
|
61
|
+
end
|
|
62
|
+
if pin.key?("max_voltage")
|
|
63
|
+
voltage = pin["max_voltage"]
|
|
64
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid max_voltage" unless voltage.is_a?(Numeric) && voltage.finite? && voltage.positive?
|
|
65
|
+
end
|
|
66
|
+
if pin.key?("max_current")
|
|
67
|
+
current = pin["max_current"]
|
|
68
|
+
unless current.is_a?(Numeric) && current.real? && current.finite? && current.positive?
|
|
69
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid max_current"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
if pin.key?("output_capable") && ![true, false].include?(pin["output_capable"])
|
|
73
|
+
raise ArgumentError, "part #{id} pin #{pin['num']} has invalid output_capable"
|
|
74
|
+
end
|
|
14
75
|
|
|
15
76
|
([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.each do |identity|
|
|
16
77
|
key = identity.to_s.downcase
|
|
@@ -25,18 +86,82 @@ module Breadkit
|
|
|
25
86
|
raise ArgumentError, "part #{id} has invalid #{key} pin pair #{pair.inspect}" unless pair.length == 2 && pair.all? { |pin| valid_pins.include?(pin.to_s) }
|
|
26
87
|
end
|
|
27
88
|
end
|
|
89
|
+
if data.key?("independent_switches")
|
|
90
|
+
pairs = Array(data["switch"])
|
|
91
|
+
terminals = pairs.flatten.map { |reference| pin(reference).fetch("num").to_s }
|
|
92
|
+
unless data["independent_switches"] == true && !pairs.empty? && terminals.uniq.length == terminals.length
|
|
93
|
+
raise ArgumentError, "part #{id} has invalid independent_switches; contacts must use distinct pins"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
28
96
|
raise ArgumentError, "part #{id} has invalid placement" unless %w[leads dip footprint offboard].include?(placement)
|
|
97
|
+
if data.key?("max_lead_span_mm")
|
|
98
|
+
limit = data["max_lead_span_mm"]
|
|
99
|
+
unless placement == "leads" && pins.length == 2 && limit.is_a?(Numeric) && limit.real? && limit.finite? && limit.positive?
|
|
100
|
+
raise ArgumentError, "part #{id} has invalid max_lead_span_mm"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
if data["render"]
|
|
104
|
+
render = data["render"]
|
|
105
|
+
unknown_render = render.is_a?(Hash) ? render.keys - %w[shape label size_mm body_offset_mm fill stroke text_color svg] : []
|
|
106
|
+
unless render.is_a?(Hash) && unknown_render.empty? && (!render.key?("shape") || render["shape"].is_a?(String)) &&
|
|
107
|
+
(!render.key?("label") || render["label"].is_a?(String)) &&
|
|
108
|
+
(!render.key?("svg") || render["svg"].is_a?(String)) &&
|
|
109
|
+
%w[fill stroke text_color].all? { |key| !render.key?(key) || Color.valid?(render[key]) }
|
|
110
|
+
raise ArgumentError, "part #{id} has invalid render options#{": #{unknown_render.join(', ')}" unless unknown_render.empty?}"
|
|
111
|
+
end
|
|
112
|
+
if render.key?("size_mm") && !valid_mm_pair?(render["size_mm"], positive: true)
|
|
113
|
+
raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]"
|
|
114
|
+
end
|
|
115
|
+
if render.key?("body_offset_mm") && !valid_mm_pair?(render["body_offset_mm"], positive: false)
|
|
116
|
+
raise ArgumentError, "part #{id} module rendering needs finite body_offset_mm [x, y]"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
29
119
|
if data["polarity"]
|
|
30
|
-
unless data["polarity"].is_a?(Hash) && data["polarity"].
|
|
120
|
+
unless data["polarity"].is_a?(Hash) && (data["polarity"].keys - %w[positive negative]).empty? &&
|
|
121
|
+
data["polarity"].values.all? { |name| identities.key?(name.to_s.downcase) }
|
|
31
122
|
raise ArgumentError, "part #{id} has invalid polarity pin reference"
|
|
32
123
|
end
|
|
33
124
|
end
|
|
125
|
+
if data.key?("max_reverse_voltage")
|
|
126
|
+
limit = data["max_reverse_voltage"]
|
|
127
|
+
raise ArgumentError, "part #{id} has invalid max_reverse_voltage" unless limit.is_a?(Numeric) && limit.finite? && limit >= 0
|
|
128
|
+
end
|
|
129
|
+
%w[forward_voltage on_resistance max_forward_current].each do |key|
|
|
130
|
+
next unless data.key?(key)
|
|
131
|
+
|
|
132
|
+
value = data[key]
|
|
133
|
+
raise ArgumentError, "part #{id} has invalid #{key}" unless value.is_a?(Numeric) && value.finite? && value.positive?
|
|
134
|
+
end
|
|
135
|
+
Array(data["provides"]).each do |source|
|
|
136
|
+
unless source.is_a?(Hash) && %w[positive negative voltage].all? { |key| source.key?(key) } &&
|
|
137
|
+
(source.keys - %w[positive negative voltage when]).empty? &&
|
|
138
|
+
%w[positive negative].all? { |key| identities.key?(source[key].to_s.downcase) } &&
|
|
139
|
+
source["voltage"].is_a?(Numeric) && source["voltage"].finite? && source["voltage"].positive?
|
|
140
|
+
raise ArgumentError, "part #{id} has invalid voltage source"
|
|
141
|
+
end
|
|
142
|
+
conditions = source["when"]
|
|
143
|
+
if conditions && (!conditions.is_a?(Hash) || conditions.empty? || conditions.any? do |key, value|
|
|
144
|
+
choices = data.fetch("attributes", {})[key]
|
|
145
|
+
!choices.is_a?(Array) || !choices.include?(value)
|
|
146
|
+
end)
|
|
147
|
+
raise ArgumentError, "part #{id} has invalid voltage source condition"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
34
150
|
if data["footprint"]
|
|
35
151
|
numbers = pins.map { |pin| pin.fetch("num").to_s }
|
|
36
152
|
unless data["footprint"].is_a?(Hash) && data["footprint"].all? { |key, offset| numbers.include?(key.to_s) && offset.is_a?(Array) && offset.length == 2 && offset.all? { |value| value.is_a?(Integer) } }
|
|
37
153
|
raise ArgumentError, "part #{id} has invalid footprint pin reference or offset"
|
|
38
154
|
end
|
|
39
155
|
end
|
|
156
|
+
if data.key?("footprint_mm")
|
|
157
|
+
offsets = data["footprint_mm"]
|
|
158
|
+
numbers = pins.map { |pin| pin.fetch("num").to_s }
|
|
159
|
+
raise ArgumentError, "part #{id} cannot set both footprint_mm and footprint" if data.key?("footprint")
|
|
160
|
+
unless placement == "footprint" && offsets.is_a?(Hash) && offsets.keys.sort == numbers.sort &&
|
|
161
|
+
offsets.values.all? { |pair| pair.is_a?(Array) && pair.length == 2 && pair.all? { |value| grid_offset_mm?(value) } }
|
|
162
|
+
raise ArgumentError, "part #{id} has invalid footprint_mm; pin offsets must align to 2.54 mm holes"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
40
165
|
if data.dig("render", "shape") == "module"
|
|
41
166
|
size_mm = data.dig("render", "size_mm")
|
|
42
167
|
raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]" unless valid_mm_pair?(size_mm, positive: true)
|
|
@@ -59,6 +184,16 @@ module Breadkit
|
|
|
59
184
|
data.fetch("placement", "leads")
|
|
60
185
|
end
|
|
61
186
|
|
|
187
|
+
def max_lead_span_mm
|
|
188
|
+
data["max_lead_span_mm"]&.to_f
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def footprint
|
|
192
|
+
return data["footprint"] if data.key?("footprint")
|
|
193
|
+
|
|
194
|
+
data["footprint_mm"]&.transform_values { |pair| pair.map { |value| (value / 2.54).round } }
|
|
195
|
+
end
|
|
196
|
+
|
|
62
197
|
def pin(value)
|
|
63
198
|
key = value.to_s.downcase
|
|
64
199
|
pins.find do |pin|
|
|
@@ -68,6 +203,11 @@ module Breadkit
|
|
|
68
203
|
|
|
69
204
|
private
|
|
70
205
|
|
|
206
|
+
def grid_offset_mm?(value)
|
|
207
|
+
value.is_a?(Numeric) && value.real? && value.finite? &&
|
|
208
|
+
((value / 2.54) - (value / 2.54).round).abs < 1e-6
|
|
209
|
+
end
|
|
210
|
+
|
|
71
211
|
def valid_mm_pair?(values, positive:)
|
|
72
212
|
values.is_a?(Array) && values.length == 2 && values.all? do |value|
|
|
73
213
|
number = Float(value)
|
|
@@ -79,13 +219,26 @@ module Breadkit
|
|
|
79
219
|
end
|
|
80
220
|
|
|
81
221
|
class PartLibrary
|
|
222
|
+
attr_reader :warnings
|
|
223
|
+
|
|
82
224
|
def initialize(extra_paths: [], extra_definitions: [])
|
|
83
|
-
|
|
84
|
-
paths
|
|
225
|
+
builtin_paths = Dir.glob(File.expand_path("../../data/parts/*.yml", __dir__))
|
|
226
|
+
paths = builtin_paths + extra_paths.flat_map { |path| Dir.glob(path) }
|
|
85
227
|
definitions = paths.uniq.map { |path| YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) }
|
|
86
228
|
definitions.concat(extra_definitions)
|
|
87
229
|
@parts = {}
|
|
230
|
+
@warnings = []
|
|
88
231
|
definitions.each do |data|
|
|
232
|
+
if data["extends"]
|
|
233
|
+
parent = @parts[data["extends"].to_s.downcase]
|
|
234
|
+
raise ArgumentError, "unknown parent part #{data['extends']}" unless parent
|
|
235
|
+
data = parent.data.merge("aliases" => []).merge(data)
|
|
236
|
+
end
|
|
237
|
+
existing = @parts[data["id"].to_s.downcase]
|
|
238
|
+
if existing && (data["override"] || data["extends"])
|
|
239
|
+
@parts.delete_if { |_key, item| item == existing }
|
|
240
|
+
@warnings << "part #{data['id']} overrides #{existing.id}"
|
|
241
|
+
end
|
|
89
242
|
part = PartDef.new(data)
|
|
90
243
|
([part.id] + Array(data["aliases"])).each do |name|
|
|
91
244
|
key = name.to_s.downcase
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Breadkit
|
|
7
|
+
# Pins local part and board definition files used by a circuit.
|
|
8
|
+
class PartLock
|
|
9
|
+
FORMAT = 1
|
|
10
|
+
SUFFIXES = %w[.bk.rb .bk.yml .bk.yaml .bk.toml].freeze
|
|
11
|
+
|
|
12
|
+
def self.write(path)
|
|
13
|
+
circuit_path = File.expand_path(path)
|
|
14
|
+
raise ArgumentError, "lock supports .bk.rb, .bk.yml, .bk.yaml, and .bk.toml circuits" unless circuit_file?(circuit_path)
|
|
15
|
+
|
|
16
|
+
document = if circuit_path.end_with?(".bk.rb")
|
|
17
|
+
DSL.load_file(circuit_path)
|
|
18
|
+
else
|
|
19
|
+
StructuredInput.load_file(circuit_path)
|
|
20
|
+
end
|
|
21
|
+
file = lock_path(circuit_path)
|
|
22
|
+
data = File.file?(file) ? read(file) : { "version" => FORMAT, "circuits" => {} }
|
|
23
|
+
data.fetch("circuits")[File.basename(circuit_path)] = entry(document, circuit_path)
|
|
24
|
+
data["circuits"] = data.fetch("circuits").sort.to_h
|
|
25
|
+
File.write(file, "#{JSON.pretty_generate(data)}\n")
|
|
26
|
+
file
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.verify(document, path)
|
|
30
|
+
circuit_path = File.expand_path(path)
|
|
31
|
+
return unless circuit_file?(circuit_path)
|
|
32
|
+
|
|
33
|
+
file = lock_path(circuit_path)
|
|
34
|
+
return unless File.file?(file)
|
|
35
|
+
|
|
36
|
+
expected = read(file).fetch("circuits")[File.basename(circuit_path)]
|
|
37
|
+
raise DSLError, "breadkit.lock has no entry for #{File.basename(circuit_path)}; run breadkit lock FILE" unless expected
|
|
38
|
+
raise DSLError, "breadkit.lock version mismatch for #{File.basename(circuit_path)}; run breadkit lock FILE" unless expected.fetch("breadkit") == VERSION
|
|
39
|
+
|
|
40
|
+
current = entry(document, circuit_path)
|
|
41
|
+
%w[parts boards].each do |kind|
|
|
42
|
+
saved, actual = expected.fetch(kind), current.fetch(kind)
|
|
43
|
+
saved_paths, actual_paths = saved.map { |item| item.fetch("path") }, actual.map { |item| item.fetch("path") }
|
|
44
|
+
raise DSLError, "breadkit.lock definition set changed for #{kind}; run breadkit lock FILE" unless saved_paths == actual_paths
|
|
45
|
+
|
|
46
|
+
saved.zip(actual).each do |before, after|
|
|
47
|
+
next if before.fetch("sha256") == after.fetch("sha256")
|
|
48
|
+
|
|
49
|
+
raise DSLError, "breadkit.lock checksum mismatch for #{after.fetch('path')}; run breadkit lock FILE"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.lock_path(path)
|
|
55
|
+
File.join(File.dirname(File.expand_path(path)), "breadkit.lock")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.circuit_file?(path)
|
|
59
|
+
SUFFIXES.any? { |suffix| path.end_with?(suffix) }
|
|
60
|
+
end
|
|
61
|
+
private_class_method :circuit_file?
|
|
62
|
+
|
|
63
|
+
def self.entry(document, path)
|
|
64
|
+
base = Pathname.new(File.dirname(path))
|
|
65
|
+
{ "breadkit" => VERSION, "parts" => records(document.part_paths, base),
|
|
66
|
+
"boards" => records(document.board_paths, base) }
|
|
67
|
+
end
|
|
68
|
+
private_class_method :entry
|
|
69
|
+
|
|
70
|
+
def self.records(paths, base)
|
|
71
|
+
paths.uniq.map do |path|
|
|
72
|
+
absolute = Pathname.new(File.expand_path(path))
|
|
73
|
+
{ "path" => absolute.relative_path_from(base).to_s.tr("\\", "/"), "sha256" => Digest::SHA256.file(absolute).hexdigest }
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
raise DSLError, "local definitions must share a filesystem with the circuit"
|
|
76
|
+
end.sort_by { |item| item.fetch("path") }
|
|
77
|
+
end
|
|
78
|
+
private_class_method :records
|
|
79
|
+
|
|
80
|
+
def self.read(path)
|
|
81
|
+
raise DSLError, "invalid breadkit.lock: file exceeds 1 MiB" if File.size(path) > 1024 * 1024
|
|
82
|
+
|
|
83
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
84
|
+
unless data.is_a?(Hash) && data.keys.sort == %w[circuits version] && data["version"] == FORMAT && data["circuits"].is_a?(Hash)
|
|
85
|
+
raise DSLError, "invalid breadkit.lock: unsupported format"
|
|
86
|
+
end
|
|
87
|
+
data.fetch("circuits").each_value do |item|
|
|
88
|
+
unless item.is_a?(Hash) && item.keys.sort == %w[boards breadkit parts] && item["breadkit"].is_a?(String) &&
|
|
89
|
+
%w[parts boards].all? { |key| valid_records?(item[key]) }
|
|
90
|
+
raise DSLError, "invalid breadkit.lock: malformed circuit entry"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
data
|
|
94
|
+
rescue JSON::ParserError => e
|
|
95
|
+
raise DSLError, "invalid breadkit.lock: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :read
|
|
98
|
+
|
|
99
|
+
def self.valid_records?(items)
|
|
100
|
+
items.is_a?(Array) && items.all? do |item|
|
|
101
|
+
item.is_a?(Hash) && item.keys.sort == %w[path sha256] && item["path"].is_a?(String) &&
|
|
102
|
+
!item["path"].empty? && item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
private_class_method :valid_records?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Patterns
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(circuit)
|
|
8
|
+
return [] if circuit.diagnostics.any? { |item| item.severity == "error" }
|
|
9
|
+
|
|
10
|
+
voltage_divider(circuit) + ne555_astable(circuit)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def voltage_divider(circuit)
|
|
14
|
+
return [] if circuit.diagnostics.any? { |item| item.severity == "error" }
|
|
15
|
+
return [] unless circuit.supplies.length == 1 && circuit.components.length == 2
|
|
16
|
+
|
|
17
|
+
supply = circuit.supplies.first
|
|
18
|
+
return [] if supply.voltage_range || !supply.voltage.to_f.positive?
|
|
19
|
+
|
|
20
|
+
resistors = circuit.components.values
|
|
21
|
+
return [] unless resistors.all? { |component| component.part.id == "resistor" &&
|
|
22
|
+
component.pins.length == 2 && Value.parse(component.value).positive? }
|
|
23
|
+
|
|
24
|
+
ends = resistors.map do |component|
|
|
25
|
+
[1, 2].map { |pin| circuit.net_of("#{component.ref}.#{pin}")&.name }
|
|
26
|
+
end
|
|
27
|
+
return [] if ends.flatten.any?(&:nil?) || ends.any? { |pair| pair.uniq.length != 2 }
|
|
28
|
+
|
|
29
|
+
plus = circuit.net_of(supply.plus)&.name
|
|
30
|
+
minus = circuit.net_of(supply.minus)&.name
|
|
31
|
+
shared = ends[0] & ends[1]
|
|
32
|
+
return [] unless plus && minus && plus != minus && shared.length == 1
|
|
33
|
+
|
|
34
|
+
midpoint = shared.first
|
|
35
|
+
return [] if [plus, minus].include?(midpoint)
|
|
36
|
+
|
|
37
|
+
top, bottom = resistors.zip(ends).partition { |_component, pair| pair.include?(plus) }
|
|
38
|
+
return [] unless top.length == 1 && bottom.length == 1 && bottom.first[1].include?(minus)
|
|
39
|
+
|
|
40
|
+
result = circuit.dc_analysis
|
|
41
|
+
return [] unless result.success? && result.voltages.key?(midpoint) && result.voltages.key?(minus)
|
|
42
|
+
|
|
43
|
+
voltage = result.voltages.fetch(midpoint) - result.voltages.fetch(minus)
|
|
44
|
+
[{ "kind" => "voltage_divider", "supply" => supply.name,
|
|
45
|
+
"top_resistor" => top.first[0].ref, "bottom_resistor" => bottom.first[0].ref,
|
|
46
|
+
"midpoint_net" => midpoint, "midpoint_voltage_v" => voltage,
|
|
47
|
+
"reference" => "#{supply.name}.-",
|
|
48
|
+
"explanation" => "Two resistors form an unloaded voltage divider. The nominal midpoint voltage is relative to the supply negative terminal." }]
|
|
49
|
+
rescue ArgumentError, TypeError
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ne555_astable(circuit)
|
|
54
|
+
return [] unless circuit.supplies.length == 1 && circuit.components.length == 8
|
|
55
|
+
|
|
56
|
+
parts = circuit.components.values.group_by { |component| component.part.id }
|
|
57
|
+
return [] unless %w[ne555 resistor capacitor electrolytic led].all? { |id| parts.key?(id) }
|
|
58
|
+
return [] unless %w[ne555 resistor capacitor electrolytic led].map { |id| parts.fetch(id).length } == [1, 3, 2, 1, 1]
|
|
59
|
+
|
|
60
|
+
timer = parts.fetch("ne555").first
|
|
61
|
+
supply = circuit.supplies.first
|
|
62
|
+
voltage = supply.voltage.to_f
|
|
63
|
+
minimum, maximum = Array(timer.part.data["supply_range"])
|
|
64
|
+
return [] unless supply.voltage_range.nil? && minimum && maximum && voltage.between?(minimum, maximum)
|
|
65
|
+
|
|
66
|
+
plus = circuit.net_of(supply.plus)&.name
|
|
67
|
+
minus = circuit.net_of(supply.minus)&.name
|
|
68
|
+
pins = (1..8).to_h { |number| [number, terminal_net(circuit, timer, number)] }
|
|
69
|
+
return [] if [plus, minus, *pins.values].any?(&:nil?)
|
|
70
|
+
return [] unless pins[1] == minus && pins[4] == plus && pins[8] == plus && pins[2] == pins[6]
|
|
71
|
+
|
|
72
|
+
discharge, timing, output, control = pins.values_at(7, 2, 3, 5)
|
|
73
|
+
return [] unless [plus, minus, discharge, timing, output, control].uniq.length == 6
|
|
74
|
+
|
|
75
|
+
led = parts.fetch("led").first
|
|
76
|
+
led_anode = terminal_net(circuit, led, "anode")
|
|
77
|
+
return [] unless terminal_net(circuit, led, "cathode") == minus && led_anode &&
|
|
78
|
+
![plus, minus, discharge, timing, output, control].include?(led_anode)
|
|
79
|
+
|
|
80
|
+
resistors = parts.fetch("resistor")
|
|
81
|
+
capacitors = parts.fetch("capacitor") + parts.fetch("electrolytic")
|
|
82
|
+
return [] unless (resistors + capacitors).all? { |part| Value.parse(part.value).positive? }
|
|
83
|
+
|
|
84
|
+
charge = between(circuit, resistors, plus, discharge)
|
|
85
|
+
discharge_resistor = between(circuit, resistors, discharge, timing)
|
|
86
|
+
output_resistor = between(circuit, resistors, output, led_anode)
|
|
87
|
+
control_capacitor = between(circuit, capacitors, control, minus)
|
|
88
|
+
bypass_capacitor = between(circuit, capacitors, plus, minus)
|
|
89
|
+
timing_capacitor = between(circuit, capacitors, timing, minus)
|
|
90
|
+
return [] unless [charge, discharge_resistor, output_resistor, control_capacitor,
|
|
91
|
+
bypass_capacitor, timing_capacitor].all?
|
|
92
|
+
return [] unless control_capacitor.part.id == "capacitor" && bypass_capacitor.part.id == "capacitor" &&
|
|
93
|
+
timing_capacitor.part.id == "electrolytic"
|
|
94
|
+
return [] unless terminal_net(circuit, timing_capacitor, 1) == timing &&
|
|
95
|
+
terminal_net(circuit, timing_capacitor, 2) == minus
|
|
96
|
+
|
|
97
|
+
[{ "kind" => "ne555_astable_wiring", "timer" => timer.ref, "supply" => supply.name,
|
|
98
|
+
"charge_resistor" => charge.ref, "discharge_resistor" => discharge_resistor.ref,
|
|
99
|
+
"timing_capacitor" => timing_capacitor.ref, "output_resistor" => output_resistor.ref,
|
|
100
|
+
"output_led" => led.ref, "control_capacitor" => control_capacitor.ref,
|
|
101
|
+
"bypass_capacitor" => bypass_capacitor.ref,
|
|
102
|
+
"datasheet" => "https://www.ti.com/lit/ds/symlink/ne555.pdf",
|
|
103
|
+
"explanation" => "This wiring matches the NE555 astable connection with an LED output load; this is not a simulation of oscillation or timing." }]
|
|
104
|
+
rescue ArgumentError, TypeError
|
|
105
|
+
[]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def terminal_net(circuit, component, pin)
|
|
109
|
+
circuit.net_of("#{component.ref}.#{pin}")&.name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def between(circuit, components, first, second)
|
|
113
|
+
components.find do |component|
|
|
114
|
+
ends = [1, 2].map { |pin| terminal_net(circuit, component, pin) }
|
|
115
|
+
ends.all? && ends.sort == [first, second].sort
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|