Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ For more details or to discuss releases, please visit the

## [Unreleased]

## [0.9.4] - 2026-07-16

- Parts whose variation codes a value rather than a plain number, such as
`ICS-0047-02V5` for 2.5 V or `RES-0008-8R3m` for 8.3 mOhm, are now accepted
everywhere an IPN is read. Releases of these parts previously failed, the
KiCad HTTP API served them without a category, and the TUI could suggest a
next available IPN that collided with one of them.

## [0.9.3] - 2026-07-15

- KiCad HTTP API: parts are now named by their IPN, so a placed symbol's library
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ and ASY have recursive BOMs.

Purchased: `RES`, `CAP`, `DIO`, `LED`, etc.

Format: `CCC-NNN-VVVV` where N is 3-4 digits, V is always 4 digits.
Format: `CCC-NNN-VVVV` where N is 3-4 digits, and V is 4 alphanumeric
characters. V often codes a value rather than a plain number, such as `02V5` for
2.5 V or `047n` for 47 nH, so its case is significant. `reIpn` in `ipn.go` is
the single definition of this format -- parse IPNs through the `ipn` type rather
than matching the format again elsewhere.

### Data Flow for Release Processing

Expand Down
60 changes: 60 additions & 0 deletions csv_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import "testing"

// The highest N in a category may belong to a part whose variation codes a
// value, such as the 02V5 (2.5 V) below. Those parts still have to count
// towards the max N, otherwise the next IPN collides with an existing part.
func TestNextAvailableIPN(t *testing.T) {
tests := []struct {
name string
rows [][]string
want string
}{
{
name: "digit variations",
rows: [][]string{
{"ICS-0045-0001"},
{"ICS-0046-0000"},
},
want: "ICS-0047-0001",
},
{
name: "max N has a value coded variation",
rows: [][]string{
{"ICS-0045-0001"},
{"ICS-0046-0000"},
{"ICS-0047-02V5"},
},
want: "ICS-0048-0001",
},
{
name: "3 digit N is preserved",
rows: [][]string{
{"PCB-001-0500"},
},
want: "PCB-002-0001",
},
{
name: "rows that are not IPNs are ignored",
rows: [][]string{
{"ICS-0045-0001"},
{"not an ipn"},
{""},
},
want: "ICS-0046-0001",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := nextAvailableIPN(test.rows, 0)
if err != nil {
t.Fatalf("nextAvailableIPN() error: %v", err)
}
if got != test.want {
t.Errorf("nextAvailableIPN() = %q, want %q", got, test.want)
}
})
}
}
53 changes: 13 additions & 40 deletions ipn.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,60 +11,38 @@ import (

type ipn string

var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d\d\d\d)$`)
var reC = regexp.MustCompile(`^[A-Z][A-Z][A-Z]$`)
// reIpn is the single definition of the IPN format: CCC-NNNN-VVVV, and
// CCC-NNN-VVVV for legacy 3-digit N.
//
// VVVV codes a variation and is alphanumeric rather than digits only, because
// it often encodes a value: 02V5 denotes 2.5 V, and 047n denotes 47 nH. Case is
// significant, since SI prefixes such as the m in 8R3m (8.3 mOhm) rely on it.
var reIpn = regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-([0-9A-Za-z]{4})$`)

func newIpn(s string) (ipn, error) {
_, _, _, err := ipn(s).parse()
return ipn(s), err
}

func newIpnParts(c string, n, v int) (ipn, error) {
if n < 0 || n > 9999 {
return "", errors.New("N out of range")
}

if v < 0 || v > 9999 {
return "", errors.New("V out of range")
}

if len(c) != 3 {
return "", errors.New("C must be 3 chars")
}

if reC.FindString(c) == "" {
return "", errors.New("C must be in format CCC")
}

nFmt := "%03v"
if n > 999 {
nFmt = "%04v"
}
return ipn(fmt.Sprintf("%v-"+nFmt+"-%04v", c, n, v)), nil
}

func (i ipn) String() string {
return string(i)
}

// parse() returns C (category), N (number), V (variation)
func (i ipn) parse() (string, int, int, error) {
// parse() returns C (category), N (number), V (variation). V is returned as a
// string because it is not always a number.
func (i ipn) parse() (string, int, string, error) {
groups := reIpn.FindStringSubmatch(string(i))
if len(groups) < 4 {
return "", 0, 0, errors.New("Error parsing ipn")
return "", 0, "", errors.New("Error parsing ipn")
}

c := groups[1]
n, err := strconv.Atoi(groups[2])
if err != nil {
return "", 0, 0, fmt.Errorf("Error parsing N: %v", err)
}
v, err := strconv.Atoi(groups[3])
if err != nil {
return "", 0, 0, fmt.Errorf("Error parsing V: %v", err)
return "", 0, "", fmt.Errorf("Error parsing N: %v", err)
}

return c, n, v, nil
return c, n, groups[3], nil
}

// nWidth returns the number of digits in the N segment of the IPN
Expand Down Expand Up @@ -95,11 +73,6 @@ func (i ipn) n() (int, error) {
return n, err
}

func (i ipn) v() (int, error) {
_, _, v, err := i.parse()
return v, err
}

var ourIPNs = []string{"PCA", "PCB", "ASY", "DOC", "DFW", "DSW", "DCL", "FIX"}

func (i ipn) isOurIPN() (bool, error) {
Expand Down
23 changes: 15 additions & 8 deletions ipn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@ type ipnTest struct {
ipn string
c string
n int
v int
v string
valid bool
}

func TestIpn(t *testing.T) {
tests := []ipnTest{
{"PCB-001-0500", "PCB", 1, 500, true},
{"ASY-200-1000", "ASY", 200, 1000, true},
{"SY-200-1000", "", 0, 0, false},
{"ASY-0001-0001", "ASY", 1, 1, true}, // 4-digit N
{"PCB-0123-0500", "PCB", 123, 500, true}, // 4-digit N
{"ASY-20-1000", "", 0, 0, false},
{"ASY-200-100", "", 0, 0, false},
{"PCB-001-0500", "PCB", 1, "0500", true},
{"ASY-200-1000", "ASY", 200, "1000", true},
{"SY-200-1000", "", 0, "", false},
{"ASY-0001-0001", "ASY", 1, "0001", true}, // 4-digit N
{"PCB-0123-0500", "PCB", 123, "0500", true}, // 4-digit N
{"ICS-0047-02V5", "ICS", 47, "02V5", true}, // V codes 2.5 V
{"CAP-0006-04R7", "CAP", 6, "04R7", true}, // R as a decimal point
{"IND-0005-047n", "IND", 5, "047n", true}, // n codes nH
{"RES-0008-8R3m", "RES", 8, "8R3m", true}, // 8.3 mOhm
{"ASY-20-1000", "", 0, "", false}, // N too short
{"ASY-200-100", "", 0, "", false}, // V too short
{"ASY-200-10000", "", 0, "", false}, // V too long
{"ics-0047-0000", "", 0, "", false}, // C must be upper case
{"ICS-0047-02_5", "", 0, "", false}, // V is alphanumeric only
}

for _, test := range tests {
Expand Down
14 changes: 6 additions & 8 deletions kicad_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"log"
"net/http"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -238,15 +237,14 @@ func (s *KiCadServer) findColumnIndex(file *CSVFile, columnName string) int {
return -1
}

// extractCategory extracts the CCC component from an IPN
// extractCategory extracts the CCC component from an IPN, or returns an empty
// string if the IPN is not in the format described on the ipn type.
func (s *KiCadServer) extractCategory(ipnStr string) string {
// IPN format: CCC-NNNN-VVVV (also supports CCC-NNN-VVVV for legacy)
re := regexp.MustCompile(`^([A-Z][A-Z][A-Z])-(\d{3,4})-(\d{4})$`)
matches := re.FindStringSubmatch(ipnStr)
if len(matches) >= 2 {
return matches[1]
c, err := ipn(ipnStr).c()
if err != nil {
return ""
}
return ""
return c
}

// getCategoryDisplayName returns a human-readable name for a category
Expand Down
42 changes: 42 additions & 0 deletions kicad_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import "testing"

type extractCategoryTest struct {
ipn string
category string
}

func TestExtractCategory(t *testing.T) {
tests := []extractCategoryTest{
{"ICS-0046-0000", "ICS"},
{"PCB-001-0500", "PCB"},
{"ASY-0001-0001", "ASY"},
// VVVV may encode a value such as voltage, so it is alphanumeric
{"ICS-0047-02V5", "ICS"},
{"REG-0006-03V3", "REG"},
{"CAP-0006-04R7", "CAP"},
{"CAP-0003-220M", "CAP"},
// SI prefixes in the variation are lower case
{"IND-0005-047n", "IND"},
{"RES-0008-8R3m", "RES"},
// Malformed IPNs have no category
{"SY-200-1000", ""},
{"ASY-20-1000", ""},
{"ASY-200-100", ""},
{"ASY-200-10000", ""},
{"ics-0047-0000", ""},
{"ICS-0047-02V", ""},
{"ICS-0047-02_5", ""},
{"", ""},
}

s := &KiCadServer{}

for _, test := range tests {
got := s.extractCategory(test.ipn)
if got != test.category {
t.Errorf("extractCategory(%q) = %q, want %q", test.ipn, got, test.category)
}
}
}
2 changes: 1 addition & 1 deletion release.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func processRelease(relPn string, relLog *strings.Builder, pmDir string) (string
}

relPnBase := relIpn.base()
relPnBaseWithVar := fmt.Sprintf("%v-%02v", relPnBase, v/100) // First two digits of variation
relPnBaseWithVar := fmt.Sprintf("%v-%v", relPnBase, v[:2]) // First two characters of variation

bomFile := relPnBase + ".csv"
bomFileWithVar := relPnBaseWithVar + ".csv"
Expand Down
Loading