Metadata-Version: 2.4
Name: pyks2
Version: 1.2.1
Summary: A Python client and CLI for the Pentax K-S2's undocumented WiFi HTTP API, plus a reverse-engineering write-up. (Web GUI planned.)
Author-email: Jamal El Siblany <jamalsiblani@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/PICKLERICK2005/pyks2
Project-URL: Documentation, https://PICKLERICK2005.github.io/pyks2/
Project-URL: Repository, https://github.com/PICKLERICK2005/pyks2
Project-URL: Issues, https://github.com/PICKLERICK2005/pyks2/issues
Project-URL: Changelog, https://github.com/PICKLERICK2005/pyks2/blob/main/CHANGELOG.md
Keywords: pentax,k-s2,camera,wifi,reverse-engineering,tethering,remote-control,mjpeg
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Multimedia :: Graphics :: Capture
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25
Provides-Extra: async
Requires-Dist: httpx>=0.24; extra == "async"
Requires-Dist: websockets>=11; extra == "async"
Provides-Extra: testing
Requires-Dist: starlette>=0.37; extra == "testing"
Requires-Dist: uvicorn>=0.29; extra == "testing"
Requires-Dist: pytest>=7; extra == "testing"
Requires-Dist: pyks2[async]; extra == "testing"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: pyks2[async]; extra == "dev"
Requires-Dist: pyks2[testing]; extra == "dev"
Dynamic: license-file

# PyKS2

[![PyPI](https://img.shields.io/pypi/v/pyks2)](https://pypi.org/project/pyks2/)
[![tests](https://github.com/PICKLERICK2005/pyks2/actions/workflows/test.yml/badge.svg)](https://github.com/PICKLERICK2005/pyks2/actions/workflows/test.yml)
[![python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
[![license](https://img.shields.io/badge/license-MIT-green)](https://github.com/PICKLERICK2005/pyks2/blob/main/LICENSE)

A Python library and CLI for controlling the **Pentax K-S2** over its
built-in WiFi, built on an extensive, hardware-verified reverse-engineering of
the camera's undocumented HTTP API. (A web GUI is planned; see below.)


<img width="250" height="128" alt="image" src="https://github.com/user-attachments/assets/d947b6b5-77f6-4309-9b3d-5002587b1f6f" />


The K-S2 has a WiFi remote-control API, but Pentax never documented it. This
project maps the **entire surface** of the K-S2's API against a physical camera,
writes it up as a proper dissection, and ships a clean client that is lighter
and more capable than the vendor's own Image Sync app.

## Known gaps

This maps what I could exercise on my own body. Two areas are not fully
verified:

- **GPS**: the optional top-mounted Pentax GPS unit surfaces in
  `exposureModeList` but I couldn't test it (I don't own the unit). Untested.
- **Live-view digital zoom**: the endpoint and its gate are verified —
  `POST /v1/liveview/zoom` returns `412` with no live view running and `200`
  while a stream is active, regardless of the body. What I couldn't find is a
  payload that produces an observable change in the frame: every shape I tried
  was accepted and nothing moved. So the gate is known; the effect isn't.

Everything else is behaviourally verified against the hardware below.

## Tested on

- **Body:** Pentax K-S2
- **Firmware:** 01.10
- **Lenses:** smc PENTAX-DA 18–50mm, DA 50–200mm

Other Pentax bodies with the same WiFi stack (K-1, KP, K-70, …) likely share
much of this API but are **untested**, reports welcome.

> **Two things make this more than "another camera library":**
> 1. A full [protocol dissection](https://picklerick2005.github.io/PyKS2/PROTOCOL.html) and
>    [reverse-engineering methodology](https://picklerick2005.github.io/PyKS2/METHODOLOGY.html): the endpoints quirks, and hardware
>    limitations I could exercise on my camera, with the raw captures to back
>    it up.
> 2. A design that **beats the official app**: event-driven via the camera's
>    WebSocket instead of the poll-storm Image Sync uses (~90% of its total
>    requests were just polling one endpoint while interacting with the app actively).

---

## What's here

```
pyks2/          the library (camera-only HTTP client, typed models, WS events)
  ├─ client.py       K_S2_WiFi, the API client
  ├─ models.py       typed response models (defensive parsing)
  ├─ events.py       /v1/changes WebSocket client (stdlib, zero-dep)
  ├─ constants.py    endpoints + capability enums
  ├─ errors.py       typed exceptions (errCode-aware)
  ├─ _mjpeg.py       shared MJPEG frame parser (sync + async liveview)
  ├─ async_client.py optional async streaming (pyks2[async])
  ├─ testing/        shipped camera simulator (pyks2[testing])
  └─ cli.py          the command-line interface
docs/           the reverse-engineering write-up (GitHub Pages source)
  ├─ PROTOCOL.md     the API dissection
  └─ METHODOLOGY.md  how it was probed (approach, false trails, traffic capture)
examples/       real captured JSON responses + the machine-readable API reference
```

---

## Quick start

```bash
pip install pyks2                     # or: pip install -e .  from a clone
```

Join the camera's WiFi (`PENTAX_XXXXXX`), then:

```python
from pyks2 import K_S2_WiFi

cam = K_S2_WiFi()            # defaults to 192.168.0.1
assert cam.ping()

# take one photo safely: records the baseline, fires, waits for the NEW file,
# and downloads it all in one call (af="off" is MF-safe: always releases)
info = cam.capture(af="off", download_to="shot.dng")
print("captured:", info.path)

# change settings (validated by the camera; illegal values raise)
cam.set_camera_params(av="8.0", sv="400")

# ...or use the typed accessors, which also catch camera-controlled fields
# (e.g. av in Sv mode) and raise instead of silently no-opping
cam.set_iso(400)
cam.set_aperture(8.0)

# browse the card
for photo in cam.list_photos():
    print(photo.path)
```

Event-driven, no polling:

```python
with cam.events() as ev:              # /v1/changes WebSocket
    for change in ev:
        if change.is_storage:         # a frame just landed
            print("captured:", cam.latest_info().path)
        elif change.is_camera:        # a setting changed on the body
            print("settings:", cam.get_camera_params())
```

Live view (MJPEG):

```python
with cam.liveview() as stream:       # closes the stream (drops the mirror)
    for jpeg in stream:               # on exit, even if you break out early
        open("frame.jpg", "wb").write(jpeg)
        break
```

---

## Async streaming

`pip install pyks2[async]` pulls in `httpx`/`websockets` for async equivalents
of the event stream and live view. Hardware-verified against a physical K-S2,
see [VERIFICATION.md](docs/VERIFICATION.md).

```python
async with cam.events_async() as ev:
    async for change in ev:
        if change.is_storage:
            print("captured:", cam.latest_info().path)
```

```python
async for jpeg in cam.iter_liveview_frames_async(max_frames=10):
    ...                               # 720x480 JPEGs; mirror drops on close
```

Both share their parsing with the sync path. MJPEG framing via
`pyks2._mjpeg`, event decoding via `events._payload_to_event`, so there is no
duplicated protocol logic between sync and async.

---

## Camera simulator (for tests)

`pip install pyks2[testing]` ships a protocol-level fake K-S2 that serves over a
real socket, so you can drive the **real** client with no camera on the bench.
Every response is replayed from bytes captured off the physical camera, and it
reproduces the awkward parts of the protocol on purpose: `errCode` in the body,
`?limit` as a head-limit only, the empty-list writability signal, one `storage`
event per capture, no "latest" photo until something is actually shot. Its card
is a real listing too: 358 files across six directories, including a RAW+JPEG
pair, so cross-directory ordering behaves like the real thing.

It is measured against the real body rather than written from the notes: the same
probe runs against both and the results are diffed, 40 of 40 checks matching,
including response times. On top of that, every response the simulator *computes*
is diffed against the captured bytes for the same scenario — a standing test,
because two divergences turned up outside what the probe compared (see
[VERIFICATION.md](docs/VERIFICATION.md), which records the scope of each).
Latency is modelled by default, a
capture reports ~2 s later, the first live view frame waits ~830 ms for the
mirror, because a mock that answers instantly hides the timeout and ordering
bugs a fake camera exists to catch. Pass `timing=FAST` when you just want speed.

```python
from pyks2.testing import SimulatorServer

with SimulatorServer() as server:
    cam = server.client()             # a real K_S2_WiFi, pointed at the fake
    info = cam.capture(af="off")      # shoot -> new file appears -> download
    cam.download(info.path, "shot.jpg", size="view")
```

Or run it standalone and point anything at it:

```bash
python -m pyks2.testing.simulator --port 8080
```

In a test suite, the `ks2_simulator` fixture is registered automatically by
installing the extra, no `conftest.py` wiring:

```python
def test_capture(ks2_simulator):      # latency off; ephemeral port
    cam = ks2_simulator.client()      # fresh camera state per test
    assert cam.capture(af="off").path
```

(`ks2_simulator_realistic` is the same thing with the camera's measured delays,
for when the timing is what you're testing.)

### Shaping the camera, and making it fail

Every knob is public API, nothing needs private attributes:

The `ks2_simulator` fixture hands you the control object as `.simulator`, so a
test can point the client at the fake *and* make it misbehave:

```python
from pyks2.testing import paths

sim = ks2_simulator.simulator

# state
sim.set_exposure_mode("B")        # the real Bulb capture: tv/xv camera-owned
sim.set_focus_mode("mf")          # in MF, af=auto is refused with a 412;
                                  # the lens reads switch to the MF capture
sim.set_camera_controlled("sv")   # ISO camera-owned: writes 200 but no-op
sim.seed_photos({"100_0101": ["IMGP0001.DNG"]})

# failures, from real captured error bodies
sim.fail(paths.SHOOT)                            # next shot returns 412
sim.fail(paths.PHOTOS, "bad_request", times=3)
sim.fail(paths.PROPS, "not_found", times=None)   # until cleared

# transport misbehaviour (behavioural, no fixture)
sim.drop(paths.PROPS)             # connection dies -> KS2ConnectionError
sim.delay(paths.PHOTOS, 5.0)      # slow enough to trip a client timeout
sim.drop_stream_after(3)          # live view dies mid-stream
sim.clear_faults()
```

Use the `paths.*` constants rather than URL strings, so your tests aren't
coupled to pyks2's spellings. `paths.all()` lists every fault-able endpoint,
and the simulator refuses to start if a route ever appears without one. Raw
paths still work. `paths.PHOTO_FILE` and `paths.PHOTO_INFO` are templated and
match any photo; pass a concrete `"/v1/photos/DIR/FILE"` to target one.

`fail()` only accepts errors that were actually captured: `"precondition"`
(412), `"bad_request"` (400), `"not_found"` (404), `"unhandled_method"` (a real
HTTP 400 with an HTML body). There is deliberately no card-full: that response
was never captured, and the simulator does not invent wire data — it points you
at the near-full `remain: 1` capture in the repo's `examples/` instead. Likewise
`set_exposure_mode()`
accepts only the dial positions with a real capability capture behind them, and
points you at `set_camera_controlled()` when you ask for another.

This is supported public surface, not internal scaffolding: libraries built on
pyks2 use it to run their integration tests against a faithful camera rather
than mocking pyks2 out. Only a capture mutates state, it is a protocol
simulator, not a camera emulator.

---

## CLI

Everything the library does, from a terminal:

```bash
pyks2 ping
pyks2 info                          # model, firmware, battery (%), storage
pyks2 shoot --af off --wait --download shot.dng
pyks2 settings                      # show current settings
pyks2 settings av=8.0 sv=400        # set them
pyks2 lists                         # capability lists (dropdown sources)
pyks2 browse --limit 20             # list photos
pyks2 download 100_1507/IMGP1974.DNG --size view -o preview.jpg
pyks2 liveview -o frame.jpg
pyks2 watch --resolve               # stream camera events live
```

> `pyks2 info` reports the K-S2 battery as an integer percentage. In practice the camera reports values like `100`, `66`, and `0`, plus a `hot` thermal state flag in `status/device`.

---

## Web GUI

A browser-based control panel is planned, full Image-Sync parity plus
extensions (live view, remote capture, touch-to-focus, a settings panel driven
by the camera's own capability lists, an idle-safe gallery, and event-driven
updates), served by a thin local backend that reuses this library so it runs on
any OS in any browser. *Not yet included in this release, the library and CLI
are the shipping surfaces.*

---

## The reverse engineering

The heart of this project is the write-up. Highlights:

- **The API surface I mapped spans 38 endpoint templates**, organised as five
  read *groups* (`constants`/`params`/`variables`/`status`/`props`) × four
  *subsystems* (`camera`/`lens`/`liveview`/`device`), plus capture/focus/photo/
  liveview actions and a WebSocket. The one gap I could not verify at all is
  GPS; for LiveView Zoom I pinned down the endpoint and its gate but never found
  a payload with a visible effect.
- **Two protocol laws** every client must respect: the real status is in the
  body's `errCode` (not the HTTP status), and datetime/numeric formats are
  inconsistent across endpoints.
- **Hardware interlocks** mapped and explained: the AF/MF lever, mode dial, and
  movie mode are physical-only; movie mode *disables WiFi entirely*; opening the
  SD-card door kills the connection; and the camera's WiFi access point uses
  client isolation (which shaped how the official app's traffic had to be
  captured).
- **How Image Sync actually works**, captured off the wire, and why this
  client's event-driven design is better.

Start with **[docs/PROTOCOL.md](https://picklerick2005.github.io/PyKS2/PROTOCOL.html)**, then
**[docs/METHODOLOGY.md](https://picklerick2005.github.io/PyKS2/METHODOLOGY.html)**. The raw evidence is in
**[examples/](https://github.com/PICKLERICK2005/pyks2/tree/main/examples)** and the machine-readable spec is
**[examples/API_REFERENCE.json](https://github.com/PICKLERICK2005/pyks2/blob/main/examples/API_REFERENCE.json)**.

---

## Development & tests

```bash
git clone https://github.com/PICKLERICK2005/pyks2.git
cd pyks2
pip install -e ".[dev]"     # installs pytest, mypy, ruff (+ the async extra)
pytest -q                    # 158 tests, no camera required
```

No hardware needed, in two layers: client tests driven from captured fixtures,
and end-to-end tests that stand up the shipped socket simulator
(`pyks2.testing`) and point the real client at it — sync and async transports,
the CLI, fault injection, and byte-level checks that every response the simulator
computes matches the capture it came from. Tests also serve as executable
documentation of the API's behaviour, including the trickier findings (async
capture, the Bulb correction, dynamic capability lists).

## Compatibility

Verified against a **Pentax K-S2, firmware 01.10**. Other Pentax bodies (K-1,
KP, K-70, K-3…) share much of this API family but differ in specifics. Running
the probing approach in [docs/METHODOLOGY.md](https://picklerick2005.github.io/PyKS2/METHODOLOGY.html) on another
body and sending the diffs is the most useful contribution you could make!

## Gaps

The only remaining gaps in this project would be:

> 1. The endpoint affiliated with the GPS module
> 2. A Liveview Zoom payload that actually zooms

The GPS one needs the physical top-mounted module, which I don't own. Liveview
Zoom needs no accessory: its gate is verified — it accepts requests only while
live view is streaming — so what's missing is a body that produces a visible
effect, not hardware.

## License

MIT — see [LICENSE](https://github.com/PICKLERICK2005/pyks2/blob/main/LICENSE).

## Acknowledgements

The [2016 K-1 WiFi analysis](https://www.pentaxforums.com/forums/190-pentax-k-1-k-1-ii/359018-k-1-wifi-transfer.html) on the pentax forums was a useful starting point for
hypotheses. Everything here was independently verified against a physical K-S2.
This project inspects only the camera's own network behaviour and my own hardware;
it contains no vendor code or any unmentioned references.
