Writing Your Own Channel Access Client

Caproto can be used to implement both Channel Access clients and servers. To give a flavor for how the API works, we’ll demonstrate a simple, synchronous client.

Channel Access Basics

A Channel Access client reads and writes values to Channels available from servers on its network. It locates these servers using UDP broadcasts. It communicates with an individual server via one or more TCP connections, which it calls Virtual Circuits.

In this example, our client will talk to a example IOC provided by caproto, but this same code could talk to any Channel Access server.

In a separate shell, start one of caproto’s demo IOCs.

$ python3 -m caproto.ioc_examples.random_walk
PVs: ['random_walk:dt', 'random_walk:x']

In a second separate shell, start a repeater process. You may see output like this:

$ caproto-repeater
[I 18:04:30.686 repeater:84] Repeater is listening on 0.0.0.0:5065

or this:

$ caproto-repeater
[I 18:04:08.790 repeater:189] Another repeater is already running; exiting.

Either is fine.

Registering with the Repeater

To begin, we need a socket configured for UDP broadcasting. Caproto provides a convenient utility for doing this in a way that works on all platforms.

In [1]: import caproto

In [2]: udp_sock = caproto.bcast_socket()

A new Channel Access client is required to register itself with a Channel Access Repeater. (What a Repeater is for is not really important to our story here. It’s an independent process that rebroadcasts incoming server heartbeats to all clients on our host. It exists because old systems don’t handle broadcasts properly.) To register, we must send a request to the Repeater and receive a response.

In [3]: bytes_to_send = b'\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

In [4]: udp_sock.sendto(bytes_to_send, ('127.0.0.1', 5065))
Out[4]: 16
In [5]: data, address = udp_sock.recvfrom(1024)

In [6]: data
Out[6]: b'\x00\x11\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x7f\x00\x00\x01'

Hurray it worked? Unless you can read Channel Access hex codes the way Neo experiences the Matrix, you may want a better way. Caproto provides a higher level of abstraction, Commands, so that we don’t need to work with raw bytes. Let’s try this again using caproto.

Note

Other sans-I/O libraries use the word Event for what we are calling a Command. “Event” is an overloaded term in Channel Access, so we’re going our own way here.

As above, create a fresh UDP socket configured for broadcasting.

In [7]: import caproto

In [8]: udp_sock = caproto.bcast_socket()

Instantiate a caproto Broadcaster and a command to broadcast — a RepeaterRegisterRequest.`

In [9]: b = caproto.Broadcaster(our_role=caproto.CLIENT)

In [10]: command = caproto.RepeaterRegisterRequest('0.0.0.0')

Pass the command to our broadcaster’s Broadcaster.send() method, which does two things. It translates command objects into bytes, and it checks them against the rules of the Channel Access protocol. The rules are encoded in the Broadcaster’s internal state machine, which tracks the state of both the client and the server. (It can serve as either.) If you try to send an illegal command, it will raise LocalProtocolError.

In [11]: bytes_to_send = b.send(command)

In [12]: bytes_to_send
Out[12]: b'\x00\x18\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

Transport those bytes over the wire, using the same udp_sock we configured above. A quick comparison will show that these bytes are the same bytes we spelled out manually before.

In [13]: udp_sock.sendto(bytes_to_send, ('127.0.0.1', 5065))
Out[13]: 16

Why do we need two steps here? Why doesn’t caproto just send the bytes for us? Because it’s designed to support any socket API you might want to use — synchronous (like this example), asynchronous, etc. Caproto does not care how or when you send and receive the bytes. Its job is to make it easier to compose outgoing messages, interpret incoming ones, and verify that the rules of the protocol are obeyed by both peers.

Recall that we are in the process of registering our client with a Channel Access Repeater and that we are expecting a response. As with sending, receiving is a two-step process. First we read bytes from the socket and pass them to the broadcaster.

In [14]: bytes_received, address = udp_sock.recvfrom(1024)

In [15]: commands = b.recv(bytes_received, address)

The bytes have been parsed into command objects. Next, check them against the Channel Access protocol.

In [16]: b.process_commands(commands)

When we call Broadcaster.process_commands(), the Broadcaster does the same thing is did for Broadcaster.send() in reverse: if one of the received commands is illegal, it raises RemoteProtocolError.

Searching for a Channel

Say we’re looking for a channel (“Process Variable”) with a typically lyrical EPICS name like "random_walk:dt". Some server on our network provides this channel.

The range of IP addresses to search is conventionally controlled by the environment variables EPICS_CA_ADDR_LIST (a space-separated list of IP addresses) and EPICS_CA_AUTO_ADDR_LIST (yes or no). Caproto provides a convenience function get_address_list() for parsing these variables, checking the available network interfaces if necessary, and returning a list.

In [17]: import caproto

In [18]: hosts = caproto.get_address_list()  # example: ['172.17.255.255']

We need to broadcast a search request to the servers on our network and receive a response. (In the event that multiple responses arrive, Channel Access specifies that all but the first response should be ignored.) We follow the same pattern as above, still using our broadcaster b, our socket udp_sock, and some new caproto commands.

We need to announce which version of the protocol we are using and the name of the channel we are seraching for. These two commands must be sent in the same broadcast (UDP datagram), so we pass them to Broadcaster.send() together.

In [19]: name  = "random_walk:dt"

In [20]: bytes_to_send = b.send(caproto.VersionRequest(priority=0, version=13),
   ....:                        caproto.SearchRequest(name=name, cid=0, version=13))
   ....: 

In [21]: bytes_to_send
Out[21]: b'\x00\x00\x00\x00\x00\x00\x00\r\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00\x10\x00\x05\x00\r\x00\x00\x00\x00\x00\x00\x00\x00random_walk:dt\x00\x00'

In [22]: for host in hosts:
   ....:     udp_sock.sendto(bytes_to_send, (host, 5064))
   ....: 

Our answer will arrive in a single datagram with multiple commands in it.

In [23]: bytes_received, recv_address = udp_sock.recvfrom(1024)

In [24]: commands = b.recv(bytes_received, recv_address)

In [25]: version_response, search_response = commands

In [26]: version_response
Out[26]: VersionResponse(version=13)

In [27]: search_response
Out[27]: SearchResponse(port=5064, ip='255.255.255.255', cid=0, version=13)

In [28]: address = caproto.extract_address(search_response)

In [29]: address
Out[29]: ('127.0.0.1', 5064)

Now we have the address of a server that has the channel we’re interested in. Next, we’ll set aside the broadcaster and initiate TCP communication with this particular server.

Creating a Channel

Create a TCP connection with the server at the address we found above.

In [30]: import socket

In [31]: sock = socket.create_connection(address)

A VirtualCircuit plays the same role for a TCP connection as the Broadcaster played for UDP: we’ll use it to interpret received bytes as commands and to ensure that incoming and outgoing bytes abide by the protocol.

In [32]: circuit = caproto.VirtualCircuit(our_role=caproto.CLIENT, address=address,
   ....:                                  priority=0)
   ....: 

We’ll use these convenience functions for what follows.

In [33]: def send(command):
   ....:     "Process a command in the VirtualCircuit and then transmit its bytes."
   ....:     buffers_to_send = circuit.send(command)
   ....:     sock.sendmsg(buffers_to_send)
   ....: 
In [34]: def recv():
   ....:     "Receive bytes; parse commands; process them in the VirtualCircuit."
   ....:     bytes_received = sock.recv(4096)
   ....:     commands, _ = circuit.recv(bytes_received)
   ....:     for command in commands:
   ....:         circuit.process_command(command)
   ....:     return commands
   ....: 

We initialize the circuit by specifying our protocol version.

In [35]: send(caproto.VersionRequest(priority=0, version=13))

In [36]: recv()
Out[36]: deque([VersionResponse(version=13)])

Optionally provide the host name and “client” name, which the server may use to determine our read/write permissions on channels. (There is no authentication in Channel Access; security has to be provided at the network level.)

In [37]: send(caproto.HostNameRequest('localhost'))

In [38]: send(caproto.ClientNameRequest('user'))

Finally, create the channel and look at the responses.

In [39]: cid = 1  # a client-specified unique ID for this Channel

In [40]: send(caproto.CreateChanRequest(name=name, cid=cid, version=13))

In [41]: commands = recv()

In [42]: access_response, create_chan_response = commands

In [43]: access_response
Out[43]: AccessRightsResponse(cid=1, access_rights=<AccessRights.WRITE|READ: 3>)

In [44]: create_chan_response
Out[44]: CreateChanResponse(data_type=<ChannelType.DOUBLE: 6>, data_count=1, cid=1, sid=0)

Success! We now have a connection to the random_walk:dt channel. Next we’ll read and write values.

Incidentally, we can reuse this same circuit and sock to connect to other channels on the same server. In the commands that follow, we’ll use the integer IDs cid (specified by our client in CreateChanRequest) and sid (specified by the server in its CreateChanResponse) to specify which channel we mean.

In [45]: sid = create_chan_response.sid

In the event of high traffic clogging the network, we can open up multiple TCP connections to the same server, each with its own VirtualCircuit, and designate them with different priority (specified in our VersionRequest). This why we need the concept of a VirtualCircuit: there can be multiple VirtualCircuits between peers.

Reading and Writing Values

Read:

In [46]: send(caproto.ReadNotifyRequest(data_type=create_chan_response.data_type,
   ....:                                data_count=create_chan_response.data_count,
   ....:                                sid=sid,
   ....:                                ioid=1))
   ....: 

In [47]: recv()
Out[47]: deque([ReadNotifyResponse(data=array([3.]), data_type=<ChannelType.DOUBLE: 6>, data_count=1, status=CAStatusCode(name='ECA_NORMAL', code=0, code_with_severity=1, severity=<CASeverity.SUCCESS: 1>, success=1, defunct=False, description='Normal successful completion'), ioid=1, metadata=None)])

We may request a particular data type and element count; in the case we just asked for the “native” data type and count that the server reported in its CreateChanResponse above.

Write:

In [48]: send(caproto.WriteNotifyRequest(data=(4,),
   ....:                                 data_type=create_chan_response.data_type,
   ....:                                 data_count=create_chan_response.data_count,
   ....:                                 sid=sid,
   ....:                                 ioid=2))
   ....: 

In [49]: recv()
Out[49]: deque([WriteNotifyResponse(data_type=<ChannelType.DOUBLE: 6>, data_count=1, status=CAStatusCode(name='ECA_NORMAL', code=0, code_with_severity=1, severity=<CASeverity.SUCCESS: 1>, success=1, defunct=False, description='Normal successful completion'), ioid=2)])

The data may be given as one of the following types:

  • tuple

  • numpy.ndarray (if numpy is installed)

  • big-endian array.array (the somewhat rarely-used builtin array library)

  • big-endian bytes-like (bytes, bytearray, memoryview)

The command also accepts a metadata parameter for data types that include metadata. See Payload Data Types for details.

Subscribing to “Events” (Updates)

Ask the server to send responses every time the value of the Channel changes. As with reading, above, we have the option of requesting a specific data type or element count, but we’ll use the “native” parameters.

In [50]: req = caproto.EventAddRequest(data_type=create_chan_response.data_type,
   ....:                               data_count=create_chan_response.data_count,
   ....:                               sid=sid,
   ....:                               subscriptionid=0,
   ....:                               low=0, high=0, to=0, mask=1)
   ....: 

In [51]: send(req)

The server always sends at least one response with the current value at subscription time.

In [52]: recv()
Out[52]: deque([EventAddResponse(data=array([4.]), data_type=<ChannelType.DOUBLE: 6>, data_count=1, status=CAStatusCode(name='ECA_NORMAL', code=0, code_with_severity=1, severity=<CASeverity.SUCCESS: 1>, success=1, defunct=False, description='Normal successful completion'), subscriptionid=0, metadata=None)])

If the value changes, additional responses will come in. If multiple subscriptions are in play at once over this circuit, we can use the subscriptionid to match them to the right channel. We also use it to end the subscription:

In [53]: send(caproto.EventCancelRequest(data_type=req.data_type,
   ....:                                 sid=req.sid,
   ....:                                 subscriptionid=req.subscriptionid))
   ....: 

In [54]: recv()
Out[54]: deque([EventAddResponse(data=(repr: tuple index out of range), data_type=<ChannelType.DOUBLE: 6>, data_count=1, status=(repr: 0), subscriptionid=0, metadata=(repr: tuple index out of range))])

Closing the Channel

To clean up, close the Channel.

In [55]: send(caproto.ClearChannelRequest(sid, cid))

In [56]: recv()
Out[56]: deque([ClearChannelResponse(sid=0, cid=1)])

If we are done with the circuit, close the socket too.

In [57]: sock.close()

Simplify Bookkeeping with Channels

In the example above, we handled a VirtualCircuit and several different commands. The VirtualCircuit policed our adherence to the Channel Access protocol by watching incoming and outgoing commands and tracking the state of the circuit itself and the state(s) of the channel(s) on the circuit. Internally, to facilitate this, it creates a ClientChannel object for each channel to encapsulate its state and stash bookkeeping details like cid and sid.

Using these objects directly can help us juggle IDs and generate valid commands more succinctly. This API is purely optional, and using it does not affect the state machines.

See how much more succinct our example becomes:

### Create
chan = caproto.ClientChannel(name, circuit)
send(chan.version())
recv()
send(chan.host_name('localhost'), chan.client_name('user'), chan.create())
recv()

### Read and Write
send(chan.read())
recv()
send(chan.write((4,)))
recv()

### Subscribe and Unsubscribe
send(chan.subscribe())
recv()
send(chan.unsubscribe(0))
recv()

### Clear
send(chan.clear())
recv()

Here is the equivalent, a condensed copy of our work from previous sections:

### Create
send(caproto.VersionRequest(priority=0, version=13))
recv()
send(caproto.HostNameRequest('localhost'))
send(caproto.ClientNameRequest('user'))
cid = 1  # a client-specified unique ID for this Channel
send(caproto.CreateChanRequest(name=name, cid=cid, version=13))
commands = recv()
access_response, create_chan_response = commands

### Read and Write
send(caproto.ReadNotifyRequest(data_type=2, data_count=1, sid=sid, ioid=1))
recv()
send(caproto.WriteNotifyRequest(data=(4,), data_type=2, data_count=1, sid=sid, ioid=2))
recv()

### Subscribe and Unsubscribe
req = caproto.EventAddRequest(data_type=create_chan_response.data_type,
                              data_count=create_chan_response.data_count,
                              sid=sid,
                              subscriptionid=0,
                              low=0, high=0, to=0, mask=1)
send(req)
recv()
send(caproto.EventCancelRequest(data_type=req.data_type,
                                sid=req.sid,
                                subscriptionid=req.subscriptionid))
recv()

### Clear
send(caproto.ClearChannelRequest(sid, cid))
recv()

Notice that the channel convenience methods like chan.create() don’t actually do anything. We still have to send the command into the VirtualCircuit and then send it over the socket. These are just easy ways to generate valid commands — with auto-generated unique IDs filled in — which you may or may not then choose to send. The state machines are not updated until (unless) the command is actually sent.