"""pyws workspace plugin for pm3py. Turns pm3py into a pyws workspace: connects the Proxmark3 (reader-mode) or opens a live ``SimSession`` (sim-mode), and injects the transponder / sim classes into the session namespace. Discovered by the pyws engine via the ``pyws.plugins`` entry point (see pyproject.toml). pyws must be installed alongside pm3py. A workspace uses one connection mode — ``reader`` OR ``sim`` — never both, since they contend on the serial port. The sim is armed explicitly (``sim.start(tag)``); opening the workspace only opens the port. See docs/plans/2026-07-05-pm3py-pyws-plugin-plan.md. """ from __future__ import annotations import logging from typing import Any from pyws import Resource, ResourceStatus, WorkspacePlugin log = logging.getLogger("pm3py.pyws") #: names injected into the workspace namespace from pm3py.sim (guarded by hasattr) _SIM_EXPORTS = [ "SimSession", "NTAG210", "NTAG212", "NTAG213", "NTAG215", "NTAG216", "MifareUltralight", "MifareUltralightC", "MifareUltralightEV1", "MifareClassicTag", "NfcType2Tag", "NfcType4Tag", "Tag15693", "NfcType5Tag", "IcodeSlix2Tag", "xNT", "xM1", "NExT", "FlexDF", "ndef_uri", "ndef_text", "ndef_mime", ] class ReaderResource(Resource): """A connected ``Proxmark3`` client, exposed as ``reader`` in the namespace. Opens the device on session start and drops it on stop. A missing / unreachable device is non-fatal — the resource reports ``disconnected`` so you can still work offline. ``client`` is injectable for tests. """ kind = "reader" def __init__(self, name: str = "reader", config: dict | None = None, *, client: Any = None): super().__init__(name, obj=None, config=config or {}) self._client = client async def start(self) -> None: self.status = ResourceStatus.CONNECTING try: if self._client is None: from pm3py import Proxmark3 self._client = Proxmark3( self.config.get("port"), self.config.get("baudrate", 115200) ) await self._client.connect() except Exception as exc: # no device / connect failure — non-fatal self.status = ResourceStatus.DISCONNECTED self.detail = f"no device ({type(exc).__name__}: {exc})" self._client = None return self.obj = self._client self.status = ResourceStatus.CONNECTED fw = getattr(self._client, "firmware", None) self.detail = str(getattr(fw, "version_string", "") or "connected") async def stop(self) -> None: if self._client is not None: try: await self._client.disconnect() except Exception: pass self.status = ResourceStatus.DISCONNECTED class Sim: """User-facing live-sim controls, bound as ``sim`` in the namespace. Arming is explicit: opening the workspace opens the port, but the sim goes live only when you call ``sim.start(tag)``. Push model edits to a running sim with ``sim.push(tag)``. """ def __init__(self, session: Any): self._sess = session def start(self, tag: Any, **kwargs: Any): """Arm the live sim for ``tag`` — dispatches 14a vs 15693 by tag type.""" from pm3py.sim import Tag14443A, Tag15693 if isinstance(tag, Tag15693): return self._sess.start_15693(tag, **kwargs) if isinstance(tag, Tag14443A): return self._sess.start_14a(tag, **kwargs) raise TypeError(f"cannot simulate {type(tag).__name__}: not a 14a or 15693 tag") def stop(self): return self._sess.stop() def push(self, tag: Any): """Push model edits to the running sim (``tag.sync()``).""" return tag.sync() @property def frames(self): """Decoded reader↔tag trace frames from the running sim.""" return self._sess.entries @property def session(self): return self._sess def __repr__(self) -> str: return f"" class SimResource(Resource): """A live ``SimSession``, exposed as ``sim`` in the namespace. Opens the port on session start; the sim is armed explicitly via ``sim.start(tag)``. A missing device is non-fatal (disconnected). ``session`` is injectable for tests. """ kind = "sim" def __init__(self, name: str = "sim", config: dict | None = None, *, session: Any = None): super().__init__(name, obj=None, config=config or {}) self._sess = session async def start(self) -> None: self.status = ResourceStatus.CONNECTING try: if self._sess is None: from pm3py.sim import SimSession self._sess = SimSession.open( self.config.get("port", "/dev/ttyACM0"), self.config.get("baudrate", 115200), ) except Exception as exc: # no device — non-fatal self.status = ResourceStatus.DISCONNECTED self.detail = f"no device ({type(exc).__name__}: {exc})" self._sess = None return self.obj = Sim(self._sess) self.status = ResourceStatus.CONNECTED self.detail = "ready (not armed — call sim.start(tag))" async def stop(self) -> None: if self._sess is not None: try: self._sess.close() # stop() + close serial + unbind tag except Exception: pass self.status = ResourceStatus.DISCONNECTED class Pm3pyPlugin(WorkspacePlugin): name = "pm3py" version = "0.1.0" #: the first connection resource created wins the serial port (reader OR sim, not both) _mode: str | None = None #: replay-safety effect patterns (pyws safe-by-default deny-list): write / field / sim / #: destructive are withheld on autoreplay; connect / read and pure model edits replay. #: Patterns are matched against the FULL dotted call path (e.g. reader.hf.mfu.wrbl). effects = { "connect": ["reader.connect", "reader.disconnect", "sim.open", "sim.close"], "read": [ "reader.hf.*.scan", "reader.hf.*.rdbl", "reader.hf.*.rdsc", "reader.hf.mfu.rdbl", "reader.hf.*.inventory", "reader.lf.*.read", "*.read_block*", ], "field": ["reader.hf.tune", "reader.hf.dropfield", "reader.lf.tune", "reader.hf.search"], "write": [ "reader.hf.*.wrbl", "reader.hf.*.writebl", "reader.lf.*.writebl", "*.sync", "sim.push", "*.write_block*", ], "sim": ["sim.start", "sim.stop", "sim.start_14a", "sim.start_15693", "reader.hf.*.sim"], "destructive": ["*.format*", "*.lock*"], } def namespace(self, session) -> dict[str, Any]: import pm3py import pm3py.sim as sim ns: dict[str, Any] = {"Proxmark3": pm3py.Proxmark3} for name in _SIM_EXPORTS: obj = getattr(sim, name, None) if obj is not None: ns[name] = obj return ns def create_resource(self, name: str, config: dict, session): if name in ("reader", "sim"): if self._mode is not None and self._mode != name: log.warning( "pm3py: workspace declares both %r and %r, which contend on the serial " "port; skipping %r", self._mode, name, name, ) return None self._mode = name if name == "reader": return ReaderResource(name, config) if name == "sim": return SimResource(name, config) return None