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
59 changes: 57 additions & 2 deletions .ai/standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,57 @@ The agent exposes raw block/page access via API. Creality, Anycubic, Qidi, etc.
- **Read:** `GET /readers/0/card` → response has `"dataType":"openprinttag"` with full parsed JSON
- **Write:** `POST /readers/0/card` with `"dataType":"openprinttag"` and JSON material fields

**ICode SLIX2 — physical format**

- Expected CC bytes: `E1 40 27 01` (MLEN=39 blocks × 8 bytes = 312 usable bytes)
- NDEF TLV: Type `0x03`, followed by CBOR payload, terminated by `0xFE`
- AuxSection: ISO 15693 auxiliary memory — contains `consumedWeight` (key 0) and workgroup (key 1)
- Reader required: **ACR1552U only** (ISO 15693 support)

**CBOR key reference — complete table**

| Key | Go field (Input) | JSON field (Response) | Description |
|-----|------------------|-----------------------|-------------|
| 0 | InstanceUUID | instanceUuid | UUIDv5(UID + materialUuid) — auto-generated |
| 1 | PackageUUID | packageUuid | Package UUID |
| 2 | MaterialUUID | materialUuid | Material UUID |
| 3 | BrandUUID | brandUuid | Brand UUID |
| 4 | GTIN | gtin | EAN-13 / GTIN product code |
| 5 | BrandSpecificInstID | brandSpecificInstanceId | Brand's spool instance reference |
| 6 | BrandSpecificPkgID | brandSpecificPackageId | Brand's package/product reference |
| 8 | MaterialClass | materialClass | 0=FFF, 1=SLA |
| 9 | MaterialType | materialType | Material type enum |
| 10 | MaterialName | materialName | Material name / color |
| 11 | BrandName | brandName | Brand name |
| 16 | NominalWeight | nominalWeight | Nominal weight (g) |
| 17 | ActualWeight | actualWeight | Actual measured weight (g) |
| 18 | SpoolWeight | spoolWeight | Empty spool weight (g) |
| 19 | PrimaryColor | primaryColor | RGBA hex color |
| 29 | Density | density | Density (g/cm³) |
| 30 | FilamentDiameter | filamentDiameter | Diameter (mm) |
| 34 | MinPrintTemp | minPrintTemp | Min nozzle temp (°C) |
| 35 | MaxPrintTemp | maxPrintTemp | Max nozzle temp (°C) |
| 36 | PreheatTemp | preheatTemp | Preheat temp (°C) |
| 37 | MinBedTemp | minBedTemp | Min bed temp (°C) |
| 38 | MaxBedTemp | maxBedTemp | Max bed temp (°C) |
| 39 | MinChamberTemp | minChamberTemp | Min chamber temp (°C) |
| 41 | ChamberTemp | chamberTemp | Max chamber temp (°C) |
| 42 | ContainerWidth | containerWidth | Spool width (mm) |
| 43 | ContainerOuterDiam | containerOuterDiam | Outer diameter (mm) |
| 44 | ContainerInnerDiam | containerInnerDiam | Inner diameter (mm) |
| 45 | ContainerHoleDiam | containerHoleDiam | Hub hole diameter (mm) |
| 53 | NominalFullLength | nominalFullLength | Nominal full length (mm) |
| 54 | ActualFullLength | actualFullLength | Actual full length (mm) |
| — | ManufacturedDate | manufacturedDate | Unix timestamp |

**Critical rule — Input AND Response must be updated together**

When adding a CBOR field, update **both structs** in `openprinttag.go`:
1. `Input` struct + `ToOpenPrintTag()` in `codec.go` → **write** path
2. `Response` struct + `ToResponse()` in `openprinttag.go` → **read** path

Forgetting step 2: the field is encoded on the tag but missing from the `GET /card` JSON response.

**Write example:**
```bash
curl -X POST http://127.0.0.1:32145/v1/readers/0/card \
Expand All @@ -67,12 +118,16 @@ curl -X POST http://127.0.0.1:32145/v1/readers/0/card \
"materialType": 0,
"primaryColor": "#1A1A1A",
"minPrintTemp": 215,
"maxPrintTemp": 230
"maxPrintTemp": 230,
"minBedTemp": 60,
"maxBedTemp": 60,
"preheatTemp": 170,
"gtin": 8594178550112
}
}'
```

**materialType values:** 0=PLA, 1=ABS, 2=PETG, 3=TPU, 4=ASA, 5=PA (Nylon), 6=PC, 7=Carbon fiber composite, 8=HIPS, 9=PVA, 10=Other
**materialType values:** 0=PLA, 1=PETG, 2=TPU, 3=ABS, 4=ASA, 5=PC, 6=PCTG, 7=PP, 8=PA, 9=PA11, 10=PA12, 11=PA66, 12=CPE, 13=TPE, 14=HIPS, 15=PHA, 16=PET, 17=PEI, 255=Unknown

---

Expand Down
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,42 @@ Full API reference: [`.ai/api.md`](.ai/api.md)
| `internal/config/config.go` | Environment variable config |
| `.goreleaser.yaml` | Linux release packaging config |
| `.github/workflows/build.yml` | Full release CI pipeline |


## Fork gienne/nfc-agent — Specific Changes

This repo is a fork of `SimplyPrint/nfc-agent`, adapted for the **bobines** app (3D filament spool management on Raspberry Pi). Changes are concentrated in `internal/openprinttag/`.

### Key fork commits

| Commit | Description |
|--------|-------------|
| `5912c1c` | fix: NDEF read — skip `0xFE` inside CBOR payload (false-positive terminator) |
| `521fa5b` | fix: ICode SLIX2 CC bytes `E1 40 27 01` (MLEN=39) + actualWeight/spoolWeight/preheatTemp/lengths |
| `becaad8` | feat: Input struct — brandSpecificInstId/PkgId + container dimensions (keys 5,6,42-45) |
| `6ea01f7` | feat: Input struct — GTIN uint64 (key 4) + codec.go mapping |
| `0180716` | feat: Response struct — GTIN, preheat/chamber temps, container dims, nominalFullLength |

### Modified files

- `internal/openprinttag/openprinttag.go` — `Input`, `Response`, `ToOpenPrintTag()`, `ToResponse()` structs
- `internal/openprinttag/codec.go` — CBOR encode/decode; Input → OpenPrintTag.Main mapping

### Rule: update Input AND Response together

When adding a CBOR field:
1. Add to `Input` struct + add mapping in `ToOpenPrintTag()` (codec.go) → **write path**
2. Add to `Response` struct + add mapping in `ToResponse()` (openprinttag.go) → **read path**

Missing step 2 means the field is written to the tag but absent from `GET /card` JSON.

### Integration with bobines app

The PHP app (`/var/www/bobines-next/`) communicates with this service via `modules/filament/nfc-proxy.php` (HTTP proxy → port 32145). JSON fields returned by `GET /v1/readers/0/card` are used directly by PHP.

When adding fields written to the tag, update in parallel:
- `nfc-proxy.php` (build the JSON payload sent to `POST /card`)
- `nfc-tag-write.php` (its own catRes SQL query + preview table)
- `add-form.php`, `edit-form.php`, `duplicate-form.php` + their handlers (if new MPI column)
- `trash-handler.php` (explicit column list in restore SELECT/INSERT)
- `nfc-erase.php` + `scripts/nfc-read.sh` (display of read data)
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,21 +674,79 @@ curl -X POST http://127.0.0.1:32145/v1/readers/0/card \

### OpenPrintTag Fields

**Identification**

| Field | Type | Description |
|-------|------|-------------|
| `materialName` | string | Material display name (required) |
| `brandName` | string | Brand/manufacturer name (required) |
| `materialClass` | int | 0 = FFF (filament), 1 = SLA (resin) |
| `materialType` | int | Material type (0=PLA, 1=ABS, 2=PETG, etc.) |
| `nominalWeight` | float | Nominal weight in grams (required) |
| `materialType` | int | Material type — see values below (required) |
| `primaryColor` | string | Hex color code (#RRGGBB or #RRGGBBAA) |
| `gtin` | uint64 | EAN-13 / GTIN product code |
| `brandSpecificInstanceId` | string | Brand's own spool instance reference |
| `brandSpecificPackageId` | string | Brand's own package/product reference |

**Weight & Length**

| Field | Type | Description |
|-------|------|-------------|
| `nominalWeight` | float | Nominal weight in grams (required) |
| `actualWeight` | float | Actual netto weight measured (g) |
| `spoolWeight` | float | Empty spool/bobbin weight (g) |
| `nominalFullLength` | float | Nominal full length (mm) |
| `actualFullLength` | float | Actual full length measured (mm) |

**Print Parameters**

| Field | Type | Description |
|-------|------|-------------|
| `filamentDiameter` | float | Diameter in mm (default: 1.75) |
| `density` | float | Material density in g/cm³ |
| `minPrintTemp` | int | Minimum print temperature °C |
| `maxPrintTemp` | int | Maximum print temperature °C |
| `minPrintTemp` | int | Minimum nozzle temperature °C |
| `maxPrintTemp` | int | Maximum nozzle temperature °C |
| `minBedTemp` | int | Minimum bed temperature °C |
| `maxBedTemp` | int | Maximum bed temperature °C |
| `preheatTemp` | int | Preheat temperature °C |
| `minChamberTemp` | int | Minimum chamber temperature °C |
| `chamberTemp` | int | Maximum chamber temperature °C |

**Container Dimensions**

| Field | Type | Description |
|-------|------|-------------|
| `containerOuterDiam` | float | Spool outer diameter (mm) |
| `containerInnerDiam` | float | Spool inner diameter (mm) |
| `containerHoleDiam` | float | Spool hub hole diameter (mm) |
| `containerWidth` | float | Spool width (mm) |

**Timestamps**

| Field | Type | Description |
|-------|------|-------------|
| `manufacturedDate` | int | Unix timestamp |
| `expirationDate` | int | Unix timestamp |

**Read-only (auto-generated on write, returned on read)**

| Field | Type | Description |
|-------|------|-------------|
| `instanceUuid` | string | UUIDv5 derived from UID + materialUuid |
| `packageUuid` | string | UUID of the material package |
| `materialUuid` | string | UUID of the material |
| `brandUuid` | string | UUID of the brand |
| `remainingWeight` | float | nominalWeight − consumedWeight (computed) |
| `filamentLength` | float | Remaining length (mm, computed from AuxSection consumedWeight) |

**AuxSection** (ICode SLIX2 only — written to the tag's auxiliary memory)

| Key | Field | Description |
|-----|-------|-------------|
| 0 | `consumedWeight` | Weight consumed so far (g) |
| 1 | workgroup | OpenPrintTag workgroup identifier |

**materialType values:** 0=PLA, 1=PETG, 2=TPU, 3=ABS, 4=ASA, 5=PC, 6=PCTG, 7=PP, 8=PA, 9=PA11, 10=PA12, 11=PA66, 12=CPE, 13=TPE, 14=HIPS, 15=PHA, 16=PET, 17=PEI, 18=PBT, 19=PVB, 20=PVA, 21=PEKK, 22=PEEK, 255=Unknown

See the [OpenPrintTag specification](https://openprinttag.org) for the complete field reference.

## Building from Source
Expand Down
64 changes: 40 additions & 24 deletions internal/core/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,17 @@ func WriteDataWithURL(readerName string, data []byte, dataType string, url strin
if err := writeMifareClassic(card, ndefMessage); err != nil {
return fmt.Errorf("failed to write NDEF message: %w", err)
}
} else if cardInfo.Protocol == "NFC-V" {
// ISO 15693 (ICode SLI/SLIX/SLIX2): CC at block 0, NDEF TLV at block 1.
// Do NOT pad to card.Size — writing past block 79 causes errors on ICode SLIX2.
// E1=NDEF magic, 40=v1.0 read/write, 27=MLEN 39 (40×8=320 bytes), 01=MBREAD
cc := []byte{0xE1, 0x40, 0x27, 0x01}
if err := writeNTAGPages(card, 0, cc); err != nil {
return fmt.Errorf("failed to write NDEF message: %w", err)
}
if err := writeNTAGPages(card, 1, ndefMessage); err != nil {
return fmt.Errorf("failed to write NDEF message: %w", err)
}
} else {
// NTAG and other cards use page-based writes.
// Zero-fill remaining user pages after the NDEF data so leftover
Expand Down Expand Up @@ -1581,13 +1592,6 @@ func readNDEFData(card *scard.Card, cardInfo *Card) {

allData = append(allData, blockData...)

// Check for NDEF terminator
for _, b := range blockData {
if b == 0xFE {
goto done
}
}

// Check if we have complete NDEF message
if len(allData) > 2 && allData[0] == 0x03 {
var ndefLength, ndefStart int
Expand All @@ -1598,8 +1602,19 @@ func readNDEFData(card *scard.Card, cardInfo *Card) {
ndefLength = int(allData[1])
ndefStart = 2
}
if ndefStart > 0 && len(allData) >= ndefStart+ndefLength+1 {
break
if ndefStart > 0 {
if len(allData) >= ndefStart+ndefLength+1 {
break
}
// Skip 0xFE check inside NDEF payload — binary payloads (CBOR) may contain 0xFE as data
continue
}
}

// Check for NDEF terminator (only when NDEF header not yet parsed)
for _, b := range blockData {
if b == 0xFE {
goto done
}
}
}
Expand All @@ -1624,13 +1639,6 @@ func readNDEFData(card *scard.Card, cardInfo *Card) {

allData = append(allData, pageData...)

// Check for NDEF terminator
for _, b := range pageData {
if b == 0xFE {
goto done
}
}

// Check if we have complete NDEF message
if len(allData) > 2 && allData[0] == 0x03 {
var ndefLength, ndefStart int
Expand All @@ -1641,8 +1649,19 @@ func readNDEFData(card *scard.Card, cardInfo *Card) {
ndefLength = int(allData[1])
ndefStart = 2
}
if ndefStart > 0 && len(allData) >= ndefStart+ndefLength+1 {
break
if ndefStart > 0 {
if len(allData) >= ndefStart+ndefLength+1 {
break
}
// Skip 0xFE check inside NDEF payload — binary payloads (CBOR) may contain 0xFE as data
continue
}
}

// Check for NDEF terminator (only when NDEF header not yet parsed)
for _, b := range pageData {
if b == 0xFE {
goto done
}
}
}
Expand Down Expand Up @@ -2223,12 +2242,9 @@ func WriteMultipleRecords(readerName string, records []NDEFRecord) error {

if isISO15693 {
// ISO 15693 (Type 5) tags: CC at block 0, NDEF at block 1
// CC format: E1 [version/access] [size/8] [features]
// - 0xE1: Magic number
// - 0x40: Version 1.0 (4), read/write access (0)
// - Size: Available memory / 8 (we'll use 0x40 = 512 bytes, conservative)
// - 0x00: No special features
cc := []byte{0xE1, 0x40, 0x40, 0x00}
// CC format: E1 [version/access] [MLEN] [features]
// E1=NDEF magic, 40=v1.0 read/write, 27=MLEN 39 ((39+1)×8=320 bytes for ICode SLIX2), 01=MBREAD
cc := []byte{0xE1, 0x40, 0x27, 0x01}

// Write CC at block 0
if err := writeNTAGPages(card, 0, cc); err != nil {
Expand Down
16 changes: 16 additions & 0 deletions internal/openprinttag/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,24 @@ func (i *Input) ToOpenPrintTag() (*OpenPrintTag, error) {
opt.Main.NominalNettoFullWeight = i.NominalWeight
opt.Main.FilamentDiameter = i.FilamentDiameter
opt.Main.Density = i.Density
opt.Main.GTIN = i.GTIN
opt.Main.BrandSpecificInstanceID = i.BrandSpecificInstID
opt.Main.BrandSpecificPackageID = i.BrandSpecificPkgID
opt.Main.ActualNettoFullWeight = i.ActualWeight
opt.Main.EmptyContainerWeight = i.SpoolWeight
opt.Main.MinPrintTemp = i.MinPrintTemp
opt.Main.MaxPrintTemp = i.MaxPrintTemp
opt.Main.PreheatTemp = i.PreheatTemp
opt.Main.MinBedTemp = i.MinBedTemp
opt.Main.MaxBedTemp = i.MaxBedTemp
opt.Main.MinChamberTemp = i.MinChamberTemp
opt.Main.ChamberTemp = i.ChamberTemp
opt.Main.ContainerWidth = i.ContainerWidth
opt.Main.ContainerOuterDiameter = i.ContainerOuterDiam
opt.Main.ContainerInnerDiameter = i.ContainerInnerDiam
opt.Main.ContainerHoleDiameter = i.ContainerHoleDiam
opt.Main.NominalFullLength = i.NominalFullLength
opt.Main.ActualFullLength = i.ActualFullLength
opt.Main.ManufacturedDate = i.ManufacturedDate
opt.Main.ExpirationDate = i.ExpirationDate

Expand Down
Loading