Skip to content

pychdk 0.1.3 — make the library tell the truth - #4

Merged
juancobo merged 9 commits into
mainfrom
release/v0.1.3
Sep 16, 2026
Merged

juancobo merged 9 commits into
mainfrom
release/v0.1.3

Conversation

@juancobo

@juancobo juancobo commented Sep 16, 2026

Copy link
Copy Markdown
Member

Supersedes #3, which folds into this release rather than merging alone — its replacement sentence still overclaimed.

What this is

A release about sentences. An external review of the README and docstrings against CHDK's own sources returned thirteen findings, and almost none were bugs in what the code does — they were bugs in what it says it does. Three needed real code changes; the rest needed the truth written down.

pychdk has no branch protection and no automated review, so three releases went out with a false statement about the library's own behaviour intact. It surfaced only because a review of a different repository asked why the capture path had no fallback.

Behaviour fixed

  • switch_mode confirmed the opposite of what it set. Its comment described uBASIC's get_mode (0 = record); the call is Lua, where get_mode() returns is_record, is_video, mode and the first is !mode_play — true in record. It now reads the answer as CHDK sends it, and an unconfirmed switch raises instead of returning exactly as a success did, which is why this survived three releases symptomlessly.
  • get_frames requested no pixel data. get_display_data() defaulted to flags=0, and live_view_get_data adds each block only if its LV_TFR_* bit was asked for — so the generator could yield frames with no image in them. It now defaults to LV_TFR_VIEWPORT; the constants are named.
  • shoot(market_iso=...) sent a menu number as a real sensitivity. iso_to_sv96 and set_sv96 both work in real units, and shooting_sv96_market_to_real subtracts a per-camera offset (69 sv96 units by default), so the camera was being set about 0.7 of a stop more sensitive than asked. The conversion now happens on the cameraset_sv96(sv96_market_to_real(iso_to_sv96(N))), every step CHDK's own — so the per-camera offset never touches our code. The argument keeps its name and Captua's call site needs no change. A draft of this used set_iso_mode, which also takes a menu number; review caught that it writes the menu property without setting the script override, so on a card with CHDK's own ISO override enabled the operator's ISO would have been silently beaten (shooting_expo_param_override_thumb, core/shooting.c:1922-1928).

Claims narrowed to what they establish

close() no longer claims nothing in the library shares a device (the signal handler reaches one a worker is using — though atexit does not, since executor threads are joined first). drain_messages is a bounded attempt at fifty, not a drain. REMOTE_CAP_NOTSET means "not initialised now", since CHDK reports a cancelled capture identically. MultiCam.shoot says what each path returns. The README no longer promises a fallback that does not exist, and no longer says the library cannot report the CHDK build (get_buildinfo() does, and our own example calls it).

Data-safety fix

tools/flash_chdk.py asked "This will ERASE the disk. Continue? [y/N]" only when there was exactly one candidate. With two or more it took an index and returned the chosen disk before the prompt was reached — so the path where picking wrong is most likely was the one path that erased without asking. Selection and confirmation are now separate steps, the prompt names the specific disk, and a negative index is rejected rather than counted from the end.

This is the only change here that can destroy someone's data.

Breaking changes

download_after / remove_after are gone — neither was implemented — and util.iso_to_sv96's parameter is renamed to real_iso. Pre-1.0 with Captua as the only consumer, so the version stays 0.1.3 rather than pretending compatibility. RELEASE-NOTES-v0.1.3.md leads with the breaks.

On the review this went through

205 → 228 tests. Adversarial review after the first pass was green found six more: five sentences still claiming more than the code or the cited source established, and one real fault introduced by the polarity fix — execute_lua_wait returns None when a script ends with no RET message, and bool(None) is False, so a camera that said nothing at all was confirmed as being in play mode. Inverting a condition changes what happens to every value that is neither true nor false.

That is the same failure mode this release exists to correct, reproduced inside the correction, with the suite green throughout.

None of this is verified against hardware. Every correction is reasoned from CHDK's sources — which is exactly how the original errors got in. A green gate means "consistent with what we now believe", not "correct".

Three of these are behaviour, the rest are sentences that said more than
the code did.

switch_mode polled on uBASIC's convention while speaking Lua. CHDK's Lua
get_mode() returns is_record, is_video, mode, and is_record is
!camera_info.state.mode_play, so it is true in record
(luaCB_get_mode, modules/luascript.c); uBASIC's returns 0 for record, 1
for play, 2 for video record (lib/ubasic/ubasic.c). execute_lua_wait
hands back the first RET value, so the loop was negating is_record and
confirming the opposite of what it set. It now reads the answer the way
CHDK sends it, and an unconfirmed switch raises instead of returning
exactly as a success did - which is why this survived three releases
with no symptom.

get_frames asked for no framebuffer. live_view_get_data adds each block
only if its LV_TFR_ bit was requested (core/live_view.c), so flags=0
returns the header and the framebuffer descriptions and no pixels. It
now asks for the viewport, and the LV_TFR_ constants are named.

market_iso was never market ISO. iso_to_sv96 is the APEX96 conversion
for real sensitivity - CHDK's own shooting_get_sv96_from_iso notes it is
"equivalent to (short)(log2(iso/3.125)*96+0.5) [APEX equation]" - and
set_sv96 wants real. The two quantities are kept apart in CHDK by
separate properties and a per-camera offset, so rename the parameter
rather than invent a conversion we cannot verify.

download_after and remove_after were never implemented: the download
listed A/DCIM, threw the listing away and returned None, the delete did
nothing. Remove them, and say what MultiCam.shoot actually returns.

Narrow four claims to what they establish: close() does reach a shared
device, through the signal handler and the atexit hook, though no lock
is added here because close() runs at interpreter shutdown; drain_
messages is a bounded attempt at fifty; REMOTE_CAP_NOTSET means not
initialized now, since CHDK reports a cancelled capture the same way
("following a timeout, RemoteCaptureIsReady and RemoteCaptureGetData
will behave as if remote capture were not initialized",
set_remotecap_timeout); get_version is the PTP protocol version, not a
firmware build; and nothing falls back after a streaming failure, with
recovery left as an open question rather than a documented workaround.

None of this is checked against a camera. It is read from CHDK's
sources, which is how the original errors got in.
pick_disk asked "This will ERASE the disk. Continue? [y/N]" only when
there was exactly one candidate. With two or more it printed the list,
took an index and returned the chosen disk before the prompt was ever
reached - so the path where picking the wrong disk is most likely was
the one path that erased without asking. Selection is not consent, and
the two are now separate steps.

The prompt names the device node, media name and size rather than "the
disk", because find_removable_disks accepts any physical removable
medium: on a Mac an external USB drive qualifies, and the operator
reading the prompt is the only check on that. A negative index is
rejected rather than counted from the end of the list.

Correct the safety notes while here: the filter is RemovableMedia plus
VirtualOrPhysical, not an Internal check, and an internal SD slot is
exactly what we want to flash from.
A release about sentences. Removing download_after/remove_after and
renaming market_iso are API breaks; pre-1.0 with Captua as the only
consumer, the version stays 0.1.3 rather than pretending otherwise, and
the notes lead with the breaks.

Captua's chdk_backend passes market_iso= and has to be updated with the
pointer bump.
Review of this release found six more, five of them the same fault the
release exists to correct.

The real one: execute_lua_wait returns None when a script ends with no
RET message, and bool(None) is False - so the polarity fix confirmed
play mode on a camera that had said nothing at all. Silence is not an
answer of false. It is retried now, and a poll that answers after a
silence is still read.

The five sentences:

- "no path that fetches a card image" (shoot, _shoot_standard,
  MultiCam.shoot). download_file fetches a card file by path. What is
  missing is discovering the path of the image a shot just wrote.
- "the library has no call that reports either" (README, on the CHDK
  build). lua_execute("return get_buildinfo()") reports it, and
  examples/test_camera.py already calls it. There is no dedicated
  method; that is a narrower claim and the true one.
- "USB remote capture is still enabled" after a streaming failure
  (README). The exception establishes no such thing: it can come from
  the script submission before init_usb_capture ran, or from a capture
  that initialized and was cancelled on CHDK's own timeout - which this
  release's own remote_capture_is_ready note explains.
- "either can close a device while a MultiCam worker is inside shoot()"
  (close). The signal handler can; atexit cannot. concurrent.futures
  registers its shutdown through threading._register_atexit, joined in
  threading._shutdown, which runs before atexit handlers. The signal
  hazard and the lock objection both stand without the atexit claim.
- and in the backend's companion fix, a test docstring claiming it
  pinned the fake's polarity as well as the code's. It pins the code's;
  stuck_in_play bypasses the fake's own switch. A second test pins the
  other half.
…s name

The value was running through iso_to_sv96 into set_sv96, both of which
work in real sensitivity, while the argument is the number in the
camera's own menu. shooting_sv96_market_to_real subtracts a per-camera
offset - 69 sv96 units by default, overridable per platform - so real
sensitivity sits below the menu number, and sending the menu number as
real made the camera about 0.7 of a stop more sensitive than asked,
silently.

The first draft of this release renamed the argument to real_iso and
kept the conversion, on the grounds that the code had never treated the
value as a menu number. That was backwards. Captua's UI offers
100/200/400/800/1600 - the camera's own ladder, picked from a dropdown -
and CameraConfig.iso is read back from the camera's own iso PTP widget
on the DSLR path. It is a menu number and the library should take one.

So market_iso keeps its name and goes to set_iso_mode, which for a value
of 50 or more selects the nearest entry in the camera's iso_table
(shooting_set_iso_mode, core/shooting.c). The camera's table does the
work, the per-camera offset never touches this code, and Captua's call
site needs no change - the API break is gone with it.

set_iso_mode snaps and reports nothing back, so a requested ISO and the
one used can differ. Every value our UI sends is already on the ladder,
which makes it a no-op for us; B11a should log requested against
reported anyway, in case that is untrue on the A2500.

The shutter path was checked for the same fault and does not have it:
tv96 has no market variant, and set_tv96_direct applies the value as
given.
Review of the previous commit found the flaw in it. set_iso_mode takes
a menu number and lets the camera's iso_table resolve it, which is why
it looked right - but it writes the menu property and leaves the script
override unset. At capture CHDK applies a script's deferred
photo_param_put_off.sv96 first and only falls back to the camera's own
configured ISO override when none was set
(shooting_expo_param_override_thumb, core/shooting.c). set_sv96 outside
a shot populates that deferred value; set_iso_mode does not. So on a
card with CHDK's ISO override enabled, the operator's ISO would have
been silently beaten by whatever the card was configured with.

Emit the whole conversion as CHDK's own Lua instead:

    set_sv96(sv96_market_to_real(iso_to_sv96(N)))

That keeps script-override priority, applies the value exactly rather
than snapping to the nearest ladder entry, and still computes nothing
here - SV96_MARKET_OFFSET stays the camera's own, per platform.

Two tests now separate the two ways this can regress: one pins the
emitted chain, the other pins that it is a script override and not a
menu write.

Also narrow the filename claim, which was still too broad: shoot() does
not discover or return the saved filename. download_file fetches by
path and CHDK's Lua can be asked where images go; this library just
does not ask.
@juancobo

Copy link
Copy Markdown
Member Author

Note on review: GitHub Copilot did not review this PR. It stopped responding after roughly fourteen reviews in a day on 2026-09-14 (a quota, not an outage) and is expected to stay out of action, so the merge is not waiting on it. External review here is Codex/Astra only — including a pass from a fresh thread, with no shared context, specifically to check the factual claims against CHDK's source rather than against each other.

Two of these described an implementation this release had already
replaced. The ISO argument was rewritten three times and util.py and the
backend's camera.py were still describing the second version, which used
set_iso_mode. util.py now says what it actually is - ISO-to-sv96
scaling, where feeding it a menu number yields a market sv96 and the
mistake is treating that as real - and points at shoot(), which converts
on the camera and does not call it.

The rest turned source agreement into claims about hardware:

- "this path applies the value asked for" and "it is also exact" claim
  achieved exposure. The source establishes conversion, rounding and
  which write happens; it says nothing about what the sensor delivers,
  and nobody has measured it.
- 69 sv96 units is CHDK's default, not a constant: the IXUS700 platform
  overrides SV96_MARKET_OFFSET to 20, so the size of the old ISO error
  varies by body.
- "CHDK cancels ... on a transfer error" is broader than the source
  supports. The reset follows the download timeout and certain
  chunk-selection errors; the PTP handler does not check what send_data
  returned, so a host-side transfer failure does not establish it.
- the A2500 port is not "alpha-level" in the build we install.
  camera_list.csv in release-1_6 has a BETA_STATUS column, 34 of its 397
  entries carry ALPHA or BETA, and a2500,100a,,, does not. The 0x2002
  failure that sentence reported is likewise something we have never
  observed, so it is named as a thing to test.
- "survived three releases with no visible symptom precisely because"
  is an unverified causal history. What the code shows is that an
  exhausted loop returned without raising.

And the test asserting the ISO is not set as a menu write passed when
the ISO was not set at all. It now requires both halves.

Release notes: 225 -> 229 tests and six -> eight flasher cases, both
wrong; plus the release order, including that v0.1.3 must be tagged on
the merged tree and not on the version-bump commit, whose shoot() still
took real_iso and would raise TypeError on the only consumer.
…pt named

The multi-disk confirmation test checked that the prompt said /dev/disk6
and then threw away what pick_disk returned. Change the return value and
leave the prompt alone and the test stays green - which is an erase of a
disk the operator never saw, in the one part of this library that can
destroy data. Found by mutation, not by reading.

It now asserts the returned disk, and a new test follows the chosen disk
through main() to format_card, since nothing downstream re-checks that
the confirmed disk and the erased disk are the same one.

Release notes: the order needs NEH-251 merged first. The final
market_iso signature is compatible with the backend's call, so this is
not a packaging dependency - but the backend before NEH-251 reads
get_mode() with uBASIC's polarity and rejects a camera that reached
record mode, so bumping the pin without it ships a corrected library to
a caller that still refuses every body. The pin bump also has to carry
the backend's test fake, which models 0.1.2's silent switch_mode.
Three copies of the same overclaim, two of which I wrote while fixing
the first. "Made the camera 0.7 of a stop more sensitive" is a claim
about achieved exposure; the source establishes the conversion and the
override that was requested, and nobody has put a meter on one of these
bodies. It requested an override about 0.72 stop above the corrected one
on a platform using CHDK's default 69-unit offset - which is also the
qualifier the earlier fix added to the docstrings and these two missed.

And the reset claim: remotecap_reset follows the download timeout and
certain chunk-selection errors. A host-side transfer failure does not
establish it, because the PTP handler does not check what send_data
returned.
@juancobo
juancobo merged commit 49a6f2e into main Sep 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant