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,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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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|
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
179
|
-
|
|
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
|