breadkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +16 -0
- data/SECURITY.md +5 -0
- data/data/boards/full.yml +18 -0
- data/data/boards/half.yml +17 -0
- data/data/boards/mini.yml +8 -0
- data/data/parts/2n3906.yml +10 -0
- data/data/parts/arduino_uno.yml +34 -0
- data/data/parts/bc547.yml +10 -0
- data/data/parts/bc557.yml +10 -0
- data/data/parts/capacitor.yml +7 -0
- data/data/parts/diode.yml +8 -0
- data/data/parts/electrolytic.yml +8 -0
- data/data/parts/led.yml +9 -0
- data/data/parts/ne555.yml +16 -0
- data/data/parts/pot.yml +12 -0
- data/data/parts/resistor.yml +7 -0
- data/data/parts/tact_switch_6mm.yml +19 -0
- data/data/parts/transistor.yml +10 -0
- data/docs/assets/01_led_button.svg +12 -0
- data/docs/dsl.md +62 -0
- data/exe/breadkit +5 -0
- data/lib/breadkit/analysis.rb +382 -0
- data/lib/breadkit/board.rb +163 -0
- data/lib/breadkit/cli.rb +61 -0
- data/lib/breadkit/dsl.rb +182 -0
- data/lib/breadkit/hole_id.rb +46 -0
- data/lib/breadkit/ir.rb +215 -0
- data/lib/breadkit/model.rb +31 -0
- data/lib/breadkit/part_library.rb +125 -0
- data/lib/breadkit/resolver.rb +449 -0
- data/lib/breadkit/value.rb +50 -0
- data/lib/breadkit/version.rb +5 -0
- data/lib/breadkit.rb +26 -0
- data/package-lock.json +1172 -0
- data/package.json +12 -0
- data/schema/ir-v1.json +128 -0
- data/schema/part-v1.json +48 -0
- data/sig/breadkit.rbs +180 -0
- data/site/assets/01_led_button.svg +14 -0
- data/site/assets/03_arduino_blink.svg +14 -0
- data/site/favicon.svg +7 -0
- data/site/guide/components/index.html +113 -0
- data/site/guide/dsl/index.html +132 -0
- data/site/guide/index.html +163 -0
- data/site/index.html +95 -0
- data/site/styles.css +14 -0
- metadata +94 -0
data/lib/breadkit/dsl.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
module DSL
|
|
5
|
+
METHODS = %w[title board use_parts use_boards supply net part wire offboard expect lint_disable resistor capacitor electrolytic diode led transistor pot button ic connected isolated].freeze
|
|
6
|
+
|
|
7
|
+
class Builder
|
|
8
|
+
attr_reader :document
|
|
9
|
+
|
|
10
|
+
def initialize(base_dir: Dir.pwd)
|
|
11
|
+
@document = Document.new
|
|
12
|
+
@expected = nil
|
|
13
|
+
@base_dir = base_dir
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def title(value)
|
|
17
|
+
document.title = value.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def board(type, **options)
|
|
21
|
+
raise DSLError, "board may only be declared once" if @board_declared
|
|
22
|
+
unknown = options.keys - [:split_rails]
|
|
23
|
+
raise DSLError, "unknown board option #{unknown.first}" unless unknown.empty?
|
|
24
|
+
if options.key?(:split_rails) && ![true, false].include?(options[:split_rails])
|
|
25
|
+
raise DSLError, "split_rails must be boolean"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@board_declared = true
|
|
29
|
+
document.board = { type: type.to_s, options: options }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def use_parts(path)
|
|
33
|
+
document.part_paths.concat(Dir.glob(resolve_path(path)))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def use_boards(path)
|
|
37
|
+
document.board_paths.concat(Dir.glob(resolve_path(path)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def supply(name, voltage:, plus:, minus:)
|
|
41
|
+
document.supplies << { name: name.to_s, voltage: Value.parse(voltage), plus: plus.to_s,
|
|
42
|
+
minus: minus.to_s, location: source_location }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def net(name, *refs, at: nil, **options)
|
|
46
|
+
if @expected
|
|
47
|
+
@expected << { kind: "net", name: name.to_s, refs: (refs + [at] + options.values).compact.map(&:to_s), location: source_location }
|
|
48
|
+
else
|
|
49
|
+
raise DSLError, "net requires at: outside expect" if at.nil? || !refs.empty? || !options.empty?
|
|
50
|
+
|
|
51
|
+
document.labels << { name: name.to_s, at: at.to_s, location: source_location }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def part(ref, type, value = nil, pins: nil, at: nil, **attrs)
|
|
56
|
+
unsupported = attrs.keys.find { |key| %i[rotate wire_layer].include?(key.to_sym) }
|
|
57
|
+
raise DSLError, "unsupported component option #{unsupported}" if unsupported
|
|
58
|
+
|
|
59
|
+
document.components << { ref: ref.to_s, type: type.to_s, value: value, pins: pins, at: at,
|
|
60
|
+
attrs: attrs, unused: Array(attrs.delete(:unused)), location: source_location }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resistor(ref, value, **options)
|
|
64
|
+
part(ref, :resistor, value, **options)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def capacitor(ref, value, **options)
|
|
68
|
+
part(ref, :capacitor, value, **options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def electrolytic(ref, value, **options)
|
|
72
|
+
part(ref, :electrolytic, value, **options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def diode(ref, value = nil, **options)
|
|
76
|
+
part(ref, :diode, value, **options)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def led(ref, **options)
|
|
80
|
+
part(ref, :led, nil, **options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def transistor(ref, value = nil, **options)
|
|
84
|
+
part(ref, :transistor, value, **options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def pot(ref, value = nil, **options)
|
|
88
|
+
part(ref, :pot, value, **options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def button(ref, **options)
|
|
92
|
+
part(ref, :button, nil, **options)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ic(ref, value, **options)
|
|
96
|
+
part(ref, value.to_s.downcase == "dip" ? :dip : value, nil, **options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def wire(from, to, color: nil, id: nil, route: :straight, layer: nil, electrical: true, dashed: false)
|
|
100
|
+
document.wires << { id: id&.to_s, from: from.to_s, to: to.to_s, color: color&.to_s,
|
|
101
|
+
route: route.to_s, layer: layer_names(layer), electrical: electrical != false,
|
|
102
|
+
dashed: !!dashed, location: source_location }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def offboard(name, type, side: :left, at: nil, unused: [], **attrs)
|
|
106
|
+
unsupported = attrs.keys.find { |key| %i[rotate wire_layer].include?(key.to_sym) }
|
|
107
|
+
raise DSLError, "unsupported component option #{unsupported}" if unsupported
|
|
108
|
+
|
|
109
|
+
attrs[:at] = at if at
|
|
110
|
+
document.components << { ref: name.to_s, type: type.to_s, attrs: attrs.merge(side: side.to_s),
|
|
111
|
+
pins: nil, unused: Array(unused), location: source_location, offboard: true }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def expect(strict: false, &block)
|
|
115
|
+
entries = []
|
|
116
|
+
previous = @expected
|
|
117
|
+
@expected = entries
|
|
118
|
+
instance_eval(&block)
|
|
119
|
+
document.expectations << { strict: strict, entries: entries, location: source_location }
|
|
120
|
+
ensure
|
|
121
|
+
@expected = previous
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def connected(*refs)
|
|
125
|
+
raise DSLError, "connected must be inside expect" unless @expected
|
|
126
|
+
|
|
127
|
+
@expected << { kind: "connected", refs: refs.map(&:to_s), location: source_location }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def isolated(*refs)
|
|
131
|
+
raise DSLError, "isolated must be inside expect" unless @expected
|
|
132
|
+
|
|
133
|
+
@expected << { kind: "isolated", refs: refs.map(&:to_s), location: source_location }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def lint_disable(rule_id, on: nil, reason: nil)
|
|
137
|
+
document.lint_disables << { rule: rule_id.to_s, on: on&.to_s, reason: reason, location: source_location }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def method_missing(name, *_args, **_kwargs, &_block)
|
|
141
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: METHODS).correct(name.to_s)
|
|
142
|
+
hint = suggestions.empty? ? "" : "; did you mean #{suggestions.first.inspect}?"
|
|
143
|
+
raise DSLError, "unknown DSL method #{name}#{hint}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def respond_to_missing?(name, _include_private = false)
|
|
147
|
+
METHODS.include?(name.to_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def source_location
|
|
153
|
+
loc = caller_locations(2, 12).find { |item| item.path && File.expand_path(item.path) != __FILE__ }
|
|
154
|
+
SourceLocation.new(path: loc&.path, line: loc&.lineno)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def resolve_path(path)
|
|
158
|
+
value = path.to_s
|
|
159
|
+
File.expand_path(value, @base_dir)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def layer_names(value)
|
|
163
|
+
return if value.nil?
|
|
164
|
+
|
|
165
|
+
Array(value).map(&:to_s)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.load_file(path)
|
|
170
|
+
absolute = File.expand_path(path)
|
|
171
|
+
builder = Builder.new(base_dir: File.dirname(absolute))
|
|
172
|
+
builder.instance_eval(File.read(absolute, encoding: "UTF-8"), absolute, 1)
|
|
173
|
+
builder.document
|
|
174
|
+
rescue DSLError
|
|
175
|
+
raise
|
|
176
|
+
rescue ScriptError, StandardError => e
|
|
177
|
+
line = e.backtrace_locations&.find { |frame| frame.path == absolute }&.lineno
|
|
178
|
+
location = line ? "#{path}:#{line}" : path
|
|
179
|
+
raise DSLError, "#{location}: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class HoleId
|
|
5
|
+
attr_reader :kind, :row, :col, :rail, :index, :ref, :pin
|
|
6
|
+
|
|
7
|
+
def initialize(kind:, row: nil, col: nil, rail: nil, index: nil, ref: nil, pin: nil)
|
|
8
|
+
@kind, @row, @col, @rail, @index, @ref, @pin = kind, row, col, rail, index, ref, pin
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.parse(value, board: nil)
|
|
13
|
+
text = value.to_s.strip
|
|
14
|
+
rows = board ? board.terminal_rows : ('a'..'j').to_a
|
|
15
|
+
rails = board ? board.rail_ids : %w[T+ T- B+ B-]
|
|
16
|
+
rows.sort_by { |row| -row.length }.each do |row|
|
|
17
|
+
m = /\A(#{Regexp.escape(row)})(\d+)\z/i.match(text)
|
|
18
|
+
next unless m
|
|
19
|
+
|
|
20
|
+
col = m[2].to_i
|
|
21
|
+
raise ArgumentError, "invalid hole: #{value}" unless col.positive?
|
|
22
|
+
return new(kind: :terminal, row: row, col: col)
|
|
23
|
+
end
|
|
24
|
+
rails.sort_by { |rail| -rail.length }.each do |rail|
|
|
25
|
+
m = /\A(#{Regexp.escape(rail)})(\d*)\z/i.match(text)
|
|
26
|
+
next unless m
|
|
27
|
+
|
|
28
|
+
index = m[2].empty? ? nil : m[2].to_i
|
|
29
|
+
raise ArgumentError, "invalid hole: #{value}" if index == 0
|
|
30
|
+
return new(kind: :rail, rail: rail, index: index)
|
|
31
|
+
end
|
|
32
|
+
if (m = /\A([A-Za-z][\w-]*)\.([A-Za-z0-9_+-]+)\z/.match(text))
|
|
33
|
+
return new(kind: :pin, ref: m[1], pin: m[2])
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "invalid hole or pin reference: #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
case kind
|
|
40
|
+
when :terminal then "#{row}#{col}"
|
|
41
|
+
when :rail then "#{rail}#{index}"
|
|
42
|
+
else "#{ref}.#{pin}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/breadkit/ir.rb
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Breadkit
|
|
6
|
+
module IR
|
|
7
|
+
class Writer
|
|
8
|
+
def write(circuit)
|
|
9
|
+
library = PartLibrary.new
|
|
10
|
+
part_definitions = circuit.components.values.map(&:part).uniq.filter_map do |part|
|
|
11
|
+
standard = library.find(part.id, pin_count: part.pins.length)
|
|
12
|
+
part.data unless standard&.data == part.data
|
|
13
|
+
end
|
|
14
|
+
result = {
|
|
15
|
+
schema_version: 1,
|
|
16
|
+
title: circuit.title,
|
|
17
|
+
board: { type: circuit.board.definition.id, options: { split_rails: circuit.board.split_rails } },
|
|
18
|
+
board_definition: circuit.board.definition.data,
|
|
19
|
+
supplies: circuit.supplies.map { |item| { name: item.name, voltage: item.voltage, plus: item.plus, minus: item.minus, source: source(item.location) } },
|
|
20
|
+
labels: circuit.labels.map { |item| { net: item.name, at: item.at, source: source(item.location) } },
|
|
21
|
+
components: circuit.components.values.map do |component|
|
|
22
|
+
{ ref: component.ref, part: component.part.id, value: component.value,
|
|
23
|
+
attrs: stringify(component.attrs), pins: component.pins.transform_values(&:hole_id),
|
|
24
|
+
unused: component.unused, source: source(component.location) }
|
|
25
|
+
end,
|
|
26
|
+
wires: circuit.wires.map { |wire| { id: wire.id, from: wire.from, to: wire.to, color: wire.color, route: wire.route,
|
|
27
|
+
layer: wire.layer, electrical: wire.electrical, dashed: wire.dashed,
|
|
28
|
+
source: source(wire.location) } },
|
|
29
|
+
expectations: stringify(circuit.expectations),
|
|
30
|
+
lint_disables: stringify(circuit.lint_disables),
|
|
31
|
+
analysis: { nets: circuit.nets.map { |net| { name: net.name, members: net.members, holes: net.holes, potential: circuit.potentials.values[net.name] } } }
|
|
32
|
+
}
|
|
33
|
+
result[:part_definitions] = part_definitions unless part_definitions.empty?
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def source(location)
|
|
40
|
+
return nil unless location && location.path && location.line
|
|
41
|
+
|
|
42
|
+
path = Pathname.new(location.path)
|
|
43
|
+
path = path.relative_path_from(Pathname.pwd) if path.absolute?
|
|
44
|
+
{ path: path.to_s, line: location.line }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stringify(value)
|
|
48
|
+
case value
|
|
49
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify(item) }
|
|
50
|
+
when Array then value.map { |item| stringify(item) }
|
|
51
|
+
when SourceLocation then source(value).transform_keys(&:to_s)
|
|
52
|
+
when Struct then value.each_pair.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify(item) }
|
|
53
|
+
when Symbol then value.to_s
|
|
54
|
+
else value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Reader
|
|
60
|
+
def read_file(path)
|
|
61
|
+
read(JSON.parse(File.read(path, encoding: "UTF-8")))
|
|
62
|
+
rescue JSON::ParserError => e
|
|
63
|
+
raise DSLError, "#{path}: invalid IR JSON: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read(data)
|
|
67
|
+
data = stringify_keys(data)
|
|
68
|
+
validate!(data)
|
|
69
|
+
doc = Document.new
|
|
70
|
+
doc.title = data["title"]
|
|
71
|
+
doc.board = { type: data.dig("board", "type") || "full", options: (data.dig("board", "options") || {}).transform_keys(&:to_sym) }
|
|
72
|
+
doc.board_definitions = [data["board_definition"]] if data["board_definition"]
|
|
73
|
+
doc.supplies = Array(data["supplies"]).map do |item|
|
|
74
|
+
{ name: item.fetch("name"), voltage: Value.parse(item.fetch("voltage")), plus: item.fetch("plus"), minus: item.fetch("minus"), location: location(item["source"]) }
|
|
75
|
+
end
|
|
76
|
+
doc.labels = Array(data["labels"]).map do |item|
|
|
77
|
+
{ name: item.fetch("net"), at: item.fetch("at"), location: location(item["source"]) }
|
|
78
|
+
end
|
|
79
|
+
doc.components = Array(data["components"]).map do |item|
|
|
80
|
+
{ ref: item.fetch("ref"), type: item.fetch("part"), value: item["value"],
|
|
81
|
+
pins: item["pins"] || {}, at: nil, attrs: (item["attrs"] || {}).transform_keys(&:to_sym),
|
|
82
|
+
unused: item["unused"] || [], location: location(item["source"]) }
|
|
83
|
+
end
|
|
84
|
+
doc.part_definitions = Array(data["part_definitions"])
|
|
85
|
+
doc.wires = Array(data["wires"]).map do |item|
|
|
86
|
+
{ id: item["id"], from: item.fetch("from"), to: item.fetch("to"), color: item["color"],
|
|
87
|
+
route: item["route"] || "straight", layer: item["layer"], electrical: item["electrical"] != false,
|
|
88
|
+
dashed: item["dashed"] == true, location: location(item["source"]) }
|
|
89
|
+
end
|
|
90
|
+
doc.expectations = Array(data["expectations"])
|
|
91
|
+
doc.lint_disables = Array(data["lint_disables"]).map do |item|
|
|
92
|
+
{ rule: item.fetch("rule"), on: item["on"], reason: item["reason"], location: location(item["location"]) }
|
|
93
|
+
end
|
|
94
|
+
Resolver.new.call(doc)
|
|
95
|
+
rescue KeyError => e
|
|
96
|
+
raise DSLError, "invalid IR: missing #{e.key}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def validate!(data)
|
|
102
|
+
require_hash(data, "root")
|
|
103
|
+
raise DSLError, "unsupported IR schema_version" unless data["schema_version"] == 1
|
|
104
|
+
raise DSLError, "invalid IR: title must be text or null" unless data["title"].nil? || data["title"].is_a?(String)
|
|
105
|
+
|
|
106
|
+
board = require_hash(data["board"], "board")
|
|
107
|
+
require_string(board["type"], "board.type")
|
|
108
|
+
options = require_hash(board["options"], "board.options")
|
|
109
|
+
unless !options.key?("split_rails") || [true, false].include?(options["split_rails"])
|
|
110
|
+
raise DSLError, "invalid IR: board.options.split_rails must be boolean"
|
|
111
|
+
end
|
|
112
|
+
validate_board_definition(data["board_definition"]) if data.key?("board_definition")
|
|
113
|
+
validate_records(data, "supplies", %w[name plus minus], %w[voltage])
|
|
114
|
+
validate_records(data, "labels", %w[net at])
|
|
115
|
+
validate_records(data, "components", %w[ref part])
|
|
116
|
+
validate_records(data, "wires", %w[id from to])
|
|
117
|
+
validate_records(data, "expectations")
|
|
118
|
+
validate_records(data, "part_definitions") if data.key?("part_definitions")
|
|
119
|
+
validate_records(data, "lint_disables", %w[rule]) if data.key?("lint_disables")
|
|
120
|
+
Array(data["part_definitions"]).each_with_index do |item, index|
|
|
121
|
+
require_string(item["id"], "part_definitions[#{index}].id")
|
|
122
|
+
require_array(item["pins"], "part_definitions[#{index}].pins").each do |pin|
|
|
123
|
+
require_hash(pin, "part_definitions[#{index}].pins item")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
Array(data["lint_disables"]).each_with_index do |item, index|
|
|
127
|
+
require_source(item["location"], "lint_disables[#{index}].location") if item.key?("location")
|
|
128
|
+
end
|
|
129
|
+
data.fetch("components").each_with_index do |item, index|
|
|
130
|
+
require_hash(item["pins"], "components[#{index}].pins").each_value do |hole|
|
|
131
|
+
raise DSLError, "invalid IR: components[#{index}].pins values must be text or null" unless hole.nil? || hole.is_a?(String)
|
|
132
|
+
end
|
|
133
|
+
require_hash(item["attrs"], "components[#{index}].attrs")
|
|
134
|
+
require_array(item["unused"], "components[#{index}].unused")
|
|
135
|
+
end
|
|
136
|
+
data.fetch("expectations").each_with_index do |item, index|
|
|
137
|
+
require_source(item["location"], "expectations[#{index}].location")
|
|
138
|
+
require_array(item["entries"], "expectations[#{index}].entries")
|
|
139
|
+
item["entries"].each_with_index do |entry, entry_index|
|
|
140
|
+
require_hash(entry, "expectations[#{index}].entries[#{entry_index}]")
|
|
141
|
+
unless %w[connected isolated net].include?(entry["kind"])
|
|
142
|
+
raise DSLError, "invalid IR: expectations[#{index}].entries[#{entry_index}].kind is unknown"
|
|
143
|
+
end
|
|
144
|
+
require_array(entry["refs"], "expectations[#{index}].entries[#{entry_index}].refs")
|
|
145
|
+
require_source(entry["location"], "expectations[#{index}].entries[#{entry_index}].location")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
require_array(require_hash(data["analysis"], "analysis")["nets"], "analysis.nets") if data.key?("analysis")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_board_definition(value)
|
|
152
|
+
board = require_hash(value, "board_definition")
|
|
153
|
+
require_string(board["id"], "board_definition.id")
|
|
154
|
+
terminal = require_hash(board["terminal"], "board_definition.terminal")
|
|
155
|
+
unless terminal["columns"].is_a?(Integer) && terminal["columns"].positive?
|
|
156
|
+
raise DSLError, "invalid IR: board_definition.terminal.columns must be a positive integer"
|
|
157
|
+
end
|
|
158
|
+
require_array(terminal["rows"], "board_definition.terminal.rows").each do |row|
|
|
159
|
+
require_string(row, "board_definition.terminal.rows item")
|
|
160
|
+
end
|
|
161
|
+
require_array(terminal["groups"], "board_definition.terminal.groups").each do |group|
|
|
162
|
+
require_array(group, "board_definition.terminal.groups item")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_records(data, key, strings = [], numbers = [])
|
|
167
|
+
require_array(data[key], key).each_with_index do |item, index|
|
|
168
|
+
require_hash(item, "#{key}[#{index}]")
|
|
169
|
+
strings.each { |field| require_string(item[field], "#{key}[#{index}].#{field}") }
|
|
170
|
+
numbers.each do |field|
|
|
171
|
+
raise DSLError, "invalid IR: #{key}[#{index}].#{field} must be a number" unless item[field].is_a?(Numeric)
|
|
172
|
+
end
|
|
173
|
+
require_source(item["source"], "#{key}[#{index}].source") if item.key?("source")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def require_source(value, path)
|
|
178
|
+
return if value.nil?
|
|
179
|
+
|
|
180
|
+
require_hash(value, path)
|
|
181
|
+
require_string(value["path"], "#{path}.path")
|
|
182
|
+
raise DSLError, "invalid IR: #{path}.line must be a positive integer" unless value["line"].is_a?(Integer) && value["line"].positive?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def require_hash(value, path)
|
|
186
|
+
raise DSLError, "invalid IR: #{path} must be an object" unless value.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def require_array(value, path)
|
|
192
|
+
raise DSLError, "invalid IR: #{path} must be an array" unless value.is_a?(Array)
|
|
193
|
+
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def require_string(value, path)
|
|
198
|
+
raise DSLError, "invalid IR: #{path} must be text" unless value.is_a?(String)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def location(source)
|
|
202
|
+
return nil unless source
|
|
203
|
+
SourceLocation.new(path: source["path"], line: source["line"])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def stringify_keys(value)
|
|
207
|
+
case value
|
|
208
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify_keys(item) }
|
|
209
|
+
when Array then value.map { |item| stringify_keys(item) }
|
|
210
|
+
else value
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
SourceLocation = Struct.new(:path, :line, keyword_init: true)
|
|
5
|
+
Diagnostic = Data.define(:code, :severity, :message, :location, :targets)
|
|
6
|
+
Hole = Struct.new(:id, :kind, :row, :col, :rail, :x, :y, :strip_id, keyword_init: true)
|
|
7
|
+
Strip = Struct.new(:id, :hole_ids, keyword_init: true)
|
|
8
|
+
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
|
|
10
|
+
def pin(reference)
|
|
11
|
+
definition = part.pin(reference)
|
|
12
|
+
pins[(definition["name"] || definition["num"]).to_s] if definition
|
|
13
|
+
end
|
|
14
|
+
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)
|
|
18
|
+
Net = Struct.new(:name, :members, :holes, :labels, :potential, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
class Document
|
|
21
|
+
attr_accessor :title, :board, :supplies, :labels, :components, :wires, :expectations,
|
|
22
|
+
:lint_disables, :part_paths, :part_definitions, :board_paths, :board_definitions
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@title = nil
|
|
26
|
+
@board = { type: "full", options: {} }
|
|
27
|
+
@supplies, @labels, @components, @wires = [], [], [], []
|
|
28
|
+
@expectations, @lint_disables, @part_paths, @part_definitions, @board_paths, @board_definitions = [], [], [], [], [], []
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Breadkit
|
|
4
|
+
class PartDef
|
|
5
|
+
attr_reader :data
|
|
6
|
+
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
raise ArgumentError, "part definition needs an id" unless data["id"]
|
|
10
|
+
raise ArgumentError, "part #{data['id']} needs pins" unless data["pins"].is_a?(Array)
|
|
11
|
+
identities = {}
|
|
12
|
+
pins.each_with_index do |pin, index|
|
|
13
|
+
raise ArgumentError, "part #{id} has invalid pin" unless pin.is_a?(Hash) && pin["num"]
|
|
14
|
+
|
|
15
|
+
([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.each do |identity|
|
|
16
|
+
key = identity.to_s.downcase
|
|
17
|
+
raise ArgumentError, "part #{id} has duplicate pin identity #{identity}" if identities.key?(key) && identities[key] != index
|
|
18
|
+
|
|
19
|
+
identities[key] = index
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
valid_pins = pins.flat_map { |pin| [pin["num"], pin["name"], *Array(pin["aliases"])].compact.map(&:to_s) }
|
|
23
|
+
%w[internal switch same_strip_ok].each do |key|
|
|
24
|
+
Array(data[key]).each do |pair|
|
|
25
|
+
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
|
+
end
|
|
27
|
+
end
|
|
28
|
+
raise ArgumentError, "part #{id} has invalid placement" unless %w[leads dip footprint offboard].include?(placement)
|
|
29
|
+
if data["polarity"]
|
|
30
|
+
unless data["polarity"].is_a?(Hash) && data["polarity"].values.all? { |name| identities.key?(name.to_s.downcase) }
|
|
31
|
+
raise ArgumentError, "part #{id} has invalid polarity pin reference"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
if data["footprint"]
|
|
35
|
+
numbers = pins.map { |pin| pin.fetch("num").to_s }
|
|
36
|
+
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
|
+
raise ArgumentError, "part #{id} has invalid footprint pin reference or offset"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
if data.dig("render", "shape") == "module"
|
|
41
|
+
size_mm = data.dig("render", "size_mm")
|
|
42
|
+
raise ArgumentError, "part #{id} module rendering needs positive size_mm [width, height]" unless valid_mm_pair?(size_mm, positive: true)
|
|
43
|
+
offset_mm = data.dig("render", "body_offset_mm")
|
|
44
|
+
if offset_mm && !valid_mm_pair?(offset_mm, positive: false)
|
|
45
|
+
raise ArgumentError, "part #{id} module rendering needs finite body_offset_mm [x, y]"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def id
|
|
51
|
+
data.fetch("id")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pins
|
|
55
|
+
data.fetch("pins")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def placement
|
|
59
|
+
data.fetch("placement", "leads")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pin(value)
|
|
63
|
+
key = value.to_s.downcase
|
|
64
|
+
pins.find do |pin|
|
|
65
|
+
([pin["num"], pin["name"]] + Array(pin["aliases"])).compact.any? { |item| item.to_s.downcase == key }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def valid_mm_pair?(values, positive:)
|
|
72
|
+
values.is_a?(Array) && values.length == 2 && values.all? do |value|
|
|
73
|
+
number = Float(value)
|
|
74
|
+
number.finite? && (!positive || number.positive?)
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class PartLibrary
|
|
82
|
+
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) })
|
|
85
|
+
definitions = paths.uniq.map { |path| YAML.safe_load(File.read(path, encoding: "UTF-8"), aliases: false) }
|
|
86
|
+
definitions.concat(extra_definitions)
|
|
87
|
+
@parts = {}
|
|
88
|
+
definitions.each do |data|
|
|
89
|
+
part = PartDef.new(data)
|
|
90
|
+
([part.id] + Array(data["aliases"])).each do |name|
|
|
91
|
+
key = name.to_s.downcase
|
|
92
|
+
raise ArgumentError, "part alias #{name} conflicts with #{@parts[key].id}" if @parts[key] && @parts[key] != part
|
|
93
|
+
|
|
94
|
+
@parts[key] = part
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def find(name, pin_count: nil)
|
|
100
|
+
key = name.to_s.downcase
|
|
101
|
+
return @parts[key] if @parts[key]
|
|
102
|
+
return unless %w[dip pin_header].include?(key) && pin_count
|
|
103
|
+
|
|
104
|
+
count = Integer(pin_count)
|
|
105
|
+
return unless count.between?(key == "dip" ? 2 : 1, 64) && (key != "dip" || count.even?)
|
|
106
|
+
generic(key, count)
|
|
107
|
+
rescue ArgumentError, TypeError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def all
|
|
112
|
+
(@parts.values.uniq + %w[dip pin_header].map { |id| generic(id, 2) }).sort_by(&:id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def generic(id, count)
|
|
118
|
+
pins = (1..count).map { |number| { "num" => number, "name" => number.to_s } }
|
|
119
|
+
data = { "id" => id, "placement" => id == "dip" ? "dip" : "footprint", "pins" => pins,
|
|
120
|
+
"package" => { "pins" => count }, "render" => { "shape" => id == "dip" ? "dip" : "generic" } }
|
|
121
|
+
data["footprint"] = (1..count).to_h { |number| [number.to_s, [number - 1, 0]] } if id == "pin_header"
|
|
122
|
+
PartDef.new(data)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|