The Pentax K-S2 WiFi API; a complete dissection
Everything here was verified against a physical Pentax K-S2 running firmware 01.10. Where a claim rests on an observation, the observation is stated. Where the camera’s behaviour contradicts “known” stuff (or contradicts this project’s own earlier assumptions), the contradiction is called out and resolved with data.
The camera exposes an undocumented HTTP API on 192.168.0.1 when its built-in WiFi is active. There is no official developer documentation; the only public prior art is a partial 2016 write-up of the closely-related K-1. This document is the first complete, behaviourally-verified map of the K-S2’s surface.
1. The shape of the API
The camera answers HTTP on port 80. It is self-describing: GET /v1/apis returns the full list of endpoint templates.
The surface is organised as five read “groups” crossed with four “facets”, plus a handful of action endpoints and a WebSocket.
The four facets are camera, lens, liveview, and device. The five groups are different views of the same underlying state:
| Group | What it returns | Mutable? | Use it for |
|---|---|---|---|
constants | Static capability lists (*List arrays) | No, never changes | Dropdown option sources — fetch once |
params | Current set values | GET + PUT | Read/write live settings |
variables | Params + lists + live values | Read-only | The “everything” poll |
status | Transient runtime state | Read-only | state, focused, liveState |
props | Flat legacy superset (K-1 style) | Read-only | Back-compat blob |
So GET /v1/constants/camera gives you the possible white-balance modes; GET /v1/params/camera gives you the one that’s currently set; PUT /v1/params/camera changes it. The decomposition is clean and consistent across all four facets, a nicer design than the K-1’s single monolithic props blob, which the K-S2 still provides for compatibility!
2. Two laws that govern every request
Law 1 — errCode lives in the body, not the HTTP status
This is the single most important thing to know about this API. The HTTP status line is ALMOST ALWAYS 200, even when the request logically failed. The real status is a field inside the JSON body:
{"errCode": 412, "errMsg": "Precondition Failed"}
A client that trusts the HTTP status code will treat failures as successes. Every request must parse the body and read errCode. Observed codes: 200 (ok), 400 (bad request / illegal value), 412 (precondition failed).
There is exactly one exception: a genuinely unhandled HTTP method (e.g. DELETE) bypasses the JSON layer and returns a raw HTML 400 Bad Request page. So “body is HTML, not JSON” is itself a signal, it means the method isn’t handled at all.
Law 2 — datetime formats are inconsistent
Two endpoints, two formats:
GET /v1/ping→ ISO-8601:2026-07-15T11:43:15GET /v1/photos/.../info→ colon-packedYY:MM:DD:HH:MM:SS:26:07:15:11:09:11
Numeric fields wobble too — exposure compensation xv has been seen as "0", "0.0", and "-0.7" across responses. Any parser must tolerate all forms. pyks2 normalises these on the way in.
3. Reading state
All read endpoints return 200 quickly (~40–120 ms) and carry no side effects. GET /v1/ping is the cheapest and is the recommended heartbeat — it never triggers the card-read behaviour that other endpoints can, keeping things fast and avoiding camera-side hangs.
The decomposed endpoints (/v1/{group}/{facet}) are preferred over the monolithic /v1/props. They return exactly the slice you need which is far more efficient, and combined with the /v1/changes WebSocket (§7) they let a client stay current without polling.
Captured examples for all of these live in examples/.
One schema curiosity: variables/camera carries an exposureModeOption field that is empty ("") in every mode on this body (checked across M, Av, Tv, P, autopict/scene, and TAv). It exists in the schema but seems to be unused on the K-S2, probably populated on bodies/modes with a sub-selection. Safe to ignore here.
4. Writing settings
PUT /v1/params/camera with a form-encoded body writes settings:
PUT /v1/params/camera
av=8.0&sv=400
Three verified behaviours:
- It sticks, and it echoes the full state back. The response to a PUT is the complete camera state (lists + values), so a write doubles as a read.
- It validates server-side. An illegal value (
av=99) is rejected witherrCode 400. The client doesn’t have to pre-validate, though checking against theconstantslists gives a nicer UX. - The option lists are dynamic.
avListis not fixed, it changes with the lens and current aperture. After changing exposure mode or lens state, the available apertures differ. Never cache the lists globally; re-fetch after changes.
focusMode (on /v1/params/lens) and the WiFi device params look writable but are not — see §6.
tv is only writable when the mode lets the user control it
A subtle but clean rule, confirmed by writing tv across every exposure mode: tv is writable exactly when the camera reports a non-empty tvList, which is exactly the modes where the user owns the shutter: M, Tv, P, and TAv (all report a 54-value tvList). In Av (aperture priority) and the auto/scene modes (autopict, etc.), tvList comes back empty and the camera controls shutter itself; a tv write returns 200 but is silently ignored (the value doesn’t change). So the list is not just a dropdown source, its emptiness is the signal that the field is camera-controlled in the current mode. Check tvList before offering tv as an editable control.
(One edge seen: in Tv mode a specific token like 1.320 occasionally didn’t stick even though the list was non-empty. The camera clamps to what the current lens/ISO allows. Treat a write as “requested”, then read back to confirm what actually took.)
5. Capture, focus, and bulb exposure
Stills capture
POST /v1/camera/shoot with af=auto|on|off fires the shutter.
{"errCode": 200, "focused": true, "focusCenters": [], "captured": false}
Three things to internalise:
capturedis alwaysfalsein the immediate response. Capture is asynchronous. To know a frame actually landed, either poll/v1/photos/latest/infountil it updates, or even better- wait for thechanged: "storage"WebSocket event (§7).- A
200does not prove a frame was written. When the card is full,shootstill returnserrCode 200withfocused:false, captured:falseand nothing saved. There is no storage-specific error code. So success detection must be positive (a new file appears), never “the POST returned 200”. af=offreleases the shutter without hunting for focus. On a manual-focus lens or a fixed-focus rig,af=autocan make the camera entirely refuse to fire if it can’t lock.af=offalways releases. This is the correct choice for a controlled studio/rig capture.
Focus
POST /v1/lens/focus with pos=x,y drives autofocus and sets the AF point, returning focused: true. Note the asymmetry: you can trigger AF and place the point over WiFi, but you cannot switch the camera between AF and MF since on the KS2 that’s the physical lever (§6).
Bulb exposure: what shoot/start and shoot/finish really are
This is a correction to an earlier assumption in this project. POST /v1/camera/shoot/start and /finish are not for video recording (video recording on the KS2 disables the wifi) they are the Bulb long-exposure mechanism, and they work over WiFi.
The trap: in every non-Bulb mode, both return errCode 412 Precondition Failed, which combined with the fact that movie mode disables WiFi made them look permanently unreachable. But with the mode dial physically set to B (Bulb):
POST /v1/camera/shoot/start→200(opens the shutter)POST /v1/camera/shoot/finish→200(closes it)- plain
POST /v1/camera/shoot→412in Bulb mode (use start/finish instead)
So the precondition on shoot/start/finish is simply “the dial is in Bulb”. The elapsed time between start and finish is the exposure. Bulb mode also reports empty tvList and xvList (no shutter-speed or EV-comp value applies), which is the tell that a mode is bulb rather than a timed exposure.
6. What the API cannot touch: the physical interlocks
A recurring theme: several pieces of camera state are governed by physical controls and are read-only (or invisible) to the API. They might look write-able but they are not.
- AF/MF lever: read-only over the API, and uniquely, it fires no change event at all. The API can observe the resulting
focusModebut cannot set it, and can’t even tell when you flip it. - Mode dial: read-only. Turning it fires
changed: "camera". - Movie mode: not settable, and enabling it kills WiFi (see §5).
- SD card door: opening it kills the WiFi connection and drops the camera toward an off/idle state. An operator must never touch the door mid-session; a client must handle sudden disconnection gracefully and wait/prmpt for a reconnect.
deviceparams (ssid,channel,key): read-only;PUT→400. The WiFi network cannot be reconfigured through the API, physical interface only.
6.5 The full mode dial, and what each position exposes
Every physical dial position was characterised over WiFi. The API reports the active mode in exposureMode, and crucially, which exposure values are user-settable in that mode is signalled by whether their list is non-empty (an empty avList/tvList/svList/xvList means the camera controls that value in this mode; writes return 200 but are ignored):
| Dial | exposureMode | av | tv | sv | xv |
|---|---|---|---|---|---|
| P | P | ✓ | ✓ | ✓ | ✓ |
| Sv | SV | – | – | ✓ (28 steps) | ✓ |
| Tv | TV | – | ✓ | ✓ | ✓ |
| Av | AV | ✓ | – | ✓ | ✓ |
| TAv | TAV | ✓ | ✓ | – | ✓ |
| M | M | ✓ | ✓ | ✓ | ✓ |
| B (Bulb) | B | ✓ | – | ✓ | – |
| U1 / U2 | U1 / U2 | ✓ | ✓ | ✓ | ✓ |
| Auto Picture (green) | autopict | – | – | ✓ | ✓ |
| SCN (Scene) | scene | – | – | ✓ | ✓ |
| HDR | AHDR | – | – | ✓ | ✓ |
| Movie | (WiFi disabled) | — | — | — | — |
Notes:
- Bulb exposes no
tv/xv(no shutter-speed value applies) the tell that a mode is bulb. Capture it viashoot/start/shoot/finish(§5). - Sv unlocks a finer 28-value
svListversus ~11 elsewhere. - U1/U2 report their own mode ids and behave like the exposure mode saved into them (all values writable in the cases seen).
- Auto/Scene/HDR only let you nudge ISO and EV comp; aperture and shutter are camera-controlled. The 18 scene sub-modes under
sceneare selected on the camera body, not over the API. - GPS appears in
exposureModeListprobably interacts with the optional top-mounted GPS unit made by Pentax for star-tracking and such. Remains untested.
EV compensation and ISO
xv (exposure compensation) is writable across the full +5.0 … −5.0 range and validated server-side (xv=+9.9 → 400). ISO is set via sv, and ISO AUTO is a real value: sv=auto (returns 200, then reads back as the resolved sensitivity); out-of-range numbers (sv=999999) are rejected with 400.
6.6 Hardware state: battery, storage, lens
- Battery (
status/device.battery) is an integer percentage observed 100, 66, and 0, so it’s a real percentage but is not to be taken for accurate. Ahotboolean reports thermal state. - Storage
remainis a FRAME COUNT, not bytes.storages[].remainis the number of shots left observed6467near-empty and1with one frame of space, dependent on shooting settings (JPEG/RAW/BOTH). When files exist, the storage object also carriesdir/filepointing at the latest. (A near-full card behaves normally for listing; the only effect isshootsilently not-saving once truly full — see §5.) - Lens identity is not exposed at all.
constants/lensis empty, and two different lenses (an 18–50 and a 50–200) returned identical data, onlyfocusMode/focused/focusCentersexist anywhere. You cannot tell which lens is mounted over WiFi. The one lens-dependent signal is indirect: theavListinvariables/camerareflects the mounted lens’s aperture range.
7. The event stream
GET /v1/changes upgrades to a WebSocket (HTTP 101). Once connected, the camera pushes small JSON events when something changes:
{"errCode": 200, "errMsg": "OK", "changed": "storage"}
The event vocabulary is deliberately coarse. Across a full physical sweep (turning the dial, half-pressing, changing aperture and ISO, flipping the AF/MF lever, firing the shutter, opening the SD door, raising the flash) only two changed values ever appear:
camera: any camera setting changed (mode dial, aperture, ISO…)storage: a file was written (a photo landed on the card)
Taking a photo fires both. Several physical actions fire nothing (AF/MF lever, half-press, SD door, flash).
So the stream is not a value push, it’s a “go look at this group” nudge. The correct pattern is: connect once, and on changed: X re-fetch that group’s params. This replaces polling entirely and makes it more efficient than the way Pentax’s Image Sync app does things.
A later, longer listen across a wider set of actions (varied setting changes, a capture, focus, an idle stretch, flash, lens zoom, playback review) produced only camera and storage, nothing else. The two-value vocabulary is therefore treated as exhaustive, not merely “all we happened to see”: there is no battery, idle, playback, or lens event.
8. Photos: browsing, previews, downloads
/v1/photos does not hang
Early in this project, GET /v1/photos appeared to hang indefinitely and put the camera into an orange-LED “card read” state, and it was written off as unusable with /v1/photos/latest/info used as a workaround. That conclusion was wrong, and disproving it is one of the more satisfying results here.
A controlled test measured /v1/photos at two card populations, three runs each, plus a run immediately after a 56-second full-DNG download (the most “busy” the camera ever gets):
| Condition | Files on card | Response time |
|---|---|---|
| Idle | 8 | 159 / 76 / 42 ms |
| Immediately after 56 s download | 8 | 158 ms — 200 OK |
| Idle | 80 | 338 / 377 / 320 ms |
| Immediately after 54 s download | 80 | 408 ms — 200 OK |
It never hung. Listing time scales gently and roughly linearly with file count (~4 ms/file) which makes sense, and the “busy” state doesn’t break it. The original hang was almost certainly a keep-alive / connection-handling artifact of the very first probes, or a card in a genuinely bad state, not a property of the endpoint. The endpoint is safe to use.
The response structure is a list of directories, each with its files:
{"dirs": [{"name": "100_1507",
"files": ["IMGP1971.DNG", "IMGP1972.DNG", "..."]}]}
A hidden ?limit=N
Sweeping query parameters turned up an undocumented one: ?limit=N caps the listing to the first N files, returning in a constant ~60 ms regardless of card size. Every other parameter tried (dir, offset, page, count, from/to) was ignored and returned the full listing. So there is a head-limit but no offset/cursor, no true pagination. Given how fast a full listing is, that’s a non-issue. It also seems like the official app doesn’t use limit at all.
Previews and downloads
Downloads are GET /v1/photos/{dir}/{file} with an optional ?size=:
size= | Result | Notes |
|---|---|---|
thumb | errCode 400 | Not supported on the K-S2 |
view | ~54 KB JPEG | The only working preview. Used for gallery thumbnails |
full | ~18 MB DNG | ~55 s over WiFi |
| (omitted) | ~18 MB DNG | Identical to full |
Per-image metadata comes from GET /v1/photos/{dir}/{file}/info, and it works for arbitrary files, not just recent ones — so full card browsing is possible. DELETE is not supported (raw HTML 400); photos can’t be removed over WiFi, probably a safety feature.
Bulk download over WiFi is impractical (55 s × N). For getting many RAWs off the camera, a USB / SD card reader remains the right tool. WiFi download is for grabbing the occasional preview or single reference frame.
9. Live view
GET /v1/liveview is a standard MJPEG stream:
Content-Type: multipart/x-mixed-replace; boundary=--boundarydonotcross
Frames are ~23 KB baseline JPEG at 720×480. The per-frame part-headers carry only Content-type: image/jpg, with no embedded focus/exposure/histogram metadata (the K-1 reportedly embedded more). Any on-screen overlays must be computed client-side or pulled from /v1/status. In a browser, pointing an <img> element at the stream URL just works, merely viewing it kicks the camera’s mirror up to start streaming, and just terminating the JPEG stream viewing flips the mirror back down.
POST /v1/liveview/zoom exists for digital zoom/pan but is gated: an empty body returns 200, but any parameters return 412 unless live view is actively streaming.
With a stream running, it was probed exhaustively, and the result is no observable effect over WiFi on the test rig. Every candidate parameter returns errCode 200 (zoom, level, scale, magnify, ratio, pos, x/y, on), including deliberately nonsensical values, and the live frame never changes for any of them. The endpoint is reachable and stream-gated, and it does not validate its parameters.
The cause is unconfirmed. One plausible explanation: the endpoint may depend on a hardware capability the test lenses don’t expose, both lenses used here are kit zooms, or on a camera state that isn’t reachable over the API. Either way, no usable digital-zoom control was observed via the API on this setup.
10. How the official app works — and how this project improves on it
Capturing the Android Image Sync app’s own traffic (see METHODOLOGY) settled the last open questions about the gallery, and revealed how the reference client is built.
The gallery is unremarkable: GET /v1/photos to enumerate, then .../info per file for metadata, then ?size=view per file for the thumbnail image, and a bare GET for full download. Exactly the endpoints documented above, sadly no secret sauce.
The more interesting finding is the app’s architecture. Of 691 captured requests, 618 were GET /v1/props, the app polls the monolithic state blob many times per second which I imagine contributes to rapid battery drainage and it’s poor performance. It opens the /v1/changes WebSocket once (and gets its 101) but then leans almost entirely on brute-force polling.
pyks2 deliberately does better:
- Event-driven, not poll-storming: it uses
/v1/changesand re-fetches only the group that changed. - Decomposed reads:
/v1/{group}/{facet}instead of the wholepropsblob. - Correct
errCodehandling, dynamic list re-fetching, and graceful handling of the SD-door disconnect. ?limit=Nfor fast initial gallery loads.
The result is a lighter, more responsive client than the vendor’s own, built entirely on a surface that had no public documentation, just some probing fun!
Next: METHODOLOGY — how all of this was probed, including the false trails and the traffic-capture that cracked the gallery.