Stream games via Moonlight and fpv.wtf to your DJI FPV Goggles!
The DJI Moonlight project is made up of three parts:
- dji-moonlight-shim: a goggle-side app that displays a video stream coming in over USB. You are here.
- dji-moonlight-gui: a Windows app that streams games to the shim via Moonlight and friends.
- dji-moonlight-embedded: a fork of Moonlight Embedded that can stream to the shim. The GUI app uses this internally.
Latency is good, in the 7-14ms range at 120Hz (w/ 5900X + 3080Ti via GeForce Experience).
- Go to fpv.wtf with your goggles connected and powered up.
- Update WTFOS to the latest version.
- Install dji-moonlight-shim via the package mangaer.
- Continue to dji-moonlight-gui for PC-side setup.
-
Connect your goggles to your PC via USB.
-
Select
Moonlight
from the menu. -
The shim will start and wait for a connection.
-
Use dji-moonlight-gui to stream video from your PC.
-
Press the BACK button on the goggles to exit the shim at any time.
Technically, this will accept any Annex B H.264 stream since all this does really is pipe the stream right into the decoder.
First, it expects a connect header:
struct connect_header_s {
uint32_t magic; // 0x42069
uint32_t width;
uint32_t height;
uint32_t fps;
}
After which, it expects a stream of frames. Each frame should be sent prefixed
with a uint32_t
length header, followed by the frame data itself.
The maximum frame size is 1MB (1000000 bytes)
, which is slightly under the
maximum packet size for the decoder. Any larger and this would need to handle
packet splitting. But also, this is absolutely enormous for a single frame.
For RNDIS, the shim hosts a TCP server on port 42069
that accepts a single
connection at a time. Normal client/server stuff applies here.
For BULK, the shim reads from the FunctionFS bulk endpoint already setup by DJI
at /dev/usb-ffs/bulk/ep1
. FunctionFS does not support non-blocking IO nor does
it support polling so reading is done from a thread into a pipe.
Currently, there's no way to tell if the BULK side has connected or disconnected, so on startup the shim just waits for the magic number to appear in this file, at which point it assumes it's about to get the rest of the connect header, followed by the rest of the stream. After that, we rely on a watchdog timer to detect if the connection has been lost (i.e., no data received for some seconds).
The dji-moonlight-shim
that gets popped into /opt/bin
is actually a wrapper
around the actual binary in /opt/moonlight/
. The glasses service and the shim
can't co-exist so this wrapper handles stopping (and restarting) it.
Everything around decoding lives in dmi and is probably the best way to understand how this works. Start from dmi_pb.c.
It's driven through a handful of devices via ioctl/iomap:
/dev/dmi_media_control
: general control, starting/stoping the decoder, etc./dev/dmi_video_playback
: the place where frames go./dev/mem
: general shared mem, mainly just for frame timing info here though.
The setup is roughly:
- Send a media playback command to start the decoder, with the expected frame dimensions.
- Set the frame rate and unpause the decoder, directly via shared memory.
Then for each frame you want to decode:
- Claim packets via the
dmi_video_playback
device, which gives you a chunk of shared memory to write the packet to. - Write the frame data along with a header to the shared memory.
- Release the packet. This will trigger the decoder to decode the frame.
- DejaVu Sans, used for the toast font: see LICENSE-DejaVuSans.
- Moonlight logo: see LICENSE-Moonlight.
Everything else: see LICENSE.