Async Python client for the JMRI web server.
pyjmri is an async Python client for the JMRI web server. It lets you drive a JMRI-controlled model railroad — turnouts, sensors, lights, throttles — from Python scripts using async/await, instead of JMRI's bundled Jython. In the next five minutes, you'll install the library, connect to a running JMRI instance, discover the layout, and flip a turnout.
- Python 3.11 or newer.
- JMRI 5.14 or later, running with its web server enabled at
http://localhost:12080. - A panel file open in JMRI with at least one turnout defined.
To verify JMRI's web server is up, open http://localhost:12080/json/v5/version in a browser — you should see a JSON envelope reporting the JSON API version.
uv is recommended (it's the modern Python toolchain pyjmri is developed against); pip works as a universal fallback:
uv add pyjmripip install pyjmriBefore writing anything, run a quick smoke test to confirm the package is healthy. First, check it imports and reports its version — this needs no JMRI:
python -c "import pyjmri, importlib.metadata as m; print('pyjmri', m.version('pyjmri'), '— import OK')"You should see something like pyjmri 1.2.0 — import OK.
Next, confirm pyjmri can reach JMRI and read your layout. This is read-only — it discovers and counts entities across all three subsystems but changes nothing on the layout, so it's safe to run anytime. Save it as smoke_test.py and run python smoke_test.py:
import asyncio
from pyjmri import Client
async def main() -> None:
async with Client() as jmri: # defaults to localhost:12080
layout = await jmri.discover()
print(f"layout: {len(layout.turnouts)} turnouts, {len(layout.sensors)} sensors, "
f"{len(layout.routes)} routes, {len(layout.blocks)} blocks")
ops = await jmri.discover_operations()
print(f"operations: {len(ops.locations)} locations, {len(ops.trains)} trains, "
f"{len(ops.cars)} cars, {len(ops.engines)} engines")
roster = await jmri.discover_roster()
print(f"roster: {len(roster)} locomotives")
asyncio.run(main())Non-zero counts mean pyjmri installed, connected to JMRI, and read your layout — you're ready for the first script below. If it can't connect, you'll get a clear JMRIConnectionError naming the URL it tried; re-check that JMRI's web server is up (see Prerequisites).
Maintainers verifying a published release (rather than an end-user install) can run
scripts/smoke_test_published.sh <version> [jmri-url]from a repo checkout — it installs the wheel from PyPI into a throwaway environment and runs this same discovery sweep. SeeCONTRIBUTING.md.
Save this as quickstart.py and run python quickstart.py:
import asyncio
from pyjmri import Client, TurnoutState
async def main() -> None:
async with Client() as jmri:
layout = await jmri.discover()
turnout = next(iter(layout.turnouts.values()))
print(f"name={turnout.name} user_name={turnout.user_name} initial state={turnout.state.name}")
target = TurnoutState.THROWN if turnout.state is TurnoutState.CLOSED else TurnoutState.CLOSED
await turnout.set_state(target)
print(f"final state={target.name}")
asyncio.run(main()) # in a Jupyter notebook, use: await main()Two lines, with the exact values depending on your panel file:
name=NT400 user_name=North Yard Lead initial state=CLOSED
final state=THROWN
The two states are intentionally opposite — the script reads the current state, then commands the opposite. Run it again and the values will swap. The final state line means JMRI accepted the command — see the Limitations section below for what "accepted" does and does not imply at the layout. If initial state=UNKNOWN appears instead of CLOSED or THROWN, that is normal — JMRI reports UNKNOWN for any turnout not yet commanded in the current session.
The repo includes explorepyjmri.ipynb, a Jupyter notebook that walks through the same quickstart broken into separate cells — imports, logging setup, client creation, and the turnout flip. It's a convenient sandbox for poking at JMRI interactively without restarting a script every time.
To use it:
- Open
explorepyjmri.ipynbin Jupyter, VS Code, Cursor, or any other notebook-capable editor. - Select a kernel that has
pyjmriinstalled (e.g., the.venvfromuv syncin this directory). - Run the cells top-to-bottom. The logging-setup cell writes DEBUG output to
pyjmri.login the working directory; that file is gitignored.
Re-running the await main() cell will flip the same turnout back and forth, since the script always commands the opposite of the currently reported state.
pyjmri is honest about what it does and does not know. DCC itself is an open-loop control protocol: the command station broadcasts packets onto the rails, but accessory and locomotive decoders do not push anything back. Virtually every DCC system in common use behaves this way. Layouts that add auxiliary feedback hardware — block-occupancy detectors, transponding receivers — regain a real upstream signal for the entities those sensors cover, and pyjmri surfaces that signal through Sensors (see "Sensors are the real feedback path" below). For everything else, pyjmri faithfully relays what JMRI reports, which is in turn what DCC tells JMRI.
Read this section before writing code that assumes a returned await implies a moved turnout, a powered locomotive, or a confirmed route.
pyjmri talks to JMRI's JSON web server, not to any specific DCC hardware. Anything JMRI can drive, pyjmri should be able to drive. v1 has been tested only against the maintainer's layout, which uses an NCE command station (USB and simulator); the rest of the limitations in this section are DCC-protocol properties that apply to any JMRI-supported DCC system, not NCE-specific quirks. If you exercise pyjmri against different DCC hardware, please open a GitHub issue with what worked and what didn't so the tested-hardware footprint can grow.
There is no DCC-bus feedback from a commanded accessory (turnout, route, light) or locomotive decoder back to the command station: the rails carry packets outbound but not inbound. JMRI knows what it commanded, not what physically happened — the state it reports for a turnout, light, or route is the last-commanded state, not an observed one. This is a DCC protocol property, not a JMRI or pyjmri choice. pyjmri does not raise an exception when reported state diverges from physical reality; it has no second signal to compare against. Visual confirmation at the layout, or an auxiliary sensor (next subsection), is the only ground truth.
When await turnout.set_state(TurnoutState.THROWN) returns, the library has confirmed that JMRI's JSON web server accepted the request and updated its internal model. The DCC bus may not have delivered the packet; the turnout coil may have failed; the decoder may be unpowered. pyjmri does not raise for any of these — it has no signal to detect a turnout that physically failed to move. This holds against a JMRI simulator and against any real DCC hardware alike. A successful await confirms intent reached JMRI, nothing more.
The booster's physical power switch is the source of truth for whether the rails are energized. JMRI can observe the booster's power state, and pyjmri surfaces that observation via jmri.power_state(), which returns PowerState.ON, PowerState.OFF, or PowerState.UNKNOWN. pyjmri does not expose a power-write method by design — the library will not synthesize a software power_on() that could mislead a script into believing it controls layout energization. pyjmri does not raise if a script keeps commanding entities while the layout is unpowered.
Entering async with jmri.throttle(dcc_address=N, long=True) as loco: successfully means JMRI accepted the acquire request and reserved a throttle slot. It does NOT mean a locomotive at DCC address N is on the rails, powered, or responsive — the decoder may be unaddressed, asleep, or absent. set_speed and set_function send DCC packets toward that address whether or not a decoder is listening. pyjmri does not raise an exception for a missing locomotive on the rails. In practice: don't gate downstream logic on a successful async with jmri.throttle(...) entry — gate it on a sensor detecting train motion to confirm the decoder is actually responding.
Block-occupancy detectors and other sensors wired through JMRI hardware DO carry a true upstream signal — they report what the physical world is doing. await sensor.wait_state(SensorState.ACTIVE) is therefore a meaningful event-driven primitive: it waits for an actual electrical change at the layout, not for a software echo. When event-driven correctness matters — waiting for a train to reach a block, confirming a route cleared — drive the wait off a sensor, never off a turnout or route state. pyjmri does not raise when a broken sensor never fires; a wait_state call without a timeout= argument will simply wait forever.
pyjmri does not add an authentication layer. JMRI's JSON web server is unauthenticated and intended for use on a trusted network — typically the same machine as JMRI itself, or the same LAN as the layout. pyjmri does not raise an exception when an untrusted client also reaches that server; there is no auth check to fail. Do not expose JMRI's web server to the public internet; keep it on localhost or behind your home firewall and let pyjmri talk to it from there.
pyjmri does not replace JMRI's bundled Jython — Jython continues to work, and pyjmri is for users who prefer modern async Python. This table maps common Jython idioms to their pyjmri equivalents for users porting existing scripts.
The biggest structural shift is from AbstractAutomaton's synchronous init() / handle() polling loop to a top-level async def function wrapped in asyncio.run(...). Long waits become await points: instead of returning True from handle() to keep polling, you continue past an await sensor.wait_active() line.
| Jython | pyjmri |
|---|---|
sensors.provideSensor("Block 1") |
layout.sensors["Block 1"] |
turnouts.getTurnout("NT400") |
layout.turnouts["NT400"] |
routes.getRoute("Crossover") |
layout.routes["Crossover"] |
memories.provideMemory(name).getValue() |
await layout.memories[name].get_value() |
self.getThrottle(5327, True) |
async with layout.throttle(5327, long=True) as t: (see note (b)) |
throttle.setSpeedSetting(0.4) |
await t.set_speed(0.4, forward=True) (see note (a)) |
throttle.setIsForward(True) |
await t.set_speed(current_speed, forward=True) (see note (a)) |
throttle.setF2(True) |
await t.set_function(2, True) |
self.waitSensorActive(s) |
await s.wait_active() |
self.waitSensorInactive(s) |
await s.wait_inactive() |
self.waitMsec(ms) |
await asyncio.sleep(ms / 1000) (see note (c)) |
AbstractAutomaton init() / handle() |
top-level async def + asyncio.run(...) (see note (d)) |
TrainManager.getTrainsByIdList() |
(await jmri.discover_operations()).trains (see note (e)) |
CarManager.getByIdList() |
(await jmri.discover_operations()).cars (see note (e)) |
- (a) Speed and direction are atomic in
pyjmri.set_speed(value, *, forward)sets both in one call. The Jython pairsetSpeedSetting(v)+setIsForward(d)collapses intoawait t.set_speed(v, forward=d).forwardis keyword-only AND required — baret.set_speed(0.4)raisesTypeError. To change only direction, re-emit the current speed with the new direction; to change only speed, re-emit the current direction with the new speed. - (b) Throttle lifecycle is the async context manager.
async with layout.throttle(addr, long=True) as t:acquires on entry and releases on exit, including exception paths. No explicitt.release()call is needed; the Jython end-of-script release pattern goes away. (A.release()method exists for advanced cases — ordinary scripts don't need it.) - (c)
asyncio.sleeptakes seconds, not milliseconds. Addimport asyncioat the top of the script (Jython'swaitMsecwas a method onAbstractAutomaton), and convert with/ 1000—waitMsec(500)becomesawait asyncio.sleep(0.5). - (d) No
init/handleanalog.pyjmriis event-driven, not polling: instead of returningTruefromhandle()to keep looping, youawaitsensor events. The top-level structure is oneasync def main()wrapped inasyncio.run(main())— no base class to subclass. - (e) Operations discovery is read-only and returns a snapshot.
await jmri.discover_operations()returns anOperationscontainer with.locations/.trains/.cars/.engines, each looked up by name like aLayoutcollection. It is a point-in-time read — re-call to refresh. See the Operations section below.
JMRI's Operations module models an operating session: locations (yards, towns, staging), the cars and engines on the layout, and trains with assigned routes. pyjmri exposes it through a separate entry point:
async with Client() as jmri:
ops = await jmri.discover_operations()
print(f"{len(ops.locations)} locations, {len(ops.trains)} trains, "
f"{len(ops.cars)} cars, {len(ops.engines)} engines")
for train in ops.trains.values():
print(train.user_name or train.name, "→", train.current_location or "—")
for car in ops.cars.values():
loc = car.location.name if car.location else "—"
print(car.name, "at", loc, "on train", car.train or "—")discover_operations() returns an Operations container — distinct from the Layout returned by discover(). Locations and trains are looked up by both system and user name; cars and engines by road+number. A worked "where is every car" report is in examples/operations_report.py.
The roster (DecoderPro's catalog) lists every engine you have ever programmed. Operations engines are the operationally-active subset actually deployed on the layout — you may have ~45 roster engines but only a handful running tonight. The two are intentionally separate: an Operations Engine is never a roster entry, and Operations tells you a car's current location and train assignment, which the roster cannot.
Operations is a pure data subsystem — cars, engines, locations, and trains are records JMRI serves over JSON regardless of hardware. Unlike throttles and sensors (see Limitations), Operations discovery is not subject to the open-loop blind spot, so it is fully exercisable on the NCE simulator with Operations data loaded.
Operations discovery is read-only. There is no build-train, move/assign-car, or generate-manifest surface in pyjmri yet — those mutating operations are deferred to a future command increment. Today you can inspect the whole operating session as typed Python; you cannot change it.
JMRI's roster is DecoderPro's catalog of every locomotive you have programmed — its address, decoder, and per-function labels. pyjmri exposes it through a separate entry point, mirroring Operations:
async with Client() as jmri:
roster = await jmri.discover_roster()
print(f"{len(roster)} roster entries")
for entry in roster.values():
print(entry.road_number or entry.name, "→ DCC", entry.dcc_address,
"·", entry.model or "—", "·", entry.decoder_family or "—")
loco = roster.by_address(1029) # get()-style: None (with a warning) if absentdiscover_roster() returns a Roster container — distinct from the Layout returned by discover() and the Operations returned by discover_operations(). A worked fleet-catalog report (per-loco reference sheet plus a decoder rollup) is in examples/roster_catalog.py.
The roster catalogs every engine you have ever programmed in DecoderPro. Operations engines are the operationally-active subset actually deployed on the layout — you may have ~45 roster engines but only a handful running tonight. The two are intentionally separate: a roster RosterEntry is never an Operations Engine. The roster tells you a decoder's capabilities and identity; Operations tells you a layout's current deployment.
A Roster is a read-only mapping: iterate it, take len(), or look an entry up by its roster ID (roster[name]). It adds one roster-specific find — by_address(dcc_address) — because JMRI's roster primary key is the entry name, not the DCC address. The two lookups behave differently on a miss, by design:
by_address(addr)is a get()-style find: an unknown address returnsNone(with a logged warning), never raises — a brand-new, un-catalogued loco is a normal case. The same philosophy reaches the throttle:Client.throttle_for_entry(<address>)for an address not in the roster warns and still drives (best-effort, motor-only assumption) so a not-yet-catalogued loco stays drivable.- A name lookup that misses raises
LayoutEntityNotFound— a name cannot be turned into a DCC address without a matching entry, sothrottle_for_entry(<name>)raises rather than guess.
The roster is a discovery-time snapshot (FR54): a loco re-addressed in JMRI after discover_roster() resolves to its prior address until you re-run discover_roster().
To decide what a loco can do (e.g. whether it is sound-equipped), read its function labels — decoder_family / decoder_model are decoder-definition-file names (date-stamped strings like "ESU LokSound 5"), not capability tags. pyjmri provides classify_capability() and firable_startup_functions() for exactly this, and a worked capability-aware startup is in examples/capability_aware_startup.py.
The roster is a pure data subsystem — entries are records JMRI serves over JSON regardless of hardware. Unlike throttles and sensors (see Limitations), roster discovery is not subject to the open-loop blind spot, so it is fully exercisable on the NCE simulator with a populated roster.
Roster discovery is read-only. There is no decoder-programming or CV-write surface in pyjmri — editing a locomotive's address, function labels, or decoder configuration stays in DecoderPro. Today you can inspect every locomotive's metadata as typed Python; you cannot change it.