476 lines
17 KiB
Ruby
476 lines
17 KiB
Ruby
# encoding: utf-8
|
|
# ruby: 2.1.0
|
|
=begin
|
|
Rakefile to manage gEDA hardware projects
|
|
=end
|
|
require 'rake/clean'
|
|
require 'csv' # to export BOM and costs
|
|
require 'open-uri' # to parse URLs
|
|
require 'open_uri_redirections' # allow redirections
|
|
require 'net/http' # to get sites
|
|
require 'nokogiri' # to parse sites
|
|
require 'json' # to parse get site responses
|
|
|
|
# =================
|
|
# project variables
|
|
# =================
|
|
|
|
# main names used for filenames
|
|
raise "define project name(s) in 'name' file" unless File.exist? "name"
|
|
names = IO.read("name").split("\n").select {|target| !target.empty?}
|
|
raise "define project name(s) in 'name' file" if names.empty?
|
|
# project version, read from "version" file
|
|
raise "define project version in 'version' file" unless File.exist? "version"
|
|
version = IO.read("version").split("\n")[0]
|
|
raise "define project version in 'version' file" unless version
|
|
# current date for stamping output
|
|
date = Time.now.strftime("%Y-%m-%d")
|
|
|
|
# create targets for each name
|
|
targets = []
|
|
names.each do |name|
|
|
# schema
|
|
sch = "#{name}.sch"
|
|
# pcb layout
|
|
pcb = "#{name}.pcb"
|
|
# schematic revision, based on the number of schematic commits
|
|
sch_rev = `git log --pretty=oneline "#{sch}" | wc -l`.chomp.to_i
|
|
# pcb layout revision, based on the number of pcb commits
|
|
pcb_rev = `git log --pretty=oneline "#{pcb}" | wc -l`.chomp.to_i
|
|
# schema name with version and revision
|
|
vsch = "#{name}_v#{version}.#{sch_rev.to_s.rjust(3,'0')}.sch"
|
|
# pcb layout name with version and revision
|
|
vpcb = "#{name}_v#{version}.#{pcb_rev.to_s.rjust(3,'0')}.pcb"
|
|
# add to targets
|
|
targets << { name: name, sch: sch, pcb: pcb, sch_rev: sch_rev, pcb_rev: pcb_rev, vsch: vsch, vpcb: vpcb }
|
|
end
|
|
|
|
# ==========
|
|
# main tasks
|
|
# ==========
|
|
|
|
desc "main building task"
|
|
task :default => [:version,:print,:notes,:bom,:gerber]
|
|
|
|
desc "create release file"
|
|
release = "hardware-release_v#{version}.tar.gz"
|
|
task :release => [:default,:photo,:cost,release]
|
|
CLOBBER.include(release)
|
|
|
|
desc "set version in schematic and layout"
|
|
versions = targets.collect{|target| [target[:vsch],target[:vpcb]]}.flatten
|
|
task :version => versions
|
|
CLEAN.include(versions)
|
|
targets.each do |target|
|
|
CLOBBER.include("#{target[:name]}_*.sch")
|
|
CLOBBER.include("#{target[:name]}_*.pcb")
|
|
end
|
|
|
|
desc "print schematic and layout (as pdf)"
|
|
prints = targets.collect{|target| ["#{target[:name]}_schematic.pdf","#{target[:name]}_layout.pdf"]}.flatten
|
|
task :print => prints
|
|
CLEAN.include(prints)
|
|
|
|
desc "export notes from schematic"
|
|
notes = targets.collect{|target| "#{target[:name]}_notes.txt"}
|
|
task :notes => notes
|
|
CLEAN.include(notes)
|
|
|
|
desc "export BOMs from schematic"
|
|
boms = targets.collect{|target| "#{target[:name]}_bom.csv"}
|
|
task :bom => boms
|
|
CLEAN.include(boms)
|
|
|
|
desc "get cost estimation for BOMs"
|
|
costs = targets.collect{|target| "#{target[:name]}_cost.csv"}
|
|
task :cost => costs
|
|
CLEAN.include(costs)
|
|
|
|
desc "convert schematic to pcb layout"
|
|
task :sch2pcb do
|
|
targets.each do |target|
|
|
sh "gsch2pcb #{target[:sch]} --elements-dir #{File.dirname(__FILE__)}/lib/footprints --skip-m4 --output-name #{target[:name]}"
|
|
end
|
|
end
|
|
targets.each do |target|
|
|
CLOBBER.include("#{target[:name]}.net")
|
|
CLOBBER.include("#{target[:name]}.cmd")
|
|
CLOBBER.include("#{target[:name]}.new.pcb")
|
|
end
|
|
|
|
photos = targets.collect{|target| ["#{target[:name]}_layout-top.png","#{target[:name]}_layout-bottom.png"]}.flatten
|
|
desc "render layout"
|
|
task :photo => photos
|
|
CLOBBER.include(photos)
|
|
|
|
desc "export gerber"
|
|
task :gerber => :version do
|
|
targets.each do |target|
|
|
export = true # export only if the gerbers are all older than the layout
|
|
Dir.foreach(".") do |file|
|
|
next unless file.start_with? target[:name]
|
|
next unless file.end_with? ".gbr" or file.end_with? ".cnc"
|
|
export &= (File.ctime(target[:vpcb])>File.ctime(file))
|
|
end
|
|
sh "pcb -x gerber --gerberfile #{target[:name]} --all-layers #{target[:vpcb]}" if export
|
|
end
|
|
end
|
|
CLOBBER.include("*.gbr")
|
|
CLOBBER.include("*.cnc")
|
|
|
|
desc "reformat gerber and drill output (some programs like LPKF CircuitPro have difficulties with gEDA pcb output)"
|
|
task :reformat do
|
|
Dir.foreach(".") do |file|
|
|
next unless File.file? file
|
|
if file.end_with? ".gbr" then
|
|
sh "gerbv --export rs274x --output '#{file}' '#{file}'"
|
|
elsif file.end_with? ".cnc" then
|
|
sh "gerbv --export drill --output '#{file}' '#{file}'"
|
|
end
|
|
end
|
|
end
|
|
|
|
# ================
|
|
# helper functions
|
|
# ================
|
|
|
|
# generate gnetlist bom2 and parse them
|
|
# arguments: schematic=schematic to use, attributes=attributes to use for generating bom2
|
|
# returns an array of hash. key is the attribute name, value is the attribute value
|
|
def bom2(schematic, attributes)
|
|
to_return = []
|
|
# force attributes to be an array
|
|
attributes = case attributes
|
|
when String
|
|
[attributes]
|
|
when Array
|
|
attributes
|
|
else
|
|
[attributes.to_s]
|
|
end
|
|
# generate bom2
|
|
list = `gnetlist -g bom2 -Oattribs=#{attributes*','} -q -o - #{schematic}`
|
|
list.encode!("UTF-8","ISO-8859-1")
|
|
list.gsub!(/(\d[Mkmµ]?)\?/,'\1Ω') # UTF-8 characters like Ω are replaced with ? by gnetlist
|
|
list.gsub!(/(https?:\/\/[^:]*):/,'"\1":') # ':' (like in links) are not protected
|
|
# parse bom2
|
|
csv = CSV.parse(list,{:col_sep => ":"})
|
|
csv[1..-1].each do |row|
|
|
line = {}
|
|
row.each_index do |col|
|
|
line[csv[0][col]] = row[col] unless row[col]=="unknown"
|
|
end
|
|
to_return << line
|
|
end
|
|
return to_return
|
|
end
|
|
|
|
# return actual USD $ to EUR € rate
|
|
def usd2eur_rate
|
|
unless $u2e then
|
|
rate_http = Net::HTTP.get_response(URI.parse("https://rate-exchange.appspot.com/currency?from=USD&to=EUR"))
|
|
rate_json = JSON.parse(rate_http.body)
|
|
$u2e = rate_json["rate"].to_f
|
|
end
|
|
return $u2e
|
|
end
|
|
|
|
# return part price
|
|
def octopart(seller,sku)
|
|
seller = {'digikey-id'=>"Digi-Key", 'farnell-id'=>"Farnell", 'mouser-id'=>"Mouser"}[seller]
|
|
return nil unless seller
|
|
to_return = {stock: nil, currency: nil, prices: []}
|
|
# octopart API key to get cost estimations
|
|
unless $octopart_api_key then
|
|
raise "provide octopart API key in 'octopart' file to get cost estimations" unless File.exist? "octopart"
|
|
key = IO.read("octopart").lines[0]
|
|
raise "provide octopart API key in 'octopart' file to get cost estimations" if key.empty?
|
|
$octopart_api_key = key
|
|
end
|
|
# query octopart
|
|
query = [{:seller => seller, :sku => sku}]
|
|
url = 'http://octopart.com/api/v3/parts/match?'
|
|
url += 'queries=' + URI.encode(JSON.generate(query))
|
|
url += '&apikey=' + $octopart_api_key
|
|
resp = Net::HTTP.get_response(URI.parse(url))
|
|
octo_info = JSON.parse(resp.body)
|
|
# get the right response
|
|
octo_info['results'].each do |result|
|
|
result['items'].each do |item|
|
|
item['offers'].each do |offer|
|
|
next unless offer['seller']['name']==seller
|
|
next unless offer['sku']==sku
|
|
to_return[:stock] = offer['in_stock_quantity']
|
|
offer['prices'].each do |currency,prices|
|
|
next unless currency=="USD" or currency=="EUR"
|
|
next if to_return[:currency]=="EUR"
|
|
to_return[:currency] = currency
|
|
prices.each do |price|
|
|
price[1] = price[1].to_f
|
|
end
|
|
to_return[:prices] = prices
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return to_return
|
|
end
|
|
|
|
DIGIKEY_URL = "http://www.digikey.de/product-detail/en/all/"
|
|
def scrape_digikey(sku)
|
|
to_return = {stock: nil, currency: "EUR", prices: []}
|
|
# get page
|
|
url = DIGIKEY_URL+sku
|
|
doc = Nokogiri::HTML(open(URI.escape(url)))
|
|
# get stock
|
|
stock_doc = doc.xpath('//td[@id="quantityavailable"]')[0]
|
|
to_return[:stock] = stock_doc.text.gsub(/[ ,]+/,"").scan(/\d+/)[0].to_i
|
|
# get prices
|
|
doc.xpath('//table[@id="pricing"]/tr').each do |row|
|
|
next unless (col=row.xpath('td')).size==3
|
|
qty = col[0].text.gsub(/[^\d]+/,"")
|
|
qty = (qty =~ /^\d+$/ ? qty.to_i : nil)
|
|
price = col[1].text.gsub(/[^\d\.]+/,"")
|
|
price = (price =~ /^\d+(\.\d+)?$/ ? price.to_f : nil)
|
|
to_return[:prices] << [qty,price] if qty and price
|
|
end
|
|
return to_return
|
|
end
|
|
|
|
FARNELL_URL = "http://de.farnell.com/"
|
|
def scrape_farnell(sku)
|
|
to_return = {stock: nil, currency: "EUR", prices: []}
|
|
# get page
|
|
url = FARNELL_URL+sku
|
|
doc = Nokogiri::HTML(open(URI.escape(url)))
|
|
# get stock
|
|
stock_doc = doc.xpath('//td[@class="prodDetailAvailability"]')[0]
|
|
if stock_doc then
|
|
to_return[:stock] = stock_doc.text.lines.to_a[-1].to_i
|
|
else # when several stocks are available
|
|
stock_doc = doc.xpath('//div[@class="stockDetail"]')[0]
|
|
to_return[:stock] = stock_doc.text.gsub(".","").scan(/\d+/)[-1].to_i # the last match should be for EU
|
|
end
|
|
# get prices
|
|
doc.xpath('//div[@class="price"]/*/tr').each do |row|
|
|
next unless row.xpath('td').size==2
|
|
qty = row.xpath('td')[0].text.split("-")[0].gsub(/[^\d]+/,"")
|
|
qty = (qty =~ /^\d+$/ ? qty.to_i : nil)
|
|
price = row.xpath('td')[1].text.gsub(",",".").gsub(/[^\d\.]+/,"")
|
|
price = (price =~ /^\d+(\.\d+)?$/ ? price.to_f : nil)
|
|
to_return[:prices] << [qty,price] if qty and price
|
|
end
|
|
return to_return
|
|
end
|
|
|
|
MOUSER_URL = "http://www.mouser.com/Search/ProductDetail.aspx?R=0virtualkey0virtualkey"
|
|
def scrape_mouser(sku)
|
|
to_return = {stock: nil, currency: "EUR", prices: []}
|
|
# get page
|
|
url = MOUSER_URL+sku
|
|
doc = Nokogiri::HTML(open(URI.escape(url),:allow_redirections => :all))
|
|
# get stock
|
|
stock_doc = doc.xpath('//table[contains(@id,"availability")]/tr/td')[0]
|
|
to_return[:stock] = stock_doc.text.gsub(".","").to_i
|
|
# get prices
|
|
doc.xpath('//table[@class="PriceBreaks"]/tr').each do |row|
|
|
qty_doc = row.xpath('td[@class="PriceBreakQuantity"]')
|
|
qty = qty_doc[0].text.gsub(/[\s:\.]+/,"").to_i unless qty_doc.empty?
|
|
price_doc = row.xpath('td[@class="PriceBreakPrice"]')
|
|
price = price_doc[0].text.gsub(/\s+/,"").gsub("€","").gsub(",",".").to_f unless price_doc.empty?
|
|
price = nil if price==0
|
|
to_return[:prices] << [qty,price] if qty and price
|
|
end
|
|
return to_return
|
|
end
|
|
|
|
def scrape_prices(seller,sku)
|
|
return case seller
|
|
when 'digikey-id'
|
|
scrape_digikey(sku)
|
|
when 'farnell-id'
|
|
scrape_farnell(sku)
|
|
when 'mouser-id'
|
|
scrape_mouser(sku)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
# ===============
|
|
# file generation
|
|
# ===============
|
|
|
|
desc "copy schematic to include complete version (version, revision, and date)"
|
|
targets.each do |target|
|
|
file target[:vsch] => target[:sch] do |t|
|
|
# embed symbols
|
|
sh "cp #{t.prerequisites.join(' ')} #{t.name}"
|
|
sh "gschlas -e #{t.name}"
|
|
# on \ is to prevent ruby interpreting it, th other is for sed
|
|
# the version
|
|
sh "sed --in-place 's/\\(version=\\)\\$Version\\$/\\1#{version}/' #{t.name}"
|
|
# the date
|
|
sh "sed --in-place 's/\\(date=\\)\\$Date\\$/\\1#{date}/' #{t.name}"
|
|
# the revision
|
|
sh "sed --in-place 's/\\(revision=\\)\\$Revision\\$/\\1#{target[:sch_rev]}/' #{t.name}"
|
|
end
|
|
end
|
|
|
|
desc "copy layout to include complete version (version, date, and run teardrops when available)"
|
|
targets.each do |target|
|
|
file target[:vpcb] => target[:pcb] do |t|
|
|
sh "cp #{t.prerequisites.join(' ')} #{t.name}"
|
|
# on \ is to prevent ruby interpreting it, th other is for sed
|
|
# the version and revision
|
|
version_revision = "v#{version}.#{target[:pcb_rev].to_s.rjust(3,'0')}"
|
|
sh "sed -i 's/\\$version\\$/#{version_revision}/' #{t.name}"
|
|
# the date
|
|
sh "sed -i 's/\\$date\\$/#{date}/' #{t.name}"
|
|
# run teardrop for vias and pins
|
|
if File.exist? "#{Dir.home}/.pcb/plugins/teardrops.so" then
|
|
sh "pcb --action-string \"djopt(splitlines) Teardrops() s() q()\" #{t.name}"
|
|
end
|
|
end
|
|
end
|
|
|
|
desc "generate printable version (PDF) of schematic"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_schematic.pdf" => target[:vsch] do |t|
|
|
sh "gaf export --format pdf --color --output '#{t.name}' #{t.prerequisites.join(' ')} 2> /dev/null"
|
|
end
|
|
end
|
|
|
|
desc "generate printable documentation (PDF) from layout"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_layout.pdf" => target[:vpcb] do |t|
|
|
sh "pcb -x ps --psfile #{t.name}.ps #{t.prerequisites.join(' ')} 2> /dev/null"
|
|
sh "ps2pdf #{t.name}.ps #{t.name} 2> /dev/null"
|
|
sh "rm #{t.name}.ps 2> /dev/null"
|
|
end
|
|
end
|
|
|
|
desc "generate note file from schematic, listing the 'note' attributes from elements"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_notes.txt" => target[:vsch] do |t|
|
|
notes_data = bom2(t.prerequisites[0],"note")
|
|
File.open(t.name,"w") do |notes_file|
|
|
notes_data.each do |note|
|
|
next unless note['note']
|
|
note['note'] = note['note'].gsub('. ',".\n").gsub(/\n+$/,'')
|
|
notes_file.puts "#{note['refdes']}:\n#{note['note']}\n\n"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
desc "generate BOM file from schematic"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_bom.csv" => target[:sch] do |t|
|
|
attributes = ["category","device","value","description","manufacturer","manufacturer-id","datasheet","digikey-id","farnell-id","mouser-id"]
|
|
bom_data = bom2(t.prerequisites[0],attributes)
|
|
CSV.open(t.name, "wb") do |csv|
|
|
all_attributes = ["refdes","qty"]+attributes
|
|
csv << all_attributes
|
|
bom_data.each do |line|
|
|
csv << all_attributes.collect{|attribute| line[attribute]}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
desc "generate cost estimation from schematic"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_cost.csv" => target[:sch] do |t|
|
|
sellers = ['digikey-id','farnell-id','mouser-id'] # provide the (supported) seller SKU as value to this attribute name in the symbols/schematic
|
|
boards = [1,10] # calculate the price for as many boards
|
|
sum = Array.new(sellers.size){Array.new(boards.size,0.0)} # total cost
|
|
stocks = Array.new(sellers.size){Array.new(boards.size){[]}} # is there enough stock
|
|
# get component information
|
|
attributes = ["manufacturer","manufacturer-id"]+sellers
|
|
parts = bom2(t.prerequisites[0],attributes)
|
|
parts.collect!{|part| part['manufacturer'] and part['manufacturer-id'] ? part : nil}
|
|
parts.compact!
|
|
# put result in CVS
|
|
CSV.open(t.name, "wb") do |csv|
|
|
csv << ["refdes","quantity","manufacturer","part number"]+(sellers.collect{|seller| [seller,"stock","currency"]+boards.collect{|qty| ["unit price for #{qty} board(s)","total price for #{qty} board(s)"]}}).flatten
|
|
parts.each do |part|
|
|
part['qty'] = part['qty'].to_i
|
|
line = [part['refdes'],part['qty'],part['manufacturer'],part['manufacturer-id']]
|
|
sellers.each_index do |i|
|
|
seller = sellers[i]
|
|
if part[seller] then
|
|
begin
|
|
prices = scrape_prices(seller,part[seller])
|
|
rescue
|
|
$stderr.puts "#{part['manufacturer']} #{part['manufacturer-id']} not available using #{seller} #{part[seller]}"
|
|
line += [part[seller]]+[nil]*(2+boards.size*2)
|
|
next
|
|
end
|
|
line << part[seller]
|
|
line << prices[:stock]
|
|
line << prices[:currency]
|
|
unit = [] # the unit price
|
|
boards.each_index do |j|
|
|
if prices[:prices].empty? then
|
|
line += [nil]*2
|
|
else
|
|
prices[:prices].each do |price|
|
|
unit[j] = price[1] if (!unit[j] or price[1]<unit[j]) and price[0]<=part['qty']*boards[j]
|
|
end
|
|
if unit[j] then
|
|
line << unit[j]
|
|
line << unit[j]*part['qty']*boards[j]
|
|
stocks[i][j] << true if part['qty']*boards[j]<=prices[:stock]
|
|
sum[i][j] += unit[j]*part['qty']*boards[j]
|
|
|
|
else # use the minimum quantity
|
|
line << prices[:prices][0][1]
|
|
line << prices[:prices][0][1]*prices[:prices][0][0]
|
|
stocks[i][j] << true
|
|
sum[i][j] += prices[:prices][0][1]*prices[:prices][0][0]
|
|
end
|
|
end
|
|
end
|
|
else
|
|
line += [nil]*(3+boards.size*2)
|
|
end
|
|
end
|
|
csv << line
|
|
end
|
|
# summary
|
|
line = [nil]*4
|
|
sellers.each_index do |i|
|
|
line += [nil,nil,nil]
|
|
boards.each_index do |j|
|
|
line << (stocks[i][j].size==parts.size ? "" : "not ")+"enough stock"
|
|
line << sum[i][j]
|
|
end
|
|
end
|
|
csv << line
|
|
end
|
|
end
|
|
end
|
|
|
|
desc "generate photo realistic picture from layout (front side)"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_layout-top.png" => target[:vpcb] do |t|
|
|
sh "pcb -x png --dpi 600 --format PNG --photo-mode --outfile #{t.name} #{t.prerequisites.join(' ')}"
|
|
end
|
|
end
|
|
|
|
desc "generate photo realistic picture from layout (bottom side)"
|
|
targets.each do |target|
|
|
file "#{target[:name]}_layout-bottom.png" => target[:vpcb] do |t|
|
|
sh "pcb -x png --dpi 600 --format PNG --photo-mode --photo-flip-x --outfile #{t.name} #{t.prerequisites.join(' ')}"
|
|
end
|
|
end
|
|
|
|
desc "create archive with release files"
|
|
SOURCES = targets.collect{|target| [target[:sch],target[:pcb]]}.flatten
|
|
ATTACHMENTS = ["cern_ohl_v_1_2_howto.pdf","CHANGES.txt","LICENSE.txt","PRODUCT.txt"]
|
|
file release => SOURCES+prints+notes+boms+costs+photos+ATTACHMENTS do |t|
|
|
gerbers = Dir.entries(".").select{|file| file.end_with? ".gbr" or file.end_with? ".cnc"}
|
|
sh "tar --create --auto-compress --file '#{t.name}' #{(t.prerequisites+gerbers).join(' ')}"
|
|
end
|