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>
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 (
SimSessionowns the serial port) or reader-mode (Proxmark3owns it), never both live. They can't share/dev/ttyACM*. simis a lifecycle wrapper aroundSimSession(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()→connecteffect (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, classPm3pyPlugin(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 …:
SimSessionand the common tag classes (NTAG213/215/216,MifareUltralight*,MifareClassicTag,NfcType2Tag,Tag15693/NfcType5Tag, implant factoriesxNT/xM1/NExT/…). These are re-exported frompm3py.sim(pm3py/sim/__init__.py:71).readerand/orsim(the resource objects, see below), bound by the resource layer.- NDEF helpers (
ndef_uri,ndef_text,ndef_mimefrompm3py.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
simexposes a thin facade over the session:sim.start(tag, *, tagtype=…, trace=False)→ dispatch tosess.start_14a(tag, …)(sim_session.py:116) orsess.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()(orsess.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(): buildProxmark3(port, baudrate)and connect. Since pyws resources start on the ResourceManager's async loop,await pm3.connect()fits naturally; the boundreaderobject 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(…)andtag.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_pageetc. replay), butsync()is withheld, so after load the model is current while firmware is not. Since arming is explicit, yoursim.start(tag)(orsim.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
WorkspaceReloadedlater 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;readerresource (connect/disconnect) + namespace injection.pywsin a reader-mode workspace → connectedreader. Tested against pm3py's existingAsyncMocktransport (no hardware). - P2 — sim resource.
simresource wrappingSimSession(open on start, explicitsim.start(tag)/sim.stop,sim.push,sim.frames, clean teardown). Mock-tested where the serial layer allows; otherwise a thin fakeSimSession. - 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)pm3workspace example. - P4 — hardware acceptance.
pyws pm3in 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
AsyncMocktransport pattern for thereaderresource. - For
sim, inject a fakeSimSession(or patchSimSession.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 — theWorkspaceLoadedhook 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_loopis 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.