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
data/lib/breadkit/board.rb
CHANGED
|
@@ -6,7 +6,20 @@ module Breadkit
|
|
|
6
6
|
|
|
7
7
|
def initialize(data)
|
|
8
8
|
@data = data
|
|
9
|
+
unknown = data.keys - %w[id name terminal rails rail_layout ground_labels]
|
|
10
|
+
raise ArgumentError, "unknown board keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
9
11
|
terminal = data["terminal"] || {}
|
|
12
|
+
unknown_terminal = terminal.keys - %w[columns rows groups ravine_between strip_direction wire_attachment]
|
|
13
|
+
raise ArgumentError, "unknown terminal keys: #{unknown_terminal.join(', ')}" unless unknown_terminal.empty?
|
|
14
|
+
unless %w[column row].include?(terminal.fetch("strip_direction", "column"))
|
|
15
|
+
raise ArgumentError, "terminal.strip_direction must be column or row"
|
|
16
|
+
end
|
|
17
|
+
unless %w[socket solder].include?(terminal.fetch("wire_attachment", "socket"))
|
|
18
|
+
raise ArgumentError, "terminal.wire_attachment must be socket or solder"
|
|
19
|
+
end
|
|
20
|
+
if data["ground_labels"] && (!data["ground_labels"].is_a?(Array) || !data["ground_labels"].all? { |label| label.is_a?(String) && !label.empty? })
|
|
21
|
+
raise ArgumentError, "ground_labels must be a list of names"
|
|
22
|
+
end
|
|
10
23
|
unless data["id"] && terminal["columns"].to_i.positive? && terminal["rows"].is_a?(Array) && terminal["groups"].is_a?(Array)
|
|
11
24
|
raise ArgumentError, "invalid board definition: #{data['id'] || '(missing id)'}"
|
|
12
25
|
end
|
|
@@ -34,12 +47,26 @@ module Breadkit
|
|
|
34
47
|
unless groups.all? { |group| group.is_a?(Array) && !group.empty? } && grouped_rows.sort == rows.sort
|
|
35
48
|
raise ArgumentError, "terminal groups must partition rows exactly"
|
|
36
49
|
end
|
|
50
|
+
if terminal.fetch("strip_direction", "column") == "row" && groups.any? { |group| group.length != 1 }
|
|
51
|
+
raise ArgumentError, "row strips require one terminal row per group"
|
|
52
|
+
end
|
|
37
53
|
if terminal.key?("ravine_between")
|
|
38
54
|
ravine = terminal["ravine_between"]
|
|
39
55
|
unless ravine.is_a?(Array) && ravine.length == 2 && rows.each_cons(2).any? { |pair| pair == ravine.map(&:to_s) }
|
|
40
56
|
raise ArgumentError, "ravine_between must name adjacent terminal rows"
|
|
41
57
|
end
|
|
42
58
|
end
|
|
59
|
+
Array(data["rails"]).each do |rail|
|
|
60
|
+
side, order = rail.values_at("side", "order")
|
|
61
|
+
unless %w[top bottom left right center].include?(side) && order.is_a?(Integer) && order >= 0
|
|
62
|
+
raise ArgumentError, "rail side or order is invalid"
|
|
63
|
+
end
|
|
64
|
+
if side == "center" && (!terminal.key?("ravine_between") || order > 1)
|
|
65
|
+
raise ArgumentError, "center rail needs a free row in the ravine"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
rail_positions = Array(data["rails"]).map { |rail| rail.values_at("side", "order") }
|
|
69
|
+
raise ArgumentError, "rail positions overlap" unless rail_positions.uniq.length == rail_positions.length
|
|
43
70
|
end
|
|
44
71
|
|
|
45
72
|
def id
|
|
@@ -87,6 +114,11 @@ module Breadkit
|
|
|
87
114
|
item && strips[item.strip_id]
|
|
88
115
|
end
|
|
89
116
|
|
|
117
|
+
def solder_pad?(id)
|
|
118
|
+
item = hole(id)
|
|
119
|
+
item&.kind == :terminal && definition.data.dig("terminal", "wire_attachment") == "solder"
|
|
120
|
+
end
|
|
121
|
+
|
|
90
122
|
def width
|
|
91
123
|
definition.data.dig("terminal", "columns").to_i
|
|
92
124
|
end
|
|
@@ -116,6 +148,18 @@ module Breadkit
|
|
|
116
148
|
rail && (rail["polarity"] || rail.fetch("id").to_s[/[+-]\z/])
|
|
117
149
|
end
|
|
118
150
|
|
|
151
|
+
def definition_for(_reference)
|
|
152
|
+
definition
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def ravine_between_for(_reference)
|
|
156
|
+
ravine_between
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def board_id_for(_reference)
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
119
163
|
private
|
|
120
164
|
|
|
121
165
|
def add_hole(hole)
|
|
@@ -126,10 +170,11 @@ module Breadkit
|
|
|
126
170
|
def build_terminal_holes
|
|
127
171
|
terminal = definition.data.fetch("terminal")
|
|
128
172
|
groups = terminal.fetch("groups")
|
|
173
|
+
row_strips = terminal.fetch("strip_direction", "column") == "row"
|
|
129
174
|
terminal.fetch("columns").times do |col|
|
|
130
175
|
groups.each_with_index do |rows, group_index|
|
|
131
|
-
strip_id = "terminal:#{col + 1}:#{group_index}"
|
|
132
176
|
rows.each do |row|
|
|
177
|
+
strip_id = row_strips ? "terminal:row:#{row}" : "terminal:#{col + 1}:#{group_index}"
|
|
133
178
|
add_hole(Hole.new(id: "#{row}#{col + 1}", kind: :terminal, row: row.to_s,
|
|
134
179
|
col: col + 1, x: col.to_f, y: @terminal_row_positions.fetch(row.to_s).to_f,
|
|
135
180
|
strip_id: strip_id))
|
|
@@ -148,10 +193,21 @@ module Breadkit
|
|
|
148
193
|
segments = layout.fetch("split_segments", segments) if split_rails
|
|
149
194
|
segments.each_with_index do |range, segment_index|
|
|
150
195
|
(range[0]..range[1]).each do |index|
|
|
151
|
-
x = layout.fetch("start_column", 1) - 1 + index - 1
|
|
152
196
|
size = layout["group_size"].to_i
|
|
153
|
-
|
|
154
|
-
|
|
197
|
+
along = index - 1
|
|
198
|
+
along += (index - 1) / size if size.positive?
|
|
199
|
+
side, order = rail.values_at("side", "order")
|
|
200
|
+
if %w[left right].include?(side)
|
|
201
|
+
x = side == "left" ? -2 - order : width + 1 + order
|
|
202
|
+
y = layout.fetch("start_row", 1) - 1 + along
|
|
203
|
+
else
|
|
204
|
+
x = layout.fetch("start_column", 1) - 1 + along
|
|
205
|
+
y = case side
|
|
206
|
+
when "top" then height + 1 + order
|
|
207
|
+
when "center" then @terminal_row_positions.fetch(definition.data.dig("terminal", "ravine_between", 0).to_s) + 1 + order
|
|
208
|
+
else -2 - order
|
|
209
|
+
end
|
|
210
|
+
end
|
|
155
211
|
strip_id = "rail:#{rail_id}:#{segment_index}"
|
|
156
212
|
add_hole(Hole.new(id: "#{rail_id}#{index}", kind: :rail, rail: rail_id,
|
|
157
213
|
col: index, x: x.to_f, y: y.to_f, strip_id: strip_id))
|
|
@@ -160,4 +216,96 @@ module Breadkit
|
|
|
160
216
|
end
|
|
161
217
|
end
|
|
162
218
|
end
|
|
219
|
+
|
|
220
|
+
class BoardSet
|
|
221
|
+
Definition = Data.define(:id, :data)
|
|
222
|
+
|
|
223
|
+
attr_reader :boards, :holes, :strips, :definition
|
|
224
|
+
|
|
225
|
+
def initialize(boards)
|
|
226
|
+
@boards = boards
|
|
227
|
+
@holes, @strips = {}, {}
|
|
228
|
+
@terminal_rows = []
|
|
229
|
+
@rail_ids = []
|
|
230
|
+
right_edge = nil
|
|
231
|
+
boards.each do |name, board|
|
|
232
|
+
@terminal_rows.concat(board.terminal_rows.map { |row| "#{name}.#{row}" })
|
|
233
|
+
@rail_ids.concat(board.rail_ids.map { |rail| "#{name}.#{rail}" })
|
|
234
|
+
left, right = board.holes.values.map(&:x).minmax
|
|
235
|
+
raise ArgumentError, "board #{name} has no holes" unless left
|
|
236
|
+
offset = right_edge ? right_edge + 8 - left : -left
|
|
237
|
+
board.holes.each_value do |hole|
|
|
238
|
+
id = "#{name}.#{hole.id}"
|
|
239
|
+
strip_id = "#{name}.#{hole.strip_id}"
|
|
240
|
+
@holes[id] = Hole.new(id: id, kind: hole.kind, row: hole.row, col: hole.col,
|
|
241
|
+
rail: hole.rail && "#{name}.#{hole.rail}", x: hole.x + offset,
|
|
242
|
+
y: hole.y, strip_id: strip_id)
|
|
243
|
+
(@strips[strip_id] ||= []) << id
|
|
244
|
+
end
|
|
245
|
+
right_edge = right + offset
|
|
246
|
+
end
|
|
247
|
+
ground_labels = boards.values.flat_map { |board| Array(board.definition.data["ground_labels"] || %w[GND 0V VSS GROUND]) }.uniq
|
|
248
|
+
@definition = Definition.new(id: "multi", data: { "ground_labels" => ground_labels })
|
|
249
|
+
@width = right_edge ? right_edge.ceil + 1 : 0
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def hole(id)
|
|
253
|
+
holes[HoleId.parse(id, board: self).to_s]
|
|
254
|
+
rescue ArgumentError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def strip(id)
|
|
259
|
+
item = hole(id)
|
|
260
|
+
item && strips[item.strip_id]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def solder_pad?(id)
|
|
264
|
+
board_for(id)&.solder_pad?(id.to_s.split(".", 2).last) || false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def terminal_rows
|
|
268
|
+
@terminal_rows
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def rail_ids
|
|
272
|
+
@rail_ids
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def width
|
|
276
|
+
@width
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def height
|
|
280
|
+
boards.values.map(&:height).max || 0
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def split_rails
|
|
284
|
+
false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def board_id_for(reference)
|
|
288
|
+
prefix = reference.to_s.split(".", 2).first
|
|
289
|
+
boards.keys.find { |name| name.casecmp?(prefix) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def board_for(reference)
|
|
293
|
+
boards[board_id_for(reference)]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def definition_for(reference)
|
|
297
|
+
board_for(reference)&.definition
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def ravine_between_for(reference)
|
|
301
|
+
name = board_id_for(reference)
|
|
302
|
+
board = boards[name]
|
|
303
|
+
board ? board.ravine_between.map { |row| "#{name}.#{row}" } : []
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def rail_polarity(id)
|
|
307
|
+
name = board_id_for(id)
|
|
308
|
+
boards[name]&.rail_polarity(id.to_s.delete_prefix("#{name}."))
|
|
309
|
+
end
|
|
310
|
+
end
|
|
163
311
|
end
|
data/lib/breadkit/cli.rb
CHANGED
|
@@ -4,7 +4,22 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
module Breadkit
|
|
6
6
|
class CLI
|
|
7
|
-
USAGE =
|
|
7
|
+
USAGE = <<~TEXT.freeze
|
|
8
|
+
Usage: breadkit COMMAND [OPTIONS]
|
|
9
|
+
ir [--force] [--timeout SECONDS] FILE
|
|
10
|
+
nets [--state SWITCH] [--timeout SECONDS] FILE
|
|
11
|
+
where HOLE FILE | explain PART FILE | bom FILE | diff OLD NEW
|
|
12
|
+
suggest FILE (show free-hole wire candidates for unmet connection intent)
|
|
13
|
+
kit --inventory KIT.yml FILE (allocate measured jumpers to straight board wires)
|
|
14
|
+
patterns FILE (recognize supported unloaded circuit patterns)
|
|
15
|
+
export --format kicad|spice|wokwi|pins|fritzing FILE
|
|
16
|
+
fmt FILE (declarative YAML or TOML; prints formatted source)
|
|
17
|
+
lock FILE (pin local part and board definitions in breadkit.lock)
|
|
18
|
+
parts [FILE] | parts show PART [FILE] | check-part YAML
|
|
19
|
+
new [FILE] --template led|555|arduino | doctor | console FILE
|
|
20
|
+
--version | --help
|
|
21
|
+
TEXT
|
|
22
|
+
TEMPLATES = %w[led 555 arduino].freeze
|
|
8
23
|
|
|
9
24
|
def run(argv)
|
|
10
25
|
args = argv.dup
|
|
@@ -12,34 +27,151 @@ module Breadkit
|
|
|
12
27
|
case command
|
|
13
28
|
when "ir", "nets"
|
|
14
29
|
state_name = nil
|
|
30
|
+
force = false
|
|
31
|
+
timeout = 10.0
|
|
15
32
|
if command == "nets"
|
|
16
|
-
OptionParser.new
|
|
33
|
+
OptionParser.new do |opts|
|
|
34
|
+
opts.on("--state SWITCH") { |value| state_name = value }
|
|
35
|
+
opts.on("--timeout SECONDS", Float) { |value| timeout = value }
|
|
36
|
+
end.parse!(args)
|
|
37
|
+
else
|
|
38
|
+
OptionParser.new do |opts|
|
|
39
|
+
opts.on("--force") { force = true }
|
|
40
|
+
opts.on("--timeout SECONDS", Float) { |value| timeout = value }
|
|
41
|
+
end.parse!(args)
|
|
17
42
|
end
|
|
18
43
|
path = args.shift
|
|
19
44
|
raise ArgumentError, "usage: breadkit #{command} FILE" unless path
|
|
20
45
|
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
21
46
|
|
|
22
|
-
circuit = Breadkit.load(path)
|
|
47
|
+
circuit = Breadkit.load(path, timeout: timeout)
|
|
23
48
|
if command == "ir"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return 1 unless errors.empty?
|
|
49
|
+
has_errors = report_errors(circuit)
|
|
50
|
+
return 1 if has_errors && !force
|
|
27
51
|
|
|
28
52
|
puts JSON.pretty_generate(circuit.to_ir)
|
|
29
53
|
0
|
|
30
54
|
else
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
return 1 if report_errors(circuit)
|
|
56
|
+
|
|
57
|
+
state = circuit.state(state_name)
|
|
33
58
|
circuit.nets(state).each { |net| puts "#{net.name}: #{net.members.join(', ')}" }
|
|
34
|
-
|
|
59
|
+
0
|
|
35
60
|
end
|
|
36
61
|
when "parts"
|
|
62
|
+
show = args.shift if args.first == "show"
|
|
63
|
+
name = args.shift if show
|
|
64
|
+
raise ArgumentError, "usage: breadkit parts show PART [FILE]" if show && !name
|
|
37
65
|
path = args.shift
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
unexpected!(args)
|
|
40
67
|
document = DSL.load_file(path) if path
|
|
41
|
-
PartLibrary.new(extra_paths: document&.part_paths || [], extra_definitions: document&.part_definitions || [])
|
|
42
|
-
|
|
68
|
+
library = PartLibrary.new(extra_paths: document&.part_paths || [], extra_definitions: document&.part_definitions || [])
|
|
69
|
+
if show
|
|
70
|
+
part = library.find(name)
|
|
71
|
+
raise ArgumentError, "unknown part #{name}" unless part
|
|
72
|
+
puts JSON.pretty_generate(part.data)
|
|
73
|
+
else
|
|
74
|
+
library.all.each { |part| puts "#{part.id}\t#{part.data['category']}" }
|
|
75
|
+
end
|
|
76
|
+
0
|
|
77
|
+
when "check-part"
|
|
78
|
+
path = required!(args, "check-part YAML")
|
|
79
|
+
data = YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false)
|
|
80
|
+
raise ArgumentError, "part definition must be a YAML mapping" unless data.is_a?(Hash)
|
|
81
|
+
part = PartLibrary.new(extra_paths: [path]).find(data["id"])
|
|
82
|
+
raise ArgumentError, "part definition needs an id" unless part
|
|
83
|
+
puts "Valid part: #{part.id} (#{part.pins.length} pins)"
|
|
84
|
+
0
|
|
85
|
+
when "where", "explain", "bom"
|
|
86
|
+
state_name = nil
|
|
87
|
+
timeout = 10.0
|
|
88
|
+
OptionParser.new do |opts|
|
|
89
|
+
opts.on("--state SWITCH") { |value| state_name = value }
|
|
90
|
+
opts.on("--timeout SECONDS", Float) { |value| timeout = value }
|
|
91
|
+
end.parse!(args)
|
|
92
|
+
target = args.shift unless command == "bom"
|
|
93
|
+
path = required!(args, command == "bom" ? "bom FILE" : "#{command} TARGET FILE")
|
|
94
|
+
circuit = Breadkit.load(path, timeout: timeout)
|
|
95
|
+
return 1 if report_errors(circuit)
|
|
96
|
+
|
|
97
|
+
state = select_state(circuit, state_name)
|
|
98
|
+
case command
|
|
99
|
+
when "where" then show_where(circuit, target, state)
|
|
100
|
+
when "explain" then show_explain(circuit, target, state)
|
|
101
|
+
when "bom" then show_bom(circuit)
|
|
102
|
+
end
|
|
103
|
+
0
|
|
104
|
+
when "new"
|
|
105
|
+
template = nil
|
|
106
|
+
OptionParser.new { |opts| opts.on("--template NAME") { |value| template = value } }.parse!(args)
|
|
107
|
+
raise ArgumentError, "choose --template #{TEMPLATES.join('|')}" unless TEMPLATES.include?(template)
|
|
108
|
+
path = args.shift || "#{template}.bk.rb"
|
|
109
|
+
unexpected!(args)
|
|
110
|
+
source = File.expand_path("templates/#{template}.bk.rb", __dir__)
|
|
111
|
+
File.open(path, "wx:UTF-8") { |file| file.write(File.read(source, encoding: "UTF-8")) }
|
|
112
|
+
puts "Created #{path}"
|
|
113
|
+
0
|
|
114
|
+
when "doctor"
|
|
115
|
+
unexpected!(args)
|
|
116
|
+
supported = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3")
|
|
117
|
+
puts "Ruby #{RUBY_VERSION}: #{supported ? 'supported' : 'requires 3.3 or newer'}"
|
|
118
|
+
%w[bklint bkrender].each do |name|
|
|
119
|
+
found = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
|
|
120
|
+
puts "#{name}: #{found ? 'available' : 'not installed'}"
|
|
121
|
+
end
|
|
122
|
+
supported ? 0 : 1
|
|
123
|
+
when "diff"
|
|
124
|
+
old_path = args.shift
|
|
125
|
+
new_path = required!(args, "diff OLD NEW")
|
|
126
|
+
raise ArgumentError, "usage: breadkit diff OLD NEW" unless old_path
|
|
127
|
+
show_diff(Breadkit.load(old_path), Breadkit.load(new_path))
|
|
128
|
+
0
|
|
129
|
+
when "export"
|
|
130
|
+
format = nil
|
|
131
|
+
OptionParser.new { |opts| opts.on("--format NAME") { |value| format = value } }.parse!(args)
|
|
132
|
+
raise ArgumentError, "choose --format #{Exporters::FORMATS.join('|')}" unless Exporters::FORMATS.include?(format)
|
|
133
|
+
path = required!(args, "export --format FORMAT FILE")
|
|
134
|
+
puts Exporters.call(Breadkit.load(path), format: format)
|
|
135
|
+
0
|
|
136
|
+
when "fmt"
|
|
137
|
+
path = required!(args, "fmt FILE")
|
|
138
|
+
print Formatter.call(path)
|
|
139
|
+
0
|
|
140
|
+
when "lock"
|
|
141
|
+
path = required!(args, "lock FILE")
|
|
142
|
+
puts "Wrote #{PartLock.write(path)}"
|
|
143
|
+
0
|
|
144
|
+
when "suggest"
|
|
145
|
+
path = required!(args, "suggest FILE")
|
|
146
|
+
circuit = Breadkit.load(path)
|
|
147
|
+
return 1 if report_errors(circuit)
|
|
148
|
+
|
|
149
|
+
puts JSON.pretty_generate(circuit.wire_suggestions)
|
|
150
|
+
0
|
|
151
|
+
when "kit"
|
|
152
|
+
inventory = nil
|
|
153
|
+
OptionParser.new { |opts| opts.on("--inventory FILE") { |value| inventory = value } }.parse!(args)
|
|
154
|
+
raise ArgumentError, "usage: breadkit kit --inventory KIT.yml FILE" unless inventory
|
|
155
|
+
|
|
156
|
+
path = required!(args, "kit --inventory KIT.yml FILE")
|
|
157
|
+
circuit = Breadkit.load(path)
|
|
158
|
+
return 1 if report_errors(circuit)
|
|
159
|
+
|
|
160
|
+
puts JSON.pretty_generate(JumperKit.load(inventory).allocate(circuit))
|
|
161
|
+
0
|
|
162
|
+
when "patterns"
|
|
163
|
+
path = required!(args, "patterns FILE")
|
|
164
|
+
circuit = Breadkit.load(path)
|
|
165
|
+
return 1 if report_errors(circuit)
|
|
166
|
+
|
|
167
|
+
puts JSON.pretty_generate(Patterns.call(circuit))
|
|
168
|
+
0
|
|
169
|
+
when "console"
|
|
170
|
+
path = required!(args, "console FILE")
|
|
171
|
+
circuit = Breadkit.load(path)
|
|
172
|
+
puts "Circuit loaded as `circuit`. Try circuit.nets or circuit.components."
|
|
173
|
+
require "irb"
|
|
174
|
+
binding.irb(show_code: false) # rubocop:disable Lint/Debugger -- This command intentionally opens a REPL.
|
|
43
175
|
0
|
|
44
176
|
when "--version", "-v"
|
|
45
177
|
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
@@ -57,5 +189,89 @@ module Breadkit
|
|
|
57
189
|
warn "breadkit: #{e.message}"
|
|
58
190
|
2
|
|
59
191
|
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def report_errors(circuit)
|
|
196
|
+
errors = circuit.diagnostics.select { |item| item.severity == "error" }
|
|
197
|
+
errors.each { |item| warn "breadkit: #{item.code}: #{item.message}" }
|
|
198
|
+
!errors.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def required!(args, usage)
|
|
202
|
+
path = args.shift
|
|
203
|
+
raise ArgumentError, "usage: breadkit #{usage}" unless path
|
|
204
|
+
unexpected!(args)
|
|
205
|
+
path
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def unexpected!(args)
|
|
209
|
+
raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def select_state(circuit, name)
|
|
213
|
+
return unless name
|
|
214
|
+
circuit.state(name)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def show_where(circuit, reference, state)
|
|
218
|
+
hole = circuit.board.hole(reference)
|
|
219
|
+
raise ArgumentError, "unknown hole #{reference}" unless hole
|
|
220
|
+
strip = circuit.board.strip(hole.id)
|
|
221
|
+
pins = circuit.components.values.flat_map do |component|
|
|
222
|
+
component.pins.values.filter_map do |pin|
|
|
223
|
+
"#{component.ref}.#{pin.name}@#{pin.hole_id}" if strip.include?(pin.hole_id)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
wires = circuit.wires.flat_map do |wire|
|
|
227
|
+
[wire.from, wire.to].filter_map { |end_at| "#{wire.id}@#{end_at}" if strip.include?(end_at) }
|
|
228
|
+
end
|
|
229
|
+
net = circuit.net_of(hole.id, state)
|
|
230
|
+
puts "Hole: #{hole.id}"
|
|
231
|
+
puts "Strip: #{strip.join(', ')}"
|
|
232
|
+
puts "Net: #{net&.name || 'unconnected'}"
|
|
233
|
+
puts "Pins: #{pins.empty? ? 'none' : pins.join(', ')}"
|
|
234
|
+
puts "Wires: #{wires.empty? ? 'none' : wires.join(', ')}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def show_explain(circuit, reference, state)
|
|
238
|
+
component = circuit.components[reference]
|
|
239
|
+
raise ArgumentError, "unknown component #{reference}" unless component
|
|
240
|
+
puts "#{component.ref}: #{component.part.id}#{" #{component.value}" if component.value}"
|
|
241
|
+
dc = circuit.dc_analysis(state)
|
|
242
|
+
potentials = []
|
|
243
|
+
component.pins.each_value do |pin|
|
|
244
|
+
net = circuit.net_of("#{component.ref}.#{pin.name}", state)
|
|
245
|
+
potential = dc.success? && net && dc.voltages.key?(net.name) ? dc.voltages[net.name] : net&.potential
|
|
246
|
+
potentials << potential
|
|
247
|
+
voltage = potential.nil? ? "unknown" : "#{format('%.3g', potential)} V"
|
|
248
|
+
voltage += " (relative)" if dc.success? && net && dc.floating.any? { |group| group.include?(net.name) }
|
|
249
|
+
puts " #{pin.name}: #{pin.hole_id || 'offboard'} | #{net&.name || 'unconnected'} | #{voltage}"
|
|
250
|
+
end
|
|
251
|
+
current = dc.currents[component.ref]&.abs if dc.success?
|
|
252
|
+
current ||= if component.part.id == "resistor" && potentials.length == 2 && potentials.none?(&:nil?)
|
|
253
|
+
resistance = Value.parse(component.value)
|
|
254
|
+
(potentials[0] - potentials[1]).abs / resistance if resistance.positive?
|
|
255
|
+
end
|
|
256
|
+
puts "Current: #{current ? "#{format('%.3g', current)} A" : 'unknown (DC operating point unavailable)'}"
|
|
257
|
+
puts "Power: #{format('%.3g', dc.power[component.ref])} W" if dc.success? && dc.power.key?(component.ref)
|
|
258
|
+
dc.assumptions.each { |assumption| puts "Assumption: #{assumption}" } if dc.success? && dc.currents.key?(component.ref)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def show_bom(circuit)
|
|
262
|
+
counts = circuit.components.values.map { |component| [component.part.id, component.value&.to_s] }.tally
|
|
263
|
+
puts "Count\tPart\tValue"
|
|
264
|
+
counts.sort_by { |(part, value), _count| [part, value.to_s] }.each do |(part, value), count|
|
|
265
|
+
puts "#{count}\t#{part}\t#{value}"
|
|
266
|
+
end
|
|
267
|
+
puts "#{circuit.wires.length}\tjumper_wire\t"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def show_diff(old_circuit, new_circuit)
|
|
271
|
+
old_circuit.diff(new_circuit).each do |key, (before, after)|
|
|
272
|
+
puts "- #{key}: #{before}" if before
|
|
273
|
+
puts "+ #{key}: #{after}" if after
|
|
274
|
+
end
|
|
275
|
+
end
|
|
60
276
|
end
|
|
61
277
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module Color
|
|
5
|
+
NAMES = %w[
|
|
6
|
+
aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown
|
|
7
|
+
burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan
|
|
8
|
+
darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred
|
|
9
|
+
darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue
|
|
10
|
+
dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray
|
|
11
|
+
green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen
|
|
12
|
+
lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink
|
|
13
|
+
lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen
|
|
14
|
+
linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue
|
|
15
|
+
mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy
|
|
16
|
+
oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip
|
|
17
|
+
peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown
|
|
18
|
+
seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle
|
|
19
|
+
tomato turquoise violet wheat white whitesmoke yellow yellowgreen
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.valid?(value)
|
|
23
|
+
text = value.to_s.downcase
|
|
24
|
+
NAMES.include?(text) || /\A#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\z/.match?(text) ||
|
|
25
|
+
valid_function?(text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.valid_function?(text)
|
|
29
|
+
if (match = /\Argb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)\z/.match(text))
|
|
30
|
+
return match.captures.all? { |channel| channel.to_i <= 255 }
|
|
31
|
+
end
|
|
32
|
+
if (match = /\Ahsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)\z/.match(text))
|
|
33
|
+
return match[1].to_i <= 360 && match[2].to_i <= 100 && match[3].to_i <= 100
|
|
34
|
+
end
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|