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>
159 lines
9.0 KiB
Markdown
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.
|