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'sFRAME_CTRL_WRITE, the only kind of frame UDPRx acts on.control, the value you want incontrol_reg, the one headed for the FPGA.0for 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
- Check
BOARD_IP. Runip addron the board and make suresend.pyuses that exact address. It's the most common mistake, because DHCP may have given the board a new address since last time. - 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.
- Watch it in Wireshark with the filter
udp.port == 5001. Your command should show the board's IP as its destination, not255.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. - 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 code2.
Other things worth adding later
- Fill in the FPGA side. Make
udprx_todo_fpga_write()really writecontrol_reginto the FPGA, and add the matching read that fillsstatus_regfrom the FPGA. - Reply with an ack. The board already has the sender's address from
recvfrom, so it can send a frame straight back, andsend.pycan wait for it instead of waiting for the next broadcast. - A sequence number, to spot lost or repeated commands, since UDP doesn't promise delivery.
- Enforce unicast. Right now a broadcast
WRITEwould still be accepted. With theIP_PKTINFOsocket option,recvmsg()also tells the board which address a datagram was sent to, so it could reject anything sent to255.255.255.255. - 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.