diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..21aada0 --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,81 @@ +# Publishes the manual in documentation-site/ to 76cb/OpenTag-Station-Docs, whose own workflow +# builds and deploys GitHub Pages. Runs on every merge to main that changes the manual, pre-release +# versions included. One-time setup (deploy key and the DOCS_DEPLOY_KEY secret) is described in +# documentation-site/README.md under "Publish". +name: Publish manual + +on: + push: + branches: [main] + paths: + - 'documentation-site/**' + - VERSION + - tools/export_docs.py + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + # The same checks as the CI documentation job. + - run: python -m pip install -r documentation-site/requirements.txt + - run: python tools/check_public_privacy.py + - run: python tools/check_hardware_docs.py + - run: python tools/export_docs.py --check + - run: python tools/check_committed_images.py + - run: python documentation-site/scripts/check_images.py + - run: python -m mkdocs build --strict -f documentation-site/mkdocs.yml + - run: python documentation-site/scripts/validate.py documentation-site/site + - run: python tools/export_docs.py --output "$RUNNER_TEMP/export" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: 76cb/OpenTag-Station-Docs + ref: main + path: docs-repo + fetch-depth: 0 + ssh-key: ${{ secrets.DOCS_DEPLOY_KEY }} + persist-credentials: true + # --delete removes manual files that no longer exist; the Docs repository keeps its own + # .git, deploy workflow and .gitignore. + - run: rsync -a --delete --exclude=.git --exclude=.github --exclude=.gitignore "$RUNNER_TEMP/export/" docs-repo/ + - id: changes + working-directory: docs-repo + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "Manual already up to date" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git status --short + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + # The Docs repository's own checks, so a broken export never reaches its main. + - if: steps.changes.outputs.changed == 'true' + working-directory: docs-repo + run: | + python -m pip install -r requirements.txt + python scripts/validate.py docs + python scripts/check_images.py + python -m mkdocs build --strict + python scripts/validate.py site + rm -rf site + - if: steps.changes.outputs.changed == 'true' + working-directory: docs-repo + env: + SOURCE_SHA: ${{ github.sha }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m "Manual for $(cat ../VERSION) from 76cb/OpenTag-Station@${SOURCE_SHA::7}" + git push origin HEAD:main diff --git a/CHANGELOG.md b/CHANGELOG.md index 034a28a..05da4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ What changed in each version of OpenTag Station, newest first. +## 1.0.1-beta.5 + +Pre-release for testers. First step of the tag-support roadmap (#93): the reader now also looks +for ISO 14443A (NFC-A) tags and names them. Nothing is read from them yet. Not run on a WT32 by +the author; see "Needs a station" below. + +**NFC** +- **NTAG, MIFARE and other NFC-A tags are found and named.** Each poll cycle still asks for + NFC-V (ISO 15693) tags first, exactly as before. Only when that finds nothing, and no NFC-V tag + is being tracked, does the same cycle also run an ISO 14443A detection (REQA and anticollision). + So an OpenPrintTag is found and lost on the same polls as in 1.0.1-beta.4; the only added cost + is one short NFC-A request on cycles with no NFC-V tag. +- An NFC-A tag is reported with its NFCID1, SAK and ATQA and a kind derived from the SAK (NXP + AN10833): `type2` (NTAG, MIFARE Ultralight), `mifare_classic`, `type4` or `other`. Nothing on + the tag is read, it is not looked up in Spoolman, and the automatic weigh does not start for it. +- Touchscreen: Home shows "TAG FOUND" / "Tag type not supported yet" with the kind; the Tag page + shows the kind and the ID; the NFC READER setup step says "Tag found. Its type is not supported + yet." Browser: the Dashboard shows "Tag type not supported yet" / "Not supported yet", Manage + tag names the kind, and "Multiple NFC-A tags detected" when there are several. +- API: `GET /api/v1/nfc` gains `technology` (`nfcv`, `nfca` or null) and, for an NFC-A tag, the + state `unsupported_technology`, lifecycle `UNSUPPORTED_TYPE`, the NFCID1 as `uid` and an `nfca` + object (`nfcid1`, `length`, `sak`, `atqa`, `kind`, `description`). `inventory.technology` now + names the technology of the tag on the reader. +- The serial diagnostic line adds `technology=` and, for an NFC-A tag, its NFCID1, SAK, ATQA and + kind. +- The production NFC check (`tools/check_production_nfc.py`) permits exactly three NFC-A RFAL + calls (mode switch, technology detection, collision resolution) in the I2C driver only, and + the ELF check now also fails if a Type 2 read or write, ISO-DEP or NFC-DEP routine is linked. + +**Needs a station** +- Detection and removal timing of an OpenPrintTag before and after this change, and the + detection of an NTAG and a MIFARE Classic tag, have not been measured by the author. The + acceptance steps are in the pull request for this release and in issue #93. + ## 1.0.1-beta.4 Pre-release for testers. The Community catalog is gone, and the leftovers from the 1.1 cleanup diff --git a/README.md b/README.md index 97dd4ea..7e1c91b 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,8 @@ The manual's source is in [`documentation-site/`](documentation-site/README.md). | Tag family | Spools and tags | Plan | |---|---|---| -| MIFARE Classic (ISO 14443A) | Bambu Lab, Creality CFS, Anycubic ACE, QIDI Box | Read first. Write where the format allows it. | -| NTAG (ISO 14443A) | OpenSpool, plain NTAG213 / 215 / 216 stickers | Read and write | +| MIFARE Classic (ISO 14443A) | Bambu Lab, Creality CFS, Anycubic ACE, QIDI Box | Detected and named since 1.0.1-beta.5. Read first. Write where the format allows it. | +| NTAG (ISO 14443A) | OpenSpool, plain NTAG213 / 215 / 216 stickers | Detected and named since 1.0.1-beta.5. Read and write | | Other ISO 15693 chips | Tags that are not SLIX2 | Write support. They are read today. | | ISO 14443B, FeliCa | Nothing on spools that the project knows of | Added if a spool system adopts them | diff --git a/VERSION b/VERSION index 2f704ed..11d6def 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.1-beta.4 +1.0.1-beta.5 diff --git a/docs/architecture.md b/docs/architecture.md index d2e052f..eac89ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,12 @@ and its grace period have ended (`src/nfc/worker_startup.hpp`). ## Data flow **Read a tag.** `BackendWorker::poll_nfc()` → `NfcWorker::poll()` → `ReadOnlyService`: inventory, -two full reads that must match, `Codec::decode`. The result is handed to `StationWorkflow`, which +two full reads that must match, `Codec::decode`. Each poll cycle (`src/nfc/poll_schedule.hpp`, +every 500 ms) runs the NFC-V inventory first, as it always has; only when that finds nothing and no +NFC-V tag is being tracked does the same field window also run an ISO 14443A detection +(`I2cReader::inventory_nfca()`: mode switch, REQA, anticollision). An NFC-A tag is reported as +`ReadState::unsupported_technology` with its NFCID1, SAK and ATQA (`nfc::nfca::Identity`); nothing +is read from it and it is not handed to the workflow (#93). The result is handed to `StationWorkflow`, which uses `SpoolIdentityResolver` and `SpoolmanAdapter` to find the spool. `StationWorkflow` stages: `awaiting_spool`, `resolving_spool`, `spool_resolution_unavailable`, `spool_not_found`, `spool_selection_required`, `spool_ready`, `assignment_complete`. `waiting_for_stable_weight` is diff --git a/docs/images/assignment.png b/docs/images/assignment.png index 05d7ad2..e8528c4 100644 Binary files a/docs/images/assignment.png and b/docs/images/assignment.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png index 6d90103..d433f2a 100644 Binary files a/docs/images/dashboard.png and b/docs/images/dashboard.png differ diff --git a/docs/images/provenance.json b/docs/images/provenance.json index 1b1272a..cc75402 100644 --- a/docs/images/provenance.json +++ b/docs/images/provenance.json @@ -1,7 +1,7 @@ { "privacy_checked_before_capture": true, "browser_captures": 80, - "touch_layout_fixtures": 42, + "touch_layout_fixtures": 43, "sources": { "tools/product_journeys.cjs": "3675d1bda218f4932fde3e8b232b5aac75ae1bb196b85fe92705c53e3b15e9ab", "tools/product_review.py": "376adaa8e3c6255ad7bf4e73cc35e53e7f9ccb6acdc8610ee77ccf39a3a6cffd", @@ -9,19 +9,19 @@ "tools/capture_product_review.cjs": "23d6c11b616ac2c271e980410b162d71cf69b72575a452926794bf4838fe22d0", "tools/render_touch_review.py": "8cb523c706690f681f2dc887a726b21e99de020a4d8ec00a9708982073daeeac", "tools/check_public_privacy.py": "b5b3fc8482b8e9692e1524002b911cbcace8c213fd180454469f7c4963ebacd9", - "src/web/web_assets.cpp": "cf10ec9b702f74a4b06ab9e7edc2746d1bfe1d2aa378d38991b61d6711bf1342", + "src/web/web_assets.cpp": "b565808d7de2cb95615d7ae49db614e4303aec28e91f5ae3ab8c61fa3eefb84c", "src/web/writer_assets.cpp": "18ca31fb929c43f13138fbd4839e4ed60dc10e342cc2c0dbabb6e95d4c8a48c7", "src/web/writer_layout.inc": "c8835b92d1951269254fec884fe210fc725060528e5505e18a207fbe23d67f98", "src/ui/product_layout.hpp": "4b3139a7f8e95142f53b72a4c9f4ac05d103426a29210885823b30b3171c8979", - "src/ui/tag_flow.hpp": "e960c1ef2412f9c6c9b64c6c41801045bc640323645df17bf2978da370942f1f", + "src/ui/tag_flow.hpp": "44647a07d972e510c8363d115382c4e8fbdf3c1ef48686cdae0f8a8aee66d3dd", "src/ui/touch_input.hpp": "80fa15c718726adbbd3c35173479826f2c1bc0b8ff9a63133e737b17b3998eed", - "test/test_touch_flow/fixtures.hpp": "b9a846f95e3824b2dd7e416935695d672fa57a4cbf573889a6d9eb9a44832247" + "test/test_touch_flow/fixtures.hpp": "e895bf1032a0ddb04a71178fdc8ac3169fb3457274c376ff3e5bbf333f4efe1a" }, "images": { - "docs/images/dashboard.png": "85e32b85fd99ced2cc3abbfcbf2a5628f52233c39494780ec4fa5f828d6f4455", - "docs/images/weigh.png": "fc9770fe4c4d3ed408b000855673fa64dd3f54ebbc094194869044961cbf2f6f", - "docs/images/assignment.png": "d8607ee2bbd563e83d3977f59a104f03af97e2d66db878d2665fcdf014ed4f9a", - "docs/images/tag-management.png": "d62ec05ca6a84589217054bb333af84ac59fd6987d553c7305b977fce4448356", + "docs/images/dashboard.png": "5a532d455b3d67bd8199043b2b11bb1deba036ae776484082fcac62fd14aace5", + "docs/images/weigh.png": "2a2b2b70419ec4038aa37ef2ad630ef77f13cf2230f444a684805f1750df6bc4", + "docs/images/assignment.png": "60dcdb03a77d12639f0964a8fc2d26d441a9e4c4aa7fb51ae7f7af53387a1362", + "docs/images/tag-management.png": "41772e6e29be1725052d1df0e352fa874850da612c915ed112d030536057df1c", "docs/images/wt32-home.png": "c768e1b3be568f2563bdf0a368f2cf5b4aab2e2e6efb8a98c8a996ff4dcdc7e3" } } diff --git a/docs/images/tag-management.png b/docs/images/tag-management.png index 7fc6ddb..75c0f8c 100644 Binary files a/docs/images/tag-management.png and b/docs/images/tag-management.png differ diff --git a/docs/images/weigh.png b/docs/images/weigh.png index 72be2ab..4585cca 100644 Binary files a/docs/images/weigh.png and b/docs/images/weigh.png differ diff --git a/documentation-site/README.md b/documentation-site/README.md index 47b1fa9..39705a3 100644 --- a/documentation-site/README.md +++ b/documentation-site/README.md @@ -50,12 +50,31 @@ CI runs the same checks on every pull request. ## Publish +A merge to `main` that changes `documentation-site/`, `VERSION` or `tools/export_docs.py` publishes +the manual automatically: the workflow `.github/workflows/docs-publish.yml` ("Publish manual") runs +the checks above, exports the manual, syncs the export over a checkout of +`76cb/OpenTag-Station-Docs` (removing files that no longer exist there, keeping that repository's +`.git`, `.github` and `.gitignore`), runs the Docs repository's own checks, and pushes a commit +`Manual for from 76cb/OpenTag-Station@` to its `main`. That repository's workflow +then builds and deploys GitHub Pages. Pre-release versions are published too. When nothing changed, +the workflow prints `Manual already up to date` and stops. + +To inspect the export locally: + ```sh python tools/export_docs.py --output ``` -Copy the exported files over a clone of `76cb/OpenTag-Station-Docs` (keep its `.git` directory), -commit and push. That repository's workflow runs the same checks and deploys to GitHub Pages. The export stamps the firmware commit and version into `firmware.json`. +One-time setup, needed before the first run: + +1. Generate a key pair: `ssh-keygen -t ed25519 -N "" -f docs-deploy-key`. +2. On `76cb/OpenTag-Station-Docs`, add `docs-deploy-key.pub` as a deploy key with write access + (Settings → Deploy keys → Add deploy key, tick **Allow write access**). +3. On `76cb/OpenTag-Station`, add the private key `docs-deploy-key` as the repository secret + `DOCS_DEPLOY_KEY` (Settings → Secrets and variables → Actions → New repository secret). +4. Run the workflow once by hand: Actions → **Publish manual** → **Run workflow**. This first run + brings the Docs repository up to date. + The manual is under the firmware's [PolyForm Noncommercial license](LICENSE.md). diff --git a/documentation-site/docs/assets/images/browser/assignment.png b/documentation-site/docs/assets/images/browser/assignment.png index 05d7ad2..e8528c4 100644 Binary files a/documentation-site/docs/assets/images/browser/assignment.png and b/documentation-site/docs/assets/images/browser/assignment.png differ diff --git a/documentation-site/docs/assets/images/browser/dashboard.png b/documentation-site/docs/assets/images/browser/dashboard.png index 6d90103..d433f2a 100644 Binary files a/documentation-site/docs/assets/images/browser/dashboard.png and b/documentation-site/docs/assets/images/browser/dashboard.png differ diff --git a/documentation-site/docs/assets/images/browser/tag-management.png b/documentation-site/docs/assets/images/browser/tag-management.png index 7fc6ddb..75c0f8c 100644 Binary files a/documentation-site/docs/assets/images/browser/tag-management.png and b/documentation-site/docs/assets/images/browser/tag-management.png differ diff --git a/documentation-site/docs/assets/images/browser/weigh.png b/documentation-site/docs/assets/images/browser/weigh.png index 72be2ab..4585cca 100644 Binary files a/documentation-site/docs/assets/images/browser/weigh.png and b/documentation-site/docs/assets/images/browser/weigh.png differ diff --git a/documentation-site/docs/contributing/release.md b/documentation-site/docs/contributing/release.md index a68eb91..16ce295 100644 --- a/documentation-site/docs/contributing/release.md +++ b/documentation-site/docs/contributing/release.md @@ -5,7 +5,7 @@ publishes it, then updates the USB installer from the same files. Before a release, run the [hardware acceptance](testing.md#hardware-acceptance) checks on a real station. -The current version is `1.0.1-beta.4`. +The current version is `1.0.1-beta.5`. ## Version names @@ -55,13 +55,21 @@ workflow); when the release already exists it only redeploys the installer from ## Publish the manual -The manual is not published automatically. It is exported from the firmware repository and -committed to , which deploys it. +The manual is published automatically. A merge to `main` that changes `documentation-site/`, +`VERSION` or `tools/export_docs.py` runs the workflow **Publish manual** +(`.github/workflows/docs-publish.yml`). It checks the manual, exports it, and pushes the export to +, which deploys it. Pre-release versions are published +too. The commit there is named `Manual for from 76cb/OpenTag-Station@`; when +nothing changed the workflow prints `Manual already up to date`. + +The export copies `documentation-site/`, records the firmware version and commit in `firmware.json`, +and points links to repository files at that commit. To look at it without publishing: ```sh python tools/export_docs.py --output ``` -The export copies `documentation-site/`, records the firmware version and commit, and points -links to repository files at that commit. Copy the result into a clone of the Docs repository -and commit it there. +The workflow needs a deploy key once; the setup is in the +[documentation-site README](https://github.com/76cb/OpenTag-Station/blob/main/documentation-site/README.md#publish). +Until the secret `DOCS_DEPLOY_KEY` exists, the workflow fails at the Docs repository checkout and +nothing is published. diff --git a/documentation-site/docs/daily-use/identify.md b/documentation-site/docs/daily-use/identify.md index 4b90dac..39ef6e1 100644 --- a/documentation-site/docs/daily-use/identify.md +++ b/documentation-site/docs/daily-use/identify.md @@ -27,10 +27,10 @@ Until then tags are ignored; see [Tag is not detected](../troubleshooting/tag.md | Blank tag | "COMPATIBLE BLANK TAG", "Ready to write", "Open Manage tag to choose a spool". The first button reads **WRITE TAG**. | "Blank tag", "Ready to write". The first button reads **Write tag**. | [Write the tag](manage-tags.md). | | Blank tag, or tag with other data, that Spoolman links to a spool | The spool's name, "Spool #N" and "NNN g remaining", with the normal **WEIGH** button | The spool's name, "Spool #N", its weight and "Linked to Spoolman", with the normal **Weigh** button | Weigh it, or [write the tag](manage-tags.md) to put the filament data on it. See [below](#tags-spoolman-links-to-a-spool). | | Tag the station cannot use | On the **Tag** page: "This tag can't be used as it is", a reason, and "Use a blank NXP ICODE SLIX2 tag." | Dashboard: "Reading spool…" and "Tag needs attention". In **Manage tag**: "Unsupported" and "OpenPrintTag decode failed / unsupported" | Use a blank NXP ICODE SLIX2 tag. See [Which tags work](../openprinttag/supported-tags.md). | -| Two or more tags on the reader | Home keeps showing "Place a spool". The **Tag** page shows "Two tags on the reader. Remove one." | In **Manage tag**: "Multiple NFC-V tags detected" and "Remove extra tags and present exactly one tag." | Leave one tag on the reader. | +| Two or more tags on the reader | Home keeps showing "Place a spool". The **Tag** page shows "Two tags on the reader. Remove one." | In **Manage tag**: "Multiple NFC-V tags detected" (or "Multiple NFC-A tags detected") and "Remove extra tags and present exactly one tag." | Leave one tag on the reader. | +| NTAG or MIFARE tag (NFC-A) | Home: "TAG FOUND", "Tag type not supported yet" and the tag's kind, for example "NTAG or other Type 2 tag (NFC-A)". The **Tag** page shows the same with the tag's ID. | Dashboard: "Tag type not supported yet", "Not supported yet". In **Manage tag**: "Tag type not supported yet" with the tag's kind. | Nothing is read from such a tag yet. Use an OpenPrintTag on NXP ICODE SLIX2. See [Which tags work](../openprinttag/supported-tags.md). | -Other kinds of NFC tag (for example NTAG or MIFARE) are not seen at all. -The screen keeps showing "Place a spool". +NFC-B and NFC-F tags are not seen at all. The screen keeps showing "Place a spool". ![Browser Dashboard with a linked spool](../assets/images/browser/dashboard.png) @@ -95,6 +95,7 @@ A similar name is not enough when you own several spools of the same filament. | What you see | What it means | What to do | |---|---|---| -| The screen stays on "Place a spool" with a tag on the reader | The tag is not an NFC-V tag, is too far from the reader, or the reader has not started yet | See [Tag is not detected](../troubleshooting/tag.md). | +| The screen stays on "Place a spool" with a tag on the reader | The tag is neither NFC-V nor NFC-A, is too far from the reader, or the reader has not started yet | See [Tag is not detected](../troubleshooting/tag.md). | +| "Tag type not supported yet" | The tag is an NFC-A tag (NTAG, MIFARE). The station names it but does not read it yet. | Use an OpenPrintTag on NXP ICODE SLIX2. | | "This tag can't be used as it is" | The tag holds data that is neither blank nor OpenPrintTag | Use a blank NXP ICODE SLIX2 tag. The station does not erase unknown data. | | "Spoolman unavailable; spool not looked up" | The station could not reach Spoolman | See [Spoolman problems](../troubleshooting/spoolman.md). You do not need to lift the spool: the station looks it up again by itself once Spoolman is back. | diff --git a/documentation-site/docs/getting-started/overview.md b/documentation-site/docs/getting-started/overview.md index 7ac0e8c..59a45ec 100644 --- a/documentation-site/docs/getting-started/overview.md +++ b/documentation-site/docs/getting-started/overview.md @@ -33,7 +33,7 @@ keep its own inventory. | The station hardware | A WT32-SC01 Plus touchscreen board, an NFC reader, a load cell with its amplifier, and a platform you build. See the [parts list](../hardware/bill-of-materials.md). | | Spoolman | Your own running Spoolman server that the station can reach on your network. The station is not useful without it. Weights are saved to Spoolman 0.26 and 0.27; see [Connect Spoolman](../inventory/spoolman.md). | | 2.4 GHz Wi-Fi | The station cannot join 5 GHz-only or WPA3-only networks. | -| Blank tags | NXP ICODE SLIX2 (NFC-V / ISO 15693). Other tag types are not seen at all. See [Which tags work](../openprinttag/supported-tags.md). | +| Blank tags | NXP ICODE SLIX2 (NFC-V / ISO 15693). NTAG and MIFARE tags are found but not read yet; other tag types are not seen at all. See [Which tags work](../openprinttag/supported-tags.md). | | A desktop computer with Chrome or Edge | Only for installing the firmware over USB. Afterwards any browser on your network works. | | A known weight | To calibrate the scale once. | | FilaBridge and a printer it supports | Optional. Only needed to assign spools to toolheads. Tested with a Prusa XL. | diff --git a/documentation-site/docs/openprinttag/supported-tags.md b/documentation-site/docs/openprinttag/supported-tags.md index ba33820..f032cd9 100644 --- a/documentation-site/docs/openprinttag/supported-tags.md +++ b/documentation-site/docs/openprinttag/supported-tags.md @@ -2,8 +2,8 @@ The station works with one kind of tag: **NXP ICODE SLIX2**, blank. Buy the right kind and every other page of this manual applies. -The reader looks only for NFC-V (ISO 15693) tags: other NFC-V tags are read but cannot be written, -and NTAG or MIFARE tags are not seen at all. +The reader reads only NFC-V (ISO 15693) tags: other NFC-V tags are read but cannot be written. +NTAG and MIFARE tags (NFC-A, ISO 14443A) are found and named, but nothing on them is read yet. ## The supported tag @@ -38,7 +38,8 @@ and it cannot erase them: **CLEAR / REUSE** only works on tags that hold OpenPri | Tag | What happens | |---|---| -| NTAG213 / 215 / 216, MIFARE and other NFC-A, NFC-B or NFC-F tags | The reader does not see them at all. The screen keeps showing `PLACE A TAG`. | +| NTAG213 / 215 / 216, MIFARE Classic and other NFC-A (ISO 14443A) tags | Found and named, not read: the touchscreen says `Tag type not supported yet` with the tag's kind and ID, the browser `Not supported yet`. Reading them is planned (NTAG: issue #95, MIFARE Classic: issue #94). | +| NFC-B or NFC-F tags | The reader does not see them at all. The screen keeps showing `PLACE A TAG`. | | ISO 15693 tags that are not SLIX2 (other maker, other memory size) | The station reads them, so OpenPrintTag data on them is shown. A blank one shows `This tag can't be used as it is`. Writing and clearing are refused with `Incompatible tag: this release approves NXP 80 x 4-byte SLIX2 only`. | | SLIX2 with phone formatting, a web link or other data | `This tag can't be used as it is` | | SLIX2 with locked blocks | `Tag has protected blocks` | diff --git a/documentation-site/docs/reference/api.md b/documentation-site/docs/reference/api.md index d9337e0..6a07b8c 100644 --- a/documentation-site/docs/reference/api.md +++ b/documentation-site/docs/reference/api.md @@ -142,7 +142,7 @@ Rules and tolerances: [Scale behaviour](scale.md) and [Spoolman integration](spo | Method and path | Auth | Body | Notes | |---|---|---|---| -| `GET /nfc` | – | – | Reader and tag state. | +| `GET /nfc` | – | – | Reader and tag state. `state` is `deferred`, `initializing`, `idle`, `stabilizing`, `reading`, `openprinttag`, `blank_compatible`, `unsupported`, `unsupported_technology`, `multiple` or `error`. `technology` is `nfcv`, `nfca` or null. For an NFC-A tag (`unsupported_technology`, lifecycle `UNSUPPORTED_TYPE`) `uid` is its NFCID1 and `nfca` holds `nfcid1`, `length`, `sak`, `atqa`, `kind` (`type2`, `mifare_classic`, `type4`, `other`) and `description`; nothing is read from it. | | `GET /nfc/tag` | – | – | The same state under `tag`. | | `GET /spool` | – | – | `workflow`: `tag_lifecycle`, `stage`, `spool_generation`, `printer_revision`, `tag`, `spoolman`, `filabridge`, `candidates[]`, `spool`, `reconciliation`, optional `error` | | `POST /spool/confirm` | Token | ≤ 512 | Exactly `spool_generation` (> 0), `spool_id` (> 0) and `confirmed: true`. Confirms one of `candidates[]` as the spool for the tag on the station. Does not need a weight on the scale; fails with "Spool changed; refresh before confirming" if the tag changed. | diff --git a/documentation-site/docs/reference/faq.md b/documentation-site/docs/reference/faq.md index d4ff5ee..ead5065 100644 --- a/documentation-site/docs/reference/faq.md +++ b/documentation-site/docs/reference/faq.md @@ -13,9 +13,10 @@ The station only writes to a tag that is completely blank or already holds OpenP A tag with a web link or other phone-written data shows `This tag can't be used as it is` and `Use a blank NXP ICODE SLIX2 tag.` The station cannot erase it for you. -**Why does the station ignore my NTAG or MIFARE tag?** -The reader only talks to NFC-V tags. Other types are not seen at all, so the touchscreen keeps -showing `PLACE A TAG`. +**Why does the station not read my NTAG or MIFARE tag?** +The reader finds NFC-A tags (NTAG, MIFARE) and names them, `Tag type not supported yet`, but reads +only NFC-V tags. Reading NTAG and MIFARE Classic spool tags is planned (issues #95 and #94). +NFC-B and NFC-F tags are not seen at all, so the touchscreen keeps showing `PLACE A TAG`. **In the NFC READER setup step the reader says it is starting and never gets ready. Why?** The reader starts only after the station has joined your Wi-Fi and the setup network has closed. diff --git a/documentation-site/docs/reference/release-notes.md b/documentation-site/docs/reference/release-notes.md index 8edf238..576e018 100644 --- a/documentation-site/docs/reference/release-notes.md +++ b/documentation-site/docs/reference/release-notes.md @@ -2,6 +2,40 @@ What changed in each version of OpenTag Station, newest first. +## 1.0.1-beta.5 + +Pre-release for testers. First step of the tag-support roadmap (#93): the reader now also looks +for ISO 14443A (NFC-A) tags and names them. Nothing is read from them yet. Not run on a WT32 by +the author; see "Needs a station" below. + +**NFC** +- **NTAG, MIFARE and other NFC-A tags are found and named.** Each poll cycle still asks for + NFC-V (ISO 15693) tags first, exactly as before. Only when that finds nothing, and no NFC-V tag + is being tracked, does the same cycle also run an ISO 14443A detection (REQA and anticollision). + So an OpenPrintTag is found and lost on the same polls as in 1.0.1-beta.4; the only added cost + is one short NFC-A request on cycles with no NFC-V tag. +- An NFC-A tag is reported with its NFCID1, SAK and ATQA and a kind derived from the SAK (NXP + AN10833): `type2` (NTAG, MIFARE Ultralight), `mifare_classic`, `type4` or `other`. Nothing on + the tag is read, it is not looked up in Spoolman, and the automatic weigh does not start for it. +- Touchscreen: Home shows "TAG FOUND" / "Tag type not supported yet" with the kind; the Tag page + shows the kind and the ID; the NFC READER setup step says "Tag found. Its type is not supported + yet." Browser: the Dashboard shows "Tag type not supported yet" / "Not supported yet", Manage + tag names the kind, and "Multiple NFC-A tags detected" when there are several. +- API: `GET /api/v1/nfc` gains `technology` (`nfcv`, `nfca` or null) and, for an NFC-A tag, the + state `unsupported_technology`, lifecycle `UNSUPPORTED_TYPE`, the NFCID1 as `uid` and an `nfca` + object (`nfcid1`, `length`, `sak`, `atqa`, `kind`, `description`). `inventory.technology` now + names the technology of the tag on the reader. +- The serial diagnostic line adds `technology=` and, for an NFC-A tag, its NFCID1, SAK, ATQA and + kind. +- The production NFC check (`tools/check_production_nfc.py`) permits exactly three NFC-A RFAL + calls (mode switch, technology detection, collision resolution) in the I2C driver only, and + the ELF check now also fails if a Type 2 read or write, ISO-DEP or NFC-DEP routine is linked. + +**Needs a station** +- Detection and removal timing of an OpenPrintTag before and after this change, and the + detection of an NTAG and a MIFARE Classic tag, have not been measured by the author. The + acceptance steps are in the pull request for this release and in issue #93. + ## 1.0.1-beta.4 Pre-release for testers. The Community catalog is gone, and the leftovers from the 1.1 cleanup diff --git a/documentation-site/docs/troubleshooting/tag.md b/documentation-site/docs/troubleshooting/tag.md index ef7c738..bf35046 100644 --- a/documentation-site/docs/troubleshooting/tag.md +++ b/documentation-site/docs/troubleshooting/tag.md @@ -16,7 +16,8 @@ In the browser, **Manage tag** shows the reader's status at the top. | NFC READER step: `NFC reader starting...` (browser: `NFC deferred: provisioning`) | The reader has not started yet. It starts only once the station is connected to your Wi-Fi. | See [The reader waits for Wi-Fi](#the-reader-waits-for-wi-fi). | | NFC READER step: `NFC reader problem. Check the NFC wiring.` (browser: `NFC reader error`) | The reader module does not answer, or reading the tag on it failed. The station tries again every 5 seconds. | Remove the tag. If the message stays, see [The reader does not answer](#the-reader-does-not-answer). | | `PLACE A TAG` although a tag is on the reader | The reader is not running, or it works but sees no tag: wrong tag type, tag too far away, or metal in between. | Check the NFC READER step first. If it says the reader is ready, see [The reader is ready but sees no tag](#the-reader-is-ready-but-sees-no-tag). | -| `Two tags on the reader. Remove one.` on **Manage tag** (NFC READER step: `More than one tag. Use one tag at a time.`; browser: `Multiple NFC-V tags detected`) | Two or more tags are in range. | Remove the others, including tagged spools lying next to the station. | +| `Tag type not supported yet` with a tag kind such as `NTAG or other Type 2 tag (NFC-A)` (NFC READER step: `Tag found. Its type is not supported yet.`; browser: `Not supported yet`) | The tag is an NFC-A (ISO 14443A) tag. The reader sees it, but this release reads nothing from it. | Use an OpenPrintTag on NXP ICODE SLIX2. See [Which tags work](../openprinttag/supported-tags.md). | +| `Two tags on the reader. Remove one.` on **Manage tag** (NFC READER step: `More than one tag. Use one tag at a time.`; browser: `Multiple NFC-V tags detected` or `Multiple NFC-A tags detected`) | Two or more tags are in range. | Remove the others, including tagged spools lying next to the station. | | `This tag can't be used as it is` | The tag is an NFC-V tag, but it is not blank SLIX2 and holds no OpenPrintTag data the station can read. | Use a blank SLIX2 tag. See [Which tags work](../openprinttag/supported-tags.md). | | "Reading tag" with the spinner for a long time, or the tag appears and disappears | The tag is at the edge of the reader's range, or more than one tag is in range. | Remove other tags. Move the tag closer to the antenna and keep it still. | | The tag is read, but **Manage tag** says "Spoolman is not reachable. The tag's link cannot be checked right now." (browser: "Link not checked") | The tag is fine; the station cannot reach Spoolman to find out which spool it belongs to. | Fix Spoolman: [Spoolman problems](spoolman.md). Leave the spool where it is; the station looks it up again by itself, usually within about 30 seconds after Spoolman is back. | @@ -52,9 +53,9 @@ step **NFC READER**. A working reader shows `NFC reader ready. Place a tag on it ## The reader is ready but sees no tag -1. **Check the tag type.** Only NFC-V (ISO 15693) tags are seen, and only NXP ICODE SLIX2 can be written. - NTAG and MIFARE stickers, the common phone tags, are invisible to this reader: - the screen does not change at all. +1. **Check the tag type.** Only NFC-V (ISO 15693) tags are read, and only NXP ICODE SLIX2 can be written. + NTAG and MIFARE stickers, the common phone tags, are found and named (`Tag type not supported yet`) + but not read. NFC-B and NFC-F tags are invisible to this reader: the screen does not change at all. 2. **Move the tag.** Hold it flat against the antenna on the NFC module, then try the position it has on the spool. 3. **Remove metal.** The load cell, screws or a metal plate near the antenna or the tag diff --git a/documentation-site/firmware.json b/documentation-site/firmware.json index 533cff9..be2e930 100644 --- a/documentation-site/firmware.json +++ b/documentation-site/firmware.json @@ -1,5 +1,5 @@ { - "version": "1.0.1-beta.4", + "version": "1.0.1-beta.5", "source_repository": "76cb/OpenTag-Station", "source_ref": "main", "acceptance": "pending" diff --git a/documentation-site/image-provenance.json b/documentation-site/image-provenance.json index 4a54ec2..256e5f2 100644 --- a/documentation-site/image-provenance.json +++ b/documentation-site/image-provenance.json @@ -1,7 +1,7 @@ { "privacy_checked_before_capture": true, "browser_captures": 80, - "touch_layout_fixtures": 42, + "touch_layout_fixtures": 43, "sources": { "tools/product_journeys.cjs": "3675d1bda218f4932fde3e8b232b5aac75ae1bb196b85fe92705c53e3b15e9ab", "tools/product_review.py": "376adaa8e3c6255ad7bf4e73cc35e53e7f9ccb6acdc8610ee77ccf39a3a6cffd", @@ -9,19 +9,19 @@ "tools/capture_product_review.cjs": "23d6c11b616ac2c271e980410b162d71cf69b72575a452926794bf4838fe22d0", "tools/render_touch_review.py": "8cb523c706690f681f2dc887a726b21e99de020a4d8ec00a9708982073daeeac", "tools/check_public_privacy.py": "b5b3fc8482b8e9692e1524002b911cbcace8c213fd180454469f7c4963ebacd9", - "src/web/web_assets.cpp": "cf10ec9b702f74a4b06ab9e7edc2746d1bfe1d2aa378d38991b61d6711bf1342", + "src/web/web_assets.cpp": "b565808d7de2cb95615d7ae49db614e4303aec28e91f5ae3ab8c61fa3eefb84c", "src/web/writer_assets.cpp": "18ca31fb929c43f13138fbd4839e4ed60dc10e342cc2c0dbabb6e95d4c8a48c7", "src/web/writer_layout.inc": "c8835b92d1951269254fec884fe210fc725060528e5505e18a207fbe23d67f98", "src/ui/product_layout.hpp": "4b3139a7f8e95142f53b72a4c9f4ac05d103426a29210885823b30b3171c8979", - "src/ui/tag_flow.hpp": "e960c1ef2412f9c6c9b64c6c41801045bc640323645df17bf2978da370942f1f", + "src/ui/tag_flow.hpp": "44647a07d972e510c8363d115382c4e8fbdf3c1ef48686cdae0f8a8aee66d3dd", "src/ui/touch_input.hpp": "80fa15c718726adbbd3c35173479826f2c1bc0b8ff9a63133e737b17b3998eed", - "test/test_touch_flow/fixtures.hpp": "b9a846f95e3824b2dd7e416935695d672fa57a4cbf573889a6d9eb9a44832247" + "test/test_touch_flow/fixtures.hpp": "e895bf1032a0ddb04a71178fdc8ac3169fb3457274c376ff3e5bbf333f4efe1a" }, "images": { - "docs/assets/images/browser/dashboard.png": "85e32b85fd99ced2cc3abbfcbf2a5628f52233c39494780ec4fa5f828d6f4455", - "docs/assets/images/browser/weigh.png": "fc9770fe4c4d3ed408b000855673fa64dd3f54ebbc094194869044961cbf2f6f", - "docs/assets/images/browser/assignment.png": "d8607ee2bbd563e83d3977f59a104f03af97e2d66db878d2665fcdf014ed4f9a", - "docs/assets/images/browser/tag-management.png": "d62ec05ca6a84589217054bb333af84ac59fd6987d553c7305b977fce4448356", + "docs/assets/images/browser/dashboard.png": "5a532d455b3d67bd8199043b2b11bb1deba036ae776484082fcac62fd14aace5", + "docs/assets/images/browser/weigh.png": "2a2b2b70419ec4038aa37ef2ad630ef77f13cf2230f444a684805f1750df6bc4", + "docs/assets/images/browser/assignment.png": "60dcdb03a77d12639f0964a8fc2d26d441a9e4c4aa7fb51ae7f7af53387a1362", + "docs/assets/images/browser/tag-management.png": "41772e6e29be1725052d1df0e352fa874850da612c915ed112d030536057df1c", "docs/assets/images/touchscreen/wt32-home.png": "c768e1b3be568f2563bdf0a368f2cf5b4aab2e2e6efb8a98c8a996ff4dcdc7e3" } } diff --git a/src/application/nfc_worker.cpp b/src/application/nfc_worker.cpp index 9566d38..e807738 100644 --- a/src/application/nfc_worker.cpp +++ b/src/application/nfc_worker.cpp @@ -70,11 +70,16 @@ void NfcWorker::run_once() { if (static_cast(now - last_log_) >= 5000U) { last_log_ = now; Serial.printf( - "NFC owner=opentag-backend state=%s generation=%llu stack_free=%lu bytes bus_errors=%lu\n", - nfc::to_string(current.state), + "NFC owner=opentag-backend state=%s technology=%s generation=%llu stack_free=%lu bytes bus_errors=%lu\n", + nfc::to_string(current.state), nfc::to_string(current.technology), static_cast(current.generation), static_cast(uxTaskGetStackHighWaterMark(nullptr)), static_cast(current.bus_errors)); + if (current.nfca) + Serial.printf("NFC-A tag nfcid1=%s sak=0x%02X atqa=0x%02X%02X kind=%s (detection only)\n", + current.nfca->diagnostic().c_str(), current.nfca->sak, + current.nfca->atqa[1], current.nfca->atqa[0], + nfc::nfca::to_string(current.nfca->kind())); } } } // namespace opentag::application diff --git a/src/hardware/nfc/st25r3916b/i2c_reader.cpp b/src/hardware/nfc/st25r3916b/i2c_reader.cpp index 134272f..c12bdaf 100644 --- a/src/hardware/nfc/st25r3916b/i2c_reader.cpp +++ b/src/hardware/nfc/st25r3916b/i2c_reader.cpp @@ -103,6 +103,46 @@ core::Result> I2cReader::inventory() { } return core::Result>::success(std::move(result)); } +core::Result> I2cReader::inventory_nfca() { + using Identity = opentag::nfc::nfca::Identity; + using Found = core::Result>; + // The sequence RFAL's own discovery worker runs for NFC-A (rfal_nfc.cpp, + // rfalNfcPollTechDetetection then rfalNfcPollCollResolution), with the + // field already on: switch the chip to ISO 14443A, REQA, then anticollision + // for the NFCID1, SAK and ATQA. No command that reads or writes tag memory. + auto rc = nfc_.rfalNfcaPollerInitialize(); + if (rc != ERR_NONE) { + (void)nfc_.rfalNfcvPollerInitialize(); + return Found::failure(error("NFC-A mode", rc)); + } + std::vector result; + rfalNfcaSensRes sens{}; + rc = nfc_.rfalNfcaPollerTechnologyDetection(RFAL_COMPLIANCE_MODE_NFC, &sens); + if (rc == ERR_NONE) { + std::array devices{}; + std::uint8_t count = 0; + rc = nfc_.rfalNfcaPollerFullCollisionResolution( + RFAL_COMPLIANCE_MODE_NFC, static_cast(devices.size()), + devices.data(), &count); + for (unsigned i = 0; rc == ERR_NONE && i < count; ++i) { + const auto& device = devices[i]; + const std::array atqa{device.sensRes.anticollisionInfo, + device.sensRes.platformInfo}; + const auto identity = Identity::from_wire( + core::ByteView(device.nfcId1, device.nfcId1Len), device.selRes.sak, atqa, + device.type == RFAL_NFCA_T1T); + if (identity.ok()) result.push_back(identity.value()); + } + } + // Back to NFC-V for the next cycle, whatever NFC-A answered. + const auto restore = nfc_.rfalNfcvPollerInitialize(); + if ((rc & 0xFF00U) == ERR_I2C_GRP) return Found::failure(error("NFC-A detection I2C", rc)); + if (restore != ERR_NONE) return Found::failure(error("NFC-V mode", restore)); + // No answer (ERR_TIMEOUT) and RF-side errors mean no usable NFC-A tag this + // cycle; they must not pause NFC-V polling in the error state. + if (rc != ERR_NONE) result.clear(); + return Found::success(std::move(result)); +} core::Result I2cReader::geometry(const Uid& uid) { // Same standard/extended fallback and parser used in the accepted diagnostic. const auto wire = wire_uid(uid); diff --git a/src/hardware/nfc/st25r3916b/i2c_reader.hpp b/src/hardware/nfc/st25r3916b/i2c_reader.hpp index 9c7c4f8..efb24cb 100644 --- a/src/hardware/nfc/st25r3916b/i2c_reader.hpp +++ b/src/hardware/nfc/st25r3916b/i2c_reader.hpp @@ -19,6 +19,7 @@ class I2cReader final : public opentag::nfc::IWriterReader { core::Result field_off() override; core::Result health() override; core::Result> inventory() override; + core::Result> inventory_nfca() override; core::Result geometry( const opentag::nfc::nfcv::Uid&) override; core::Result read_blocks(const opentag::nfc::nfcv::Uid&, std::size_t, diff --git a/src/nfc/poll_schedule.hpp b/src/nfc/poll_schedule.hpp new file mode 100644 index 0000000..832e6d1 --- /dev/null +++ b/src/nfc/poll_schedule.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace opentag::nfc { +// Decides what one NFC poll cycle does. NFC-V is asked first on every cycle, +// exactly as since 1.0. NFC-A is asked in the same field window, after NFC-V, +// and only while NFC-V finds nothing and no NFC-V tag is being tracked. An +// NFC-V tag is therefore found and lost on the same polls as before; the +// only cost is one short NFC-A request (REQA) on cycles with no NFC-V tag. +class PollSchedule { + public: + static constexpr std::uint32_t cadence_ms = 500U; + static constexpr std::uint32_t error_cadence_ms = 5000U; + // True when a poll cycle is due. The first call is always due. + bool due(std::uint32_t now_ms, bool in_error) { + const auto cadence = in_error ? error_cadence_ms : cadence_ms; + if (polled_ && static_cast(now_ms - last_poll_ms_) < cadence) return false; + polled_ = true; + last_poll_ms_ = now_ms; + return true; + } + // `nfcv_found`: the NFC-V inventory of this cycle answered with a tag. + // `nfcv_tracked`: a tag is still counted present (stabilizing, read, or in + // its one tolerated missed poll). + static bool nfca_turn(bool nfcv_found, bool nfcv_tracked) { + return !nfcv_found && !nfcv_tracked; + } + + private: + std::uint32_t last_poll_ms_{0}; + bool polled_{false}; +}; +} // namespace opentag::nfc diff --git a/src/nfc/presentation.hpp b/src/nfc/presentation.hpp index 124b9f3..3dcc7d2 100644 --- a/src/nfc/presentation.hpp +++ b/src/nfc/presentation.hpp @@ -11,6 +11,8 @@ inline std::string describe(const ReadSnapshot& s, : s.tag ? "OpenPrintTag recognized" : s.state == ReadState::unsupported ? "NFC-V tag: unsupported OpenPrintTag" + : s.state == ReadState::unsupported_technology + ? "Tag type not supported yet" : s.state == ReadState::multiple ? "Multiple tags: present only one" : s.state == ReadState::error ? "NFC reader error" @@ -19,6 +21,9 @@ inline std::string describe(const ReadSnapshot& s, if (s.uid) out += std::string("\nUID ") + nfcv::format_diagnostic_uid(s.uid->bytes).data(); + if (s.nfca) + out += "\n" + std::string(nfca::describe(s.nfca->kind())) + " (NFC-A)\nID " + + s.nfca->diagnostic(); if (s.checksum) out += std::string("\nChecksum ") + nfcv::format_diagnostic_checksum(*s.checksum).data(); diff --git a/src/nfc/protocols/nfca/identity.cpp b/src/nfc/protocols/nfca/identity.cpp new file mode 100644 index 0000000..2a6a15f --- /dev/null +++ b/src/nfc/protocols/nfca/identity.cpp @@ -0,0 +1,68 @@ +#include "nfc/protocols/nfca/identity.hpp" + +#include + +namespace opentag::nfc::nfca { +namespace { +constexpr char hex_digits[] = "0123456789ABCDEF"; +// SAK bits, NXP AN10833 table 1 and ISO/IEC 14443-3 table 9. +constexpr std::uint8_t sak_iso14443_4 = 0x20U; // bit 6: ISO-DEP (Type 4) +constexpr std::uint8_t sak_uid_incomplete = 0x04U; // bit 3: cascade not finished +constexpr std::uint8_t sak_mifare_classic = 0x08U; // bit 4: MIFARE Classic 1K/4K/Mini + +std::string format(const Identity& identity, bool colons) { + std::string out; + for (std::uint8_t i = 0; i < identity.length; ++i) { + if (colons && i != 0U) out += ':'; + out += hex_digits[(identity.nfcid1[i] >> 4U) & 0x0FU]; + out += hex_digits[identity.nfcid1[i] & 0x0FU]; + } + return out; +} +} // namespace + +const char* to_string(Kind kind) { + switch (kind) { + case Kind::type2: return "type2"; + case Kind::mifare_classic: return "mifare_classic"; + case Kind::type4: return "type4"; + case Kind::other: break; + } + return "other"; +} + +const char* describe(Kind kind) { + switch (kind) { + case Kind::type2: return "NTAG or other Type 2 tag"; + case Kind::mifare_classic: return "MIFARE Classic tag"; + case Kind::type4: return "Type 4 (ISO 14443-4) tag"; + case Kind::other: break; + } + return "other NFC-A tag"; +} + +core::Result Identity::from_wire(core::ByteView id, std::uint8_t sak, + std::array atqa, bool type1) { + if (id.size != 4U && id.size != 7U && id.size != 10U) + return core::Result::failure( + {core::ErrorCategory::unsupported_tag, "NFC-A NFCID1 must be 4, 7 or 10 bytes", false}); + Identity result; + std::copy(id.data, id.data + id.size, result.nfcid1.begin()); + result.length = static_cast(id.size); + result.sak = sak; + result.atqa = atqa; + result.type1 = type1; + return core::Result::success(result); +} + +Kind Identity::kind() const { + if (type1 || (sak & sak_uid_incomplete) != 0U) return Kind::other; + if ((sak & sak_iso14443_4) != 0U) return Kind::type4; + if ((sak & sak_mifare_classic) != 0U) return Kind::mifare_classic; + if (sak == 0x00U) return Kind::type2; + return Kind::other; +} + +std::string Identity::hex() const { return format(*this, false); } +std::string Identity::diagnostic() const { return format(*this, true); } +} // namespace opentag::nfc::nfca diff --git a/src/nfc/protocols/nfca/identity.hpp b/src/nfc/protocols/nfca/identity.hpp new file mode 100644 index 0000000..556716d --- /dev/null +++ b/src/nfc/protocols/nfca/identity.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/byte_view.hpp" +#include "core/result.hpp" + +namespace opentag::nfc::nfca { +// What the anticollision answer says about an ISO 14443A tag. Derived from +// SAK (SEL_RES) bits as NXP AN10833 "MIFARE type identification procedure" +// describes: bit 6 set means ISO 14443-4 (Type 4), bit 4 set means MIFARE +// Classic, SAK 0x00 is a Type 2 tag (NTAG, MIFARE Ultralight). Nothing here +// is read from the tag's memory. +enum class Kind { type2, mifare_classic, type4, other }; +const char* to_string(Kind kind); +// Plain words for the touchscreen and the browser. +const char* describe(Kind kind); + +struct Identity { + static constexpr std::size_t maximum_length = 10U; + std::array nfcid1{}; + std::uint8_t length{0}; // 4, 7 or 10 bytes (cascade level 1, 2 or 3) + std::uint8_t sak{0}; + // SENS_RES (ATQA) as received: anticollision information, platform information. + std::array atqa{}; + // A Type 1 (Topaz) tag answers anticollision differently and has no SAK. + bool type1{false}; + + [[nodiscard]] static core::Result from_wire(core::ByteView nfcid1, std::uint8_t sak, + std::array atqa, + bool type1 = false); + [[nodiscard]] Kind kind() const; + // Upper-case hex without separators, e.g. "04A1B2C3D4E5F6". + [[nodiscard]] std::string hex() const; + // Upper-case hex with colons, e.g. "04:A1:B2:C3:D4:E5:F6", like the NFC-V UID. + [[nodiscard]] std::string diagnostic() const; + [[nodiscard]] bool operator==(const Identity& other) const { + return length == other.length && sak == other.sak && type1 == other.type1 && + std::equal(nfcid1.begin(), nfcid1.begin() + length, other.nfcid1.begin()); + } + [[nodiscard]] bool operator!=(const Identity& other) const { return !(*this == other); } +}; +} // namespace opentag::nfc::nfca diff --git a/src/nfc/read_only_service.cpp b/src/nfc/read_only_service.cpp index 2c02944..da35225 100644 --- a/src/nfc/read_only_service.cpp +++ b/src/nfc/read_only_service.cpp @@ -45,6 +45,8 @@ const char* to_string(ReadState s) { return "blank_compatible"; case ReadState::unsupported: return "unsupported"; + case ReadState::unsupported_technology: + return "unsupported_technology"; case ReadState::multiple: return "multiple"; case ReadState::error: @@ -69,8 +71,15 @@ void ReadOnlyService::clear_active() { live_.checksum.reset(); processed_.reset(); } +void ReadOnlyService::clear_nfca() { + nfca_candidate_.reset(); + nfca_missed_ = 0U; + live_.nfca.reset(); +} void ReadOnlyService::fail(const core::Error& e) { clear_active(); + clear_nfca(); + live_.technology = Technology::none; candidate_.reset(); consecutive_ = 0; missed_polls_ = 0U; @@ -160,12 +169,51 @@ core::Result ReadOnlyService::read_tag(const nfcv::Uid& uid) { } return core::Result::success(); } +core::Result ReadOnlyService::poll_nfca(std::uint32_t now) { + const auto found = reader_.inventory_nfca(); + if (!found.ok()) return core::Result::failure(found.error()); + const auto& tags = found.value(); + // The same weak-link rule as for NFC-V: one empty answer keeps a known tag. + if (tags.empty() && nfca_missed_ == 0U && nfca_candidate_.has_value()) { + ++nfca_missed_; + return core::Result::success(); + } + nfca_missed_ = 0U; + missed_polls_ = 0U; + live_.error.reset(); + if (tags.size() != 1U) { + live_.tag_count = tags.size(); + live_.present = !tags.empty(); + clear_active(); + clear_nfca(); + candidate_.reset(); + consecutive_ = 0; + live_.uid.reset(); + live_.technology = tags.empty() ? Technology::none : Technology::nfca; + live_.state = tags.empty() ? ReadState::idle : ReadState::multiple; + return core::Result::success(); + } + const auto& identity = tags.front(); + if (!nfca_candidate_ || *nfca_candidate_ != identity) { + clear_active(); + candidate_.reset(); + consecutive_ = 0; + live_.uid.reset(); + nfca_candidate_ = identity; + ++live_.generation; // a new tag for anyone watching the generation + } + live_.tag_count = 1U; + live_.present = true; + live_.last_seen_ms = now; + live_.technology = Technology::nfca; + live_.nfca = identity; + // Detection only: nothing is read from an NFC-A tag in this release. + live_.state = ReadState::unsupported_technology; + return core::Result::success(); +} void ReadOnlyService::poll() { const auto now = reader_.now_ms(); - const auto cadence = live_.state == ReadState::error ? 5000U : 500U; - if (polled_ && static_cast(now - last_poll_) < cadence) return; - polled_ = true; - last_poll_ = now; + if (!schedule_.due(now, live_.state == ReadState::error)) return; if (!live_.initialized) { const auto init = reader_.initialize(); if (!init.ok()) { @@ -179,9 +227,13 @@ void ReadOnlyService::poll() { auto result = reader_.field_on(); if (result.ok()) { const auto found = reader_.inventory(); + const bool nfcv_tracked = processed_.has_value() || candidate_.has_value(); if (!found.ok()) result = core::Result::failure(found.error()); - else { + else if (found.value().empty() && PollSchedule::nfca_turn(false, nfcv_tracked)) { + // NFC-V first, unchanged; NFC-A only when there is no NFC-V tag to track. + result = poll_nfca(now); + } else { if (live_.state != ReadState::unsupported) live_.error.reset(); // A tag at the edge of the field drops out of single inventories. // One empty answer keeps what is known about it; only a second one @@ -197,8 +249,10 @@ void ReadOnlyService::poll() { missed_polls_ = 0U; live_.tag_count = found.value().size(); live_.present = !found.value().empty(); + live_.technology = found.value().empty() ? Technology::none : Technology::nfcv; live_.error.reset(); clear_active(); + clear_nfca(); candidate_.reset(); consecutive_ = 0; live_.uid.reset(); @@ -208,6 +262,8 @@ void ReadOnlyService::poll() { missed_polls_ = 0U; live_.tag_count = 1U; live_.present = true; + live_.technology = Technology::nfcv; + clear_nfca(); const auto uid = found.value().front(); live_.uid = uid; live_.last_seen_ms = now; diff --git a/src/nfc/read_only_service.hpp b/src/nfc/read_only_service.hpp index d920351..f3455a9 100644 --- a/src/nfc/read_only_service.hpp +++ b/src/nfc/read_only_service.hpp @@ -4,8 +4,11 @@ #include #include "nfc/formats/openprinttag/codec.hpp" +#include "nfc/poll_schedule.hpp" +#include "nfc/protocols/nfca/identity.hpp" #include "nfc/protocols/nfcv/tag.hpp" #include "nfc/read_storage.hpp" +#include "nfc/technology.hpp" namespace opentag::nfc { @@ -18,6 +21,11 @@ class IReadOnlyReader { virtual core::Result field_off() = 0; virtual core::Result health() = 0; virtual core::Result> inventory() = 0; + // ISO 14443A detection only: anticollision answers (NFCID1, SAK, ATQA), + // never a memory read. A reader without NFC-A support reports no tags. + virtual core::Result> inventory_nfca() { + return core::Result>::success({}); + } virtual core::Result geometry(const nfcv::Uid&) = 0; virtual core::Result read_blocks(const nfcv::Uid&, std::size_t first, std::size_t count, @@ -38,6 +46,8 @@ enum class ReadState { openprinttag, blank, unsupported, + // An NFC-A (ISO 14443A) tag: found and named, nothing read (#93). + unsupported_technology, multiple, error }; @@ -66,6 +76,10 @@ struct ReadSnapshot { std::uint32_t bus_errors{0}; std::optional error; std::shared_ptr tag; + // The technology of the present tag(s); none while the reader is empty. + Technology technology{Technology::none}; + // Set with state unsupported_technology; uid stays empty for such a tag. + std::optional nfca; }; // A present tag without OpenPrintTag data (blank, or other data) can still be @@ -83,7 +97,7 @@ class ReadOnlyService { void poll(); ReadSnapshot snapshot() const; // Sole backend owner, only after an explicit writer finishes or fails. - void invalidate_after_write() { clear_active(); processed_.reset(); candidate_.reset(); consecutive_ = 0; missed_polls_ = 0; read_failures_ = 0; publish(); } + void invalidate_after_write() { clear_active(); clear_nfca(); processed_.reset(); candidate_.reset(); consecutive_ = 0; missed_polls_ = 0; read_failures_ = 0; publish(); } static constexpr std::size_t maximum_memory_bytes = 4096U; static constexpr unsigned stable_polls = 3U; // Consecutive failed reads of one present tag before the error state. @@ -95,7 +109,10 @@ class ReadOnlyService { ReadImage&, std::uint32_t started); core::Result read_tag(const nfcv::Uid& uid); + // The NFC-A turn of a poll cycle (PollSchedule::nfca_turn). + core::Result poll_nfca(std::uint32_t now); void clear_active(); + void clear_nfca(); void fail(const core::Error& error); void publish(); IReadOnlyReader& reader_; @@ -107,7 +124,8 @@ class ReadOnlyService { unsigned consecutive_{0}; unsigned missed_polls_{0}; unsigned read_failures_{0}; - std::uint32_t last_poll_{0}; - bool polled_{false}; + PollSchedule schedule_; + std::optional nfca_candidate_; + unsigned nfca_missed_{0}; }; } // namespace opentag::nfc diff --git a/src/nfc/technology.hpp b/src/nfc/technology.hpp new file mode 100644 index 0000000..73913d3 --- /dev/null +++ b/src/nfc/technology.hpp @@ -0,0 +1,19 @@ +#pragma once + +namespace opentag::nfc { +// The RF technology a tag was found with. Only NFC-V tags are read; an NFC-A +// tag is detected and named so the UIs can say it is not supported yet (#93). +enum class Technology { none, nfcv, nfca }; +inline const char* to_string(Technology technology) { + switch (technology) { + case Technology::nfcv: return "nfcv"; + case Technology::nfca: return "nfca"; + case Technology::none: break; + } + return "none"; +} +// The wording the browser and the API have used for NFC-V since 1.0. +inline const char* technology_label(Technology technology) { + return technology == Technology::nfca ? "NFC-A / ISO14443A" : "NFC-V / ISO15693"; +} +} // namespace opentag::nfc diff --git a/src/services/tag_lifecycle.hpp b/src/services/tag_lifecycle.hpp index 6c78295..617dd53 100644 --- a/src/services/tag_lifecycle.hpp +++ b/src/services/tag_lifecycle.hpp @@ -4,13 +4,16 @@ namespace opentag::services { // Presentation only. No state here grants permission to mutate NFC or Spoolman. enum class TagLifecycle { no_tag, reading, blank_compatible, linked, unlinked, - write_pending, cleanup_pending, ready, unsupported }; + write_pending, cleanup_pending, ready, unsupported, + // A tag of a technology the station does not read yet (NFC-A). + unsupported_type }; struct TagLifecycleInput { bool present{false}, blank{false}, valid{false}, linked{false}, unsupported{false}; // Pending transactions survive absence/reboot. Completed results must match // the currently present physical UID before they override a fresh read. std::string_view phase; bool writer_matches{false}; + bool unsupported_type{false}; }; inline TagLifecycle tag_lifecycle(const TagLifecycleInput& s) { if (s.phase == "unlink_pending" || s.phase == "unlinking" || s.phase == "clear_recovery") @@ -19,6 +22,7 @@ inline TagLifecycle tag_lifecycle(const TagLifecycleInput& s) { s.phase == "verifying" || s.phase == "associating" || s.phase == "clearing") return TagLifecycle::write_pending; if (!s.present) return TagLifecycle::no_tag; + if (s.unsupported_type) return TagLifecycle::unsupported_type; if (s.writer_matches && (s.phase == "cleared" || s.phase == "complete")) return TagLifecycle::ready; if (s.blank) return TagLifecycle::blank_compatible; @@ -36,6 +40,7 @@ inline const char* to_string(TagLifecycle state) { case TagLifecycle::cleanup_pending: return "CLEANUP_PENDING"; case TagLifecycle::ready: return "READY"; case TagLifecycle::unsupported: return "UNSUPPORTED"; + case TagLifecycle::unsupported_type: return "UNSUPPORTED_TYPE"; } return "NO_TAG"; } diff --git a/src/ui/tag_flow.hpp b/src/ui/tag_flow.hpp index d3644c5..0301c83 100644 --- a/src/ui/tag_flow.hpp +++ b/src/ui/tag_flow.hpp @@ -849,6 +849,10 @@ inline TagScreen TagFlow::screen() const { else if (lifecycle == services::TagLifecycle::unsupported) s.body = "This tag can't be used as it is\n" + tag_error + "\nUse a blank NXP ICODE SLIX2 tag."; + else if (lifecycle == services::TagLifecycle::unsupported_type) + // #93: found and named, nothing read. tag_error holds the kind. + s.body = "Tag type not supported yet\n" + tag_error + "\nID " + uid + + "\nUse an OpenPrintTag on NXP ICODE SLIX2."; else { s.title = "Reading tag"; s.body = "Hold the spool still."; diff --git a/src/ui/ui_service.cpp b/src/ui/ui_service.cpp index 68105ea..bcf241b 100644 --- a/src/ui/ui_service.cpp +++ b/src/ui/ui_service.cpp @@ -79,6 +79,7 @@ const char* nfc_status_text(const nfc::ReadSnapshot& tag) { case nfc::ReadState::openprinttag: return "OpenPrintTag found. The reader works."; case nfc::ReadState::blank: return "Blank tag found. The reader works."; case nfc::ReadState::unsupported: return "Tag found, but it can't be used as it is."; + case nfc::ReadState::unsupported_technology: return "Tag found. Its type is not supported yet."; case nfc::ReadState::multiple: return "More than one tag. Use one tag at a time."; case nfc::ReadState::error: return "NFC reader problem. Check the NFC wiring."; } @@ -864,7 +865,10 @@ void UiService::refresh_tags() { if(!tag_flow_||!nfc_detail_)return; auto& flow=*tag_flow_; const auto tag=nfc_.snapshot(); - flow.observe_tag(tag.uid?tag.uid->hex():std::string(),tag.error?&tag.error->message:nullptr); + // An NFC-A tag has no NFC-V UID; its NFCID1 and kind take their place. + const std::string foreign_kind=tag.nfca?std::string(nfc::nfca::describe(tag.nfca->kind()))+" (NFC-A)":std::string(); + flow.observe_tag(tag.uid?tag.uid->hex():tag.nfca?tag.nfca->hex():std::string(), + tag.error?&tag.error->message:tag.nfca?&foreign_kind:nullptr); workflow_.visit([&](const services::WorkflowSnapshot& current) { flow.current_spool=tag.tag&&tag.uid&¤t.openprinttag_available&¤t.uid==*tag.uid&¤t.spool?current.spool->id:0; // As on Home: "not linked" only once Spoolman answered for this tag. @@ -881,7 +885,7 @@ void UiService::refresh_tags() { } }); flow.material=tag.tag?tag.tag->decoded.material.material_name.value_or("Filament spool"):""; - flow.observe_lifecycle(services::tag_lifecycle({tag.present,tag.blank_compatible,bool(tag.tag),flow.current_spool>0,tag.state==nfc::ReadState::unsupported,flow.phase,false})); + flow.observe_lifecycle(services::tag_lifecycle({tag.present,tag.blank_compatible,bool(tag.tag),flow.current_spool>0,tag.state==nfc::ReadState::unsupported,flow.phase,false,tag.state==nfc::ReadState::unsupported_technology})); flow.multiple_tags=tag.state==nfc::ReadState::multiple; auto body=backend_worker_.writer_snapshot(); const auto checksum=nfc::nfcv::diagnostic_checksum(reinterpret_cast(body.data()),body.size()); @@ -2200,6 +2204,8 @@ void UiService::refresh_home_page( const auto remaining=workflow.spool?workflow.spool->remaining_grams:std::optional{}; if(remaining)lv_label_set_text_fmt(workflow_weight_label_,"%ld g remaining",static_cast(std::lround(*remaining))); else lv_label_set_text(workflow_weight_label_,!scale.scale_calibrated?"Scale not calibrated":present?"Weight not known":""); + // #93: an NFC-A tag is found but not read. Say so instead of "Place a spool". + if(!present&&tag.state==nfc::ReadState::unsupported_technology&&tag.nfca){lv_label_set_text(workflow_home_state_label_,"TAG FOUND");lv_label_set_text(workflow_material_label_,"Tag type not supported yet");lv_label_set_text(workflow_identity_label_,(std::string(nfc::nfca::describe(tag.nfca->kind()))+" (NFC-A). Use an OpenPrintTag on NXP ICODE SLIX2.").c_str());} if(blank){lv_label_set_text(workflow_home_state_label_,"COMPATIBLE BLANK TAG");lv_label_set_text(workflow_material_label_,"Ready to write");lv_label_set_text(workflow_identity_label_,"Open Manage tag to choose a spool");} // A7: unreadable settings storage is not a tag problem. Say what to do. if(!present&&!diagnostics_.storage_ready()){lv_label_set_text(workflow_home_state_label_,"STORAGE ERROR");lv_label_set_text(workflow_material_label_,"Settings cannot be read");lv_label_set_text(workflow_identity_label_,"Reinstall the firmware with the USB installer.");} diff --git a/src/web/application_api_context.cpp b/src/web/application_api_context.cpp index 99147c0..ce1bedb 100644 --- a/src/web/application_api_context.cpp +++ b/src/web/application_api_context.cpp @@ -956,7 +956,7 @@ core::Result ApplicationApiContext::snapshot_json( const bool same=tag.uid&&tag.uid->hex()==std::string(writer["uid"]|""); workflow_.visit([&](const services::WorkflowSnapshot& workflow) { auto encoded = document["workflow"].to(); - encoded["tag_lifecycle"]=services::to_string(services::tag_lifecycle({tag.present,tag.blank_compatible,bool(tag.tag),bool(tag.uid&&workflow.uid==*tag.uid&&workflow.spool),tag.state==nfc::ReadState::unsupported,phase,same})); + encoded["tag_lifecycle"]=services::to_string(services::tag_lifecycle({tag.present,tag.blank_compatible,bool(tag.tag),bool(tag.uid&&workflow.uid==*tag.uid&&workflow.spool),tag.state==nfc::ReadState::unsupported,phase,same,tag.state==nfc::ReadState::unsupported_technology})); encoded["stage"] = workflow_stage_name(workflow.stage); encoded["spool_generation"] = workflow.spool_generation; encoded["openprinttag_available"] = workflow.openprinttag_available; diff --git a/src/web/nfc_json.hpp b/src/web/nfc_json.hpp index 1889af9..f0e7a50 100644 --- a/src/web/nfc_json.hpp +++ b/src/web/nfc_json.hpp @@ -37,17 +37,34 @@ inline void write_nfc(JsonObject out, const nfc::ReadSnapshot& status, out["bytes"] = status.geometry.capacity(); if (status.uid) out["uid"] = nfc::nfcv::format_diagnostic_uid(status.uid->bytes).data(); + else if (status.nfca) + out["uid"] = status.nfca->diagnostic(); else out["uid"] = nullptr; + // "nfcv", "nfca" or null: the technology of the tag(s) on the reader. + if (status.technology == nfc::Technology::none) out["technology"] = nullptr; + else out["technology"] = nfc::to_string(status.technology); + if (status.nfca) { + auto nfca = out["nfca"].to(); + nfca["nfcid1"] = status.nfca->diagnostic(); + nfca["length"] = status.nfca->length; + nfca["sak"] = status.nfca->sak; + nfca["atqa"] = (static_cast(status.nfca->atqa[1]) << 8U) | status.nfca->atqa[0]; + nfca["kind"] = nfc::nfca::to_string(status.nfca->kind()); + nfca["description"] = nfc::nfca::describe(status.nfca->kind()); + } else { + out["nfca"] = nullptr; + } if (status.checksum) out["checksum"] = nfc::nfcv::format_diagnostic_checksum(*status.checksum).data(); else out["checksum"] = nullptr; out["blank_compatible"]=status.blank_compatible; - out["lifecycle"]=services::to_string(services::tag_lifecycle({status.present,status.blank_compatible,bool(status.tag),false,status.state==nfc::ReadState::unsupported,{},false})); + const bool unsupported_type = status.state == nfc::ReadState::unsupported_technology; + out["lifecycle"]=services::to_string(services::tag_lifecycle({status.present,status.blank_compatible,bool(status.tag),false,status.state==nfc::ReadState::unsupported,{},false,unsupported_type})); out["decode"] = status.blank_compatible ? "blank" : status.tag ? "pass" - : status.state == nfc::ReadState::unsupported ? "fail" + : status.state == nfc::ReadState::unsupported || unsupported_type ? "fail" : "pending"; if (status.error) out["last_error"] = status.error->message; @@ -57,7 +74,7 @@ inline void write_nfc(JsonObject out, const nfc::ReadSnapshot& status, inventory["tag_count"] = status.tag_count; inventory["present"] = status.present; inventory["uid"] = out["uid"]; - inventory["technology"] = "NFC-V / ISO15693"; + inventory["technology"] = nfc::technology_label(status.technology); auto geometry = out["geometry"].to(); geometry["block_size"] = status.geometry.block_size; geometry["block_count"] = status.geometry.block_count; diff --git a/src/web/ui/product.js b/src/web/ui/product.js index 00535d2..73766ce 100644 --- a/src/web/ui/product.js +++ b/src/web/ui/product.js @@ -47,6 +47,8 @@ function renderCurrentSpool() { const linked=present&&!blank&&!!s.id, valid=present&&!blank&&(t.decode==='pass'||linked||lifecycle==='OPENPRINTTAG_UNLINKED'); const cleanup=lifecycle==='CLEANUP_PENDING'||state.clearSnapshot?.phase==='unlink_pending'; const writePending=lifecycle==='WRITE_PENDING'; + // #93: an NFC-A tag (NTAG, MIFARE) is found and named, nothing is read. + const foreign=present&&(t.technology==='nfca'||lifecycle==='UNSUPPORTED_TYPE'); byId('tag-update').hidden=!linked||cleanup||writePending; byId('writer-open').hidden=(!blank&&!valid&&!writePending)||cleanup; byId('clear-open').hidden=(!valid&&!cleanup)||writePending; @@ -57,13 +59,13 @@ function renderCurrentSpool() { if (!present) return; const remaining=first(s.remaining_grams,t.remaining_weight); const initial=first(s.initial_grams,t.actual_full_weight,t.nominal_full_weight); - const name=first(s.display_name,t.material_name,t.blank_compatible?'Blank tag':'Reading spool…'); + const name=foreign?'Tag type not supported yet':first(s.display_name,t.material_name,t.blank_compatible?'Blank tag':'Reading spool…'); setText('current-name',name);setText('manage-material',name); setText('current-vendor',first(s.vendor,t.brand_name,t.brand,'')); setText('current-material',first(s.material,t.material_abbreviation,'')); // Unlinked only means "no link" once Spoolman has actually answered for this tag. const look=!s.id&&w.stage==='resolving_spool',off=!s.id&&!look&&(w.spoolman==='offline'||w.stage==='spool_resolution_unavailable'); - setText('current-number',s.id?'Spool #'+s.id:look?'Looking up…':off?'Link not checked':blank?'Ready to write':'Not yet linked'); + setText('current-number',foreign?first(t.nfca?.description,'NFC-A tag')+' · not read':s.id?'Spool #'+s.id:look?'Looking up…':off?'Link not checked':blank?'Ready to write':'Not yet linked'); setText('current-remaining',remaining===null?'—':Math.round(Number(remaining))); const percentage=remaining!==null&&Number(initial)>0?Math.max(0,Math.min(100,Math.round(Number(remaining)/Number(initial)*100))):null; byId('remaining-summary').hidden=percentage===null; @@ -74,9 +76,9 @@ function renderCurrentSpool() { byId('current-spool').style.setProperty('--filament',safe?'rgb('+color.join(',')+')':'#879b9b'); setText('current-color',safe?'Filament color':'Color not provided'); const reading=tagReading(t); - setText('current-tag',t.blank_compatible?'Blank tag':t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':reading?'Reading tag…':'Tag not read'); + setText('current-tag',foreign?'Not supported yet':t.blank_compatible?'Blank tag':t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':reading?'Reading tag…':'Tag not read'); byId('current-tag').dataset.valid=String(t.decode==='pass');byId('current-tag').dataset.reading=String(reading); - setText('current-link',s.id?'Linked to Spoolman':look?'Looking up this spool in Spoolman…':off?(w.lookup_error?'The Spoolman lookup failed: '+w.lookup_error:"Spoolman is not reachable; the link can't be checked right now. It will be looked up again when Spoolman is back."):'Not linked to Spoolman');byId('current-link').dataset.state=off?'warn':''; + setText('current-link',foreign?'The station reads OpenPrintTag on NFC-V (ISO 15693) only':s.id?'Linked to Spoolman':look?'Looking up this spool in Spoolman…':off?(w.lookup_error?'The Spoolman lookup failed: '+w.lookup_error:"Spoolman is not reachable; the link can't be checked right now. It will be looked up again when Spoolman is back."):'Not linked to Spoolman');byId('current-link').dataset.state=off?'warn':''; const assignments=state.printers.flatMap(p=>asArray(p.toolheads).filter(h=>s.id&&Number(first(h.assigned_spool_id,h.spool_id))===Number(s.id)).map(h=>(p.display_name||p.name||'Printer')+' · T'+(Number(first(h.backend_id,h.id))+1))); setText('current-assignment',assignments.length?assignments.join(', '):'Not assigned'); setText('dashboard-printer',assignments.length?assignments.join(', '):'Choose a toolhead to assign this spool.'); diff --git a/src/web/web_assets.cpp b/src/web/web_assets.cpp index cc52205..987970b 100644 --- a/src/web/web_assets.cpp +++ b/src/web/web_assets.cpp @@ -1543,7 +1543,7 @@ function tagStatus(){renderCurrentSpool();const t=state.currentTag||{},w=state.t const same=uid&&String(w.tag?.uid||'').replace(/:/g,'')===uid.replace(/:/g,''),linked=present&&same&&w.openprinttag_available&&w.spool,owned=present&&uid&&String(writer.uid||'').replace(/:/g,'')===uid.replace(/:/g,''); const cleared=state.clearSnapshot||{},blank=String(cleared.uid||'').replace(/:/g,'')===uid.replace(/:/g,'')&&['cleared','unlink_pending','unlinking'].includes(cleared.phase); const pending=owned&&writer.phase==='association_pending',complete=owned&&writer.phase==='complete';const id=blank?0:pending?0:complete?writer.spool_id:linked?(w.spool.id||w.spool.spool_id):0; -setText('nfc-detected-chip',present?'Tag detected':'No tag');const rd=present&&tagReading(t);setText('nfc-decode-chip',t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':rd?'Reading tag…':'Not read');byId('nfc-decode-chip').dataset.reading=String(rd);setText('nfc-link-chip',pending?'Link pending':id?'Linked to Spoolman':'Not linked'); +setText('nfc-detected-chip',present?'Tag detected':'No tag');const rd=present&&tagReading(t),foreign=present&&t.technology==='nfca';setText('nfc-decode-chip',foreign?'Not supported yet':t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':rd?'Reading tag…':'Not read');byId('nfc-decode-chip').dataset.reading=String(rd);setText('nfc-link-chip',pending?'Link pending':id?'Linked to Spoolman':'Not linked'); setText('nfc-association',pending?'Association pending — open Write / Rewrite to retry':id?'Linked · Spool #'+id:'Not linked');setText('nfc-identity',id?'Spoolman #'+id:'—');byId('nfc-identity-row').hidden=!id;byId('nfc-copy').disabled=!uid; if(t.blank_compatible&&!blank){setBadge('nfc-badge','Blank','good');setText('nfc-summary','Compatible blank tag');setText('nfc-guidance','Ready to write');setText('nfc-decode-chip','BLANK');}if(blank){setBadge('nfc-badge','Blank','good');setText('nfc-summary','Tag ready to reuse');setText('nfc-guidance',cleared.phase==='cleared'?'Ready to write':cleared.message||'Tag blank and verified. Cleanup still needs attention.');setText('nfc-decode-chip','BLANK VERIFIED');setText('nfc-link-chip',cleared.phase==='cleared'?'UNLINKED':'UNLINK PENDING');} if(uid)setText('nfc-uid',uid.replace(/[^a-f0-9]/gi,'').match(/.{1,2}/g)?.join(':')||uid); @@ -1585,11 +1585,11 @@ if (bringup === 'fault' || bringup === 'error') { summary = 'NFC reader error'; badgeKind = 'bad'; } else if (ready && tagCount > 1) { -summary = 'Multiple NFC-V tags detected'; +summary = 'Multiple ' + (nfc.technology === 'nfca' ? 'NFC-A' : 'NFC-V') + ' tags detected'; guidance = 'Remove extra tags and present exactly one tag.'; badgeKind = 'warning'; } else if (ready && present && tagCount === 1) { -summary = 'NFC-V TAG DETECTED'; +summary = (nfc.technology === 'nfca' ? 'NFC-A' : 'NFC-V') + ' TAG DETECTED'; guidance = uid ? 'UID: ' + uid : 'Tag inventory succeeded.'; badgeKind = 'good'; } else if (ready) { @@ -1622,6 +1622,11 @@ setBadge('nfc-badge', 'Blank', 'good'); setText('nfc-summary', 'NFC-V tag detected'); setText('nfc-guidance', 'OpenPrintTag decode failed / unsupported'); setBadge('nfc-badge', 'Unsupported', 'warning'); +} else if (nfc.state === 'unsupported_technology') { +const a = asObject(nfc.nfca); +setText('nfc-summary', 'Tag type not supported yet'); +setText('nfc-guidance', first(a.description, 'NFC-A tag') + ' (NFC-A / ISO 14443A). The station reads OpenPrintTag on NFC-V (ISO 15693) only; nothing was read from this tag.'); +setBadge('nfc-badge', 'Not supported yet', 'warning'); } else if (nfc.state === 'error') { setText('nfc-summary', 'NFC reader error'); setText('nfc-guidance', nfc.last_error); @@ -3301,6 +3306,8 @@ function renderCurrentSpool() { const linked=present&&!blank&&!!s.id, valid=present&&!blank&&(t.decode==='pass'||linked||lifecycle==='OPENPRINTTAG_UNLINKED'); const cleanup=lifecycle==='CLEANUP_PENDING'||state.clearSnapshot?.phase==='unlink_pending'; const writePending=lifecycle==='WRITE_PENDING'; + // #93: an NFC-A tag (NTAG, MIFARE) is found and named, nothing is read. + const foreign=present&&(t.technology==='nfca'||lifecycle==='UNSUPPORTED_TYPE'); byId('tag-update').hidden=!linked||cleanup||writePending; byId('writer-open').hidden=(!blank&&!valid&&!writePending)||cleanup; byId('clear-open').hidden=(!valid&&!cleanup)||writePending; @@ -3311,13 +3318,13 @@ function renderCurrentSpool() { if (!present) return; const remaining=first(s.remaining_grams,t.remaining_weight); const initial=first(s.initial_grams,t.actual_full_weight,t.nominal_full_weight); - const name=first(s.display_name,t.material_name,t.blank_compatible?'Blank tag':'Reading spool…'); + const name=foreign?'Tag type not supported yet':first(s.display_name,t.material_name,t.blank_compatible?'Blank tag':'Reading spool…'); setText('current-name',name);setText('manage-material',name); setText('current-vendor',first(s.vendor,t.brand_name,t.brand,'')); setText('current-material',first(s.material,t.material_abbreviation,'')); // Unlinked only means "no link" once Spoolman has actually answered for this tag. const look=!s.id&&w.stage==='resolving_spool',off=!s.id&&!look&&(w.spoolman==='offline'||w.stage==='spool_resolution_unavailable'); - setText('current-number',s.id?'Spool #'+s.id:look?'Looking up…':off?'Link not checked':blank?'Ready to write':'Not yet linked'); + setText('current-number',foreign?first(t.nfca?.description,'NFC-A tag')+' · not read':s.id?'Spool #'+s.id:look?'Looking up…':off?'Link not checked':blank?'Ready to write':'Not yet linked'); setText('current-remaining',remaining===null?'—':Math.round(Number(remaining))); const percentage=remaining!==null&&Number(initial)>0?Math.max(0,Math.min(100,Math.round(Number(remaining)/Number(initial)*100))):null; byId('remaining-summary').hidden=percentage===null; @@ -3328,9 +3335,9 @@ function renderCurrentSpool() { byId('current-spool').style.setProperty('--filament',safe?'rgb('+color.join(',')+')':'#879b9b'); setText('current-color',safe?'Filament color':'Color not provided'); const reading=tagReading(t); - setText('current-tag',t.blank_compatible?'Blank tag':t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':reading?'Reading tag…':'Tag not read'); + setText('current-tag',foreign?'Not supported yet':t.blank_compatible?'Blank tag':t.decode==='pass'?'Tag valid':t.decode==='fail'?'Tag needs attention':reading?'Reading tag…':'Tag not read'); byId('current-tag').dataset.valid=String(t.decode==='pass');byId('current-tag').dataset.reading=String(reading); - setText('current-link',s.id?'Linked to Spoolman':look?'Looking up this spool in Spoolman…':off?(w.lookup_error?'The Spoolman lookup failed: '+w.lookup_error:"Spoolman is not reachable; the link can't be checked right now. It will be looked up again when Spoolman is back."):'Not linked to Spoolman');byId('current-link').dataset.state=off?'warn':''; + setText('current-link',foreign?'The station reads OpenPrintTag on NFC-V (ISO 15693) only':s.id?'Linked to Spoolman':look?'Looking up this spool in Spoolman…':off?(w.lookup_error?'The Spoolman lookup failed: '+w.lookup_error:"Spoolman is not reachable; the link can't be checked right now. It will be looked up again when Spoolman is back."):'Not linked to Spoolman');byId('current-link').dataset.state=off?'warn':''; const assignments=state.printers.flatMap(p=>asArray(p.toolheads).filter(h=>s.id&&Number(first(h.assigned_spool_id,h.spool_id))===Number(s.id)).map(h=>(p.display_name||p.name||'Printer')+' · T'+(Number(first(h.backend_id,h.id))+1))); setText('current-assignment',assignments.length?assignments.join(', '):'Not assigned'); setText('dashboard-printer',assignments.length?assignments.join(', '):'Choose a toolhead to assign this spool.'); diff --git a/test/test_nfc_read_only/test_main.cpp b/test/test_nfc_read_only/test_main.cpp index 552d641..b312bb1 100644 --- a/test/test_nfc_read_only/test_main.cpp +++ b/test/test_nfc_read_only/test_main.cpp @@ -17,6 +17,10 @@ Uid known{{0xE0, 0x04, 0x01, 0x08, 0x66, 0x27, 0xD8, 0xD4}}; class Fake final : public nfc::IReadOnlyReader { public: std::vector tags; + // ISO 14443A tags answering anticollision; detection only (#93). + std::vector nfca_tags; + unsigned nfca_inventories = 0; + bool nfca_broken = false; std::vector image; nfc::nfcv::TagGeometry shape{4, 80}; unsigned reads = 0, offs = 0, ons = 0, inventories = 0; @@ -55,6 +59,13 @@ class Fake final : public nfc::IReadOnlyReader { {core::ErrorCategory::nfc_communication, "transport", false}); return Result>::success(tags); } + Result> inventory_nfca() override { + ++nfca_inventories; + if (nfca_broken) + return Result>::failure( + {core::ErrorCategory::nfc_communication, "NFC-A detection I2C", false}); + return Result>::success(nfca_tags); + } Result geometry(const Uid&) override { return Result::success(shape); } @@ -416,6 +427,181 @@ void broken_transport_is_still_immediate() { static_cast(s.snapshot().state)); TEST_ASSERT_NULL(s.snapshot().tag.get()); } +// ---- 1.0.1-beta.5 (#93): ISO 14443A detection next to ISO 15693 ---- +nfc::nfca::Identity ntag() { + const std::uint8_t id[] = {0x04, 0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6}; + return nfc::nfca::Identity::from_wire(core::ByteView(id, sizeof(id)), 0x00, {0x44, 0x00}).value(); +} +nfc::nfca::Identity classic() { + const std::uint8_t id[] = {0xDE, 0xAD, 0xBE, 0xEF}; + return nfc::nfca::Identity::from_wire(core::ByteView(id, sizeof(id)), 0x08, {0x04, 0x00}).value(); +} +void nfca_identity_names_the_tag_kind_from_sak() { + using nfc::nfca::Kind; + TEST_ASSERT_EQUAL_INT(static_cast(Kind::type2), static_cast(ntag().kind())); + TEST_ASSERT_EQUAL_STRING("04A1B2C3D4E5F6", ntag().hex().c_str()); + TEST_ASSERT_EQUAL_STRING("04:A1:B2:C3:D4:E5:F6", ntag().diagnostic().c_str()); + TEST_ASSERT_EQUAL_INT(static_cast(Kind::mifare_classic), static_cast(classic().kind())); + const std::uint8_t id4[] = {1, 2, 3, 4}; + auto make = [&](std::uint8_t sak, bool type1 = false) { + return nfc::nfca::Identity::from_wire(core::ByteView(id4, 4), sak, {0, 0}, type1).value().kind(); + }; + TEST_ASSERT_EQUAL_INT(static_cast(Kind::mifare_classic), static_cast(make(0x18))); // 4K + TEST_ASSERT_EQUAL_INT(static_cast(Kind::mifare_classic), static_cast(make(0x09))); // Mini + TEST_ASSERT_EQUAL_INT(static_cast(Kind::type4), static_cast(make(0x20))); // DESFire + TEST_ASSERT_EQUAL_INT(static_cast(Kind::type4), static_cast(make(0x28))); // Plus, ISO-DEP + TEST_ASSERT_EQUAL_INT(static_cast(Kind::other), static_cast(make(0x04))); // cascade unfinished + TEST_ASSERT_EQUAL_INT(static_cast(Kind::other), static_cast(make(0x00, true))); // Topaz + TEST_ASSERT_EQUAL_INT(static_cast(Kind::other), static_cast(make(0x01))); + TEST_ASSERT_EQUAL_STRING("mifare_classic", nfc::nfca::to_string(Kind::mifare_classic)); + TEST_ASSERT_EQUAL_STRING("NTAG or other Type 2 tag", nfc::nfca::describe(Kind::type2)); + const std::uint8_t bad[] = {1, 2, 3, 4, 5}; + TEST_ASSERT_FALSE(nfc::nfca::Identity::from_wire(core::ByteView(bad, 5), 0, {0, 0}).ok()); + TEST_ASSERT_TRUE(ntag() == ntag()); + TEST_ASSERT_TRUE(ntag() != classic()); +} +void poll_schedule_keeps_the_cadence_and_gives_nfca_the_empty_turns() { + nfc::PollSchedule schedule; + TEST_ASSERT_TRUE(schedule.due(0, false)); + TEST_ASSERT_FALSE(schedule.due(499, false)); + TEST_ASSERT_TRUE(schedule.due(500, false)); + TEST_ASSERT_FALSE(schedule.due(4999, true)); // the error state polls every 5 s + TEST_ASSERT_TRUE(schedule.due(5500, true)); + // NFC-A is asked only when NFC-V answered nothing and tracks nothing. + TEST_ASSERT_TRUE(nfc::PollSchedule::nfca_turn(false, false)); + TEST_ASSERT_FALSE(nfc::PollSchedule::nfca_turn(true, false)); + TEST_ASSERT_FALSE(nfc::PollSchedule::nfca_turn(false, true)); + TEST_ASSERT_FALSE(nfc::PollSchedule::nfca_turn(true, true)); +} +void nfca_tag_is_found_named_and_never_read() { + Fake f; + f.nfca_tags = {ntag()}; + nfc::ReadOnlyService s(f); + tick(f, s); // no stabilization: nothing is read, so nothing can be read wrong + const auto a = s.snapshot(); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::ReadState::unsupported_technology), static_cast(a.state)); + TEST_ASSERT_TRUE(a.present); + TEST_ASSERT_EQUAL(1, a.tag_count); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::Technology::nfca), static_cast(a.technology)); + TEST_ASSERT_TRUE(a.nfca.has_value()); + TEST_ASSERT_TRUE(*a.nfca == ntag()); + TEST_ASSERT_FALSE(a.uid.has_value()); + TEST_ASSERT_NULL(a.tag.get()); + TEST_ASSERT_FALSE(a.error.has_value()); + TEST_ASSERT_EQUAL(0, f.reads); + TEST_ASSERT_EQUAL(0, nfc::uid_only_generation(a)); // no Spoolman lookup by this ID yet + TEST_ASSERT_EQUAL(1, a.generation); + TEST_ASSERT_EQUAL(f.ons, f.offs); + JsonDocument doc; + web::write_nfc(doc.to(), a); + TEST_ASSERT_EQUAL_STRING("unsupported_technology", doc["state"]); + TEST_ASSERT_EQUAL_STRING("nfca", doc["technology"]); + TEST_ASSERT_EQUAL_STRING("04:A1:B2:C3:D4:E5:F6", doc["uid"]); + TEST_ASSERT_EQUAL_STRING("NFC-A / ISO14443A", doc["inventory"]["technology"]); + TEST_ASSERT_EQUAL_STRING("UNSUPPORTED_TYPE", doc["lifecycle"]); + TEST_ASSERT_EQUAL_STRING("fail", doc["decode"]); + TEST_ASSERT_EQUAL_STRING("type2", doc["nfca"]["kind"]); + TEST_ASSERT_EQUAL(7, doc["nfca"]["length"].as()); + TEST_ASSERT_EQUAL(0, doc["nfca"]["sak"].as()); + TEST_ASSERT_EQUAL(0x0044, doc["nfca"]["atqa"].as()); + TEST_ASSERT_EQUAL(0, doc["block_count"].as()); + const auto text = nfc::describe(a); + TEST_ASSERT_TRUE(text.find("Tag type not supported yet") != std::string::npos); + TEST_ASSERT_TRUE(text.find("NTAG or other Type 2 tag (NFC-A)") != std::string::npos); + TEST_ASSERT_TRUE(text.find("ID 04:A1:B2:C3:D4:E5:F6") != std::string::npos); + // Stays found without re-polling anything else; a second kind replaces it. + tick(f, s, 5); + TEST_ASSERT_EQUAL(1, s.snapshot().generation); + f.nfca_tags = {classic()}; + tick(f, s); + TEST_ASSERT_EQUAL(2, s.snapshot().generation); + TEST_ASSERT_EQUAL_STRING("mifare_classic", nfc::nfca::to_string(s.snapshot().nfca->kind())); + // Two NFC-A tags are "multiple", like two NFC-V tags. + f.nfca_tags = {classic(), ntag()}; + tick(f, s); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::ReadState::multiple), static_cast(s.snapshot().state)); + TEST_ASSERT_EQUAL(2, s.snapshot().tag_count); + TEST_ASSERT_FALSE(s.snapshot().nfca.has_value()); + web::write_nfc(doc.to(), s.snapshot()); + TEST_ASSERT_EQUAL_STRING("NFC-A / ISO14443A", doc["inventory"]["technology"]); + TEST_ASSERT_TRUE(doc["uid"].isNull()); +} +void nfca_removal_tolerates_one_missed_poll_like_nfcv() { + Fake f; + f.nfca_tags = {ntag()}; + nfc::ReadOnlyService s(f); + tick(f, s); + f.nfca_tags.clear(); + tick(f, s); + TEST_ASSERT_TRUE(s.snapshot().present); // a weak link, not a removal + TEST_ASSERT_TRUE(s.snapshot().nfca.has_value()); + tick(f, s); + TEST_ASSERT_FALSE(s.snapshot().present); + TEST_ASSERT_FALSE(s.snapshot().nfca.has_value()); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::ReadState::idle), static_cast(s.snapshot().state)); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::Technology::none), static_cast(s.snapshot().technology)); + JsonDocument doc; + web::write_nfc(doc.to(), s.snapshot()); + TEST_ASSERT_TRUE(doc["technology"].isNull()); + TEST_ASSERT_TRUE(doc["nfca"].isNull()); + TEST_ASSERT_EQUAL_STRING("NFC-V / ISO15693", doc["inventory"]["technology"]); +} +void nfcv_is_polled_first_and_nfca_only_on_empty_cycles() { + // The scheduler: an NFC-V tag costs no NFC-A request at all, including on + // its one tolerated missed poll, so its detection and removal timing is + // the same as before #93. + Fake f; + f.tags = {known}; + nfc::ReadOnlyService s(f); + tick(f, s, 3); + TEST_ASSERT_NOT_NULL(s.snapshot().tag.get()); + TEST_ASSERT_EQUAL(0, f.nfca_inventories); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::Technology::nfcv), static_cast(s.snapshot().technology)); + tick(f, s, 10); + TEST_ASSERT_EQUAL(0, f.nfca_inventories); + f.tags.clear(); + tick(f, s); // tolerated miss: still tracking NFC-V, no NFC-A turn + TEST_ASSERT_TRUE(s.snapshot().present); + TEST_ASSERT_EQUAL(0, f.nfca_inventories); + tick(f, s); // removal confirmed on the same poll as before + TEST_ASSERT_FALSE(s.snapshot().present); + TEST_ASSERT_EQUAL(0, f.nfca_inventories); + // From here every empty cycle asks NFC-A once, after NFC-V. + const auto inventories = f.inventories; + tick(f, s, 4); + TEST_ASSERT_EQUAL(4, f.nfca_inventories); + TEST_ASSERT_EQUAL(inventories + 4, f.inventories); + TEST_ASSERT_EQUAL(f.ons, f.offs); + // With an NFC-A tag tracked, NFC-V is still asked first on every cycle and + // wins at once when it answers. + f.nfca_tags = {ntag()}; + tick(f, s); + TEST_ASSERT_TRUE(s.snapshot().nfca.has_value()); + f.tags = {known}; + tick(f, s); + TEST_ASSERT_FALSE(s.snapshot().nfca.has_value()); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::ReadState::stabilizing), static_cast(s.snapshot().state)); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::Technology::nfcv), static_cast(s.snapshot().technology)); + const auto nfca_polls = f.nfca_inventories; + tick(f, s, 2); + TEST_ASSERT_NOT_NULL(s.snapshot().tag.get()); + TEST_ASSERT_EQUAL(nfca_polls, f.nfca_inventories); +} +void nfca_transport_failure_is_a_reader_error() { + Fake f; + f.nfca_broken = true; + nfc::ReadOnlyService s(f); + tick(f, s); + TEST_ASSERT_EQUAL_INT(static_cast(nfc::ReadState::error), static_cast(s.snapshot().state)); + TEST_ASSERT_FALSE(s.snapshot().present); + TEST_ASSERT_EQUAL(f.ons, f.offs); + // An NFC-V tag is still found once the chip answers again. + f.nfca_broken = false; + f.tags = {known}; + f.time += 5000; + tick(f, s, 3); + TEST_ASSERT_NOT_NULL(s.snapshot().tag.get()); +} void setUp() {} void tearDown() {} int main() { @@ -440,5 +626,11 @@ int main() { RUN_TEST(weak_link_survives_one_missed_poll_but_not_two); RUN_TEST(failed_read_is_retried_without_the_error_pause); RUN_TEST(broken_transport_is_still_immediate); + RUN_TEST(nfca_identity_names_the_tag_kind_from_sak); + RUN_TEST(poll_schedule_keeps_the_cadence_and_gives_nfca_the_empty_turns); + RUN_TEST(nfca_tag_is_found_named_and_never_read); + RUN_TEST(nfca_removal_tolerates_one_missed_poll_like_nfcv); + RUN_TEST(nfcv_is_polled_first_and_nfca_only_on_empty_cycles); + RUN_TEST(nfca_transport_failure_is_a_reader_error); return UNITY_END(); } diff --git a/test/test_touch_flow/beta5_cases.hpp b/test/test_touch_flow/beta5_cases.hpp new file mode 100644 index 0000000..8ee5d8f --- /dev/null +++ b/test/test_touch_flow/beta5_cases.hpp @@ -0,0 +1,33 @@ +#pragma once +// 1.0.1-beta.5 touchscreen cases (#93): a tag of another technology (NFC-A) +// is found and named, nothing is read. Included by test_main.cpp after +// beta4_cases.hpp. +namespace beta5 { +using namespace opentag; +using namespace opentag::ui; +using rc23::mentions; + +inline void nfca_tag_is_named_as_not_supported_yet() { + TagFlow f; + // The UI passes the NFCID1 as the uid and the kind as the error text. + const std::string kind = "NTAG or other Type 2 tag (NFC-A)"; + f.observe_tag("04A1B2C3D4E5F6", &kind); + f.observe_lifecycle(services::TagLifecycle::unsupported_type); + const auto s = f.screen(); + TEST_ASSERT_TRUE(mentions(s, "Tag type not supported yet")); + TEST_ASSERT_TRUE(mentions(s, "NTAG or other Type 2 tag (NFC-A)")); + TEST_ASSERT_TRUE(mentions(s, "ID 04A1B2C3D4E5F6")); + TEST_ASSERT_TRUE(mentions(s, "Use an OpenPrintTag on NXP ICODE SLIX2.")); + TEST_ASSERT_FALSE(mentions(s, "can't be used as it is")); + // Only DONE: nothing can be written, linked or cleared. + TEST_ASSERT_EQUAL(1, s.count); + TEST_ASSERT_EQUAL_STRING("DONE", s.buttons[0].text.c_str()); + // Removing it returns to the empty prompt; an NFC-V tag after it is a new flow. + f.observe_tag("", nullptr); + f.observe_lifecycle(services::TagLifecycle::no_tag); + TEST_ASSERT_TRUE(mentions(f.screen(), "PLACE A TAG")); + f.observe_tag("E004000000000001", nullptr); + f.observe_lifecycle(services::TagLifecycle::blank_compatible); + TEST_ASSERT_TRUE(mentions(f.screen(), "BLANK TAG")); +} +} // namespace beta5 diff --git a/test/test_touch_flow/fixtures.hpp b/test/test_touch_flow/fixtures.hpp index 13844ef..caa7efc 100644 --- a/test/test_touch_flow/fixtures.hpp +++ b/test/test_touch_flow/fixtures.hpp @@ -17,6 +17,7 @@ inline void export_touch_fixtures() { TagFlow f;f.uid="E004000000000001";f.material="SUNLU PLA+ 2.0 Black"; emit("no-tag",f);f.lifecycle=services::TagLifecycle::reading;emit("reading",f); f.multiple_tags=true;emit("two-tags",f);f.multiple_tags=false; + {const std::string kind="NTAG or other Type 2 tag (NFC-A)";f.observe_tag("04A1B2C3D4E5F6",&kind);f.observe_lifecycle(services::TagLifecycle::unsupported_type);emit("unsupported-type",f);f.observe_tag("E004000000000001",nullptr);} f.lifecycle=services::TagLifecycle::blank_compatible;emit("blank",f); f.lifecycle=services::TagLifecycle::linked;f.current_spool=28;emit("tag",f); f.lifecycle=services::TagLifecycle::unlinked;emit("unlinked",f); diff --git a/test/test_touch_flow/test_main.cpp b/test/test_touch_flow/test_main.cpp index a16b5a6..95496d1 100644 --- a/test/test_touch_flow/test_main.cpp +++ b/test/test_touch_flow/test_main.cpp @@ -239,6 +239,7 @@ void production_sink_bounds_streamed_bytes() { #include "train_cases.hpp" #include "rc23_cases.hpp" #include "beta4_cases.hpp" +#include "beta5_cases.hpp" void setUp() {} void tearDown() {} int main() {UNITY_BEGIN();RUN_TEST(lifecycle);RUN_TEST(sensible_actions);RUN_TEST(bounded_paging_and_selection);RUN_TEST(short_pages_use_next_offset);RUN_TEST(reassign_review);RUN_TEST(review_warns_from_writer_preview);RUN_TEST(action_buttons_are_coloured_by_role);RUN_TEST(failed_lookup_still_offers_linking);RUN_TEST(created_spool_previews_once);RUN_TEST(reading_and_two_tags);RUN_TEST(loading_spool_says_what_it_does);RUN_TEST(spool_colour_ring);RUN_TEST(exact_confirmation);RUN_TEST(clear_retry_to_immediate_assign);RUN_TEST(filament_create);RUN_TEST(stale_operation_rejected);RUN_TEST(keyboard_geometry);RUN_TEST(keyboard_value_and_validation);RUN_TEST(production_sink_closes_on_disposition);RUN_TEST(production_sink_bounds_streamed_bytes);RUN_TEST(rc10::assignment_dialog_only_affirmative_labels_confirm);RUN_TEST(rc10::progress_never_shows_blocks_for_spoolman_work);RUN_TEST(rc10::filament_review_formats_diameter);RUN_TEST(rc10::unknown_empty_spool_weight_must_be_entered);RUN_TEST(rc10::vendor_search_is_kept_while_paging);RUN_TEST(rc10::destructive_and_duplicate_actions);RUN_TEST(rc10::recovery_can_be_skipped_explicitly);RUN_TEST(rc10::metadata_candidates_can_be_chosen_on_the_touchscreen); RUN_TEST(train::nfc_error_does_not_replace_recovery_instructions); @@ -256,4 +257,5 @@ int main() {UNITY_BEGIN();RUN_TEST(lifecycle);RUN_TEST(sensible_actions);RUN_TES RUN_TEST(rc23::writer_errors_are_shown_in_plain_words); RUN_TEST(beta4::confirmation_notice_is_shown_until_the_tag_is_linked); RUN_TEST(beta4::review_shows_generic_preview_warnings); + RUN_TEST(beta5::nfca_tag_is_named_as_not_supported_yet); export_touch_fixtures();return UNITY_END();} diff --git a/test/test_ui_contract/test_main.cpp b/test/test_ui_contract/test_main.cpp index 97a1ba4..8efc74e 100644 --- a/test/test_ui_contract/test_main.cpp +++ b/test/test_ui_contract/test_main.cpp @@ -447,6 +447,33 @@ void test_rc23_touchscreen_wording_is_plain() { } // namespace +// 1.0.1-beta.5 (#93): a tag of another technology is named on every screen +// instead of leaving "Place a spool" / "PLACE A TAG" on, and nothing reads it. +void test_beta5_nfca_tag_is_named_not_read() { + const auto ui = read_source("src/ui/ui_service.cpp"); + const auto status = method(ui, "const char* nfc_status_text(", "void style_screen("); + TEST_ASSERT_TRUE(status.find("unsupported_technology: return \"Tag found. Its type is not supported yet.\"") != std::string::npos); + const auto home = method(ui, "void UiService::refresh_home_page(", "void UiService::refresh_printer_page()"); + TEST_ASSERT_TRUE(home.find("ReadState::unsupported_technology") != std::string::npos); + TEST_ASSERT_TRUE(home.find("\"Tag type not supported yet\"") != std::string::npos); + const auto tags = method(ui, "void UiService::refresh_tags()", "void UiService::weight_update_callback("); + TEST_ASSERT_TRUE(tags.find("tag.nfca->hex()") != std::string::npos); + TEST_ASSERT_TRUE(tags.find("ReadState::unsupported_technology") != std::string::npos); + const auto flow = read_source("src/ui/tag_flow.hpp"); + TEST_ASSERT_TRUE(flow.find("TagLifecycle::unsupported_type") != std::string::npos); + TEST_ASSERT_TRUE(flow.find("\"Tag type not supported yet\\n\"") != std::string::npos); + // The service asks NFC-A only inside its own turn, after NFC-V, and that + // turn never reads tag memory. + const auto service = read_source("src/nfc/read_only_service.cpp"); + const auto turn = method(service, "core::Result ReadOnlyService::poll_nfca(", "void ReadOnlyService::poll()"); + TEST_ASSERT_TRUE(turn.find("reader_.inventory_nfca()") != std::string::npos); + TEST_ASSERT_TRUE(turn.find("read_blocks") == std::string::npos); + TEST_ASSERT_TRUE(turn.find("read_tag(") == std::string::npos); + const auto poll = method(service, "void ReadOnlyService::poll()", "} // namespace opentag::nfc"); + TEST_ASSERT_TRUE(poll.find("reader_.inventory_nfca()") == std::string::npos); + TEST_ASSERT_LESS_THAN(poll.find("poll_nfca(now)"), poll.find("reader_.inventory()")); +} + int main(int argc, char** argv) { UNITY_BEGIN(); RUN_TEST(test_weigh_readback_uses_captured_policy_behind_revision_fence); @@ -468,5 +495,6 @@ int main(int argc, char** argv) { RUN_TEST(test_rc23_setup_waits_for_its_save_and_selects_the_printer); RUN_TEST(test_rc23_brightness_is_saved_and_follows_settings); RUN_TEST(test_rc23_touchscreen_wording_is_plain); + RUN_TEST(test_beta5_nfca_tag_is_named_not_read); return UNITY_END(); } diff --git a/tools/check_production_nfc.py b/tools/check_production_nfc.py index 98bfa45..24dacd4 100644 --- a/tools/check_production_nfc.py +++ b/tools/check_production_nfc.py @@ -26,6 +26,11 @@ def check(): "rfalNfcvPollerGetSystemInformation", "rfalNfcvPollerExtendedGetSystemInformation", "rfalNfcvPollerReadSingleBlock", "rfalNfcvPollerExtendedReadSingleBlock", "rfalNfcvPollerReadMultipleBlocks", "rfalNfcvPollerExtendedReadMultipleBlocks", + # 1.0.1-beta.5 (#93): ISO 14443A detection only. Mode switch, REQA and + # anticollision give the NFCID1, SAK and ATQA; no T2T, MIFARE or ISO-DEP + # command is permitted, so no NFC-A memory is read or written. + "rfalNfcaPollerInitialize", "rfalNfcaPollerTechnologyDetection", + "rfalNfcaPollerFullCollisionResolution", } excluded = set() for source in (ROOT / "src").rglob("*"): @@ -39,6 +44,10 @@ def check(): writer_binding = relative == "src/hardware/nfc/st25r3916b/openprinttag_write_binding.cpp" permitted = allowed | ({"rfalNfcvPollerWriteSingleBlock"} if writer_binding else set()) assert calls <= permitted, f"unexpected RFAL calls in {relative}: {calls - permitted}" + # NFC-A stays in the one detection method of the I2C driver. + if any(call.startswith("rfalNfca") for call in calls): + assert relative == "src/hardware/nfc/st25r3916b/i2c_reader.cpp", relative + assert text.count("rfalNfcaPoller") == 3, "NFC-A detection must stay three RFAL calls" if "commit_openprinttag_block(" in text: assert relative in {"src/nfc/openprinttag_writer.hpp", "src/nfc/openprinttag_writer.cpp", "src/hardware/nfc/st25r3916b/i2c_reader.hpp", @@ -114,7 +123,7 @@ def check_binary(): candidates = list(core.glob("packages/toolchain-xtensa-esp32s3/bin/xtensa-esp32s3-elf-nm*")) assert elf.is_file() and candidates, "production ELF/toolchain required for binding check" symbols = subprocess.check_output([str(candidates[0]), "-C", "--defined-only", str(elf)], text=True) - forbidden = re.findall(r".*(?:VerifiedWriter::|rfalNfcvPoller\w*(?:Lock|Protect|Password|Privacy|SetAFI|SetDSFID|SetEAS|WriteMultiple|ExtendedWrite))[^\n]*", symbols) + forbidden = re.findall(r".*(?:VerifiedWriter::|rfalNfcvPoller\w*(?:Lock|Protect|Password|Privacy|SetAFI|SetDSFID|SetEAS|WriteMultiple|ExtendedWrite)|rfalT2TPoller(?:Read|Write)|rfalNfcaPollerSelect|rfalIsoDep\w*(?:Apdu|Transceive)|rfalNfcDepPoller)[^\n]*", symbols) assert not forbidden, "unapproved destructive runtime linked: " + "\n".join(forbidden) assert "OpenPrintTagWriter::execute" in symbols and "I2cReader::commit_openprinttag_block" in symbols # Xtensa longcalls load literal addresses then callx; inspect relocation diff --git a/tools/test_web_transport.mjs b/tools/test_web_transport.mjs index 4e40930..ebbefd5 100644 --- a/tools/test_web_transport.mjs +++ b/tools/test_web_transport.mjs @@ -1642,6 +1642,34 @@ test('Tags renders bounded disabled, initializing, ready, detected, multiple, an assert.equal(document.getElementById('nfc-read-status').textContent.includes('255'), false, 'raw memory must not be rendered into routine status UI'); }); +test('an NFC-A tag is named as not supported yet in Manage tag and on the Dashboard (#93)', () => { + const { T, document } = loadApplication(); + const nfca = { state: 'unsupported_technology', read_only: true, available: true, bringup_state: 'ready', + present: true, technology: 'nfca', uid: '04:A1:B2:C3:D4:E5:F6', decode: 'fail', lifecycle: 'UNSUPPORTED_TYPE', + nfca: { nfcid1: '04:A1:B2:C3:D4:E5:F6', length: 7, sak: 0, atqa: 68, kind: 'type2', description: 'NTAG or other Type 2 tag' }, + inventory: { present: true, tag_count: 1, uid: '04:A1:B2:C3:D4:E5:F6', technology: 'NFC-A / ISO14443A' } }; + T.renderNfc(nfca); + assert.equal(document.getElementById('nfc-summary').textContent, 'Tag type not supported yet'); + assert.match(document.getElementById('nfc-guidance').textContent, /NTAG or other Type 2 tag \(NFC-A/); + assert.match(document.getElementById('nfc-guidance').textContent, /nothing was read/); + assert.equal(document.getElementById('nfc-badge').textContent, 'Not supported yet'); + assert.equal(document.getElementById('nfc-technology').textContent, 'NFC-A / ISO14443A'); + assert.equal(document.getElementById('nfc-uid').textContent, '04:A1:B2:C3:D4:E5:F6'); + assert.equal(document.getElementById('nfc-decode-chip').textContent, 'Not supported yet'); + // The Dashboard does not pretend to read it, and offers no tag action. + T.state.currentTag = nfca; T.state.tagWorkflow = { tag_lifecycle: 'UNSUPPORTED_TYPE', stage: 'awaiting_spool' }; + T.renderCurrentSpool(); + assert.equal(document.getElementById('current-name').textContent, 'Tag type not supported yet'); + assert.equal(document.getElementById('current-number').textContent, 'NTAG or other Type 2 tag · not read'); + assert.equal(document.getElementById('current-tag').textContent, 'Not supported yet'); + assert.match(document.getElementById('current-link').textContent, /NFC-V \(ISO 15693\) only/); + assert.equal(document.getElementById('writer-open').hidden, true); + assert.equal(document.getElementById('clear-open').hidden, true); + // Two NFC-A tags read as multiple NFC-A tags, not NFC-V. + T.renderNfc({ available: true, bringup_state: 'ready', technology: 'nfca', + inventory: { tag_count: 2, present: true, technology: 'NFC-A / ISO14443A' } }); + assert.equal(document.getElementById('nfc-summary').textContent, 'Multiple NFC-A tags detected'); +}); test('product navigation exposes four product destinations and preserves legacy deep links', () => { const { T, document, context } = loadApplication(); const pages = ['home', 'inventory', 'printer', 'settings']; diff --git a/web-flasher/manifest.json b/web-flasher/manifest.json index a7247f8..9a96ef9 100644 --- a/web-flasher/manifest.json +++ b/web-flasher/manifest.json @@ -1,6 +1,6 @@ { "name": "OpenTag Station", - "version": "1.0.1-beta.4", + "version": "1.0.1-beta.5", "new_install_prompt_erase": true, "new_install_improv_wait_time": 0, "builds": [