Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/application/usecases/camera/get_camera_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class CameraStatusResult:
info: camera_queries.CameraInfo
custom_slot_count: int
slots: tuple[camera_queries.SlotState, ...] | None # None when not requested
# True/False from GetDeviceInfo; None when the camera reports no property list.
slots_supported: bool | None


def get_camera_status(*, read_slots: bool) -> CameraStatusResult:
Expand All @@ -26,9 +28,18 @@ def get_camera_status(*, read_slots: bool) -> CameraStatusResult:
try:
info = camera_queries.camera_info(device)
slot_count = camera_queries.custom_slot_count(info.camera_name)
slots_supported = camera_queries.supports_custom_slots(device)
slots = None
if read_slots and slot_count > 0:
# Reading slots on a body that does not expose the cursor yields the
# per-property fallbacks ("", 0) for every slot, which reads as real
# data. Skip the scan instead of reporting fiction.
if read_slots and slot_count > 0 and slots_supported is not False:
slots = tuple(camera_queries.slot_states(device, slot_count))
return CameraStatusResult(info=info, custom_slot_count=slot_count, slots=slots)
return CameraStatusResult(
info=info,
custom_slot_count=slot_count,
slots=slots,
slots_supported=slots_supported,
)
finally:
device.disconnect()
9 changes: 9 additions & 0 deletions src/domain/camera/ptp_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ def set_property_string(self, code: int, value: str) -> int:
"""
...

def supported_properties(self) -> list[int]:
"""
Device property codes reported by GetDeviceInfo.

Returns an empty list when the camera does not answer, so callers must
treat an empty result as "unknown", not "nothing supported".
"""
...

@property
def camera_name(self) -> str:
"""
Expand Down
24 changes: 22 additions & 2 deletions src/domain/camera/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,14 @@ def camera_info(device: ptp_device.PTPDevice) -> CameraInfo:

This is safe to call at any time after connect().
"""
battery_raw = _get_int(device, constants.PROP_BATTERY)
usb_mode = _get_int(device, 0xD16E) # PTP_DPC_FUJI_USBMode
try:
battery_raw = _get_int(device, constants.PROP_BATTERY)
except ptp_device.CameraConnectionError:
battery_raw = 0 # not supported on all models (e.g. X-E5)
try:
usb_mode = _get_int(device, 0xD16E) # PTP_DPC_FUJI_USBMode
except ptp_device.CameraConnectionError:
usb_mode = 0 # not supported on all models (e.g. X-E5)
try:
firmware_version = _get_int(device, 0xD153) # PTP_DPC_FUJI_FirmwareVersion
except ptp_device.CameraConnectionError:
Expand Down Expand Up @@ -166,6 +172,20 @@ def film_sim_name(self) -> str:
return constants.PTP_TO_FILM_SIMULATION.get(self.film_sim_ptp, f"Unknown({self.film_sim_ptp})")


def supports_custom_slots(device: ptp_device.PTPDevice) -> bool | None:
"""
Whether this body exposes the slot cursor, without which C1–Cn is unreachable.

Returns None when the camera does not answer GetDeviceInfo — the older
bodies this code already tolerates report no property list at all, so an
empty answer means "unknown", not "unsupported".
"""
supported = device.supported_properties()
if not supported:
return None
return constants.PROP_SLOT_CURSOR in supported


def slot_states(device: ptp_device.PTPDevice, slot_count: int) -> list[SlotState]:
"""
Read the current content (name + film sim) of each custom slot.
Expand Down
10 changes: 10 additions & 0 deletions src/interfaces/management/commands/camera_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from django.core.management.base import BaseCommand, CommandParser

from src.application.usecases.camera import get_camera_info as get_camera_info_uc
from src.data.camera import constants
from src.domain.camera import ptp_device


Expand Down Expand Up @@ -61,6 +62,15 @@ def handle(self, *args: object, **options: Any) -> None:
self.stdout.write("Disconnected.")

if options["slots"]:
if result.slots_supported is False:
self.stderr.write(
self.style.ERROR(
"This body does not advertise the custom-slot cursor "
f"(0x{constants.PROP_SLOT_CURSOR:04X}), so C1–Cn cannot be read "
"or written over USB."
)
)
return
if result.slots is None:
self.stdout.write(" (This camera model does not support custom slots.)")
return
Expand Down
3 changes: 3 additions & 0 deletions tests/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ def set_property_uint16(self, code: int, value: int) -> int:
def set_property_string(self, code: int, value: str) -> int:
return self._set(code, str_value=value)

def supported_properties(self) -> list[int]:
return sorted(set(self._int_store) | set(self._str_store))

# ------------------------------------------------------------------
# Camera identity
# ------------------------------------------------------------------
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/application/camera/test_get_camera_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Integration tests for the get_camera_status use case.

A body that does not advertise the slot cursor still answers every
per-property read with a fallback ("" for names, 0 for film simulations), so
scanning it produces a full set of plausible-looking empty slots. Those must
not be reported as the camera's contents.

Uses FakePTPDevice via settings.PTP_DEVICE (see conftest autouse fixture).
"""

from src.application.usecases.camera.get_camera_info import get_camera_status
from src.data.camera import constants
from tests.fakes import FakePTPDevice


class TestSlotsSupportedReporting:
def test_true_when_the_cursor_is_advertised(self, settings):
settings.PTP_DEVICE = lambda: FakePTPDevice(
int_values={constants.PROP_SLOT_CURSOR: 1}
)
assert get_camera_status(read_slots=False).slots_supported is True

def test_false_when_the_cursor_is_absent_from_a_populated_list(self, settings):
settings.PTP_DEVICE = lambda: FakePTPDevice(int_values={0xD041: 0, 0xD303: 0})
assert get_camera_status(read_slots=False).slots_supported is False

def test_none_when_the_camera_reports_no_property_list(self, settings):
settings.PTP_DEVICE = lambda: FakePTPDevice()
assert get_camera_status(read_slots=False).slots_supported is None


class TestSlotScanIsSkippedWhenUnsupported:
def test_no_slots_returned_when_the_cursor_is_absent(self, settings):
# X-S10 → 4 slots by model table, but the body cannot reach them.
settings.PTP_DEVICE = lambda: FakePTPDevice(int_values={0xD041: 0, 0xD303: 0})

result = get_camera_status(read_slots=True)

assert result.custom_slot_count == 4
assert result.slots is None

def test_slots_are_read_when_the_cursor_is_advertised(self, settings):
settings.PTP_DEVICE = lambda: FakePTPDevice(
int_values={constants.PROP_SLOT_CURSOR: 1},
string_values={constants.PROP_SLOT_NAME: "My Slot"},
)

result = get_camera_status(read_slots=True)

assert result.slots is not None
assert len(result.slots) == 4

def test_slots_are_read_when_support_is_unknown(self, settings):
# Older bodies answer GetDeviceInfo with nothing. Unknown must not
# regress into skipping the scan the previous behaviour performed.
settings.PTP_DEVICE = lambda: FakePTPDevice()

result = get_camera_status(read_slots=True)

assert result.slots is not None
assert len(result.slots) == 4
73 changes: 73 additions & 0 deletions tests/unit/domain/camera/test_camera_info_tolerance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Not every body serves every status property. Reading identity must survive a
camera that declines one of them rather than failing the whole call.
"""

from src.data.camera import constants
from src.domain.camera.ptp_device import CameraConnectionError
from src.domain.camera.queries import camera_info
from tests.fakes import FakePTPDevice

PROP_USB_MODE = 0xD16E
PROP_FIRMWARE_VERSION = 0xD153


class TestCameraInfoWithMissingProperties:
def test_reads_all_properties_when_the_camera_serves_them(self):
device = FakePTPDevice(
camera_name="X-T5",
int_values={
constants.PROP_BATTERY: 3,
PROP_USB_MODE: 1,
PROP_FIRMWARE_VERSION: 42,
},
)

info = camera_info(device)

assert info.camera_name == "X-T5"
assert info.battery_raw == 3
assert info.usb_mode == 1
assert info.firmware_version == 42

def test_battery_falls_back_to_zero_when_not_served(self):
# The X-E5 does not answer PROP_BATTERY.
device = FakePTPDevice(
camera_name="X-E5",
int_values={PROP_USB_MODE: 1, PROP_FIRMWARE_VERSION: 42},
get_errors={constants.PROP_BATTERY: CameraConnectionError("unsupported")},
)

info = camera_info(device)

assert info.battery_raw == 0
assert info.usb_mode == 1
assert info.firmware_version == 42

def test_usb_mode_falls_back_to_zero_when_not_served(self):
# The X-E5 does not answer USBMode either.
device = FakePTPDevice(
camera_name="X-E5",
int_values={constants.PROP_BATTERY: 3, PROP_FIRMWARE_VERSION: 42},
get_errors={PROP_USB_MODE: CameraConnectionError("unsupported")},
)

info = camera_info(device)

assert info.usb_mode == 0
assert info.battery_raw == 3

def test_identity_still_reads_when_no_status_property_is_served(self):
device = FakePTPDevice(
camera_name="X-E5",
get_errors={
constants.PROP_BATTERY: CameraConnectionError("unsupported"),
PROP_USB_MODE: CameraConnectionError("unsupported"),
PROP_FIRMWARE_VERSION: CameraConnectionError("unsupported"),
},
)

info = camera_info(device)

assert info.camera_name == "X-E5"
assert (info.battery_raw, info.usb_mode, info.firmware_version) == (0, 0, 0)
26 changes: 26 additions & 0 deletions tests/unit/domain/camera/test_supports_custom_slots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Bodies that do not expose the slot cursor must be reported as unsupported
rather than scanned — a scan returns the per-property fallbacks ("", 0) for
every slot, which is indistinguishable from a camera with seven empty slots.
"""

from src.data.camera import constants
from src.domain.camera.queries import supports_custom_slots
from tests.fakes import FakePTPDevice


class TestSupportsCustomSlots:
def test_true_when_slot_cursor_is_advertised(self):
device = FakePTPDevice(int_values={constants.PROP_SLOT_CURSOR: 1})
assert supports_custom_slots(device) is True

def test_false_when_slot_cursor_is_absent(self):
# An X-E5 advertises a handful of vendor properties, none of them the cursor.
device = FakePTPDevice(int_values={0xD041: 0, 0xD303: 0, 0xD406: 0, 0xD407: 0})
assert supports_custom_slots(device) is False

def test_none_when_camera_reports_no_property_list(self):
# Older bodies answer GetDeviceInfo with nothing; that is "unknown",
# not "unsupported", so the existing tolerant read path still runs.
device = FakePTPDevice()
assert supports_custom_slots(device) is None
Loading