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,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "matrix"
4
+
5
+ module Breadkit
6
+ DCResult = Struct.new(:status, :voltages, :currents, :power, :floating, :reference_nodes,
7
+ :assumptions, :errors, keyword_init: true) do
8
+ def success?
9
+ status == :ok
10
+ end
11
+ end
12
+
13
+ class Circuit
14
+ def dc_analysis(state = nil)
15
+ DCAnalysis.new(self).solve(state)
16
+ end
17
+ end
18
+
19
+ # DC model: ideal voltage sources and switches, ohmic resistors, and fixed-drop diodes.
20
+ # Capacitors are open at DC. Voltage in an ungrounded domain is relative to its
21
+ # reported reference node; it has no absolute meaning against another domain.
22
+ class DCAnalysis
23
+ DIODE_MODELS = { "led" => [2.0, 1.0], "diode" => [0.7, 1.0] }.freeze
24
+ MAX_ITERATIONS = 32
25
+
26
+ def initialize(circuit)
27
+ @circuit = circuit
28
+ end
29
+
30
+ def solve(state = nil)
31
+ return failure(:invalid, "circuit has errors") if circuit.diagnostics.any? { |item| item.severity == "error" }
32
+
33
+ @state = state
34
+ @sources, @resistors, @diodes, unsupported = [], [], [], []
35
+ ground_labels = Array(circuit.board.definition.data["ground_labels"] || %w[GND 0V VSS GROUND]).map(&:upcase)
36
+ @ground_names = circuit.nets(state).select do |net|
37
+ net.labels.any? { |label| ground_labels.include?(label.upcase) }
38
+ end.map(&:name)
39
+ collect_sources
40
+ circuit.components.each_value { |component| collect_component(component, unsupported) }
41
+ return failure(:unsupported, "no DC model for #{unsupported.join(', ')}") unless unsupported.empty?
42
+
43
+ active = Array.new(@diodes.length, true)
44
+ seen = {}
45
+ MAX_ITERATIONS.times do
46
+ key = active.join
47
+ return failure(:nonconvergent, "diode states did not converge") if seen[key]
48
+
49
+ seen[key] = true
50
+ solved = solve_linear(active)
51
+ return solved if solved.is_a?(DCResult)
52
+
53
+ voltages, source_currents, references, floating = solved
54
+ next_active = @diodes.map do |diode|
55
+ anode, cathode, drop, resistance = diode.values_at(:anode, :cathode, :drop, :resistance)
56
+ voltages.fetch(anode) - voltages.fetch(cathode) > drop - 1e-9 &&
57
+ (voltages.fetch(anode) - voltages.fetch(cathode) - drop) / resistance >= -1e-9
58
+ end
59
+ if active == next_active
60
+ if (bridge = unreturned_diode(active))
61
+ return failure(:indeterminate, "#{bridge} has no return path; its DC bias is undefined")
62
+ end
63
+
64
+ currents = source_currents.dup
65
+ power = {}
66
+ @resistors.each do |resistor|
67
+ current = resistor[:ohms].zero? ? source_currents.fetch(resistor[:name]) :
68
+ (voltages.fetch(resistor[:a]) - voltages.fetch(resistor[:b])) / resistor[:ohms]
69
+ currents[resistor[:name]] = current
70
+ power[resistor[:name]] = current * current * resistor[:ohms]
71
+ end
72
+ @diodes.each_with_index do |diode, index|
73
+ current = active[index] ? (voltages.fetch(diode[:anode]) - voltages.fetch(diode[:cathode]) - diode[:drop]) / diode[:resistance] : 0.0
74
+ currents[diode[:name]] = current
75
+ end
76
+ return DCResult.new(status: :ok, voltages: voltages, currents: currents, power: power,
77
+ floating: floating, reference_nodes: references, assumptions: assumptions, errors: [])
78
+ end
79
+ active = next_active
80
+ end
81
+ failure(:nonconvergent, "diode states did not converge")
82
+ rescue ArgumentError, TypeError, KeyError => error
83
+ failure(:invalid, error.message)
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :circuit
89
+
90
+ def failure(status, message)
91
+ DCResult.new(status: status, voltages: {}, currents: {}, power: {}, floating: [],
92
+ reference_nodes: [], assumptions: assumptions, errors: [message])
93
+ end
94
+
95
+ def assumptions
96
+ @sources.to_a.filter_map do |source|
97
+ next unless source[:range]
98
+
99
+ "#{source[:name]}: nominal #{source[:volts]} V from #{source[:range].join('..')} V range"
100
+ end + @diodes.to_a.map do |diode|
101
+ "#{diode[:name]}: fixed #{diode[:drop]} V forward drop, #{diode[:resistance]} Ω on resistance"
102
+ end
103
+ end
104
+
105
+ def net_name(reference)
106
+ circuit.net_of(reference, @state)&.name || (raise ArgumentError, "unresolved terminal #{reference}")
107
+ end
108
+
109
+ def collect_sources
110
+ circuit.voltage_sources.each do |source|
111
+ voltage = source.voltage.to_f
112
+ raise ArgumentError, "invalid voltage source #{source.name}" unless voltage.finite? && voltage.positive?
113
+
114
+ @sources << { name: source.name, a: net_name(source.plus), b: net_name(source.minus), volts: voltage,
115
+ range: source.voltage_range }
116
+ end
117
+ end
118
+
119
+ def collect_component(component, unsupported)
120
+ part = component.part
121
+ case part.id
122
+ when "resistor"
123
+ value = Value.parse(component.value)
124
+ raise ArgumentError, "invalid resistance #{component.ref}" unless value.finite? && value >= 0
125
+
126
+ @resistors << { name: component.ref, a: net_name("#{component.ref}.1"), b: net_name("#{component.ref}.2"), ohms: value }
127
+ when "led", "diode"
128
+ default_drop, default_resistance = DIODE_MODELS.fetch(part.id)
129
+ drop = part.data.fetch("forward_voltage", default_drop).to_f
130
+ resistance = part.data.fetch("on_resistance", default_resistance).to_f
131
+ polarity = part.data.fetch("polarity")
132
+ @diodes << { name: component.ref, anode: net_name("#{component.ref}.#{polarity.fetch('positive')}"),
133
+ cathode: net_name("#{component.ref}.#{polarity.fetch('negative')}"),
134
+ drop: drop, resistance: resistance }
135
+ when "capacitor", "electrolytic", "pin_header", "battery_box", "dc_jack_2wire"
136
+ nil
137
+ else
138
+ return if part.data["switch"]
139
+ if part.data["provides"]
140
+ provided_outputs = Array(part.data["provides"]).filter_map { |source| part.pin(source["positive"]) }
141
+ driven = component.pins.values.any? do |pin|
142
+ definition = part.pin(pin.number)
143
+ next unless %w[gpio output].include?(pin.role) || definition&.fetch("output_capable", false)
144
+ next if provided_outputs.include?(definition)
145
+
146
+ net = circuit.net_of("#{component.ref}.#{pin.name}", @state)
147
+ net&.members&.any? { |member| member != "#{component.ref}.#{pin.name}" }
148
+ end
149
+ unsupported << "#{component.ref} output state" if driven
150
+ return
151
+ end
152
+
153
+ unsupported << component.ref
154
+ end
155
+ end
156
+
157
+ def solve_linear(active)
158
+ nodes = (@sources.flat_map { |item| [item[:a], item[:b]] } +
159
+ @resistors.flat_map { |item| [item[:a], item[:b]] } +
160
+ @diodes.flat_map { |item| [item[:anode], item[:cathode]] }).uniq
161
+ links = @sources.map { |item| [item[:a], item[:b]] } +
162
+ @resistors.map { |item| [item[:a], item[:b]] } +
163
+ @diodes.each_with_index.filter_map { |item, index| [item[:anode], item[:cathode]] if active[index] }
164
+ groups = connected_groups(nodes, links)
165
+ group_of = groups.each_with_index.each_with_object({}) { |(group, index), result| group.each { |node| result[node] = index } }
166
+ @diodes.each_with_index do |diode, index|
167
+ next if active[index] || group_of[diode[:anode]] == group_of[diode[:cathode]]
168
+
169
+ return failure(:indeterminate, "#{diode[:name]} bridges independent DC domains while off")
170
+ end
171
+ references = groups.map { |group| reference_for(group) }
172
+ floating = groups.reject { |group| group.any? { |name| ground?(name) } }
173
+ variables = nodes - references
174
+ index = variables.each_with_index.to_h
175
+ ideal = @sources + @resistors.select { |item| item[:ohms].zero? }.map { |item| item.merge(volts: 0.0) }
176
+ size = variables.length + ideal.length
177
+ matrix = Array.new(size) { Array.new(size, 0.0) }
178
+ rhs = Array.new(size, 0.0)
179
+ @resistors.each { |item| stamp_conductance(matrix, index, item[:a], item[:b], 1.0 / item[:ohms]) unless item[:ohms].zero? }
180
+ @diodes.each_with_index do |item, position|
181
+ next unless active[position]
182
+
183
+ conductance = 1.0 / item[:resistance]
184
+ stamp_conductance(matrix, index, item[:anode], item[:cathode], conductance)
185
+ rhs[index.fetch(item[:anode])] += conductance * item[:drop] if index.key?(item[:anode])
186
+ rhs[index.fetch(item[:cathode])] -= conductance * item[:drop] if index.key?(item[:cathode])
187
+ end
188
+ ideal.each_with_index do |item, position|
189
+ row = variables.length + position
190
+ [[item[:a], 1.0], [item[:b], -1.0]].each do |node, sign|
191
+ next unless index.key?(node)
192
+
193
+ matrix[index.fetch(node)][row] += sign
194
+ matrix[row][index.fetch(node)] += sign
195
+ end
196
+ rhs[row] = item[:volts]
197
+ end
198
+ solution = size.zero? ? [] : Matrix.rows(matrix).lup.solve(Vector.elements(rhs)).to_a
199
+ return failure(:singular, "DC equations are inconsistent or underdetermined") unless valid_solution?(matrix, rhs, solution)
200
+
201
+ voltages = nodes.to_h { |node| [node, index.key?(node) ? solution.fetch(index.fetch(node)) : 0.0] }
202
+ currents = ideal.each_with_index.to_h { |item, position| [item[:name], solution.fetch(variables.length + position)] }
203
+ [voltages, currents, references, floating]
204
+ rescue ExceptionForMatrix::ErrNotRegular
205
+ failure(:singular, "DC equations are inconsistent or underdetermined")
206
+ end
207
+
208
+ def stamp_conductance(matrix, index, a, b, conductance)
209
+ return if a == b
210
+
211
+ if index.key?(a)
212
+ row = index.fetch(a)
213
+ matrix[row][row] += conductance
214
+ matrix[row][index.fetch(b)] -= conductance if index.key?(b)
215
+ end
216
+ if index.key?(b)
217
+ row = index.fetch(b)
218
+ matrix[row][row] += conductance
219
+ matrix[row][index.fetch(a)] -= conductance if index.key?(a)
220
+ end
221
+ end
222
+
223
+ def connected_groups(nodes, links)
224
+ union = UnionFind.new
225
+ nodes.each { |node| union.add(node) }
226
+ links.each { |a, b| union.union(a, b) }
227
+ nodes.group_by { |node| union.find(node) }.values
228
+ end
229
+
230
+ def unreturned_diode(active)
231
+ base = @sources.map { |item| [item[:a], item[:b]] } + @resistors.map { |item| [item[:a], item[:b]] }
232
+ @diodes.each_with_index do |diode, index|
233
+ next unless active[index]
234
+
235
+ union = UnionFind.new
236
+ (base + @diodes.each_with_index.filter_map do |other, other_index|
237
+ [other[:anode], other[:cathode]] if active[other_index] && other_index != index
238
+ end).each { |a, b| union.union(a, b) }
239
+ return diode[:name] unless union.find(diode[:anode]) == union.find(diode[:cathode])
240
+ end
241
+ nil
242
+ end
243
+
244
+ def reference_for(group)
245
+ group.find { |name| ground?(name) } || @sources.lazy.map { |source| source[:b] }.find { |name| group.include?(name) } || group.first
246
+ end
247
+
248
+ def ground?(name)
249
+ @ground_names.include?(name)
250
+ end
251
+
252
+ def valid_solution?(matrix, rhs, solution)
253
+ return false unless solution.all?(&:finite?)
254
+
255
+ matrix.each_with_index.all? do |row, index|
256
+ result = row.zip(solution).sum { |coefficient, value| coefficient * value }
257
+ (result - rhs.fetch(index)).abs <= 1e-7 * [rhs.fetch(index).abs, result.abs, 1.0].max
258
+ end
259
+ end
260
+ end
261
+ end
data/lib/breadkit/dsl.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Breadkit
4
6
  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
7
+ METHODS = %w[title board use_parts use_boards include block use_block bus step supply net part wire connect offboard expect expect_voltage expect_current lint_disable resistor capacitor electrolytic diode led transistor pot button ic connected isolated].freeze
6
8
 
7
9
  class Builder
8
10
  attr_reader :document
@@ -11,35 +13,151 @@ module Breadkit
11
13
  @document = Document.new
12
14
  @expected = nil
13
15
  @base_dir = base_dir
16
+ @include_stack = []
17
+ @blocks = {}
18
+ @active_blocks = []
19
+ @current_step = nil
14
20
  end
15
21
 
16
22
  def title(value)
17
23
  document.title = value.to_s
18
24
  end
19
25
 
20
- def board(type, **options)
21
- raise DSLError, "board may only be declared once" if @board_declared
26
+ def board(type, as: nil, **options)
22
27
  unknown = options.keys - [:split_rails]
23
28
  raise DSLError, "unknown board option #{unknown.first}" unless unknown.empty?
24
29
  if options.key?(:split_rails) && ![true, false].include?(options[:split_rails])
25
30
  raise DSLError, "split_rails must be boolean"
26
31
  end
27
32
 
28
- @board_declared = true
29
- document.board = { type: type.to_s, options: options }
33
+ if as
34
+ raise DSLError, "named and unnamed boards cannot be mixed" if @board_declared
35
+ name = helper_name(as, "board")
36
+ if document.boards.any? { |item| item[:name].casecmp?(name) }
37
+ raise DSLError, "duplicate board name #{name}"
38
+ end
39
+ document.boards << { name: name, type: type.to_s, options: options }
40
+ else
41
+ raise DSLError, "named and unnamed boards cannot be mixed" unless document.boards.empty?
42
+ raise DSLError, "board may only be declared once" if @board_declared
43
+
44
+ @board_declared = true
45
+ document.board = { type: type.to_s, options: options }
46
+ end
30
47
  end
31
48
 
32
49
  def use_parts(path)
33
- document.part_paths.concat(Dir.glob(resolve_path(path)))
50
+ matches = Dir.glob(resolve_path(path))
51
+ document.diagnostics << Diagnostic.new(code: "unmatched_parts", severity: "warning",
52
+ message: "no part definitions match #{path}", location: source_location,
53
+ targets: []) if matches.empty?
54
+ document.part_paths.concat(matches)
34
55
  end
35
56
 
36
57
  def use_boards(path)
37
58
  document.board_paths.concat(Dir.glob(resolve_path(path)))
38
59
  end
39
60
 
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 }
61
+ def include(path)
62
+ absolute = resolve_path(path)
63
+ raise DSLError.new("circular include: #{absolute}", location: source_location) if @include_stack.include?(absolute)
64
+
65
+ previous_dir = @base_dir
66
+ @include_stack << absolute
67
+ @base_dir = File.dirname(absolute)
68
+ instance_eval(File.read(absolute, encoding: "UTF-8"), absolute, 1)
69
+ rescue DSLError, ScriptError => e
70
+ frame = e.backtrace_locations&.find { |item| item.path == absolute }
71
+ location = e.respond_to?(:location) && e.location || SourceLocation.new(path: absolute, line: frame&.lineno)
72
+ raise DSLError.new(e.message, location: location)
73
+ ensure
74
+ if previous_dir
75
+ @base_dir = previous_dir
76
+ @include_stack.pop
77
+ end
78
+ end
79
+
80
+ def block(name, &body)
81
+ key = helper_name(name, "block")
82
+ raise DSLError, "block #{key} requires a body" unless body
83
+ raise DSLError, "block #{key} is already defined" if @blocks.key?(key)
84
+
85
+ @blocks[key] = body
86
+ end
87
+
88
+ def use_block(name, *args, **kwargs)
89
+ active = false
90
+ key = helper_name(name, "block")
91
+ body = @blocks[key]
92
+ raise DSLError, "unknown block #{key}" unless body
93
+ raise DSLError, "recursive block #{key}" if @active_blocks.include?(key)
94
+
95
+ @active_blocks << key
96
+ active = true
97
+ instance_exec(*args, **kwargs, &body)
98
+ ensure
99
+ @active_blocks.pop if active
100
+ end
101
+
102
+ def bus(name, **lines)
103
+ prefix = helper_name(name, "bus").upcase
104
+ raise DSLError, "bus #{prefix} must declare at least one line" if lines.empty?
105
+ raise DSLError, "bus cannot be declared inside expect" if @expected
106
+
107
+ labels = {}
108
+ lines.each do |line, reference|
109
+ key = helper_name(line, "bus line").upcase
110
+ raise DSLError, "duplicate bus line #{key}" if labels.key?(key)
111
+ raise DSLError, "bus line #{key} reference must be text" unless reference.is_a?(String) && !reference.empty?
112
+
113
+ labels[key] = reference
114
+ end
115
+ labels.each { |line, reference| net "#{prefix}_#{line}", at: reference }
116
+ end
117
+
118
+ def step(number, title: nil, &body)
119
+ active = false
120
+ raise DSLError, "nested step declarations are not allowed" if @current_step
121
+ expected = document.steps.length + 1
122
+ raise DSLError, "step number must be #{expected}" unless number.is_a?(Integer) && number == expected
123
+ raise DSLError, "step title must be nonempty text" unless title.nil? || (title.is_a?(String) && !title.empty?)
124
+ raise DSLError, "step #{number} requires a body" unless body
125
+
126
+ document.steps << { number: number, title: title, source: source_location }
127
+ @current_step = number
128
+ active = true
129
+ instance_eval(&body)
130
+ ensure
131
+ @current_step = nil if active
132
+ end
133
+
134
+ def supply(name = nil, voltage: nil, plus: nil, minus: nil, from: nil, isolated: false, current_limit: nil)
135
+ if from
136
+ raise DSLError, "supply from: cannot mix standalone supply options" if name || voltage || isolated != false || current_limit
137
+ raise DSLError, "supply from: requires plus and minus destinations" unless plus && minus
138
+ unless [from, plus, minus].all? { |value| value.is_a?(String) && !value.empty? }
139
+ raise DSLError, "supply from:, plus:, and minus: must be nonempty text"
140
+ end
141
+
142
+ document.wires << { supply_from: from, plus: plus, minus: minus,
143
+ location: source_location, step: @current_step }
144
+ return
145
+ end
146
+
147
+ raise DSLError, "supply requires name, voltage, plus, and minus" unless name && voltage && plus && minus
148
+
149
+ range = voltage.is_a?(Range) ? [Value.parse(voltage.begin), Value.parse(voltage.end)] : nil
150
+ raise DSLError, "supply voltage range must be inclusive and ascending" if range && (voltage.exclude_end? || range[0] > range[1])
151
+ parsed = range ? (range[0] + range[1]) / 2.0 : Value.parse(voltage)
152
+ raise DSLError, "supply voltage must be positive" unless parsed.finite? && parsed.positive?
153
+ raise DSLError, "supply voltage range must be positive" if range && range.any? { |value| !value.finite? || !value.positive? }
154
+ raise DSLError, "isolated must be boolean" unless [true, false].include?(isolated)
155
+ if !current_limit.nil? && (!current_limit.is_a?(Numeric) || !current_limit.real? || !current_limit.finite? || !current_limit.positive?)
156
+ raise DSLError, "current_limit must be a positive current in amperes"
157
+ end
158
+ document.supplies << { name: name.to_s, voltage: parsed, plus: plus.to_s,
159
+ minus: minus.to_s, isolated: isolated, voltage_range: range,
160
+ current_limit: current_limit, location: source_location, step: @current_step }
43
161
  end
44
162
 
45
163
  def net(name, *refs, at: nil, **options)
@@ -48,16 +166,18 @@ module Breadkit
48
166
  else
49
167
  raise DSLError, "net requires at: outside expect" if at.nil? || !refs.empty? || !options.empty?
50
168
 
51
- document.labels << { name: name.to_s, at: at.to_s, location: source_location }
169
+ document.labels << { name: name.to_s, at: at.to_s, location: source_location, step: @current_step }
52
170
  end
53
171
  end
54
172
 
55
173
  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) }
174
+ unsupported = attrs.keys.find { |key| key.to_sym == :wire_layer }
57
175
  raise DSLError, "unsupported component option #{unsupported}" if unsupported
176
+ raise DSLError, "rotate must be 0, 90, 180, or 270" if attrs.key?(:rotate) && ![0, 90, 180, 270].include?(attrs[:rotate])
177
+ raise DSLError, "mirror must be boolean" if attrs.key?(:mirror) && ![true, false].include?(attrs[:mirror])
58
178
 
59
179
  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 }
180
+ attrs: attrs, unused: Array(attrs.delete(:unused)), location: source_location, step: @current_step }
61
181
  end
62
182
 
63
183
  def resistor(ref, value, **options)
@@ -99,24 +219,31 @@ module Breadkit
99
219
  def wire(from, to, color: nil, id: nil, route: :straight, layer: nil, electrical: true, dashed: false)
100
220
  document.wires << { id: id&.to_s, from: from.to_s, to: to.to_s, color: color&.to_s,
101
221
  route: route.to_s, layer: layer_names(layer), electrical: electrical != false,
102
- dashed: !!dashed, location: source_location }
222
+ dashed: !!dashed, location: source_location, step: @current_step }
103
223
  end
104
224
 
105
225
  def offboard(name, type, side: :left, at: nil, unused: [], **attrs)
106
- unsupported = attrs.keys.find { |key| %i[rotate wire_layer].include?(key.to_sym) }
226
+ unsupported = attrs.keys.find { |key| %i[rotate mirror wire_layer].include?(key.to_sym) }
107
227
  raise DSLError, "unsupported component option #{unsupported}" if unsupported
108
228
 
109
229
  attrs[:at] = at if at
110
230
  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 }
231
+ pins: nil, unused: Array(unused), location: source_location, offboard: true,
232
+ step: @current_step }
112
233
  end
113
234
 
114
- def expect(strict: false, &block)
235
+ def expect(strict: false, **options, &block)
236
+ unknown = options.keys - [:when]
237
+ raise DSLError, "unknown expect option #{unknown.first}" unless unknown.empty?
238
+ at_state = options[:when]&.to_s
239
+ raise DSLError, "expect when: must name a switch state" if options.key?(:when) && at_state.to_s.empty?
115
240
  entries = []
116
241
  previous = @expected
117
242
  @expected = entries
118
243
  instance_eval(&block)
119
- document.expectations << { strict: strict, entries: entries, location: source_location }
244
+ expectation = { strict: strict, entries: entries, location: source_location }
245
+ expectation[:when] = at_state if at_state
246
+ document.expectations << expectation
120
247
  ensure
121
248
  @expected = previous
122
249
  end
@@ -127,12 +254,36 @@ module Breadkit
127
254
  @expected << { kind: "connected", refs: refs.map(&:to_s), location: source_location }
128
255
  end
129
256
 
257
+ def connect(*refs, **options)
258
+ raise DSLError, "connect is an intent assertion; use connected inside expect" if @expected
259
+ raise DSLError, "connect requires exactly two references and does not place a wire" unless refs.length == 2
260
+ raise DSLError, "connect references must be nonempty text" unless refs.all? { |ref| (ref.is_a?(String) || ref.is_a?(Symbol)) && !ref.to_s.empty? }
261
+ unknown = options.keys - [:when]
262
+ raise DSLError, "unknown connect option #{unknown.first}" unless unknown.empty?
263
+
264
+ at_state = options[:when]&.to_s
265
+ raise DSLError, "connect when: must name a switch state" if options.key?(:when) && at_state.to_s.empty?
266
+
267
+ location = source_location
268
+ expectation = { strict: false, entries: [{ kind: "connected", refs: refs.map(&:to_s), location: location }], location: location }
269
+ expectation[:when] = at_state if at_state
270
+ document.expectations << expectation
271
+ end
272
+
130
273
  def isolated(*refs)
131
274
  raise DSLError, "isolated must be inside expect" unless @expected
132
275
 
133
276
  @expected << { kind: "isolated", refs: refs.map(&:to_s), location: source_location }
134
277
  end
135
278
 
279
+ def expect_voltage(reference, range)
280
+ measurement_expectation("voltage", reference, range)
281
+ end
282
+
283
+ def expect_current(reference, range)
284
+ measurement_expectation("current", reference, range)
285
+ end
286
+
136
287
  def lint_disable(rule_id, on: nil, reason: nil)
137
288
  document.lint_disables << { rule: rule_id.to_s, on: on&.to_s, reason: reason, location: source_location }
138
289
  end
@@ -149,6 +300,29 @@ module Breadkit
149
300
 
150
301
  private
151
302
 
303
+ def helper_name(value, kind)
304
+ name = value.to_s
305
+ raise DSLError, "invalid #{kind} name #{name.inspect}" unless value.is_a?(String) || value.is_a?(Symbol)
306
+ raise DSLError, "invalid #{kind} name #{name.inspect}" unless /\A[A-Za-z][A-Za-z0-9_]*\z/.match?(name)
307
+
308
+ name
309
+ end
310
+
311
+ def measurement_expectation(kind, reference, range)
312
+ raise DSLError, "#{kind} expectation requires an inclusive range" unless range.is_a?(Range) && !range.exclude_end?
313
+
314
+ limits = [Value.parse(range.begin), Value.parse(range.end)]
315
+ raise DSLError, "#{kind} expectation range must be inclusive and ascending" unless limits.all?(&:finite?) && limits.first <= limits.last
316
+ raise DSLError, "current expectation cannot be negative" if kind == "current" && limits.first.negative?
317
+
318
+ entry = { kind: kind, refs: [reference.to_s], range: limits, location: source_location }
319
+ if @expected
320
+ @expected << entry
321
+ else
322
+ document.expectations << { strict: false, entries: [entry], location: source_location }
323
+ end
324
+ end
325
+
152
326
  def source_location
153
327
  loc = caller_locations(2, 12).find { |item| item.path && File.expand_path(item.path) != __FILE__ }
154
328
  SourceLocation.new(path: loc&.path, line: loc&.lineno)
@@ -166,17 +340,24 @@ module Breadkit
166
340
  end
167
341
  end
168
342
 
169
- def self.load_file(path)
343
+ def self.load_file(path, timeout: 10, source: nil)
344
+ raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
170
345
  absolute = File.expand_path(path)
171
346
  builder = Builder.new(base_dir: File.dirname(absolute))
172
- builder.instance_eval(File.read(absolute, encoding: "UTF-8"), absolute, 1)
347
+ builder.document.source_root = File.dirname(absolute)
348
+ Timeout.timeout(timeout) { builder.instance_eval(source || File.read(absolute, encoding: "UTF-8"), absolute, 1) }
173
349
  builder.document
174
- rescue DSLError
175
- raise
176
- rescue ScriptError, StandardError => e
350
+ rescue DSLError, ScriptError, StandardError, SystemExit, SystemStackError => e
177
351
  line = e.backtrace_locations&.find { |frame| frame.path == absolute }&.lineno
178
- location = line ? "#{path}:#{line}" : path
179
- raise DSLError, "#{location}: #{e.message}"
352
+ location = e.is_a?(DSLError) && e.location || SourceLocation.new(path: path, line: line)
353
+ detail = if e.is_a?(SystemExit)
354
+ "exit is not allowed in a circuit file"
355
+ elsif e.is_a?(Timeout::Error)
356
+ "circuit evaluation timed out after #{timeout} seconds"
357
+ else
358
+ e.message
359
+ end
360
+ raise DSLError.new("#{location.path}#{":#{location.line}" if location.line}: #{detail}", location: location)
180
361
  end
181
362
  end
182
363
  end