Here's the video walkthrough, if you'd rather watch it:

Why I Started Using This Tool

The UDPRx task left the board sitting in recvfrom() on port 5001, waiting for a WRITE frame. Something on the PC has to send one. I already had listen.py from the UDPTx episode to watch what the board broadcasts, so the natural next step was its mirror: a tiny host app that builds a frame and sends it to the board. I wanted it small enough to copy to any PC on my bench, and I wanted it to talk to the board by its own IP address instead of shouting at the whole network.

What It Does

Save this as send.py on your PC, next to listen.py:

import socket
import struct
import sys

BOARD_IP = "192.168.1.50"   # the board's own IP (unicast), not 255.255.255.255
control = int(sys.argv[1], 0) if len(sys.argv) > 1 else 1

frame = struct.pack(">IIII", 0xDE10, 2, control, 0)   # header, WRITE, control, status

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(frame, (BOARD_IP, 5001))
print(f"sent control=0x{control:08X} to {BOARD_IP}:5001")

Run python send.py 1, then python send.py 0, then python send.py 0x5. The 0 in int(..., 0) lets you type decimal or 0x hex.

struct.pack: the mirror of listen.py

It's the mirror of listen.py: struct.pack instead of struct.unpack, with the same ">IIII" layout. The > means big-endian, network order, which is exactly what the board's ntohl() expects. Each I is one unsigned 32-bit field, so four of them make the 16-byte frame:

  • 0xDE10, the header. UDPRx drops anything that doesn't start with it.
  • 2, the control code. That's FRAME_CTRL_WRITE, the only kind of frame UDPRx acts on.
  • control, the value you want in control_reg, the one headed for the FPGA.
  • 0 for status. The board ignores it anyway, because status belongs to the board.

Unicast, not broadcast

BOARD_IP is the board's own address, for example 192.168.1.50. That's unicast: one sender, one receiver. The script could send to 255.255.255.255 and the board would still hear it, because UDPRx binds to INADDR_ANY. But then every device on the network gets a copy, and with two boards on the bench a broadcast would switch both of them. Unicast means "this command is for this board". There's also no bind() here, for the same reason UDPTx didn't need one: a socket that only sends lets the kernel pick any free local port.

Free confirmation from listen.py

control_reg lives in the board's shared frame, and UDPTx broadcasts that frame every second. So once send.py writes a new value, it shows up in listen.py and in Wireshark within a second. On the board, the log shows the sender's IP next to the change:

[UDPRx] from 192.168.1.100 control 0x00000000 -> 0x00000001
[UDPRx] TODO: write 0x00000001 to FPGA

Any PC can send

Copy send.py to a second PC and run it there too. The board accepts it just the same, and the log line shows the new sender's IP. Nothing on the board changes, and nothing needs recompiling. That's the upside of UDPRx not filtering on the sender. The downside is that anyone on your network can switch your board, which is fine on a bench and not fine anywhere else.

If nothing happens on the board

  1. Check BOARD_IP. Run ip addr on the board and make sure send.py uses that exact address. It's the most common mistake, because DHCP may have given the board a new address since last time.
  2. Check the network. The PC and the board must be on the same subnet. If the PC has both Wi-Fi and Ethernet, make sure the one facing the board is on the same network as the board.
  3. Watch it in Wireshark with the filter udp.port == 5001. Your command should show the board's IP as its destination, not 255.255.255.255. If it's on the wire but the board ignores it, check the header and control code. If it's not on the wire, it's the PC side, often the Windows firewall.
  4. Look at the stop summary. Dropped going up by one every second is just the board's own broadcasts. If your PC's frames also count as dropped, check that the frame is 16 bytes, big-endian, header 0xDE10, control code 2.

Other things worth adding later

  1. Fill in the FPGA side. Make udprx_todo_fpga_write() really write control_reg into the FPGA, and add the matching read that fills status_reg from the FPGA.
  2. Reply with an ack. The board already has the sender's address from recvfrom, so it can send a frame straight back, and send.py can wait for it instead of waiting for the next broadcast.
  3. A sequence number, to spot lost or repeated commands, since UDP doesn't promise delivery.
  4. Enforce unicast. Right now a broadcast WRITE would still be accepted. With the IP_PKTINFO socket option, recvmsg() also tells the board which address a datagram was sent to, so it could reject anything sent to 255.255.255.255.
  5. A checksum or shared key, if the board ever leaves a trusted bench network, so that not just anyone can switch it. An optional allow list of PC addresses is a lighter-weight option.

Final Verdict

It's almost funny how little the PC side needs: one struct.pack, one socket, one sendto. The real work is agreeing on the frame, and because send.py uses the same ">IIII" layout as listen.py, there was nothing new to agree on. What I like most is the loop it closes: send.py writes control_reg, UDPRx applies it, and UDPTx broadcasts it back so listen.py confirms it a second later. Remember to send big-endian, send to the board's own IP rather than broadcast, and use control code 2. If you're following along, run send.py with a few values and watch control_reg come straight back in listen.py. Then send from a second PC and watch its IP show up in the board's log.