Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ DATABASE_URL="postgresql://partpilot:partpilotPass@localhost:5432/partpilotdb?sc
# openssl rand -base64 32
NEXTAUTH_SECRET="K3l7o+g1mCZUQjszYk6SH0k66mmYeX1gh1ANqJA6/9o=" # for local
NEXTAUTH_URL="http://localhost:3000" # for local

# Autocomplete
# MOUSER_API_KEY="your_mouser_api_key_here"
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Welcome to PartPilot, the ultimate open-source solution designed to streamline a

- 🏬 **Inventory Management**: Effortlessly catalog your electronic parts with detailed information, including datasheets, supplier data, stock levels, and more.

- 🖥️ **Direct LCSC Integration**: Seamlessly connect with LCSC for direct access to a vast inventory of parts, enabling easy addition and management of components within PartPilot.
- 🖥️ **Direct LCSC & Mouser Integration**: Seamlessly connect with LCSC or Mouser for direct access to a vast inventory of parts, enabling easy addition and management of components within PartPilot.

- 👁️ **Barcode Scanner Functionality**: Add parts to your inventory swiftly using the barcode scanner feature, enhancing efficiency and accuracy in part management.

Expand Down Expand Up @@ -83,6 +83,11 @@ To host PartPilot on your homeserver using docker-compose:
copy the contents of the `docker-compose-release.yml` into a `docker-compose.yml` file on your server
start the service using `docker-compose up -d` or `docker compose up -d`.

### ⚙️ Configuration

To enable the Mouser search functionality, you need to provide a Mouser Search API key.
Add the `MOUSER_API_KEY` environment variable to your deployment (e.g., in `docker-compose.yml` or `.env` file).

<a id="development"></a>

## 👨‍💻 Development
Expand Down
24 changes: 23 additions & 1 deletion app/add/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Image,
ThemeIcon,
LoadingOverlay,
Select,
} from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import { scannerInputToType } from "../dashboardPage";
Expand Down Expand Up @@ -67,6 +68,17 @@ export default function Add() {

const [scannerInput, setScannerInput] = useState("");
const [productCode, setProductCode] = useState("");
const [provider, setProvider] = useState<string | null>("LCSC");
const [availableProviders, setAvailableProviders] = useState<string[]>(["LCSC"]);

useEffect(() => {
fetch("/api/providers")
.then((res) => res.json())
.then((data) => {
setAvailableProviders(data);
})
.catch((e) => console.error(e));
}, []);

//When the user presses the Autocomplete Button
async function handleAutocomplete() {
Expand All @@ -88,6 +100,7 @@ export default function Add() {
method: "POST",
body: JSON.stringify({
productCode: productCodeInternal,
provider: provider,
}),
}).then((response) =>
response
Expand Down Expand Up @@ -290,13 +303,22 @@ export default function Add() {
<Paper p={"sm"} shadow="sm">
<Group justify="space-between" pb={4}>
<Text>Autocomplete: </Text>
<Tooltip label="Autocomplete For LCSC">
<Tooltip label={`Autocomplete For ${provider}`}>
<ThemeIcon>
<IconInfoCircle />
</ThemeIcon>
</Tooltip>
</Group>
<Grid>
<Grid.Col span={12}>
<Select
data={availableProviders}
value={provider}
onChange={setProvider}
allowDeselect={false}
placeholder="Provider"
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
placeholder="Scanner Input"
Expand Down
13 changes: 12 additions & 1 deletion app/api/parts/autocomplete/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { extractPartInfoFromLCSCResponse } from "@/lib/helper/lcsc_api";
import { searchMouser } from "@/lib/helper/mouser_api";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
Expand All @@ -7,8 +8,18 @@ export async function POST(request: NextRequest) {
console.log(res);

const pcNumber = res.productCode;
console.log(pcNumber);
const provider = res.provider || "LCSC"; // Default to LCSC
console.log(`Searching for ${pcNumber} using ${provider}`);

if (provider === "Mouser") {
const partInfo = await searchMouser(pcNumber);
if (!partInfo) {
return NextResponse.json({ status: 404, error: "Part not found" });
}
return NextResponse.json({ status: 200, body: partInfo });
}

// Default LCSC fallback
const LSCSPart = await fetch(
"https://wmsc.lcsc.com/ftps/wm/product/detail?productCode=" + pcNumber
)
Expand Down
11 changes: 11 additions & 0 deletions app/api/providers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";

export async function GET() {
const providers = ["LCSC"];

if (process.env.MOUSER_API_KEY) {
providers.push("Mouser");
}

return NextResponse.json(providers);
}
107 changes: 107 additions & 0 deletions lib/helper/mouser_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { PartState } from "./part_state";
import { unit } from "mathjs";

interface MouserPartResponse {
MouserPartNumber: string;
ManufacturerPartNumber: string;
Manufacturer: string;
Description: string;
ProductDetailUrl: string;
DataSheetUrl: string;
Availability: string;
PriceBreaks: { Quantity: number; Price: string }[];
ProductAttributes: { AttributeName: string; AttributeValue: string }[];
SearchResults: { NumberOfResult: number; Parts: any[] };
}

export async function searchMouser(partNumber: string): Promise<PartState | null> {
const apiKey = process.env.MOUSER_API_KEY;

if (!apiKey) {
throw new Error("MOUSER_API_KEY environment variable is not set");
}

const response = await fetch(
`https://api.mouser.com/api/v1/search/partnumber?apiKey=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
SearchByPartRequest: {
mouserPartNumber: partNumber,
partSearchOptions: "Exact",
},
}),
}
);

if (!response.ok) {
throw new Error(`Mouser API error: ${response.statusText}`);
}

const data = await response.json();
const parts = data.SearchResults?.Parts;

if (!parts || parts.length === 0) {
return null;
}

// Take the first result
const result = parts[0];
return mapMouserResponseToPartState(result);
}

// Helper to convert to specific units if possible, otherwise base
function parseValue(value: string, targetUnit?: string): number | undefined {
if (!value) return undefined;
try {
const u = unit(value);
if (targetUnit) {
return u.toNumber(targetUnit);
}
return u.toNumber(); // Base unit
} catch (e) {
const num = parseFloat(value);
return isNaN(num) ? undefined : num;
}
}

function mapMouserResponseToPartState(result: any): PartState {
const attributes = result.ProductAttributes || [];
const getAttr = (name: string) =>
attributes.find((a: any) => a.AttributeName === name)?.AttributeValue;

return {
id: 0, // Placeholder, generated by DB
title: result.Description,
quantity: result.Availability ? parseInt(result.Availability.replace(/[^0-9]/g, "")) : 0,
productId: 0, // Mouser does not provide a numeric ProductId that maps to our schema.
productCode: result.MouserPartNumber,
productModel: result.ManufacturerPartNumber,
productDescription: result.Description,
catalogName: "Electronic Components",
brandName: result.Manufacturer,
productImages: result.ImagePath ? [result.ImagePath] : [],
pdfLink: result.DataSheetUrl,
productLink: result.ProductDetailUrl,
createdAt: new Date(),
updatedAt: new Date(),

// Parse technical parameters
voltage: parseValue(getAttr("Voltage Rating"), "V"),
resistance: parseValue(getAttr("Resistance"), "ohm"),
power: parseValue(getAttr("Power Rating"), "W"),
current: parseValue(getAttr("Current Rating"), "A"),
tolerance: getAttr("Tolerance"),
frequency: parseValue(getAttr("Frequency"), "Hz"),
capacitance: parseValue(getAttr("Capacitance"), "nF"),
inductance: parseValue(getAttr("Inductance"), "uH"),

prices: result.PriceBreaks?.map((pb: any) => ({
ladder: pb.Quantity.toString(),
price: parseFloat(pb.Price.replace(',', '.').replace(/[^0-9.]/g, "")),
})),
};
}