I shoot with a Panasonic LUMIX DMC-GX80. It is a lovely little camera and it has WiFi built in.
The universe was against us
My starting point was not a clever desire to do things over WiFi. It was that every normal way of getting photos off a camera was closed to me, one after another. My MacBook Air has no SD card slot, so I could not just pull the card and plug it in. And the GX80 charges and tethers over a micro-USB port, while the Air only has USB-C, so I would have needed a USB-C to micro-USB cable, which I do not own.
The last fallback was to borrow a card reader from a friend. That failed too, in the most on-brand way possible: his reader only had a micro SD slot, and the GX80 takes a full-size SD card, so the card physically would not fit. The card reader was out, the cable was out, the friend’s reader was out, and the universe was clearly against us. The only door left open was the WiFi the camera already had. So WiFi it had to be.
I assumed that part, at least, would be a solved problem. It is not. This post is the story of how I got there anyway. It ends with a roughly 250-line Python script, with no external dependencies, that connects to the camera over its own WiFi, filters by date range, downloads everything in parallel, and writes the correct capture dates back onto the files.
The problem: the obvious tools all fail
The first surprise is that the camera does not speak PTP/IP over WiFi.
A quick definition, because it is the crux of everything. PTP, the Picture Transfer Protocol, is the standard language that cameras and computers use to exchange photos. It is what Apple’s Image Capture, the macOS Photos app, and the open-source gphoto2 tool all rely on. “PTP/IP” is simply PTP carried over a network connection instead of a cable. The catch is that the GX80 only implements PTP when it is tethered over a USB cable. Over WiFi it does not offer PTP at all. That means Image Capture, Photos, and gphoto2 cannot see the camera over WiFi, no matter what you try. This is the single most important thing to understand, and it is why every “just use Image Capture” suggestion you will find online quietly does not apply here. (source: libgphoto2 issue #409)
The rest of the official software does not help much on a Mac either:
- PHOTOfunSTUDIO, Panasonic’s own desktop importer, is Windows-only.
- LUMIX Sync, the newer companion app, runs only on phones and tablets. Worse, the iOS app caps each transfer at 30 items, so moving a real shoot means babysitting it through batch after batch of 30, and then the photos are stuck on the phone rather than on the Mac where I wanted them.
- The one off-the-shelf Mac application that genuinely works is Cascable. It is paid and closed-source, but if you just want your pictures and you do not want to write any code, that is the honest answer. Buy it and move on.
I wanted two things that Cascable could not give me: an understanding of what the camera was actually doing under the hood, and something I could script and automate. So I went digging.
The whole decision, from “I want my photos” down to “I have to write a script,” looks like this:
What the camera actually speaks
When you put the camera into its WiFi mode (the menu path is New Connection, then Remote Shooting & View), it creates its own little WiFi network. Your Mac joins that network the way it would join any hotspot. Once connected, the camera always sits at the same fixed address, 192.168.54.1, and it exposes three separate services:
- A proprietary HTTP control API called
cam.cgi, on port 80. Think of this as the camera’s remote control. It is how you authenticate and how you flip the camera between “shooting” mode and “playback” mode. - A DLNA / UPnP media server on port 60606. DLNA is the same standard that lets a smart TV browse photos off a network drive. It provides a catalog of everything recorded on the card. It does not, by itself, hand you the files; it hands you a list with links.
- The actual media files, served as ordinary HTTP downloads, on port 50001.
So the plan wrote itself: use cam.cgi to log in and switch the camera to playback, use the DLNA catalog to list the files, then fetch each file over plain HTTP from port 50001. None of this is documented by Panasonic. I pieced it together from the libgphoto2 reverse-engineering discussion (linked at the bottom of this post) and a good deal of poking at the live camera with curl.
Visually, the script and the three camera services line up like this:
The handshake
The control flow turned out to be a strict sequence. Order matters, and getting it wrong returns cryptic errors that tell you nothing about what you did wrong. Here is the full exchange, including the on-screen prompt you have to press:
Step 1: request access
Before the camera will talk to you, you have to introduce yourself. You send it a request that includes a unique identifier (a UUID) and an application name:
curl "http://192.168.54.1/cam.cgi?mode=accctrl&type=req_acc&value=<uuid>&value2=gx80_pull"
The camera replies with something like this:
ok_under_research,GX80-137EDA,remote,open
At the same moment, and this part is essential, the camera pops up a confirmation prompt on its own rear screen, and you have to physically press it to accept the connection. There is no way to skip this from software; it is a deliberate security gate.
Step 2: switch to playback
The DLNA catalog only lists recorded media once the camera is in playback mode (the same mode you get when you press the blue play button on the camera). You set that over the API:
curl "http://192.168.54.1/cam.cgi?mode=camcmd&value=playmode"
Step 3: browse the media
Now you ask the DLNA server for its catalog. DLNA uses a request style called SOAP, which is just a specific XML message format sent over HTTP. You send a Browse request asking for the direct children of the root folder (identified as ObjectID=0):
curl -X POST "http://192.168.54.1:60606/Server0/CDS_control" \
-H 'Content-Type: text/xml; charset="utf-8"' \
-H 'SOAPACTION: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"' \
--data @browse.xml
The reply is XML in a format called DIDL-Lite, which is DLNA’s standard way of describing media items. It comes back in pages, so you ask for a chunk, then the next chunk, and so on. On the GX80 the structure is completely flat: all of the roughly 2,785 items on my card hang directly off the root, with no folder hierarchy to walk through. That actually makes the code simpler, since there is no tree to recurse into.
Step 4: pick the original file
Each item in the catalog lists several different versions of the same picture, each with its own download URL. There is a small thumbnail (its filename starts with DT), a downsized preview (starting with DS), and the full-resolution original (starting with DO, and ending in .JPG or .MP4). The original is the one tagged PANASONIC.COM_PN=CAM_ORG in the catalog. The reliable way to pick it is simply to choose the largest of the listed versions, which is always the original:
http://192.168.54.1:50001/DO1050634.JPG
Step 5: download
A plain HTTP GET request on that URL writes the original file to disk. That is the entire pipeline: authenticate, switch to playback, list, choose the originals, download.
The date problem (the interesting part)
This is where it got genuinely fun. I did not want all 2,785 files. I wanted “everything since the 18th of June.” That should be easy: filter the catalog by date and download the matches.
Except the GX80’s DLNA catalog has no date field at all. Each item gives you a title, like 105-0634, and a file size, and nothing else. The standard DLNA field for a capture date, dc:date, is simply absent from the camera’s responses. There is no way to tell, from the catalog alone, when any given photo was taken.
After staring at the responses for a while, I tried something different. Instead of asking for the catalog, I made a HEAD request directly against one of the file URLs. A HEAD request is like a GET, except the server sends back only the response headers (the metadata about the file) and not the file contents itself. It is a cheap way to ask “tell me about this file without actually sending it.” And there, in the headers, was exactly what I needed: a custom, Panasonic-specific header carrying the capture timestamp.
X-REC_DATE_TIME: 2022-07-02T13:10:13
The exact moment the photo was taken, one file at a time. So the filtering strategy became: send one HEAD request per file to read its date, and keep only the files in the range I want.
Doing thousands of those requests against a tiny embedded web server would be slow and would risk overloading it. So I added an optimization. The catalog is in chronological order, so I page through it newest-first (starting from the end of the list rather than the beginning), and I stop the moment I cross below my target date. To pull “everything since the 18th,” that means I only ever inspect a few dozen recent files instead of all 2,785. The rest are never touched.
Fixing the file dates after download
Once the files landed on disk, their modification times were wrong , every file stamped with the download moment, not the capture time. The fix is to read the real capture date out of each file and set the mtime to match. Photos and videos store that date in different places, so they need different parsers. For JPGs, the capture time is in the EXIF tag DateTimeOriginal (0x9003). The Python standard library has no EXIF reader, so I parse it by hand: locate the EXIF block, read the byte order, walk the metadata entries. Here is a trimmed sketch:
# inside the EXIF block, after locating the metadata directory:
for i in range(entry_count):
tag = read_u16(data, off)
if tag == 0x9003: # DateTimeOriginal
ptr = read_u32(data, off + 8)
raw = data[tiff + ptr : tiff + ptr + 19] # "2022:07:02 13:10:13"
return datetime.strptime(raw.decode(), "%Y:%m:%d %H:%M:%S")
off += 12
For MP4 and MOV videos, there is no EXIF at all. Video files are built out of nested boxes called “atoms,” and the capture time lives in one named mvhd (short for “movie header”), which sits inside a parent atom named moov. The value stored there is creation_time, counted as the number of seconds since the 1st of January 1904, which is the reference date the QuickTime format happens to use. So to read it, you walk the tree of atoms, find mvhd, read the number, and convert from the 1904 reference point to a normal date.
There is a subtle trap waiting here. The QuickTime specification says that mvhd timestamps are supposed to be in UTC (universal time). Panasonic does not follow the specification: it writes the local wall-clock time of the camera into that field instead. If you trust the spec and convert the value as if it were UTC, every video ends up shifted by your timezone offset, an hour or two off from reality. So I deliberately read it back as local time, which makes the videos line up correctly with the photos. I confirmed this against real files: an MP4 and a JPG taken a few seconds apart now show timestamps a few seconds apart, exactly as they should.
That raises an obvious question. If the camera already hands out the capture date in that convenient X-REC_DATE_TIME header, why bother parsing the files at all? The answer is that the header turned out to be missing or unreliable for MP4 video files. Reading the date out of the file’s own embedded metadata (EXIF for photos, mvhd for video) is the approach that works reliably for both, with no dependence on the camera being cooperative.
The complete date handling, from filtering before download to repairing the timestamp after, looks like this:
def capture_date(path):
if path.lower().endswith((".jpg", ".jpeg")):
return exif_datetime_original(path)
return quicktime_mvhd_local(path) # MP4 / MOV
Caveats and gotchas
This camera’s WiFi stack is, to put it kindly, fragile. Here is everything I ran into, so you do not have to rediscover it the hard way:
- The embedded web server struggles under load. It identifies itself as
KDM SERVER, and when I tried downloading 8 files at once, I got intermittent[Errno 60] Operation timed outfailures. The fixes are: retry failed downloads automatically, with a short pause that grows on each attempt (a technique called backoff); skip any file that is already on disk, so that simply re-running the script picks up whatever is still missing; and reduce the number of parallel downloads if it is still flaky. With those in place, a couple of re-runs reliably gets everything. - Only one client can connect at a time. If the LUMIX phone app is already connected to the camera, your script will get nowhere. Close the phone app first.
- You must accept the on-screen prompt during the access handshake, and remember that calling any command before that handshake returns
err_reject. - The camera falls asleep. It needs to stay awake for the whole transfer, so check your power-saving settings if a long pull cuts out.
- WiFi is brutal on the battery. Running the access point and serving files over WiFi drains the GX80 fast, far faster than normal shooting. Mine actually died mid-transfer once, which is another good reason for the skip-if-already-present behaviour: I charged it, re-ran the same command, and it picked up exactly where it had left off. For a big pull, start with a full battery or keep the camera on power.
- AVCHD video is not shared over this protocol. Only MP4 video appears in the DLNA catalog. If you record in AVCHD, those clips will not show up, and you will still need the card reader for them.
- The address
192.168.54.1is only valid in the camera’s own hotspot mode (the “Direct” connection). If you instead join the camera to your home WiFi network, it will be assigned a different address by your router, and you will need to find that address first. - There are no dates in the DLNA catalog, as covered above, which is the whole reason for the per-file
HEADrequests and the newest-first early stop.
The result
What I ended up with is two small Python scripts that use only the standard library. There is nothing to pip install and nothing to keep updated. Both live in one small GitHub repository: laurentmmeyer/lumix-gx80.
gx80_pull.py(about 250 lines): runs the access handshake, switches the camera to playback, browses the DLNA catalog newest-first, filters by a date range using theX-REC_DATE_TIMEheader, downloads the originals in parallel with retries and skip-if-already-present, and stamps the correct capture date onto every file as it lands.set_capture_mtime.py: a companion script that repairs the capture dates on folders you have already downloaded, using the same EXIF andmvhdlogic described above.
A typical run looks like this:
python3 gx80_pull.py --since 2026-06-18 --dest ~/Pictures/gx80 --workers 4
It connects, finds everything shot since the 18th, pulls it down, and the files land in the right order with the right dates. No card reader, no cable.
A full confession is in order: this code is, of course, vibe coded. I am too old and too lazy to hand-write a DLNA client and an EXIF parser byte by byte, so I leaned on an AI to do the grunt work while I steered, tested against the real camera, and swore at the timeouts. The interesting parts of this post, the protocol quirks and the date saga, are real and hard-won. The typing was outsourced. Clone the repo, point it at your camera, and improve it as you see fit.
Closing notes
The two genuinely satisfying moments in this whole exercise were finding the X-REC_DATE_TIME response header, which is the only place the capture date is exposed before you download a file, and untangling the mvhd local-time-instead-of-UTC quirk that was silently throwing every video off by my timezone offset.
Everything is standard-library Python, so there are no dependencies that can rot or break a year from now. And because the whole approach rides on Panasonic’s generic cam.cgi plus DLNA stack, rather than anything specific to the GX80, the same method should work on other WiFi-capable LUMIX cameras in the G, GX, and GH families that speak the same protocol. If you have a different LUMIX, it is worth trying. Start by pointing curl at http://192.168.54.1/cam.cgi?mode=accctrl&type=req_acc&... and see whether your camera lights up and asks you to accept the connection.
Resources and links
- This project on GitHub: the two scripts, ready to clone and run.
- libgphoto2, "Lumix HTTP/Wifi protocol" issue: the most authoritative reverse-engineering reference for the cam.cgi and DLNA flow.
- libgphoto2 project: the PTP and USB side, for reference.
- LumixSecurity: a GitHub project containing gh4.py, a useful reference for the DLNA browse-and-download flow on LUMIX bodies.
- cleverfox/lumixproto: another reverse-engineering effort focused on the GX80 protocol.
- Cascable: the off-the-shelf paid Mac app, if you would rather not write any code.
- UPnP AV ContentDirectory specification: background on DIDL-Lite and the Browse action, published by the Open Connectivity Foundation.
- QuickTime File Format and ISO Base Media File Format: background on the moov and mvhd atom structure and the 1904-based creation_time field.