diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4eeb5..001301b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to SmartParts are documented in this file. +## [1.1.0] — 2026-05-31 + +### Added +- **Dedicated Parameter Normalization Dashboard**: Built a separate Django View and HTML Template specifically for the Parameter Normalization Dashboard, moving management cleanly out of cramped plugin settings. +- **Permanent Ignore Filter Heuristics**: Added permanent ignore dropdown support. Ignored parameters are saved with `is_ignored = True` and silently dropped during the data merge phase in `data_merger.py` (and Creator safeguards). +- **Autocomplete Dynamic Dropdowns**: Integrated autocomplete inputs for Canonical Values utilizing the database-backed `ParameterTemplate` options combined with hardcoded canonical electronic maps. +- **TME.eu API Client**: Secure, HMAC-SHA1 signed API integration to retrieve TME pricing, stock, and parameters. +- **element14 / Farnell API Client**: regional storefront selection with fallback lookup capabilities. +- **Regex-Based Parameter Sanitization**: String pre-processing filter to strip punctuation (hyphens, underscores, brackets, parentheses, slashes) and normalize whitespaces for robust canonical matches. +- **"Catch & Learn" Parameter Map UI**: Interactive Dashboard interface to track unknown parameters returned from APIs and map them dynamically. +- **GS character Wedge Handling**: Parser support for high-density DataMatrix barcodes containing non-printable GS (ASCII 29) group separator characters from hardware keyboard wedge scanners. +- **CI/CD Integration**: Configured GitHub Actions workflows for automated PEP 8 lints (`flake8`), `black` code styling checks, and full normalizer test execution. + +### Changed +- **Parameter Normalization Expansion**: Expanded standard `PARAMETER_MAP` to cover 100+ common electronic, mechanical, and semiconductor units with SI scaling (e.g. converting `0.00001 F` -> `10 µF`). +- **Codebase Sanitization & Formatting**: Reformatted the entire Python codebase (22 modules) using the `black` formatter to strictly conform to PEP 8. +- **Documentation Overhaul**: Rewrote the entire `README.md` to showcase the new multi-source features, parameter mapping architecture, PureScan terminal commands, and configuration variables. + +### Fixed +- **Sanitized API Diagnostics & Logs**: Uniformly standardized all connection test results to return clean, standard success formats (e.g. `Connected successfully. Test search returned: LM7805`) and implemented a robust query parameter filter to scrub sensitive parameters/URLs from all API error exceptions and activity log warnings. +- **Dead Code & Unused Imports Cleanup**: Removed unused imports and legacy variables across all Python files, including `duplicate_checker.py`, `image_handler.py`, `views.py`, and `tools/generate_command_sheet.py`. +- **Unicode Console Output**: Resolved cp1252 encoding and character crashes on Windows terminal environments when testing values containing Ω or µ symbols. + ## [1.0.1] — 2026-05-16 ### Fixed @@ -30,4 +53,4 @@ All notable changes to SmartParts are documented in this file. - Auto label printing (Dymo/Zebra via inventreelabelmachine) - Duplicate detection with update-in-place support - DataMatrix barcode parsing (ANSI MH10.8.2 / ISO/IEC 15434) -- PUI integration (panels + dashboard widgets) +- PUI integration (panels + dashboard widgets) \ No newline at end of file diff --git a/inventree_smart_parts/api_clients/__init__.py b/inventree_smart_parts/api_clients/__init__.py index c2f4f27..d7be016 100644 --- a/inventree_smart_parts/api_clients/__init__.py +++ b/inventree_smart_parts/api_clients/__init__.py @@ -1,7 +1,8 @@ """ API Client Layer ================ -Provides uniform access to distributor APIs (Mouser, DigiKey, LCSC). +Provides uniform access to distributor APIs (Mouser, DigiKey, LCSC, +element14/Farnell, TME). Each client normalizes response data into a shared PartData structure. """ @@ -9,6 +10,8 @@ from .mouser import MouserClient from .digikey import DigiKeyClient from .lcsc import LCSCClient +from .element14 import Element14Client +from .tme import TMEClient __all__ = [ "BaseApiClient", @@ -18,4 +21,6 @@ "MouserClient", "DigiKeyClient", "LCSCClient", + "Element14Client", + "TMEClient", ] diff --git a/inventree_smart_parts/api_clients/base.py b/inventree_smart_parts/api_clients/base.py index dccea90..31792a8 100644 --- a/inventree_smart_parts/api_clients/base.py +++ b/inventree_smart_parts/api_clients/base.py @@ -18,6 +18,15 @@ logger = logging.getLogger("inventree_smart_parts.api") +def sanitize_error_message(msg: str) -> str: + """Strip query parameters from URLs inside the error message to prevent credential/parameter leaks.""" + if not msg: + return msg + import re + + return re.sub(r"\?[^\s'\"]*", "", msg) + + # ═══════════════════════════════════════════════════════════════════ # Shared Data Structures # ═══════════════════════════════════════════════════════════════════ @@ -192,25 +201,33 @@ def _request( return data except requests.exceptions.Timeout: - logger.error(f"[{self.SOURCE_NAME}] Request timeout for URL: {url}") + cleaned_url = url.split("?")[0] + logger.error(f"[{self.SOURCE_NAME}] Request timeout for URL: {cleaned_url}") raise ApiTimeoutError(f"{self.SOURCE_NAME} API request timed out") except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response is not None else "unknown" - logger.error(f"[{self.SOURCE_NAME}] HTTP {status} error: {e}") + raw_err_msg = str(e) + cleaned_msg = sanitize_error_message(raw_err_msg) + logger.error(f"[{self.SOURCE_NAME}] HTTP {status} error: {cleaned_msg}") raise ApiHttpError( - f"{self.SOURCE_NAME} API returned HTTP {status}", + f"{self.SOURCE_NAME} API returned HTTP {status}: {cleaned_msg}", status_code=status, response=e.response, ) except requests.exceptions.ConnectionError: - logger.error(f"[{self.SOURCE_NAME}] Connection failed for URL: {url}") + cleaned_url = url.split("?")[0] + logger.error( + f"[{self.SOURCE_NAME}] Connection failed for URL: {cleaned_url}" + ) raise ApiConnectionError(f"Could not connect to {self.SOURCE_NAME} API") except requests.exceptions.RequestException as e: - logger.error(f"[{self.SOURCE_NAME}] Request error: {e}") - raise ApiError(f"{self.SOURCE_NAME} API error: {e}") + raw_err_msg = str(e) + cleaned_msg = sanitize_error_message(raw_err_msg) + logger.error(f"[{self.SOURCE_NAME}] Request error: {cleaned_msg}") + raise ApiError(f"{self.SOURCE_NAME} API error: {cleaned_msg}") @abstractmethod def search_by_mpn(self, mpn: str) -> Optional[PartData]: diff --git a/inventree_smart_parts/api_clients/digikey.py b/inventree_smart_parts/api_clients/digikey.py index 07f6d47..52a67dc 100644 --- a/inventree_smart_parts/api_clients/digikey.py +++ b/inventree_smart_parts/api_clients/digikey.py @@ -342,19 +342,23 @@ def test_connection(self) -> Dict[str, Any]: self._authenticate() return { "success": True, - "message": "OAuth2 authentication successful. Connection OK.", + "message": "Connected successfully. OAuth2 authentication OK.", "details": { "token_expires_in": int(self._token_expiry - time.time()), }, } except ApiAuthError as e: + from .base import sanitize_error_message + return { "success": False, - "message": f"Authentication failed: {str(e)}", + "message": f"Authentication failed: {sanitize_error_message(str(e))}", } except Exception as e: + from .base import sanitize_error_message + return { "success": False, - "message": f"Unexpected error: {str(e)}", + "message": f"Unexpected error: {sanitize_error_message(str(e))}", } diff --git a/inventree_smart_parts/api_clients/element14.py b/inventree_smart_parts/api_clients/element14.py new file mode 100644 index 0000000..e3b7726 --- /dev/null +++ b/inventree_smart_parts/api_clients/element14.py @@ -0,0 +1,416 @@ +""" +element14 / Farnell / Newark API Client +======================================== +Integration with the element14 Product Search API (Powered by "PARTMINER" / +the Farnell global search REST service). + +Authentication: A single ``api_key`` passed as a query-string parameter. +The same key works across all element14 storefronts (Farnell, Newark, element14). +A ``store_name`` is required to target the right regional catalogue. + +API docs: + https://partner.element14.com/docs/Product_Search_API_REST__Description + +Supported store names (not exhaustive): + uk.farnell.com de.farnell.com fr.farnell.com + www.newark.com au.element14.com sg.element14.com in.element14.com +""" + +import logging +from typing import Optional, Dict, Any, List + +from .base import ( + BaseApiClient, + PartData, + PriceBreak, + PartParameter, + ApiAuthError, +) + +logger = logging.getLogger("inventree_smart_parts.api.element14") + +# element14 REST endpoint template – store_name is injected at runtime +# element14 REST endpoint — manuPartNum exact search (primary) +_SEARCH_URL_TEMPLATE = ( + "https://api.element14.com/catalog/products" + "?term=manuPartNum%3A{mpn}" + "&storeInfo.id={store}" + "&resultsSettings.offset=0" + "&resultsSettings.numberOfResults=10" + "&resultsSettings.responseGroup=large" + "&callInfo.omitXmlSchema=false" + "&callInfo.responseDataFormat=json" + "&callInfo.apiKey={api_key}" +) + +# Keyword / fallback search (prefixed with manuPartNum for compatibility) +_KEYWORD_URL_TEMPLATE = ( + "https://api.element14.com/catalog/products" + "?term=manuPartNum%3A{mpn}" + "&storeInfo.id={store}" + "&resultsSettings.offset=0" + "&resultsSettings.numberOfResults=10" + "&resultsSettings.responseGroup=large" + "&callInfo.omitXmlSchema=false" + "&callInfo.responseDataFormat=json" + "&callInfo.apiKey={api_key}" +) + + +class Element14Client(BaseApiClient): + """ + Client for the element14 Product Search REST API. + + Covers all regional storefronts (Farnell, Newark, element14) via a single + ``store_name`` selector. One API key is shared across all stores. + """ + + SOURCE_NAME = "element14" + # Base URL used for rate-limiting tracking only; actual calls use the full + # template above. + BASE_URL = "https://api.element14.com" + + def __init__(self, api_key: str, store_name: str = "uk.farnell.com", **kwargs): + super().__init__(**kwargs) + self.api_key = api_key.strip() if api_key else "" + self.store_name = store_name.strip() if store_name else "uk.farnell.com" + # element14 recommend ≤ 1 req/s for free tier keys + self._min_request_interval = 1.0 + + # ── Public interface ────────────────────────────────────────────────────── + + def search_by_mpn(self, mpn: str) -> Optional[PartData]: + """ + Search element14 for a part by Manufacturer Part Number. + + Tries an exact ``manuPartNum`` filter first (high precision). + Falls back to a plain keyword search if the exact filter returns nothing, + which handles cases where the MPN is indexed differently in the catalogue. + """ + if not self.api_key: + raise ApiAuthError("element14 API key is not configured") + + logger.info(f"[element14] Searching for MPN: {mpn} on {self.store_name}") + + # --- Pass 1: exact manufacturer part number filter --- + url = _SEARCH_URL_TEMPLATE.format( + mpn=_url_encode(mpn), + store=self.store_name, + api_key=self.api_key, + ) + try: + data = self._request("GET", url) + products = self._extract_products(data) + except Exception as e: + logger.warning(f"[element14] MPN filter search failed: {e}") + products = [] + + # --- Pass 2: keyword fallback --- + if not products: + logger.info( + f"[element14] MPN filter returned nothing, trying keyword search for: {mpn}" + ) + kw_url = _KEYWORD_URL_TEMPLATE.format( + mpn=_url_encode(mpn), + store=self.store_name, + api_key=self.api_key, + ) + try: + data = self._request("GET", kw_url) + products = self._extract_products(data) + except Exception as e: + logger.warning(f"[element14] Keyword search also failed: {e}") + return None + + if not products: + logger.info(f"[element14] No results found for MPN: {mpn}") + return None + + best = self._find_best_match(products, mpn) + if best is None: + best = products[0] + + return self._parse_part(best, mpn) + + def test_connection(self) -> Dict[str, Any]: + """Test element14 API key with a plain keyword search for a common part.""" + try: + if not self.api_key: + return {"success": False, "message": "API key is not configured"} + + # Use a plain keyword search so results are returned regardless of + # whether the test part is currently in stock. + kw_url = _KEYWORD_URL_TEMPLATE.format( + mpn=_url_encode("LM7805"), + store=self.store_name, + api_key=self.api_key, + ) + data = self._request("GET", kw_url) + products = self._extract_products(data) + + if products: + first = products[0] + name = ( + first.get("displayName", "") + or first.get("translatedManufacturerPartNumber", "") + or first.get("sku", "unknown") + ) + return { + "success": True, + "message": f"Connected successfully. Test search returned: {name}", + "details": {"store": self.store_name, "results": len(products)}, + } + return { + "success": True, + "message": f"Connected successfully to {self.store_name} (test search returned 0 results).", + } + except ApiAuthError as e: + from .base import sanitize_error_message + + return { + "success": False, + "message": f"Authentication failed: {sanitize_error_message(str(e))}", + } + except Exception as e: + from .base import sanitize_error_message + + return { + "success": False, + "message": f"Unexpected error: {sanitize_error_message(str(e))}", + } + + # ── Internal helpers ────────────────────────────────────────────────────── + + def _extract_products(self, data: Dict[str, Any]) -> List[Dict]: + """ + Extract the product list from an element14 API response dict. + Supports all potential wrapper keys returned by the different search types. + """ + wrapper_keys = [ + "manufacturerPartNumberSearchReturn", + "premierFarnellPartNumberReturn", + "keywordSearchReturn", + "keywordSearchResults", + ] + for key in wrapper_keys: + # Check direct key and capitalized variant + wrapper = data.get(key) or data.get(key[0].upper() + key[1:]) or {} + if isinstance(wrapper, dict): + products = wrapper.get("products") or wrapper.get("Products") + if products: + return products + return [] + + def _find_best_match(self, products: List[Dict], mpn: str) -> Optional[Dict]: + """Return the product whose MPN most closely matches the search term.""" + mpn_lower = mpn.lower().strip() + + # Exact manufacturer part number match + for p in products: + mfr_pn = ( + ( + p.get("translatedManufacturerPartNumber", "") + or p.get("manufacturerPartNumber", "") + or "" + ) + .lower() + .strip() + ) + if mfr_pn == mpn_lower: + return p + + # Contains match + for p in products: + mfr_pn = ( + ( + p.get("translatedManufacturerPartNumber", "") + or p.get("manufacturerPartNumber", "") + or "" + ) + .lower() + .strip() + ) + if mpn_lower in mfr_pn: + return p + + return None + + def _parse_part(self, raw: Dict[str, Any], search_mpn: str) -> PartData: + """Map a raw element14 product dict to the shared PartData structure.""" + + # ── Identification ── + mpn_result = ( + raw.get("translatedManufacturerPartNumber", "") + or raw.get("manufacturerPartNumber", "") + or search_mpn + ) + sku = raw.get("sku", "") + manufacturer = raw.get("vendorName", "") + description = raw.get("displayName", "") or raw.get("description", "") + + # ── Category ── + category_parts = [] + cat1 = raw.get("categoryTree", "") + if cat1: + category_parts.append(cat1) + cat2 = raw.get("subCategory", {}) + if isinstance(cat2, dict): + cat2_name = cat2.get("name", "") + if cat2_name: + category_parts.append(cat2_name) + elif isinstance(cat2, str) and cat2: + category_parts.append(cat2) + category = " > ".join(category_parts) if category_parts else "" + + # ── Package ── + package = raw.get("packageType", "") or raw.get("vendorPackage", "") + + # ── URLs ── + product_url = raw.get("manuLeadTime", "") # placeholder – replaced below + product_url = "" + # element14 product page: https:///p/ + if sku and self.store_name: + product_url = f"https://{self.store_name}/p/{sku}" + + datasheet_url = "" + for doc in raw.get("datasheets") or []: + if isinstance(doc, dict): + url = doc.get("url", "") or doc.get("URL", "") + if url and url.startswith("http"): + datasheet_url = url + break + + image_url = "" + for img in raw.get("imageList") or []: + if isinstance(img, dict): + url = img.get("url", "") or img.get("baseName", "") + if url: + if not url.startswith("http"): + url = f"https://{self.store_name}{url}" + image_url = url + break + elif isinstance(img, str) and img: + if not img.startswith("http"): + img = f"https://{self.store_name}{img}" + image_url = img + break + + # Fallback: single image field + if not image_url: + img_val = raw.get("image", "") + if isinstance(img_val, dict): + base_name = img_val.get("baseName", "") + vrnt_path = img_val.get("vrntPath", "") + if base_name: + if not base_name.startswith("/"): + base_name = "/" + base_name + + if vrnt_path == "farnell/": + # Use French locale for French storefront, fallback to en_GB + lang = ( + "fr_FR" if "fr.farnell.com" in self.store_name else "en_GB" + ) + image_url = f"https://{self.store_name}/productimages/standard/{lang}{base_name}" + elif vrnt_path == "nio/": + image_url = f"https://{self.store_name}/productimages/standard/en_US{base_name}" + else: + image_url = f"https://{self.store_name}/productimages/standard/en_GB{base_name}" + elif isinstance(img_val, str) and img_val: + image_url = img_val + if not image_url.startswith("http"): + image_url = f"https://{self.store_name}{image_url}" + + # ── Pricing ── + price_breaks: List[PriceBreak] = [] + for pb in raw.get("prices") or []: + try: + qty = int(pb.get("from", 0)) + price = float(pb.get("cost", 0)) + currency = pb.get("currency", "GBP") + if qty > 0 and price > 0: + price_breaks.append( + PriceBreak(quantity=qty, price=price, currency=currency) + ) + except (ValueError, TypeError): + continue + + # ── Stock ── + stock = None + stock_val = raw.get("stock", {}) + if isinstance(stock_val, dict): + try: + stock = int(stock_val.get("level", 0)) + except (ValueError, TypeError): + pass + elif stock_val is not None: + try: + stock = int(stock_val) + except (ValueError, TypeError): + pass + + # ── Parameters ── + parameters: List[PartParameter] = [] + for attr in raw.get("attributes") or []: + if not isinstance(attr, dict): + continue + p_name = attr.get("attributeLabel", "") or attr.get("attributeName", "") + p_value = attr.get("attributeValue", "") + p_unit = attr.get("attributeUnit", "") + if p_name and p_value: + parameters.append( + PartParameter(name=p_name, value=p_value, unit=p_unit) + ) + + # ── Min order ── + min_qty = 1 + try: + min_qty = int(raw.get("translatedMinimumOrderQuality", 1) or 1) + except (ValueError, TypeError): + pass + + # ── Order multiple ── + order_mult = 1 + try: + order_mult = int(raw.get("translatedOrderMultiple", 1) or 1) + except (ValueError, TypeError): + pass + + # ── Confidence ── + confidence = 1.0 + if mpn_result.lower().strip() != search_mpn.lower().strip(): + confidence = 0.85 # element14 uses official Farnell catalogue + + return PartData( + mpn=mpn_result, + manufacturer=manufacturer, + description=description, + name=f"{manufacturer} {mpn_result}" if manufacturer else mpn_result, + category=category, + supplier_name="Farnell / element14", + supplier_sku=sku, + supplier_url=product_url, + datasheet_url=datasheet_url, + image_url=image_url, + package=package, + parameters=parameters, + price_breaks=price_breaks, + stock_available=stock, + minimum_order_qty=min_qty, + order_multiple=order_mult, + source="element14", + raw_data=raw, + confidence=confidence, + ) + + +# ── Tiny helper ─────────────────────────────────────────────────────────────── + + +def _url_encode(text: str) -> str: + """Percent-encode a string for use in a URL query parameter value.""" + try: + from urllib.parse import quote + + return quote(text, safe="") + except Exception: + return text diff --git a/inventree_smart_parts/api_clients/lcsc.py b/inventree_smart_parts/api_clients/lcsc.py index 5204ecb..d6762a6 100644 --- a/inventree_smart_parts/api_clients/lcsc.py +++ b/inventree_smart_parts/api_clients/lcsc.py @@ -8,7 +8,7 @@ import logging from typing import Optional, Dict, Any, List -from .base import BaseApiClient, PartData, PriceBreak, PartParameter, ApiError +from .base import BaseApiClient, PartData, PriceBreak, PartParameter logger = logging.getLogger("inventree_smart_parts.api.lcsc") @@ -200,7 +200,7 @@ def test_connection(self) -> Dict[str, Any]: if result: return { "success": True, - "message": f"Connected. Test search found: {result.mpn}", + "message": f"Connected successfully. Test search returned: {result.mpn}", "details": { "test_mpn": result.mpn, "manufacturer": result.manufacturer, @@ -209,10 +209,12 @@ def test_connection(self) -> Dict[str, Any]: else: return { "success": True, - "message": "Connected, but test search returned no results.", + "message": "Connected successfully, but test search returned no results.", } except Exception as e: + from .base import sanitize_error_message + return { "success": False, - "message": f"Connection failed: {str(e)}", + "message": f"Connection failed: {sanitize_error_message(str(e))}", } diff --git a/inventree_smart_parts/api_clients/mouser.py b/inventree_smart_parts/api_clients/mouser.py index d76c93d..b2c8525 100644 --- a/inventree_smart_parts/api_clients/mouser.py +++ b/inventree_smart_parts/api_clients/mouser.py @@ -247,12 +247,16 @@ def test_connection(self) -> Dict[str, Any]: } except ApiError as e: + from .base import sanitize_error_message + return { "success": False, - "message": f"Connection failed: {str(e)}", + "message": f"Connection failed: {sanitize_error_message(str(e))}", } except Exception as e: + from .base import sanitize_error_message + return { "success": False, - "message": f"Unexpected error: {str(e)}", + "message": f"Unexpected error: {sanitize_error_message(str(e))}", } diff --git a/inventree_smart_parts/api_clients/tme.py b/inventree_smart_parts/api_clients/tme.py new file mode 100644 index 0000000..5c25584 --- /dev/null +++ b/inventree_smart_parts/api_clients/tme.py @@ -0,0 +1,512 @@ +""" +TME API Client (v2.0) +==================== +Integration with the new TME (Transfer Multisort Elektronik) REST API v2. + +Authentication: OAuth 2.0 Client Credentials flow with Bearer Access Tokens. + - Basic authentication to /auth/token using token as username and secret as password. + - Subsequent requests use the Authorization: Bearer header. + +API docs: + https://api-doc.tme.eu/v2 +""" + +import logging +from typing import Optional, Dict, Any, List + +from .base import ( + BaseApiClient, + PartData, + PriceBreak, + PartParameter, + ApiError, + ApiAuthError, +) + +logger = logging.getLogger("inventree_smart_parts.api.tme") + +_BASE_URL = "https://api.tme.eu" + + +class TMEClient(BaseApiClient): + """ + Client for the TME REST API v2.0 using OAuth 2.0 Bearer authentication. + + Performs a three-step enrichment pipeline for each MPN search: + 1. `/products/search` – locate the best matching product symbol + 2. `/products/data` – fetch price breaks & stock level + 3. `/products/files` – fetch datasheet & image URLs + 4. `/products/parameters` – fetch technical parameters (best-effort) + """ + + SOURCE_NAME = "tme" + BASE_URL = _BASE_URL + + def __init__( + self, + token: str, + secret: str, + country: str = "DE", + language: str = "EN", + currency: str = "EUR", + **kwargs, + ): + super().__init__(**kwargs) + + # TME API v2 expects the 50-character token as the username + # and the 20-character secret as the password. + # Dynamically auto-detect and swap them if entered in the wrong order. + t_val = token.strip() if token else "" + s_val = secret.strip() if secret else "" + if len(t_val) >= len(s_val): + self.token = t_val + self.secret = s_val + else: + self.token = s_val + self.secret = t_val + + self.country = country.strip().upper() if country else "DE" + self.language = language.strip().upper() if language else "EN" + self.currency = currency.strip().upper() if currency else "EUR" + + # TME rate-limit: max 10 req/s; played safe at ~2 req/s + self._min_request_interval = 0.5 + + # OAuth cache properties + self._access_token = None + self._token_expires_at = 0 + + # ── Public interface ────────────────────────────────────────────────────── + + def search_by_mpn(self, mpn: str) -> Optional[PartData]: + """ + Search TME for a part by MPN and enrich with prices, files, and parameters. + """ + if not self.token or not self.secret: + raise ApiAuthError("TME API token and/or secret are not configured") + + logger.info(f"[TME] Searching for MPN: {mpn}") + + # Step 1 – search + symbol = self._search_symbol(mpn) + if not symbol: + logger.info(f"[TME] No results for MPN: {mpn}") + return None + + symbol_name = symbol.get("symbol", "") + + # Steps 2-4 – enrich sequentially + prices_data = self._get_prices([symbol_name]) + files_data = self._get_files([symbol_name]) + params_data = self._get_parameters([symbol_name]) + + return self._build_part_data( + symbol=symbol, + mpn=mpn, + prices_data=prices_data, + files_data=files_data, + params_data=params_data, + ) + + def test_connection(self) -> Dict[str, Any]: + """Verify TME API credentials with a minimal search.""" + try: + if not self.token or not self.secret: + return { + "success": False, + "message": "Token and/or secret are not configured", + } + + result = self.search_by_mpn("LM7805") + if result: + return { + "success": True, + "message": f"Connected successfully. Test search returned: {result.mpn}", + "details": { + "test_mpn": result.mpn, + "manufacturer": result.manufacturer, + "country": self.country, + }, + } + return { + "success": True, + "message": f"Connected (country={self.country}), but test search returned no results.", + } + except ApiAuthError as e: + from .base import sanitize_error_message + + return { + "success": False, + "message": f"Authentication failed: {sanitize_error_message(str(e))}", + } + except Exception as e: + from .base import sanitize_error_message + + return { + "success": False, + "message": f"Unexpected error: {sanitize_error_message(str(e))}", + } + + # ── API sub-calls ───────────────────────────────────────────────────────── + + def _search_symbol(self, mpn: str) -> Optional[Dict[str, Any]]: + """Call /products/search and return the best-matching product element.""" + params = { + "phrase": mpn, + "scope[]": "products", + "country": self.country, + } + data = self._request_v2("GET", "/products/search", params) + + product_list = data.get("data", {}).get("products", {}).get("elements", []) + if not product_list: + return None + + # Find the best matching symbol + best = _find_best_symbol(product_list, mpn) + return best or product_list[0] + + def _get_prices(self, symbols: List[str]) -> Dict[str, Any]: + """Call /products/data for a list of TME symbols.""" + if not symbols: + return {} + params = { + "country": self.country, + "currency": self.currency, + "scope[]": ["prices", "stock"], + } + for i, sym in enumerate(symbols): + params[f"symbols[{i}]"] = sym + + try: + data = self._request_v2("GET", "/products/data", params) + return data.get("data", {}) + except Exception as e: + from .base import sanitize_error_message + + logger.warning(f"[TME] GetPrices failed: {sanitize_error_message(str(e))}") + return {} + + def _get_files(self, symbols: List[str]) -> Dict[str, Any]: + """Call /products/files for a list of TME symbols.""" + if not symbols: + return {} + params = { + "country": self.country, + } + for i, sym in enumerate(symbols): + params[f"symbols[{i}]"] = sym + + try: + data = self._request_v2("GET", "/products/files", params) + return data.get("data", {}) + except Exception as e: + from .base import sanitize_error_message + + logger.warning( + f"[TME] GetProductsFiles failed: {sanitize_error_message(str(e))}" + ) + return {} + + def _get_parameters(self, symbols: List[str]) -> Dict[str, Any]: + """Call /products/parameters for a list of TME symbols.""" + if not symbols: + return {} + params = { + "country": self.country, + } + for i, sym in enumerate(symbols): + params[f"symbols[{i}]"] = sym + + try: + data = self._request_v2("GET", "/products/parameters", params) + return data.get("data", {}) + except Exception as e: + from .base import sanitize_error_message + + logger.warning( + f"[TME] GetParameters failed (non-fatal): {sanitize_error_message(str(e))}" + ) + return {} + + # ── OAuth 2.0 and API helper calls ──────────────────────────────────────── + + def _get_headers(self) -> Dict[str, str]: + """Generate Authorization headers with a valid Bearer token.""" + import time + + # Refresh token if not set or within 10 seconds of expiry + if not self._access_token or time.time() > self._token_expires_at - 10: + self._authenticate() + return { + "Authorization": f"Bearer {self._access_token}", + "Accept": "application/json", + } + + def _authenticate(self): + """Fetch a fresh OAuth access token from TME.""" + import base64 + import time + + auth_url = f"{_BASE_URL}/auth/token" + auth_str = f"{self.token}:{self.secret}" + auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") + + headers = { + "Authorization": f"Basic {auth_b64}", + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "client_credentials", + } + + logger.info("[TME] Authenticating with TME OAuth token endpoint") + response = self.session.post( + auth_url, headers=headers, data=data, timeout=self.timeout + ) + response.raise_for_status() + + res_data = response.json() + self._access_token = res_data.get("access_token") + expires_in = res_data.get("expires_in", 300) + self._token_expires_at = time.time() + expires_in + + def _request_v2( + self, method: str, endpoint: str, params: Optional[Dict] = None + ) -> Dict[str, Any]: + """Perform a Bearer-authenticated REST query to the TME API.""" + url = f"{_BASE_URL}{endpoint}" + headers = self._get_headers() + + self._rate_limit() + + try: + if method.upper() == "GET": + response = self.session.get( + url, headers=headers, params=params, timeout=self.timeout + ) + else: + response = self.session.post( + url, headers=headers, json=params, timeout=self.timeout + ) + + response.raise_for_status() + + data = response.json() + if not isinstance(data, dict): + raise ApiError( + f"TME API returned unexpected type: {type(data).__name__}" + ) + + status = data.get("status", "") + if status not in ("OK", "ok", ""): + raise ApiError(f"TME API error status: {status}") + + return data + + except ApiError: + raise + except Exception as e: + from .base import sanitize_error_message + + cleaned_msg = sanitize_error_message(str(e)) + raise ApiError(f"TME request to {endpoint} failed: {cleaned_msg}") from e + + # ── Data assembly ───────────────────────────────────────────────────────── + + def _build_part_data( + self, + symbol: Dict[str, Any], + mpn: str, + prices_data: Dict[str, Any], + files_data: Dict[str, Any], + params_data: Dict[str, Any], + ) -> PartData: + """Assemble a PartData object from the raw TME v2 sub-call responses.""" + symbol_name = symbol.get("symbol", "") + + # Select best MPN from manufacturer_symbols list + mfr_symbols = symbol.get("manufacturer_symbols", []) + mpn_result = mfr_symbols[0] if mfr_symbols else symbol_name + + manufacturer = symbol.get("manufacturer", {}).get("name", "") + description = symbol.get("description", "") + category = symbol.get("category", {}).get("name", "") + + # ── Price breaks & stock ── + price_breaks: List[PriceBreak] = [] + stock = None + + # prices_data structure: + # {"elements": [{"symbol": "...", "stock_quantity": N, "prices": {"elements": [{"amount": N, "price": X.XX}]}}]} + for prod in prices_data.get("elements") or []: + if prod.get("symbol", "").upper() != symbol_name.upper(): + continue + try: + stock = int(prod.get("stock_quantity", 0)) + except (ValueError, TypeError): + pass + + prices_dict = prod.get("prices", {}) or {} + for pb in prices_dict.get("elements") or []: + try: + qty = int(pb.get("amount", 0)) + price = float(pb.get("price", 0)) + if qty > 0 and price > 0: + price_breaks.append( + PriceBreak( + quantity=qty, + price=price, + currency=self.currency, + ) + ) + except (ValueError, TypeError): + continue + break + + # ── Datasheet & image ── + datasheet_url = "" + image_url = "" + + # files_data structure: + # {"elements": [{"symbol": "...", "assets": {"primary_photo": {"high_resolution": "...", "prime": "..."}}, "documents": {"elements": [{"url": "...", "type": "..."}]}}]} + for prod in files_data.get("elements") or []: + if prod.get("symbol", "").upper() != symbol_name.upper(): + continue + assets = prod.get("assets", {}) or {} + primary_photo = assets.get("primary_photo", {}) or {} + + img_path = ( + primary_photo.get("high_resolution") or primary_photo.get("prime") or "" + ) + if img_path: + image_url = ( + img_path if img_path.startswith("http") else f"https:{img_path}" + ) + + docs_dict = prod.get("documents", {}) or {} + for doc in docs_dict.get("elements") or []: + doc_type = (doc.get("type", "") or "").upper() + if doc_type in ("DTE", "DATASHEET", "DS"): + url = doc.get("url", "") or "" + if url: + datasheet_url = ( + url if url.startswith("http") else f"https:{url}" + ) + break + # Fallback: first document regardless of type + if not datasheet_url: + for doc in docs_dict.get("elements") or []: + url = doc.get("url", "") or "" + if url: + datasheet_url = ( + url if url.startswith("http") else f"https:{url}" + ) + break + break + + # ── Parameters ── + parameters: List[PartParameter] = [] + # params_data structure: + # {"elements": [{"symbol": "...", "parameters": {"elements": [{"name": "...", "values": [{"value": "..."}]}]}}]} + for prod in params_data.get("elements") or []: + if prod.get("symbol", "").upper() != symbol_name.upper(): + continue + params_dict = prod.get("parameters", {}) or {} + for param in params_dict.get("elements") or []: + p_name = param.get("name", "") + values = param.get("values", []) + if p_name and values: + p_val = values[0].get("value", "") + if p_val: + parameters.append( + PartParameter( + name=p_name, + value=p_val, + unit="", + ) + ) + break + + # ── Min order qty ── + min_qty = 1 + try: + min_qty = int(symbol.get("minimal_amount", 1) or 1) + except (ValueError, TypeError): + pass + + # ── Order multiple ── + order_mult = 1 + try: + order_mult = int(symbol.get("multiples", 1) or 1) + except (ValueError, TypeError): + pass + + # ── Product URL ── + product_url = ( + f"https://www.tme.eu/en/details/{symbol_name}/" if symbol_name else "" + ) + + # ── Confidence ── + confidence = 1.0 + if mpn_result.lower().strip() != mpn.lower().strip(): + confidence = 0.85 + + return PartData( + mpn=mpn_result, + manufacturer=manufacturer, + description=description, + name=f"{manufacturer} {mpn_result}" if manufacturer else mpn_result, + category=category, + supplier_name="TME", + supplier_sku=symbol_name, + supplier_url=product_url, + datasheet_url=datasheet_url, + image_url=image_url, + package="", + parameters=parameters, + price_breaks=price_breaks, + stock_available=stock, + minimum_order_qty=min_qty, + order_multiple=order_mult, + source="tme", + raw_data=symbol, + confidence=confidence, + ) + + +# ── Module-level helpers ────────────────────────────────────────────────────── + + +def _find_best_symbol(product_list: List[Dict], mpn: str) -> Optional[Dict]: + """ + Return the product from ``product_list`` whose ``manufacturer_symbols`` + (or ``symbol``) most closely matches ``mpn``. + """ + mpn_lower = mpn.lower().strip() + + # Exact match on manufacturer_symbols (= the manufacturer's part numbers) + for p in product_list: + for m_sym in p.get("manufacturer_symbols", []): + if m_sym.lower().strip() == mpn_lower: + return p + + # Exact match on TME Symbol + for p in product_list: + sym = (p.get("symbol", "") or "").lower().strip() + if sym == mpn_lower: + return p + + # Contains match on manufacturer_symbols + for p in product_list: + for m_sym in p.get("manufacturer_symbols", []): + if mpn_lower in m_sym.lower().strip(): + return p + + # Contains match on Symbol + for p in product_list: + sym = (p.get("symbol", "") or "").lower().strip() + if mpn_lower in sym: + return p + + return None diff --git a/inventree_smart_parts/batch/altium_parser.py b/inventree_smart_parts/batch/altium_parser.py index 96e83d1..afd47c0 100644 --- a/inventree_smart_parts/batch/altium_parser.py +++ b/inventree_smart_parts/batch/altium_parser.py @@ -32,7 +32,7 @@ import logging import re -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Tuple, Optional, Dict, List logger = logging.getLogger("inventree_smart_parts.batch.altium") diff --git a/inventree_smart_parts/batch/importer.py b/inventree_smart_parts/batch/importer.py index 1b8b776..b2a896c 100644 --- a/inventree_smart_parts/batch/importer.py +++ b/inventree_smart_parts/batch/importer.py @@ -7,13 +7,12 @@ import csv import io -import json import logging import threading import uuid from datetime import datetime from typing import Dict, List, Any, Optional -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass, field logger = logging.getLogger("inventree_smart_parts.batch") diff --git a/inventree_smart_parts/core.py b/inventree_smart_parts/core.py index bddd0e7..fb395d4 100644 --- a/inventree_smart_parts/core.py +++ b/inventree_smart_parts/core.py @@ -33,8 +33,8 @@ class SmartPartsPlugin(UserInterfaceMixin, SettingsMixin, UrlsMixin, InvenTreePl TITLE = "Smart Parts – Inventory Assistant" DESCRIPTION = ( "Automates part creation from MPN lookup. " - "Fetches data from Mouser, DigiKey & LCSC, maps categories, " - "detects duplicates, and supports batch Excel import." + "Fetches data from Mouser, DigiKey, LCSC, element14/Farnell, and TME. " + "Maps categories, detects duplicates, and supports batch Excel import." ) VERSION = "1.0.1" AUTHOR = "StarkStrom Engineering" @@ -77,14 +77,74 @@ class SmartPartsPlugin(UserInterfaceMixin, SettingsMixin, UrlsMixin, InvenTreePl "default": True, "validator": bool, }, + # ── element14 / Farnell / Newark ── + "ELEMENT14_API_KEY": { + "name": "element14 API Key", + "description": ( + "API key for the element14 Product Search REST API. " + "Covers Farnell (EU), Newark (US), and element14 (Asia-Pacific). " + "Register at https://partner.element14.com/" + ), + "default": "", + }, + "ELEMENT14_STORE": { + "name": "element14 Store / Storefront", + "description": ( + "Regional storefront to query. Examples: " + "uk.farnell.com de.farnell.com fr.farnell.com " + "www.newark.com au.element14.com sg.element14.com in.element14.com" + ), + "default": "uk.farnell.com", + }, + "ELEMENT14_ENABLED": { + "name": "Enable element14 / Farnell", + "description": "Enable or disable element14/Farnell as a data source", + "default": False, + "validator": bool, + }, + # ── TME ── + "TME_API_TOKEN": { + "name": "TME API Token", + "description": ( + "Public API token for the TME REST API (HMAC-SHA1 signed). " + "Obtain from https://developers.tme.eu/" + ), + "default": "", + }, + "TME_API_SECRET": { + "name": "TME API Secret", + "description": ("Secret API key used to sign TME requests"), + "default": "", + }, + "TME_COUNTRY": { + "name": "TME Country Code", + "description": ( + "ISO 3166-1 alpha-2 country code for TME pricing and stock. " + "Examples: DE PL GB US FR NL" + ), + "default": "DE", + }, + "TME_CURRENCY": { + "name": "TME Currency", + "description": "Currency for TME price breaks (e.g. EUR, USD, GBP, PLN)", + "default": "EUR", + }, + "TME_ENABLED": { + "name": "Enable TME", + "description": "Enable or disable TME as a data source", + "default": False, + "validator": bool, + }, # ── Data Merging ── "API_PRIORITY": { "name": "API Priority Order", "description": ( "Comma-separated priority order for data merging. " - "First source wins for each field. Example: mouser,digikey,lcsc" + "First source wins for each field. " + "Valid tokens: mouser, digikey, lcsc, element14, tme. " + "Example: mouser,digikey,element14,tme,lcsc" ), - "default": "mouser,digikey,lcsc", + "default": "mouser,digikey,element14,tme,lcsc", }, # ── Category Mapping ── "FUZZY_THRESHOLD": { @@ -124,6 +184,22 @@ class SmartPartsPlugin(UserInterfaceMixin, SettingsMixin, UrlsMixin, InvenTreePl ), "default": "{}", }, + "LEARNED_PARAMETER_MAPPINGS": { + "name": "Learned Parameter Mappings (JSON)", + "description": ( + "JSON dictionary mapping raw distributor parameter names to canonical names. " + 'Example: {"ic mounting": "Mounting Type", "capacitance - value": "Capacitance"}' + ), + "default": "{}", + }, + "TRACKED_UNKNOWN_PARAMETERS": { + "name": "Tracked Unknown Parameters (JSON)", + "description": ( + "Automatically populated with unknown parameter names and their frequency counts. " + "Map these in Learned Parameter Mappings to standardize them." + ), + "default": "{}", + }, # ── Duplicate Handling ── "DUPLICATE_ACTION": { "name": "Duplicate Part Action", @@ -198,7 +274,7 @@ def get_ui_dashboard_items(self, request, context, **kwargs): { "key": "smartparts-dashboard-widget", "title": "Smart Parts", - "description": "Quick MPN search across Mouser, DigiKey & LCSC", + "description": "Quick MPN search across Mouser, DigiKey, LCSC, Farnell & TME", "icon": "ti:cpu", "source": f"{self.STATIC_URL_BASE}/smartparts_dashboard.js:renderSmartPartsDashboard", "options": { @@ -230,6 +306,7 @@ def setup_urls(self): path("batch/report//", views.batch_report, name="batch-report"), # Settings & Admin path("settings/", views.plugin_settings, name="settings"), + path("parameters/", views.parameter_dashboard, name="parameter-dashboard"), path( "api/test-connection//", views.test_connection, @@ -242,6 +319,21 @@ def setup_urls(self): # Plugin-level settings helpers path("api/settings/synonyms/", views.api_synonyms, name="api-synonyms"), path("api/settings/learned/", views.api_learned, name="api-learned"), + path( + "api/settings/parameters/", + views.api_parameter_mappings, + name="api-parameter-mappings", + ), + path( + "api/settings/unknown-parameters/", + views.api_unknown_parameters, + name="api-unknown-parameters", + ), + path( + "api/settings/canonical-parameters/", + views.api_canonical_parameters, + name="api-canonical-parameters", + ), # Stock & Label APIs path( "api/stock/locations/", diff --git a/inventree_smart_parts/services/assembly_builder.py b/inventree_smart_parts/services/assembly_builder.py index a536ce7..0c8f80e 100644 --- a/inventree_smart_parts/services/assembly_builder.py +++ b/inventree_smart_parts/services/assembly_builder.py @@ -11,7 +11,7 @@ import logging from dataclasses import dataclass, field -from typing import List, Optional, Tuple +from typing import List, Optional logger = logging.getLogger("inventree_smart_parts.assembly") diff --git a/inventree_smart_parts/services/data_merger.py b/inventree_smart_parts/services/data_merger.py index 36e67db..81793ac 100644 --- a/inventree_smart_parts/services/data_merger.py +++ b/inventree_smart_parts/services/data_merger.py @@ -7,7 +7,7 @@ import logging from typing import List, Optional -from ..api_clients.base import PartData, PriceBreak, PartParameter +from ..api_clients.base import PartData, PartParameter logger = logging.getLogger("inventree_smart_parts.services.merger") @@ -120,14 +120,53 @@ def sort_key(pd: PartData) -> int: return merged +import re + +_PUNCT_RE = re.compile(r"[-_()\[\]/\\.]") +_SPACES_RE = re.compile(r"\s+") + + +def sanitize_parameter_name(raw_name: str) -> str: + """ + Sanitize a parameter name by replacing punctuation with a single space, + collapsing multiple spaces, and returning the lowercased, stripped result. + """ + if not raw_name: + return "" + # Replace punctuation with a space + s = _PUNCT_RE.sub(" ", raw_name) + # Collapse multiple spaces + s = _SPACES_RE.sub(" ", s) + return s.lower().strip() + + def _merge_parameters(results: List[PartData]) -> List[PartParameter]: """Merge parameters from all sources, deduplicating by name.""" + from .parameter_normalizer import normalize_parameter_name + from plugin.registry import registry + + plugin = None + try: + plugin = registry.get_plugin("smartparts") + except Exception: + pass + seen_names = set() merged_params = [] + from .parameter_normalizer import is_parameter_ignored + for result in results: for param in result.parameters: - name_key = param.name.lower().strip() + if is_parameter_ignored(param.name, plugin): + logger.debug(f"Silently dropping ignored parameter: {param.name}") + continue + + sanitized_name = sanitize_parameter_name(param.name) + canonical_name = normalize_parameter_name(sanitized_name, plugin) + param.name = canonical_name + + name_key = canonical_name.lower().strip() if name_key not in seen_names: seen_names.add(name_key) merged_params.append(param) diff --git a/inventree_smart_parts/services/duplicate_checker.py b/inventree_smart_parts/services/duplicate_checker.py index e203105..730dedc 100644 --- a/inventree_smart_parts/services/duplicate_checker.py +++ b/inventree_smart_parts/services/duplicate_checker.py @@ -6,7 +6,7 @@ """ import logging -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, List from dataclasses import dataclass logger = logging.getLogger("inventree_smart_parts.services.duplicates") @@ -46,7 +46,6 @@ def check_duplicate(mpn: str, manufacturer: str = "") -> DuplicateResult: try: from company.models import ManufacturerPart - from part.models import Part mpn_clean = mpn.strip() diff --git a/inventree_smart_parts/services/image_handler.py b/inventree_smart_parts/services/image_handler.py index 7157910..67c38a2 100644 --- a/inventree_smart_parts/services/image_handler.py +++ b/inventree_smart_parts/services/image_handler.py @@ -13,9 +13,7 @@ - Automatic format detection """ -import io import os -import time import logging import tempfile from typing import Optional, List, Dict, Tuple diff --git a/inventree_smart_parts/services/parameter_normalizer.py b/inventree_smart_parts/services/parameter_normalizer.py index 6d8ff7b..1c81ade 100644 --- a/inventree_smart_parts/services/parameter_normalizer.py +++ b/inventree_smart_parts/services/parameter_normalizer.py @@ -352,3 +352,554 @@ def normalize_parameter_list(parameters: list) -> list: } ) return result + + +# ═══════════════════════════════════════════════════════════════════ +# Parameter Name Normalization & Self-Learning (Catch & Learn) +# ═══════════════════════════════════════════════════════════════════ + +_PUNCT_RE = re.compile(r"[-_()\[\]/\\.]") +_SPACES_RE = re.compile(r"\s+") + + +def sanitize_parameter_name(raw_name: str) -> str: + """ + Sanitize a parameter name by replacing punctuation with a single space, + collapsing multiple spaces, and returning the lowercased, stripped result. + """ + if not raw_name: + return "" + # Replace punctuation with a space + s = _PUNCT_RE.sub(" ", raw_name) + # Collapse multiple spaces + s = _SPACES_RE.sub(" ", s) + return s.lower().strip() + + +PARAMETER_MAP = { + # ── Resistance & Resistors ───────────────────────────────────────── + "resistance": "Resistance", + "resistance (ohms)": "Resistance", + "resistance value": "Resistance", + "resistance - value": "Resistance", + "res": "Resistance", + "resistor value": "Resistance", + "nominal resistance": "Resistance", + "resistance range": "Resistance", + "resistance tolerance": "Resistance Tolerance", + "resistance tolerance (%)": "Resistance Tolerance", + "resistor tolerance": "Resistance Tolerance", + "temperature coefficient": "Temperature Coefficient (TCR)", + "temp coefficient": "Temperature Coefficient (TCR)", + "temperature coefficient of resistance": "Temperature Coefficient (TCR)", + "tcr": "Temperature Coefficient (TCR)", + "temperature coefficient (ppm/c)": "Temperature Coefficient (TCR)", + "temperature coefficient (ppm/°c)": "Temperature Coefficient (TCR)", + "tempco": "Temperature Coefficient (TCR)", + # ── Capacitance & Capacitors ─────────────────────────────────────── + "capacitance": "Capacitance", + "capacitance - value": "Capacitance", + "capacitance value": "Capacitance", + "cap": "Capacitance", + "capacitor value": "Capacitance", + "nominal capacitance": "Capacitance", + "capacitance tolerance": "Capacitance Tolerance", + "capacitance tolerance (%)": "Capacitance Tolerance", + "capacitor tolerance": "Capacitance Tolerance", + "dielectric material": "Dielectric Material", + "dielectric": "Dielectric Material", + "dielectric characteristic": "Dielectric Material", + "temperature coefficient (capacitor)": "Dielectric Material", + "dielectric code": "Dielectric Material", + "capacitor dielectric": "Dielectric Material", + "dielectric type": "Dielectric Material", + "equivalent series resistance": "Equivalent Series Resistance (ESR)", + "esr": "Equivalent Series Resistance (ESR)", + "equivalent series resistance (esr)": "Equivalent Series Resistance (ESR)", + "esr (ohms)": "Equivalent Series Resistance (ESR)", + "max esr": "Equivalent Series Resistance (ESR)", + "ripple current": "Ripple Current", + "ripple current (rms)": "Ripple Current", + "max ripple current": "Ripple Current", + "ripple current - max": "Ripple Current", + "ripple current @ low frequency": "Ripple Current", + "ripple current @ high frequency": "Ripple Current", + "leakage current": "Leakage Current", + "capacitor leakage current": "Leakage Current", + "max leakage current": "Leakage Current", + "leakage current - max": "Leakage Current", + "dc leakage current": "Leakage Current", + # ── Inductance & Magnetics ───────────────────────────────────────── + "inductance": "Inductance", + "inductance (henries)": "Inductance", + "inductance value": "Inductance", + "nominal inductance": "Inductance", + "ind": "Inductance", + "inductance tolerance": "Inductance Tolerance", + "inductance tolerance (%)": "Inductance Tolerance", + "inductor tolerance": "Inductance Tolerance", + "q factor": "Q Factor", + "q @ frequency": "Q Factor", + "quality factor": "Q Factor", + "q minimum": "Q Factor", + "q min": "Q Factor", + "self resonant frequency": "Self Resonant Frequency (SRF)", + "srf": "Self Resonant Frequency (SRF)", + "self-resonant frequency": "Self Resonant Frequency (SRF)", + "resonant frequency": "Self Resonant Frequency (SRF)", + "srf (min)": "Self Resonant Frequency (SRF)", + "srf min": "Self Resonant Frequency (SRF)", + "dc resistance": "DC Resistance (DCR)", + "dcr": "DC Resistance (DCR)", + "dc resistance (dcr)": "DC Resistance (DCR)", + "dcr (ohms)": "DC Resistance (DCR)", + "max dcr": "DC Resistance (DCR)", + "dc resistance max": "DC Resistance (DCR)", + "core material": "Core Material", + "inductor core material": "Core Material", + "core type": "Core Material", + "saturation current": "Saturation Current (Isat)", + "isat": "Saturation Current (Isat)", + "saturation current (isat)": "Saturation Current (Isat)", + "inductor saturation current": "Saturation Current (Isat)", + "current - saturation (isat)": "Saturation Current (Isat)", + "temperature rise current": "Temperature Rise Current (Itemp)", + "itemp": "Temperature Rise Current (Itemp)", + "rms current": "Temperature Rise Current (Itemp)", + "current - temperature rise": "Temperature Rise Current (Itemp)", + "rated current (temp rise)": "Temperature Rise Current (Itemp)", + # ── Voltage Rating ───────────────────────────────────────────────── + "voltage rated": "Voltage Rating", + "voltage rating": "Voltage Rating", + "voltage": "Voltage Rating", + "voltage dc": "Voltage Rating", + "voltage ac": "Voltage Rating", + "rated voltage": "Voltage Rating", + "max voltage": "Voltage Rating", + "voltage working": "Voltage Rating", + "voltage rating dc": "Voltage Rating", + "voltage rating ac": "Voltage Rating", + "output voltage nom": "Voltage Rating", + "output voltage": "Voltage Rating", + # ── Mounting Type ────────────────────────────────────────────────── + "mounting type": "Mounting Type", + "mounting style": "Mounting Type", + "ic mounting": "Mounting Type", + "mount style": "Mounting Type", + "mounting": "Mounting Type", + "mount": "Mounting Type", + # ── Basic Semiconductor Ratings ──────────────────────────────────── + "forward voltage": "Forward Voltage", + "forward voltage (vf)": "Forward Voltage", + "vf": "Forward Voltage", + "forward voltage max": "Forward Voltage", + "vf max": "Forward Voltage", + "forward voltage (vf) (max)": "Forward Voltage", + "reverse voltage": "Reverse Voltage", + "dc reverse voltage": "Reverse Voltage", + "vr": "Reverse Voltage", + "reverse voltage max": "Reverse Voltage", + "vr max": "Reverse Voltage", + "dc blocking voltage": "Reverse Voltage", + "reverse voltage (vr) (max)": "Reverse Voltage", + "reverse current": "Reverse Current", + "reverse leakage current": "Reverse Current", + "ir": "Reverse Current", + "max reverse current": "Reverse Current", + "reverse current max": "Reverse Current", + "leakage current (reverse)": "Reverse Current", + "zener voltage": "Zener Voltage", + "vz": "Zener Voltage", + "zener voltage (vz)": "Zener Voltage", + "nominal zener voltage": "Zener Voltage", + "zener voltage range": "Zener Voltage", + "reverse recovery time": "Reverse Recovery Time", + "trr": "Reverse Recovery Time", + "reverse recovery time (trr)": "Reverse Recovery Time", + "recovery time (trr)": "Reverse Recovery Time", + # ── Transistors (BJT & MOSFET) ───────────────────────────────────── + "dc current gain": "Current Gain (hFE)", + "hfe": "Current Gain (hFE)", + "dc current gain (hfe)": "Current Gain (hFE)", + "current gain": "Current Gain (hFE)", + "hfe min": "Current Gain (hFE)", + "collector emitter saturation voltage": "Collector-Emitter Saturation Voltage", + "vce sat": "Collector-Emitter Saturation Voltage", + "vce(sat)": "Collector-Emitter Saturation Voltage", + "collector-emitter saturation voltage (max)": "Collector-Emitter Saturation Voltage", + "vce saturation": "Collector-Emitter Saturation Voltage", + "continuous collector current": "Continuous Collector Current", + "collector current": "Continuous Collector Current", + "ic": "Continuous Collector Current", + "continuous collector current (ic)": "Continuous Collector Current", + "max collector current": "Continuous Collector Current", + "continuous drain current (id)": "Continuous Drain Current (Id)", + "id": "Continuous Drain Current (Id)", + "current - continuous drain (id) @ 25°c": "Continuous Drain Current (Id)", + "continuous drain current": "Continuous Drain Current (Id)", + "drain current": "Continuous Drain Current (Id)", + "drain to source voltage (vdss)": "Drain to Source Voltage (Vdss)", + "vdss": "Drain to Source Voltage (Vdss)", + "voltage - drain source (vdss)": "Drain to Source Voltage (Vdss)", + "drain-source breakdown voltage": "Drain to Source Voltage (Vdss)", + "drain source voltage": "Drain to Source Voltage (Vdss)", + "gate to source threshold voltage (vgs th)": "Gate to Source Threshold Voltage (Vgs th)", + "gate to source threshold voltage": "Gate to Source Threshold Voltage (Vgs th)", + "vgs th": "Gate to Source Threshold Voltage (Vgs th)", + "vgs(th)": "Gate to Source Threshold Voltage (Vgs th)", + "voltage - gate threshold (vgs th)": "Gate to Source Threshold Voltage (Vgs th)", + "gate threshold voltage": "Gate to Source Threshold Voltage (Vgs th)", + "on resistance (rds on)": "On Resistance (Rds On)", + "rds on": "On Resistance (Rds On)", + "rds(on)": "On Resistance (Rds On)", + "rds(on) max": "On Resistance (Rds On)", + "drain to source on resistance": "On Resistance (Rds On)", + "rds on (max)": "On Resistance (Rds On)", + "gate charge": "Gate Charge", + "total gate charge": "Gate Charge", + "qg": "Gate Charge", + "gate charge (qg)": "Gate Charge", + "total gate charge (qg)": "Gate Charge", + "input capacitance": "Input Capacitance", + "ciss": "Input Capacitance", + "input capacitance (ciss)": "Input Capacitance", + "capacitance - input": "Input Capacitance", + "output capacitance": "Output Capacitance", + "coss": "Output Capacitance", + "output capacitance (coss)": "Output Capacitance", + "capacitance - output": "Output Capacitance", + "reverse recovery charge": "Reverse Recovery Charge (Qrr)", + "qrr": "Reverse Recovery Charge (Qrr)", + "reverse recovery charge (qrr)": "Reverse Recovery Charge (Qrr)", + # ── Integrated Circuits (ICs) & Power ────────────────────────────── + "supply voltage": "Supply Voltage", + "voltage - supply": "Supply Voltage", + "operating supply voltage": "Supply Voltage", + "supply voltage range": "Supply Voltage", + "supply voltage - min": "Supply Voltage", + "supply voltage - max": "Supply Voltage", + "voltage supply": "Supply Voltage", + "power supply voltage": "Supply Voltage", + "vcc": "Supply Voltage", + "vdd": "Supply Voltage", + "supply current": "Supply Current", + "current - supply": "Supply Current", + "operating supply current": "Supply Current", + "supply current (max)": "Supply Current", + "icc": "Supply Current", + "idd": "Supply Current", + "output current": "Output Current", + "current - output": "Output Current", + "output current max": "Output Current", + "max output current": "Output Current", + "continuous output current": "Output Current", + "iout": "Output Current", + "interface": "Interface", + "connectivity": "Interface", + "protocols": "Interface", + "communication interface": "Interface", + "memory size": "Memory Size", + "memory depth": "Memory Size", + "capacity": "Memory Size", + "program memory size": "Memory Size", + "ram size": "Memory Size", + "flash memory size": "Memory Size", + "memory type": "Memory Type", + "non-volatile memory type": "Memory Type", + "memory category": "Memory Type", + "clock frequency": "Clock Frequency", + "clock speed": "Clock Frequency", + "max clock frequency": "Clock Frequency", + "frequency - clock": "Clock Frequency", + "oscillator frequency": "Clock Frequency", + "number of pins": "Pin Count", + "pin count": "Pin Count", + "pins": "Pin Count", + "number of positions": "Pin Count", + "positions count": "Pin Count", + "no. of pins": "Pin Count", + "termination count": "Pin Count", + "core processor": "Core Processor", + "core size": "Core Processor", + "core family": "Core Processor", + "processor core": "Core Processor", + "cpu": "Core Processor", + "core width": "Core Width", + "data bus width": "Core Width", + "bit size": "Core Width", + "core size (bits)": "Core Width", + "adc / dac resolution": "ADC / DAC Resolution", + "data converters": "ADC / DAC Resolution", + "adc resolution": "ADC / DAC Resolution", + "dac resolution": "ADC / DAC Resolution", + "converter resolution": "ADC / DAC Resolution", + "common mode rejection ratio": "Common Mode Rejection Ratio (CMRR)", + "cmrr": "Common Mode Rejection Ratio (CMRR)", + "common mode rejection ratio (cmrr)": "Common Mode Rejection Ratio (CMRR)", + "slew rate": "Slew Rate", + "slew rate (typ)": "Slew Rate", + "slew rate max": "Slew Rate", + "logic type": "Logic Type", + "logic family": "Logic Type", + "logic function": "Logic Type", + "output type": "Output Type", + "logic output type": "Output Type", + "output configuration": "Output Type", + "reference voltage": "Reference Voltage", + "voltage reference": "Reference Voltage", + "internal reference voltage": "Reference Voltage", + "input bias current": "Input Bias Current", + "input bias current (ib)": "Input Bias Current", + "max input bias current": "Input Bias Current", + "input offset voltage": "Input Offset Voltage", + "input offset voltage (vios)": "Input Offset Voltage", + "max input offset voltage": "Input Offset Voltage", + "number of channels": "Number of Channels", + "channels": "Number of Channels", + "channel count": "Number of Channels", + "number of outputs": "Number of Outputs", + "outputs": "Number of Outputs", + "output count": "Number of Outputs", + # ── Electromechanical & Mechanical ───────────────────────────────── + "switch configuration": "Circuit / Contact Form", + "circuit": "Circuit / Contact Form", + "poles and throws": "Circuit / Contact Form", + "contact form": "Circuit / Contact Form", + "switch circuit": "Circuit / Contact Form", + "contact rating": "Contact Rating", + "contact current rating": "Contact Rating", + "contact rating @ voltage": "Contact Rating", + "contact current rating (max)": "Contact Rating", + "switch contact rating": "Contact Rating", + "contact resistance": "Contact Resistance", + "max contact resistance": "Contact Resistance", + "switch contact resistance": "Contact Resistance", + "insulation resistance": "Insulation Resistance", + "insulation resistance (min)": "Insulation Resistance", + "min insulation resistance": "Insulation Resistance", + "dielectric strength": "Dielectric Strength", + "dielectric voltage withstand": "Dielectric Strength", + "voltage withstand": "Dielectric Strength", + "actuator type": "Actuator Type", + "actuator style": "Actuator Type", + "actuator": "Actuator Type", + "switch actuator": "Actuator Type", + "illumination": "Illumination", + "illumination type": "Illumination", + "illumination voltage": "Illumination", + "backlight": "Illumination", + "illuminated": "Illumination", + "coil voltage": "Coil Voltage", + "relay coil voltage": "Coil Voltage", + "coil voltage (dc)": "Coil Voltage", + "coil voltage (ac)": "Coil Voltage", + "coil resistance": "Coil Resistance", + "relay coil resistance": "Coil Resistance", + "coil resistance (ohms)": "Coil Resistance", + "coil power": "Coil Power", + "coil power consumption": "Coil Power", + "coil power (watts)": "Coil Power", + "contact material": "Contact Material", + "relay contact material": "Contact Material", + "contact plating": "Contact Material", + "row count": "Row Count", + "number of rows": "Row Count", + "rows": "Row Count", + "pitch": "Pitch", + "pitch - mating": "Pitch", + "contact pitch": "Pitch", + "spacing": "Pitch", + "gender / type": "Gender / Type", + "gender": "Gender / Type", + "connector gender": "Gender / Type", + "plug / receptacle": "Gender / Type", + "contact type": "Gender / Type", + "mounting orientation": "Mounting Orientation", + "mounting angle": "Mounting Orientation", + "connector orientation": "Mounting Orientation", + "right angle / vertical": "Mounting Orientation", + "fan airflow": "Fan Airflow", + "airflow": "Fan Airflow", + "airflow (cfm)": "Fan Airflow", + "max airflow": "Fan Airflow", + "fan speed": "Fan Speed", + "speed": "Fan Speed", + "speed (rpm)": "Fan Speed", + "rated speed": "Fan Speed", + "fan static pressure": "Fan Static Pressure", + "static pressure": "Fan Static Pressure", + "static pressure (in h2o)": "Fan Static Pressure", + "fan noise": "Fan Noise", + "noise": "Fan Noise", + "noise (dba)": "Fan Noise", + "acoustic noise": "Fan Noise", + "fan bearing type": "Fan Bearing Type", + "bearing type": "Fan Bearing Type", + "bearing": "Fan Bearing Type", + "fan rated voltage": "Fan Rated Voltage", + "rated voltage (fan)": "Fan Rated Voltage", + # ── General & Environmental ─────────────────────────────────────── + "operating temperature": "Operating Temperature", + "operating temp": "Operating Temperature", + "operating temperature range": "Operating Temperature", + "temp range": "Operating Temperature", + "temperature range": "Operating Temperature", + "min operating temperature": "Operating Temperature", + "max operating temperature": "Operating Temperature", + "operating temperature max": "Operating Temperature", + "operating temperature min": "Operating Temperature", + "storage temperature": "Storage Temperature", + "storage temp": "Storage Temperature", + "storage temperature range": "Storage Temperature", + "storage temperature max": "Storage Temperature", + "storage temperature min": "Storage Temperature", + "package / case": "Package / Case", + "package/case": "Package / Case", + "package": "Package / Case", + "case/package": "Package / Case", + "case": "Package / Case", + "casing": "Package / Case", + "packaging": "Package / Case", + "device package": "Package / Case", + "termination style": "Termination Style", + "termination": "Termination Style", + "termination type": "Termination Style", + "contact termination": "Termination Style", + "termination method": "Termination Style", + "moisture sensitivity level": "Moisture Sensitivity Level (MSL)", + "msl": "Moisture Sensitivity Level (MSL)", + "moisture sensitivity level (msl)": "Moisture Sensitivity Level (MSL)", + "rohs status": "RoHS Status", + "rohs": "RoHS Status", + "rohs compliant": "RoHS Status", + "lead free status": "Lead-Free Status", + "lead free": "Lead-Free Status", + "pb free": "Lead-Free Status", + "lead free status (rohs)": "Lead-Free Status", + "halogen free status": "Halogen-Free Status", + "halogen free": "Halogen-Free Status", + "physical width": "Physical Width", + "width": "Physical Width", + "dimension width": "Physical Width", + "package width": "Physical Width", + "physical length": "Physical Length", + "length": "Physical Length", + "dimension length": "Physical Length", + "package length": "Physical Length", + "physical height": "Physical Height", + "height": "Physical Height", + "dimension height": "Physical Height", + "package height": "Physical Height", + "max height": "Physical Height", + "weight": "Weight", + "unit weight": "Weight", + "device weight": "Weight", + "weight (grams)": "Weight", + "color": "Color", + "led color": "Color", + "colour": "Color", + "material": "Material", + "body material": "Material", + "housing material": "Material", + "mounting hole diameter": "Mounting Hole Diameter", + "mounting hole": "Mounting Hole Diameter", + "hole diameter": "Mounting Hole Diameter", + "mounting hole size": "Mounting Hole Diameter", +} + +# Pre-sanitize all keys in PARAMETER_MAP at startup to guarantee perfect lookup hits +PARAMETER_MAP = {sanitize_parameter_name(k): v for k, v in PARAMETER_MAP.items()} + + +def is_parameter_ignored(name: str, plugin=None) -> bool: + """ + Check if a parameter raw name is explicitly ignored. + """ + if not name: + return False + + key = sanitize_parameter_name(name.strip()) + if plugin: + try: + learned_json = plugin.get_setting("LEARNED_PARAMETER_MAPPINGS") or "{}" + import json + + learned = json.loads(learned_json) + if isinstance(learned, dict): + for k, v in learned.items(): + if sanitize_parameter_name(k) == key: + if isinstance(v, dict) and v.get("is_ignored"): + return True + except Exception as e: + logger.warning(f"Error checking ignored parameter: {e}") + + return False + + +def normalize_parameter_name(name: str, plugin=None) -> str: + """ + Standardize a parameter name to a canonical name using built-in PARAMETER_MAP + and/or user-learned mappings from settings. + + If the parameter name is unknown, it's tracked in the 'TRACKED_UNKNOWN_PARAMETERS' setting. + """ + if not name: + return "" + + stripped = name.strip() + key = sanitize_parameter_name(stripped) + + # 1. Check user-defined learned mappings first (case-insensitive key comparison) + if plugin: + try: + learned_json = plugin.get_setting("LEARNED_PARAMETER_MAPPINGS") or "{}" + import json + + learned = json.loads(learned_json) + if isinstance(learned, dict): + # Search case-insensitively using sanitized keys, supporting both strings and dicts + learned_lower = {} + for k, v in learned.items(): + if not k or v is None: + continue + sanitized_k = sanitize_parameter_name(k) + if isinstance(v, dict): + if v.get("is_ignored"): + # Ignored parameter maps to empty or special token + learned_lower[sanitized_k] = "" + else: + learned_lower[sanitized_k] = str( + v.get("canonical_name", "") + ).strip() + else: + learned_lower[sanitized_k] = str(v).strip() + + if key in learned_lower: + return learned_lower[key] + except Exception as e: + logger.warning(f"Error loading learned parameter mappings: {e}") + + # 2. Check hardcoded mapping + if key in PARAMETER_MAP: + return PARAMETER_MAP[key] + + # 3. Parameter is unknown: track it if not ignored! + if plugin and not is_parameter_ignored(stripped, plugin): + try: + unknowns_json = plugin.get_setting("TRACKED_UNKNOWN_PARAMETERS") or "{}" + import json + + unknowns = json.loads(unknowns_json) + if not isinstance(unknowns, dict): + unknowns = {} + + # Increment frequency count + unknowns[stripped] = unknowns.get(stripped, 0) + 1 + + plugin.set_setting( + "TRACKED_UNKNOWN_PARAMETERS", json.dumps(unknowns, ensure_ascii=False) + ) + except Exception as e: + logger.warning(f"Error saving tracked unknown parameter: {e}") + + return stripped diff --git a/inventree_smart_parts/services/part_creator.py b/inventree_smart_parts/services/part_creator.py index 76b8d9b..7763e62 100644 --- a/inventree_smart_parts/services/part_creator.py +++ b/inventree_smart_parts/services/part_creator.py @@ -632,9 +632,22 @@ def _create_parameters(part, parameters: List[Any]): part_type = ContentType.objects.get_for_model(part) skipped = 0 + from plugin.registry import registry + + plugin = None + try: + plugin = registry.get_plugin("smartparts") + except Exception: + pass + from .parameter_normalizer import is_parameter_ignored + for param in parameters: - # Drop parameters without a name or with a useless value - if not param.name or is_useless_value(getattr(param, "value", None)): + # Drop parameters without a name, with a useless value, or if explicitly ignored + if ( + not param.name + or is_useless_value(getattr(param, "value", None)) + or is_parameter_ignored(param.name, plugin) + ): skipped += 1 continue diff --git a/inventree_smart_parts/static/inventree_smart_parts/ui/editor_helpers.js b/inventree_smart_parts/static/inventree_smart_parts/ui/editor_helpers.js index ccc012e..b3e6c6a 100644 --- a/inventree_smart_parts/static/inventree_smart_parts/ui/editor_helpers.js +++ b/inventree_smart_parts/static/inventree_smart_parts/ui/editor_helpers.js @@ -120,7 +120,7 @@ function buildInitialSuppliers(searchData, existingData) { return `${normName(name)}:${String(sku||'').trim().toLowerCase()}`; } - ['mouser','digikey','lcsc'].forEach(src => { + ['mouser','digikey','lcsc','element14','tme'].forEach(src => { const s = searchData.sources[src]; if (!s || s.error) return; // Fallback: if API confirmed a match but SKU is empty, use MPN diff --git a/inventree_smart_parts/static/inventree_smart_parts/ui/editor_main.js b/inventree_smart_parts/static/inventree_smart_parts/ui/editor_main.js index 26866d3..b6ff5d6 100644 --- a/inventree_smart_parts/static/inventree_smart_parts/ui/editor_main.js +++ b/inventree_smart_parts/static/inventree_smart_parts/ui/editor_main.js @@ -107,7 +107,7 @@ function renderEditor(data, existing) { /* ── Source badges ────────────────────────────────────────── */ let srcBadges = ''; - ['mouser','digikey','lcsc'].forEach(s => { + ['mouser','digikey','lcsc','element14','tme'].forEach(s => { const src = data.sources[s]; if (!src) return; srcBadges += src.error @@ -341,7 +341,7 @@ function collectFormData() { // Per-source image URLs – ordered with most reliable first (digikey works server-side) const sourceImageUrls = []; - ['digikey', 'lcsc', 'mouser'].forEach(src => { + ['digikey', 'lcsc', 'mouser', 'element14', 'tme'].forEach(src => { const s = _searchData?.sources?.[src]; if (s && s.image_url && !s.error) { sourceImageUrls.push({ source: src, url: s.image_url }); diff --git a/inventree_smart_parts/static/inventree_smart_parts/ui/smartparts_dashboard.js b/inventree_smart_parts/static/inventree_smart_parts/ui/smartparts_dashboard.js index 2354319..cc4efbf 100644 --- a/inventree_smart_parts/static/inventree_smart_parts/ui/smartparts_dashboard.js +++ b/inventree_smart_parts/static/inventree_smart_parts/ui/smartparts_dashboard.js @@ -15,7 +15,7 @@ export function renderSmartPartsDashboard(target, context) { Smart Parts Lookup

- Search parts across Mouser, DigiKey & LCSC + Search parts across Mouser, DigiKey, LCSC, element14 & TME

Smart Parts Lookup -
Search Mouser, DigiKey & LCSC
+
Search Mouser, DigiKey, LCSC, element14 & TME
{ + ['mouser','digikey','lcsc','element14','tme'].forEach(s => { if (data.sources && data.sources[s] && !data.sources[s].error) { sourceBadges += '' + s + ' ✓'; } diff --git a/inventree_smart_parts/templates/inventree_smart_parts/dashboard.html b/inventree_smart_parts/templates/inventree_smart_parts/dashboard.html index b335569..37c894e 100644 --- a/inventree_smart_parts/templates/inventree_smart_parts/dashboard.html +++ b/inventree_smart_parts/templates/inventree_smart_parts/dashboard.html @@ -186,6 +186,11 @@

MPN Search

API Settings Configure & test API keys
+ + + Parameter Dashboard + Map & normalize parameters + Activity Log @@ -213,6 +218,16 @@

API Status

{% if lcsc_enabled %}Active{% else %}Inactive{% endif %} +
+ Farnell / element14 + + {% if element14_enabled %}Active{% else %}Inactive{% endif %} +
+
+ TME + + {% if tme_enabled %}Active{% else %}Inactive{% endif %} +
diff --git a/inventree_smart_parts/templates/inventree_smart_parts/parameter_dashboard.html b/inventree_smart_parts/templates/inventree_smart_parts/parameter_dashboard.html new file mode 100644 index 0000000..1152416 --- /dev/null +++ b/inventree_smart_parts/templates/inventree_smart_parts/parameter_dashboard.html @@ -0,0 +1,621 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block page_title %}{% trans "Parameter Normalization Dashboard" %} | Smart Parts{% endblock %} + +{% block content %} + + + + + +
+ + + + +
+ +
+ Permanent Ignore Heuristics:
+ Clicking "Ignore" is permanent. The parameter will be hidden from this list and silently dropped during all future API imports. +
+
+ + +
+

Pending Parameters (Caught from search)

+

+ Parameters retrieved from supplier APIs that are currently unmapped. Map them to a standard database Template or permanently ignore them. +

+ +
+ Excellent! No unknown parameters currently tracked. +
+ +
+
+ + +
+

Active Parameter Mappings & Rules

+

+ Manage your persistent normalization dictionary. Clean, lowercase string matching handles spelling variations. +

+ + + +
+ +
+ + +
+ + +
+ Raw Database JSON View + +
+
+
+ + +{% endblock %} diff --git a/inventree_smart_parts/templates/inventree_smart_parts/settings_page.html b/inventree_smart_parts/templates/inventree_smart_parts/settings_page.html index 1937b83..0dc02ef 100644 --- a/inventree_smart_parts/templates/inventree_smart_parts/settings_page.html +++ b/inventree_smart_parts/templates/inventree_smart_parts/settings_page.html @@ -144,6 +144,14 @@ background: linear-gradient(135deg, #0066cc, #3399ff) } + .sp-provider-icon.element14 { + background: linear-gradient(135deg, #e30613, #ff6b6b) + } + + .sp-provider-icon.tme { + background: linear-gradient(135deg, #1a5276, #2980b9) + } + .sp-provider-name { font-weight: 600; font-size: 1rem @@ -283,6 +291,48 @@

API Providers

+ +
+
+
F
+
+
element14 / Farnell / Newark
+
+ {% if element14_enabled %} + ● Enabled + {% if element14_has_key %} – Key configured – {{ element14_store }}{% else %} – No API key{% endif %} + {% else %} + ● Disabled + {% endif %} +
+
+
+ +
+
+ +
+
+
T
+
+
TME (Transfer Multisort Elektronik)
+
+ {% if tme_enabled %} + ● Enabled + {% if tme_has_token %} – Token configured – {{ tme_country }} / {{ tme_currency }}{% else %} – No API token{% endif %} + {% else %} + ● Disabled + {% endif %} +
+
+
+ +
+
diff --git a/inventree_smart_parts/tools/generate_command_sheet.py b/inventree_smart_parts/tools/generate_command_sheet.py index 62bc74f..6d81f6d 100644 --- a/inventree_smart_parts/tools/generate_command_sheet.py +++ b/inventree_smart_parts/tools/generate_command_sheet.py @@ -49,7 +49,7 @@ def generate_qr_svg(text: str, box_size: int = 4) -> str: """Generate an inline SVG QR code for the given text.""" try: import qrcode - import qrcode.image.svg + from qrcode.image.svg import SvgPathImage qr = qrcode.QRCode( version=1, @@ -59,8 +59,7 @@ def generate_qr_svg(text: str, box_size: int = 4) -> str: ) qr.add_data(text) qr.make(fit=True) - factory = qrcode.image.svg.SvgPathImage - img = qr.make_image(image_factory=factory) + img = qr.make_image(image_factory=SvgPathImage) svg_bytes = img.to_string() return svg_bytes.decode("utf-8") except ImportError: diff --git a/inventree_smart_parts/views.py b/inventree_smart_parts/views.py index 28b1b64..448b85f 100644 --- a/inventree_smart_parts/views.py +++ b/inventree_smart_parts/views.py @@ -76,6 +76,10 @@ def dashboard(request): "mouser_enabled": plugin.get_setting("MOUSER_ENABLED") if plugin else False, "digikey_enabled": plugin.get_setting("DIGIKEY_ENABLED") if plugin else False, "lcsc_enabled": plugin.get_setting("LCSC_ENABLED") if plugin else False, + "element14_enabled": ( + plugin.get_setting("ELEMENT14_ENABLED") if plugin else False + ), + "tme_enabled": plugin.get_setting("TME_ENABLED") if plugin else False, } return render(request, "inventree_smart_parts/dashboard.html", context) @@ -351,8 +355,50 @@ def api_search(request): results["lcsc"] = {"error": str(e)} logger.warning(f"LCSC search error: {e}") + # element14 / Farnell + if plugin.get_setting("ELEMENT14_ENABLED"): + try: + from .api_clients import Element14Client + + client = Element14Client( + api_key=plugin.get_setting("ELEMENT14_API_KEY"), + store_name=plugin.get_setting("ELEMENT14_STORE") or "uk.farnell.com", + ) + r = client.search_by_mpn(mpn) + if r: + api_results.append(r) + results["element14"] = _part_data_to_dict(r) + else: + results["element14"] = None + except Exception as e: + results["element14"] = {"error": str(e)} + logger.warning(f"element14 search error: {e}") + + # TME + if plugin.get_setting("TME_ENABLED"): + try: + from .api_clients import TMEClient + + client = TMEClient( + token=plugin.get_setting("TME_API_TOKEN"), + secret=plugin.get_setting("TME_API_SECRET"), + country=plugin.get_setting("TME_COUNTRY") or "DE", + currency=plugin.get_setting("TME_CURRENCY") or "EUR", + ) + r = client.search_by_mpn(mpn) + if r: + api_results.append(r) + results["tme"] = _part_data_to_dict(r) + else: + results["tme"] = None + except Exception as e: + results["tme"] = {"error": str(e)} + logger.warning(f"TME search error: {e}") + # Merge - priority_str = plugin.get_setting("API_PRIORITY") or "mouser,digikey,lcsc" + priority_str = ( + plugin.get_setting("API_PRIORITY") or "mouser,digikey,element14,tme,lcsc" + ) priority_order = [p.strip() for p in priority_str.split(",") if p.strip()] merged = merge_part_data(api_results, priority_order) @@ -458,7 +504,7 @@ def create_part(request): if not plugin: return JsonResponse({"error": "Plugin not loaded"}, status=500) - from .api_clients.base import PartData, PriceBreak, PartParameter + from .api_clients.base import PartData, PartParameter from .services.part_creator import create_part_from_data # Reconstruct PartData from the request @@ -898,6 +944,23 @@ def plugin_settings(request): bool(plugin.get_setting("DIGIKEY_CLIENT_ID")) if plugin else False ), "lcsc_enabled": plugin.get_setting("LCSC_ENABLED") if plugin else False, + "element14_enabled": ( + plugin.get_setting("ELEMENT14_ENABLED") if plugin else False + ), + "element14_has_key": ( + bool(plugin.get_setting("ELEMENT14_API_KEY")) if plugin else False + ), + "element14_store": ( + plugin.get_setting("ELEMENT14_STORE") or "uk.farnell.com" + if plugin + else "uk.farnell.com" + ), + "tme_enabled": plugin.get_setting("TME_ENABLED") if plugin else False, + "tme_has_token": bool(plugin.get_setting("TME_API_TOKEN")) if plugin else False, + "tme_country": plugin.get_setting("TME_COUNTRY") or "DE" if plugin else "DE", + "tme_currency": ( + plugin.get_setting("TME_CURRENCY") or "EUR" if plugin else "EUR" + ), } return render(request, "inventree_smart_parts/settings_page.html", context) @@ -909,7 +972,13 @@ def test_connection(request, provider: str): if not plugin: return JsonResponse({"error": "Plugin not loaded"}, status=500) - from .api_clients import MouserClient, DigiKeyClient, LCSCClient + from .api_clients import ( + MouserClient, + DigiKeyClient, + LCSCClient, + Element14Client, + TMEClient, + ) if provider == "mouser": client = MouserClient(api_key=plugin.get_setting("MOUSER_API_KEY")) @@ -920,6 +989,18 @@ def test_connection(request, provider: str): ) elif provider == "lcsc": client = LCSCClient() + elif provider == "element14": + client = Element14Client( + api_key=plugin.get_setting("ELEMENT14_API_KEY"), + store_name=plugin.get_setting("ELEMENT14_STORE") or "uk.farnell.com", + ) + elif provider == "tme": + client = TMEClient( + token=plugin.get_setting("TME_API_TOKEN"), + secret=plugin.get_setting("TME_API_SECRET"), + country=plugin.get_setting("TME_COUNTRY") or "DE", + currency=plugin.get_setting("TME_CURRENCY") or "EUR", + ) else: return JsonResponse({"error": f"Unknown provider: {provider}"}, status=400) @@ -1099,6 +1180,119 @@ def api_learned(request): return JsonResponse({"error": "Method not allowed"}, status=405) +@csrf_exempt +def api_parameter_mappings(request): + """ + GET → return current LEARNED_PARAMETER_MAPPINGS plugin setting as JSON + POST → validate & save a new LEARNED_PARAMETER_MAPPINGS value + """ + import json as _json + + plugin = _get_plugin() + if not plugin: + return JsonResponse({"error": "Plugin not loaded"}, status=500) + + if request.method == "GET": + value = plugin.get_setting("LEARNED_PARAMETER_MAPPINGS") or "{}" + return JsonResponse({"value": value}) + + if request.method == "POST": + denied = _check_perm(request, "part.change_part") + if denied: + return denied + try: + body = _json.loads(request.body) + raw = body.get("value", "{}") + # Validate it's a JSON object + parsed = _json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("Expected a JSON object") + except (ValueError, _json.JSONDecodeError, TypeError) as e: + return JsonResponse({"error": f"Invalid JSON: {e}"}, status=400) + + try: + plugin.set_setting("LEARNED_PARAMETER_MAPPINGS", raw) + return JsonResponse({"success": True, "value": raw}) + except Exception as e: + logger.error( + f"Failed to save LEARNED_PARAMETER_MAPPINGS: {e}", exc_info=True + ) + return JsonResponse({"error": str(e)}, status=500) + + return JsonResponse({"error": "Method not allowed"}, status=405) + + +@csrf_exempt +def api_unknown_parameters(request): + """ + GET → return current TRACKED_UNKNOWN_PARAMETERS plugin setting as JSON + POST → validate & save a new TRACKED_UNKNOWN_PARAMETERS value + """ + import json as _json + + plugin = _get_plugin() + if not plugin: + return JsonResponse({"error": "Plugin not loaded"}, status=500) + + if request.method == "GET": + value = plugin.get_setting("TRACKED_UNKNOWN_PARAMETERS") or "{}" + return JsonResponse({"value": value}) + + if request.method == "POST": + denied = _check_perm(request, "part.change_part") + if denied: + return denied + try: + body = _json.loads(request.body) + raw = body.get("value", "{}") + # Validate it's a JSON object + parsed = _json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("Expected a JSON object") + except (ValueError, _json.JSONDecodeError, TypeError) as e: + return JsonResponse({"error": f"Invalid JSON: {e}"}, status=400) + + try: + plugin.set_setting("TRACKED_UNKNOWN_PARAMETERS", raw) + return JsonResponse({"success": True, "value": raw}) + except Exception as e: + logger.error( + f"Failed to save TRACKED_UNKNOWN_PARAMETERS: {e}", exc_info=True + ) + return JsonResponse({"error": str(e)}, status=500) + + return JsonResponse({"error": "Method not allowed"}, status=405) + + +def parameter_dashboard(request): + """View to display the Parameter Normalization Dashboard.""" + plugin = _get_plugin() + context = { + "plugin": plugin, + } + return render(request, "inventree_smart_parts/parameter_dashboard.html", context) + + +def api_canonical_parameters(request): + """Return a list of all existing canonical parameter names from DB and built-in map.""" + from common.models import ParameterTemplate + from .services.parameter_normalizer import PARAMETER_MAP + + # Get database parameter templates + try: + db_names = list(ParameterTemplate.objects.all().values_list("name", flat=True)) + except Exception: + db_names = [] + + # Get built-in canonical names + builtin_names = list(set(PARAMETER_MAP.values())) + + # Merge and deduplicate + all_names = sorted(list(set(db_names + builtin_names))) + + return JsonResponse({"names": all_names}) + + # ═══════════════════════════════════════════════════════════════════ # Stock & Label APIs # ═══════════════════════════════════════════════════════════════════ diff --git a/setup.py b/setup.py index aa2ed67..0f2e918 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="inventree-smart-parts", - version="1.0.0", + version="1.1.0", author="0neShot", description="Intelligent inventory assistant for InvenTree that automates part creation from MPN lookup.", long_description=long_description,