# 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.