Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 1 | #!/usr/bin/env ruby |
| 2 | |
| 3 | # This makes DNS lookups actually work reliably and give useful errors. |
| 4 | require 'resolv-replace.rb' |
| 5 | |
| 6 | require 'yaml' |
| 7 | require 'optparse' |
| 8 | |
| 9 | require './parts' |
| 10 | require './gschem_file' |
| 11 | |
| 12 | =begin |
| 13 | Property names in the schematic file: |
| 14 | value is the main value of the component. |
| 15 | secondary_value is another value of the component. |
| 16 | The value in the schematic is only a minimum. |
| 17 | power rating for resistors. |
| 18 | voltage for capacitors. |
| 19 | pn_optional set to 1 means that the pn property is non-critical. |
| 20 | tolerance is a maximum tolerance for value. |
| 21 | =end |
| 22 | |
| 23 | options = {} |
| 24 | OptionParser.new do |opts| |
| 25 | opts.banner = "Usage: ordering.rb file.sch [options]" |
| 26 | |
| 27 | opts.on('--myro-bom', String, :REQUIRED, 'Generate myropcb BOM') do |v| |
| 28 | options[:bom_file] = v |
| 29 | options[:bom_style] = :myro |
| 30 | end |
| 31 | opts.on('--mouser-bom', String, :REQUIRED, 'Generate Mouser BOM (ONLY MOUSER PARTS)') do |v| |
| 32 | options[:bom_file] = v |
| 33 | options[:bom_style] = :mouser |
| 34 | end |
| 35 | opts.on('--bom', String, :REQUIRED, 'Generate a nice BOM') do |v| |
| 36 | options[:bom_file] = v |
| 37 | options[:bom_style] = :nice |
| 38 | end |
| 39 | end.parse! |
| 40 | |
| 41 | options[:order_quantity] = 2 |
| 42 | options[:include_dev] = true |
| 43 | |
| 44 | if options[:bom_file] |
| 45 | $bom_file = File.open(options[:bom_file], 'w') |
| 46 | case options[:bom_style] |
| 47 | when :myro |
| 48 | $bom_file.puts 'Qty,Part Reference,Part Number,MFGR,Distributor NO,DESCRIPTION,FOOTPRINT,DNI' |
| 49 | when :mouser |
| 50 | $bom_file.puts 'Mfg Part Number,Quantity,Description' |
| 51 | else |
| 52 | $bom_file.puts 'Quantity,Refdes,Part Number,Footprint,Value,Unit Price' |
| 53 | end |
| 54 | end |
| 55 | |
| 56 | if ARGV.size == 0 |
| 57 | raise 'Need schematics to look at!' |
| 58 | end |
| 59 | puts ARGV |
| 60 | filenames = get_schematic_filenames ARGV |
| 61 | |
Brian Silverman | 856af59 | 2017-12-18 11:17:09 -0500 | [diff] [blame] | 62 | #$parts_yaml = YAML.load_file('parts.yaml') |
| 63 | $parts_yaml = {} |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 64 | |
| 65 | module Unicode |
| 66 | NBSP = "\u00A0" |
| 67 | PlusMinus = "\u00B1" |
| 68 | Micro = "\u00B5" |
| 69 | end |
| 70 | |
| 71 | def chomp_zeros s |
| 72 | while s[-1] == '0' |
| 73 | s.chomp! '0' |
| 74 | end |
| 75 | end |
| 76 | |
| 77 | class Part |
| 78 | SecondaryValueRegex = /^([0-9.]+) (.*)$/ |
| 79 | |
| 80 | attr_reader :refdeses |
| 81 | attr_reader :pn, :value, :footprint, :device, :secondary_value, :tolerance |
| 82 | attr_reader :pn_optional, :dev_only |
| 83 | |
| 84 | attr_reader :low_quantity |
| 85 | |
| 86 | def quantity |
| 87 | @refdeses.size |
| 88 | end |
| 89 | |
| 90 | def initialize(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only) |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 91 | if value == 'DNP' |
| 92 | if pn |
| 93 | puts "Error: part #{pn} for DNP from #{refdes}" |
| 94 | end |
| 95 | @value = 'DNP' |
| 96 | @pn, @footprint, @device = nil, nil, nil |
| 97 | @secondary_value, @tolerance = nil |
| 98 | @pn_optional, @dev_only = nil |
| 99 | else |
| 100 | @pn, @value, @footprint, @device = pn, value, footprint, device |
| 101 | @secondary_value, @tolerance = secondary_value, tolerance |
| 102 | @pn_optional, @dev_only = pn_optional, dev_only |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 103 | |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 104 | puts "#{refdes} has no footprint" unless footprint |
| 105 | end |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 106 | |
| 107 | @refdeses = [refdes] |
| 108 | end |
| 109 | def found_another(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only) |
| 110 | error = false |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 111 | if value == 'DNP' |
| 112 | if @value != 'DNP' |
| 113 | puts "Error: DNP vs not" |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 114 | error = true |
| 115 | end |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 116 | else |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 117 | if pn != @pn |
| 118 | puts "Error: pn #@pn vs #{pn}." |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 119 | error = true |
| 120 | end |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 121 | if value != @value |
| 122 | puts "Error: value #@value vs #{value}." |
| 123 | error = true |
| 124 | end |
| 125 | if footprint != @footprint |
| 126 | puts "Error: footprint #@footprint vs #{footprint}." |
| 127 | error = true |
| 128 | end |
| 129 | if device != @device |
| 130 | puts "Error: device #@device vs #{device}." |
| 131 | error = true |
| 132 | end |
| 133 | |
| 134 | if pn_optional != @pn_optional |
| 135 | puts "Error: pn_optional #@pn_optional vs #{pn_optional}." |
| 136 | error = true |
| 137 | end |
| 138 | if dev_only != @dev_only |
| 139 | puts "Error: dev_only #@dev_only vs #{dev_only}." |
| 140 | error = true |
| 141 | end |
| 142 | |
| 143 | if tolerance && @tolerance |
| 144 | if tolerance != @tolerance |
| 145 | puts "Error: tolerance #@tolerance vs #{tolerance}." |
| 146 | error = true |
| 147 | end |
| 148 | elsif tolerance |
| 149 | @tolerance = tolerance |
| 150 | end |
| 151 | |
| 152 | new_secondary_match = SecondaryValueRegex.match secondary_value |
| 153 | my_secondary_match = SecondaryValueRegex.match @secondary_value |
| 154 | if new_secondary_match && my_secondary_match && |
| 155 | new_secondary_match[2] == my_secondary_match[2] |
| 156 | new_value = new_secondary_match[1].to_f |
| 157 | my_value = my_secondary_match[1].to_f |
| 158 | @secondary_value = [new_value, my_value].max.to_s + ' ' + my_secondary_match[2] |
| 159 | else |
| 160 | if secondary_value != @secondary_value |
| 161 | puts "Error: secondary_value #@secondary_value vs #{secondary_value}." |
| 162 | error = true |
| 163 | end |
| 164 | end |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 165 | end |
| 166 | |
| 167 | if error |
| 168 | puts "\tAdding #{refdes} to #{@refdeses.inspect}" |
| 169 | end |
| 170 | |
| 171 | @refdeses.push refdes unless @refdeses.include? refdes |
| 172 | end |
| 173 | |
| 174 | def override_pn pn |
| 175 | @pn = pn |
| 176 | end |
| 177 | def override_footprint footprint |
| 178 | @footprint = footprint |
| 179 | end |
| 180 | end |
| 181 | |
| 182 | $parts = {} |
| 183 | |
| 184 | def add_part(attrs, refdes) |
| 185 | args = [attrs[:pn], attrs[:value], attrs[:footprint], attrs[:device], |
| 186 | attrs[:secondary_value], refdes, attrs[:tolerance], |
| 187 | attrs[:pn_optional], attrs[:dev_only]] |
Brian Silverman | 09493a3 | 2018-01-07 15:49:15 -0800 | [diff] [blame] | 188 | if attrs[:value] == 'DNP' |
| 189 | key = 'DNP' |
| 190 | else |
| 191 | key = attrs[:pn] |
| 192 | unless key |
| 193 | key = attrs[:value] || '' |
| 194 | key += attrs[:footprint] || '' |
| 195 | end |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 196 | end |
| 197 | raise "No pn or value for #{args} (#{refdes})" unless key && !key.empty? |
| 198 | if $parts.include?(key) |
| 199 | $parts[key].found_another(*args) |
| 200 | else |
| 201 | $parts[key] = Part.new(*args) |
| 202 | end |
| 203 | end |
| 204 | |
| 205 | def set_or_same(old, new) |
| 206 | if new |
| 207 | if old |
| 208 | if old != new |
| 209 | raise "#{old.inspect} != #{new.inspect}" |
| 210 | end |
| 211 | end |
| 212 | new |
| 213 | else |
| 214 | old |
| 215 | end |
| 216 | end |
| 217 | |
| 218 | MouserKeyRegex = /^\n(.*):\n$/ |
| 219 | MouserValueRegex = /^\n(.*)\n$/ |
| 220 | |
| 221 | def mouser_table_to_map table |
| 222 | raise "Bad table from Mouser" if table.empty? |
| 223 | r = {} |
| 224 | rows = table.css('tr > td > table > tr') |
| 225 | rows.each do |row| |
| 226 | raise "Unexpected HTML" unless row.elements.length == 3 |
| 227 | |
| 228 | name = row.elements[0].text |
| 229 | match = MouserKeyRegex.match(name) |
| 230 | raise "Unexpected table key #{name.inspect}" unless match |
| 231 | key = match[1] |
| 232 | |
| 233 | value = row.elements[1].text |
| 234 | match = MouserValueRegex.match(value) |
| 235 | raise "Unexpected table value #{value.inspect}" unless match |
| 236 | value = match[1] |
| 237 | r[key] = value |
| 238 | end |
| 239 | r |
| 240 | end |
| 241 | |
| 242 | def digikey_table_to_map table |
| 243 | raise "Unexpected HTML" unless table.name == 'table' |
| 244 | r = {} |
| 245 | table.elements.each do |row| |
| 246 | raise "Unexpected HTML" unless row.name == 'tr' |
| 247 | raise "Unexpected HTML" unless row.elements.length == 2 |
| 248 | |
| 249 | name, value = row.elements |
| 250 | raise "Unexpected HTML" unless name.name == 'th' |
| 251 | raise "Unexpected HTML" unless value.name == 'td' |
| 252 | r[name.text.gsub(Unicode::NBSP, '').strip] = value.text.gsub(Unicode::NBSP, '').strip |
| 253 | end |
| 254 | r |
| 255 | end |
| 256 | |
| 257 | SimpleAttributes = [:pn, :value, :footprint, :device, :tolerance, :pn_optional, :dev_only] |
| 258 | |
| 259 | filenames.each do |filename| |
| 260 | puts filename |
| 261 | file = GschemSchematic.new(filename) |
| 262 | file.components.each do |component| |
| 263 | next if component.is_power |
| 264 | next if component[:graphical] |
| 265 | |
| 266 | simple = {} |
| 267 | SimpleAttributes.each do |attr| |
| 268 | simple[attr] = component[attr] |
| 269 | end |
| 270 | case component[:device] |
| 271 | when 'CAPACITOR' |
| 272 | simple[:secondary_value] = component[:voltage] |
| 273 | when 'POLARIZED_CAPACITOR' |
| 274 | simple[:secondary_value] = component[:voltage] |
| 275 | when 'RESISTOR' |
| 276 | simple[:secondary_value] = component[:power] |
| 277 | end |
| 278 | |
| 279 | symbol_filename = "schematic/symbols/#{component.filename}" |
| 280 | if File.exists? symbol_filename |
| 281 | symbol = GschemSymbol.new(symbol_filename) |
| 282 | if symbol[:device] |
| 283 | if component[:pn] |
| 284 | if !component[:footprint] |
| 285 | puts "#{component.refdes} has a pn and its symbol has a device but it has no footprint." |
| 286 | end |
| 287 | else |
| 288 | simple[:pn] = symbol[:device] |
| 289 | end |
| 290 | end |
| 291 | |
| 292 | SimpleAttributes.each do |attr| |
| 293 | if symbol[attr] |
| 294 | if component[attr] |
| 295 | if component[attr] != symbol[attr] |
| 296 | puts "#{component.refdes} overriding #{attr} from symbol." |
| 297 | puts "\t#{symbol[attr]} => #{component[attr]}" |
| 298 | end |
| 299 | else |
| 300 | simple[attr] = symbol[attr] |
| 301 | end |
| 302 | end |
| 303 | end |
| 304 | end |
| 305 | |
| 306 | add_part(simple, component.refdes) |
| 307 | end |
| 308 | end |
| 309 | |
| 310 | # An array of arrays where the first one is the English case code and the second |
| 311 | # is the metric one. |
| 312 | MetricCases = [['0402', '1005'], ['0603', '1608'], ['0805', '2012'], |
| 313 | ['1206', '3216'], ['1210', '3225'], ['1812', '4532']] |
| 314 | SimpleCaseRegex = /^\d\d\d\d$/ |
| 315 | |
| 316 | def retrieve_mouser_package attributes |
| 317 | raw = attributes['Package / Case'] |
| 318 | r = nil |
| 319 | if raw |
| 320 | if SimpleCaseRegex =~ raw |
| 321 | r = set_or_same r, raw |
| 322 | else |
| 323 | t = nil |
| 324 | MetricCases.each do |pair| |
| 325 | if /^#{pair[0]} \(#{pair[1]} [mM]etric\)$/ =~ raw |
| 326 | t = set_or_same r, pair[0] |
| 327 | end |
| 328 | end |
| 329 | r = set_or_same r, t || raw |
| 330 | end |
| 331 | end |
| 332 | r = set_or_same r, attributes['Case Code - in'] |
| 333 | |
| 334 | unless r |
| 335 | return nil |
| 336 | end |
| 337 | |
| 338 | $parts_yaml['footprint_mappings'][r] || r |
| 339 | end |
| 340 | |
| 341 | class PartInfo |
| 342 | Price = Struct.new(:quantity, :price, :link, :dpn) |
| 343 | |
| 344 | attr_reader :part |
| 345 | |
| 346 | attr_accessor :case_package |
| 347 | attr_accessor :component_value |
| 348 | attr_accessor :component_secondary_value |
| 349 | attr_accessor :component_value_tolerance |
| 350 | |
| 351 | attr_accessor :order_quantity |
| 352 | |
| 353 | # value_field is the field name for Octopart. |
| 354 | # value_unit is the units Octopart has. |
| 355 | # value_unit_suffix is the suffix in the .sch file. |
| 356 | attr_accessor :value_field, :value_unit, :value_unit_suffix |
| 357 | # mouser_value_unit_suffix is the suffix Mouser has on the value. |
| 358 | attr_accessor :mouser_value_field, :mouser_value_unit_suffix |
| 359 | attr_accessor :digikey_value_field, :digikey_value_unit_suffix |
| 360 | attr_accessor :secondary_value_field, :secondary_value_unit |
| 361 | attr_accessor :digikey_secondary_value_field, :mouser_secondary_value_field |
| 362 | attr_accessor :mouser_secondary_value_field |
| 363 | |
| 364 | attr_accessor :manufacturer |
| 365 | |
| 366 | def initialize part |
| 367 | @part = part |
| 368 | |
| 369 | @prices = [] |
| 370 | @descriptions = [] |
| 371 | end |
| 372 | |
| 373 | def add_description d |
| 374 | @descriptions.push d |
| 375 | end |
| 376 | def description |
| 377 | raise "No description for #{part}" if @descriptions.empty? |
| 378 | @descriptions[0] |
| 379 | end |
| 380 | |
| 381 | def set_or_same field, value |
| 382 | name = '@' + field.to_s |
| 383 | old = instance_variable_get name |
| 384 | if value |
| 385 | if old |
| 386 | if value.class == Float && old.class == Float |
| 387 | eq = float_eq old, value |
| 388 | else |
| 389 | eq = old == value |
| 390 | end |
| 391 | if !eq |
| 392 | raise "#{old.inspect} (old) != #{value.inspect} (new) for #{part.inspect}" |
| 393 | end |
| 394 | else |
| 395 | instance_variable_set name, value |
| 396 | return value |
| 397 | end |
| 398 | end |
| 399 | return old |
| 400 | end |
| 401 | |
| 402 | def add_price price |
| 403 | @prices.push price |
| 404 | end |
| 405 | def price_for quantity |
| 406 | return nil, nil if @prices.empty? |
| 407 | lowest_info = nil |
| 408 | lowest_price = Float::INFINITY |
| 409 | @prices.each do |price| |
| 410 | c = price.price * (quantity.to_f / price.quantity.to_f).ceil * price.quantity |
| 411 | if c < lowest_price |
| 412 | lowest_info = price |
| 413 | lowest_price = c |
| 414 | end |
| 415 | end |
| 416 | return lowest_price, lowest_info unless lowest_info |
| 417 | if lowest_info.quantity.to_f / quantity.to_f > 5 && (lowest_info.quantity - quantity) > 50 |
| 418 | puts "Warning: no good price for #{quantity} of #{part.inspect}" |
| 419 | puts "\thave #{@prices.inspect}" |
| 420 | end |
| 421 | return lowest_price, lowest_info |
| 422 | end |
| 423 | end |
| 424 | |
| 425 | def strip_from_end suffix, raw |
| 426 | if suffix.class == Regexp |
| 427 | match = suffix.match raw |
| 428 | raise "#{raw.inspect} does not match #{suffix}" unless match |
| 429 | match[1] |
| 430 | else |
| 431 | raise "#{raw.inspect} does not end with #{suffix}" unless raw.end_with? suffix |
| 432 | raw[0..-(suffix.length + 1)] |
| 433 | end |
| 434 | end |
| 435 | |
| 436 | def make_digikey_request part, info |
| 437 | pn = $parts_yaml['digikey_numbers'][part.pn] || part.pn |
| 438 | page = "http://www.digikey.com/product-search/en?keywords=#{URI.escape pn}" |
| 439 | page = $parts_yaml[part.pn]['digikey_url'] || page if $parts_yaml[part.pn] |
| 440 | results = Nokogiri::HTML(retrieve_url(page)) |
| 441 | search_results_html = results.css '#productTable > tbody' |
| 442 | values_table_html = results.css '.attributes-table-main' |
| 443 | prices_table = results.css '#pricing' |
| 444 | product_details = results.css '.product-details' |
| 445 | if !results.css(':contains("No records match your search criteria")').empty? |
| 446 | # No results from Digikey. Skip it. |
| 447 | elsif !values_table_html.empty? |
| 448 | raise "Unexpected HTML" unless values_table_html.length == 1 |
| 449 | raise "Unexpected HTML" unless prices_table.length == 1 |
| 450 | raise "Unexpected HTML" unless product_details.length == 1 |
| 451 | parse_digikey_results values_table_html[0].elements[0], prices_table[0], |
| 452 | product_details[0], page, info |
| 453 | elsif !search_results_html.empty? |
| 454 | raise "Unexpected HTML" unless search_results_html.length == 1 |
| 455 | found = false |
| 456 | search_results_html.children.each do |result| |
| 457 | if result.elements.length == 0 |
| 458 | next |
| 459 | end |
| 460 | raise "Unexpected HTML" unless result.elements.length >= 5 |
| 461 | links = result.elements[4].css 'a' |
| 462 | raise "Unexpected HTML" unless links.length == 1 |
| 463 | mpn = links[0].text.strip |
| 464 | if mpn == part.pn |
| 465 | found = true |
| 466 | |
| 467 | actual_page = 'http://www.digikey.com' + links[0]['href'] |
| 468 | new_results = Nokogiri::HTML(retrieve_url(actual_page)) |
| 469 | new_values_table = new_results.css '.attributes-table-main' |
| 470 | new_prices_table = new_results.css '#pricing' |
| 471 | new_product_details = new_results.css '.product-details' |
| 472 | new_feedback = results.css '#product-details-feedback' |
| 473 | unless new_feedback.empty? |
| 474 | if new_feedback.text == 'Obsolete item; call Digi-Key for more information.' |
| 475 | next |
| 476 | else |
| 477 | puts "Warning: Digikey gave feedback #{new_feedback}" |
| 478 | end |
| 479 | end |
| 480 | raise "Unexpected HTML" unless new_values_table.length == 1 |
| 481 | raise "Unexpected HTML" unless new_prices_table.length == 1 |
| 482 | raise "Unexpected HTML" unless new_product_details.length == 1 |
| 483 | parse_digikey_results new_values_table[0].elements[0], |
| 484 | new_prices_table[0], new_product_details[0], |
| 485 | actual_page, info |
| 486 | end |
| 487 | end |
| 488 | |
| 489 | if !found |
| 490 | puts "Warning: Got multiple Digikey search results for #{part.inspect}, none of them right." |
| 491 | end |
| 492 | else |
| 493 | raise "Don't know what Digikey page #{page} => #{results} for #{part.inspect} contains!" |
| 494 | end |
| 495 | end |
| 496 | |
| 497 | def make_mouser_request part, info |
| 498 | pn = $parts_yaml['mouser_numbers'][part.pn] || part.pn |
| 499 | url = "http://www.mouser.com/Search/Refine.aspx?Keyword=#{URI.escape pn}" |
| 500 | mouser_results = Nokogiri::HTML(retrieve_url(url)) |
| 501 | search_results_html = mouser_results.css '#ctl00_ContentMain_SearchResultsGrid_grid' |
| 502 | values_table_html = mouser_results.css '.specs' |
| 503 | prices_table = mouser_results.css '.PriceBreaks' |
| 504 | details_table = mouser_results.css '#product-desc' |
| 505 | if !mouser_results.css(':contains("did not return any results.")').empty? |
| 506 | # No results from Mouser. Skip it. |
| 507 | elsif !values_table_html.empty? |
| 508 | parse_mouser_results values_table_html, prices_table, details_table, url, info |
| 509 | elsif !search_results_html.empty? |
| 510 | found = false |
| 511 | search_results_html.children[2..-1].each do |result| |
| 512 | if result.elements.length == 0 |
| 513 | next |
| 514 | end |
| 515 | raise "Unexpected HTML" unless result.elements.length >= 4 |
| 516 | links = result.elements[3].css 'a' |
| 517 | raise "Unexpected HTML" unless links.length >= 1 |
| 518 | mpn = links.first.text.strip |
| 519 | if mpn == part.pn |
| 520 | if result.elements[2].text.strip == 'Not Assigned' |
| 521 | # This means that Mouser doesn't actually carry it and probably |
| 522 | # doesn't have much data on it, none of which I want to trust. |
| 523 | next |
| 524 | end |
| 525 | found = true |
| 526 | |
| 527 | actual_page = 'http://www.mouser.com/Search/' + links[0]['href'] |
| 528 | mouser_results = Nokogiri::HTML(retrieve_url(actual_page)) |
| 529 | values_table_html = mouser_results.css '.specs' |
| 530 | prices_table = mouser_results.css '.PriceBreaks' |
| 531 | details_table = mouser_results.css '#product-desc' |
| 532 | parse_mouser_results values_table_html, prices_table, details_table, actual_page, info |
| 533 | end |
| 534 | end |
| 535 | if !found |
| 536 | puts "Warning: Got multiple Mouser search results for #{part.inspect}, none of them right." |
| 537 | end |
| 538 | else |
| 539 | raise "Don't know what Mouser page #{url} => #{mouser_results} contains!" |
| 540 | end |
| 541 | end |
| 542 | |
| 543 | WeirdFractionRegex = /^([0-9.]+ [a-zA-Z]+) \([0-9]+\/[0-9]+ [a-zA-Z]+\)$/ |
| 544 | |
| 545 | def parse_digikey_results values_table_html, prices_table, product_details, url, info |
| 546 | attributes = digikey_table_to_map values_table_html |
| 547 | |
| 548 | digikey_package = retrieve_mouser_package attributes |
| 549 | if digikey_package != 'Non Standard' && digikey_package != 'Nonstandard' |
| 550 | info.set_or_same :case_package, digikey_package |
| 551 | end |
| 552 | |
| 553 | if info.digikey_value_field && attributes[info.digikey_value_field] |
| 554 | v = strip_from_end info.digikey_value_unit_suffix, attributes[info.digikey_value_field] |
| 555 | info.set_or_same :component_value, to_f_with_suffix(v) |
| 556 | end |
| 557 | if info.digikey_secondary_value_field && attributes[info.digikey_secondary_value_field] |
| 558 | v = strip_from_end info.secondary_value_unit, attributes[info.digikey_secondary_value_field] |
| 559 | info.set_or_same :component_secondary_value, to_f_with_suffix(v) |
| 560 | end |
| 561 | |
| 562 | if attributes['Tolerance'] |
| 563 | if attributes['Tolerance'] == '-20%, +80%' |
| 564 | info.set_or_same :component_value_tolerance, 80 |
| 565 | else |
| 566 | v = strip_from_end '%', attributes['Tolerance'].gsub(Unicode::PlusMinus, '') |
| 567 | info.set_or_same :component_value_tolerance, v.to_f |
| 568 | end |
| 569 | end |
| 570 | |
| 571 | distributor_row = product_details.elements[2] |
| 572 | raise "Unexpected HTML" unless distributor_row.elements[0].text == 'Digi-Key Part Number' |
| 573 | description_row = product_details.elements[6] |
| 574 | raise "Unexpected HTML" unless description_row.elements[0].text == 'Description' |
| 575 | |
| 576 | info.add_description description_row.elements[1].text.chomp |
| 577 | |
| 578 | prices_table.elements[1..-1].each do |price| |
| 579 | quantity = price.elements[0].text.gsub(',', '').to_i |
| 580 | raise unless quantity.to_s == price.elements[0].text.gsub(',', '') |
| 581 | price_number = price.elements[1].text.gsub(',', '').to_f |
| 582 | raise unless chomp_zeros(price_number.to_s) == chomp_zeros(price.elements[1].text) |
| 583 | info.add_price PartInfo::Price.new(quantity, price_number, url, |
| 584 | distributor_row.elements[1].text) |
| 585 | end |
| 586 | |
| 587 | manufacturer_row = product_details.elements[4] |
| 588 | raise "Unexpected HTML" unless manufacturer_row.elements[0].text == 'Manufacturer' |
| 589 | manufacturer = manufacturer_row.elements[1].text |
| 590 | manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer |
| 591 | info.set_or_same :manufacturer, manufacturer |
| 592 | end |
| 593 | |
| 594 | def parse_mouser_results values_table_html, prices_table, details_table, url, info |
| 595 | attributes = mouser_table_to_map values_table_html |
| 596 | |
| 597 | mouser_package = retrieve_mouser_package attributes |
| 598 | if info.case_package == 'SRR1210' && mouser_package == '1210' |
| 599 | elsif info.case_package == '0612' && mouser_package == '1206' |
| 600 | else |
| 601 | info.set_or_same :case_package, mouser_package |
| 602 | end |
| 603 | |
| 604 | if info.mouser_value_field && attributes[info.mouser_value_field] |
| 605 | v = strip_from_end info.mouser_value_unit_suffix, attributes[info.mouser_value_field] |
| 606 | info.set_or_same :component_value, to_f_with_suffix(v) |
| 607 | end |
| 608 | if info.mouser_secondary_value_field && attributes[info.mouser_secondary_value_field] |
| 609 | v = attributes[info.mouser_secondary_value_field] |
| 610 | match = WeirdFractionRegex.match v |
| 611 | if match |
| 612 | v = match[1] |
| 613 | end |
| 614 | v = strip_from_end info.secondary_value_unit, v |
| 615 | info.set_or_same :component_secondary_value, to_f_with_suffix(v) |
| 616 | end |
| 617 | |
| 618 | if info.part.device != 'CRYSTAL' && attributes['Tolerance'] |
| 619 | v = strip_from_end '%', attributes['Tolerance'].gsub('+/-', '') |
| 620 | info.set_or_same :component_value_tolerance, v.to_f |
| 621 | end |
| 622 | |
| 623 | raise "Unexpected HTML" unless details_table.length == 1 |
| 624 | details_table_contents = {} |
| 625 | if details_table[0].elements.size > 1 |
| 626 | details_table.first.elements.each do |row| |
| 627 | details_table_contents[row.elements[0].text.strip] = |
| 628 | row.elements[1].text.strip |
| 629 | end |
| 630 | else |
| 631 | details_table.first.elements.first.elements.each do |row| |
| 632 | details_table_contents[row.elements.first.text.strip] = |
| 633 | row.elements[1].text.strip |
| 634 | end |
| 635 | end |
| 636 | details_table_contents.each do |_, v| |
| 637 | if v == 'Obsolete' |
| 638 | return |
| 639 | end |
| 640 | end |
| 641 | |
| 642 | manufacturers = details_table_contents['Manufacturer:'].lines.collect do |line| |
| 643 | line.strip |
| 644 | end.delete_if do |line| |
| 645 | line.empty? |
| 646 | end.uniq |
| 647 | unless manufacturers.length == 1 |
| 648 | raise "Not sure what manufacturers #{manufactures.inspect} from Mouser mean" |
| 649 | end |
| 650 | manufacturer = manufacturers[0] |
| 651 | manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer |
| 652 | info.set_or_same :manufacturer, manufacturer |
| 653 | |
| 654 | distributor = details_table_contents['Mouser Part #:'] |
| 655 | if distributor == 'Not Assigned' |
| 656 | return |
| 657 | end |
| 658 | raise "Unexpected HTML" unless prices_table.length == 1 |
| 659 | |
| 660 | info.add_description details_table_contents['Description:'].chomp |
| 661 | |
| 662 | prices_table[0].elements.each do |row| |
| 663 | raise "Unexpected HTML" unless row.name == 'div' || row.name == 'tr' |
| 664 | if row.elements.length == 4 |
| 665 | quantity_text = row.elements[1].elements[0].text |
| 666 | price_text = row.elements[2].text.strip |
| 667 | if price_text == 'Quote>' |
| 668 | next |
| 669 | elsif price_text == '' |
| 670 | # There is (sometimes?) an empty row at the bottom. |
| 671 | raise "Huh?" unless quantity_text == '' |
| 672 | next |
| 673 | elsif !price_text.start_with? '$' |
| 674 | puts row |
| 675 | raise "Don't know how to interpret Mouser price #{price_text.inspect}." |
| 676 | end |
| 677 | quantity = quantity_text.gsub(',', '').to_i |
| 678 | raise unless quantity.to_s == quantity_text.gsub(',', '') |
| 679 | price = price_text[1..-1].gsub(',', '').to_f |
| 680 | raise unless chomp_zeros(price.to_s) == chomp_zeros(price_text[1..-1]) |
| 681 | info.add_price PartInfo::Price.new(quantity, price, url, distributor) |
| 682 | end |
| 683 | end |
| 684 | end |
| 685 | |
| 686 | def parse_value f, unit |
| 687 | raise "Don't know what min/max values mean." if f['min_value'] || f['max_value'] |
| 688 | v = f['value'][0] |
| 689 | if unit == '%' |
| 690 | raise "Want %, not units" if f['metadata']['unit'] |
| 691 | if v == '+80/-20%' |
| 692 | v = '80' |
| 693 | else |
| 694 | v = strip_from_end '%', v.gsub(Unicode::PlusMinus, '') |
| 695 | end |
| 696 | else |
| 697 | if f['metadata']['unit']['name'] != unit |
| 698 | raise "Wrong units on #{f.inspect} (expected #{unit})." |
| 699 | end |
| 700 | end |
| 701 | raise "Don't know what multiple values #{f['value'].uniq} mean." if f['value'].uniq.size != 1 |
| 702 | r = v.to_f |
| 703 | raise "Error parsing #{v}" if r == 0.0 |
| 704 | r |
| 705 | end |
| 706 | |
| 707 | SiSuffixes = {'M' => 1e6, 'k' => 1e3, 'm' => 1e-3, 'u' => 1e-6, 'n' => 1e-9, |
| 708 | 'p' => 1e-12, 'f' => 1e-15, |
| 709 | Unicode::Micro => 1e-6} |
| 710 | |
| 711 | # Matches Digikey-style resistor power ratings like "0.063W, 1/16". |
| 712 | FractionalWattsRegex = /^([0-9.]+)W, ([0-9]+)\/([0-9]+)$/ |
| 713 | |
| 714 | def to_f_with_suffix raw |
| 715 | raw.rstrip! |
| 716 | suffix_power = 1 |
| 717 | SiSuffixes.each do |suffix, power| |
| 718 | if raw.end_with? suffix |
| 719 | raw = raw[0..-2] |
| 720 | suffix_power *= power |
| 721 | end |
| 722 | end |
| 723 | raw.rstrip! |
| 724 | |
| 725 | fractional_watts = FractionalWattsRegex.match(raw) |
| 726 | if fractional_watts |
| 727 | raise "Fraction and power for #{raw.inspect}?" unless suffix_power == 1 |
| 728 | decimal = fractional_watts[1].to_f |
| 729 | fraction = fractional_watts[2].to_f / fractional_watts[3].to_f |
| 730 | if (decimal - fraction).abs > 10 ** -(fractional_watts[1].length - 2) |
| 731 | raise "Mismatched fraction #{fraction} and decimal #{decimal}" |
| 732 | end |
| 733 | return decimal |
| 734 | end |
| 735 | |
| 736 | raise "Can't parse number #{raw.inspect}" if /^[0-9.]+$/ !~ raw |
| 737 | |
| 738 | raw.to_f * suffix_power |
| 739 | end |
| 740 | |
| 741 | def float_eq a, b |
| 742 | ((a - b).abs / [a.abs, b.abs].max) < 1e-15 |
| 743 | end |
| 744 | |
| 745 | def check_value_attribute raw, suffix, expected |
| 746 | raw = strip_from_end suffix, raw |
| 747 | found_value = to_f_with_suffix raw |
| 748 | float_eq expected, found_value |
| 749 | end |
| 750 | |
| 751 | total_price = 0 |
| 752 | total_per_item_price = 0 |
| 753 | |
| 754 | $parts.each do |_, part| |
| 755 | info = PartInfo.new part |
| 756 | |
| 757 | unit_price = nil |
| 758 | order_quantity = nil |
| 759 | if $bom_file |
Brian Silverman | 856af59 | 2017-12-18 11:17:09 -0500 | [diff] [blame] | 760 | #footprint = $parts_yaml['bom_footprints'][part.footprint] || part.footprint |
| 761 | footprint = part.footprint |
Brian Silverman | 8fa2aad | 2017-06-10 16:45:30 -0700 | [diff] [blame] | 762 | case options[:bom_style] |
| 763 | when :myro |
| 764 | line = [part.quantity, |
| 765 | part.refdeses.join(' '), |
| 766 | part.pn, |
| 767 | info.manufacturer, |
| 768 | lowest_price_info ? lowest_price_info.dpn : 'NA', |
| 769 | info.description.gsub('"', ''), |
| 770 | footprint, |
| 771 | dnp ? 'DNI' : ''].collect do |o| |
| 772 | o.to_s.inspect |
| 773 | end.join(',') |
| 774 | when :mouser |
| 775 | if lowest_price_info.link.include?('mouser.com') |
| 776 | #line = "#{part.pn.inspect},#{order_quantity},#{info.description.gsub('"', '').inspect}" |
| 777 | elsif lowest_price_info.link.include?('digikey.com') |
| 778 | line = "#{lowest_price_info.dpn},#{order_quantity}" |
| 779 | else |
| 780 | raise |
| 781 | end |
| 782 | else |
| 783 | line = [part.quantity, |
| 784 | part.refdeses.join(' '), |
| 785 | part.pn, |
| 786 | footprint, |
| 787 | part.value, |
| 788 | unit_price].collect do |o| |
| 789 | o.to_s.inspect |
| 790 | end.join(',') |
| 791 | end |
| 792 | $bom_file.puts line if line |
| 793 | end |
| 794 | end |
| 795 | puts total_price |
| 796 | puts total_per_item_price |
| 797 | |
| 798 | $bom_file.close if $bom_file |