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.