breadkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.codespellignore +2 -0
  3. data/.yamllint +16 -0
  4. data/README.md +109 -6
  5. data/data/boards/double_full.yml +18 -0
  6. data/data/boards/stripboard.yml +9 -0
  7. data/data/boards/universal.yml +8 -0
  8. data/data/parts/2n3904.yml +11 -0
  9. data/data/parts/2n7000_onsemi_to92.yml +10 -0
  10. data/data/parts/74hc00.yml +22 -0
  11. data/data/parts/74hc04.yml +22 -0
  12. data/data/parts/74hc08.yml +22 -0
  13. data/data/parts/74hc14.yml +22 -0
  14. data/data/parts/74hc165.yml +24 -0
  15. data/data/parts/74hc32.yml +22 -0
  16. data/data/parts/74hc595.yml +24 -0
  17. data/data/parts/active_buzzer.yml +8 -0
  18. data/data/parts/ams1117_3v3.yml +12 -0
  19. data/data/parts/arduino_nano.yml +70 -0
  20. data/data/parts/arduino_uno.yml +29 -26
  21. data/data/parts/atmega328p.yml +37 -0
  22. data/data/parts/attiny85.yml +16 -0
  23. data/data/parts/battery_box.yml +8 -0
  24. data/data/parts/cd4017.yml +24 -0
  25. data/data/parts/ck_bd04.yml +29 -0
  26. data/data/parts/crystal.yml +7 -0
  27. data/data/parts/dc_jack_2wire.yml +8 -0
  28. data/data/parts/diode.yml +1 -0
  29. data/data/parts/esp32_devkitc_v4_wroom32e.yml +86 -0
  30. data/data/parts/irlz44n.yml +10 -0
  31. data/data/parts/l293d.yml +24 -0
  32. data/data/parts/led.yml +1 -0
  33. data/data/parts/lm358.yml +16 -0
  34. data/data/parts/lm393.yml +16 -0
  35. data/data/parts/ne555.yml +2 -2
  36. data/data/parts/ntc_thermistor.yml +7 -0
  37. data/data/parts/photoresistor.yml +8 -0
  38. data/data/parts/pico.yml +89 -0
  39. data/data/parts/resistor.yml +1 -0
  40. data/data/parts/sc56_11ewa.yml +30 -0
  41. data/data/parts/seeed_xiao_samd21.yml +37 -0
  42. data/data/parts/shillehtek_mb102_4pin.yml +23 -0
  43. data/data/parts/slide_switch_spst.yml +8 -0
  44. data/data/parts/sparkfun_pro_micro_5v.yml +58 -0
  45. data/data/parts/transistor.yml +0 -1
  46. data/data/parts/ua7805.yml +10 -0
  47. data/data/parts/uln2003a.yml +23 -0
  48. data/data/parts/wp154a4sureqbfzgc.yml +11 -0
  49. data/docs/CLI.md +108 -0
  50. data/docs/COMPATIBILITY.md +60 -0
  51. data/docs/DECLARATIVE.md +138 -0
  52. data/docs/IR.md +42 -0
  53. data/docs/JUMPER_KIT.md +45 -0
  54. data/docs/LOCK.md +75 -0
  55. data/docs/LSP.md +59 -0
  56. data/docs/MB102_POWER.md +11 -0
  57. data/docs/MCP.md +44 -0
  58. data/docs/RAKE.md +19 -0
  59. data/docs/dsl.md +197 -6
  60. data/exe/breadkit-lsp +13 -0
  61. data/exe/breadkit-mcp +12 -0
  62. data/lib/breadkit/analysis.rb +174 -50
  63. data/lib/breadkit/board.rb +152 -4
  64. data/lib/breadkit/cli.rb +229 -13
  65. data/lib/breadkit/color.rb +38 -0
  66. data/lib/breadkit/dc_analysis.rb +261 -0
  67. data/lib/breadkit/dsl.rb +205 -24
  68. data/lib/breadkit/exporters.rb +202 -0
  69. data/lib/breadkit/formatter.rb +52 -0
  70. data/lib/breadkit/fritzing_export.rb +256 -0
  71. data/lib/breadkit/ir.rb +154 -22
  72. data/lib/breadkit/jumper_kit.rb +127 -0
  73. data/lib/breadkit/lsp_server.rb +248 -0
  74. data/lib/breadkit/mcp_server.rb +133 -0
  75. data/lib/breadkit/model.rb +47 -5
  76. data/lib/breadkit/part_library.rb +156 -3
  77. data/lib/breadkit/part_lock.rb +107 -0
  78. data/lib/breadkit/patterns.rb +119 -0
  79. data/lib/breadkit/rake_task.rb +25 -0
  80. data/lib/breadkit/resolver.rb +160 -28
  81. data/lib/breadkit/structured_input.rb +275 -0
  82. data/lib/breadkit/templates/555.bk.rb +34 -0
  83. data/lib/breadkit/templates/arduino.bk.rb +17 -0
  84. data/lib/breadkit/templates/led.bk.rb +21 -0
  85. data/lib/breadkit/value.rb +42 -3
  86. data/lib/breadkit/version.rb +1 -1
  87. data/lib/breadkit/wire_suggestions.rb +106 -0
  88. data/lib/breadkit.rb +28 -3
  89. data/package.json +1 -1
  90. data/schema/board-v1.json +38 -0
  91. data/schema/ir-v1.json +56 -11
  92. data/schema/ir-v2.json +173 -0
  93. data/schema/part-v1.json +52 -4
  94. data/sig/breadkit.rbs +107 -5
  95. metadata +119 -14
  96. data/docs/assets/01_led_button.svg +0 -12
  97. data/site/assets/01_led_button.svg +0 -14
  98. data/site/assets/03_arduino_blink.svg +0 -14
  99. data/site/favicon.svg +0 -7
  100. data/site/guide/components/index.html +0 -113
  101. data/site/guide/dsl/index.html +0 -132
  102. data/site/guide/index.html +0 -163
  103. data/site/index.html +0 -95
  104. data/site/styles.css +0 -14
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "../breadkit"
5
+
6
+ module Breadkit
7
+ class MCPServer
8
+ EXTENSIONS = %w[.bk.yml .bk.yaml .bk.toml .json].freeze
9
+ MAX_INPUT_BYTES = 8 * 1024 * 1024
10
+
11
+ def initialize(root: Dir.pwd)
12
+ @root = File.realpath(root)
13
+ raise ArgumentError, "MCP root must be a directory" unless File.directory?(@root)
14
+ end
15
+
16
+ def server
17
+ @server ||= build_server
18
+ end
19
+
20
+ def run
21
+ MCP::Server::Transports::StdioTransport.new(server).open
22
+ end
23
+
24
+ private
25
+
26
+ def build_server
27
+ result = MCP::Server.new(name: "breadkit", version: VERSION,
28
+ instructions: "Read-only circuit tools. Inputs must be declarative YAML, TOML, or JSON IR files inside the configured root.")
29
+ responder, resolver, nets, valid = method(:respond), method(:resolve), method(:inspect_nets), method(:valid_circuit)
30
+ path_schema = { type: "object", properties: { path: { type: "string", minLength: 1 } },
31
+ required: ["path"], additionalProperties: false }
32
+ result.define_tool(name: "breadkit_resolve", description: "Resolve a circuit and report its parts, wiring, and diagnostics.",
33
+ input_schema: path_schema, annotations: read_only_annotations) do |path:|
34
+ responder.call { resolver.call(path) }
35
+ end
36
+ result.define_tool(name: "breadkit_nets", description: "List the resolved electrical nets and potentials of a circuit.",
37
+ input_schema: { type: "object", properties: path_schema[:properties].merge(state: { type: "string" }),
38
+ required: ["path"], additionalProperties: false }, annotations: read_only_annotations) do |path:, state: nil|
39
+ responder.call { nets.call(path, state) }
40
+ end
41
+ result.define_tool(name: "breadkit_ir", description: "Export a valid circuit as Breadkit JSON IR.",
42
+ input_schema: path_schema, annotations: read_only_annotations) do |path:|
43
+ responder.call { valid.call(path).to_ir }
44
+ end
45
+ result
46
+ end
47
+
48
+ def read_only_annotations
49
+ { read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false }
50
+ end
51
+
52
+ def respond
53
+ value = yield
54
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(value) }], structured_content: value)
55
+ rescue StandardError => e
56
+ MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
57
+ end
58
+
59
+ def resolve(path)
60
+ circuit = load_circuit(path)
61
+ { valid: circuit.diagnostics.none? { |item| item.severity == "error" }, title: circuit.title,
62
+ boards: board_summary(circuit), components: circuit.components.keys, wires: circuit.wires.length,
63
+ supplies: circuit.supplies.map(&:name), diagnostics: circuit.diagnostics.map { |item| diagnostic_data(item) } }
64
+ end
65
+
66
+ def inspect_nets(path, state_name)
67
+ circuit = valid_circuit(path)
68
+ state = circuit.state(state_name) unless state_name == "base"
69
+ { state: state_name || "base", nets: circuit.nets(state).map do |net|
70
+ { name: net.name, members: net.members, holes: net.holes, potential: net.potential }
71
+ end }
72
+ end
73
+
74
+ def valid_circuit(path)
75
+ circuit = load_circuit(path)
76
+ errors = circuit.diagnostics.select { |item| item.severity == "error" }
77
+ raise DSLError, errors.map(&:message).join("; ") unless errors.empty?
78
+
79
+ circuit
80
+ end
81
+
82
+ def load_circuit(path)
83
+ file = circuit_path(path)
84
+ return IR::Reader.new.read_file(file) if file.end_with?(".json")
85
+
86
+ document = StructuredInput.load_file(file)
87
+ (document.part_paths + document.board_paths).each { |dependency| checked_path(dependency) }
88
+ PartLock.verify(document, file)
89
+ Resolver.new.call(document)
90
+ end
91
+
92
+ def circuit_path(path)
93
+ raise ArgumentError, "path must be text" unless path.is_a?(String) && !path.empty?
94
+ raise ArgumentError, "MCP accepts only YAML, TOML, or JSON IR circuit files" unless EXTENSIONS.any? { |extension| path.end_with?(extension) }
95
+
96
+ file = checked_path(path)
97
+ unless EXTENSIONS.any? { |extension| file.end_with?(extension) }
98
+ raise ArgumentError, "MCP accepts only YAML, TOML, or JSON IR circuit files"
99
+ end
100
+ file
101
+ end
102
+
103
+ def checked_path(path)
104
+ expanded = File.expand_path(path, @root)
105
+ raise ArgumentError, "path is outside the MCP root" unless inside_root?(expanded)
106
+
107
+ real = File.realpath(expanded)
108
+ raise ArgumentError, "path is outside the MCP root" unless inside_root?(real)
109
+ raise ArgumentError, "path must be a file" unless File.file?(real)
110
+ raise ArgumentError, "input file exceeds 8 MiB" if File.size(real) > MAX_INPUT_BYTES
111
+
112
+ real
113
+ end
114
+
115
+ def inside_root?(path)
116
+ prefix = @root == File::SEPARATOR ? @root : "#{@root}#{File::SEPARATOR}"
117
+ path.start_with?(prefix)
118
+ end
119
+
120
+ def board_summary(circuit)
121
+ if circuit.multi_board?
122
+ circuit.boards.map { |name, board| { name: name, type: board.definition.id } }
123
+ else
124
+ [{ type: circuit.board.definition.id }]
125
+ end
126
+ end
127
+
128
+ def diagnostic_data(item)
129
+ location = item.location && { path: item.location.path, line: item.location.line }
130
+ { code: item.code, severity: item.severity, message: item.message, targets: item.targets, location: location }
131
+ end
132
+ end
133
+ end
@@ -1,31 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Breadkit
4
+ module Geometry
5
+ def self.transform(offset, rotate: 0, mirror: false)
6
+ x, y = offset
7
+ x = -x if mirror
8
+ case rotate
9
+ when 90 then [-y, x]
10
+ when 180 then [-x, -y]
11
+ when 270 then [y, -x]
12
+ else [x, y]
13
+ end
14
+ end
15
+ end
16
+
4
17
  SourceLocation = Struct.new(:path, :line, keyword_init: true)
5
18
  Diagnostic = Data.define(:code, :severity, :message, :location, :targets)
6
19
  Hole = Struct.new(:id, :kind, :row, :col, :rail, :x, :y, :strip_id, keyword_init: true)
7
20
  Strip = Struct.new(:id, :hole_ids, keyword_init: true)
8
21
  Pin = Struct.new(:name, :number, :hole_id, :node_id, :role, keyword_init: true)
9
- Component = Struct.new(:ref, :part, :value, :attrs, :pins, :unused, :location, keyword_init: true) do
22
+ Component = Struct.new(:ref, :part, :value, :attrs, :pins, :unused, :location, :step, keyword_init: true) do
10
23
  def pin(reference)
11
24
  definition = part.pin(reference)
12
25
  pins[(definition["name"] || definition["num"]).to_s] if definition
13
26
  end
27
+
28
+ def provided_sources
29
+ Array(part.data["provides"]).select do |source|
30
+ (source["when"] || {}).all? do |key, expected|
31
+ actual = attrs.key?(key.to_sym) ? attrs[key.to_sym] : attrs[key]
32
+ !actual.nil? && actual.to_s == expected.to_s
33
+ end
34
+ end
35
+ end
36
+
37
+ def body_bounds(board)
38
+ render = part.data["render"] || {}
39
+ return unless render["size_mm"]
40
+
41
+ holes = pins.values.filter_map { |pin| board.hole(pin.hole_id) if pin.hole_id }
42
+ return if holes.empty?
43
+
44
+ width, height = render.fetch("size_mm").map { |value| Float(value) / 2.54 }
45
+ offset = (render["body_offset_mm"] || [0, 0]).map { |value| Float(value) / 2.54 }
46
+ rotate = attrs[:rotate] || 0
47
+ offset_x, offset_y = Geometry.transform(offset, rotate: rotate, mirror: attrs[:mirror] == true)
48
+ width, height = height, width if [90, 270].include?(rotate)
49
+ center_x = (holes.map(&:x).min + holes.map(&:x).max) / 2.0 + offset_x
50
+ center_y = (holes.map(&:y).min + holes.map(&:y).max) / 2.0 + offset_y
51
+ [center_x - width / 2, center_y - height / 2, width, height]
52
+ end
14
53
  end
15
- Wire = Struct.new(:id, :from, :to, :color, :route, :layer, :electrical, :dashed, :location, keyword_init: true)
16
- Supply = Struct.new(:name, :voltage, :plus, :minus, :location, keyword_init: true)
17
- Label = Struct.new(:name, :at, :location, keyword_init: true)
54
+ Wire = Struct.new(:id, :from, :to, :color, :route, :layer, :electrical, :dashed, :location, :step, keyword_init: true)
55
+ Supply = Struct.new(:name, :voltage, :plus, :minus, :location, :isolated, :voltage_range, :current_limit, :step, keyword_init: true)
56
+ Label = Struct.new(:name, :at, :location, :step, keyword_init: true)
18
57
  Net = Struct.new(:name, :members, :holes, :labels, :potential, keyword_init: true)
19
58
 
20
59
  class Document
21
60
  attr_accessor :title, :board, :supplies, :labels, :components, :wires, :expectations,
22
- :lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions
61
+ :lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions, :diagnostics, :source_root, :steps, :boards
23
62
 
24
63
  def initialize
25
64
  @title = nil
26
65
  @board = { type: "full", options: {} }
27
66
  @supplies, @labels, @components, @wires = [], [], [], []
28
67
  @expectations, @lint_disables, @part_paths, @part_definitions, @board_paths, @board_definitions = [], [], [], [], [], []
68
+ @steps = []
69
+ @boards = []
70
+ @diagnostics = []
29
71
  end
30
72
  end
31
73
  end
@@ -1,16 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Breadkit
4
6
  class PartDef
5
7
  attr_reader :data
6
8
 
9
+ KEYS = %w[id aliases category placement pins polarity footprint footprint_mm internal switch independent_switches same_strip_ok straddle package render flags supply_range transistor_polarity attributes required_attributes provides extends override max_reverse_voltage forward_voltage on_resistance max_forward_current max_lead_span_mm datasheet_url].freeze
10
+ PIN_KEYS = %w[num name aliases type role label max_voltage max_current output_capable mount].freeze
11
+
7
12
  def initialize(data)
13
+ if data["pins"].is_a?(Array)
14
+ data = data.merge("pins" => data["pins"].map do |pin|
15
+ next pin unless pin.is_a?(Hash) && pin.key?("role")
16
+ raise ArgumentError, "part #{data['id']} pin #{pin['num']} has both type and role" if pin.key?("type")
17
+ pin.merge("type" => pin["role"]).reject { |key, _value| key == "role" }
18
+ end)
19
+ end
8
20
  @data = data
21
+ unknown = data.keys - KEYS
22
+ raise ArgumentError, "part #{data['id']} has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
9
23
  raise ArgumentError, "part definition needs an id" unless data["id"]
10
24
  raise ArgumentError, "part #{data['id']} needs pins" unless data["pins"].is_a?(Array)
25
+ if data.key?("datasheet_url")
26
+ valid_url = begin
27
+ url = data["datasheet_url"]
28
+ parsed = URI.parse(url) if url.is_a?(String)
29
+ parsed.is_a?(URI::HTTPS) && !parsed.host.to_s.empty? && !parsed.userinfo
30
+ rescue URI::InvalidURIError
31
+ false
32
+ end
33
+ raise ArgumentError, "part #{id} has invalid datasheet_url" unless valid_url
34
+ end
35
+ if data.key?("attributes")
36
+ attributes = data["attributes"]
37
+ unless attributes.is_a?(Hash) && attributes.all? { |key, schema| key.is_a?(String) &&
38
+ (schema == "css_color" || (schema.is_a?(Array) && !schema.empty? && schema.all? { |value| value.is_a?(String) || value.is_a?(Numeric) })) }
39
+ raise ArgumentError, "part #{id} has invalid attributes schema"
40
+ end
41
+ end
42
+ if data.key?("required_attributes")
43
+ required = data["required_attributes"]
44
+ unless required.is_a?(Array) && required.uniq.length == required.length &&
45
+ required.all? { |key| key.is_a?(String) && data.fetch("attributes", {}).key?(key) }
46
+ raise ArgumentError, "part #{id} has invalid required_attributes"
47
+ end
48
+ end
11
49
  identities = {}
12
50
  pins.each_with_index do |pin, index|
13
51
  raise ArgumentError, "part #{id} has invalid pin" unless pin.is_a?(Hash) && pin["num"]
52
+ unknown_pin_keys = pin.keys - PIN_KEYS
53
+ raise ArgumentError, "part #{id} has unknown pin keys: #{unknown_pin_keys.join(', ')}" unless unknown_pin_keys.empty?
54
+ role = pin["type"]
55
+ if role && !%w[input output gpio power ground data clock interrupt address nc passive analog reset open_drain].include?(role.to_s)
56
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid type #{role}"
57
+ end
58
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid mount" if pin.key?("mount") && pin["mount"] != "rail"
59
+ unless !pin.key?("label") || pin["label"].is_a?(String) || [true, false].include?(pin["label"])
60
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid label"
61
+ end
62
+ if pin.key?("max_voltage")
63
+ voltage = pin["max_voltage"]
64
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid max_voltage" unless voltage.is_a?(Numeric) && voltage.finite? && voltage.positive?
65
+ end
66
+ if pin.key?("max_current")
67
+ current = pin["max_current"]
68
+ unless current.is_a?(Numeric) && current.real? && current.finite? && current.positive?
69
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid max_current"
70
+ end
71
+ end
72
+ if pin.key?("output_capable") && ![true, false].include?(pin["output_capable"])
73
+ raise ArgumentError, "part #{id} pin #{pin['num']} has invalid output_capable"
74
+ end
14
75
 
15
76
  ([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.each do |identity|
16
77
  key = identity.to_s.downcase
@@ -25,18 +86,82 @@ module Breadkit
25
86
  raise ArgumentError, "part #{id} has invalid #{key} pin pair #{pair.inspect}" unless pair.length == 2 && pair.all? { |pin| valid_pins.include?(pin.to_s) }
26
87
  end
27
88
  end
89
+ if data.key?("independent_switches")
90
+ pairs = Array(data["switch"])
91
+ terminals = pairs.flatten.map { |reference| pin(reference).fetch("num").to_s }
92
+ unless data["independent_switches"] == true && !pairs.empty? && terminals.uniq.length == terminals.length
93
+ raise ArgumentError, "part #{id} has invalid independent_switches; contacts must use distinct pins"
94
+ end
95
+ end
28
96
  raise ArgumentError, "part #{id} has invalid placement" unless %w[leads dip footprint offboard].include?(placement)
97
+ if data.key?("max_lead_span_mm")
98
+ limit = data["max_lead_span_mm"]
99
+ unless placement == "leads" && pins.length == 2 && limit.is_a?(Numeric) && limit.real? && limit.finite? && limit.positive?
100
+ raise ArgumentError, "part #{id} has invalid max_lead_span_mm"
101
+ end
102
+ end
103
+ if data["render"]
104
+ render = data["render"]
105
+ unknown_render = render.is_a?(Hash) ? render.keys - %w[shape label size_mm body_offset_mm fill stroke text_color svg] : []
106
+ unless render.is_a?(Hash) && unknown_render.empty? && (!render.key?("shape") || render["shape"].is_a?(String)) &&
107
+ (!render.key?("label") || render["label"].is_a?(String)) &&
108
+ (!render.key?("svg") || render["svg"].is_a?(String)) &&
109
+ %w[fill stroke text_color].all? { |key| !render.key?(key) || Color.valid?(render[key]) }
110
+ raise ArgumentError, "part #{id} has invalid render options#{": #{unknown_render.join(', ')}" unless unknown_render.empty?}"
111
+ end
112
+ if render.key?("size_mm") && !valid_mm_pair?(render["size_mm"], positive: true)
113
+ raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]"
114
+ end
115
+ if render.key?("body_offset_mm") && !valid_mm_pair?(render["body_offset_mm"], positive: false)
116
+ raise ArgumentError, "part #{id} module rendering needs finite body_offset_mm [x, y]"
117
+ end
118
+ end
29
119
  if data["polarity"]
30
- unless data["polarity"].is_a?(Hash) && data["polarity"].values.all? { |name| identities.key?(name.to_s.downcase) }
120
+ unless data["polarity"].is_a?(Hash) && (data["polarity"].keys - %w[positive negative]).empty? &&
121
+ data["polarity"].values.all? { |name| identities.key?(name.to_s.downcase) }
31
122
  raise ArgumentError, "part #{id} has invalid polarity pin reference"
32
123
  end
33
124
  end
125
+ if data.key?("max_reverse_voltage")
126
+ limit = data["max_reverse_voltage"]
127
+ raise ArgumentError, "part #{id} has invalid max_reverse_voltage" unless limit.is_a?(Numeric) && limit.finite? && limit >= 0
128
+ end
129
+ %w[forward_voltage on_resistance max_forward_current].each do |key|
130
+ next unless data.key?(key)
131
+
132
+ value = data[key]
133
+ raise ArgumentError, "part #{id} has invalid #{key}" unless value.is_a?(Numeric) && value.finite? && value.positive?
134
+ end
135
+ Array(data["provides"]).each do |source|
136
+ unless source.is_a?(Hash) && %w[positive negative voltage].all? { |key| source.key?(key) } &&
137
+ (source.keys - %w[positive negative voltage when]).empty? &&
138
+ %w[positive negative].all? { |key| identities.key?(source[key].to_s.downcase) } &&
139
+ source["voltage"].is_a?(Numeric) && source["voltage"].finite? && source["voltage"].positive?
140
+ raise ArgumentError, "part #{id} has invalid voltage source"
141
+ end
142
+ conditions = source["when"]
143
+ if conditions && (!conditions.is_a?(Hash) || conditions.empty? || conditions.any? do |key, value|
144
+ choices = data.fetch("attributes", {})[key]
145
+ !choices.is_a?(Array) || !choices.include?(value)
146
+ end)
147
+ raise ArgumentError, "part #{id} has invalid voltage source condition"
148
+ end
149
+ end
34
150
  if data["footprint"]
35
151
  numbers = pins.map { |pin| pin.fetch("num").to_s }
36
152
  unless data["footprint"].is_a?(Hash) && data["footprint"].all? { |key, offset| numbers.include?(key.to_s) && offset.is_a?(Array) && offset.length == 2 && offset.all? { |value| value.is_a?(Integer) } }
37
153
  raise ArgumentError, "part #{id} has invalid footprint pin reference or offset"
38
154
  end
39
155
  end
156
+ if data.key?("footprint_mm")
157
+ offsets = data["footprint_mm"]
158
+ numbers = pins.map { |pin| pin.fetch("num").to_s }
159
+ raise ArgumentError, "part #{id} cannot set both footprint_mm and footprint" if data.key?("footprint")
160
+ unless placement == "footprint" && offsets.is_a?(Hash) && offsets.keys.sort == numbers.sort &&
161
+ offsets.values.all? { |pair| pair.is_a?(Array) && pair.length == 2 && pair.all? { |value| grid_offset_mm?(value) } }
162
+ raise ArgumentError, "part #{id} has invalid footprint_mm; pin offsets must align to 2.54 mm holes"
163
+ end
164
+ end
40
165
  if data.dig("render", "shape") == "module"
41
166
  size_mm = data.dig("render", "size_mm")
42
167
  raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]" unless valid_mm_pair?(size_mm, positive: true)
@@ -59,6 +184,16 @@ module Breadkit
59
184
  data.fetch("placement", "leads")
60
185
  end
61
186
 
187
+ def max_lead_span_mm
188
+ data["max_lead_span_mm"]&.to_f
189
+ end
190
+
191
+ def footprint
192
+ return data["footprint"] if data.key?("footprint")
193
+
194
+ data["footprint_mm"]&.transform_values { |pair| pair.map { |value| (value / 2.54).round } }
195
+ end
196
+
62
197
  def pin(value)
63
198
  key = value.to_s.downcase
64
199
  pins.find do |pin|
@@ -68,6 +203,11 @@ module Breadkit
68
203
 
69
204
  private
70
205
 
206
+ def grid_offset_mm?(value)
207
+ value.is_a?(Numeric) && value.real? && value.finite? &&
208
+ ((value / 2.54) - (value / 2.54).round).abs < 1e-6
209
+ end
210
+
71
211
  def valid_mm_pair?(values, positive:)
72
212
  values.is_a?(Array) && values.length == 2 && values.all? do |value|
73
213
  number = Float(value)
@@ -79,13 +219,26 @@ module Breadkit
79
219
  end
80
220
 
81
221
  class PartLibrary
222
+ attr_reader :warnings
223
+
82
224
  def initialize(extra_paths: [], extra_definitions: [])
83
- paths = Dir.glob(File.expand_path("../../data/parts/*.yml", __dir__))
84
- paths.concat(extra_paths.flat_map { |path| Dir.glob(path) })
225
+ builtin_paths = Dir.glob(File.expand_path("../../data/parts/*.yml", __dir__))
226
+ paths = builtin_paths + extra_paths.flat_map { |path| Dir.glob(path) }
85
227
  definitions = paths.uniq.map { |path| YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) }
86
228
  definitions.concat(extra_definitions)
87
229
  @parts = {}
230
+ @warnings = []
88
231
  definitions.each do |data|
232
+ if data["extends"]
233
+ parent = @parts[data["extends"].to_s.downcase]
234
+ raise ArgumentError, "unknown parent part #{data['extends']}" unless parent
235
+ data = parent.data.merge("aliases" => []).merge(data)
236
+ end
237
+ existing = @parts[data["id"].to_s.downcase]
238
+ if existing && (data["override"] || data["extends"])
239
+ @parts.delete_if { |_key, item| item == existing }
240
+ @warnings << "part #{data['id']} overrides #{existing.id}"
241
+ end
89
242
  part = PartDef.new(data)
90
243
  ([part.id] + Array(data["aliases"])).each do |name|
91
244
  key = name.to_s.downcase
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+
6
+ module Breadkit
7
+ # Pins local part and board definition files used by a circuit.
8
+ class PartLock
9
+ FORMAT = 1
10
+ SUFFIXES = %w[.bk.rb .bk.yml .bk.yaml .bk.toml].freeze
11
+
12
+ def self.write(path)
13
+ circuit_path = File.expand_path(path)
14
+ raise ArgumentError, "lock supports .bk.rb, .bk.yml, .bk.yaml, and .bk.toml circuits" unless circuit_file?(circuit_path)
15
+
16
+ document = if circuit_path.end_with?(".bk.rb")
17
+ DSL.load_file(circuit_path)
18
+ else
19
+ StructuredInput.load_file(circuit_path)
20
+ end
21
+ file = lock_path(circuit_path)
22
+ data = File.file?(file) ? read(file) : { "version" => FORMAT, "circuits" => {} }
23
+ data.fetch("circuits")[File.basename(circuit_path)] = entry(document, circuit_path)
24
+ data["circuits"] = data.fetch("circuits").sort.to_h
25
+ File.write(file, "#{JSON.pretty_generate(data)}\n")
26
+ file
27
+ end
28
+
29
+ def self.verify(document, path)
30
+ circuit_path = File.expand_path(path)
31
+ return unless circuit_file?(circuit_path)
32
+
33
+ file = lock_path(circuit_path)
34
+ return unless File.file?(file)
35
+
36
+ expected = read(file).fetch("circuits")[File.basename(circuit_path)]
37
+ raise DSLError, "breadkit.lock has no entry for #{File.basename(circuit_path)}; run breadkit lock FILE" unless expected
38
+ raise DSLError, "breadkit.lock version mismatch for #{File.basename(circuit_path)}; run breadkit lock FILE" unless expected.fetch("breadkit") == VERSION
39
+
40
+ current = entry(document, circuit_path)
41
+ %w[parts boards].each do |kind|
42
+ saved, actual = expected.fetch(kind), current.fetch(kind)
43
+ saved_paths, actual_paths = saved.map { |item| item.fetch("path") }, actual.map { |item| item.fetch("path") }
44
+ raise DSLError, "breadkit.lock definition set changed for #{kind}; run breadkit lock FILE" unless saved_paths == actual_paths
45
+
46
+ saved.zip(actual).each do |before, after|
47
+ next if before.fetch("sha256") == after.fetch("sha256")
48
+
49
+ raise DSLError, "breadkit.lock checksum mismatch for #{after.fetch('path')}; run breadkit lock FILE"
50
+ end
51
+ end
52
+ end
53
+
54
+ def self.lock_path(path)
55
+ File.join(File.dirname(File.expand_path(path)), "breadkit.lock")
56
+ end
57
+
58
+ def self.circuit_file?(path)
59
+ SUFFIXES.any? { |suffix| path.end_with?(suffix) }
60
+ end
61
+ private_class_method :circuit_file?
62
+
63
+ def self.entry(document, path)
64
+ base = Pathname.new(File.dirname(path))
65
+ { "breadkit" => VERSION, "parts" => records(document.part_paths, base),
66
+ "boards" => records(document.board_paths, base) }
67
+ end
68
+ private_class_method :entry
69
+
70
+ def self.records(paths, base)
71
+ paths.uniq.map do |path|
72
+ absolute = Pathname.new(File.expand_path(path))
73
+ { "path" => absolute.relative_path_from(base).to_s.tr("\\", "/"), "sha256" => Digest::SHA256.file(absolute).hexdigest }
74
+ rescue ArgumentError
75
+ raise DSLError, "local definitions must share a filesystem with the circuit"
76
+ end.sort_by { |item| item.fetch("path") }
77
+ end
78
+ private_class_method :records
79
+
80
+ def self.read(path)
81
+ raise DSLError, "invalid breadkit.lock: file exceeds 1 MiB" if File.size(path) > 1024 * 1024
82
+
83
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
84
+ unless data.is_a?(Hash) && data.keys.sort == %w[circuits version] && data["version"] == FORMAT && data["circuits"].is_a?(Hash)
85
+ raise DSLError, "invalid breadkit.lock: unsupported format"
86
+ end
87
+ data.fetch("circuits").each_value do |item|
88
+ unless item.is_a?(Hash) && item.keys.sort == %w[boards breadkit parts] && item["breadkit"].is_a?(String) &&
89
+ %w[parts boards].all? { |key| valid_records?(item[key]) }
90
+ raise DSLError, "invalid breadkit.lock: malformed circuit entry"
91
+ end
92
+ end
93
+ data
94
+ rescue JSON::ParserError => e
95
+ raise DSLError, "invalid breadkit.lock: #{e.message}"
96
+ end
97
+ private_class_method :read
98
+
99
+ def self.valid_records?(items)
100
+ items.is_a?(Array) && items.all? do |item|
101
+ item.is_a?(Hash) && item.keys.sort == %w[path sha256] && item["path"].is_a?(String) &&
102
+ !item["path"].empty? && item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
103
+ end
104
+ end
105
+ private_class_method :valid_records?
106
+ end
107
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Breadkit
4
+ module Patterns
5
+ module_function
6
+
7
+ def call(circuit)
8
+ return [] if circuit.diagnostics.any? { |item| item.severity == "error" }
9
+
10
+ voltage_divider(circuit) + ne555_astable(circuit)
11
+ end
12
+
13
+ def voltage_divider(circuit)
14
+ return [] if circuit.diagnostics.any? { |item| item.severity == "error" }
15
+ return [] unless circuit.supplies.length == 1 && circuit.components.length == 2
16
+
17
+ supply = circuit.supplies.first
18
+ return [] if supply.voltage_range || !supply.voltage.to_f.positive?
19
+
20
+ resistors = circuit.components.values
21
+ return [] unless resistors.all? { |component| component.part.id == "resistor" &&
22
+ component.pins.length == 2 && Value.parse(component.value).positive? }
23
+
24
+ ends = resistors.map do |component|
25
+ [1, 2].map { |pin| circuit.net_of("#{component.ref}.#{pin}")&.name }
26
+ end
27
+ return [] if ends.flatten.any?(&:nil?) || ends.any? { |pair| pair.uniq.length != 2 }
28
+
29
+ plus = circuit.net_of(supply.plus)&.name
30
+ minus = circuit.net_of(supply.minus)&.name
31
+ shared = ends[0] & ends[1]
32
+ return [] unless plus && minus && plus != minus && shared.length == 1
33
+
34
+ midpoint = shared.first
35
+ return [] if [plus, minus].include?(midpoint)
36
+
37
+ top, bottom = resistors.zip(ends).partition { |_component, pair| pair.include?(plus) }
38
+ return [] unless top.length == 1 && bottom.length == 1 && bottom.first[1].include?(minus)
39
+
40
+ result = circuit.dc_analysis
41
+ return [] unless result.success? && result.voltages.key?(midpoint) && result.voltages.key?(minus)
42
+
43
+ voltage = result.voltages.fetch(midpoint) - result.voltages.fetch(minus)
44
+ [{ "kind" => "voltage_divider", "supply" => supply.name,
45
+ "top_resistor" => top.first[0].ref, "bottom_resistor" => bottom.first[0].ref,
46
+ "midpoint_net" => midpoint, "midpoint_voltage_v" => voltage,
47
+ "reference" => "#{supply.name}.-",
48
+ "explanation" => "Two resistors form an unloaded voltage divider. The nominal midpoint voltage is relative to the supply negative terminal." }]
49
+ rescue ArgumentError, TypeError
50
+ []
51
+ end
52
+
53
+ def ne555_astable(circuit)
54
+ return [] unless circuit.supplies.length == 1 && circuit.components.length == 8
55
+
56
+ parts = circuit.components.values.group_by { |component| component.part.id }
57
+ return [] unless %w[ne555 resistor capacitor electrolytic led].all? { |id| parts.key?(id) }
58
+ return [] unless %w[ne555 resistor capacitor electrolytic led].map { |id| parts.fetch(id).length } == [1, 3, 2, 1, 1]
59
+
60
+ timer = parts.fetch("ne555").first
61
+ supply = circuit.supplies.first
62
+ voltage = supply.voltage.to_f
63
+ minimum, maximum = Array(timer.part.data["supply_range"])
64
+ return [] unless supply.voltage_range.nil? && minimum && maximum && voltage.between?(minimum, maximum)
65
+
66
+ plus = circuit.net_of(supply.plus)&.name
67
+ minus = circuit.net_of(supply.minus)&.name
68
+ pins = (1..8).to_h { |number| [number, terminal_net(circuit, timer, number)] }
69
+ return [] if [plus, minus, *pins.values].any?(&:nil?)
70
+ return [] unless pins[1] == minus && pins[4] == plus && pins[8] == plus && pins[2] == pins[6]
71
+
72
+ discharge, timing, output, control = pins.values_at(7, 2, 3, 5)
73
+ return [] unless [plus, minus, discharge, timing, output, control].uniq.length == 6
74
+
75
+ led = parts.fetch("led").first
76
+ led_anode = terminal_net(circuit, led, "anode")
77
+ return [] unless terminal_net(circuit, led, "cathode") == minus && led_anode &&
78
+ ![plus, minus, discharge, timing, output, control].include?(led_anode)
79
+
80
+ resistors = parts.fetch("resistor")
81
+ capacitors = parts.fetch("capacitor") + parts.fetch("electrolytic")
82
+ return [] unless (resistors + capacitors).all? { |part| Value.parse(part.value).positive? }
83
+
84
+ charge = between(circuit, resistors, plus, discharge)
85
+ discharge_resistor = between(circuit, resistors, discharge, timing)
86
+ output_resistor = between(circuit, resistors, output, led_anode)
87
+ control_capacitor = between(circuit, capacitors, control, minus)
88
+ bypass_capacitor = between(circuit, capacitors, plus, minus)
89
+ timing_capacitor = between(circuit, capacitors, timing, minus)
90
+ return [] unless [charge, discharge_resistor, output_resistor, control_capacitor,
91
+ bypass_capacitor, timing_capacitor].all?
92
+ return [] unless control_capacitor.part.id == "capacitor" && bypass_capacitor.part.id == "capacitor" &&
93
+ timing_capacitor.part.id == "electrolytic"
94
+ return [] unless terminal_net(circuit, timing_capacitor, 1) == timing &&
95
+ terminal_net(circuit, timing_capacitor, 2) == minus
96
+
97
+ [{ "kind" => "ne555_astable_wiring", "timer" => timer.ref, "supply" => supply.name,
98
+ "charge_resistor" => charge.ref, "discharge_resistor" => discharge_resistor.ref,
99
+ "timing_capacitor" => timing_capacitor.ref, "output_resistor" => output_resistor.ref,
100
+ "output_led" => led.ref, "control_capacitor" => control_capacitor.ref,
101
+ "bypass_capacitor" => bypass_capacitor.ref,
102
+ "datasheet" => "https://www.ti.com/lit/ds/symlink/ne555.pdf",
103
+ "explanation" => "This wiring matches the NE555 astable connection with an LED output load; this is not a simulation of oscillation or timing." }]
104
+ rescue ArgumentError, TypeError
105
+ []
106
+ end
107
+
108
+ def terminal_net(circuit, component, pin)
109
+ circuit.net_of("#{component.ref}.#{pin}")&.name
110
+ end
111
+
112
+ def between(circuit, components, first, second)
113
+ components.find do |component|
114
+ ends = [1, 2].map { |pin| terminal_net(circuit, component, pin) }
115
+ ends.all? && ends.sort == [first, second].sort
116
+ end
117
+ end
118
+ end
119
+ end