Add helper scripts

Change-Id: I196523408d195aa73a7d47b0b6ea9e800bcf7f4e
diff --git a/motors/big_schematic/check_refdes.rb b/motors/big_schematic/check_refdes.rb
new file mode 100755
index 0000000..1770e9d
--- /dev/null
+++ b/motors/big_schematic/check_refdes.rb
@@ -0,0 +1,42 @@
+#!/usr/bin/env ruby
+
+require './gschem_file'
+require 'pp'
+
+if ARGV.size == 0
+  filenames = Dir.glob('**/*.{sch,sym}')
+else
+  filenames = get_schematic_filenames ARGV
+end
+
+seen = {}
+
+filenames.each do |filename|
+  file = GschemSchematic.new(filename)
+
+  file.components.each do |component|
+  	refdes = component.refdes
+	slot = component[:slot]
+	if !refdes
+	  if !component.is_power
+	  	puts "Warning: #{component.inspect} does not have a refdes."
+	  end
+	elsif !seen.has_key? refdes
+	  seen[refdes] = [[filename, slot]]
+	else
+	  if seen[refdes][0][1] == nil && slot == nil
+	  	puts "Error: duplicate unslotted component #{refdes} in #{seen[refdes].collect { |a| a[0] }.inspect}."
+	  elsif (seen[refdes][0][1] == nil) != (slot == nil)
+	  	puts "Error: slotted and unslotted component #{refdes} at #{seen[refdes].collect { |a| "#{a[1] || 'none'} in #{a[0]}" }.inspect}."
+      else
+        seen[refdes].each do |_, s|
+          if s == slot
+	  	    puts "Error: duplicate slotted component #{refdes}:#{slot} in #{seen[refdes].collect { |a| a[0] if a[1] == slot }.compact.inspect}."
+            break
+          end
+        end
+      end
+      seen[refdes].push [filename, slot]
+	end
+  end
+end
diff --git a/motors/big_schematic/count_holes.sh b/motors/big_schematic/count_holes.sh
new file mode 100755
index 0000000..a44ee0d
--- /dev/null
+++ b/motors/big_schematic/count_holes.sh
@@ -0,0 +1,3 @@
+#!/bin/bash -x
+
+egrep 'Via|Pin' $1 | wc -l
diff --git a/motors/big_schematic/gschem_file.rb b/motors/big_schematic/gschem_file.rb
new file mode 100644
index 0000000..b8a17dc
--- /dev/null
+++ b/motors/big_schematic/gschem_file.rb
@@ -0,0 +1,182 @@
+VersionRegex = /v (\d{8}) (\d+)/
+ComponentRegex = /C (\d+) (\d+) ([01]) (\d{1,3}) ([01]) (.+)/
+TextRegex = /T (\d+) (\d+) (\d+) (\d+) ([01]) ([012]) (\d{1,3}) ([0-8]) (\d+)/
+PowerRegex = /^(([0-9.]+V-plus)|(title-.)|(gnd)|(output)|(input)|(io)|-1)|(vbat)|(vhalfbat)|(generic-power).sym$/
+
+# All coordinates are in "mils".
+
+class Component
+  attr_accessor :x, :y, :selectable, :angle, :mirrored, :filename
+  attr_accessor :attributes
+
+  def initialize(x, y, selectable, angle, mirrored, filename)
+    @x, @y, @selectable = x, y, selectable
+    @angle, @mirrored, @filename = angle, mirrored, filename
+
+    @attributes = {}
+  end
+
+  def refdes
+    if attributes.has_key?(:refdes)
+      attributes[:refdes].value
+    else
+      nil
+    end
+  end
+
+  def [](key)
+    r = attributes[key]
+    if r
+      r.value
+    else
+      nil
+    end
+  end
+
+  def is_power
+    filename =~ PowerRegex
+  end
+end
+
+def parse_bool(string)
+  if string == '0'
+    return false
+  elsif string == '1'
+    return true
+  else
+    raise "'#{string}' is not a bool"
+  end
+end
+
+class Attribute
+  attr_accessor :x, :y, :color, :size, :visible, :show, :angle, :alignment
+  attr_accessor :name, :value
+
+  def initialize(x, y, color, size, visible, show, angle, alignment, name, value)
+    @x, @y, @color, @size, @visible, @show = x, y, color, size, visible, show
+    @angle, @alignment, @name, @value = angle, alignment, name, value
+  end
+
+  def Attribute.parse(match, value_line)
+    raise "Don't know what multi-line attributes mean" if match[9] != '1'
+    attribute_value = value_line.chomp.split('=', 2)
+
+    Attribute.new(match[1].to_i,
+                  match[2].to_i,
+                  match[3].to_i,
+                  match[4].to_i,
+                  parse_bool(match[5]),
+                  match[6],
+                  match[7].to_i,
+                  match[8].to_i,
+                  attribute_value[0],
+                  attribute_value[1])
+  end
+end
+
+class GschemFile
+  attr_accessor :version_date, :version
+
+  def initialize(filename)
+    lines = nil
+    File.open(filename) do |f|
+      lines = f.readlines
+    end
+    version_line = lines.shift
+    version_match = VersionRegex.match version_line
+    raise "Can't parse version line '#{version_line}'" unless version_match
+    @version_date = version_match[1]
+    @version = version_match[2].to_i
+
+    return lines
+  end
+end
+
+class GschemSymbol < GschemFile
+  attr_accessor :attributes
+
+  def initialize(filename)
+    lines = super(filename)
+
+    @attributes = {}
+
+    until lines.empty?
+      line = lines.shift
+
+      match = TextRegex.match(line)
+      if match
+        attribute = Attribute.parse(match, lines.shift)
+        @attributes[attribute.name.intern] = attribute
+      end
+    end
+  end
+
+  def [](key)
+    r = attributes[key]
+    if r
+      r.value
+    else
+      nil
+    end
+  end
+end
+
+class GschemSchematic < GschemFile
+  attr_accessor :components
+
+  def initialize(filename)
+    lines = super(filename)
+
+    @components = []
+
+    until lines.empty?
+      line = lines.shift
+
+      match = ComponentRegex.match(line)
+      if match
+        c = Component.new(match[1].to_i,
+                          match[2].to_i,
+                          parse_bool(match[3]),
+                          match[4].to_i,
+                          parse_bool(match[5]),
+                          match[6])
+        line = lines.shift
+        unless line
+          # There's a component without {} at the end of the file.
+          raise "Huh?" unless lines.empty?
+          next
+        end
+        if line.chomp == '{'
+          line = lines.shift
+          until line.chomp == '}'
+            match = TextRegex.match(line)
+            raise "Error parsing attribute '#{line}'" unless match
+
+            attribute = Attribute.parse(match, lines.shift)
+            c.attributes[attribute.name.intern] = attribute
+
+            line = lines.shift
+          end
+        else
+          lines.unshift line
+        end
+        @components.push c
+        next
+      end
+    end
+  end
+end
+
+def get_schematic_filenames files
+  r = []
+  files.each do |file|
+    if file.end_with? '.gsch2pcb'
+      File.open(file, 'r') do |f|
+        r += f.readline.split(' ')[1..-1]
+      end
+    else
+      r.push file
+    end
+  end
+  r
+end
diff --git a/motors/big_schematic/html.rb b/motors/big_schematic/html.rb
new file mode 100644
index 0000000..fa0be3c
--- /dev/null
+++ b/motors/big_schematic/html.rb
@@ -0,0 +1,53 @@
+require 'net/http'
+begin
+  require "sha1"
+rescue LoadError
+  require "digest/sha1"
+end
+
+require 'rubygems'
+require 'nokogiri'
+
+def retrieve_url url
+  cache_file = '.url_cache/' + Digest::SHA1.hexdigest(url)
+  if File.exists? cache_file
+    server_response = YAML.load_file(cache_file)
+  else
+    uri = URI.parse(url)
+    resp = Net::HTTP.start(uri.hostname, uri.port) do |http|
+      path = uri.path
+      if uri.query
+        path += '?'
+        path += uri.query
+      end
+      if uri.host == 'www.digikey.com'
+        req = Net::HTTP::Post.new(path)
+        req.set_form_data(
+          'TS01ddcca6_id' => '3',
+          'TS01ddcca6_cr' => 'ce1fcbf6f531dc4315cd98214a898f01:jnlm:8Ij6i51F:267420643',
+          'TS01ddcca6_76' => '0',
+          'TS01ddcca6_86' => '0',
+          'TS01ddcca6_md' => '1',
+          'TS01ddcca6_rf' => '0',
+          'TS01ddcca6_ct' => '0',
+          'TS01ddcca6_pd' => '0',
+        )
+        req['Referrer'] = 'http://www.digikey.com/product-search/en?keywords=CL21C271JBANNNC'
+      else
+        req = Net::HTTP::Get.new(path)
+      end
+      http.request(req)
+    end
+    if resp.code == '301' || resp.code == '302'
+      server_response = retrieve_url(uri.scheme + '://' + uri.host + resp['location'])
+    else
+      resp.value
+      server_response = resp.body
+    end
+    File.open(cache_file, 'w') do |f|
+      YAML.dump server_response, f
+    end
+  end
+
+  server_response
+end
diff --git a/motors/big_schematic/next_refdes.rb b/motors/big_schematic/next_refdes.rb
new file mode 100755
index 0000000..68e302a
--- /dev/null
+++ b/motors/big_schematic/next_refdes.rb
@@ -0,0 +1,46 @@
+#!/usr/bin/env ruby
+
+require './gschem_file'
+
+if ARGV.size < 2
+  puts "Usage: next_refdes.rb file.sch BASE [quantity]"
+  exit 1
+end
+
+filenames = ARGV.select do |name|
+  name.include? '.'
+end
+leftover = ARGV - filenames
+
+refdes_pattern = /^refdes=(.+)$/
+this_pattern = /^#{leftover[0]}(\d+)$/
+used = []
+
+get_schematic_filenames(filenames).each do |name|
+  File.open(name, 'r') do |f|
+    f.readlines.each do |line|
+      match = refdes_pattern.match(line)
+      if match
+        refdes = this_pattern.match(match[1])
+        if refdes
+          used.push(refdes[1].to_i)
+        end
+      end
+    end
+  end
+end
+
+if leftover.length > 1
+  todo = leftover[1].to_i
+else
+  todo = 1
+end
+
+i = 1
+while todo > 0
+  if !used.include?(i)
+	puts "#{leftover[0]}#{i}"
+    todo -= 1
+  end
+  i += 1
+end
diff --git a/motors/big_schematic/ordering.rb b/motors/big_schematic/ordering.rb
new file mode 100755
index 0000000..8f5a01a
--- /dev/null
+++ b/motors/big_schematic/ordering.rb
@@ -0,0 +1,775 @@
+#!/usr/bin/env ruby
+
+# This makes DNS lookups actually work reliably and give useful errors.
+require 'resolv-replace.rb'
+
+require 'yaml'
+require 'optparse'
+
+require './parts'
+require './gschem_file'
+
+=begin
+Property names in the schematic file:
+  value is the main value of the component.
+  secondary_value is another value of the component.
+    The value in the schematic is only a minimum.
+      power rating for resistors.
+      voltage for capacitors.
+  pn_optional set to 1 means that the pn property is non-critical.
+  tolerance is a maximum tolerance for value.
+=end
+
+options = {}
+OptionParser.new do |opts|
+  opts.banner = "Usage: ordering.rb file.sch [options]"
+
+  opts.on('--myro-bom', String, :REQUIRED, 'Generate myropcb BOM') do |v|
+    options[:bom_file] = v
+    options[:bom_style] = :myro
+  end
+  opts.on('--mouser-bom', String, :REQUIRED, 'Generate Mouser BOM (ONLY MOUSER PARTS)') do |v|
+    options[:bom_file] = v
+    options[:bom_style] = :mouser
+  end
+  opts.on('--bom', String, :REQUIRED, 'Generate a nice BOM') do |v|
+    options[:bom_file] = v
+    options[:bom_style] = :nice
+  end
+end.parse!
+
+options[:order_quantity] = 2
+options[:include_dev] = true
+
+if options[:bom_file]
+  $bom_file = File.open(options[:bom_file], 'w')
+  case options[:bom_style]
+  when :myro
+    $bom_file.puts 'Qty,Part Reference,Part Number,MFGR,Distributor NO,DESCRIPTION,FOOTPRINT,DNI'
+  when :mouser
+    $bom_file.puts 'Mfg Part Number,Quantity,Description'
+  else
+    $bom_file.puts 'Quantity,Refdes,Part Number,Footprint,Value,Unit Price'
+  end
+end
+
+if ARGV.size == 0
+  raise 'Need schematics to look at!'
+end
+puts ARGV
+filenames = get_schematic_filenames ARGV
+
+$parts_yaml = YAML.load_file('parts.yaml')
+
+module Unicode
+  NBSP = "\u00A0"
+  PlusMinus = "\u00B1"
+  Micro = "\u00B5"
+end
+
+def chomp_zeros s
+  while s[-1] == '0'
+    s.chomp! '0'
+  end
+end
+
+class Part
+  SecondaryValueRegex = /^([0-9.]+) (.*)$/
+
+  attr_reader :refdeses
+  attr_reader :pn, :value, :footprint, :device, :secondary_value, :tolerance
+  attr_reader :pn_optional, :dev_only
+
+  attr_reader :low_quantity
+
+  def quantity
+    @refdeses.size
+  end
+
+  def initialize(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only)
+    @pn, @value, @footprint, @device = pn, value, footprint, device
+    @secondary_value, @tolerance = secondary_value, tolerance
+    @pn_optional, @dev_only = pn_optional, dev_only
+
+    puts "#{refdes} has no footprint" unless footprint
+
+    @refdeses = [refdes]
+  end
+  def found_another(pn, value, footprint, device, secondary_value, refdes, tolerance, pn_optional, dev_only)
+    error = false
+    if pn != @pn
+      puts "Error: pn #@pn vs #{pn}."
+      error = true
+    end
+    if value != @value
+      puts "Error: value #@value vs #{value}."
+      error = true
+    end
+    if footprint != @footprint
+      puts "Error: footprint #@footprint vs #{footprint}."
+      error = true
+    end
+    if device != @device
+      puts "Error: device #@device vs #{device}."
+      error = true
+    end
+
+    if pn_optional != @pn_optional
+      puts "Error: pn_optional #@pn_optional vs #{pn_optional}."
+      error = true
+    end
+    if dev_only != @dev_only
+      puts "Error: dev_only #@dev_only vs #{dev_only}."
+      error = true
+    end
+
+    if tolerance && @tolerance
+      if tolerance != @tolerance
+        puts "Error: tolerance #@tolerance vs #{tolerance}."
+        error = true
+      end
+    elsif tolerance
+      @tolerance = tolerance
+    end
+
+    new_secondary_match = SecondaryValueRegex.match secondary_value
+    my_secondary_match = SecondaryValueRegex.match @secondary_value
+    if new_secondary_match && my_secondary_match &&
+       new_secondary_match[2] == my_secondary_match[2]
+      new_value = new_secondary_match[1].to_f
+      my_value = my_secondary_match[1].to_f
+      @secondary_value = [new_value, my_value].max.to_s + ' ' + my_secondary_match[2]
+    else
+      if secondary_value != @secondary_value
+        puts "Error: secondary_value #@secondary_value vs #{secondary_value}."
+        error = true
+      end
+    end
+
+    if error
+      puts "\tAdding #{refdes} to #{@refdeses.inspect}"
+    end
+
+    @refdeses.push refdes unless @refdeses.include? refdes
+  end
+
+  def override_pn pn
+    @pn = pn
+  end
+  def override_footprint footprint
+    @footprint = footprint
+  end
+end
+
+$parts = {}
+
+def add_part(attrs, refdes)
+  args = [attrs[:pn], attrs[:value], attrs[:footprint], attrs[:device],
+          attrs[:secondary_value], refdes, attrs[:tolerance],
+          attrs[:pn_optional], attrs[:dev_only]]
+  key = attrs[:pn]
+  unless key
+    key = attrs[:value] || ''
+    key += attrs[:footprint] || ''
+  end
+  raise "No pn or value for #{args} (#{refdes})" unless key && !key.empty?
+  if $parts.include?(key)
+    $parts[key].found_another(*args)
+  else
+    $parts[key] = Part.new(*args)
+  end
+end
+
+def set_or_same(old, new)
+  if new
+    if old
+      if old != new
+        raise "#{old.inspect} != #{new.inspect}"
+      end
+    end
+    new
+  else
+    old
+  end
+end
+
+MouserKeyRegex = /^\n(.*):\n$/
+MouserValueRegex = /^\n(.*)\n$/
+
+def mouser_table_to_map table
+  raise "Bad table from Mouser" if table.empty?
+  r = {}
+  rows = table.css('tr > td > table > tr')
+  rows.each do |row|
+    raise "Unexpected HTML" unless row.elements.length == 3
+
+    name = row.elements[0].text
+    match = MouserKeyRegex.match(name)
+    raise "Unexpected table key #{name.inspect}" unless match
+    key = match[1]
+
+    value = row.elements[1].text
+    match = MouserValueRegex.match(value)
+    raise "Unexpected table value #{value.inspect}" unless match
+    value = match[1]
+    r[key] = value
+  end
+  r
+end
+
+def digikey_table_to_map table
+  raise "Unexpected HTML" unless table.name == 'table'
+  r = {}
+  table.elements.each do |row|
+    raise "Unexpected HTML" unless row.name == 'tr'
+    raise "Unexpected HTML" unless row.elements.length == 2
+
+    name, value = row.elements
+    raise "Unexpected HTML" unless name.name == 'th'
+    raise "Unexpected HTML" unless value.name == 'td'
+    r[name.text.gsub(Unicode::NBSP, '').strip] = value.text.gsub(Unicode::NBSP, '').strip
+  end
+  r
+end
+
+SimpleAttributes = [:pn, :value, :footprint, :device, :tolerance, :pn_optional, :dev_only]
+
+filenames.each do |filename|
+  puts filename
+  file = GschemSchematic.new(filename)
+  file.components.each do |component|
+    next if component.is_power
+    next if component[:graphical]
+
+    simple = {}
+    SimpleAttributes.each do |attr|
+      simple[attr] = component[attr]
+    end
+    case component[:device]
+    when 'CAPACITOR'
+      simple[:secondary_value] = component[:voltage]
+    when 'POLARIZED_CAPACITOR'
+      simple[:secondary_value] = component[:voltage]
+    when 'RESISTOR'
+      simple[:secondary_value] = component[:power]
+    end
+
+    symbol_filename = "schematic/symbols/#{component.filename}"
+    if File.exists? symbol_filename
+      symbol = GschemSymbol.new(symbol_filename)
+      if symbol[:device]
+        if component[:pn]
+          if !component[:footprint]
+            puts "#{component.refdes} has a pn and its symbol has a device but it has no footprint."
+          end
+        else
+          simple[:pn] = symbol[:device]
+        end
+      end
+
+      SimpleAttributes.each do |attr|
+        if symbol[attr]
+          if component[attr]
+            if component[attr] != symbol[attr]
+              puts "#{component.refdes} overriding #{attr} from symbol."
+              puts "\t#{symbol[attr]} => #{component[attr]}"
+            end
+          else
+            simple[attr] = symbol[attr]
+          end
+        end
+      end
+    end
+
+    add_part(simple, component.refdes)
+  end
+end
+
+# An array of arrays where the first one is the English case code and the second
+# is the metric one.
+MetricCases = [['0402', '1005'], ['0603', '1608'], ['0805', '2012'],
+               ['1206', '3216'], ['1210', '3225'], ['1812', '4532']]
+SimpleCaseRegex = /^\d\d\d\d$/
+
+def retrieve_mouser_package attributes
+  raw = attributes['Package / Case']
+  r = nil
+  if raw
+    if SimpleCaseRegex =~ raw
+      r = set_or_same r, raw
+    else
+      t = nil
+      MetricCases.each do |pair|
+        if /^#{pair[0]} \(#{pair[1]} [mM]etric\)$/ =~ raw
+          t = set_or_same r, pair[0]
+        end
+      end
+      r = set_or_same r, t || raw
+    end
+  end
+  r = set_or_same r, attributes['Case Code - in']
+
+  unless r
+    return nil
+  end
+
+  $parts_yaml['footprint_mappings'][r] || r
+end
+
+class PartInfo
+  Price = Struct.new(:quantity, :price, :link, :dpn)
+
+  attr_reader :part
+
+  attr_accessor :case_package
+  attr_accessor :component_value
+  attr_accessor :component_secondary_value
+  attr_accessor :component_value_tolerance
+
+  attr_accessor :order_quantity
+
+  # value_field is the field name for Octopart.
+  # value_unit is the units Octopart has.
+  # value_unit_suffix is the suffix in the .sch file.
+  attr_accessor :value_field, :value_unit, :value_unit_suffix
+  # mouser_value_unit_suffix is the suffix Mouser has on the value.
+  attr_accessor :mouser_value_field, :mouser_value_unit_suffix
+  attr_accessor :digikey_value_field, :digikey_value_unit_suffix
+  attr_accessor :secondary_value_field, :secondary_value_unit
+  attr_accessor :digikey_secondary_value_field, :mouser_secondary_value_field
+  attr_accessor :mouser_secondary_value_field
+
+  attr_accessor :manufacturer
+
+  def initialize part
+    @part = part
+
+    @prices = []
+    @descriptions = []
+  end
+
+  def add_description d
+    @descriptions.push d
+  end
+  def description
+    raise "No description for #{part}" if @descriptions.empty?
+    @descriptions[0]
+  end
+
+  def set_or_same field, value
+    name = '@' + field.to_s
+    old = instance_variable_get name
+    if value
+      if old
+        if value.class == Float && old.class == Float
+          eq = float_eq old, value
+        else
+          eq = old == value
+        end
+        if !eq
+          raise "#{old.inspect} (old) != #{value.inspect} (new) for #{part.inspect}"
+        end
+      else
+        instance_variable_set name, value
+        return value
+      end
+    end
+    return old
+  end
+
+  def add_price price
+    @prices.push price
+  end
+  def price_for quantity
+    return nil, nil if @prices.empty?
+    lowest_info = nil
+    lowest_price = Float::INFINITY
+    @prices.each do |price|
+      c = price.price * (quantity.to_f / price.quantity.to_f).ceil * price.quantity
+      if c < lowest_price
+        lowest_info = price
+        lowest_price = c
+      end
+    end
+    return lowest_price, lowest_info unless lowest_info
+    if lowest_info.quantity.to_f / quantity.to_f > 5 && (lowest_info.quantity - quantity) > 50
+      puts "Warning: no good price for #{quantity} of #{part.inspect}"
+      puts "\thave #{@prices.inspect}"
+    end
+    return lowest_price, lowest_info
+  end
+end
+
+def strip_from_end suffix, raw
+  if suffix.class == Regexp
+    match = suffix.match raw
+    raise "#{raw.inspect} does not match #{suffix}" unless match
+    match[1]
+  else
+    raise "#{raw.inspect} does not end with #{suffix}" unless raw.end_with? suffix
+    raw[0..-(suffix.length + 1)]
+  end
+end
+
+def make_digikey_request part, info
+  pn = $parts_yaml['digikey_numbers'][part.pn] || part.pn
+  page = "http://www.digikey.com/product-search/en?keywords=#{URI.escape pn}"
+  page = $parts_yaml[part.pn]['digikey_url'] || page if $parts_yaml[part.pn]
+  results = Nokogiri::HTML(retrieve_url(page))
+  search_results_html = results.css '#productTable > tbody'
+  values_table_html = results.css '.attributes-table-main'
+  prices_table = results.css '#pricing'
+  product_details = results.css '.product-details'
+  if !results.css(':contains("No records match your search criteria")').empty?
+    # No results from Digikey. Skip it.
+  elsif !values_table_html.empty?
+    raise "Unexpected HTML" unless values_table_html.length == 1
+    raise "Unexpected HTML" unless prices_table.length == 1
+    raise "Unexpected HTML" unless product_details.length == 1
+    parse_digikey_results values_table_html[0].elements[0], prices_table[0],
+                          product_details[0], page, info
+  elsif !search_results_html.empty?
+    raise "Unexpected HTML" unless search_results_html.length == 1
+    found = false
+    search_results_html.children.each do |result|
+      if result.elements.length == 0
+        next
+      end
+      raise "Unexpected HTML" unless result.elements.length >= 5
+      links = result.elements[4].css 'a'
+      raise "Unexpected HTML" unless links.length == 1
+      mpn = links[0].text.strip
+      if mpn == part.pn
+        found = true
+
+        actual_page = 'http://www.digikey.com' + links[0]['href']
+        new_results = Nokogiri::HTML(retrieve_url(actual_page))
+        new_values_table = new_results.css '.attributes-table-main'
+        new_prices_table = new_results.css '#pricing'
+        new_product_details = new_results.css '.product-details'
+        new_feedback = results.css '#product-details-feedback'
+        unless new_feedback.empty?
+          if new_feedback.text == 'Obsolete item; call Digi-Key for more information.'
+            next
+          else
+            puts "Warning: Digikey gave feedback #{new_feedback}"
+          end
+        end
+        raise "Unexpected HTML" unless new_values_table.length == 1
+        raise "Unexpected HTML" unless new_prices_table.length == 1
+        raise "Unexpected HTML" unless new_product_details.length == 1
+        parse_digikey_results new_values_table[0].elements[0],
+                              new_prices_table[0], new_product_details[0],
+                              actual_page, info
+      end
+    end
+
+    if !found
+      puts "Warning: Got multiple Digikey search results for #{part.inspect}, none of them right."
+    end
+  else
+    raise "Don't know what Digikey page #{page} => #{results} for #{part.inspect} contains!"
+  end
+end
+
+def make_mouser_request part, info
+  pn = $parts_yaml['mouser_numbers'][part.pn] || part.pn
+  url = "http://www.mouser.com/Search/Refine.aspx?Keyword=#{URI.escape pn}"
+  mouser_results = Nokogiri::HTML(retrieve_url(url))
+  search_results_html = mouser_results.css '#ctl00_ContentMain_SearchResultsGrid_grid'
+  values_table_html = mouser_results.css '.specs'
+  prices_table = mouser_results.css '.PriceBreaks'
+  details_table = mouser_results.css '#product-desc'
+  if !mouser_results.css(':contains("did not return any results.")').empty?
+    # No results from Mouser. Skip it.
+  elsif !values_table_html.empty?
+    parse_mouser_results values_table_html, prices_table, details_table, url, info
+  elsif !search_results_html.empty?
+    found = false
+    search_results_html.children[2..-1].each do |result|
+      if result.elements.length == 0
+        next
+      end
+      raise "Unexpected HTML" unless result.elements.length >= 4
+      links = result.elements[3].css 'a'
+      raise "Unexpected HTML" unless links.length >= 1
+      mpn = links.first.text.strip
+      if mpn == part.pn
+        if result.elements[2].text.strip == 'Not Assigned'
+          # This means that Mouser doesn't actually carry it and probably
+          # doesn't have much data on it, none of which I want to trust.
+          next
+        end
+        found = true
+
+        actual_page = 'http://www.mouser.com/Search/' + links[0]['href']
+        mouser_results = Nokogiri::HTML(retrieve_url(actual_page))
+        values_table_html = mouser_results.css '.specs'
+        prices_table = mouser_results.css '.PriceBreaks'
+        details_table = mouser_results.css '#product-desc'
+        parse_mouser_results values_table_html, prices_table, details_table, actual_page, info
+      end
+    end
+    if !found
+      puts "Warning: Got multiple Mouser search results for #{part.inspect}, none of them right."
+    end
+  else
+    raise "Don't know what Mouser page #{url} => #{mouser_results} contains!"
+  end
+end
+
+WeirdFractionRegex = /^([0-9.]+ [a-zA-Z]+) \([0-9]+\/[0-9]+ [a-zA-Z]+\)$/
+
+def parse_digikey_results values_table_html, prices_table, product_details, url, info
+  attributes = digikey_table_to_map values_table_html
+
+  digikey_package = retrieve_mouser_package attributes
+  if digikey_package != 'Non Standard' && digikey_package != 'Nonstandard'
+    info.set_or_same :case_package, digikey_package
+  end
+
+  if info.digikey_value_field && attributes[info.digikey_value_field]
+    v = strip_from_end info.digikey_value_unit_suffix, attributes[info.digikey_value_field]
+    info.set_or_same :component_value, to_f_with_suffix(v)
+  end
+  if info.digikey_secondary_value_field && attributes[info.digikey_secondary_value_field]
+    v = strip_from_end info.secondary_value_unit, attributes[info.digikey_secondary_value_field]
+    info.set_or_same :component_secondary_value, to_f_with_suffix(v)
+  end
+
+  if attributes['Tolerance']
+    if attributes['Tolerance'] == '-20%, +80%'
+      info.set_or_same :component_value_tolerance, 80
+    else
+      v = strip_from_end '%', attributes['Tolerance'].gsub(Unicode::PlusMinus, '')
+      info.set_or_same :component_value_tolerance, v.to_f
+    end
+  end
+
+  distributor_row = product_details.elements[2]
+  raise "Unexpected HTML" unless distributor_row.elements[0].text == 'Digi-Key Part Number'
+  description_row = product_details.elements[6]
+  raise "Unexpected HTML" unless description_row.elements[0].text == 'Description'
+
+  info.add_description description_row.elements[1].text.chomp
+
+  prices_table.elements[1..-1].each do |price|
+    quantity = price.elements[0].text.gsub(',', '').to_i
+    raise unless quantity.to_s == price.elements[0].text.gsub(',', '')
+    price_number = price.elements[1].text.gsub(',', '').to_f
+    raise unless chomp_zeros(price_number.to_s) == chomp_zeros(price.elements[1].text)
+    info.add_price PartInfo::Price.new(quantity, price_number, url,
+                                       distributor_row.elements[1].text)
+  end
+
+  manufacturer_row = product_details.elements[4]
+  raise "Unexpected HTML" unless manufacturer_row.elements[0].text == 'Manufacturer'
+  manufacturer = manufacturer_row.elements[1].text
+  manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer
+  info.set_or_same :manufacturer, manufacturer
+end
+
+def parse_mouser_results values_table_html, prices_table, details_table, url, info
+  attributes = mouser_table_to_map values_table_html
+
+  mouser_package = retrieve_mouser_package attributes
+  if info.case_package == 'SRR1210' && mouser_package == '1210'
+  elsif info.case_package == '0612' && mouser_package == '1206'
+  else
+    info.set_or_same :case_package, mouser_package
+  end
+
+  if info.mouser_value_field && attributes[info.mouser_value_field]
+    v = strip_from_end info.mouser_value_unit_suffix, attributes[info.mouser_value_field]
+    info.set_or_same :component_value, to_f_with_suffix(v)
+  end
+  if info.mouser_secondary_value_field && attributes[info.mouser_secondary_value_field]
+    v = attributes[info.mouser_secondary_value_field]
+    match = WeirdFractionRegex.match v
+    if match
+      v = match[1]
+    end
+    v = strip_from_end info.secondary_value_unit, v
+    info.set_or_same :component_secondary_value, to_f_with_suffix(v)
+  end
+
+  if info.part.device != 'CRYSTAL' && attributes['Tolerance']
+    v = strip_from_end '%', attributes['Tolerance'].gsub('+/-', '')
+    info.set_or_same :component_value_tolerance, v.to_f
+  end
+
+  raise "Unexpected HTML" unless details_table.length == 1
+  details_table_contents = {}
+  if details_table[0].elements.size > 1
+    details_table.first.elements.each do |row|
+      details_table_contents[row.elements[0].text.strip] =
+        row.elements[1].text.strip
+    end
+  else
+    details_table.first.elements.first.elements.each do |row|
+      details_table_contents[row.elements.first.text.strip] =
+        row.elements[1].text.strip
+    end
+  end
+  details_table_contents.each do |_, v|
+    if v == 'Obsolete'
+      return
+    end
+  end
+
+  manufacturers = details_table_contents['Manufacturer:'].lines.collect do |line|
+    line.strip
+  end.delete_if do |line|
+    line.empty?
+  end.uniq
+  unless manufacturers.length == 1
+    raise "Not sure what manufacturers #{manufactures.inspect} from Mouser mean"
+  end
+  manufacturer = manufacturers[0]
+  manufacturer = $parts_yaml['manufacturer_names'][manufacturer] || manufacturer
+  info.set_or_same :manufacturer, manufacturer
+
+  distributor = details_table_contents['Mouser Part #:']
+  if distributor == 'Not Assigned'
+    return
+  end
+  raise "Unexpected HTML" unless prices_table.length == 1
+
+  info.add_description details_table_contents['Description:'].chomp
+
+  prices_table[0].elements.each do |row|
+    raise "Unexpected HTML" unless row.name == 'div' || row.name == 'tr'
+    if row.elements.length == 4
+      quantity_text = row.elements[1].elements[0].text
+      price_text = row.elements[2].text.strip
+      if price_text == 'Quote>'
+        next
+      elsif price_text == ''
+        # There is (sometimes?) an empty row at the bottom.
+        raise "Huh?" unless quantity_text == ''
+        next
+      elsif !price_text.start_with? '$'
+        puts row
+        raise "Don't know how to interpret Mouser price #{price_text.inspect}."
+      end
+      quantity = quantity_text.gsub(',', '').to_i
+      raise unless quantity.to_s == quantity_text.gsub(',', '')
+      price = price_text[1..-1].gsub(',', '').to_f
+      raise unless chomp_zeros(price.to_s) == chomp_zeros(price_text[1..-1])
+      info.add_price PartInfo::Price.new(quantity, price, url, distributor)
+    end
+  end
+end
+
+def parse_value f, unit
+  raise "Don't know what min/max values mean." if f['min_value'] || f['max_value']
+  v = f['value'][0]
+  if unit == '%'
+    raise "Want %, not units" if f['metadata']['unit']
+    if v == '+80/-20%'
+      v = '80'
+    else
+      v = strip_from_end '%', v.gsub(Unicode::PlusMinus, '')
+    end
+  else
+    if f['metadata']['unit']['name'] != unit
+      raise "Wrong units on #{f.inspect} (expected #{unit})."
+    end
+  end
+  raise "Don't know what multiple values #{f['value'].uniq} mean." if f['value'].uniq.size != 1
+  r = v.to_f
+  raise "Error parsing #{v}" if r == 0.0
+  r
+end
+
+SiSuffixes = {'M' => 1e6, 'k' => 1e3, 'm' => 1e-3, 'u' => 1e-6, 'n' => 1e-9,
+              'p' => 1e-12, 'f' => 1e-15,
+              Unicode::Micro => 1e-6}
+
+# Matches Digikey-style resistor power ratings like "0.063W, 1/16".
+FractionalWattsRegex = /^([0-9.]+)W, ([0-9]+)\/([0-9]+)$/
+
+def to_f_with_suffix raw
+  raw.rstrip!
+  suffix_power = 1
+  SiSuffixes.each do |suffix, power|
+    if raw.end_with? suffix
+      raw = raw[0..-2]
+      suffix_power *= power
+    end
+  end
+  raw.rstrip!
+
+  fractional_watts = FractionalWattsRegex.match(raw)
+  if fractional_watts
+    raise "Fraction and power for #{raw.inspect}?" unless suffix_power == 1
+    decimal = fractional_watts[1].to_f
+    fraction = fractional_watts[2].to_f / fractional_watts[3].to_f
+    if (decimal - fraction).abs > 10 ** -(fractional_watts[1].length - 2)
+      raise "Mismatched fraction #{fraction} and decimal #{decimal}"
+    end
+    return decimal
+  end
+
+  raise "Can't parse number #{raw.inspect}" if /^[0-9.]+$/ !~ raw
+
+  raw.to_f * suffix_power
+end
+
+def float_eq a, b
+  ((a - b).abs / [a.abs, b.abs].max) < 1e-15
+end
+
+def check_value_attribute raw, suffix, expected
+  raw = strip_from_end suffix, raw
+  found_value = to_f_with_suffix raw
+  float_eq expected, found_value
+end
+
+total_price = 0
+total_per_item_price = 0
+
+$parts.each do |_, part|
+  info = PartInfo.new part
+
+  unit_price = nil
+  order_quantity = nil
+  if $bom_file
+    footprint = $parts_yaml['bom_footprints'][part.footprint] || part.footprint
+    case options[:bom_style]
+    when :myro
+      line = [part.quantity,
+              part.refdeses.join(' '),
+              part.pn,
+              info.manufacturer,
+              lowest_price_info ? lowest_price_info.dpn : 'NA',
+              info.description.gsub('"', ''),
+              footprint,
+              dnp ? 'DNI' : ''].collect do |o|
+        o.to_s.inspect
+      end.join(',')
+    when :mouser
+      if lowest_price_info.link.include?('mouser.com')
+        #line = "#{part.pn.inspect},#{order_quantity},#{info.description.gsub('"', '').inspect}"
+      elsif lowest_price_info.link.include?('digikey.com')
+        line = "#{lowest_price_info.dpn},#{order_quantity}"
+      else
+        raise
+      end
+    else
+      line = [part.quantity,
+              part.refdeses.join(' '),
+              part.pn,
+              footprint,
+              part.value,
+              unit_price].collect do |o|
+        o.to_s.inspect
+      end.join(',')
+    end
+    $bom_file.puts line if line
+  end
+end
+puts total_price
+puts total_per_item_price
+
+$bom_file.close if $bom_file
diff --git a/motors/big_schematic/parts.rb b/motors/big_schematic/parts.rb
new file mode 100644
index 0000000..87650ed
--- /dev/null
+++ b/motors/big_schematic/parts.rb
@@ -0,0 +1,30 @@
+=begin
+parts.yaml is a YAML file of mappings to mappings. The top-level key is the part
+number that shows up in the schematics and the second-level mappings are
+overrides for various things that can't be mapped automatically.
+
+Overrides:
+  package: The PCB-style package name.
+=end
+
+require 'json'
+require 'yaml'
+require 'net/http'
+
+require './html'
+
+def get_octopart_results(mpn)
+  query = [{:mpn => mpn, :limit => 20}]
+  response_include = ['specs', 'descriptions', 'short_description', 'compliance_documents', 'external_links']
+
+  url = 'http://octopart.com/api/v3/parts/match?'
+  url += '&apikey=80a2e435'
+  response_include.each do |i|
+    url += '&include[]=' + i
+  end
+  url += '&queries=' + URI.encode(JSON.generate(query))
+
+  server_response = JSON.parse(retrieve_url(url))
+
+  server_response['results']
+end
diff --git a/motors/big_schematic/parts.yaml b/motors/big_schematic/parts.yaml
new file mode 100644
index 0000000..bd9a150
--- /dev/null
+++ b/motors/big_schematic/parts.yaml
@@ -0,0 +1,133 @@
+SRR1210-150M:
+  package: SRR1210
+
+PRL1632-R020-F-T1:
+  package: "0612"
+
+ADXRS453:
+  mpn: ADXRS453BRGZ
+
+22-23-2021:
+  package: 22-23-2021
+22-23-2031:
+  package: 22-23-2031
+22-23-2041:
+  package: 22-23-2041
+22-23-2051:
+  package: 22-23-2051
+22-23-2061:
+  package: 22-23-2061
+
+PPTC021LFBN-RC:
+  package: HEADER2x1
+PPTC031LFBN-RC:
+  package: HEADER3x1
+PPTC041LFBN-RC:
+  package: HEADER4x1
+
+SFH11-PBPC-D05-ST-BK:
+  package: HEADER10_2_POLAR
+SFH11-PBPC-D17-ST-BK:
+  package: HEADER34_2_POLAR
+
+# Stuff that doesn't get ordered with these scripts.
+no_order:
+  - 4-40_bolt
+  - 8_bolt
+
+not_parts:
+  - SOMETHING
+
+assembly_dnp:
+  - "PPTC021LFBN-RC"
+  - "PPTC031LFBN-RC"
+  - "PPTC041LFBN-RC"
+  - "SFH11-PBPC-D05-ST-BK"
+  - "SFH11-PBPC-D17-ST-BK"
+
+remapped_footprints:
+  SC70_6: SOT-363
+  SC70_5: SOT-353
+  TO220AB: TO-220-3
+  SOT23: SOT-23
+  SOT23_5: SOT-23-5
+  TO3P: TO-3P
+  QFN16_3: QFN-16
+  LQFP64_10: QFP-64
+  DPAK_369C: TO-252
+  SOD123: SOD-123F
+  SO16: SO-16
+  SOD882: SOD-882
+  TSSOP20: TSSOP-20
+  HTSSOP20: HTSSOP-20
+  HC49S: "HC49/US"
+  TOPMOD: TO-PMOD-7
+  SOT223: SOT-223
+  SO16W: SOIC-16
+
+digikey_numbers:
+  SOMETHING: SOMETHING-ND
+
+mouser_numbers:
+  SOMETHING: 279-SOMETHING
+
+footprint_mappings:
+  SOT-23-3: SOT-23
+  DPAK-2: TO-252
+  SC-70-6: SOT-363
+  SC-70-5: SOT-353
+  SOT-23-6: SOT6L
+  "TO-3P-3, SC-65-3": TO-3P
+  64-LQFP: QFP-64
+  "6-TSSOP, SC-88, SOT-363": SOT-363
+  "TO-236-3, SC-59, SOT-23-3": SOT-23
+  "SC-74A, SOT-753": SOT-23-5
+  "SOT-23-6 Thin, TSOT-23-6": SOT6L
+  "6-TSSOP (5 Lead), SC-88A, SOT-353": SOT-353
+  TO-236AB: SOT-23
+  "16-SOIC (0.154\", 3.90mm Width)": SO-16
+  TO-220: TO-220-3
+  "20-TSSOP (0.173\", 4.40mm Width)": TSSOP-20
+  "20-TSSOP (0.173\", 4.40mm Width) Exposed Pad": HTSSOP-20
+  "TSSOP EP": HTSSOP-20
+  "HC-49/US SM": "HC49/US"
+  "VSON-8 Clip": TRANS_NexFET_Q3
+  "8-TDFN Exposed Pad": TRANS_NexFET_Q3
+  "DO-214AC, SMA": SMA
+  "Wide 1206 (3216 Metric), 0612": "0612"
+  "TO-PMOD-7, Power Module": TO-PMOD-7
+  "TO-261-4, TO-261AA": SOT-223
+  "16-SOIC (0.295\", 7.50mm Width)": SOIC-16
+
+bom_footprints:
+  SOMETHING: "actually another thing"
+
+low_quantity_substitutes:
+  CL21A106KPFNNNG: CL21A106KPCLQNC
+  CL21A106KPFNNNS: CL21A106KPCLQNC
+
+manufacturer_names:
+  "SEI Stackpole": "Stackpole Electronics"
+  "Stackpole Electronics Inc": "Stackpole Electronics"
+  "TE Connectivity / Alcoswitch": "TE Connectivity"
+  "TE Connectivity / AMP": "TE Connectivity"
+  "Murata Electronics North America": "Murata Electronics"
+  "TDK Corporation": "TDK"
+  "Micro Commercial Co": "Micro Commercial Components (MCC)"
+  "Panasonic Electronic Components": "Panasonic"
+  "OSRAM Opto Semiconductors Inc": "OSRAM Opto Semiconductors"
+  "OSRAM Opto Semiconductors Inc.": "OSRAM Opto Semiconductors"
+  "Littelfuse Inc": Littelfuse
+  "Bourns Inc.": Bourns
+  "Vishay / Dale": "Vishay Dale"
+  "Molex Inc": Molex
+  "Molex Inc.": Molex
+  "Molex, LLC": Molex
+  "Samsung Electro-Mechanics America, Inc": "Samsung"
+  "Skyworks Solutions Inc": "Skyworks Solutions"
+  "Skyworks Solutions, Inc.": "Skyworks Solutions"
+  "CTS-Frequency Controls": "CTS Electronic Components"
+  AVX: "AVX Corporation"
+  "Analog Devices Inc": "Analog Devices"
+  "Analog Devices Inc.": "Analog Devices"
+  "ROHM Semiconductor": "Rohm Semiconductor"