diff --git a/src/application/usecases/camera/get_camera_info.py b/src/application/usecases/camera/get_camera_info.py index ba7c21d..db19155 100644 --- a/src/application/usecases/camera/get_camera_info.py +++ b/src/application/usecases/camera/get_camera_info.py @@ -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: @@ -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() diff --git a/src/domain/camera/ptp_device.py b/src/domain/camera/ptp_device.py index ccbecae..50c01a0 100644 --- a/src/domain/camera/ptp_device.py +++ b/src/domain/camera/ptp_device.py @@ -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: """ diff --git a/src/domain/camera/queries.py b/src/domain/camera/queries.py index c663bf9..08d351b 100644 --- a/src/domain/camera/queries.py +++ b/src/domain/camera/queries.py @@ -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: @@ -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. diff --git a/src/interfaces/management/commands/camera_info.py b/src/interfaces/management/commands/camera_info.py index cbb28e0..a8c99f0 100644 --- a/src/interfaces/management/commands/camera_info.py +++ b/src/interfaces/management/commands/camera_info.py @@ -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 @@ -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 diff --git a/tests/fakes.py b/tests/fakes.py index e4b27c4..253bc2f 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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 # ------------------------------------------------------------------ diff --git a/tests/integration/application/camera/test_get_camera_status.py b/tests/integration/application/camera/test_get_camera_status.py new file mode 100644 index 0000000..0849dba --- /dev/null +++ b/tests/integration/application/camera/test_get_camera_status.py @@ -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 diff --git a/tests/unit/domain/camera/test_camera_info_tolerance.py b/tests/unit/domain/camera/test_camera_info_tolerance.py new file mode 100644 index 0000000..ffde799 --- /dev/null +++ b/tests/unit/domain/camera/test_camera_info_tolerance.py @@ -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) diff --git a/tests/unit/domain/camera/test_supports_custom_slots.py b/tests/unit/domain/camera/test_supports_custom_slots.py new file mode 100644 index 0000000..a135b94 --- /dev/null +++ b/tests/unit/domain/camera/test_supports_custom_slots.py @@ -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