Files
pm3py/docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md
michael 829f254194 Add pyws workspace plugin (P1: reader resource + namespace + effects)
Exposes pm3py as a pyws workspace, discovered via the pyws.plugins entry point:
- ReaderResource: connects/disconnects a Proxmark3 as `reader`; a missing device is
  non-fatal (reports disconnected)
- namespace injection of the transponder/sim classes, SimSession and Proxmark3
- effect declarations so autoreplay/replay withhold writes/field/sim/destructive while
  reads and pure model edits replay

Hardware-free tests via AsyncMock. The live `sim` resource is P2.
See docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 07:56:13 -07:00

159 lines
9.0 KiB
Markdown

# pm3py ↔ pyws plugin — implementation plan (2026-07-05)
A pyws plugin, living in **this** repo, that turns pm3py into a first-class pyws workspace:
open `pyws pm3` and you land in a shell with the reader connected (or the sim ready), the
transponder classes in scope, your tags reconstructed from history, and replay/save that
understand which pm3py operations touch hardware.
pyws itself stays domain-agnostic; all pm3py knowledge lives here and is discovered through the
`pyws.plugins` entry point.
## Decisions (locked with the user)
- **Sim arming is explicit (option b).** On load the connection opens and the tag model is
reconstructed, but the sim goes live only when *you* call `sim.start(tag)`. No auto-arm.
- **One connection, one mode per workspace** — sim-mode (`SimSession` owns the serial port) or
reader-mode (`Proxmark3` owns it), never both live. They can't share `/dev/ttyACM*`.
- **`sim` is a lifecycle wrapper around `SimSession`** (which already runs its own trace thread) —
response-table sims only for now, since upstream's WTX relay (`sim_session.py:_relay_loop`) is a
stub for future 14a Layer-4.
- **14a / NTAG first**, ISO-15693 a fast-follow.
- `SimSession.open()``connect` effect (replayable); it raises loudly if no PM3 is attached — the
resource surfaces that as a *disconnected* status on load (non-fatal) and as a clear error on an
explicit call.
## Wiring
- Module: `pm3py/pyws_plugin.py`, class `Pm3pyPlugin(WorkspacePlugin)`.
- Entry point in pm3py's `pyproject.toml`:
```toml
[project.entry-points."pyws.plugins"]
pm3py = "pm3py.pyws_plugin:Pm3pyPlugin"
[project.optional-dependencies]
pyws = ["pyws"] # installs the engine; dependency points pm3py -> pyws, never the reverse
```
- Install for use: `pip install -e ".[pyws]"` into the pm3py venv (which already has pm3py + deps).
## What the plugin provides
### Namespace (`Pm3pyPlugin.namespace(session)`)
Inject what a pm3py workspace user reaches for, so `startup.py` needs no `from pm3py… import …`:
- `SimSession` and the common tag classes (`NTAG213/215/216`, `MifareUltralight*`,
`MifareClassicTag`, `NfcType2Tag`, `Tag15693`/`NfcType5Tag`, implant factories `xNT/xM1/NExT/…`).
These are re-exported from `pm3py.sim` (`pm3py/sim/__init__.py:71`).
- `reader` and/or `sim` (the resource objects, see below), bound by the resource layer.
- NDEF helpers (`ndef_uri`, `ndef_text`, `ndef_mime` from `pm3py.sim.type5`).
Keep it a curated set, not a wildcard dump — anything else is a normal `from pm3py.sim import X`.
### `sim` resource (sim-mode) — `create_resource("sim", cfg, session)`
A pyws `Resource` wrapping a `SimSession`.
- `start()` (resource lifecycle, at session start): `SimSession.open(port, baudrate)`
(`sim_session.py:73`). Port from cfg (`sim: {port: /dev/ttyACM0}`), else pm3py auto-detect.
On failure (no device) → catch, `status = disconnected`, log a notice; **do not crash the
session** (you can still edit tags offline).
- Bound object `sim` exposes a thin facade over the session:
- `sim.start(tag, *, tagtype=…, trace=False)` → dispatch to `sess.start_14a(tag, …)`
(`sim_session.py:116`) or `sess.start_15693(tag, …)` (`:226`) by tag base class. **This is the
explicit arm.** Effect: `sim`.
- `sim.stop()` → `sess.stop()` (`:515`). Effect: `sim`.
- `sim.push(tag=None)` → `tag.sync()` (or `sess.sync()`), the "push my edits to the running sim"
step. Effect: `write`.
- `sim.frames` → `sess.entries` (decoded reader↔tag trace).
- `stop()` (session exit): `sess.stop(); sess.close()` (`:531`) — port released, tag unbound.
### `reader` resource (reader-mode) — `create_resource("reader", cfg, session)`
A pyws `Resource` wrapping `Proxmark3` (`core/client.py:50`).
- `start()`: build `Proxmark3(port, baudrate)` and connect. Since pyws resources start on the
ResourceManager's async loop, `await pm3.connect()` fits naturally; the bound `reader` object is
the connected client (`.hw/.hf/.lf`, `pm3.hf.iso14a.scan()`, `pm3.hf.mfu.rdbl()`, …). No-device →
disconnected status, non-fatal.
- `stop()`: `await pm3.disconnect()`.
### One mode per workspace
A workspace declares exactly one of `reader:` / `sim:` under `resources:`. If both are declared,
the plugin refuses the second with a clear message (they contend on the port). In-session switching
(`sim.stop()` then connect a reader) is possible but not the default path.
### `tag` — user-created, **not** a resource
The tag model is created by you (`authorized_tag = NTAG213(uid=…, password=…, pack=…)`) in
`startup.py` or interactively, and reconstructed on load by autoreplay. It is *not* a plugin
resource. (Optional future convenience: a `tag:` resource that builds a default tag from yaml
config — deferred; yaml is a poor place for uid/keys/memory.)
## Effect declarations (replay safety)
`Pm3pyPlugin.effects` — pattern policy consumed by pyws's classifier. Safe-by-default deny-list:
anything classified `write/field/sim/destructive` is withheld on autoreplay; everything else
(incl. `connect`, `read`, pure model edits) replays.
```python
effects = {
"connect": ["sim.open", "sim.close", "reader.connect", "reader.disconnect"],
"read": ["reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc",
"reader.hf.mfu.rdbl", "reader.hf.*.inventory", "*.read_block*"],
"field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune"],
"write": ["reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl",
"*.sync", "*.write_block*"],
"sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693",
"reader.hf.*.sim"],
"destructive": ["*.format*", "*.lock*"],
}
```
Consequences (the important ones):
- `authorized_tag = NTAG213(…)` and `tag.set_page(4, …)` / `tag.set_ndef(…)` → no match → `unknown`
→ **replay** (pure-Python model edits, no device). So the tag rebuilds on load.
- `tag.sync()` → `write` → **withheld** on autoreplay. Reconstruction rebuilds the *model*, never
re-pushes to firmware.
- `sim.start(tag)` → `sim` → **withheld** → the sim is armed explicitly (decision b).
- `reader.hf.iso14a.scan()` → `read` → replays; `reader.hf.mfu.wrbl(…)` → `write` → withheld.
Patterns are refined against real method names during P3; treat the above as the shape.
## How it rides pyws's features
- **Autoreplay + `tag.sync()` ordering** — the model rebuilds from history (`set_page` etc. replay),
but `sync()` is withheld, so after load the *model* is current while firmware is not. Since arming
is explicit, your `sim.start(tag)` (or `sim.push()`) is what pushes the final model — no stale-sync
hazard, and no arming before the tag exists.
- **save() / state.py** — the tag is a live object → not written to `state.py` (only round-trippable
data is). It comes back via history replay of its construction + edits. Keys/uids that you typed as
literals persist fine; the object is rebuilt, not serialized.
- **Events** — the plugin may subscribe to `WorkspaceReloaded` later for niceties (e.g. warn if the
live sim is now serving a tag you've edited but not re-pushed). Not required for v1.
- **Serial arbitration** — enforced by one-mode-per-workspace; the resource owns the single port.
## Phases
- **P1 — reader + namespace.** `Pm3pyPlugin` + entry point + `[pyws]` extra; `reader` resource
(connect/disconnect) + namespace injection. `pyws` in a reader-mode workspace → connected `reader`.
Tested against pm3py's existing `AsyncMock` transport (no hardware).
- **P2 — sim resource.** `sim` resource wrapping `SimSession` (open on start, explicit
`sim.start(tag)` / `sim.stop`, `sim.push`, `sim.frames`, clean teardown). Mock-tested where the
serial layer allows; otherwise a thin fake `SimSession`.
- **P3 — effects + docs.** Effect patterns above, verified against real method names; add the pyws
integration section to pm3py's `CLAUDE.md`; a documented (non-scaffolded) `pm3` workspace example.
- **P4 — hardware acceptance.** `pyws pm3` in sim-mode → create/reconstruct a tag → `sim.start(tag)`
→ read it back with the ACR1552U. The real end-to-end.
## Testing (hardware-free)
- Reuse pm3py's `AsyncMock` transport pattern for the `reader` resource.
- For `sim`, inject a fake `SimSession` (or patch `SimSession.open`) so the resource lifecycle,
arming, effect classification, and teardown are all testable without a PM3.
- pyws-side already covers resource lifecycle, effect gating, autoreplay — the plugin tests only its
own wiring.
## Open / future
- ISO-15693 sim path (`start_15693`, response-table compile/upload).
- Auto-arm option (opt-in `sim.live: true`) if the explicit model gets tedious — the `WorkspaceLoaded`
hook is already there.
- Dual-interface (NTAG5, PM3 RF + MCU I2C via `DualInterfaceSession`) as a distinct resource.
- WTX relay for 14a Layer-4 once upstream `_relay_loop` is real.
## pm3py docs to update (P3)
- `CLAUDE.md`: a short "pyws integration" section — entry point, `[pyws]` extra, the reader/sim
resources, and the effect/replay semantics.