Skip to content

Commit 92dd585

Browse files
committed
Add native OpenPrintTag support for filament NFC tags
Implements the OpenPrintTag specification (https://specs.openprinttag.org) for reading and writing CBOR-encoded filament/material data on NFC tags. Features: - New internal/openprinttag package with CBOR encode/decode - UUIDv5 generation per spec (brand, material, package, instance) - Indefinite-length CBOR maps per spec requirement - MIME type: application/vnd.openprinttag - API support for reading/writing OpenPrintTag data - Web UI with OpenPrintTag display and write template Verified against Prusa's OpenPrintTag decoder and SimplyPrint tags.
1 parent ca974c2 commit 92dd585

File tree

9 files changed

+1622
-14
lines changed

9 files changed

+1622
-14
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ require (
1010
)
1111

1212
require (
13+
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
1314
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
1415
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
1516
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
1617
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
1718
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
1819
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
1920
github.com/go-stack/stack v1.8.0 // indirect
21+
github.com/google/uuid v1.6.0 // indirect
2022
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
23+
github.com/x448/float16 v0.8.4 // indirect
2124
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8
22
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ESu1goRp4B3fdhemWMMrS4g=
44
github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY=
5+
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
6+
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
57
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
68
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
79
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
@@ -18,6 +20,8 @@ github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sTho
1820
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
1921
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
2022
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
23+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
24+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
2125
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
2226
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
2327
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
@@ -30,6 +34,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:s
3034
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
3135
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
3236
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
37+
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
38+
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
3339
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
3440
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3541
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=

internal/api/http.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/SimplyPrint/nfc-agent/internal/core"
1212
"github.com/SimplyPrint/nfc-agent/internal/data"
1313
"github.com/SimplyPrint/nfc-agent/internal/logging"
14+
"github.com/SimplyPrint/nfc-agent/internal/openprinttag"
1415
"github.com/SimplyPrint/nfc-agent/internal/service"
1516
"github.com/SimplyPrint/nfc-agent/internal/web"
1617
)
@@ -207,9 +208,19 @@ func handleReaderCard(w http.ResponseWriter, r *http.Request, readerName string)
207208
})
208209
return
209210
}
211+
case "openprinttag":
212+
// Validate JSON structure for openprinttag
213+
var input openprinttag.Input
214+
if err := json.Unmarshal([]byte(req.Data), &input); err != nil {
215+
respondJSON(w, http.StatusBadRequest, map[string]string{
216+
"error": "invalid openprinttag JSON format: " + err.Error(),
217+
})
218+
return
219+
}
220+
dataBytes = []byte(req.Data)
210221
default:
211222
respondJSON(w, http.StatusBadRequest, map[string]string{
212-
"error": "dataType must be 'text', 'json', 'binary', or 'url'",
223+
"error": "dataType must be 'text', 'json', 'binary', 'url', or 'openprinttag'",
213224
})
214225
return
215226
}

internal/api/websocket.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/SimplyPrint/nfc-agent/internal/core"
1212
"github.com/SimplyPrint/nfc-agent/internal/data"
1313
"github.com/SimplyPrint/nfc-agent/internal/logging"
14+
"github.com/SimplyPrint/nfc-agent/internal/openprinttag"
1415
"github.com/gorilla/websocket"
1516
)
1617

@@ -329,8 +330,16 @@ func (c *WSClient) handleWriteCard(id string, payload json.RawMessage) {
329330
c.sendError(id, "invalid base64 data")
330331
return
331332
}
333+
case "openprinttag":
334+
// Validate JSON structure for openprinttag
335+
var input openprinttag.Input
336+
if err := json.Unmarshal([]byte(req.Data), &input); err != nil {
337+
c.sendError(id, "invalid openprinttag JSON format: "+err.Error())
338+
return
339+
}
340+
dataBytes = []byte(req.Data)
332341
default:
333-
c.sendError(id, "invalid dataType")
342+
c.sendError(id, "invalid dataType (must be 'text', 'json', 'binary', 'url', or 'openprinttag')")
334343
return
335344
}
336345

internal/core/card.go

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package core
22

33
import (
4+
"encoding/base64"
45
"encoding/hex"
6+
"encoding/json"
57
"fmt"
68
"time"
79

810
"github.com/SimplyPrint/nfc-agent/internal/logging"
11+
"github.com/SimplyPrint/nfc-agent/internal/openprinttag"
912
"github.com/ebfe/scard"
1013
)
1114

@@ -317,8 +320,19 @@ func WriteDataWithURL(readerName string, data []byte, dataType string, url strin
317320
ndefMessage = createNDEFMimeRecord("application/octet-stream", data)
318321
case "url":
319322
ndefMessage = createNDEFURIRecord(string(data))
323+
case "openprinttag":
324+
// Parse JSON input and encode to CBOR
325+
var input openprinttag.Input
326+
if err := json.Unmarshal(data, &input); err != nil {
327+
return fmt.Errorf("invalid openprinttag JSON: %w", err)
328+
}
329+
cborPayload, err := input.Encode()
330+
if err != nil {
331+
return fmt.Errorf("failed to encode openprinttag: %w", err)
332+
}
333+
ndefMessage = createNDEFMimeRecord(openprinttag.MIMEType, cborPayload)
320334
default:
321-
return fmt.Errorf("unsupported data type: %s (use 'json', 'text', 'binary', or 'url')", dataType)
335+
return fmt.Errorf("unsupported data type: %s (use 'json', 'text', 'binary', 'url', or 'openprinttag')", dataType)
322336
}
323337
}
324338

@@ -357,6 +371,9 @@ func createMultiRecordNDEF(url string, data []byte, dataType string) []byte {
357371
dataRecord = createNDEFRecordRaw(0x01, []byte("T"), textPayload, false, true)
358372
case "binary":
359373
dataRecord = createNDEFRecordRaw(0x02, []byte("application/octet-stream"), data, false, true)
374+
case "openprinttag":
375+
// Data is already CBOR-encoded at this point
376+
dataRecord = createNDEFRecordRaw(0x02, []byte(openprinttag.MIMEType), data, false, true)
360377
default:
361378
textPayload := []byte{0x02}
362379
textPayload = append(textPayload, []byte("en")...)
@@ -821,6 +838,43 @@ func readNDEFData(card *scard.Card, cardInfo *Card) {
821838
}
822839
}
823840

841+
// Check if we have complete NDEF message
842+
if len(allData) > 2 && allData[0] == 0x03 {
843+
var ndefLength, ndefStart int
844+
if allData[1] == 0xFF && len(allData) >= 4 {
845+
ndefLength = int(allData[2])<<8 | int(allData[3])
846+
ndefStart = 4
847+
} else if allData[1] != 0xFF {
848+
ndefLength = int(allData[1])
849+
ndefStart = 2
850+
}
851+
if ndefStart > 0 && len(allData) >= ndefStart+ndefLength+1 {
852+
break
853+
}
854+
}
855+
}
856+
} else if cardInfo.Type == "ISO 15693" {
857+
// ISO 15693 (Type 5) tags: NDEF starts at block 1 (after CC at block 0)
858+
maxBlocks := 79 // 80 blocks total, skip CC at block 0
859+
for blockNum := 1; blockNum < 1+maxBlocks; blockNum++ {
860+
blockData, err := readNTAGPage(card, blockNum)
861+
if err != nil {
862+
logging.Debug(logging.CatCard, "NDEF read failed", map[string]any{
863+
"block": blockNum,
864+
"error": err.Error(),
865+
})
866+
break
867+
}
868+
869+
allData = append(allData, blockData...)
870+
871+
// Check for NDEF terminator
872+
for _, b := range blockData {
873+
if b == 0xFE {
874+
goto done
875+
}
876+
}
877+
824878
// Check if we have complete NDEF message
825879
if len(allData) > 2 && allData[0] == 0x03 {
826880
var ndefLength, ndefStart int
@@ -981,6 +1035,19 @@ done:
9811035
if mimeType == "application/json" {
9821036
cardInfo.Data = string(payload)
9831037
cardInfo.DataType = "json"
1038+
} else if mimeType == openprinttag.MIMEType {
1039+
// OpenPrintTag format (application/vnd.openprinttag)
1040+
opt, err := openprinttag.Decode(payload)
1041+
if err == nil {
1042+
resp := opt.ToResponse()
1043+
jsonData, _ := json.Marshal(resp)
1044+
cardInfo.Data = string(jsonData)
1045+
cardInfo.DataType = "openprinttag"
1046+
} else {
1047+
// Fallback to binary if CBOR decode fails
1048+
cardInfo.Data = hex.EncodeToString(payload)
1049+
cardInfo.DataType = "binary"
1050+
}
9841051
} else if mimeType == "application/octet-stream" {
9851052
cardInfo.Data = hex.EncodeToString(payload)
9861053
cardInfo.DataType = "binary"
@@ -1324,8 +1391,10 @@ func RemovePassword(readerName string, password []byte) error {
13241391

13251392
// WriteMultipleRecords writes multiple NDEF records to a card
13261393
type NDEFRecord struct {
1327-
Type string `json:"type"` // "url", "text", "json", "binary"
1328-
Data string `json:"data"` // Data content (base64 for binary)
1394+
Type string `json:"type"` // "url", "text", "json", "binary", "mime"
1395+
Data string `json:"data"` // Data content
1396+
MimeType string `json:"mimeType,omitempty"` // For generic mime records (e.g., "application/vnd.openprinttag")
1397+
DataType string `json:"dataType,omitempty"` // "binary" for base64-encoded data
13291398
}
13301399

13311400
func WriteMultipleRecords(readerName string, records []NDEFRecord) error {
@@ -1345,6 +1414,14 @@ func WriteMultipleRecords(readerName string, records []NDEFRecord) error {
13451414
}
13461415
defer card.Disconnect(scard.LeaveCard)
13471416

1417+
// Get ATR to detect card type
1418+
status, err := card.Status()
1419+
if err != nil {
1420+
return fmt.Errorf("failed to get card status: %w", err)
1421+
}
1422+
atr := hex.EncodeToString(status.Atr)
1423+
isISO15693 := contains(atr, "03060b")
1424+
13481425
// Build multi-record NDEF message
13491426
var ndefRecords []byte
13501427
for i, rec := range records {
@@ -1366,12 +1443,28 @@ func WriteMultipleRecords(readerName string, records []NDEFRecord) error {
13661443
case "json":
13671444
recordBytes = createNDEFRecordRaw(0x02, []byte("application/json"), []byte(rec.Data), isFirst, isLast)
13681445
case "binary":
1369-
// Decode base64
1446+
// Decode hex
13701447
decoded, err := hex.DecodeString(rec.Data)
13711448
if err != nil {
13721449
return fmt.Errorf("invalid binary data in record %d: %w", i, err)
13731450
}
13741451
recordBytes = createNDEFRecordRaw(0x02, []byte("application/octet-stream"), decoded, isFirst, isLast)
1452+
case "mime":
1453+
if rec.MimeType == "" {
1454+
return fmt.Errorf("mimeType required for mime record type in record %d", i)
1455+
}
1456+
var payload []byte
1457+
if rec.DataType == "binary" {
1458+
// Data is base64 encoded
1459+
decoded, err := base64.StdEncoding.DecodeString(rec.Data)
1460+
if err != nil {
1461+
return fmt.Errorf("invalid base64 data in mime record %d: %w", i, err)
1462+
}
1463+
payload = decoded
1464+
} else {
1465+
payload = []byte(rec.Data)
1466+
}
1467+
recordBytes = createNDEFRecordRaw(0x02, []byte(rec.MimeType), payload, isFirst, isLast)
13751468
default:
13761469
return fmt.Errorf("unsupported record type: %s", rec.Type)
13771470
}
@@ -1390,8 +1483,29 @@ func WriteMultipleRecords(readerName string, records []NDEFRecord) error {
13901483
tlv = append(tlv, ndefRecords...)
13911484
tlv = append(tlv, 0xFE)
13921485

1393-
if err := writeNTAGPages(card, 4, tlv); err != nil {
1394-
return fmt.Errorf("failed to write NDEF records: %w", err)
1486+
if isISO15693 {
1487+
// ISO 15693 (Type 5) tags: CC at block 0, NDEF at block 1
1488+
// CC format: E1 [version/access] [size/8] [features]
1489+
// - 0xE1: Magic number
1490+
// - 0x40: Version 1.0 (4), read/write access (0)
1491+
// - Size: Available memory / 8 (we'll use 0x40 = 512 bytes, conservative)
1492+
// - 0x00: No special features
1493+
cc := []byte{0xE1, 0x40, 0x40, 0x00}
1494+
1495+
// Write CC at block 0
1496+
if err := writeNTAGPages(card, 0, cc); err != nil {
1497+
return fmt.Errorf("failed to write CC block: %w", err)
1498+
}
1499+
1500+
// Write NDEF TLV starting at block 1
1501+
if err := writeNTAGPages(card, 1, tlv); err != nil {
1502+
return fmt.Errorf("failed to write NDEF records: %w", err)
1503+
}
1504+
} else {
1505+
// NTAG (Type 2) tags: NDEF at page 4
1506+
if err := writeNTAGPages(card, 4, tlv); err != nil {
1507+
return fmt.Errorf("failed to write NDEF records: %w", err)
1508+
}
13951509
}
13961510

13971511
return nil

0 commit comments

Comments
 (0)