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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +2 -0
  3. data/.yamllint +16 -0
  4. data/README.md +109 -6
  5. data/data/boards/double_full.yml +18 -0
  6. data/data/boards/stripboard.yml +9 -0
  7. data/data/boards/universal.yml +8 -0
  8. data/data/parts/2n3904.yml +11 -0
  9. data/data/parts/2n7000_onsemi_to92.yml +10 -0
  10. data/data/parts/74hc00.yml +22 -0
  11. data/data/parts/74hc04.yml +22 -0
  12. data/data/parts/74hc08.yml +22 -0
  13. data/data/parts/74hc14.yml +22 -0
  14. data/data/parts/74hc165.yml +24 -0
  15. data/data/parts/74hc32.yml +22 -0
  16. data/data/parts/74hc595.yml +24 -0
  17. data/data/parts/active_buzzer.yml +8 -0
  18. data/data/parts/ams1117_3v3.yml +12 -0
  19. data/data/parts/arduino_nano.yml +70 -0
  20. data/data/parts/arduino_uno.yml +29 -26
  21. data/data/parts/atmega328p.yml +37 -0
  22. data/data/parts/attiny85.yml +16 -0
  23. data/data/parts/battery_box.yml +8 -0
  24. data/data/parts/cd4017.yml +24 -0
  25. data/data/parts/ck_bd04.yml +29 -0
  26. data/data/parts/crystal.yml +7 -0
  27. data/data/parts/dc_jack_2wire.yml +8 -0
  28. data/data/parts/diode.yml +1 -0
  29. data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
  30. data/data/parts/irlz44n.yml +10 -0
  31. data/data/parts/l293d.yml +24 -0
  32. data/data/parts/led.yml +1 -0
  33. data/data/parts/lm358.yml +16 -0
  34. data/data/parts/lm393.yml +16 -0
  35. data/data/parts/ne555.yml +2 -2
  36. data/data/parts/ntc_thermistor.yml +7 -0
  37. data/data/parts/photoresistor.yml +8 -0
  38. data/data/parts/pico.yml +89 -0
  39. data/data/parts/resistor.yml +1 -0
  40. data/data/parts/sc56_11ewa.yml +30 -0
  41. data/data/parts/seeed_xiao_samd21.yml +37 -0
  42. data/data/parts/shillehtek_mb102_4pin.yml +23 -0
  43. data/data/parts/slide_switch_spst.yml +8 -0
  44. data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
  45. data/data/parts/transistor.yml +0 -1
  46. data/data/parts/ua7805.yml +10 -0
  47. data/data/parts/uln2003a.yml +23 -0
  48. data/data/parts/wp154a4sureqbfzgc.yml +11 -0
  49. data/docs/CLI.md +108 -0
  50. data/docs/COMPATIBILITY.md +60 -0
  51. data/docs/DECLARATIVE.md +138 -0
  52. data/docs/IR.md +42 -0
  53. data/docs/JUMPER_KIT.md +45 -0
  54. data/docs/LOCK.md +75 -0
  55. data/docs/LSP.md +59 -0
  56. data/docs/MB102_POWER.md +11 -0
  57. data/docs/MCP.md +44 -0
  58. data/docs/RAKE.md +19 -0
  59. data/docs/dsl.md +197 -6
  60. data/exe/breadkit-lsp +13 -0
  61. data/exe/breadkit-mcp +12 -0
  62. data/lib/breadkit/analysis.rb +174 -50
  63. data/lib/breadkit/board.rb +152 -4
  64. data/lib/breadkit/cli.rb +229 -13
  65. data/lib/breadkit/color.rb +38 -0
  66. data/lib/breadkit/dc_analysis.rb +261 -0
  67. data/lib/breadkit/dsl.rb +205 -24
  68. data/lib/breadkit/exporters.rb +202 -0
  69. data/lib/breadkit/formatter.rb +52 -0
  70. data/lib/breadkit/fritzing_export.rb +256 -0
  71. data/lib/breadkit/ir.rb +154 -22
  72. data/lib/breadkit/jumper_kit.rb +127 -0
  73. data/lib/breadkit/lsp_server.rb +248 -0
  74. data/lib/breadkit/mcp_server.rb +133 -0
  75. data/lib/breadkit/model.rb +47 -5
  76. data/lib/breadkit/part_library.rb +156 -3
  77. data/lib/breadkit/part_lock.rb +107 -0
  78. data/lib/breadkit/patterns.rb +119 -0
  79. data/lib/breadkit/rake_task.rb +25 -0
  80. data/lib/breadkit/resolver.rb +160 -28
  81. data/lib/breadkit/structured_input.rb +275 -0
  82. data/lib/breadkit/templates/555.bk.rb +34 -0
  83. data/lib/breadkit/templates/arduino.bk.rb +17 -0
  84. data/lib/breadkit/templates/led.bk.rb +21 -0
  85. data/lib/breadkit/value.rb +42 -3
  86. data/lib/breadkit/version.rb +1 -1
  87. data/lib/breadkit/wire_suggestions.rb +106 -0
  88. data/lib/breadkit.rb +28 -3
  89. data/package.json +1 -1
  90. data/schema/board-v1.json +38 -0
  91. data/schema/ir-v1.json +56 -11
  92. data/schema/ir-v2.json +173 -0
  93. data/schema/part-v1.json +52 -4
  94. data/sig/breadkit.rbs +107 -5
  95. metadata +119 -14
  96. data/docs/assets/01_led_button.svg +0 -12
  97. data/site/assets/01_led_button.svg +0 -14
  98. data/site/assets/03_arduino_blink.svg +0 -14
  99. data/site/favicon.svg +0 -7
  100. data/site/guide/components/index.html +0 -113
  101. data/site/guide/dsl/index.html +0 -132
  102. data/site/guide/index.html +0 -163
  103. data/site/index.html +0 -95
  104. data/site/styles.css +0 -14
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../breadkit"
4
+ require "rake"
5
+
6
+ module Breadkit
7
+ class RakeTask
8
+ attr_reader :task
9
+
10
+ def initialize(name = :breadkit, files:)
11
+ raise ArgumentError, "RakeTask needs a task name" if name.to_s.empty?
12
+ raise ArgumentError, "RakeTask files must be a nonempty list of paths" unless files.is_a?(Array) && !files.empty? && files.all? { |path| path.is_a?(String) && !path.empty? }
13
+
14
+ @task = Rake::Task.define_task(name) do
15
+ files.each do |path|
16
+ circuit = Breadkit.load(path)
17
+ errors = circuit.diagnostics.select { |item| item.severity == "error" }
18
+ raise "#{path}: #{errors.map { |item| "#{item.code}: #{item.message}" }.join('; ')}" unless errors.empty?
19
+
20
+ puts "Checked #{path}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -4,19 +4,23 @@ module Breadkit
4
4
  class Resolver
5
5
  def call(document)
6
6
  @document = document
7
- @diagnostics = []
7
+ @diagnostics = document.diagnostics.dup
8
8
  @board = load_board
9
9
  @terminal_positions = @board.holes.values.select { |hole| hole.kind == :terminal }.to_h { |hole| [[hole.x, hole.y], hole] }
10
10
  @library = PartLibrary.new(extra_paths: document.part_paths, extra_definitions: document.part_definitions)
11
+ @library.warnings.each { |message| @diagnostics << diagnostic(:part_override, "warning", message, nil) }
11
12
  components = resolve_components
13
+ @wire_items = expand_source_supplies(components)
12
14
  wires = resolve_wires(components)
13
15
  supplies = resolve_supplies
14
16
  validate_names(components, wires, supplies)
15
- labels = document.labels.map { |item| Label.new(name: item[:name], at: item[:at], location: item[:location]) }
17
+ labels = document.labels.map { |item| Label.new(name: item[:name], at: item[:at], location: item[:location], step: item[:step]) }
16
18
  validate_references(components, supplies, labels)
17
19
  circuit = Circuit.new(title: document.title, board: @board, components: components, wires: wires,
18
20
  supplies: supplies, labels: labels, expectations: document.expectations,
19
- lint_disables: document.lint_disables, diagnostics: @diagnostics)
21
+ lint_disables: document.lint_disables, diagnostics: @diagnostics,
22
+ steps: document.steps,
23
+ source_root: document.source_root)
20
24
  validate_split_labels(circuit) if labels.length > 1
21
25
  circuit
22
26
  end
@@ -24,12 +28,34 @@ module Breadkit
24
28
  private
25
29
 
26
30
  def load_board
27
- Board.new(BoardDef.load(@document.board[:type], extra_paths: @document.board_paths,
28
- extra_definitions: @document.board_definitions),
29
- split_rails: @document.board[:options][:split_rails] || false)
30
- rescue StandardError => e
31
- @diagnostics << diagnostic(:unknown_board, "error", e.message, nil)
32
- Board.new(BoardDef.load("full"))
31
+ unless @document.boards.empty?
32
+ names = {}
33
+ @document.boards.each do |item|
34
+ name = item[:name].to_s
35
+ raise DSLError, "invalid board name #{name.inspect}" unless /\A[A-Za-z][A-Za-z0-9_]*\z/.match?(name)
36
+ raise DSLError, "duplicate board name #{name}" if names[name.downcase]
37
+
38
+ names[name.downcase] = true
39
+ end
40
+ boards = @document.boards.to_h do |item|
41
+ definition = BoardDef.load(item[:type], extra_paths: @document.board_paths,
42
+ extra_definitions: @document.board_definitions)
43
+ [item[:name], Board.new(definition, split_rails: item[:options][:split_rails] || false)]
44
+ rescue StandardError => e
45
+ @diagnostics << diagnostic(:unknown_board, "error", e.message, nil)
46
+ [item[:name], Board.new(BoardDef.load("full"))]
47
+ end
48
+ return BoardSet.new(boards)
49
+ end
50
+
51
+ begin
52
+ Board.new(BoardDef.load(@document.board[:type], extra_paths: @document.board_paths,
53
+ extra_definitions: @document.board_definitions),
54
+ split_rails: @document.board[:options][:split_rails] || false)
55
+ rescue StandardError => e
56
+ @diagnostics << diagnostic(:unknown_board, "error", e.message, nil)
57
+ Board.new(BoardDef.load("full"))
58
+ end
33
59
  end
34
60
 
35
61
  def resolve_components
@@ -54,12 +80,58 @@ module Breadkit
54
80
  @diagnostics << diagnostic(:unknown_part, "error", "unknown part #{item[:type]}", item[:location], [ref])
55
81
  next
56
82
  end
83
+ if %w[resistor capacitor electrolytic pot].include?(part.id) && item[:value]
84
+ begin
85
+ value = Value.parse(item[:value])
86
+ raise ArgumentError unless value.finite? && value >= 0
87
+ rescue ArgumentError, TypeError
88
+ @diagnostics << diagnostic(:invalid_value, "error", "invalid #{part.id} value #{item[:value].inspect}", item[:location], [ref])
89
+ end
90
+ end
57
91
  pins = resolve_pins(item, part)
58
92
  result[ref] = Component.new(ref: ref, part: part, value: item[:value], attrs: item[:attrs] || {},
59
- pins: pins, unused: Array(item[:unused]).map(&:to_s), location: item[:location])
93
+ pins: pins, unused: Array(item[:unused]).map(&:to_s), location: item[:location], step: item[:step])
60
94
  end
61
95
  end
62
96
 
97
+ def expand_source_supplies(components)
98
+ @document.wires.flat_map do |item|
99
+ next item unless item[:supply_from]
100
+
101
+ reference = item[:supply_from]
102
+ parsed = HoleId.parse(reference, board: @board)
103
+ component = components[parsed.ref] if parsed.kind == :pin
104
+ source_pin = component&.part&.pin(parsed.pin)
105
+ matches = if component&.part&.placement == "offboard" && source_pin
106
+ component.provided_sources.select do |entry|
107
+ component.part.pin(entry.fetch("positive"))["num"].to_s == source_pin["num"].to_s
108
+ end
109
+ else
110
+ []
111
+ end
112
+ if matches.empty?
113
+ @diagnostics << diagnostic(:unknown_supply_source, "error", "#{reference} is not a provided offboard power output", item[:location], [reference])
114
+ next []
115
+ end
116
+ if matches.length > 1
117
+ @diagnostics << diagnostic(:ambiguous_supply_source, "error", "#{reference} matches multiple provided power outputs", item[:location], [reference])
118
+ next []
119
+ end
120
+
121
+ source = matches.first
122
+ [source_wire(item, "#{parsed.ref}.#{source.fetch('positive')}", item[:plus]),
123
+ source_wire(item, "#{parsed.ref}.#{source.fetch('negative')}", item[:minus])]
124
+ rescue ArgumentError
125
+ @diagnostics << diagnostic(:unknown_supply_source, "error", "#{reference} is not a provided offboard power output", item[:location], [reference])
126
+ []
127
+ end
128
+ end
129
+
130
+ def source_wire(item, from, to)
131
+ { id: nil, from: from, to: to, color: nil, route: "straight", layer: nil,
132
+ electrical: true, dashed: false, location: item[:location], step: item[:step] }
133
+ end
134
+
63
135
  def resolve_pins(item, part)
64
136
  requested = item[:pins]
65
137
  result = {}
@@ -69,9 +141,27 @@ module Breadkit
69
141
  end
70
142
  end
71
143
  attrs = item[:attrs] || {}
144
+ Array(part.data["required_attributes"]).each do |name|
145
+ next if attrs.key?(name) || attrs.key?(name.to_sym)
146
+
147
+ @diagnostics << diagnostic(:invalid_option, "error", "missing required component option #{item[:ref]}.#{name}", item[:location], [item[:ref]])
148
+ end
149
+ if attrs.key?(:rotate) || attrs.key?(:mirror)
150
+ placement_error(item, "#{item[:ref]} orientation requires a footprint") unless part.placement == "footprint"
151
+ end
72
152
  pin_names = part.pins.flat_map { |pin| [pin["num"], pin["name"], *Array(pin["aliases"])] }.compact.map(&:to_s)
73
- attrs.each_key do |key|
74
- next if part.pin(key) || %w[pin_count color layer address label side at].include?(key.to_s)
153
+ attrs.each do |key, value|
154
+ if key.to_s == "color" && !Color.valid?(value)
155
+ @diagnostics << diagnostic(:invalid_color, "error", "invalid #{item[:ref]} color #{value.inspect}", item[:location], [item[:ref]])
156
+ next
157
+ end
158
+ schema = (part.data["attributes"] || {})[key.to_s]
159
+ if schema
160
+ valid = schema == "css_color" ? Color.valid?(value) : Array(schema).any? { |allowed| allowed.to_s == value.to_s }
161
+ @diagnostics << diagnostic(:invalid_option, "error", "invalid #{item[:ref]}.#{key}: #{value.inspect}", item[:location], [item[:ref]]) unless valid
162
+ next
163
+ end
164
+ next if part.pin(key) || %w[pin_count color layer address label side at rotate mirror].include?(key.to_s)
75
165
 
76
166
  suggestion = DidYouMean::SpellChecker.new(dictionary: pin_names).correct(key.to_s).first
77
167
  if suggestion
@@ -99,6 +189,12 @@ module Breadkit
99
189
  @diagnostics << diagnostic(:invalid_hole, "error", "invalid hole #{anchor.inspect}", item[:location], [item[:ref], anchor])
100
190
  end
101
191
  end
192
+ if hole && definition["mount"] == "rail"
193
+ expected_polarity = { "power" => "+", "ground" => "-" }[definition["type"]]
194
+ if hole.kind != :rail || (expected_polarity && @board.rail_polarity(hole.rail) != expected_polarity)
195
+ placement_error(item, "#{item[:ref]}.#{name} needs a #{expected_polarity || 'matching'} rail hole")
196
+ end
197
+ end
102
198
  result[name.to_s] = Pin.new(name: name.to_s, number: number, hole_id: hole&.id,
103
199
  node_id: "pin:#{item[:ref]}.#{name}", role: definition["type"] || definition["role"])
104
200
  else
@@ -106,9 +202,14 @@ module Breadkit
106
202
  role: definition["type"] || definition["role"])
107
203
  end
108
204
  end
205
+ if @board.is_a?(BoardSet)
206
+ board_ids = result.values.filter_map { |pin| @board.board_id_for(pin.hole_id) if pin.hole_id }.uniq
207
+ placement_error(item, "#{item[:ref]} cannot span multiple boards") if board_ids.length > 1
208
+ end
109
209
  validate_footprint_geometry(item, part, result) if requested && part.placement == "footprint"
110
210
  if part.data["straddle"] && (item[:at] || requested)
111
- groups = @board.definition.data.dig("terminal", "groups")
211
+ anchor = item[:at] || result.values.find(&:hole_id)&.hole_id
212
+ groups = @board.definition_for(anchor)&.data&.dig("terminal", "groups")
112
213
  occupied_groups = result.values.filter_map do |pin|
113
214
  row = @board.hole(pin.hole_id)&.row
114
215
  groups&.index { |rows| rows.include?(row) } if row
@@ -134,7 +235,7 @@ module Breadkit
134
235
  end
135
236
 
136
237
  def validate_footprint_geometry(item, part, result)
137
- footprint = part.data["footprint"]
238
+ footprint = part.footprint
138
239
  return unless footprint
139
240
 
140
241
  first = part.pins.first
@@ -142,7 +243,7 @@ module Breadkit
142
243
  origin = @board.hole(item[:at] || result[first_key]&.hole_id)
143
244
  return unless origin
144
245
 
145
- first_offset = footprint[first["num"].to_s] || [0, 0]
246
+ first_offset = transformed_offset(item, footprint[first["num"].to_s] || [0, 0])
146
247
  part.pins.each do |definition|
147
248
  pin = result[(definition["name"] || definition["num"]).to_s]
148
249
  next unless pin&.hole_id
@@ -150,8 +251,8 @@ module Breadkit
150
251
  offset = footprint[definition["num"].to_s]
151
252
  next unless offset
152
253
 
153
- expected = @terminal_positions[[origin.x + offset[0].to_i - first_offset[0].to_i,
154
- origin.y + offset[1].to_i - first_offset[1].to_i]]
254
+ x, y = transformed_offset(item, offset)
255
+ expected = @terminal_positions[[origin.x + x - first_offset[0], origin.y + y - first_offset[1]]]
155
256
  unless pin.hole_id == expected&.id
156
257
  placement_error(item, "#{item[:ref]} pins do not match its footprint")
157
258
  break
@@ -171,7 +272,7 @@ module Breadkit
171
272
  count = part.pins.length if count.zero?
172
273
  half = count / 2
173
274
  first_row = at.row
174
- near_row, far_row = @board.ravine_between
275
+ near_row, far_row = @board.ravine_between_for(at.to_s)
175
276
  return invalid_placement(item, at) unless [near_row, far_row].include?(first_row)
176
277
  row = first_row
177
278
  col = at.col
@@ -185,13 +286,15 @@ module Breadkit
185
286
  end
186
287
  return "#{row}#{col}"
187
288
  end
188
- footprint = part.data["footprint"]
289
+ footprint = part.footprint
189
290
  return nil unless footprint
190
291
  offset = footprint[pin_num]
191
292
  return item[:at] if !offset && pin_num == part.pins.first["num"].to_s
192
293
  return nil unless offset
193
294
  anchor = @board.hole(at.to_s)
194
- target = anchor && @terminal_positions[[anchor.x + offset[0].to_i, anchor.y + offset[1].to_i]]
295
+ x, y = transformed_offset(item, offset)
296
+ first_offset = transformed_offset(item, footprint[part.pins.first["num"].to_s] || [0, 0])
297
+ target = anchor && @terminal_positions[[anchor.x + x - first_offset[0], anchor.y + y - first_offset[1]]]
195
298
  unless target
196
299
  placement_error(item, "#{item[:ref]} footprint extends beyond the board or into the center gap")
197
300
  return nil
@@ -202,6 +305,10 @@ module Breadkit
202
305
  nil
203
306
  end
204
307
 
308
+ def transformed_offset(item, offset)
309
+ Geometry.transform(offset.map(&:to_i), rotate: item.dig(:attrs, :rotate) || 0, mirror: item.dig(:attrs, :mirror) == true)
310
+ end
311
+
205
312
  def placement_error(item, message)
206
313
  return if @diagnostics.any? { |entry| entry.code == "invalid_placement" && entry.targets.include?(item[:ref]) }
207
314
 
@@ -210,7 +317,7 @@ module Breadkit
210
317
 
211
318
  def invalid_placement(item, at)
212
319
  unless @diagnostics.any? { |entry| entry.code == "invalid_placement" && entry.targets.include?(item[:ref]) }
213
- @diagnostics << diagnostic(:invalid_placement, "error", "DIP #{item[:ref]} must straddle the center gap (#{@board.ravine_between.join('/')} row)", item[:location], [item[:ref], at.to_s])
320
+ @diagnostics << diagnostic(:invalid_placement, "error", "DIP #{item[:ref]} must straddle the center gap (#{@board.ravine_between_for(at.to_s).join('/')} row)", item[:location], [item[:ref], at.to_s])
214
321
  end
215
322
  nil
216
323
  end
@@ -240,7 +347,7 @@ module Breadkit
240
347
  end
241
348
  end
242
349
  reserved = {}
243
- @document.wires.each do |item|
350
+ @wire_items.each do |item|
244
351
  next if item[:electrical] == false
245
352
 
246
353
  [item[:from], item[:to]].each do |endpoint|
@@ -250,14 +357,23 @@ module Breadkit
250
357
  end
251
358
  end
252
359
  ids = {}
253
- reserved_ids = components.keys + @document.supplies.map { |item| item[:name] } + @document.wires.filter_map { |item| item[:id] }
360
+ reserved_ids = components.keys + @document.supplies.map { |item| item[:name] } + @wire_items.filter_map { |item| item[:id] }
254
361
  next_id = 1
255
- @document.wires.each do |item|
362
+ @wire_items.each do |item|
256
363
  unless item[:id]
257
364
  next_id += 1 while reserved_ids.include?("W#{next_id}") || ids["W#{next_id}"]
258
365
  end
259
366
  wire_id = item[:id] || "W#{next_id}"
260
367
  next_id += 1 unless item[:id]
368
+ if item[:id] && hole_like_id?(wire_id)
369
+ @diagnostics << diagnostic(:invalid_wire_id, "error", "wire ID #{wire_id} looks like a board hole", item[:location], [wire_id])
370
+ end
371
+ unless %w[straight arc edge].include?(item[:route].to_s)
372
+ @diagnostics << diagnostic(:invalid_route, "error", "invalid wire route #{item[:route].inspect}", item[:location], [wire_id])
373
+ end
374
+ if item[:color] && !Color.valid?(item[:color])
375
+ @diagnostics << diagnostic(:invalid_color, "error", "invalid wire color #{item[:color].inspect}", item[:location], [wire_id])
376
+ end
261
377
  if ids[wire_id]
262
378
  @diagnostics << diagnostic(:duplicate_ref, "error", "duplicate wire ID #{wire_id}", item[:location], [wire_id])
263
379
  next
@@ -281,7 +397,7 @@ module Breadkit
281
397
  end
282
398
  wires << Wire.new(id: wire_id, from: endpoints[0], to: endpoints[1], color: item[:color],
283
399
  route: item[:route], layer: item[:layer], electrical: false, dashed: item[:dashed],
284
- location: item[:location])
400
+ location: item[:location], step: item[:step])
285
401
  next
286
402
  end
287
403
  parsed = endpoints.map { |endpoint| HoleId.parse(endpoint, board: @board) rescue nil }
@@ -299,6 +415,7 @@ module Breadkit
299
415
  picked = candidates && candidates.map { |id| @board.hole(id) }
300
416
  .compact.reject { |hole| occupied[hole.id] || reserved[hole.id] }
301
417
  .min_by { |hole| [(target_x ? (hole.x - target_x).abs : 0), hole_order(hole)] }
418
+ picked ||= @board.hole(pin_hole) if @board.solder_pad?(pin_hole)
302
419
  if picked
303
420
  endpoints[side], parsed[side] = picked.id, HoleId.parse(picked.id, board: @board)
304
421
  occupied[picked.id] = wire_id
@@ -326,14 +443,14 @@ module Breadkit
326
443
  endpoints.each do |endpoint|
327
444
  id = endpoint_hole(endpoint, components)
328
445
  next unless id && @board.hole(id)
329
- if occupied[id] && occupied[id] != wire_id
446
+ if occupied[id] && occupied[id] != wire_id && !@board.solder_pad?(id)
330
447
  @diagnostics << diagnostic(:hole_conflict, "error", "hole #{id} is already occupied", item[:location], [wire_id, id])
331
448
  end
332
449
  occupied[id] = true
333
450
  end
334
451
  wires << Wire.new(id: wire_id, from: endpoints[0], to: endpoints[1], color: item[:color],
335
452
  route: item[:route], layer: item[:layer], electrical: true, dashed: item[:dashed],
336
- location: item[:location])
453
+ location: item[:location], step: item[:step])
337
454
  end
338
455
  wires
339
456
  end
@@ -343,6 +460,12 @@ module Breadkit
343
460
  @board.hole(id)&.x if id
344
461
  end
345
462
 
463
+ def hole_like_id?(value)
464
+ %i[terminal rail].include?(HoleId.parse(value, board: @board).kind)
465
+ rescue ArgumentError
466
+ false
467
+ end
468
+
346
469
  def endpoint_hole(endpoint, components)
347
470
  id = HoleId.parse(endpoint, board: @board)
348
471
  return id.to_s unless id.kind == :pin
@@ -380,7 +503,9 @@ module Breadkit
380
503
  end
381
504
  end
382
505
  @document.supplies.map do |item|
383
- Supply.new(name: item[:name], voltage: item[:voltage], plus: item[:plus], minus: item[:minus], location: item[:location])
506
+ Supply.new(name: item[:name], voltage: item[:voltage], plus: item[:plus], minus: item[:minus],
507
+ location: item[:location], isolated: item[:isolated] == true, voltage_range: item[:voltage_range],
508
+ current_limit: item[:current_limit], step: item[:step])
384
509
  end
385
510
  end
386
511
 
@@ -389,6 +514,7 @@ module Breadkit
389
514
  entries = components.values.map { |item| [item.ref, item.location] } +
390
515
  wires.map { |item| [item.id, item.location] } +
391
516
  supplies.map { |item| [item.name, item.location] }
517
+ entries.concat(@board.boards.keys.map { |name| [name, nil] }) if @board.is_a?(BoardSet)
392
518
  entries.each do |name, location|
393
519
  if names[name]
394
520
  @diagnostics << diagnostic(:duplicate_ref, "error", "duplicate name #{name}", location, [name])
@@ -417,6 +543,12 @@ module Breadkit
417
543
  entries.each do |entry|
418
544
  refs = entry[:refs] || entry["refs"] || []
419
545
  location = entry[:location] || entry["location"]
546
+ if (entry[:kind] || entry["kind"]) == "current"
547
+ Array(refs).each do |reference|
548
+ @diagnostics << diagnostic(:unknown_part, "error", "unknown component #{reference}", location, [reference]) unless components.key?(reference.to_s)
549
+ end
550
+ next
551
+ end
420
552
  Array(refs).each { |reference| validate_reference(reference, location, components, supplies, labels) }
421
553
  end
422
554
  end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ class StructuredInput
5
+ ROOT_KEYS = %w[title board boards use_parts use_boards supplies labels parts offboard wires connections expectations lint_disables].freeze
6
+
7
+ def self.load_file(path)
8
+ new(File.expand_path(path)).load_file
9
+ end
10
+
11
+ def self.load_source(path, source)
12
+ new(File.expand_path(path), source: source).load_file
13
+ end
14
+
15
+ def initialize(path, source: nil)
16
+ @path = path
17
+ @source = source
18
+ @location = SourceLocation.new(path: path, line: 1)
19
+ @builder = DSL::Builder.new(base_dir: File.dirname(path))
20
+ end
21
+
22
+ def load_file
23
+ source = @source || File.read(@path, encoding: "UTF-8")
24
+ raw = @path.end_with?(".toml") ? parse_toml(source) : YAML.safe_load(source, aliases: false)
25
+ data = mapping(raw, "root")
26
+ allowed!(data, ROOT_KEYS, "root")
27
+ @builder.document.source_root = File.dirname(@path)
28
+ @builder.title(string(data["title"], "title")) if data.key?("title")
29
+ raise DSLError, "board and boards cannot be used together" if data.key?("board") && data.key?("boards")
30
+ load_board(data["board"]) if data.key?("board")
31
+ if data.key?("boards")
32
+ named = records(data, "boards")
33
+ raise DSLError, "boards must contain at least one board" if named.empty?
34
+ named.each_with_index { |item, index| load_named_board(item, "boards[#{index}]") }
35
+ end
36
+ paths(data, "use_parts").each { |item| @builder.use_parts(item) }
37
+ paths(data, "use_boards").each { |item| @builder.use_boards(item) }
38
+ records(data, "supplies").each_with_index { |item, index| load_supply(item, "supplies[#{index}]") }
39
+ records(data, "labels").each_with_index { |item, index| load_label(item, "labels[#{index}]") }
40
+ records(data, "parts").each_with_index { |item, index| load_part(item, "parts[#{index}]", offboard: false) }
41
+ records(data, "offboard").each_with_index { |item, index| load_part(item, "offboard[#{index}]", offboard: true) }
42
+ records(data, "wires").each_with_index { |item, index| load_wire(item, "wires[#{index}]") }
43
+ records(data, "connections").each_with_index { |item, index| load_connection(item, "connections[#{index}]") }
44
+ records(data, "expectations").each_with_index { |item, index| load_expectation(item, "expectations[#{index}]") }
45
+ records(data, "lint_disables").each_with_index { |item, index| load_disable(item, "lint_disables[#{index}]") }
46
+ document = @builder.document
47
+ (document.supplies + document.labels + document.components + document.wires + document.lint_disables).each do |entry|
48
+ entry[:location] = @location
49
+ end
50
+ document.expectations.each do |group|
51
+ group[:location] = @location
52
+ group[:entries].each { |entry| entry[:location] = @location }
53
+ end
54
+ document.diagnostics.map! do |item|
55
+ Diagnostic.new(code: item.code, severity: item.severity, message: item.message,
56
+ location: @location, targets: item.targets)
57
+ end
58
+ document
59
+ rescue Psych::Exception, DSLError, ArgumentError, TypeError => e
60
+ raise DSLError.new("#{@path}: #{e.message}", location: @location)
61
+ end
62
+
63
+ private
64
+
65
+ def parse_toml(source)
66
+ require "tomlrb"
67
+ Tomlrb.parse(source)
68
+ rescue Tomlrb::ParseError => e
69
+ raise DSLError, e.message
70
+ end
71
+
72
+ def load_board(value)
73
+ if value.is_a?(String)
74
+ @builder.board(string(value, "board"))
75
+ else
76
+ item = mapping(value, "board")
77
+ allowed!(item, %w[type split_rails], "board")
78
+ options = {}
79
+ options[:split_rails] = boolean(item["split_rails"], "board.split_rails") if item.key?("split_rails")
80
+ @builder.board(string(item["type"], "board.type"), **options)
81
+ end
82
+ end
83
+
84
+ def load_named_board(value, context)
85
+ item = mapping(value, context)
86
+ allowed!(item, %w[name type split_rails], context)
87
+ options = {}
88
+ options[:split_rails] = boolean(item["split_rails"], "#{context}.split_rails") if item.key?("split_rails")
89
+ @builder.board(string(item["type"], "#{context}.type"), as: string(item["name"], "#{context}.name"), **options)
90
+ end
91
+
92
+ def load_supply(value, context)
93
+ item = mapping(value, context)
94
+ if item.key?("from")
95
+ allowed!(item, %w[from plus minus], context)
96
+ @builder.supply(from: string(item["from"], "#{context}.from"),
97
+ plus: string(item["plus"], "#{context}.plus"),
98
+ minus: string(item["minus"], "#{context}.minus"))
99
+ return
100
+ end
101
+
102
+ allowed!(item, %w[name voltage plus minus isolated current_limit], context)
103
+ options = { voltage: number_or_range(item["voltage"], "#{context}.voltage"),
104
+ plus: string(item["plus"], "#{context}.plus"),
105
+ minus: string(item["minus"], "#{context}.minus") }
106
+ options[:isolated] = boolean(item["isolated"], "#{context}.isolated") if item.key?("isolated")
107
+ options[:current_limit] = item["current_limit"] if item.key?("current_limit")
108
+ @builder.supply(string(item["name"], "#{context}.name"), **options)
109
+ end
110
+
111
+ def load_label(value, context)
112
+ item = mapping(value, context)
113
+ allowed!(item, %w[name at], context)
114
+ @builder.net(string(item["name"], "#{context}.name"), at: string(item["at"], "#{context}.at"))
115
+ end
116
+
117
+ def load_part(value, context, offboard:)
118
+ item = mapping(value, context)
119
+ allowed!(item, %w[ref type value pins at attrs unused side offboard], context)
120
+ is_offboard = offboard || item["offboard"] == true
121
+ boolean(item["offboard"], "#{context}.offboard") if item.key?("offboard")
122
+ attrs = item.key?("attrs") ? mapping(item["attrs"], "#{context}.attrs").transform_keys(&:to_sym) : {}
123
+ ref = string(item["ref"], "#{context}.ref")
124
+ type = string(item["type"], "#{context}.type")
125
+ unused = item.key?("unused") ? strings(item["unused"], "#{context}.unused") : []
126
+ if is_offboard
127
+ raise DSLError, "#{context}.pins is unavailable for offboard parts" if item.key?("pins")
128
+ raise DSLError, "#{context}.value is unavailable for offboard parts" if item.key?("value")
129
+ side = item.key?("side") ? string(item["side"], "#{context}.side") : "left"
130
+ at = item.key?("at") ? string(item["at"], "#{context}.at") : nil
131
+ @builder.offboard(ref, type, side: side, at: at, unused: unused, **attrs)
132
+ else
133
+ raise DSLError, "#{context}.side requires offboard: true" if item.key?("side")
134
+ pins = pins(item["pins"], "#{context}.pins") if item.key?("pins")
135
+ at = item.key?("at") ? string(item["at"], "#{context}.at") : nil
136
+ value = item["value"]
137
+ unless value.nil? || value.is_a?(String) || value.is_a?(Numeric)
138
+ raise DSLError, "#{context}.value must be text or a number"
139
+ end
140
+ @builder.part(ref, type, value, pins: pins, at: at, **attrs.merge(unused: unused))
141
+ end
142
+ end
143
+
144
+ def load_wire(value, context)
145
+ item = mapping(value, context)
146
+ allowed!(item, %w[from to color id route layer electrical dashed], context)
147
+ options = {}
148
+ %w[color id route].each do |key|
149
+ options[key.to_sym] = string(item[key], "#{context}.#{key}") if item.key?(key)
150
+ end
151
+ if item.key?("layer")
152
+ layer = item["layer"]
153
+ options[:layer] = layer.is_a?(Array) ? strings(layer, "#{context}.layer") : string(layer, "#{context}.layer")
154
+ end
155
+ %w[electrical dashed].each do |key|
156
+ options[key.to_sym] = boolean(item[key], "#{context}.#{key}") if item.key?(key)
157
+ end
158
+ @builder.wire(string(item["from"], "#{context}.from"), string(item["to"], "#{context}.to"), **options)
159
+ end
160
+
161
+ def load_connection(value, context)
162
+ item = mapping(value, context)
163
+ allowed!(item, %w[from to when], context)
164
+ options = {}
165
+ options[:when] = string(item["when"], "#{context}.when") if item.key?("when")
166
+ @builder.connect(string(item["from"], "#{context}.from"), string(item["to"], "#{context}.to"), **options)
167
+ end
168
+
169
+ def load_expectation(value, context)
170
+ item = mapping(value, context)
171
+ allowed!(item, %w[strict when connected isolated nets voltage current], context)
172
+ strict = item.key?("strict") ? boolean(item["strict"], "#{context}.strict") : false
173
+ group = { strict: strict, entries: [], location: @location }
174
+ group[:when] = string(item["when"], "#{context}.when") if item.key?("when")
175
+ %w[connected isolated].each do |kind|
176
+ next unless item.key?(kind)
177
+ groups(item[kind], "#{context}.#{kind}").each do |refs|
178
+ group[:entries] << { kind: kind, refs: refs, location: @location }
179
+ end
180
+ end
181
+ records(item, "nets", context).each_with_index do |record, index|
182
+ entry = mapping(record, "#{context}.nets[#{index}]")
183
+ allowed!(entry, %w[name refs], "#{context}.nets[#{index}]")
184
+ group[:entries] << { kind: "net", name: string(entry["name"], "#{context}.nets[#{index}].name"),
185
+ refs: strings(entry["refs"], "#{context}.nets[#{index}].refs"), location: @location }
186
+ end
187
+ %w[voltage current].each do |kind|
188
+ records(item, kind, context).each_with_index do |record, index|
189
+ entry = mapping(record, "#{context}.#{kind}[#{index}]")
190
+ allowed!(entry, %w[ref range], "#{context}.#{kind}[#{index}]")
191
+ range = number_or_range(entry["range"], "#{context}.#{kind}[#{index}].range")
192
+ raise DSLError, "#{context}.#{kind}[#{index}].range must be inclusive" unless range.is_a?(Range)
193
+ limits = [Value.parse(range.begin), Value.parse(range.end)]
194
+ unless limits.all?(&:finite?) && limits.first <= limits.last && (kind != "current" || !limits.first.negative?)
195
+ raise DSLError, "#{context}.#{kind}[#{index}].range is invalid"
196
+ end
197
+ group[:entries] << { kind: kind, refs: [string(entry["ref"], "#{context}.#{kind}[#{index}].ref")],
198
+ range: limits, location: @location }
199
+ end
200
+ end
201
+ @builder.document.expectations << group
202
+ end
203
+
204
+ def load_disable(value, context)
205
+ item = mapping(value, context)
206
+ allowed!(item, %w[rule on reason], context)
207
+ options = {}
208
+ options[:on] = string(item["on"], "#{context}.on") if item.key?("on")
209
+ options[:reason] = string(item["reason"], "#{context}.reason") if item.key?("reason")
210
+ @builder.lint_disable(string(item["rule"], "#{context}.rule"), **options)
211
+ end
212
+
213
+ def number_or_range(value, context)
214
+ return value if value.is_a?(Numeric)
215
+ raise DSLError, "#{context} must be a number or numeric string" unless value.is_a?(String)
216
+
217
+ match = /\A(.+)\.\.(.+)\z/.match(value)
218
+ match ? (Value.parse(match[1])..Value.parse(match[2])) : value
219
+ end
220
+
221
+ def mapping(value, context)
222
+ unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
223
+ raise DSLError, "#{context} must be a mapping with text keys"
224
+ end
225
+ value
226
+ end
227
+
228
+ def allowed!(item, keys, context)
229
+ unknown = item.keys - keys
230
+ raise DSLError, "#{context} has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
231
+ end
232
+
233
+ def records(item, key, context = "root")
234
+ value = item.fetch(key, [])
235
+ raise DSLError, "#{context}.#{key} must be a list" unless value.is_a?(Array)
236
+ value
237
+ end
238
+
239
+ def paths(item, key)
240
+ value = item.fetch(key, [])
241
+ value = [value] if value.is_a?(String)
242
+ strings(value, key)
243
+ end
244
+
245
+ def strings(value, context)
246
+ raise DSLError, "#{context} must be a list of text" unless value.is_a?(Array)
247
+ value.map.with_index { |item, index| string(item, "#{context}[#{index}]") }
248
+ end
249
+
250
+ def groups(value, context)
251
+ raise DSLError, "#{context} must be a list of reference lists" unless value.is_a?(Array)
252
+ value.map.with_index do |item, index|
253
+ refs = strings(item, "#{context}[#{index}]")
254
+ raise DSLError, "#{context}[#{index}] needs at least two references" if refs.length < 2
255
+ refs
256
+ end
257
+ end
258
+
259
+ def pins(value, context)
260
+ return strings(value, context) if value.is_a?(Array)
261
+ item = mapping(value, context)
262
+ item.transform_values { |hole| string(hole, "#{context} pin") }
263
+ end
264
+
265
+ def string(value, context)
266
+ raise DSLError, "#{context} must be nonempty text" unless value.is_a?(String) && !value.empty?
267
+ value
268
+ end
269
+
270
+ def boolean(value, context)
271
+ raise DSLError, "#{context} must be boolean" unless [true, false].include?(value)
272
+ value
273
+ end
274
+ end
275
+ end