diff options
-rw-r--r-- | kicad_bom_cmd.py | 304 | ||||
-rw-r--r-- | kicad_bom_cmd_test.py | 95 | ||||
-rw-r--r-- | octopart/core.py | 46 | ||||
-rw-r--r-- | octopart/part_search.py | 65 | ||||
-rw-r--r-- | requirements.txt | 1 |
5 files changed, 474 insertions, 37 deletions
diff --git a/kicad_bom_cmd.py b/kicad_bom_cmd.py new file mode 100644 index 0000000..5c555ff --- /dev/null +++ b/kicad_bom_cmd.py @@ -0,0 +1,304 @@ +import xmltodict +import sys +import json + +import octopart + +# <export> +# <components> +# <comp ref="R1"> +# <value>1k</value> +# <footprint>Resistors_SMD:R_1210_HandSoldering</footprint> +# <fields> +# <field name="Farnell">1470030RL</field> +# </fields> +# <libsource lib="device" part="R"/> +# <sheetpath names="/" tstamps="/"/> +# <tstamp>55FEEA8B</tstamp> + +settings = { + 'resistor': { + 'tolerance': '10', + 'power_rating': '0.25' + }, + 'currency': 'NOK' +} + +settings['seller'] = 'farnell' + +def apply_settings(search): + search['seller'] = settings.get('seller', None) + +# Thank you Wikipedia: https://en.wikipedia.org/wiki/Surface-mount_technology#Two-terminal_packages +def find_case(footprint): + footprint = footprint.lower() + + metric = ['0402', '0603', '1005', '1608', '2012', '2520', '3216', '3225', '4516', '4532', '4564', '5025', '6332'] + imperial = ['01005', '0201', '0402', '0603', '0805', '1008', '1206', '1210', '1806', '1812', '1825', '2010', '2512', '2920'] + + for s in metric: + if s in footprint: + return s + + for s in imperial: + if s in footprint: + return s + + return None + +# TODO: check for power_rating and tolerance +def resistor(comp): + ref = comp['@ref'] + value = parse_value(comp['value']) + footprint = comp.get('footprint', None) + tolerance = None or settings['resistor']['tolerance'] + power_rating = None or settings['resistor']['power_rating'] + + case = None + if footprint is not None: + case = find_case(footprint) + + r = Resistor(value, tolerance, power_rating, case) + entry = bom.get_entry_for_component(r) + entry.add_reference(ref) + +def parse_value(v): + try: + factor = 1 + s = v.strip().lower() + if s.endswith('k'): + s = s[0:-1] + factor = 1000 + elif s.endswith('m'): + s = s[0:-1] + factor = 1000 * 1000 + + return float(s) * factor + except ValueError: + raise Exception('Bad value: ' + s) + +class Resistor(object): + def __init__(self, resistance, tolerance, power_rating, case): + self.resistance = resistance + self.tolerance = tolerance + self.power_rating = power_rating + self.case = case + + def __eq__(self, other): + return type(self) == type(other) and \ + self.resistance == other.resistance and \ + self.tolerance == other.tolerance and \ + self.power_rating == other.power_rating and \ + self.case == other.case + + def __str__(self): + return "Resistor: resistance={}, tolerance={}, power_rating={}, case={}".format(self.resistance, self.tolerance, self.power_rating, self.case) + + def resolve_parts(self): + search = octopart.ResistorSearch() + search['resistance'] = self.resistance + search['power_rating'] = self.power_rating + search['tolerance'] = self.tolerance + search['case'] = self.case + apply_settings(search) + return octopart.resistor_search(search) + +class GenericComponent(object): + def __init__(self, ref, value): + self.ref = ref + self.value = value + + def __str__(self): + return "Generic: ref={}, value={}".format(self.ref, self.value) + + def __eq__(self, other): + return type(self) == type(other) and self.ref == other.ref and self.value == other.value + + def resolve_parts(self): + # TODO: to a query with the value + return octopart.SearchResponse.empty() + +class Bom(object): + def __init__(self): + self.entries = [] + + def get_entry_for_component(self, component): + for e in self.entries: + if e.component == component: + return e + + e = BomEntry(component) + self.entries.append(e) + return e + + def resolve(self): + for entry in self.entries: + entry.resolve_parts() + +class BomOption(object): + def __init__(self, part, offer, prices): +# print "BomOption, part=" + str(part) + ", prices=" + str(prices) + self.offer = offer + self.sku = offer.sku + self.seller = offer.seller + assert prices is not None + self.prices = prices + + def price_for_quantity(self, quantity): + cost = None + self.bom_quantity = max(quantity, self.offer.moq) + + for p in self.prices: + if self.bom_quantity < p.quantity: + continue + price = p.amount * self.bom_quantity + if cost is None or cost > price: + cost = price +# print "price_for_quantity, SKU:{}, quantity={}, min={}".format(self.offer.sku, quantity, cost) + return cost + +class BomEntry(object): + def __init__(self, component): + self.component = component + self.references = [] + self.search_response = None + self.options = None + self.octopart_mpn = None + self.octopart_url = None + + def add_reference(self, reference): + self.references.append(reference) + + def quantity(self): + return len(self.references) + + def resolve_parts(self): + self.search_response = self.component.resolve_parts() + self.options = [] + + for search_result in self.search_response.results: + p = search_result.item + self.octopart_mpn = p.mpn + self.octopart_url = p.octopart_url + + for offer in p.offers: + prices = BomEntry.adjust_prices_to_currency(offer) + if prices is None: + print 'Could not find prices for offer on SKU {} from {}'.format(offer.sku, offer.seller.name) + continue + self.options.append(BomOption(p, offer, prices)) + +# self.total_price = offers[0] +# self.price_per_part = self.total_price / self.quantity() +# break + + def __str__(self): + return "Bom entry.." + + @staticmethod + def adjust_prices_to_currency(offer): +# print 'adjust_prices_to_currency: SKU:{}, prices={}'.format(offer.sku, offer.prices) + prices = offer.prices + c = settings['currency'].upper() + +# print 'prices: ' + str(prices) + + p = prices.get(c, None) + + if p is not None: + print 'NOK, p=' + str(p) + return p + + currencies = ['USD', 'EUR'] + + rates = octopart.get('http://api.fixer.io/latest?base=' + c)['rates'] + for c in currencies: + p = prices.get(c, None) + + if p is None: + continue + + rate = rates.get(c, None) + + if rate is None: + # TODO: warn + continue + +# print 'found, c={}, p={}'.format(c, p) + + return p + + return None + +if __name__ == '__main__': + bom = Bom() + + with open(sys.argv[1]) as fd: + obj = xmltodict.parse(fd.read()) + comp = json.dumps(obj['export']['components']['comp']) + for comp in obj['export']['components']['comp']: + ref = comp['@ref'] + value = comp['value'] + + libsource = comp['libsource'] + lib = libsource['@lib'] + part = libsource['@part'] + + if lib == 'device' and part == 'R': + resistor(comp) + else: + c = GenericComponent(ref, value) + entry = bom.get_entry_for_component(c) + entry.add_reference(ref) + + do_resolve = False + do_resolve = True + + if do_resolve: +# bom.entries = bom.entries[0:1] + bom.resolve() + + print "BOM:" + i = 1 + for entry in bom.entries: + print "{0:-3}: {1:5}".format(i, ', '.join(entry.references)) + print " {}".format(entry.component) + i += 1 + + if do_resolve: + print '' + print '' + i = 1 + print "Resolved BOM:" + for entry in bom.entries: + print "{0:-3}: {1:5}".format(i, ', '.join(entry.references)) + print " Specification: {}".format(entry.component) + print " Octopart MPN: {}, URL: {}".format(entry.octopart_mpn, entry.octopart_url) + + for o in entry.options: + quantity = entry.quantity() + price = o.price_for_quantity(quantity) + if price is None: + print " SKU: {0}, price: not available".format(o.sku, price) + else: + if quantity != o.bom_quantity: + extra = ' (adjusted quantity: {})'.format(o.bom_quantity) + else: + extra = '' + print " SKU: {0}, price: {1:.2f}{2}".format(o.sku, price, extra) + + for search_result in entry.search_response.results: + p = search_result.item +# print "{}: Snippet: {}".format(p.uid, search_result.snippet) + print " {}: len={}, {}".format(p.uid, len(p.offers), search_result.snippet) +# for offer in part.offers: +# print 'offer: ' + str(offer) + i+=1 + + print "CSV:" + print "references;mpn;quantity;specification" + for entry in bom.entries: + if entry.octopart_mpn is None: + continue + + print "{};{};{}".format(','.join(entry.references),entry.octopart_mpn,entry.quantity(),entry.component) diff --git a/kicad_bom_cmd_test.py b/kicad_bom_cmd_test.py new file mode 100644 index 0000000..93368a4 --- /dev/null +++ b/kicad_bom_cmd_test.py @@ -0,0 +1,95 @@ +import unittest +from octopart import * +from kicad_bom_cmd import * + +class TestBomOption(unittest.TestCase): + + def _test_price(self): + s = 'hello world' + self.assertEqual(s.split(), ['hello', 'world']) + # check that s.split fails when the separator is not a string + with self.assertRaises(TypeError): + s.split(2) + + def test_price(self): + part_offer = PartOffer(offer) + o = BomOption(Part(part), part_offer, part_offer.prices['NOK']) + self.assertEqual("49.22", "{0:.2f}".format(o.price_for_quantity(23))) + +import json + +part = json.loads('''{ + "__class__": "Part", + "brand": { + "__class__": "Brand", + "homepage_url": "http://pewa.panasonic.com/", + "name": "Panasonic", + "uid": "bc357d3f904c6bf2" + }, + "manufacturer": { + "__class__": "Manufacturer", + "homepage_url": "http://pewa.panasonic.com/", + "name": "Panasonic", + "uid": "0ccec9c424b7df09" + }, + "mpn": "ERJP14F1001U", + "octopart_url": "http://octopart.com/erjp14f1001u-panasonic-11911978", + "offers": [ ], + "uid": "594990f6bf05868e" + }''') + +offer = json.loads('''{ + "__class__": "PartOffer", + "_naive_id": "114db981bb037cd92e7082796607f6ba", + "eligible_region": "NO", + "factory_lead_days": null, + "factory_order_multiple": null, + "in_stock_quantity": 3448, + "is_authorized": true, + "is_realtime": false, + "last_updated": "2015-09-24T06:29:11Z", + "moq": 10, + "octopart_rfq_url": null, + "on_order_eta": null, + "on_order_quantity": null, + "order_multiple": null, + "packaging": "Cut Tape", + "prices": { + "NOK": [ + [ + 10, + "2.14000" + ], + [ + 100, + "0.97700" + ], + [ + 500, + "0.72700" + ], + [ + 1000, + "0.57800" + ], + [ + 5000, + "0.41100" + ] + ] + }, + "product_url": "https://octopart-clicks.com/click/track?country=NO&ak=2452f140&sig=0d0964e&sid=819&ppid=11911978&vpid=146838441&ct=offers", + "seller": { + "__class__": "Seller", + "display_flag": "GB", + "has_ecommerce": true, + "homepage_url": "http://www.farnell.com/", + "id": "819", + "name": "Farnell", + "uid": "58989d9272cd8b5f" + }, + "sku": "1750918RL" + }''') + +if __name__ == '__main__': + unittest.main() diff --git a/octopart/core.py b/octopart/core.py index 8278b29..fd0cc75 100644 --- a/octopart/core.py +++ b/octopart/core.py @@ -34,7 +34,7 @@ def extract_date(json, key): except KeyError: return None -class Category(): +class Category(object): def __init__(self, json): self.uid = json['uid'] self.name = json['name'] @@ -48,16 +48,19 @@ def map_price(json): print 'price: ' + str(json) return json -class Price(): +class Price(object): def __init__(self, currency, quantity, amount): self.currency = currency self.quantity = quantity self.amount = amount -class PartOffer(): + def __str__(self): + return str(self.quantity) + '=' + str(self.amount) + '@' + self.currency + +class PartOffer(object): def __init__(self, json): self.sku = json['sku'] -# self.seller = Seller(json['seller']) + self.seller = Seller(json['seller']) self.eligible_region = json['eligible_region'] self.product_url = json['product_url'] self.octopart_rfq_url = json['octopart_rfq_url'] @@ -76,15 +79,15 @@ class PartOffer(): self.moq = extract_int(json, 'moq') self.last_updated = extract_date(json, 'last_updated') -class Part(): +class Part(object): def __init__(self, json): self.uid = json['uid'] self.mpn = json['mpn'] + self.octopart_url = json['octopart_url'] self.offers = map(PartOffer, json['offers']) - -class SearchResult(): +class SearchResult(object): def __init__(self, json): self.item = Part(json['item']) @@ -92,17 +95,32 @@ class SearchResult(): self.snippet = json['snippet'] pass -class SearchResponse(): +class SearchResponse(object): def __init__(self, json): self.results = map(SearchResult, json['results']) - self.hits = json['hits'] + self.hits = int(json['hits']) + + def filter_seller(self, seller): + for r in self.results: + os = filter(lambda o: o.seller.name == seller, r.item.offers) + if len(os) > 0: + r.item.offers = os + else: + r.item.offers = [] + + @staticmethod + def empty(): + return SearchResponse({'results': [], 'hits': '0'}) def params(p): p['apikey'] = apikey return p -def get(path, p): - url = base_url + path +def get(path, p = {}): + if not path.startswith('http'): + url = base_url + path + else: + url = path # print('path: {}, params: {}'.format(path, p)) print('path: {}'.format(path)) for k, v in p.iteritems(): @@ -114,9 +132,10 @@ def get(path, p): print(json.dumps(j, indent=2, sort_keys=True)) return j -class Seller(): +class Seller(object): def __init__(self, json): self.uid = json['uid'] + self.id = json['id'] self.name = json['name'] def seller_search_raw(q): @@ -144,6 +163,3 @@ def category_search(q): item = result['item'] categories.append(Category(item)) return categories - -# ############################################################################# -# Part Search diff --git a/octopart/part_search.py b/octopart/part_search.py index 438cb67..08834fe 100644 --- a/octopart/part_search.py +++ b/octopart/part_search.py @@ -1,7 +1,7 @@ from octopart import enum import octopart -def part_search(q, start = 0, limit = False, fields={}, include=[]): +def part_search(q, start = 0, limit = False, fields={}, includes=[]): p = {'q': q, 'start': 0} if limit: p['limit'] = limit @@ -17,9 +17,8 @@ def part_search(q, start = 0, limit = False, fields={}, include=[]): # arr.append(data['q']) pass - if len(include) > 0: - p['include[]'] = include - + if len(includes) > 0: + p['include[]'] = includes # p['spec_drilldown[include]'] = 'true' @@ -31,21 +30,34 @@ PackageType = enum('through_hole', 'smd') class ResistorSearch(): def __init__(self): self.params = {} - self.fields = ['package_type', 'case', 'resistance', 'tolerance', 'seller'] + self.fields = ['package_type', 'case', 'resistance', 'tolerance', 'seller', 'power_rating'] + + def has_value(self, field): + self.assert_has_field(field) + try: + return self.params[field] is not None + except KeyError: + return False - def has_key(self, key): + def has_field(self, field): try: - self.fields.index(key) + self.fields.index(field) + return True except ValueError: - raise Exception('Invalid key for search: ' + key) + return False + + def assert_has_field(self, field): + if not self.has_field(field): + raise Exception('Missing parameter for search: ' + field) - def __setitem__(self, key, item): - self.has_key(key) - self.params[key] = item + def __setitem__(self, field, item): + self.assert_has_field(field) + if field is not None: + self.params[field] = item - def __getitem__(self, key): - self.has_key(key) - return self.params[key] + def __getitem__(self, field): + self.assert_has_field(field) + return self.params[field] # lifecycle_status # specs.resistance_tolerance.value @@ -64,20 +76,29 @@ def resistor_search(search): fields['specs.resistance.value'] = {'q': search['resistance']} # case_package is broken (always returns 0 hits), but it is usually in the description - if search['case'] is not None: - q += ' ' + search['case'] + if search.has_value('case'): + q += ' ' + search['case'] + + if search.has_value('power_rating'): + fields['specs.power_rating.value'] = {'q': '[' + str(search['power_rating']) + ' TO 2500]'} # if package_type == PackageType.smd: # fields['specs.case_package.value'] = {'q': '1210'} - seller = search['seller'] - if seller is not None: - sellers = octopart.seller_search(search['seller']) + resolved_seller = None + if search.has_value('seller'): + seller = search['seller'] + sellers = octopart.seller_search(seller) if len(sellers) == 0: raise Exception('Could not find seller: ' + seller) - fields['offers.seller.name'] = {'q': sellers[0].name} + resolved_seller = sellers[0].name + fields['offers.seller.name'] = {'q': resolved_seller} - res = octopart.part_search(q, limit=100, fields=fields) - return res + includes = [] +# includes.append('specs') + res = octopart.part_search(q, limit=100, fields=fields, includes=includes) + if resolved_seller: + res.filter_seller(resolved_seller) + return res diff --git a/requirements.txt b/requirements.txt index b7345fe..7d7f98b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ iso8601==0.1.10 requests==2.7.0 requests-cache==0.4.10 wsgiref==0.1.2 +xmltodict==0.9.2 |