From 50e8131eee174aff8a1a539f523d16600fb930e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 16 Oct 2017 09:47:48 +0200 Subject: [PATCH] add Rakefile to generate output files --- Rakefile | 475 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 Rakefile diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..323fa0f --- /dev/null +++ b/Rakefile @@ -0,0 +1,475 @@ +# 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] 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