breadkit-lint 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.yamllint +16 -0
  3. data/CHANGELOG.md +30 -2
  4. data/README.md +55 -40
  5. data/action.yml +42 -0
  6. data/config/default.yml +33 -0
  7. data/docs/REFERENCE.md +129 -0
  8. data/docs/rules/Electrical/CapacitorVoltageRating.md +5 -0
  9. data/docs/rules/Electrical/FloatingInput.md +11 -0
  10. data/docs/rules/Electrical/GpioOvercurrent.md +18 -0
  11. data/docs/rules/Electrical/I2CAddressConflict.md +5 -0
  12. data/docs/rules/Electrical/I2CPullupMissing.md +5 -0
  13. data/docs/rules/Electrical/LedOvercurrent.md +5 -0
  14. data/docs/rules/Electrical/MinimumResistance.md +14 -0
  15. data/docs/rules/Electrical/MissingBaseResistor.md +5 -0
  16. data/docs/rules/Electrical/MissingDecouplingCapacitor.md +5 -0
  17. data/docs/rules/Electrical/MissingFlybackDiode.md +18 -0
  18. data/docs/rules/Electrical/MissingPullResistor.md +17 -0
  19. data/docs/rules/Electrical/MissingSeriesResistor.md +3 -1
  20. data/docs/rules/Electrical/NoCommonGround.md +1 -0
  21. data/docs/rules/Electrical/RailPolarityMismatch.md +10 -0
  22. data/docs/rules/Electrical/ResistorPowerRating.md +5 -0
  23. data/docs/rules/Electrical/ReversePolarity.md +1 -1
  24. data/docs/rules/Electrical/SupplyOverload.md +11 -0
  25. data/docs/rules/Electrical/SupplyVoltageRange.md +1 -0
  26. data/docs/rules/Electrical/VoltageDomainMismatch.md +6 -0
  27. data/docs/rules/Intent/ConnectionMismatch.md +3 -1
  28. data/docs/rules/Intent/MeasurementUnavailable.md +10 -0
  29. data/docs/rules/Layout/AmbiguousSupplySource.md +5 -0
  30. data/docs/rules/Layout/BodyOverlap.md +11 -0
  31. data/docs/rules/Layout/HoleCovered.md +11 -0
  32. data/docs/rules/Layout/InvalidColor.md +3 -0
  33. data/docs/rules/Layout/InvalidOption.md +3 -0
  34. data/docs/rules/Layout/InvalidRoute.md +3 -0
  35. data/docs/rules/Layout/InvalidValue.md +3 -0
  36. data/docs/rules/Layout/InvalidWireId.md +3 -0
  37. data/docs/rules/Layout/LeadSpan.md +21 -0
  38. data/docs/rules/Layout/PartOverride.md +3 -0
  39. data/docs/rules/Layout/UnknownSupplySource.md +5 -0
  40. data/docs/rules/Layout/UnmatchedParts.md +3 -0
  41. data/docs/rules/Layout/WireOverIC.md +11 -0
  42. data/docs/rules/Lint/RedundantDisable.md +3 -0
  43. data/docs/rules/Lint/UnknownRuleInDisable.md +5 -0
  44. data/lib/breadkit/lint/baseline.rb +38 -0
  45. data/lib/breadkit/lint/rake_task.rb +22 -0
  46. data/lib/breadkit/lint/rules/electrical.rb +580 -30
  47. data/lib/breadkit/lint/rules/intent.rb +69 -4
  48. data/lib/breadkit/lint/rules/layout.rb +148 -0
  49. data/lib/breadkit/lint/rules/style.rb +36 -7
  50. data/lib/breadkit/lint/rules/support.rb +45 -11
  51. data/lib/breadkit/lint/rules.rb +205 -2
  52. data/lib/breadkit/lint/source_editor.rb +138 -0
  53. data/lib/breadkit/lint/version.rb +1 -1
  54. data/lib/breadkit/lint.rb +422 -59
  55. data/lib/guard/breadkit/templates/Guardfile +7 -0
  56. data/lib/guard/breadkit.rb +26 -0
  57. data/locales/en.yml +82 -0
  58. data/locales/ja.yml +130 -4
  59. data/locales/ko.yml +191 -0
  60. data/locales/zh.yml +191 -0
  61. data/package-lock.json +103 -0
  62. data/package.json +2 -1
  63. data/scripts/action-run.sh +51 -0
  64. data/scripts/build-rule-pages.mjs +87 -0
  65. data/scripts/check-rule-pages.mjs +18 -0
  66. data/scripts/pre-commit +24 -0
  67. data/sig/breadkit/lint.rbs +12 -1
  68. data/site/index.html +9 -6
  69. data/site/schemas/lint-v1.json +71 -0
  70. data/site/styles.css +13 -0
  71. metadata +64 -4
@@ -4,6 +4,8 @@ module Breadkit
4
4
  module Lint
5
5
  class Checks
6
6
  def short_circuit(circuit, rule, state)
7
+ return [] if circuit.voltage_sources.empty?
8
+
7
9
  baseline = if state.closed_switches.empty?
8
10
  []
9
11
  else
@@ -61,6 +63,108 @@ module Breadkit
61
63
  end
62
64
  end
63
65
 
66
+ def floating_inputs(circuit, rule, state)
67
+ anchored = nil
68
+ circuit.components.values.flat_map do |component|
69
+ component.pins.values.filter_map do |pin|
70
+ next unless pin.role == "input" && pin.hole_id
71
+ next if component.unused.include?(pin.name) || component.unused.include?(pin.number)
72
+ reference = "#{component.ref}.#{pin.name}"
73
+ net = circuit.net_of(reference, state)
74
+ next unless net
75
+ anchored ||= anchored_input_nets(circuit, state)
76
+ next if anchored[net.name]
77
+ connected = net.members.any? do |member|
78
+ member != reference && (!member.include?(".") || member.split(".", 2).first != component.ref)
79
+ end
80
+ next unless connected
81
+ offense(rule.id, translate("floating_input", "#{reference} has no modeled source or pull resistor", pin: reference),
82
+ component.location, targets: { components: [component.ref], pins: [reference], holes: [pin.hole_id], nets: [net.name] },
83
+ state: state.name)
84
+ end
85
+ end
86
+ end
87
+
88
+ def missing_pull_resistors(circuit, rule, state)
89
+ switches = supply_switch_input_nets(circuit, state)
90
+ return [] if switches.empty?
91
+ anchored = anchored_input_nets(circuit, state)
92
+ circuit.components.values.flat_map do |component|
93
+ component.pins.values.filter_map do |pin|
94
+ next unless pin.role == "input" && pin.hole_id
95
+ next if component.unused.include?(pin.name) || component.unused.include?(pin.number)
96
+ reference = "#{component.ref}.#{pin.name}"
97
+ net = circuit.net_of(reference, state)
98
+ switch = net && switches[net.name]
99
+ next unless switch && !anchored[net.name]
100
+ message = translate("missing_pull_resistor", "#{reference} can float when #{switch.ref} is open; add a pull resistor",
101
+ pin: reference, switch: switch.ref)
102
+ offense(rule.id, message, component.location,
103
+ targets: { components: [component.ref, switch.ref], pins: [reference], nets: [net.name] })
104
+ end
105
+ end
106
+ end
107
+
108
+ def supply_switch_input_nets(circuit, state)
109
+ supplies = circuit.voltage_sources.flat_map do |source|
110
+ [source.plus, source.minus].filter_map { |terminal| circuit.net_of(terminal, state)&.name }
111
+ end
112
+ return {} if supplies.empty?
113
+
114
+ circuit.components.values.each_with_object({}) do |component, found|
115
+ Array(component.part.data["switch"]).each do |pair|
116
+ pins = pair.map { |reference| component.pin(reference) }
117
+ next unless pins.all?
118
+ nets = pins.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
119
+ next unless nets.all? && nets.uniq.length == 2
120
+ nets.each_with_index do |net, index|
121
+ found[net] ||= component if !supplies.include?(net) && supplies.include?(nets[1 - index])
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ def anchored_input_nets(circuit, state)
128
+ anchored = {}
129
+ circuit.voltage_sources.each do |source|
130
+ [source.plus, source.minus].each do |terminal|
131
+ net = circuit.net_of(terminal, state)
132
+ anchored[net.name] = true if net
133
+ end
134
+ end
135
+ circuit.components.each_value do |component|
136
+ component.pins.each_value do |pin|
137
+ output = %w[output gpio open_drain].include?(pin.role) || component.part.pin(pin.number)&.fetch("output_capable", false)
138
+ next unless output
139
+ net = circuit.net_of("#{component.ref}.#{pin.name}", state)
140
+ anchored[net.name] = true if net
141
+ end
142
+ end
143
+ neighbors = Hash.new { |hash, key| hash[key] = [] }
144
+ circuit.components.each_value do |component|
145
+ next unless conductive_resistor?(component)
146
+ nets = component.pins.values.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
147
+ next unless nets.length == 2 && nets.all? && nets[0] != nets[1]
148
+ neighbors[nets[0]] << nets[1]
149
+ neighbors[nets[1]] << nets[0]
150
+ end
151
+ queue = anchored.keys
152
+ until queue.empty?
153
+ neighbors[queue.shift].each do |name|
154
+ next if anchored[name]
155
+ anchored[name] = true
156
+ queue << name
157
+ end
158
+ end
159
+ anchored
160
+ end
161
+
162
+ def conductive_resistor?(component)
163
+ component.part.id == "resistor" && Breadkit::Value.parse(component.value) >= 0
164
+ rescue ArgumentError, TypeError
165
+ false
166
+ end
167
+
64
168
  def dangling_wires(circuit, rule, state)
65
169
  attachments = Hash.new { |hash, key| hash[key] = [] }
66
170
  circuit.components.each_value do |component|
@@ -116,9 +220,50 @@ module Breadkit
116
220
  circuit.wires.any? { |wire| wire.id == member } || circuit.components.keys.any? { |ref| member.start_with?("#{ref}.") }
117
221
  end
118
222
  next unless net && voltage.nil? && touched
119
- offense(rule.id, translate("split_rail", "used segment of #{rail} has no supply", rail: rail), nil,
120
- targets: { holes: [segment_holes.first.id], nets: [net.name] })
223
+ source_holes = entries.find { |_holes, _net, potential| !potential.nil? }&.first
224
+ target_hole = segment_holes.first.id
225
+ source_hole = source_holes&.min_by { |hole| (hole.y - segment_holes.first.y).abs }&.id
226
+ connection = source_hole ? "#{source_hole} to #{target_hole}" : target_hole
227
+ location = circuit.wires.find { |wire| net.members.include?(wire.id) }&.location ||
228
+ circuit.components.values.find { |component| net.members.any? { |member| member.start_with?("#{component.ref}.") } }&.location
229
+ offense(rule.id, translate("split_rail", "used segment of #{rail} has no supply; bridge #{connection}", rail: rail, bridge: connection),
230
+ location, targets: { holes: [source_hole, target_hole].compact, nets: [net.name] })
231
+ end
232
+ end
233
+ end
234
+
235
+ def rail_polarity_mismatch(circuit, rule, _state)
236
+ circuit.voltage_sources.filter_map do |source|
237
+ next if source.isolated
238
+ positive_net = circuit.net_of(source.plus)
239
+ negative_net = circuit.net_of(source.minus)
240
+ next unless positive_net && negative_net && positive_net != negative_net
241
+ positive_rails = polarized_rails(circuit, positive_net)
242
+ negative_rails = polarized_rails(circuit, negative_net)
243
+ next unless positive_rails.map(&:first).uniq == ["-"] && negative_rails.map(&:first).uniq == ["+"]
244
+ board_id = lambda do |hole|
245
+ circuit.board.respond_to?(:board_id_for) ? circuit.board.board_id_for(hole.id) : nil
121
246
  end
247
+ positive_by_board = positive_rails.group_by { |_polarity, hole| board_id.call(hole) }
248
+ negative_by_board = negative_rails.group_by { |_polarity, hole| board_id.call(hole) }
249
+ common_boards = positive_by_board.keys & negative_by_board.keys
250
+ next if common_boards.empty?
251
+ common_board = common_boards.first
252
+ wrong_positive = positive_by_board.fetch(common_board).first.last
253
+ wrong_negative = negative_by_board.fetch(common_board).first.last
254
+ offense(rule.id, translate("rail_polarity_mismatch",
255
+ "#{source.name} positive terminal is on #{wrong_positive.rail} and negative terminal is on #{wrong_negative.rail}",
256
+ source: source.name, positive_rail: wrong_positive.rail, negative_rail: wrong_negative.rail),
257
+ source.location,
258
+ targets: { holes: [wrong_positive.id, wrong_negative.id], nets: [positive_net.name, negative_net.name] })
259
+ end
260
+ end
261
+
262
+ def polarized_rails(circuit, net)
263
+ net.holes.filter_map do |id|
264
+ hole = circuit.board.hole(id)
265
+ polarity = hole&.rail && circuit.board.rail_polarity(hole.rail)
266
+ [polarity, hole] if polarity
122
267
  end
123
268
  end
124
269
 
@@ -136,16 +281,79 @@ module Breadkit
136
281
  offense(rule.id, translate("missing_series_resistor", "#{component.ref} has no current-limiting resistor in series",
137
282
  ref: component.ref), component.location,
138
283
  targets: { components: [component.ref], pins: ["#{component.ref}.#{positive.name}", "#{component.ref}.#{negative.name}"],
139
- nets: [high.name, low.name] }, state: state.name)
284
+ nets: [high.name, low.name] }, state: state.name,
285
+ suggestion: series_resistor_recommendation(circuit, component, high.name, low.name, state))
140
286
  end
141
287
  end
142
288
 
143
- def unprotected_path?(circuit, start, finish, excluded_component, state)
289
+ E12 = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2].freeze
290
+
291
+ def series_resistor_recommendation(circuit, component, high, low, state)
292
+ forward_voltage = component.part.data["forward_voltage"]
293
+ current_limit = component.part.data["max_forward_current"]
294
+ return unless forward_voltage && current_limit && high != low
295
+
296
+ sources = circuit.voltage_sources.filter_map do |source|
297
+ positive = circuit.net_of(source.plus, state)&.name
298
+ negative = circuit.net_of(source.minus, state)&.name
299
+ [source, positive, negative] if positive && negative && [positive, negative].sort == [high, low].sort
300
+ end
301
+ return unless sources.one?
302
+
303
+ source, positive, negative = sources.first
304
+ return unless positive == high && negative == low
305
+
306
+ voltage = source.voltage_range ? source.voltage_range.last : source.voltage
307
+ minimum = (voltage - forward_voltage) / current_limit
308
+ return unless minimum.positive? && minimum.finite?
309
+
310
+ decade = 10.0**Math.log10(minimum).floor
311
+ recommended = (E12.map { |ratio| ratio * decade } + [10 * decade]).find { |value| value >= minimum * (1 - 1e-12) }
312
+ resistance = Breadkit::Value.new(recommended, category: :resistor).to_s
313
+ voltage_text = format("%.3g", voltage)
314
+ forward_text = format("%.3g", forward_voltage)
315
+ current_text = format("%.3g", current_limit * 1000)
316
+ translate("series_resistor_recommendation",
317
+ "Add an E12 resistor of at least #{resistance} in series. Estimate uses #{voltage_text} V maximum supply, " \
318
+ "#{forward_text} V declared forward voltage, and #{current_text} mA declared maximum current; verify tolerances.",
319
+ resistance: resistance, voltage: voltage_text, forward_voltage: forward_text, current: current_text)
320
+ end
321
+
322
+ def minimum_resistances(circuit, rule, state)
323
+ pots = circuit.components.values.select { |component| component.part.id == "pot" }
324
+ circuit.components.values.filter_map do |led|
325
+ next unless led.part.id == "led" && pots.any?
326
+ polarity = led.part.data["polarity"] || {}
327
+ high = circuit.net_of("#{led.ref}.#{polarity['positive']}", state)&.name
328
+ low = circuit.net_of("#{led.ref}.#{polarity['negative']}", state)&.name
329
+ next unless high && low
330
+ next if unprotected_path?(circuit, high, low, led, state)
331
+
332
+ pot = pots.find do |candidate|
333
+ %w[left right].any? { |end_pin| unprotected_path?(circuit, high, low, led, state, zero_pot: [candidate, end_pin]) }
334
+ end
335
+ next unless pot
336
+ offense(rule.id, translate("minimum_resistance", "#{led.ref} relies on #{pot.ref} for current limiting; its wiper can reach 0 Ω",
337
+ led: led.ref, pot: pot.ref), led.location,
338
+ targets: { components: [led.ref, pot.ref] }, state: state.name)
339
+ end
340
+ end
341
+
342
+ def unprotected_path?(circuit, start, finish, excluded_component, state, zero_pot: nil)
144
343
  adjacency = Hash.new { |hash, key| hash[key] = [] }
145
344
  circuit.components.each_value do |component|
146
- next if component == excluded_component || component.pins.length != 2
147
- next unless component.part.data["category"] == "diode" || (component.part.id == "resistor" && !current_limiter?(component))
148
- pins = component.pins.values
345
+ next if component == excluded_component
346
+ pins = if component == zero_pot&.first
347
+ [component.pin("wiper"), component.pin(zero_pot.last)].compact
348
+ elsif component.part.data["category"] == "transistor"
349
+ [component.pin("collector"), component.pin("emitter")].compact
350
+ elsif component.pins.length == 2 &&
351
+ (component.part.data["category"] == "diode" || (component.part.id == "resistor" && !current_limiter?(component)))
352
+ component.pins.values
353
+ else
354
+ []
355
+ end
356
+ next unless pins.length == 2
149
357
  left, right = pins.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
150
358
  next unless left && right && left != right
151
359
  adjacency[left] << right
@@ -173,19 +381,26 @@ module Breadkit
173
381
  end
174
382
 
175
383
  def source_pairs(circuit, state)
176
- pairs = circuit.supplies.filter_map do |supply|
177
- high = circuit.net_of("#{supply.name}.+", state)&.name
178
- low = circuit.net_of("#{supply.name}.-", state)&.name
384
+ pairs = circuit.voltage_sources.filter_map do |supply|
385
+ high = circuit.net_of(supply.plus, state)&.name
386
+ low = circuit.net_of(supply.minus, state)&.name
179
387
  [high, low] if high && low
180
388
  end
181
389
  circuit.components.each_value do |component|
182
390
  next unless component.part.placement == "offboard" || component.part.data["category"] == "module"
183
391
  pins = component.pins.values
184
392
  grounds = pins.select { |pin| pin.role == "ground" }.filter_map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
185
- outputs = pins.select { |pin| pin.role == "power" || pin.name.match?(/\A(?:GP|GPIO|D)\d+\z/i) }
393
+ outputs = pins.select do |pin|
394
+ %w[gpio output].include?(pin.role) || component.part.pin(pin.number)&.fetch("output_capable", false)
395
+ end
186
396
  outputs.each do |pin|
187
- high = circuit.net_of("#{component.ref}.#{pin.name}", state)&.name
188
- grounds.each { |low| pairs << [high, low] } if high
397
+ gpio = circuit.net_of("#{component.ref}.#{pin.name}", state)&.name
398
+ next unless gpio
399
+ grounds.each { |ground| pairs << [gpio, ground] }
400
+ pairs.concat(circuit.voltage_sources.filter_map do |source|
401
+ high = circuit.net_of(source.plus, state)&.name
402
+ [high, gpio] if high
403
+ end)
189
404
  end
190
405
  end
191
406
  pairs
@@ -200,6 +415,10 @@ module Breadkit
200
415
  def reverse_polarity(circuit, rule, state)
201
416
  ranges, domains = potential_ranges(circuit, state)
202
417
  circuit.components.values.filter_map do |component|
418
+ flags = Array(component.part.data["flags"])
419
+ next unless %w[led electrolytic].include?(component.part.id) || flags.include?("needs_series_resistor") || component.part.data["category"] == "capacitor"
420
+ next if component.attrs[:bias].to_s == "reverse" || component.attrs["bias"].to_s == "reverse"
421
+ next if flags.include?("reverse_bias_ok")
203
422
  polarity = component.part.data["polarity"]
204
423
  next unless polarity
205
424
  positive = component.pins[polarity["positive"]]
@@ -209,7 +428,8 @@ module Breadkit
209
428
  low = circuit.net_of("#{component.ref}.#{negative.name}", state)
210
429
  next unless high && low && ranges[high.name] && ranges[low.name]
211
430
  next unless domains[high.name] && domains[high.name] == domains[low.name]
212
- next unless ranges[high.name][1] < ranges[low.name][0]
431
+ reverse_voltage = ranges[low.name][0] - ranges[high.name][1]
432
+ next unless reverse_voltage > component.part.data.fetch("max_reverse_voltage", 0).to_f
213
433
  offense(rule.id, translate("reverse_polarity", "#{component.ref} positive pin is below its negative pin",
214
434
  ref: component.ref), component.location,
215
435
  targets: { components: [component.ref], pins: ["#{component.ref}.#{positive.name}", "#{component.ref}.#{negative.name}"],
@@ -219,6 +439,8 @@ module Breadkit
219
439
 
220
440
  def potential_ranges(circuit, state)
221
441
  # ponytail: resistor-only DC leaves active-device voltages unknown; add device models when those cases matter.
442
+ return [{}, {}] if circuit.voltage_sources.empty?
443
+
222
444
  known = circuit.potentials(state).values
223
445
  ranges = known.transform_values { |value| [value, value] }
224
446
  domains = supply_domains(circuit, state)
@@ -271,9 +493,9 @@ module Breadkit
271
493
 
272
494
  def supply_domains(circuit, state)
273
495
  adjacency = Hash.new { |hash, key| hash[key] = [] }
274
- circuit.supplies.each do |supply|
275
- high = circuit.net_of("#{supply.name}.+", state)&.name
276
- low = circuit.net_of("#{supply.name}.-", state)&.name
496
+ circuit.voltage_sources.each do |supply|
497
+ high = circuit.net_of(supply.plus, state)&.name
498
+ low = circuit.net_of(supply.minus, state)&.name
277
499
  next unless high && low
278
500
  adjacency[high] << low
279
501
  adjacency[low] << high
@@ -312,9 +534,12 @@ module Breadkit
312
534
  end
313
535
 
314
536
  def power_pins(circuit, rule, state)
315
- grounds = circuit.supplies.filter_map { |supply| circuit.net_of("#{supply.name}.-", state)&.name }
537
+ components = circuit.components.values.reject { |component| component.part.placement == "offboard" }
538
+ return [] unless components.any? { |component| component.pins.values.any? { |pin| %w[power ground].include?(pin.role) } }
539
+
540
+ grounds = circuit.voltage_sources.filter_map { |supply| circuit.net_of(supply.minus, state)&.name }
316
541
  values = circuit.potentials(state).values
317
- circuit.components.values.reject { |component| component.part.placement == "offboard" }.flat_map do |component|
542
+ components.flat_map do |component|
318
543
  component.pins.values.filter_map do |pin|
319
544
  next unless %w[power ground].include?(pin.role)
320
545
  next if component.unused.include?(pin.number) || component.unused.include?(pin.name)
@@ -336,11 +561,14 @@ module Breadkit
336
561
  end
337
562
 
338
563
  def supply_ranges(circuit, rule, state)
564
+ components = circuit.components.values.select { |component| component.part.data["supply_range"] }
565
+ return [] if components.empty?
566
+
339
567
  values = circuit.potentials(state).values
340
568
  domains = supply_domains(circuit, state)
341
- circuit.components.values.flat_map do |component|
342
- range = component.part.data["supply_range"]
343
- next [] unless range
569
+ components.flat_map do |component|
570
+ rated = component.part.data["supply_range"]
571
+ next [] unless rated
344
572
  powers = component.pins.values.select { |pin| pin.role == "power" }
345
573
  grounds = component.pins.values.select { |pin| pin.role == "ground" }
346
574
  powers.product(grounds).filter_map do |power, ground|
@@ -348,10 +576,12 @@ module Breadkit
348
576
  low = circuit.net_of("#{component.ref}.#{ground.name}", state)
349
577
  next unless high && low && values.key?(high.name) && values.key?(low.name)
350
578
  next unless domains[high.name] && domains[high.name] == domains[low.name]
351
- voltage = values[high.name] - values[low.name]
352
- next if voltage >= range[0].to_f && voltage <= range[1].to_f
353
- message = translate("supply_range", "#{component.ref} #{power.name}/#{ground.name} supply is #{voltage.round(3)} V; expected #{range[0]}..#{range[1]} V",
354
- ref: component.ref, power: power.name, ground: ground.name, voltage: voltage.round(3), minimum: range[0], maximum: range[1])
579
+ nominal = values[high.name] - values[low.name]
580
+ actual = direct_source_range(circuit, state, high.name, low.name) || [nominal, nominal]
581
+ next if actual[0] >= rated[0].to_f && actual[1] <= rated[1].to_f
582
+ voltage = actual[0] == actual[1] ? actual[0].round(3).to_s : "#{actual[0]}..#{actual[1]}"
583
+ message = translate("supply_range", "#{component.ref} #{power.name}/#{ground.name} supply is #{voltage} V; expected #{rated[0]}..#{rated[1]} V",
584
+ ref: component.ref, power: power.name, ground: ground.name, voltage: voltage, minimum: rated[0], maximum: rated[1])
355
585
  offense(rule.id, message, component.location,
356
586
  targets: { components: [component.ref], pins: ["#{component.ref}.#{power.name}", "#{component.ref}.#{ground.name}"],
357
587
  nets: [high.name, low.name] }, state: state.name)
@@ -360,10 +590,330 @@ module Breadkit
360
590
  end
361
591
 
362
592
  def common_ground(circuit, rule, state)
363
- return [] if circuit.supplies.length < 2
364
- grounds = circuit.supplies.filter_map { |supply| circuit.net_of("#{supply.name}.-", state)&.name }.uniq
365
- return [] if grounds.length <= 1
366
- [offense(rule.id, translate("common_ground", "power supplies do not share a ground"), nil, targets: { nets: grounds })]
593
+ supplies = circuit.voltage_sources.reject { |supply| supply.respond_to?(:isolated) && supply.isolated }
594
+ return [] if supplies.length < 2
595
+ domains = supply_domains(circuit, state)
596
+ references = supplies.filter_map { |supply| circuit.net_of(supply.minus, state)&.name }
597
+ return [] if references.filter_map { |name| domains[name] }.uniq.length <= 1
598
+ location = supplies.find { |supply| supply.location }&.location
599
+ [offense(rule.id, translate("common_ground", "power supplies do not share a ground"), location,
600
+ targets: { nets: references.uniq })]
601
+ end
602
+
603
+ def voltage_domain_mismatches(circuit, rule, state)
604
+ ranges, domains = potential_ranges(circuit, state)
605
+ circuit.components.values.flat_map do |component|
606
+ ground_names = component.pins.values.select { |pin| pin.role == "ground" }
607
+ .filter_map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
608
+ component.pins.values.filter_map do |pin|
609
+ limit = component.part.pin(pin.number)&.fetch("max_voltage", nil)
610
+ next unless limit && !ground_names.empty?
611
+ net = circuit.net_of("#{component.ref}.#{pin.name}", state)
612
+ next unless net && ranges.key?(net.name)
613
+ ground = ground_names.find { |name| ranges.key?(name) && domains[name] == domains[net.name] }
614
+ next unless ground
615
+ voltage = direct_source_range(circuit, state, net.name, ground)&.last || (ranges[net.name][1] - ranges[ground][0])
616
+ next unless voltage > limit.to_f
617
+ message = translate("voltage_domain", "#{component.ref}.#{pin.name} can see up to #{voltage.round(3)} V; maximum is #{limit} V",
618
+ pin: "#{component.ref}.#{pin.name}", voltage: voltage.round(3), maximum: limit)
619
+ offense(rule.id, message, component.location,
620
+ targets: { components: [component.ref], pins: ["#{component.ref}.#{pin.name}"], nets: [net.name] },
621
+ state: state.name)
622
+ end
623
+ end
624
+ end
625
+
626
+ def resistor_power_ratings(circuit, rule, state)
627
+ ranges, domains = potential_ranges(circuit, state)
628
+ circuit.components.values.filter_map do |component|
629
+ next unless component.part.id == "resistor" && component.value
630
+ rating = Breadkit::Value.power_rating(component.value)
631
+ next unless rating
632
+ voltage = rated_voltage(circuit, component, state, ranges, domains)
633
+ next unless voltage
634
+ minimum = Breadkit::Value.parse(component.value) * (1.0 - (Breadkit::Value.tolerance(component.value) || 0.0))
635
+ next unless minimum.positive?
636
+ watts = voltage**2 / minimum
637
+ next unless watts > rating
638
+ offense(rule.id, "#{component.ref} may dissipate #{watts.round(3)} W; rated for #{rating} W",
639
+ component.location, targets: { components: [component.ref] }, state: state.name)
640
+ end
641
+ end
642
+
643
+ def capacitor_voltage_ratings(circuit, rule, state)
644
+ ranges, domains = potential_ranges(circuit, state)
645
+ circuit.components.values.filter_map do |component|
646
+ next unless component.part.id == "electrolytic" && component.value
647
+ rating = Breadkit::Value.voltage_rating(component.value)
648
+ next unless rating
649
+ voltage = rated_voltage(circuit, component, state, ranges, domains)
650
+ next unless voltage && voltage > rating
651
+ offense(rule.id, "#{component.ref} may see #{voltage.round(3)} V; rated for #{rating} V",
652
+ component.location, targets: { components: [component.ref] }, state: state.name)
653
+ end
654
+ end
655
+
656
+ def led_overcurrent(circuit, rule, state)
657
+ rated = circuit.components.values.select { |component| component.part.id == "led" && component.part.data["max_forward_current"] }
658
+ return [] if rated.empty?
659
+
660
+ analysis = circuit.dc_analysis(state)
661
+ return [] unless analysis.success?
662
+
663
+ rated.filter_map do |component|
664
+ limit = component.part.data["max_forward_current"]
665
+ current = analysis.currents[component.ref]
666
+ next unless current && current > limit
667
+ offense(rule.id, "#{component.ref} may carry #{(current * 1000).round(2)} mA; maximum is #{(limit * 1000).round(2)} mA",
668
+ component.location, targets: { components: [component.ref] }, state: state.name)
669
+ end
670
+ end
671
+
672
+ def gpio_overcurrent(circuit, rule, state)
673
+ rated = circuit.components.values.flat_map do |component|
674
+ Array(component.part.data["provides"]).flat_map do |source|
675
+ %w[positive negative].filter_map do |terminal|
676
+ pin = component.pin(source.fetch(terminal))
677
+ next unless pin && %w[gpio output].include?(pin.role)
678
+ limit = component.part.pin(pin.number)&.fetch("max_current", nil)
679
+ [component, source, pin, limit] if limit
680
+ end
681
+ end
682
+ end
683
+ return [] if rated.empty?
684
+
685
+ analysis = circuit.dc_analysis(state)
686
+ return [] unless analysis.success?
687
+
688
+ rated.filter_map do |component, source, pin, limit|
689
+ current = analysis.currents["#{component.ref}.#{source.fetch('positive')}"]
690
+ next unless current && current.abs > limit * (1 + 1e-9)
691
+ actual_ma, limit_ma = [current.abs, limit].map { |value| (value * 1000).round(2) }
692
+ reference = "#{component.ref}.#{pin.name}"
693
+ message = translate("gpio_overcurrent", "#{reference} carries #{actual_ma} mA; maximum is #{limit_ma} mA",
694
+ pin: reference, current: actual_ma, limit: limit_ma)
695
+ net = circuit.net_of(reference, state)
696
+ offense(rule.id, message, component.location,
697
+ targets: { components: [component.ref], pins: [reference], nets: [net&.name].compact }, state: state.name)
698
+ end
699
+ end
700
+
701
+ def supply_overloads(circuit, rule, state)
702
+ rated = circuit.supplies.select(&:current_limit)
703
+ return [] if rated.empty?
704
+
705
+ analysis = circuit.dc_analysis(state)
706
+ return [] unless analysis.success?
707
+
708
+ rated.filter_map do |supply|
709
+ limit = supply.current_limit
710
+ delivered = -analysis.currents.fetch(supply.name, 0.0)
711
+ next unless delivered > limit * (1 + 1e-9)
712
+
713
+ actual_ma, limit_ma = [delivered, limit].map { |value| (value * 1000).round(2) }
714
+ message = translate("supply_overload", "#{supply.name} supplies #{actual_ma} mA; limit is #{limit_ma} mA",
715
+ supply: supply.name, current: actual_ma, limit: limit_ma)
716
+ nets = [supply.plus, supply.minus].filter_map { |terminal| circuit.net_of(terminal, state)&.name }
717
+ offense(rule.id, message, supply.location, targets: { nets: nets, holes: [supply.plus, supply.minus] }, state: state.name)
718
+ end
719
+ end
720
+
721
+ def rated_voltage(circuit, component, state, ranges, domains)
722
+ pins = component.pins.values
723
+ return unless pins.length == 2
724
+ left, right = pins.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
725
+ return unless left && right && ranges[left] && ranges[right] && domains[left] && domains[left] == domains[right]
726
+ direct = direct_source_range(circuit, state, left, right) || direct_source_range(circuit, state, right, left)
727
+ return direct.map(&:abs).max if direct
728
+ [(ranges[left][0] - ranges[right][1]).abs, (ranges[left][1] - ranges[right][0]).abs].max
729
+ end
730
+
731
+ def i2c_address_conflicts(circuit, rule, state)
732
+ devices = circuit.components.values.filter_map do |component|
733
+ address = component.attrs[:address] || component.attrs["address"]
734
+ next unless address && component.pin("SDA") && component.pin("SCL")
735
+ parsed = Integer(address.to_s, 0) rescue nil
736
+ next unless parsed
737
+ sda = circuit.net_of("#{component.ref}.SDA", state)&.name
738
+ scl = circuit.net_of("#{component.ref}.SCL", state)&.name
739
+ [[sda, scl, parsed], component] if sda && scl
740
+ end
741
+ devices.group_by(&:first).filter_map do |(sda, scl, address), entries|
742
+ next if entries.length < 2
743
+ components = entries.map(&:last)
744
+ refs = components.map(&:ref)
745
+ message = translate("i2c_address", "#{refs.join(', ')} share I2C address 0x#{address.to_s(16).upcase} on the same bus",
746
+ refs: refs.join(", "), address: "0x#{address.to_s(16).upcase}")
747
+ offense(rule.id, message, components.last.location,
748
+ targets: { components: refs, pins: refs.flat_map { |ref| ["#{ref}.SDA", "#{ref}.SCL"] },
749
+ nets: [sda, scl] }, state: state.name)
750
+ end
751
+ end
752
+
753
+ def i2c_pullup_missing(circuit, rule, state)
754
+ board_id = lambda do |hole|
755
+ circuit.board.respond_to?(:board_id_for) ? circuit.board.board_id_for(hole) : nil
756
+ end
757
+ positive_nets = circuit.voltage_sources.filter_map do |source|
758
+ net = circuit.net_of(source.plus, state)
759
+ [net.name, net.holes.map(&board_id).uniq] if net
760
+ end
761
+ return [] if positive_nets.empty?
762
+
763
+ devices = circuit.components.values.select do |component|
764
+ address = component.attrs[:address] || component.attrs["address"]
765
+ address && component.pin("SDA") && component.pin("SCL") && (Integer(address.to_s, 0) rescue nil)
766
+ end
767
+ devices.group_by do |component|
768
+ %w[SDA SCL].map { |pin| circuit.net_of("#{component.ref}.#{pin}", state)&.name }
769
+ end.flat_map do |(sda, scl), components|
770
+ next [] unless sda && scl && sda != scl
771
+
772
+ { "SDA" => sda, "SCL" => scl }.filter_map do |pin, net_name|
773
+ net = circuit.net_of("#{components.first.ref}.#{pin}", state)
774
+ next unless net && components.any? { |component| net.members.any? { |member| member != "#{component.ref}.#{pin}" } }
775
+ boards = net.holes.map(&board_id).uniq
776
+ sources = positive_nets.filter_map { |name, ids| name unless (boards & ids).empty? }
777
+ next if sources.empty? || i2c_pullup_resistor?(circuit, state, net_name, sources)
778
+
779
+ refs = components.map(&:ref)
780
+ message = translate("i2c_pullup_missing", "I2C #{pin} on #{refs.join(', ')} has no modeled pull-up resistor",
781
+ line: pin, refs: refs.join(", "))
782
+ offense(rule.id, message, components.first.location,
783
+ targets: { components: refs, pins: refs.map { |ref| "#{ref}.#{pin}" }, nets: [net_name] }, state: state.name)
784
+ end
785
+ end
786
+ end
787
+
788
+ def i2c_pullup_resistor?(circuit, state, bus_net, positive_nets)
789
+ circuit.components.values.any? do |component|
790
+ next false unless component.part.id == "resistor"
791
+ resistance = Breadkit::Value.parse(component.value) rescue nil
792
+ next false unless resistance&.positive?
793
+ nets = component.pins.values.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
794
+ nets.length == 2 && nets.include?(bus_net) && nets.uniq.length == 2 && positive_nets.include?((nets - [bus_net]).first)
795
+ end
796
+ end
797
+
798
+ def missing_decoupling_capacitors(circuit, rule, state)
799
+ source_pairs = circuit.voltage_sources.filter_map do |source|
800
+ high = circuit.net_of(source.plus, state)&.name
801
+ low = circuit.net_of(source.minus, state)&.name
802
+ [high, low] if high && low && high != low
803
+ end
804
+ return [] if source_pairs.empty?
805
+
806
+ circuit.components.values.flat_map do |component|
807
+ next [] unless component.part.data["category"] == "ic" && component.part.placement != "offboard"
808
+ powers = component.pins.values.select { |pin| pin.role == "power" && !component.unused.include?(pin.name) && !component.unused.include?(pin.number) }
809
+ grounds = component.pins.values.select { |pin| pin.role == "ground" && !component.unused.include?(pin.name) && !component.unused.include?(pin.number) }
810
+ pairs = {}
811
+ powers.product(grounds).each do |power, ground|
812
+ high = circuit.net_of("#{component.ref}.#{power.name}", state)&.name
813
+ low = circuit.net_of("#{component.ref}.#{ground.name}", state)&.name
814
+ pairs[[high, low]] ||= [power, ground] if source_pairs.include?([high, low])
815
+ end
816
+ pairs.filter_map do |(high, low), (power, ground)|
817
+ next if decoupling_capacitor?(circuit, state, high, low)
818
+ message = translate("missing_decoupling_capacitor", "#{component.ref} has no modeled capacitor across #{power.name} and #{ground.name}",
819
+ ref: component.ref, power: power.name, ground: ground.name)
820
+ offense(rule.id, message, component.location,
821
+ targets: { components: [component.ref], pins: ["#{component.ref}.#{power.name}", "#{component.ref}.#{ground.name}"],
822
+ nets: [high, low] }, state: state.name)
823
+ end
824
+ end
825
+ end
826
+
827
+ def decoupling_capacitor?(circuit, state, high, low)
828
+ circuit.components.values.any? do |component|
829
+ next false unless %w[capacitor electrolytic].include?(component.part.id)
830
+ nets = component.pins.values.map { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name }
831
+ nets.length == 2 && nets.all? && nets.sort == [high, low].sort
832
+ end
833
+ end
834
+
835
+ def missing_base_resistors(circuit, rule, state)
836
+ outputs = Hash.new { |hash, key| hash[key] = [] }
837
+ circuit.components.each_value do |component|
838
+ component.pins.each_value do |pin|
839
+ next unless pin.role == "output"
840
+ next if component.unused.include?(pin.name) || component.unused.include?(pin.number)
841
+ net = circuit.net_of("#{component.ref}.#{pin.name}", state)
842
+ outputs[net.name] << [component, pin] if net
843
+ end
844
+ end
845
+
846
+ circuit.components.values.filter_map do |transistor|
847
+ next unless transistor.part.data["category"] == "transistor"
848
+ base = transistor.pin("base")
849
+ next unless base && !transistor.unused.include?(base.name) && !transistor.unused.include?(base.number)
850
+ net = circuit.net_of("#{transistor.ref}.#{base.name}", state)
851
+ next unless net
852
+ driver, output = outputs[net.name].find { |component, _pin| component.ref != transistor.ref }
853
+ next unless driver
854
+ message = translate("missing_base_resistor", "#{driver.ref}.#{output.name} drives #{transistor.ref}.#{base.name} without a series resistor",
855
+ driver: "#{driver.ref}.#{output.name}", base: "#{transistor.ref}.#{base.name}")
856
+ offense(rule.id, message, transistor.location,
857
+ targets: { components: [transistor.ref, driver.ref], pins: ["#{transistor.ref}.#{base.name}", "#{driver.ref}.#{output.name}"],
858
+ nets: [net.name] }, state: state.name)
859
+ end
860
+ end
861
+
862
+ def missing_flyback_diodes(circuit, rule, state)
863
+ source_nets = circuit.voltage_sources.filter_map do |source|
864
+ positive = circuit.net_of(source.plus, state)&.name
865
+ negative = circuit.net_of(source.minus, state)&.name
866
+ [positive, negative] if positive && negative && positive != negative
867
+ end
868
+ return [] if source_nets.empty?
869
+
870
+ circuit.components.values.filter_map do |component|
871
+ next unless Array(component.part.data["flags"]).include?("needs_flyback_diode")
872
+ polarity = component.part.data["polarity"] || {}
873
+ positive_pin = component.pin(polarity["positive"])
874
+ negative_pin = component.pin(polarity["negative"])
875
+ next unless positive_pin && negative_pin
876
+ positive = circuit.net_of("#{component.ref}.#{positive_pin.name}", state)&.name
877
+ negative = circuit.net_of("#{component.ref}.#{negative_pin.name}", state)&.name
878
+ next unless positive && negative && positive != negative
879
+ next unless source_nets.any? { |source_positive, source_negative| source_positive == positive || source_negative == negative }
880
+ next unless [positive, negative].all? { |net| flyback_terminal_attached?(circuit, state, net, component.ref, source_nets) }
881
+ next if reverse_flyback_diode?(circuit, state, positive, negative)
882
+
883
+ message = translate("missing_flyback_diode", "#{component.ref} has no reverse flyback diode across #{positive_pin.name} and #{negative_pin.name}",
884
+ ref: component.ref, positive: positive_pin.name, negative: negative_pin.name)
885
+ offense(rule.id, message, component.location,
886
+ targets: { components: [component.ref], pins: ["#{component.ref}.#{positive_pin.name}", "#{component.ref}.#{negative_pin.name}"],
887
+ nets: [positive, negative] }, state: state.name)
888
+ end
889
+ end
890
+
891
+ def flyback_terminal_attached?(circuit, state, net_name, owner, source_nets)
892
+ return true if source_nets.any? { |positive, negative| positive == net_name || negative == net_name }
893
+ circuit.components.values.any? do |component|
894
+ component.ref != owner && component.part.data["category"] != "diode" &&
895
+ component.pins.values.any? { |pin| circuit.net_of("#{component.ref}.#{pin.name}", state)&.name == net_name }
896
+ end
897
+ end
898
+
899
+ def reverse_flyback_diode?(circuit, state, positive, negative)
900
+ circuit.components.values.any? do |component|
901
+ next false unless component.part.data["category"] == "diode"
902
+ polarity = component.part.data["polarity"] || {}
903
+ anode = component.pin(polarity["positive"])
904
+ cathode = component.pin(polarity["negative"])
905
+ next false unless anode && cathode
906
+ circuit.net_of("#{component.ref}.#{anode.name}", state)&.name == negative &&
907
+ circuit.net_of("#{component.ref}.#{cathode.name}", state)&.name == positive
908
+ end
909
+ end
910
+
911
+ def direct_source_range(circuit, state, high_name, low_name)
912
+ sources = circuit.voltage_sources.select do |source|
913
+ circuit.net_of(source.plus, state)&.name == high_name && circuit.net_of(source.minus, state)&.name == low_name
914
+ end
915
+ return unless sources.length == 1
916
+ sources.first.voltage_range
367
917
  end
368
918
 
369
919
  end