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,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
|
data/lib/breadkit/resolver.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
154
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
@
|
|
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] } + @
|
|
360
|
+
reserved_ids = components.keys + @document.supplies.map { |item| item[:name] } + @wire_items.filter_map { |item| item[:id] }
|
|
254
361
|
next_id = 1
|
|
255
|
-
@
|
|
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],
|
|
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
|