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
@@ -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
- x += (index - 1) / size if size.positive?
154
- y = rail.fetch("side") == "top" ? height + 1 + rail.fetch("order", 0) : -2 - rail.fetch("order", 0)
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 = "Usage: breadkit ir FILE | nets [--state SWITCH] FILE | parts [FILE] | --version".freeze
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 { |opts| opts.on("--state SWITCH") { |value| state_name = value } }.parse!(args)
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
- errors = circuit.diagnostics.select { |item| item.severity == "error" }
25
- errors.each { |item| warn "breadkit: #{item.code}: #{item.message}" }
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
- state = circuit.states.find { |item| item.name == state_name } if state_name
32
- raise ArgumentError, "unknown switch state #{state_name}" if state_name && !state
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
- circuit.diagnostics.any? { |item| item.severity == "error" } ? 1 : 0
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
- raise ArgumentError, "unexpected arguments: #{args.join(' ')}" unless args.empty?
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
- .all.each { |part| puts "#{part.id}\t#{part.data['category']}" }
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