Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pychdk"
version = "0.1.1"
version = "0.1.2"
description = "Pure Python CHDK PTP camera control"
requires-python = ">=3.11"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion src/pychdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Pure Python CHDK PTP camera control."""
import importlib

__version__ = "0.1.1"
__version__ = "0.1.2"

__all__ = [
"ChdkDevice", "list_devices", "DeviceInfo", "install_signal_handlers",
Expand Down
34 changes: 34 additions & 0 deletions src/pychdk/chdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,35 @@ class ChdkPTP:

def __init__(self, session):
self._session = session
self._last_capture_chunks = 0

@property
def last_capture_chunks(self):
"""How many chunks the last remote capture arrived in.

Zeroed when a capture is attempted and incremented as each
chunk lands, so it is readable — and still true — after a
capture that failed part way through, and reads zero after one
that never got a chunk at all. A still that arrives in one
chunk and one that arrives in forty say different things about
the wire, and there is one bench session to find out which of
them a real camera does.

Read it from the thread that ran the capture, or after that
thread has finished: a reader watching from elsewhere while a
capture is in flight sees a partial count, since it rises as
the chunks arrive.
"""
return self._last_capture_chunks

def reset_capture_chunks(self):
"""Zero the chunk count at the start of a capture attempt.

Called by the caller that begins a capture, because a capture
can fail before any download is attempted and must not go on
reporting the previous capture's chunks.
"""
self._last_capture_chunks = 0

def get_version(self):
"""Get CHDK PTP protocol version.
Expand Down Expand Up @@ -404,6 +433,9 @@ def remote_capture_get_data(self, format_flag):
Args:
format_flag: Which format to download (JPEG=1, RAW=2, DNG_HDR=4).

The chunk count is left in last_capture_chunks rather than
returned, so the signature callers depend on is unchanged.

Returns:
Image data as bytes.

Expand All @@ -412,8 +444,10 @@ def remote_capture_get_data(self, format_flag):
"""
image = bytearray()
cursor = 0
self._last_capture_chunks = 0
for _ in range(MAX_CAPTURE_CHUNKS):
chunk, more, position = self.remote_capture_get_chunk(format_flag)
self._last_capture_chunks += 1
if position >= 0:
cursor = position
end = cursor + len(chunk)
Expand Down
80 changes: 68 additions & 12 deletions src/pychdk/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,54 @@ def __init__(self, device_info, _usb_device=None):
self._open()

def _open(self):
self._transport.open()
self._session.open()
self._connected = True
_open_devices.add(self)
# Re-register so our cleanup runs before any pyusb finalizers
# that were registered during device creation (atexit is LIFO).
atexit.register(_cleanup_all)
"""Claim the interface and open a session, or claim nothing.

Until the device is tracked there is nothing for the caller to
close: a constructor that raised here left the interface
claimed with no object to release it, so a host retrying
enumeration piled up claims on a port until the camera was
unplugged. Anything that fails past the claim gives it back —
including a failure inside the transport's own open, which can
hold a claim and still raise.
"""
try:
self._transport.open()
self._session.open()
self._connected = True
_open_devices.add(self)
# Re-register so our cleanup runs before any pyusb finalizers
# that were registered during device creation (atexit is LIFO).
atexit.register(_cleanup_all)
except BaseException:
self._connected = False
_open_devices.discard(self)
try:
self._transport.close()
except Exception:
pass
raise

@property
def is_connected(self):
return self._connected

@property
def last_capture_chunks(self):
"""How many chunks the last streamed capture arrived in.

Read after shoot(stream=True) rather than returned by it: the
return value is the picture, and MultiCam.shoot promises a list
of those, one per camera. The count lives per device, so after
a MultiCam shot each camera's own figure is on its entry in
MultiCam.cameras.

Zero means no chunk arrived, not that no capture was tried.
Read it from the thread that took the shot, or once that thread
has finished: MultiCam shoots on a pool, and a reader looking
at another worker's device mid-capture sees a partial count.
"""
return self._chdk.last_capture_chunks

def switch_mode(self, mode):
"""Switch camera to 'record' or 'play' mode.

Expand Down Expand Up @@ -239,6 +275,11 @@ def shoot(self, shutter_speed=None, market_iso=None, dng=False,
def _shoot_streaming(self, setup_parts, dng):
"""Capture using remote capture (PTP commands 13/14).

The chunk count is zeroed here, at the attempt, rather than
where the download begins: a capture refused, or one that never
becomes ready, would otherwise keep reporting the chunks of the
capture before it.

Setup and shutter go out as one script, because a second script
kills the first unless NOKILL is set ("if script is running
return error instead of killing", core/ptp.h) — so a separate
Expand Down Expand Up @@ -266,6 +307,8 @@ def _shoot_streaming(self, setup_parts, dng):
file. This method downloads one format, so it cannot,
and _shoot_standard does not request a DNG either.
"""
self._chdk.reset_capture_chunks()

if dng:
raise NotImplementedError(
"DNG capture is not implemented. Streaming would need the "
Expand Down Expand Up @@ -431,13 +474,26 @@ def reconnect(self, wait=2.0):
except Exception:
pass
time.sleep(wait)
self._transport.open()
self._session.open()
self._connected = True
_open_devices.add(self)
# Same claim, same rollback: a reopen that fails mid-session
# leaks exactly as a failed construction did.
self._open()

def close(self):
"""Close the connection to the camera."""
"""Close the connection to the camera.

Safe to call more than once in sequence.

Concurrently it is safe in one half and not the other, and the
halves are worth keeping apart. Releasing the USB interface is
serialised by pyusb itself, so two closers cannot double-release
it — PTPDevice.close carries the citation. Closing the PTP
session is not serialised: this sends a close over the wire, and
two threads can both find the session open and both send one,
because nothing here guards that. So a host that shares one
device across threads has to serialise its own teardown.
Nothing in this library shares one: MultiCam gives each worker
its own device.
"""
self._connected = False
_open_devices.discard(self)
try:
Expand Down
21 changes: 18 additions & 3 deletions src/pychdk/multicam.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,28 @@ class MultiCam:
"""Manages multiple CHDK cameras for coordinated capture."""

def __init__(self):
"""Open every camera found, or leave none of them open.

A camera that fails to open partway down the list leaves the
ones before it open and claimed, and the half-built MultiCam is
discarded, so nothing is left holding them: not the caller, who
never got an object, and not the cleanup registry, which tracks
devices weakly. They stay claimed until the process ends.

PTPDevice.open's guarantee does not reach this, because these
cameras opened successfully. They are orphans rather than
partial opens, so the rollback has to be here.
"""
devices = list_devices()
if not devices:
raise RuntimeError("No CHDK cameras found")
self.cameras = []
for info in devices:
cam = ChdkDevice(info)
self.cameras.append(cam)
try:
for info in devices:
self.cameras.append(ChdkDevice(info))
except BaseException:
self.close()
raise

def shoot(self, **kwargs):
"""Capture from all cameras concurrently.
Expand Down
10 changes: 10 additions & 0 deletions src/pychdk/ptp.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ def _receive_data(self, tx_id):
return data[:total_length - CONTAINER_HEADER_SIZE]

def __enter__(self):
# No rollback here, deliberately. The property: a session open
# that raises holds no operating-system resource, whatever
# raised it. Nothing is claimed, so there is nothing to give
# back, which is what makes this unlike PTPDevice.open.
#
# Not covered: the camera's own state. If OPEN_SESSION reached
# the camera and we failed before recording the session, the
# camera may hold one we will never close, since close()
# returns early on _is_open — and nothing on this side can tell
# that from a session that was never opened at all.
self.open()
return self

Expand Down
Loading
Loading