Remote Access
plesty.lib.service puts a device on the network. A device class is written
once, synchronously, against the instrument in front of it; the service tier
serves that same object over ZeroMQ so an experiment on another machine calls
it with the syntax it would use locally.
This is the top half of the six device layers — the part nobody writes per device.
DeviceTCPIPClient → ZMQ → DeviceTCPIPServer → async wrapper → your device
Serving a Device
build_server(device, fixed_threading=True, address="tcp://*:5555") wraps a
synchronous device and runs it behind a ROUTER socket that accepts several
clients at once.
from plesty.lib.service import build_server
from my_device import MyPowerMeter
device = MyPowerMeter("USB0::0x1313::0x8078::P0000001::INSTR")
server = build_server(device, address="tcp://*:5555")
server.run()
run() serves until shutdown(); address and is_running report what the
server is doing.
fixed_threading is the argument that matters. It defaults to True,
which pins every device call to one dedicated worker thread
(AsyncDeviceThread). This became the default after the 2026-08 field round:
instrument drivers turned out to have thread affinity, not merely
thread-unsafety, and migrating between pool threads starved a .NET completion
callback and lost ZMQ messages under real latency. False selects
AsyncWrapperSafe, which serializes calls but runs them on rotating executor
threads — correct for a pure-Python driver, wrong for a vendor DLL.
Using a Remote Device
build_client(address, timeout=5000, resources=None, client_id=None) returns a
client that discovers the served methods on connect and binds them as
attributes, so a remote call reads like a local one.
from plesty.lib.service import build_client
with build_client("tcp://192.168.1.40:5555") as pm:
pm.connect()
pm.write("wavelength", 1064.0)
print(pm.query("wavelength"))
print(pm.read_power()) # a served operation, bound on connect
query / write reach the parameter system, call(name, **kwargs) reaches any
served operation, and describe() returns the device’s doc_model() — the same
structure the docs generator renders, so a client can list what it may call
without importing the device module. timeout is in milliseconds;
set_default_timeout() changes it for later calls.
The client presents no get_config_list(). Tools that need the key list — the
client field test,
for one — are told the keys explicitly rather than discovering them.
Multi-Client Access Control
One server can hold several clients, which means two of them can reach for the
same physical channel. ResourceManager arbitrates.
The lockable resources belong to the device, not to the server:
build_server reads them from device._resources, which the device fills by
calling register_resources() — usually in its own init. A client then names
what it needs when it connects.
device.register_resources(
{"Dev1": {"port0": ["line0", "line1"], "port1": ["line0", "line1"]}}
)
server = build_server(device, address="tcp://*:5555")
# one client takes a whole subtree, another takes a single line
scanner = build_client(address, resources="Dev1/port0")
shutter = build_client(address, resources=["Dev1/port1/line0"])
A request is a string ("Dev1/port0" claims every leaf beneath it,
"Dev1/port0/line0" claims one), a flat collection, or a nested dictionary.
The manager grants what is free and refuses what is held; client_resources()
reports who holds what, and disconnecting releases everything that client took.
connection_info on the client carries the outcome of its own request.
A device decides what a client may do with its allocation by overriding
_user_permission_check(user_resources, key) on the device class — the default
is permissive.
Composite Devices
CompositeDevice consumes clients built here, which is how a multi-instrument
rig becomes one object with resilient remote calls — a timeout rebuilds the
client and retries before it propagates. See
Composite device.