Broadcasts#
When a Client sets or reads a parameter, the Server announces it as a
Broadcast: a small message on a PUB socket that any number of subscribers
receive at once, from the Server’s own GUI to a Listener on another
machine. Nobody polls. The
getting started overview shows what
Broadcasts are for; this page is the deeper twin. It covers what exactly
triggers a Broadcast, what the two frames on the wire look like, how a Client
subscribes with SubClient, how the Server forwards Broadcasts to other
machines, and how an instrument emits its own Broadcasts through the
Broadcaster contract, with the Parameter Manager’s Lock, Type and
side-effect actions as the worked example.
The user-facing side of the Parameter Manager’s Broadcasts is in the User Guide’s Parameter Manager page, and Blueprints in general are described in Blueprints and Proxies.
What triggers a Broadcast#
The Server emits every Broadcast from the worker thread that executed the client request, while that worker holds the instrument’s instrument mutex. Requests for one instrument are serialized by that mutex, so the Broadcasts about one instrument go out in the order the requests ran.
Four things trigger a Broadcast. A parameter set emits parameter-update,
carrying the value that was set. A parameter read emits parameter-call,
carrying the value that was read. A client call to a method literally named
add_parameter emits parameter-creation, and one to remove_parameter
emits parameter-deletion; the message’s name is the instrument name and
the call’s arguments joined with dots, and a creation carries the
initial_value and unit keyword arguments the call received. Any other
method call emits nothing.
The examples below run against a Client and the two instruments they use:
>>> from instrumentserver.client import Client
>>> cli = Client()
>>> dummy = cli.find_or_create_instrument(
... "dummy",
... "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentWithSubmodule",
... )
>>> pm = cli.find_or_create_instrument(
... "parameter_manager", "instrumentserver.params.ParameterManager"
... )
Captured through a SubClient (the SubClient section shows the wiring), the
triggers look like this, in the order they were emitted:
>>> dummy.param0.set(0.5)
>>> received[0].action, received[0].name, received[0].value
('parameter-update', 'dummy.param0', 0.5)
>>> dummy.param0()
>>> received[1].action, received[1].value
('parameter-call', 0.5)
>>> pm.add_parameter("extra.gain", initial_value=3, unit="V")
>>> received[2].action, received[2].name, received[2].value, received[2].unit
('parameter-creation', 'parameter_manager.extra.gain', 3, 'V')
>>> pm.remove_parameter("extra.gain")
>>> received[3].action, received[3].name, received[3].value
('parameter-deletion', 'parameter_manager.extra.gain', None)
That is the whole list for a plain instrument. An instrument that implements the Broadcaster contract emits its own Broadcasts on top, which is how the Parameter Manager announces Locks, Types and the parameters it creates as side effects.
Every Broadcast carries one of six action strings. They are module constants
in blueprints.py, and their values are the wire contract that external
subscribers parse:
Constant |
Action string |
Emitted by |
|
|---|---|---|---|
|
|
the Server, on a parameter set |
the new value |
|
|
the Server, on a parameter read |
the value that was read |
|
|
the Server, on a client call to |
the initial value ( |
|
|
the Server, on a client call to |
nothing ( |
|
|
the Parameter Manager, one per affected Follower |
the Follower’s |
|
|
the Parameter Manager, one per edited Type |
the Type’s fresh |
The wire format#
The Server publishes Broadcasts on a PUB socket bound to tcp://* on the
request port plus one. A Server on the default port 5555 publishes on 5556.
A Broadcast is a two-frame ZMQ message. Frame 1 is the topic: the instrument
name, which is the first dotted component of the message’s name. Frame 2 is
the JSON of the message’s ParameterBroadcastBluePrint dict. Here is one
real message as a subscriber received it, both frames:
frame 1: 'dummy'
frame 2: {"name": "dummy.param0", "action": "parameter-update", "value": "0.5", "unit": "", "_class_type": "ParameterBroadcastBluePrint"}
The topic is what lets subscribers filter per instrument: a SUB socket that
subscribes to parameter_manager receives the Parameter Manager’s Broadcasts
and nothing else. The subscription is a plain prefix match, so subscribing to
dummy also receives the messages of an instrument named dummy2. One
Broadcast reaches every subscriber: two SUB sockets connected to the
Server’s PUB socket each received the identical two frames.
Frame 2 carries every scalar field of the Blueprint as a string, including
_class_type, which names the class the dict stands for. A value that is
itself a Blueprint travels as a nested dict with its own _class_type, as
the Parameter Manager payloads at the end of this page show. decode turns
the payload back into the dataclass: json.loads, then deserialize_obj,
which sees the _class_type key and rebuilds a ParameterBroadcastBluePrint
from the fields. Decoding the frame 2 payload from above (a subscriber holds
it as payload), the numeric strings come back as numbers:
>>> from instrumentserver.base import decode
>>> bp = decode(payload)
>>> type(bp).__name__
'ParameterBroadcastBluePrint'
>>> bp.name, bp.action, bp.value, bp.unit
('dummy.param0', 'parameter-update', 0.5, '')
One PUB/SUB quirk to know: a subscriber that connects after a Broadcast was published misses it. The subscription only counts from the moment it reached the Server’s socket. So subscribe before you trigger, and give a fresh subscriber a moment to connect before you expect messages from it. The shared helpers sleep briefly after connecting for exactly this reason.
SubClient#
SubClient (in instrumentserver.client.proxy) is the Qt consumer of
Broadcasts. It opens a SUB socket on the Broadcast port, subscribes to the
instruments you name, or to everything when you pass none, and emits every
received Broadcast as a ParameterBroadcastBluePrint on its update
signal:
sub = SubClient(instruments=["parameter_manager"]) # or instruments=None: all
thread = QtCore.QThread()
sub.moveToThread(thread) # the receive loop runs on its own QThread
thread.started.connect(sub.connect) # connect() runs the loop until stop()
sub.finished.connect(thread.quit)
sub.update.connect(on_broadcast) # every Broadcast, as a ParameterBroadcastBluePrint
thread.start()
That is the exact wiring the Parameter Manager GUI and the shared helpers
use. stop() ends the receive loop; stop the thread with it, as the GUI’s
stopListener does: sub.stop(), then thread.quit(), then
thread.wait().
This is also how GUIs stay live without polling. The Parameter Manager GUI’s
model receives every Broadcast of its instrument through a SubClient and
routes the two Parameter Manager actions straight into its client-side
state: a pm-lock-update payload replaces one Follower’s Lock, and a
pm-type-update payload replaces that one Type, which carries the whole
fresh definition, so the GUI never fetches that Type or Lock again. The
Parameter Manager’s actions section at the end of this page shows the
payloads; the Parameter Manager page
shows the widgets this feeds.
You do not need Qt to subscribe. A plain SUB loop is the whole recipe, and the Listener is exactly that:
import zmq
from instrumentserver.base import recvMultipart
context = zmq.Context.instance()
socket = context.socket(zmq.SUB)
socket.connect("tcp://localhost:5556")
socket.setsockopt_string(zmq.SUBSCRIBE, "")
while True:
topic, blueprint = recvMultipart(socket)
# blueprint is a ParameterBroadcastBluePrint, decoded for you
...
The Monitoring page covers the Listener and where it can send what it receives.
External forwarding#
By default the Server publishes Broadcasts at tcp://* on the request port
plus one, so every machine that can reach the Server can subscribe there.
The station config can additionally name a second address to publish the
same Broadcasts to, in its networking section:
networking:
# Adds an address to broadcast parameter changes to, Example: "tcp://192.168.1.1:6000"
externalBroadcast: "tcp://192.168.1.1:6000"
With that in place the Server binds a second PUB socket on the given address
and sends every Broadcast to both sockets. The copy is the same two-frame
message as the original: a subscriber on the external address received
frames identical to the ones on the local port, and a SubClient pointed at
the external address received the Blueprint unchanged. Existing subscribers
need no change; the address is just a second door into the same stream.
That is what puts live UIs and Listeners on other machines: a dedicated
monitoring network or a fixed address of your choosing, with the same
stream on both.
In code, the same setting is the ipAddresses dict: startServer accepts
ipAddresses={"externalBroadcast": "tcp://127.0.0.1:6000"} and binds the
second PUB socket on it. The Server page covers
the config file and where each entry lands.
The Broadcaster contract#
Everything above is the Server broadcasting what it can see: parameter sets
and reads, and calls to methods literally named add_parameter and
remove_parameter. Structural changes inside an instrument stayed
invisible to everyone else. The Broadcaster contract (ADR-0003) is the
opt-in way for an instrument to announce them itself.
An instrument implements the contract by mixing in Broadcaster (in
instrumentserver.base), which brings three methods:
add_broadcast_sink(fn)registersfnto receive every Broadcast the instrument emits. Sinks are stored in a plain list, so registering the same sink twice means receiving everything twice, and removing it once leaves it registered once.remove_broadcast_sink(fn)removes it again.broadcast(bp)sends oneParameterBroadcastBluePrintto every registered sink. With no sinks registered it is a no-op, so an instrument used standalone (no Server, no sinks) emits nothing and nothing raises. An exception raised inside one sink is logged and the remaining sinks still receive the Broadcast.
The Server knows the contract by shape, not by base class: when an
instrument joins the Station, the Server checks for an
add_broadcast_sink attribute and registers its own
StationServer._broadcastParameterChange as a sink. This happens at the two
points where instruments enter the Station: created over the wire with
find_or_create_instrument, and loaded from the station config at startup.
Instruments without the mixin are registered exactly as before and stay
untouched.
broadcast runs on the thread that called the instrument method. For a
client request that is the worker thread holding the instrument mutex, the
same place the Server’s own Broadcasts run from. And the emissions use the
same socket, the same topic (the instrument name) and the same two-frame
wire format as the Server’s own Broadcasts, so an existing subscriber parses
them without knowing or caring who produced them.
The shipping example is the test instrument the verification of this page
uses, instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument.
Its emitting method is the whole pattern:
def emit_broadcast(self, value=1.0, unit="V", action="parameter-update"):
bp = ParameterBroadcastBluePrint(
name=f"{self.name}.param0", action=action, value=value, unit=unit
)
self.broadcast(bp)
return bp
The Server registered itself as a sink when the instrument joined the
Station, so broadcast put the Blueprint on the Server’s PUB socket, and a
SubClient received it like any other Broadcast:
>>> bcaster = cli.find_or_create_instrument(
... "bcaster",
... "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument",
... )
>>> bcaster.emit_broadcast(value=3.0, unit="V")
>>> received[0].name, received[0].action, received[0].value, received[0].unit
('bcaster.param0', 'parameter-update', 3.0, 'V')
The Parameter Manager’s actions#
The Parameter Manager is the first instrument built on the Broadcaster
contract. It emits pm-lock-update and pm-type-update, and re-emits
parameter-creation for parameters it creates as side effects. On the wire
its emissions are indistinguishable from the Server’s own: same socket, same
topic, same two-frame format. What the actions mean to use is described in
the User Guide’s Parameter Manager
page, its Locks and Types sections in particular.
Every Lock method that changes a Lock emits one pm-lock-update per
affected Follower (D10). The message’s name is the Follower’s full dotted
path, and its value is the Follower’s PMLockBluePrint, or None when
its Lock was removed. So lock, unlock, relock, toggle_lock and
remove_lock each announce their one change, and remove_parameter on a
Target announces every Lock that deleting it drops, one None payload per
dropped Follower. The no-op paths emit nothing: unlocking an already
unlocked Lock is not news. Here is the payload of a pm-lock-update as it
crossed the wire, stringified fields and all:
{"name": "parameter_manager.show.follower", "action": "pm-lock-update", "value": {"target": "parameter_manager.show.target", "locked": "True", "_class_type": "PMLockBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"}
Every Type-editing method emits one pm-type-update per affected Type
(D22), the two Type Lock methods included. lock_type_parameter on top
creates the Locks it declares, so it additionally emits one
pm-lock-update per applied Lock; unlock_type_parameter removes only the
rule. The message’s name is the Type’s full dotted name,
<instrument>.<type>, and its value is the Type’s fresh
PMTypeBluePrint with its entries (defaults, units and Type Lock Targets),
its Nested Types and its effective set, or None when the Type was removed.
The payload carries the whole new definition, which is why a GUI can replace
that one Type locally and recompute its tints with no follow-up fetch:
{"name": "parameter_manager.display", "action": "pm-type-update", "value": {"name": "display", "parameters": {"gain": {"default": "12", "unit": "dB", "target": "None"}}, "nested": {}, "effective": {"gain": {"unit": "dB", "from_type": "display"}}, "_class_type": "PMTypeBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"}
Parameters the Parameter Manager creates as side effects are announced as
parameter-creation, one per created parameter in creation order: an
add_type_parameter that writes an entry into every Instance lacking it, an
add_nested_type that writes the Nested Type’s entries under its submodule
into every Instance of the outer Type lacking them, an add_instance, and
the Globals Target that a Type Lock with no explicit Target creates on
demand. Direct add_parameter and remove_parameter calls keep being
announced by the Server, so nothing is ever announced twice.
The emissions of one method arrive in a fixed order:
lock_type_parameterwith the default Target: first theparameter-creationof the Globals parameter, then onepm-lock-updateper applied Lock, then the Type’spm-type-update.add_nested_type: first oneparameter-creationper created parameter, in creation order, then thepm-lock-updates of the Type Locks the new Instances get, then onepm-type-updateper affected Type, the edited Type first.add_instance: first oneparameter-creationper created parameter, in creation order, then thepm-lock-updates of the Type Locks the new Instance gets at creation. It edits no Type, so nopm-type-update.remove_parameteron a Target: first onepm-lock-updatewithNoneper dropped Follower, then onepm-type-updateper affected Type (the Type Locks whose stored Target it was), then theparameter-deletionthat the Server emits once the call has returned.