Files
fujicli/src/camera/mod.rs
2025-10-19 19:50:45 +01:00

357 lines
14 KiB
Rust

pub mod devices;
pub mod error;
pub mod ptp;
use std::time::Duration;
use anyhow::{anyhow, bail};
use devices::SupportedCamera;
use log::{debug, error};
use ptp::{
Ptp,
hex::{
CommandCode, DevicePropCode, FujiClarity, FujiColor, FujiColorChromeEffect,
FujiColorChromeFXBlue, FujiColorSpace, FujiCustomSetting, FujiCustomSettingName,
FujiDynamicRange, FujiDynamicRangePriority, FujiFilmSimulation, FujiGrainEffect,
FujiHighISONR, FujiHighlightTone, FujiImageQuality, FujiImageSize,
FujiLensModulationOptimizer, FujiMonochromaticColorTemperature, FujiMonochromaticColorTint,
FujiShadowTone, FujiSharpness, FujiSmoothSkinEffect, FujiWhiteBalance,
FujiWhiteBalanceShift, FujiWhiteBalanceTemperature, ObjectFormat, UsbMode,
},
structs::{DeviceInfo, ObjectInfo},
};
use ptp_cursor::{PtpDeserialize, PtpSerialize};
use rusb::{GlobalContext, constants::LIBUSB_CLASS_IMAGE};
use crate::usb::find_endpoint;
const SESSION: u32 = 1;
pub struct Camera {
pub r#impl: Box<dyn CameraImpl<GlobalContext>>,
pub ptp: Ptp,
}
macro_rules! camera_with_ptp {
($($fn_name:ident => $ret:ty),* $(,)?) => {
$(
#[allow(dead_code)]
pub fn $fn_name(&mut self) -> anyhow::Result<$ret> {
self.r#impl.$fn_name(&mut self.ptp)
}
)*
};
($($fn_name:ident($($arg:ident : $arg_ty:ty),*) => $ret:ty),* $(,)?) => {
$(
#[allow(dead_code)]
pub fn $fn_name(&mut self, $($arg: $arg_ty),*) -> anyhow::Result<$ret> {
self.r#impl.$fn_name(&mut self.ptp, $($arg),*)
}
)*
};
}
impl Camera {
pub fn name(&self) -> &'static str {
self.r#impl.supported_camera().name
}
pub fn vendor_id(&self) -> u16 {
self.r#impl.supported_camera().vendor
}
pub fn product_id(&self) -> u16 {
self.r#impl.supported_camera().product
}
pub fn connected_usb_id(&self) -> String {
format!("{}.{}", self.ptp.bus, self.ptp.address)
}
camera_with_ptp! {
get_info => DeviceInfo,
get_usb_mode => UsbMode,
get_battery_info => u32,
}
camera_with_ptp! {
export_backup => Vec<u8>,
get_active_custom_setting => FujiCustomSetting,
get_custom_setting_name => FujiCustomSettingName,
get_image_size => FujiImageSize,
get_image_quality => FujiImageQuality,
get_dynamic_range => FujiDynamicRange,
get_dynamic_range_priority => FujiDynamicRangePriority,
get_film_simulation => FujiFilmSimulation,
get_monochromatic_color_temperature => FujiMonochromaticColorTemperature,
get_monochromatic_color_tint => FujiMonochromaticColorTint,
get_grain_effect => FujiGrainEffect,
get_white_balance => FujiWhiteBalance,
get_high_iso_nr => FujiHighISONR,
get_highlight_tone => FujiHighlightTone,
get_shadow_tone => FujiShadowTone,
get_color => FujiColor,
get_sharpness => FujiSharpness,
get_clarity => FujiClarity,
get_white_balance_shift_red => FujiWhiteBalanceShift,
get_white_balance_shift_blue => FujiWhiteBalanceShift,
get_white_balance_temperature => FujiWhiteBalanceTemperature,
get_color_chrome_effect => FujiColorChromeEffect,
get_color_chrome_fx_blue => FujiColorChromeFXBlue,
get_smooth_skin_effect => FujiSmoothSkinEffect,
get_lens_modulation_optimizer => FujiLensModulationOptimizer,
get_color_space => FujiColorSpace,
}
camera_with_ptp! {
import_backup(buffer: &[u8]) => (),
set_active_custom_setting(value: &FujiCustomSetting) => (),
set_custom_setting_name(value: &FujiCustomSettingName) => (),
set_image_size(value: &FujiImageSize) => (),
set_image_quality(value: &FujiImageQuality) => (),
set_dynamic_range(value: &FujiDynamicRange) => (),
set_dynamic_range_priority(value: &FujiDynamicRangePriority) => (),
set_film_simulation(value: &FujiFilmSimulation) => (),
set_monochromatic_color_temperature(value: &FujiMonochromaticColorTemperature) => (),
set_monochromatic_color_tint(value: &FujiMonochromaticColorTint) => (),
set_grain_effect(value: &FujiGrainEffect) => (),
set_white_balance(value: &FujiWhiteBalance) => (),
set_high_iso_nr(value: &FujiHighISONR) => (),
set_highlight_tone(value: &FujiHighlightTone) => (),
set_shadow_tone(value: &FujiShadowTone) => (),
set_color(value: &FujiColor) => (),
set_sharpness(value: &FujiSharpness) => (),
set_clarity(value: &FujiClarity) => (),
set_white_balance_shift_red(value: &FujiWhiteBalanceShift) => (),
set_white_balance_shift_blue(value: &FujiWhiteBalanceShift) => (),
set_white_balance_temperature(value: &FujiWhiteBalanceTemperature) => (),
set_color_chrome_effect(value: &FujiColorChromeEffect) => (),
set_color_chrome_fx_blue(value: &FujiColorChromeFXBlue) => (),
set_smooth_skin_effect(value: &FujiSmoothSkinEffect) => (),
set_lens_modulation_optimizer(value: &FujiLensModulationOptimizer) => (),
set_color_space(value: &FujiColorSpace) => (),
}
}
impl Drop for Camera {
fn drop(&mut self) {
debug!("Closing session");
if let Err(e) = self.ptp.close_session(SESSION, self.r#impl.timeout()) {
error!("Error closing session: {e}");
}
debug!("Session closed");
}
}
impl TryFrom<&rusb::Device<GlobalContext>> for Camera {
type Error = anyhow::Error;
fn try_from(device: &rusb::Device<GlobalContext>) -> anyhow::Result<Self> {
let descriptor = device.device_descriptor()?;
let vendor = descriptor.vendor_id();
let product = descriptor.product_id();
for supported_camera in devices::SUPPORTED {
if vendor != supported_camera.vendor || product != supported_camera.product {
continue;
}
let r#impl = (supported_camera.impl_factory)();
let bus = device.bus_number();
let address = device.address();
let config_descriptor = device.active_config_descriptor()?;
let interface_descriptor = config_descriptor
.interfaces()
.flat_map(|i| i.descriptors())
.find(|x| x.class_code() == LIBUSB_CLASS_IMAGE)
.ok_or(rusb::Error::NotFound)?;
let interface = interface_descriptor.interface_number();
debug!("Found interface {interface}");
let handle = device.open()?;
handle.claim_interface(interface)?;
let bulk_in = find_endpoint(
&interface_descriptor,
rusb::Direction::In,
rusb::TransferType::Bulk,
)?;
let bulk_out = find_endpoint(
&interface_descriptor,
rusb::Direction::Out,
rusb::TransferType::Bulk,
)?;
let transaction_id = 0;
let chunk_size = r#impl.chunk_size();
let mut ptp = Ptp {
bus,
address,
interface,
bulk_in,
bulk_out,
handle,
transaction_id,
chunk_size,
};
debug!("Opening session");
let () = ptp.open_session(SESSION, r#impl.timeout())?;
debug!("Session opened");
return Ok(Self { r#impl, ptp });
}
bail!("Device not supported");
}
}
macro_rules! camera_impl_custom_settings {
($( $name:ident : $type:ty => $code:expr ),+ $(,)?) => {
$(
paste::paste! {
#[allow(dead_code)]
fn [<get_ $name>](&self, ptp: &mut Ptp) -> anyhow::Result<$type> {
let bytes = ptp.get_prop_value($code, self.timeout())?;
let result = <$type>::try_from_ptp(&bytes)?;
Ok(result)
}
#[allow(dead_code)]
fn [<set_ $name>](&self, ptp: &mut Ptp, value: &$type) -> anyhow::Result<()> {
let bytes = value.try_into_ptp()?;
ptp.set_prop_value($code, &bytes, self.timeout())?;
Ok(())
}
}
)+
};
}
pub trait CameraImpl<P: rusb::UsbContext> {
fn supported_camera(&self) -> &'static SupportedCamera<P>;
fn timeout(&self) -> Duration {
Duration::default()
}
fn chunk_size(&self) -> usize {
// Conservative estimate. Could go up to 15.75 * 1024^2 on the X-T5 but only gained 200ms.
1024 * 1024
}
fn get_info(&mut self, ptp: &mut Ptp) -> anyhow::Result<DeviceInfo> {
let info = ptp.get_info(self.timeout())?;
Ok(info)
}
fn get_usb_mode(&mut self, ptp: &mut Ptp) -> anyhow::Result<UsbMode> {
let data = ptp.get_prop_value(DevicePropCode::FujiUsbMode, self.timeout())?;
let result = UsbMode::try_from_ptp(&data)?;
Ok(result)
}
fn get_battery_info(&mut self, ptp: &mut Ptp) -> anyhow::Result<u32> {
let data = ptp.get_prop_value(DevicePropCode::FujiBatteryInfo2, self.timeout())?;
debug!("Raw battery data: {data:?}");
let raw_string = String::try_from_ptp(&data)?;
debug!("Decoded raw string: {raw_string}");
let percentage: u32 = raw_string
.split(',')
.next()
.ok_or_else(|| anyhow!("Failed to parse battery percentage"))?
.parse()?;
Ok(percentage)
}
fn export_backup(&self, ptp: &mut Ptp) -> anyhow::Result<Vec<u8>> {
const HANDLE: u32 = 0x0;
debug!("Sending GetObjectInfo command for backup");
let response = ptp.send(CommandCode::GetObjectInfo, &[HANDLE], None, self.timeout())?;
debug!("Received response with {} bytes", response.len());
debug!("Sending GetObject command for backup");
let response = ptp.send(CommandCode::GetObject, &[HANDLE], None, self.timeout())?;
debug!("Received response with {} bytes", response.len());
Ok(response)
}
fn import_backup(&self, ptp: &mut Ptp, buffer: &[u8]) -> anyhow::Result<()> {
debug!("Preparing ObjectInfo header for backup");
let mut header = Vec::with_capacity(1076);
let object_info = ObjectInfo {
object_format: ObjectFormat::FujiBackup,
compressed_size: u32::try_from(buffer.len())?,
..Default::default()
};
object_info.try_write_ptp(&mut header)?;
// TODO: What is this?
for _ in 0..1020 {
0x0u8.try_write_ptp(&mut header)?;
}
debug!("Sending SendObjectInfo command for backup");
let response = ptp.send(
CommandCode::SendObjectInfo,
&[0x0, 0x0],
Some(&header),
self.timeout(),
)?;
debug!("Received response with {} bytes", response.len());
debug!("Sending SendObject command for backup");
let response = ptp.send(
CommandCode::SendObject,
&[0x0],
Some(buffer),
self.timeout(),
)?;
debug!("Received response with {} bytes", response.len());
Ok(())
}
camera_impl_custom_settings! {
active_custom_setting: FujiCustomSetting => DevicePropCode::FujiCustomSetting,
custom_setting_name: FujiCustomSettingName => DevicePropCode::FujiCustomSettingName,
image_size: FujiImageSize => DevicePropCode::FujiCustomSettingImageSize,
image_quality: FujiImageQuality => DevicePropCode::FujiCustomSettingImageQuality,
dynamic_range: FujiDynamicRange => DevicePropCode::FujiCustomSettingDynamicRange,
dynamic_range_priority: FujiDynamicRangePriority => DevicePropCode::FujiCustomSettingDynamicRangePriority,
film_simulation: FujiFilmSimulation => DevicePropCode::FujiCustomSettingFilmSimulation,
monochromatic_color_temperature: FujiMonochromaticColorTemperature => DevicePropCode::FujiCustomSettingMonochromaticColorTemperature,
monochromatic_color_tint: FujiMonochromaticColorTint => DevicePropCode::FujiCustomSettingMonochromaticColorTint,
grain_effect: FujiGrainEffect => DevicePropCode::FujiCustomSettingGrainEffect,
white_balance: FujiWhiteBalance => DevicePropCode::FujiCustomSettingWhiteBalance,
high_iso_nr: FujiHighISONR => DevicePropCode::FujiCustomSettingHighISONR,
highlight_tone: FujiHighlightTone => DevicePropCode::FujiCustomSettingHighlightTone,
shadow_tone: FujiShadowTone => DevicePropCode::FujiCustomSettingShadowTone,
color: FujiColor => DevicePropCode::FujiCustomSettingColor,
sharpness: FujiSharpness => DevicePropCode::FujiCustomSettingSharpness,
clarity: FujiClarity => DevicePropCode::FujiCustomSettingClarity,
white_balance_shift_red: FujiWhiteBalanceShift => DevicePropCode::FujiCustomSettingWhiteBalanceShiftRed,
white_balance_shift_blue: FujiWhiteBalanceShift => DevicePropCode::FujiCustomSettingWhiteBalanceShiftBlue,
white_balance_temperature: FujiWhiteBalanceTemperature => DevicePropCode::FujiCustomSettingWhiteBalanceTemperature,
color_chrome_effect: FujiColorChromeEffect => DevicePropCode::FujiCustomSettingColorChromeEffect,
color_chrome_fx_blue: FujiColorChromeFXBlue => DevicePropCode::FujiCustomSettingColorChromeFXBlue,
smooth_skin_effect: FujiSmoothSkinEffect => DevicePropCode::FujiCustomSettingSmoothSkinEffect,
lens_modulation_optimizer: FujiLensModulationOptimizer => DevicePropCode::FujiCustomSettingLensModulationOptimizer,
color_space: FujiColorSpace => DevicePropCode::FujiCustomSettingColorSpace,
}
}