summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--kicad_bom_cmd.py304
-rw-r--r--kicad_bom_cmd_test.py95
-rw-r--r--octopart/core.py46
-rw-r--r--octopart/part_search.py65
-rw-r--r--requirements.txt1
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