All notable changes to pyks2 are documented here. This project follows Semantic Versioning.
-
ChangesClient.next_event()no longer drops coalesced change events. If several complete/v1/changesWebSocket frames arrive in one receive buffer, every decoded event is now retained and returned exactly once in receive order. The iterator remains compatible and shares the same lossless internal queue and decoder. Incomplete trailing frames remain buffered as before.Verified on a physical PENTAX K-S2 under rapid capture: five physical shots produced all five expected
storageevents throughnext_event().
162 tests pass, including coalesced two- and five-event reads, queued delivery
without another socket receive, fragmented network reads, and iterator/
next_event() ordering conformance.
The 1.2 line, stable. One headline feature — a shipped, protocol-level camera
simulator — plus the client fixes and protocol corrections that building it
turned up. Consolidates 1.2.0b1, b2, rc1 and rc2; the entries below keep
the blow-by-blow.
Upgrading from 1.1.0 is a drop-in for camera code. The only breaking change is
the Python floor: 3.9 is no longer supported.
-
A shipped camera simulator,
pyks2[testing].pyks2.testingserves a protocol-level fake K-S2 over a real socket, so the actual client — sync and async — runs end to end with no camera on the bench. This is public, supported surface, not internal scaffolding: downstream libraries import it to test against a faithful camera instead of mocking pyks2 out.from pyks2.testing import SimulatorServer with SimulatorServer() as server: cam = server.client() # a real K_S2_WiFi info = cam.capture(af="off") # shoot -> file appears -> download
Also
python -m pyks2.testing.simulator --port 8080, andks2_simulator/ks2_simulator_realisticpytest fixtures registered through apytest11entry point — installing the extra is the whole setup, noconftest.py.Every response body is replayed from bytes captured off a physical K-S2 (firmware 01.10), shipped as package data so it works from a plain
pip install. It reproduces the protocol's awkward parts rather than an idealised API:errCodein the body under HTTP 200 (Law 1),/v1/photosoldest-first with?limitas a head-limit only, the empty-list writability signal, onestorageevent per capture, no "latest" photo until something is shot this power session, and the 412/200 gating on/v1/liveview/zoom. Its card is a real 358-file listing across six directories, RAW+JPEG pair included. -
Modelled response latency (
Timing), from measured medians — ~103 ms for/v1/props, ~1.5 s for a full/v1/photos, ~1.9 s shutter →storageevent, ~830 ms for the first live view frame while the mirror flips. Realistic is the default, because a mock that answers instantly hides the timeout and ordering bugs a fake camera exists to catch;timing=FASTremoves it. -
Fault injection:
fail(path, error, times=)serves a real captured error body,drop(),delay()anddrop_stream_after()reproduce transport misbehaviour,clear_faults()resets.ERROR_BODIESadvertises only errors that were actually captured. -
A public configuration API —
set_exposure_mode(),set_focus_mode(),set_camera_controlled(),set_user_controlled(),writable(),seed_photos(),add_photo()— so nothing reaches into private attributes. -
Symbolic endpoint constants (
pyks2.testing.paths), so downstream fault tests key onpaths.SHOOTrather than a URL string.create_app()refuses to start if the route table and the constants ever disagree. -
Bulb is modelled properly:
shoot/start/shoot/finishgated on the dial being onB, a plainshootonBreturning 412, and a completed exposure writing a file and firing onestorageevent.
K_S2_WiFinow accepts an address with a port ("127.0.0.1:8080").ipwas interpreted inconsistently: HTTP interpolated it into the URL, but the/v1/changesclients needed host and port separately — the async one builtws://host:8080:80/v1/changes. There was no working way to point the event stream anywhere but port 80. Invisible against the camera, which is always192.168.0.1:80.pyks2 shoot --download OUTsilently skipped the download unless--waitwas also passed — the flag was only read inside that branch, so the shutter fired and nothing was fetched or reported. It now implies the wait.preview_bytes()accepted a malformed path and built a nonsense URL from it, wherephoto_info()anddownload()both raiseValueError.latest_info()andwait_for_capture()were documented wrong. "Latest" means latest this power session, not newest on the card: with 358 files present, a freshly powered camera answerscaptured: falsewith nodir/file.- Dropped the deprecated
asyncio.get_event_loop(), and kept a strong reference to the background capture task that asyncio only holds weakly. The suite runs under-W error::DeprecationWarning.
Both measured, both correcting this project's own earlier write-up:
/v1/liveview/zoomgating. PROTOCOL.md §9 claimed an empty body returned 200. It does not: with no stream running, no body, an empty body andzoom=1all return 412; with a stream running, all three return 200. The gate is purely whether live view is streaming.- Live view serves one stream at a time. Opening a second delivers one more frame to the first and then closes it; the first never recovers. An earlier note claiming the camera "permits concurrency" came from a flawed test that only read the second stream's headers.
- Python 3.9 is dropped;
requires-pythonis now>=3.10. It was already untenable — currentstarletteanduvicornboth require 3.10, sopip install pyks2[testing]on 3.9 either failed to resolve or silently backslid to untested versions. - CI runs on
developas well asmain, across 3.10–3.13 on Linux plus a Windows leg, and gates lint. The ruff rule set is pinned explicitly rather than inherited from whatever ruff CI resolves, andpublish.ymlnow runs the same checks as branch CI — a publish pipeline that tests less than branch CI is how a broken tag gets cut. .gitattributesnormalises source line endings while keeping the captured fixtures byte-exact.
The simulator is measured against the camera rather than written from the notes:
the same raw-socket probe ran against the physical body and against the
simulator and the results were diffed — 40 of 40 checks match, wire behaviour
and latency. On top of that, every response the simulator computes is diffed
against the captured bytes for the same scenario, which is a standing test rather
than a one-off, because two divergences turned up outside what the probe
compared. docs/VERIFICATION.md records both, and the scope of each.
158 tests, up from 70 at 1.1.0. pyks2/testing/data/PROVENANCE.md documents every
fixture's origin, labels the two generated bodies as generated and the one
inferred body as inferred, names the three deliberate deviations from the camera,
and records what was observed but not modelled — notably that camera events
are intermittent on real hardware, so callers should poll after writes rather
than trust them.
A second release candidate, not the stable release, and the reason is process
rather than doubt about the code. 1.2.0rc1 was soaked by a real downstream
consumer (SPTFS); everything below landed after that soak, so no consumer has
yet run the bytes that would become 1.2.0. Two of these fixes are in the client
itself, not the simulator, so "the simulator got more faithful" does not cover
it. rc2 exists to be re-verified from the outside before promotion.
rc1 cannot be re-cut: it is published and immutable, and it carries the
superseded bytes.
No API changed, so anything written against rc1 keeps working — but if you
depend on get_lens_state().focused, read the first entry under Fixed: the
client.
These reach anyone using the library, independently of the simulator.
get_lens_state().focusedwas wrong in MF. Strictly it was the simulator that lied, but it lied about the value a client reads, so any downstream test asserting onfocusedin MF was asserting the wrong thing and passing. Detail in the simulator section below.pyks2 shoot --download OUTsilently skipped the download unless--waitwas also passed: the flag was only read inside the--waitbranch, so the shutter fired and nothing was fetched or reported. It reads as a download that failed rather than one that never ran.--downloadnow implies the wait, since there is nothing to fetch until the file lands.preview_bytes()accepted a malformed path and built a nonsense URL from it, wherephoto_info()anddownload()both raiseValueErroron the same input. It now raises too.
- The lens read groups now replay their captures instead of deriving them.
params/lens,variables/lensandstatus/lenswere computed from a "MF means focused" rule, and the rule was wrong in both directions. The camera contradicts itself here: in AF it reportsfocused: falseonstatus/lensandprops/lensbutfocused: trueonvariables/lens, in one physical state; in MF all three reporttrue. Sovariables/lensservedfalsein AF where the capture saystrue, andstatus/lensstayed on the AF capture however the lever was set — meaningget_lens_state().focusedwas wrong in MF, which is exactly the state MF users are in.set_focus_mode()now switches all three to the matching capture. The MF captures had shipped in the wheel sincerc1and were never read:params-lens-mf.json,variables-lens-mf.jsonandstatus-lens-mf.json.props/lenswas only ever captured in AF and so does not follow the lever; nothing was invented to make it. - An illegal
PUT /v1/params/cameravalue served rebuilt bytes. It was the one refusal the simulator encoded withcamera_json()rather than replaying, and it differed from the capture: the firmware formats error bodies unlike its data bodies —{"errCode": 400,"errMsg": "Bad Request"}, no break after the comma — so the two were two bytes apart on a pathPROVENANCE.mdexplicitly claims the captured 400 covers. Every refusal now serves the captured file, and a test compares the bytes. An audit of every other computed response against its capture found no further divergence. clear_faults(path)cancelleddrop_stream_after()as a side effect, even though the stream drop is not keyed on a path. Onlyclear_faults()does now.mypy pyks2is clean again (25 errors → 0). All of them came from splatting adict[str, bool]into uvicorn's precisely typed signature, plus a route list that mixesRoutewithWebSocketRoute. It is configured inpyproject.tomlbut not gated in CI, so this had gone unnoticed.
PROVENANCE.mdnow labels both generated bodies, not one. The/v1/liveview/zoomsuccess body was inferred — the gate was measured exhaustively, the 200 body's bytes were not kept — and that is now recorded as inferred rather than passed off as captured.- The card-full guard pointed at
status-device-cardfull.jsonas if it were available to an installed user. It is in the repo'sexamples/, is a state rather than a failure response, and its formatting was normalised when it was written there — so its values are real but its bytes are not wire bytes, which is also why it is not promoted to a fixture. The message andPROVENANCE.mdboth say so now.
Nine new test functions, 14 cases (144 → 158), all regression cover for the
above: the three lens groups replayed byte-for-byte at both lever positions, the
AF self-contradiction pinned explicitly, get_lens_state().focused in MF, the
captured 400 bytes, the fault-clearing scope, and the CLI driven end to end
against the simulator — the first CLI coverage in the project, which is how the
--download bug had survived.
One of them is a standing audit rather than a single case: it diffs every response the simulator computes against the capture for the same scenario, as bytes. Both fidelity bugs above were invisible to a parsed-JSON assertion.
Downstream re-verification. SPTFS soaked rc1; it has not seen any of the above.
Promotion to stable waits on that, and the 1.2.0 entry will fold this one in.
Feature-complete for the 1.2 line, and the simulator's public API is frozen pending final downstream validation. Everything here is measured: this was the last session with the physical camera, so the read surface was swept exhaustively and every remaining hardware-dependent response captured.
- The whole client surface now works against the simulator. Nine public
calls previously failed. Bulb (
/v1/camera/shoot/start+/finish) was not implemented at all;/v1/lens/focuswas registered GET-only while the client POSTs to it; and ten group reads (the bare/v1/constants,/v1/params,/v1/variables,/v1/statusroots plusconstants/lens,constants/liveview,params/liveview,variables/lens,variables/liveview,variables/device) existed inexamples/but had never been copied into package data. A new test iterates the client's entire camera-facing surface and asserts nothing fails unexpectedly, rather than trusting a hand-written list. - A public configuration API, replacing the documented advice to poke
sim._variables:set_exposure_mode(),set_focus_mode(),set_camera_controlled(),set_user_controlled(),writable(),seed_photos(),add_photo().set_exposure_mode()accepts only"M"and"B"— the two dial positions with a real captured capability set — because lists differ per mode and inventing them would make the writability signal fiction. - Symbolic endpoint constants (
pyks2.testing.paths) for the fault API, so downstream tests are keyed onpaths.SHOOTrather than"/v1/camera/shoot"and are not coupled to pyks2's URL spellings.paths.all()enumerates every fault-able endpoint,ENDPOINTSis theNAME -> pathmapping behind it, andcreate_app()refuses to start if the route table and the constants ever disagree — so a new route cannot appear without a name. Raw path strings keep working.paths.PHOTO_FILE/PHOTO_INFOare templated and match any photo, while a concrete/v1/photos/DIR/FILEtargets one. - Fault injection.
fail(path, error, times=)returns a real captured error body;drop(path),delay(path, seconds)anddrop_stream_after(frames)reproduce transport misbehaviour;clear_faults()resets.ERROR_BODIESadvertises only 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. Both faithfulness guards redirect rather than merely refusing — asking for card-full points at the near-fullremain: 1state, and an uncaptured exposure mode points atset_camera_controlled(). The message text is pinned by tests, so the guidance survives refactoring. - Bulb is modelled properly:
shoot/startandshoot/finishare gated on the dial being onB, a plainshootonBreturns 412, and a completed bulb exposure writes a file and fires onestorageevent.
- Live view serves one stream at a time. Measured over two trials: opening a second stream delivers one more frame to the first and then closes it, and the first never recovers — the newest requester wins. The simulator served both independently, which it no longer does. (An earlier note in this project claiming the camera "permits concurrency" was based on a flawed test that only read the second stream's headers.)
- In MF,
shoot af=autoreturns 412 — a hard refusal, not a silent no-op, and no file is written.POST /v1/lens/focusfails in MF too, and writingfocusModeover WiFi returns 400 with the value unchanged. All measured. ?limit=abcis ignored and returns the full listing; the simulator was answering errCode 400. An unrecognisedPUTkey is accepted and ignored; the simulator was adding it to the params and firing a spuriouscameraevent.- Replaced the deprecated
asyncio.get_event_loop()and kept a strong reference to the background capture task, which asyncio only holds weakly. The suite now runs under-W error::DeprecationWarning.
- Python 3.9 is dropped;
requires-pythonis now>=3.10. It was already untenable: currentstarletteanduvicornboth require 3.10, sopip install pyks2[testing]on 3.9 either failed to resolve or silently backslid to versions this code has never been tested against. The ruff target and the classifiers move with it. - CI runs on
developas well asmain, across 3.10–3.13 on Linux plus a Windows leg, and runs lint. Every simulator change before this had been verified on a single machine. .gitattributesnormalises source line endings while keeping the captured fixtures byte-exact, verified withgit check-attr.
Bulb start/finish bodies and the genuine Bulb-mode empty-list state; the MF lens
state and its refusals; POST /v1/lens/focus succeeding; the ten missing group
reads; and an exhaustive 32-endpoint read sweep built from pyks2.constants
itself, all of which returned 200. A 2 s bulb exposure reported
tv: "198.100" — 1.98 s — independently corroborating the tv encoding.
Identifying fields (ssid, key, macAddress, serialNo) are redacted to the
repo's existing placeholders, and PROVENANCE.md labels those fixtures redacted
rather than raw. It also records what was observed but deliberately not
modelled: camera event delivery was intermittent (2 of 5 attempts), so the
simulator emits reliably and callers are told not to depend on it.
The simulator, measured against the camera instead of written from the notes.
The same raw-socket probe was run against the physical body and against the
simulator and the results diffed: 40 of 40 checks now match, covering wire
behaviour and response times. Full record in
docs/VERIFICATION.md.
Still beta for the same reason as 1.2.0b1 — the simulator's public API is not
frozen yet, not because anything is unverified.
/v1/liveview/zoomgating was documented wrong. PROTOCOL.md §9 claimed an empty body returned200. Measuring it: with no stream running, all of no body, an empty body andzoom=1returned412; with a stream running, all three returned200. The gate is purely whether live view is streaming. §9 corrected and the simulator matches.latest_info()does not always report a file. After a power cycle, with 358 files on the card,/v1/photos/latest/inforeturnedcaptured: falsewith nodir/file— "latest" means latest this power session, not newest on the card. Thelatest_info()andwait_for_capture()docstrings said otherwise and are corrected; the simulator now starts in that state.- Captured fixtures were being corrupted by git. With
core.autocrlf=true, the norm on Windows, a fresh clone rewrote every LF as CRLF: the/v1/changespayload became 54 bytes instead of the 53 measured on the wire, andphotos-listing.jsonpicked up 29 stray CRs. CI runs on Linux so it would never have failed there. A.gitattributesnow pins the fixture bytes;unhandled-method.htmlneeded the opposite treatment, since the camera really does send CRLF in that body. - Not every fixture type was reaching the wheel. The package-data glob listed
extensions, so a new
.jpgand.htmlfixture were silently left out of the wheel while the sdist was fine — the failure only shows up from an installed wheel. Matched by wildcard now. - The live view stream busy-looped when the frame interval was zero, flooding the
socket and starving the event loop, and
stop()waited its full timeout on any open stream. Fixed with a floored interval, a disconnect check, and a forced exit after a short grace period. The suite went from 376 s to 21 s.
- Modelled response latency (
pyks2.testing.Timing), from measured medians: ~103 ms for/v1/props, ~1.5 s for a 358-file/v1/photos(it scales at ~110 ms + 3.9 ms per file returned, which is why?limitexists), ~1.9 s from shutter tostorageevent, ~830 ms for the first live view frame while the mirror flips up, ~7.6 fps thereafter. Realistic is the default, because a mock that answers instantly hides the timeout and ordering bugs a fake camera exists to catch; passtiming=FASTfor none. Theks2_simulatorfixture is fast, and a newks2_simulator_realisticis there when the timing itself is under test. - Generated responses are encoded in the firmware's own JSON house style
(
camera_json()), verified by round-tripping captured bodies byte-for-byte, so computed responses look like replayed ones on the wire. - Re-captured fixtures:
/v1/photosis now a full card, 358 files across 6 directories, so cross-directory flattening and ordering are genuinely exercised; and?size=viewserves the real 53 KB camera preview rather than a live view frame standing in for one.?size=fullis now the only fabricated payload, an 18 MB DNG being impractical to commit.
PUT /v1/params/cameraechoes avariables-shaped body (the capability lists,stateandexposureModeOption), not just the params aGETreturns.?limit=Nkeeps every directory in the response, giving those past the limit an emptyfileslist, andlimit=0means no limit.- A missing photo is
errCode 404and an unknown patherrCode 400, both under HTTP 200; an unhandled method is the one break from Law 1, a real HTTP 400 with an HTML body. /v1/changespayloads end with a newline — the storage frame is 53 bytes.- Listing order is ascending shot number, which is not the same as sorted
filenames: a RAW+JPEG pair shares a number and the
.JPGcomes first. - Every response carries
Server: server,Cache-Control,Pragma,Expires,Max-AgeandAccept-Ranges, and noDate.
Worth knowing rather than changing: with no latest photo yet, capture() passes
since=None, which makes wait_for_capture() re-read the baseline after the
shutter has fired. That is safe against the real camera only because the file
takes ~2 s to appear. An early simulator build created the file instantly and
capture() hung, adopting the new file as its own baseline. The simulator now
defers the file exactly as the camera does, with a floor that survives
timing=FAST.
A prerelease so the new simulator can soak before its API is frozen. Beta because that public surface may still change, not because anything is unverified — the simulator replays hardware-captured wire data throughout.
-
A shipped camera simulator (
pyks2[testing]):pyks2.testingserves a protocol-level fake K-S2 over a real socket, so the actual pyks2 client — sync and async — can be driven end to end with no camera on the bench. This is deliberately public, supported surface, not internal test scaffolding: downstream libraries import it to run their own integration tests against a faithful camera instead of mocking pyks2 out.from pyks2.testing import SimulatorServer with SimulatorServer() as server: cam = server.client() # a real K_S2_WiFi info = cam.capture(af="off")
Also available as
python -m pyks2.testing.simulator --port 8080, and as aks2_simulatorpytest fixture on an ephemeral port, registered through apytest11entry point so downstream suites get it just by installing the extra. Covers/v1/props,/v1/params/camera(GET + PUT),/v1/photosand photo download,POST /v1/camera/shoot, the/v1/changesWebSocket and the/v1/liveviewMJPEG stream, plus the ping/apis/constants/variables/status reads.Every response body is replayed from bytes captured off a physical K-S2 (firmware 01.10) and shipped inside the package as
pyks2/testing/data/, so it works from a plainpip installrather than only in a git checkout. It reproduces the verified protocol behaviours rather than an idealised API:errCodein the body with HTTP 200 (Law 1),/v1/photosoldest-first with?limitas a head-limit only, the empty-list writability signal (a write to a camera-controlled value returns 200 and is silently ignored), exactly onestorageevent per capture and acameraevent per settings write, and the 412/200 gating on/v1/liveview/zoom. State is intentionally shallow: only a capture mutates anything, making a new file appear and firing the matching event so a shoot → new-file → download sequence works. The two payloads that are not captured bytes (?size=view,?size=full) are documented inpyks2/testing/data/PROVENANCE.md. -
Tests driving the real client against the simulator over loopback, for both transports —
events_async()anditer_liveview_frames_async()included — covering the requests/httpx/websockets transports and the MJPEG and event parsers that the existing fake-transport tests cannot reach.
K_S2_WiFinow accepts an address with a port ("127.0.0.1:8080").ipwas interpreted inconsistently: HTTP interpolated it straight into the URL, so it could carry a port, but the/v1/changesclients needed host and port separately — the async one builtws://host:8080:80/v1/changesand the sync one passed the whole string to a socket connect. There was therefore no working way to point the event stream anywhere but port 80..hostand.portare now parsed once and used for the WebSocket. Invisible against the camera, which is always192.168.0.1:80; found immediately by pointing the client at the simulator.
- New
testingextra (starlette,uvicorn,pytest, pluspyks2[async]).devnow includes it.import pyks2still works with no extras installed; only building or running the simulator raises, and the error names the extra. pyks2.testingadded to the distribution, with itsdata/declared as package-data so the fixtures land in the wheel as well as the sdist.- CI installs
[dev,async,testing].
Three additive features on top of 1.0.0, all now hardware-verified. All are
backward-compatible — no existing public API changed behaviour. This promotes
1.1.0b1 unchanged in behaviour: the only code differences are the version
bump and the removal of the async caveats, which the verification below
retired.
- Live view context manager:
with cam.liveview() as stream: for frame in stream: ...guarantees the underlying streaming Response (and therefore the camera's mirror-up state) closes on__exit__, even if the caller breaks out of the loop early or an exception propagates through it.liveview_stream()anditer_liveview_frames()are unchanged and still supported — the latter's cleanup still depends on the generator being exhausted or garbage-collected, which is exactly the gapliveview()closes. Hardware-verified (same transport as the existing liveview code). - Typed exposure-value accessors:
set_iso(),set_aperture(),set_shutter_speed(),set_exposure_comp(), andset_wb()accept native Python types (int/"auto"for ISO, afractions.Fractionof seconds for shutter speed, signed floats for EV comp) and consult the camera's list-emptiness writability signal (PROTOCOL.md §6.5) before writing. Writing a camera-controlled value now raisesKS2UnsupportedErrorinstead of silently no-opping. AddedCameraConstants.sv_writable/.xv_writable, mirroring the existingtv_writable/av_writable.set_camera_params(**kwargs)remains the raw, unvalidated escape hatch. Hardware-verified (writability semantics per PROTOCOL.md §6.5; value encoding validated against captured examples). - Async streaming (
pyks2[async]extra):cam.events_async()returns anAsyncChangesClientforasync for ev in cam.events_async(): ...over/v1/changes, andcam.iter_liveview_frames_async()gives an async live view frame iterator. Both share their parsing with the sync path — MJPEG framing via the newMjpegFrameParser(pyks2._mjpeg), event decoding viaevents._payload_to_event— so there is no duplicated protocol logic between sync and async. Requires the optionalhttpx/websocketsdependencies (pip install pyks2[async]); the base install stays dependency-light, andimport pyks2/import pyks2.async_clientboth succeed with neither installed — only calling the async APIs raises a clearImportErrorpointing at the extra. Hardware-verified — see below.
- The async transport is now hardware-verified, closing the one gap left
open by
1.1.0b1. Against a physical K-S2 (firmware01.10) on 2026-07-29:events_async()delivered the same/v1/changespayload for a capture as the already-verified synccapture_with_events()— byte-identical{"changed": "storage"}, same ordering — and also receivescameraevents on a settings write.iter_liveview_frames_async()yielded real 720×480 JPEGs (full Pillow decode, not just marker checks) and raises the camera's mirror on start / drops it on close, matching the sync path exactly. Full record, including what the camera returned, indocs/VERIFICATION.md. - The camera's
/v1/changesWebSocket handshake is RFC-6455 compliant (itsSec-WebSocket-Acceptmatches), so strict clients likewebsocketsconnect without the leniency the sync client allows for.
liveStatenever reports live view. It reads"idle"in all three groups (/v1/props,/v1/status,/v1/status/liveview) even while frames are streaming. Callers must not use it to detect an active stream; the reliable signal is the documented/v1/liveview/zoomgate (412when inactive,200while streaming — PROTOCOL.md §9). Affects sync and async identically; this is camera firmware behaviour, not a pyks2 bug.- A single capture emits exactly one
/v1/changesmessage (storage), with no trailingcameraframe — confirmed over a 20 s listen window. Multiple rapid settings writes coalesce into onecameraevent, consistent with PROTOCOL.md §7 describing these as coarse "re-fetch that group" pokes.
- Development status classifier moved from
4 - Betato5 - Production/Stable. MANIFEST.innow shipsexamples/*.bin, for the raw MJPEG fixture below.
examples/changes-capture-sequence.jsonl— the complete/v1/changesmessage sequence for one capture.examples/liveview-frame-raw.bin— one raw/v1/liveviewmultipart part with boundary and part headers intact, for replaying real framing.
First stable release: an extensive, hardware-verified reverse-engineering of the Pentax K-S2's built-in WiFi HTTP API, with a Python library, a CLI, and a protocol write-up. The camera's 38 API endpoint templates are characterised as confirmed working, confirmed read-only, or confirmed unsupported, with the remaining gaps noted in the docs.
- Complete map of the
/v1/*API: five read groups (constants/params/variables/status/props) × four subsystems (camera/lens/liveview/device), plus capture, focus, photo, live view, and the/v1/changesWebSocket. - Two protocol laws documented:
errCodelives in the body (not the HTTP status), and datetime/numeric formats vary by endpoint. - Full mode-dial characterisation (P/Sv/Tv/Av/TAv/M/Bulb/U1/U2/auto/scene/ HDR/movie), with a per-mode value-writability matrix driven by list emptiness.
- Hardware interlocks mapped and explained: AF/MF lever, mode dial, movie mode disabling WiFi, the SD-door disconnect, device/lens read-only params, and the WiFi AP's client isolation.
- 40 real captured responses in
examples/, plus a machine-readableexamples/API_REFERENCE.json.
/v1/photosdoes not hang. The long-standing "it hangs indefinitely" belief was a client-side artifact; the endpoint is reliable and scales gently with file count. It also supports an undocumented?limit=N.shoot/start/shoot/finishare Bulb exposure controls, not movie, and they work over WiFi when the dial is on B. (bulb_start(),bulb_finish(),bulb_exposure(seconds).)storages[].remainis a frame count, not bytes.- Lens identity is not exposed over WiFi.
/v1/liveview/zoomis a no-op over WiFi — accepts any param, returns 200, but never changes the frame.
K_S2_WiFia camera-only HTTP client with typed models, defensive parsing for the datetime/numeric quirks, anderrCode-aware exceptions.- Race-free capture via
capture()(baseline → shoot → wait for the new file). - Event-driven workflow via the
/v1/changesWebSocket (events()), replacing the official app's polling. - Correct handling of dynamic capability lists (e.g.
avList) and the list-emptiness writability signal forav/tv.
pyks2 ping | info | apis | lists | shoot | settings | focus | browse | download | liveview | bulb | watch.
- A web GUI is planned for a later release; this version ships the library, CLI, and documentation.