Files
pm3py/pm3py/pyws_plugin.py
michael 3aabb64402 pyws plugin P2: live sim resource
- Sim facade (bound as `sim`): start(tag) arms the sim, dispatching 14a/15693 by tag
  type; stop(), push(tag) -> tag.sync(), frames -> decoded trace
- SimResource: opens SimSession on load, closes on exit; a missing device is non-fatal
- one connection mode per workspace (reader OR sim) — the second is refused (serial contention)
- sim.push added to the write effect patterns (withheld on autoreplay)

Arming stays explicit. Hardware-free tests via injected sessions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:50:02 -07:00

207 lines
7.5 KiB
Python

"""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"<Sim session={self._sess!r}>"
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