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

9.0 KiB

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:
    [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.framessess.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.

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 → unknownreplay (pure-Python model edits, no device). So the tag rebuilds on load.
  • tag.sync()writewithheld on autoreplay. Reconstruction rebuilds the model, never re-pushes to firmware.
  • sim.start(tag)simwithheld → 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.