breadkit 0.1.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +16 -0
  4. data/SECURITY.md +5 -0
  5. data/data/boards/full.yml +18 -0
  6. data/data/boards/half.yml +17 -0
  7. data/data/boards/mini.yml +8 -0
  8. data/data/parts/2n3906.yml +10 -0
  9. data/data/parts/arduino_uno.yml +34 -0
  10. data/data/parts/bc547.yml +10 -0
  11. data/data/parts/bc557.yml +10 -0
  12. data/data/parts/capacitor.yml +7 -0
  13. data/data/parts/diode.yml +8 -0
  14. data/data/parts/electrolytic.yml +8 -0
  15. data/data/parts/led.yml +9 -0
  16. data/data/parts/ne555.yml +16 -0
  17. data/data/parts/pot.yml +12 -0
  18. data/data/parts/resistor.yml +7 -0
  19. data/data/parts/tact_switch_6mm.yml +19 -0
  20. data/data/parts/transistor.yml +10 -0
  21. data/docs/assets/01_led_button.svg +12 -0
  22. data/docs/dsl.md +62 -0
  23. data/exe/breadkit +5 -0
  24. data/lib/breadkit/analysis.rb +382 -0
  25. data/lib/breadkit/board.rb +163 -0
  26. data/lib/breadkit/cli.rb +61 -0
  27. data/lib/breadkit/dsl.rb +182 -0
  28. data/lib/breadkit/hole_id.rb +46 -0
  29. data/lib/breadkit/ir.rb +215 -0
  30. data/lib/breadkit/model.rb +31 -0
  31. data/lib/breadkit/part_library.rb +125 -0
  32. data/lib/breadkit/resolver.rb +449 -0
  33. data/lib/breadkit/value.rb +50 -0
  34. data/lib/breadkit/version.rb +5 -0
  35. data/lib/breadkit.rb +26 -0
  36. data/package-lock.json +1172 -0
  37. data/package.json +12 -0
  38. data/schema/ir-v1.json +128 -0
  39. data/schema/part-v1.json +48 -0
  40. data/sig/breadkit.rbs +180 -0
  41. data/site/assets/01_led_button.svg +14 -0
  42. data/site/assets/03_arduino_blink.svg +14 -0
  43. data/site/favicon.svg +7 -0
  44. data/site/guide/components/index.html +113 -0
  45. data/site/guide/dsl/index.html +132 -0
  46. data/site/guide/index.html +163 -0
  47. data/site/index.html +95 -0
  48. data/site/styles.css +14 -0
  49. metadata +94 -0
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module DSL
5
+ METHODS = %w[title board use_parts use_boards supply net part wire offboard expect lint_disable resistor capacitor electrolytic diode led transistor pot button ic connected isolated].freeze
6
+
7
+ class Builder
8
+ attr_reader :document
9
+
10
+ def initialize(base_dir: Dir.pwd)
11
+ @document = Document.new
12
+ @expected = nil
13
+ @base_dir = base_dir
14
+ end
15
+
16
+ def title(value)
17
+ document.title = value.to_s
18
+ end
19
+
20
+ def board(type, **options)
21
+ raise DSLError, "board may only be declared once" if @board_declared
22
+ unknown = options.keys - [:split_rails]
23
+ raise DSLError, "unknown board option #{unknown.first}" unless unknown.empty?
24
+ if options.key?(:split_rails) && ![true, false].include?(options[:split_rails])
25
+ raise DSLError, "split_rails must be boolean"
26
+ end
27
+
28
+ @board_declared = true
29
+ document.board = { type: type.to_s, options: options }
30
+ end
31
+
32
+ def use_parts(path)
33
+ document.part_paths.concat(Dir.glob(resolve_path(path)))
34
+ end
35
+
36
+ def use_boards(path)
37
+ document.board_paths.concat(Dir.glob(resolve_path(path)))
38
+ end
39
+
40
+ def supply(name, voltage:, plus:, minus:)
41
+ document.supplies << { name: name.to_s, voltage: Value.parse(voltage), plus: plus.to_s,
42
+ minus: minus.to_s, location: source_location }
43
+ end
44
+
45
+ def net(name, *refs, at: nil, **options)
46
+ if @expected
47
+ @expected << { kind: "net", name: name.to_s, refs: (refs + [at] + options.values).compact.map(&:to_s), location: source_location }
48
+ else
49
+ raise DSLError, "net requires at: outside expect" if at.nil? || !refs.empty? || !options.empty?
50
+
51
+ document.labels << { name: name.to_s, at: at.to_s, location: source_location }
52
+ end
53
+ end
54
+
55
+ def part(ref, type, value = nil, pins: nil, at: nil, **attrs)
56
+ unsupported = attrs.keys.find { |key| %i[rotate wire_layer].include?(key.to_sym) }
57
+ raise DSLError, "unsupported component option #{unsupported}" if unsupported
58
+
59
+ document.components << { ref: ref.to_s, type: type.to_s, value: value, pins: pins, at: at,
60
+ attrs: attrs, unused: Array(attrs.delete(:unused)), location: source_location }
61
+ end
62
+
63
+ def resistor(ref, value, **options)
64
+ part(ref, :resistor, value, **options)
65
+ end
66
+
67
+ def capacitor(ref, value, **options)
68
+ part(ref, :capacitor, value, **options)
69
+ end
70
+
71
+ def electrolytic(ref, value, **options)
72
+ part(ref, :electrolytic, value, **options)
73
+ end
74
+
75
+ def diode(ref, value = nil, **options)
76
+ part(ref, :diode, value, **options)
77
+ end
78
+
79
+ def led(ref, **options)
80
+ part(ref, :led, nil, **options)
81
+ end
82
+
83
+ def transistor(ref, value = nil, **options)
84
+ part(ref, :transistor, value, **options)
85
+ end
86
+
87
+ def pot(ref, value = nil, **options)
88
+ part(ref, :pot, value, **options)
89
+ end
90
+
91
+ def button(ref, **options)
92
+ part(ref, :button, nil, **options)
93
+ end
94
+
95
+ def ic(ref, value, **options)
96
+ part(ref, value.to_s.downcase == "dip" ? :dip : value, nil, **options)
97
+ end
98
+
99
+ def wire(from, to, color: nil, id: nil, route: :straight, layer: nil, electrical: true, dashed: false)
100
+ document.wires << { id: id&.to_s, from: from.to_s, to: to.to_s, color: color&.to_s,
101
+ route: route.to_s, layer: layer_names(layer), electrical: electrical != false,
102
+ dashed: !!dashed, location: source_location }
103
+ end
104
+
105
+ def offboard(name, type, side: :left, at: nil, unused: [], **attrs)
106
+ unsupported = attrs.keys.find { |key| %i[rotate wire_layer].include?(key.to_sym) }
107
+ raise DSLError, "unsupported component option #{unsupported}" if unsupported
108
+
109
+ attrs[:at] = at if at
110
+ document.components << { ref: name.to_s, type: type.to_s, attrs: attrs.merge(side: side.to_s),
111
+ pins: nil, unused: Array(unused), location: source_location, offboard: true }
112
+ end
113
+
114
+ def expect(strict: false, &block)
115
+ entries = []
116
+ previous = @expected
117
+ @expected = entries
118
+ instance_eval(&block)
119
+ document.expectations << { strict: strict, entries: entries, location: source_location }
120
+ ensure
121
+ @expected = previous
122
+ end
123
+
124
+ def connected(*refs)
125
+ raise DSLError, "connected must be inside expect" unless @expected
126
+
127
+ @expected << { kind: "connected", refs: refs.map(&:to_s), location: source_location }
128
+ end
129
+
130
+ def isolated(*refs)
131
+ raise DSLError, "isolated must be inside expect" unless @expected
132
+
133
+ @expected << { kind: "isolated", refs: refs.map(&:to_s), location: source_location }
134
+ end
135
+
136
+ def lint_disable(rule_id, on: nil, reason: nil)
137
+ document.lint_disables << { rule: rule_id.to_s, on: on&.to_s, reason: reason, location: source_location }
138
+ end
139
+
140
+ def method_missing(name, *_args, **_kwargs, &_block)
141
+ suggestions = DidYouMean::SpellChecker.new(dictionary: METHODS).correct(name.to_s)
142
+ hint = suggestions.empty? ? "" : "; did you mean #{suggestions.first.inspect}?"
143
+ raise DSLError, "unknown DSL method #{name}#{hint}"
144
+ end
145
+
146
+ def respond_to_missing?(name, _include_private = false)
147
+ METHODS.include?(name.to_s)
148
+ end
149
+
150
+ private
151
+
152
+ def source_location
153
+ loc = caller_locations(2, 12).find { |item| item.path && File.expand_path(item.path) != __FILE__ }
154
+ SourceLocation.new(path: loc&.path, line: loc&.lineno)
155
+ end
156
+
157
+ def resolve_path(path)
158
+ value = path.to_s
159
+ File.expand_path(value, @base_dir)
160
+ end
161
+
162
+ def layer_names(value)
163
+ return if value.nil?
164
+
165
+ Array(value).map(&:to_s)
166
+ end
167
+ end
168
+
169
+ def self.load_file(path)
170
+ absolute = File.expand_path(path)
171
+ builder = Builder.new(base_dir: File.dirname(absolute))
172
+ builder.instance_eval(File.read(absolute, encoding: "UTF-8"), absolute, 1)
173
+ builder.document
174
+ rescue DSLError
175
+ raise
176
+ rescue ScriptError, StandardError => e
177
+ line = e.backtrace_locations&.find { |frame| frame.path == absolute }&.lineno
178
+ location = line ? "#{path}:#{line}" : path
179
+ raise DSLError, "#{location}: #{e.message}"
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ class HoleId
5
+ attr_reader :kind, :row, :col, :rail, :index, :ref, :pin
6
+
7
+ def initialize(kind:, row: nil, col: nil, rail: nil, index: nil, ref: nil, pin: nil)
8
+ @kind, @row, @col, @rail, @index, @ref, @pin = kind, row, col, rail, index, ref, pin
9
+ freeze
10
+ end
11
+
12
+ def self.parse(value, board: nil)
13
+ text = value.to_s.strip
14
+ rows = board ? board.terminal_rows : ('a'..'j').to_a
15
+ rails = board ? board.rail_ids : %w[T+ T- B+ B-]
16
+ rows.sort_by { |row| -row.length }.each do |row|
17
+ m = /\A(#{Regexp.escape(row)})(\d+)\z/i.match(text)
18
+ next unless m
19
+
20
+ col = m[2].to_i
21
+ raise ArgumentError, "invalid hole: #{value}" unless col.positive?
22
+ return new(kind: :terminal, row: row, col: col)
23
+ end
24
+ rails.sort_by { |rail| -rail.length }.each do |rail|
25
+ m = /\A(#{Regexp.escape(rail)})(\d*)\z/i.match(text)
26
+ next unless m
27
+
28
+ index = m[2].empty? ? nil : m[2].to_i
29
+ raise ArgumentError, "invalid hole: #{value}" if index == 0
30
+ return new(kind: :rail, rail: rail, index: index)
31
+ end
32
+ if (m = /\A([A-Za-z][\w-]*)\.([A-Za-z0-9_+-]+)\z/.match(text))
33
+ return new(kind: :pin, ref: m[1], pin: m[2])
34
+ end
35
+ raise ArgumentError, "invalid hole or pin reference: #{value.inspect}"
36
+ end
37
+
38
+ def to_s
39
+ case kind
40
+ when :terminal then "#{row}#{col}"
41
+ when :rail then "#{rail}#{index}"
42
+ else "#{ref}.#{pin}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Breadkit
6
+ module IR
7
+ class Writer
8
+ def write(circuit)
9
+ library = PartLibrary.new
10
+ part_definitions = circuit.components.values.map(&:part).uniq.filter_map do |part|
11
+ standard = library.find(part.id, pin_count: part.pins.length)
12
+ part.data unless standard&.data == part.data
13
+ end
14
+ result = {
15
+ schema_version: 1,
16
+ title: circuit.title,
17
+ board: { type: circuit.board.definition.id, options: { split_rails: circuit.board.split_rails } },
18
+ board_definition: circuit.board.definition.data,
19
+ supplies: circuit.supplies.map { |item| { name: item.name, voltage: item.voltage, plus: item.plus, minus: item.minus, source: source(item.location) } },
20
+ labels: circuit.labels.map { |item| { net: item.name, at: item.at, source: source(item.location) } },
21
+ components: circuit.components.values.map do |component|
22
+ { ref: component.ref, part: component.part.id, value: component.value,
23
+ attrs: stringify(component.attrs), pins: component.pins.transform_values(&:hole_id),
24
+ unused: component.unused, source: source(component.location) }
25
+ end,
26
+ wires: circuit.wires.map { |wire| { id: wire.id, from: wire.from, to: wire.to, color: wire.color, route: wire.route,
27
+ layer: wire.layer, electrical: wire.electrical, dashed: wire.dashed,
28
+ source: source(wire.location) } },
29
+ expectations: stringify(circuit.expectations),
30
+ lint_disables: stringify(circuit.lint_disables),
31
+ analysis: { nets: circuit.nets.map { |net| { name: net.name, members: net.members, holes: net.holes, potential: circuit.potentials.values[net.name] } } }
32
+ }
33
+ result[:part_definitions] = part_definitions unless part_definitions.empty?
34
+ result
35
+ end
36
+
37
+ private
38
+
39
+ def source(location)
40
+ return nil unless location && location.path && location.line
41
+
42
+ path = Pathname.new(location.path)
43
+ path = path.relative_path_from(Pathname.pwd) if path.absolute?
44
+ { path: path.to_s, line: location.line }
45
+ end
46
+
47
+ def stringify(value)
48
+ case value
49
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify(item) }
50
+ when Array then value.map { |item| stringify(item) }
51
+ when SourceLocation then source(value).transform_keys(&:to_s)
52
+ when Struct then value.each_pair.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify(item) }
53
+ when Symbol then value.to_s
54
+ else value
55
+ end
56
+ end
57
+ end
58
+
59
+ class Reader
60
+ def read_file(path)
61
+ read(JSON.parse(File.read(path, encoding: "UTF-8")))
62
+ rescue JSON::ParserError => e
63
+ raise DSLError, "#{path}: invalid IR JSON: #{e.message}"
64
+ end
65
+
66
+ def read(data)
67
+ data = stringify_keys(data)
68
+ validate!(data)
69
+ doc = Document.new
70
+ doc.title = data["title"]
71
+ doc.board = { type: data.dig("board", "type") || "full", options: (data.dig("board", "options") || {}).transform_keys(&:to_sym) }
72
+ doc.board_definitions = [data["board_definition"]] if data["board_definition"]
73
+ doc.supplies = Array(data["supplies"]).map do |item|
74
+ { name: item.fetch("name"), voltage: Value.parse(item.fetch("voltage")), plus: item.fetch("plus"), minus: item.fetch("minus"), location: location(item["source"]) }
75
+ end
76
+ doc.labels = Array(data["labels"]).map do |item|
77
+ { name: item.fetch("net"), at: item.fetch("at"), location: location(item["source"]) }
78
+ end
79
+ doc.components = Array(data["components"]).map do |item|
80
+ { ref: item.fetch("ref"), type: item.fetch("part"), value: item["value"],
81
+ pins: item["pins"] || {}, at: nil, attrs: (item["attrs"] || {}).transform_keys(&:to_sym),
82
+ unused: item["unused"] || [], location: location(item["source"]) }
83
+ end
84
+ doc.part_definitions = Array(data["part_definitions"])
85
+ doc.wires = Array(data["wires"]).map do |item|
86
+ { id: item["id"], from: item.fetch("from"), to: item.fetch("to"), color: item["color"],
87
+ route: item["route"] || "straight", layer: item["layer"], electrical: item["electrical"] != false,
88
+ dashed: item["dashed"] == true, location: location(item["source"]) }
89
+ end
90
+ doc.expectations = Array(data["expectations"])
91
+ doc.lint_disables = Array(data["lint_disables"]).map do |item|
92
+ { rule: item.fetch("rule"), on: item["on"], reason: item["reason"], location: location(item["location"]) }
93
+ end
94
+ Resolver.new.call(doc)
95
+ rescue KeyError => e
96
+ raise DSLError, "invalid IR: missing #{e.key}"
97
+ end
98
+
99
+ private
100
+
101
+ def validate!(data)
102
+ require_hash(data, "root")
103
+ raise DSLError, "unsupported IR schema_version" unless data["schema_version"] == 1
104
+ raise DSLError, "invalid IR: title must be text or null" unless data["title"].nil? || data["title"].is_a?(String)
105
+
106
+ board = require_hash(data["board"], "board")
107
+ require_string(board["type"], "board.type")
108
+ options = require_hash(board["options"], "board.options")
109
+ unless !options.key?("split_rails") || [true, false].include?(options["split_rails"])
110
+ raise DSLError, "invalid IR: board.options.split_rails must be boolean"
111
+ end
112
+ validate_board_definition(data["board_definition"]) if data.key?("board_definition")
113
+ validate_records(data, "supplies", %w[name plus minus], %w[voltage])
114
+ validate_records(data, "labels", %w[net at])
115
+ validate_records(data, "components", %w[ref part])
116
+ validate_records(data, "wires", %w[id from to])
117
+ validate_records(data, "expectations")
118
+ validate_records(data, "part_definitions") if data.key?("part_definitions")
119
+ validate_records(data, "lint_disables", %w[rule]) if data.key?("lint_disables")
120
+ Array(data["part_definitions"]).each_with_index do |item, index|
121
+ require_string(item["id"], "part_definitions[#{index}].id")
122
+ require_array(item["pins"], "part_definitions[#{index}].pins").each do |pin|
123
+ require_hash(pin, "part_definitions[#{index}].pins item")
124
+ end
125
+ end
126
+ Array(data["lint_disables"]).each_with_index do |item, index|
127
+ require_source(item["location"], "lint_disables[#{index}].location") if item.key?("location")
128
+ end
129
+ data.fetch("components").each_with_index do |item, index|
130
+ require_hash(item["pins"], "components[#{index}].pins").each_value do |hole|
131
+ raise DSLError, "invalid IR: components[#{index}].pins values must be text or null" unless hole.nil? || hole.is_a?(String)
132
+ end
133
+ require_hash(item["attrs"], "components[#{index}].attrs")
134
+ require_array(item["unused"], "components[#{index}].unused")
135
+ end
136
+ data.fetch("expectations").each_with_index do |item, index|
137
+ require_source(item["location"], "expectations[#{index}].location")
138
+ require_array(item["entries"], "expectations[#{index}].entries")
139
+ item["entries"].each_with_index do |entry, entry_index|
140
+ require_hash(entry, "expectations[#{index}].entries[#{entry_index}]")
141
+ unless %w[connected isolated net].include?(entry["kind"])
142
+ raise DSLError, "invalid IR: expectations[#{index}].entries[#{entry_index}].kind is unknown"
143
+ end
144
+ require_array(entry["refs"], "expectations[#{index}].entries[#{entry_index}].refs")
145
+ require_source(entry["location"], "expectations[#{index}].entries[#{entry_index}].location")
146
+ end
147
+ end
148
+ require_array(require_hash(data["analysis"], "analysis")["nets"], "analysis.nets") if data.key?("analysis")
149
+ end
150
+
151
+ def validate_board_definition(value)
152
+ board = require_hash(value, "board_definition")
153
+ require_string(board["id"], "board_definition.id")
154
+ terminal = require_hash(board["terminal"], "board_definition.terminal")
155
+ unless terminal["columns"].is_a?(Integer) && terminal["columns"].positive?
156
+ raise DSLError, "invalid IR: board_definition.terminal.columns must be a positive integer"
157
+ end
158
+ require_array(terminal["rows"], "board_definition.terminal.rows").each do |row|
159
+ require_string(row, "board_definition.terminal.rows item")
160
+ end
161
+ require_array(terminal["groups"], "board_definition.terminal.groups").each do |group|
162
+ require_array(group, "board_definition.terminal.groups item")
163
+ end
164
+ end
165
+
166
+ def validate_records(data, key, strings = [], numbers = [])
167
+ require_array(data[key], key).each_with_index do |item, index|
168
+ require_hash(item, "#{key}[#{index}]")
169
+ strings.each { |field| require_string(item[field], "#{key}[#{index}].#{field}") }
170
+ numbers.each do |field|
171
+ raise DSLError, "invalid IR: #{key}[#{index}].#{field} must be a number" unless item[field].is_a?(Numeric)
172
+ end
173
+ require_source(item["source"], "#{key}[#{index}].source") if item.key?("source")
174
+ end
175
+ end
176
+
177
+ def require_source(value, path)
178
+ return if value.nil?
179
+
180
+ require_hash(value, path)
181
+ require_string(value["path"], "#{path}.path")
182
+ raise DSLError, "invalid IR: #{path}.line must be a positive integer" unless value["line"].is_a?(Integer) && value["line"].positive?
183
+ end
184
+
185
+ def require_hash(value, path)
186
+ raise DSLError, "invalid IR: #{path} must be an object" unless value.is_a?(Hash)
187
+
188
+ value
189
+ end
190
+
191
+ def require_array(value, path)
192
+ raise DSLError, "invalid IR: #{path} must be an array" unless value.is_a?(Array)
193
+
194
+ value
195
+ end
196
+
197
+ def require_string(value, path)
198
+ raise DSLError, "invalid IR: #{path} must be text" unless value.is_a?(String)
199
+ end
200
+
201
+ def location(source)
202
+ return nil unless source
203
+ SourceLocation.new(path: source["path"], line: source["line"])
204
+ end
205
+
206
+ def stringify_keys(value)
207
+ case value
208
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify_keys(item) }
209
+ when Array then value.map { |item| stringify_keys(item) }
210
+ else value
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ SourceLocation = Struct.new(:path, :line, keyword_init: true)
5
+ Diagnostic = Data.define(:code, :severity, :message, :location, :targets)
6
+ Hole = Struct.new(:id, :kind, :row, :col, :rail, :x, :y, :strip_id, keyword_init: true)
7
+ Strip = Struct.new(:id, :hole_ids, keyword_init: true)
8
+ 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
10
+ def pin(reference)
11
+ definition = part.pin(reference)
12
+ pins[(definition["name"] || definition["num"]).to_s] if definition
13
+ end
14
+ 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)
18
+ Net = Struct.new(:name, :members, :holes, :labels, :potential, keyword_init: true)
19
+
20
+ class Document
21
+ attr_accessor :title, :board, :supplies, :labels, :components, :wires, :expectations,
22
+ :lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions
23
+
24
+ def initialize
25
+ @title = nil
26
+ @board = { type: "full", options: {} }
27
+ @supplies, @labels, @components, @wires = [], [], [], []
28
+ @expectations, @lint_disables, @part_paths, @part_definitions, @board_paths, @board_definitions = [], [], [], [], [], []
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ class PartDef
5
+ attr_reader :data
6
+
7
+ def initialize(data)
8
+ @data = data
9
+ raise ArgumentError, "part definition needs an id" unless data["id"]
10
+ raise ArgumentError, "part #{data['id']} needs pins" unless data["pins"].is_a?(Array)
11
+ identities = {}
12
+ pins.each_with_index do |pin, index|
13
+ raise ArgumentError, "part #{id} has invalid pin" unless pin.is_a?(Hash) && pin["num"]
14
+
15
+ ([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.each do |identity|
16
+ key = identity.to_s.downcase
17
+ raise ArgumentError, "part #{id} has duplicate pin identity #{identity}" if identities.key?(key) && identities[key] != index
18
+
19
+ identities[key] = index
20
+ end
21
+ end
22
+ valid_pins = pins.flat_map { |pin| [pin["num"], pin["name"], *Array(pin["aliases"])].compact.map(&:to_s) }
23
+ %w[internal switch same_strip_ok].each do |key|
24
+ Array(data[key]).each do |pair|
25
+ 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
+ end
27
+ end
28
+ raise ArgumentError, "part #{id} has invalid placement" unless %w[leads dip footprint offboard].include?(placement)
29
+ if data["polarity"]
30
+ unless data["polarity"].is_a?(Hash) && data["polarity"].values.all? { |name| identities.key?(name.to_s.downcase) }
31
+ raise ArgumentError, "part #{id} has invalid polarity pin reference"
32
+ end
33
+ end
34
+ if data["footprint"]
35
+ numbers = pins.map { |pin| pin.fetch("num").to_s }
36
+ 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
+ raise ArgumentError, "part #{id} has invalid footprint pin reference or offset"
38
+ end
39
+ end
40
+ if data.dig("render", "shape") == "module"
41
+ size_mm = data.dig("render", "size_mm")
42
+ raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]" unless valid_mm_pair?(size_mm, positive: true)
43
+ offset_mm = data.dig("render", "body_offset_mm")
44
+ if offset_mm && !valid_mm_pair?(offset_mm, positive: false)
45
+ raise ArgumentError, "part #{id} module rendering needs finite body_offset_mm [x, y]"
46
+ end
47
+ end
48
+ end
49
+
50
+ def id
51
+ data.fetch("id")
52
+ end
53
+
54
+ def pins
55
+ data.fetch("pins")
56
+ end
57
+
58
+ def placement
59
+ data.fetch("placement", "leads")
60
+ end
61
+
62
+ def pin(value)
63
+ key = value.to_s.downcase
64
+ pins.find do |pin|
65
+ ([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.any? { |item| item.to_s.downcase == key }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def valid_mm_pair?(values, positive:)
72
+ values.is_a?(Array) && values.length == 2 && values.all? do |value|
73
+ number = Float(value)
74
+ number.finite? && (!positive || number.positive?)
75
+ rescue ArgumentError, TypeError
76
+ false
77
+ end
78
+ end
79
+ end
80
+
81
+ class PartLibrary
82
+ def initialize(extra_paths: [], extra_definitions: [])
83
+ paths = Dir.glob(File.expand_path("../../data/parts/*.yml", __dir__))
84
+ paths.concat(extra_paths.flat_map { |path| Dir.glob(path) })
85
+ definitions = paths.uniq.map { |path| YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) }
86
+ definitions.concat(extra_definitions)
87
+ @parts = {}
88
+ definitions.each do |data|
89
+ part = PartDef.new(data)
90
+ ([part.id] + Array(data["aliases"])).each do |name|
91
+ key = name.to_s.downcase
92
+ raise ArgumentError, "part alias #{name} conflicts with #{@parts[key].id}" if @parts[key] && @parts[key] != part
93
+
94
+ @parts[key] = part
95
+ end
96
+ end
97
+ end
98
+
99
+ def find(name, pin_count: nil)
100
+ key = name.to_s.downcase
101
+ return @parts[key] if @parts[key]
102
+ return unless %w[dip pin_header].include?(key) && pin_count
103
+
104
+ count = Integer(pin_count)
105
+ return unless count.between?(key == "dip" ? 2 : 1, 64) && (key != "dip" || count.even?)
106
+ generic(key, count)
107
+ rescue ArgumentError, TypeError
108
+ nil
109
+ end
110
+
111
+ def all
112
+ (@parts.values.uniq + %w[dip pin_header].map { |id| generic(id, 2) }).sort_by(&:id)
113
+ end
114
+
115
+ private
116
+
117
+ def generic(id, count)
118
+ pins = (1..count).map { |number| { "num" => number, "name" => number.to_s } }
119
+ data = { "id" => id, "placement" => id == "dip" ? "dip" : "footprint", "pins" => pins,
120
+ "package" => { "pins" => count }, "render" => { "shape" => id == "dip" ? "dip" : "generic" } }
121
+ data["footprint"] = (1..count).to_h { |number| [number.to_s, [number - 1, 0]] } if id == "pin_header"
122
+ PartDef.new(data)
123
+ end
124
+ end
125
+ end