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
46 changes: 46 additions & 0 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,30 @@ def collect_gphoto2_plugins() -> None:

collect_gphoto2_plugins()


def collect_fuji_ble() -> None:
"""Ship the optional Fujifilm Bluetooth trigger (fuji-ble-negpy-trigger + bleak).

Both are imported lazily by ``negpy.infrastructure.capture.fuji_ble``, so PyInstaller
would miss them — and bleak's CoreBluetooth backend (pyobjc) must be bundled for
Bluetooth to work in the packaged app. Mirrors the gphoto2 optional-extra pattern:
``uv sync --group fuji-ble`` before building, or the packaged app simply shows its
setup hint.
"""
if is_windows:
return # the Bluetooth trigger is macOS/Linux only
try:
import fujitrigger # noqa: F401
except ImportError:
print("fuji-ble-negpy-trigger not installed — packaging without the Bluetooth trigger")
return
params.append("--collect-all=fujitrigger")
params.append("--collect-all=bleak")
print("Bundling Fujifilm Bluetooth trigger (fujitrigger + bleak)")


collect_fuji_ble()

# Add platform-specific icon
if is_windows:
icon_path = os.path.abspath("media/icons/icon.ico")
Expand Down Expand Up @@ -310,10 +334,32 @@ def package_windows():
raise


def _add_bluetooth_usage_description(app_path: str) -> None:
"""bleak/CoreBluetooth requires ``NSBluetoothAlwaysUsageDescription`` in a bundled app's
Info.plist, or Bluetooth discovery is denied outright. Inject it (only when the trigger
is bundled)."""
try:
import fujitrigger # noqa: F401
except ImportError:
return
import plistlib

info_path = os.path.join(app_path, "Contents", "Info.plist")
with open(info_path, "rb") as f:
info = plistlib.load(f)
usage = "NegPy uses Bluetooth to trigger a Fujifilm camera for automated film scanning."
info["NSBluetoothAlwaysUsageDescription"] = usage
info["NSBluetoothPeripheralUsageDescription"] = usage # older macOS key, harmless to include
with open(info_path, "wb") as f:
plistlib.dump(info, f)
print("Added Bluetooth usage description to Info.plist")


def package_macos():
"""Package the built application into a DMG with Applications symlink."""
print(f"Packaging for macOS (DMG) version {VERSION}...")
app_path = os.path.join("dist", f"{APP_NAME}.app")
_add_bluetooth_usage_description(app_path)
dmg_name = f"{APP_NAME}-{VERSION}-macOS-{get_macos_target_arch()}.dmg"
dmg_path = os.path.join("dist", dmg_name)
temp_dmg_dir = os.path.join("dist", "dmg_temp")
Expand Down
44 changes: 44 additions & 0 deletions docs/CAMERA_SCANNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,50 @@ film-dye crosstalk, which the density-domain **Crosstalk** matrix handles (see

---

## Fujifilm Bluetooth trigger

Fujifilm bodies (the GFX 100 II in particular) do not behave over a gphoto2 tether: they
stick in a tethered-capture state and refuse to reconnect. For these, the **Trigger**
selector in the CAMERA panel offers a second route — **Fujifilm Bluetooth** — that splits
the two jobs a tether normally does in one:

- the **shutter fires over Bluetooth LE** (the body's own remote-control protocol, ported
from the [furble](https://github.com/gkoh/furble) firmware), and
- the **RAW arrives over WiFi** — the camera's "PC Auto Save" pushes each shot to a folder
on the Mac, which NegPy watches and picks up after each trigger.

No gphoto2 session is opened at all, so there is no tethered state to get stuck in.

### Setup

```bash
pip install bleak # the optional Bluetooth dependency
```

1. In the CAMERA panel set **Trigger → Fujifilm Bluetooth**.
2. On the camera: **Connection Setting → Bluetooth/Smartphone Connection → On**, then enter
**Pairing** so it shows "Searching". Make sure the Fujifilm phone app is not connected.
3. Press **Pair…**. macOS prompts for Bluetooth permission once — grant it. NegPy discovers
the body, bonds with it and remembers it (the pairing is stored with the other capture
settings).
4. Set the camera's WiFi **PC Auto Save** to push to a folder, and point **Drop** at that
folder.
5. Set ISO / shutter / aperture **on the body** (the Bluetooth remote carries only the
shutter), pick a preset for the RGB levels, and scan.

### Limitations of the Bluetooth route

- **No live view and no calibration** — the remote carries only the shutter, so there is no
preview stream to aim at. Frame and focus on the body (or the Fujifilm app), then scan.
The scan window opens without a preview and the Scan button still works.
- **Exposure is manual.** A preset's baked ISO/shutter/aperture is not pushed over Bluetooth;
set it on the body once. The preset still supplies the per-channel RGB *levels*, which is
what the narrowband merge needs.
- **Files come over WiFi**, so each RAW takes a moment to land in the drop folder; the
capture waits for it (up to a minute) before moving on.

---

## Troubleshooting

| Symptom | Cause | Fix |
Expand Down
25 changes: 25 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ class AppController(QObject):
connection_polled = pyqtSignal(dict) # {usb_ok, usb_model, light_ok, light_detail}
poll_light_temp_requested = pyqtSignal(str) # light port (temp-only poll, runs even mid-live-view)
light_temp_polled = pyqtSignal(object) # Scanlight LED temperature °C, or None
camera_backend_requested = pyqtSignal(str, dict) # backend ("usb"|"fuji_ble") + fuji config
fuji_pair_requested = pyqtSignal(float) # discovery timeout (s)
fuji_pairing_finished = pyqtSignal(bool, str, dict) # ok, message, pairing dict (empty on failure)
fuji_detect_requested = pyqtSignal(float) # discovery timeout (s)
fuji_detected = pyqtSignal(list) # advertising Fujifilm bodies (advertisement dicts)

def __init__(self, session_manager: DesktopSessionManager):
super().__init__()
Expand Down Expand Up @@ -542,6 +547,11 @@ def _connect_signals(self) -> None:
self.capture_worker.poll_status.connect(self.connection_polled.emit)
self.poll_light_temp_requested.connect(self.capture_worker.poll_light_temp)
self.capture_worker.light_temp_polled.connect(self.light_temp_polled.emit)
self.camera_backend_requested.connect(self.capture_worker.set_camera_backend)
self.fuji_pair_requested.connect(self.capture_worker.pair_fuji)
self.capture_worker.fuji_pairing_finished.connect(self.fuji_pairing_finished.emit)
self.fuji_detect_requested.connect(self.capture_worker.detect_fuji)
self.capture_worker.fuji_detected.connect(self.fuji_detected.emit)

self.session.active_file_changing.connect(lambda: self._update_thumbnail_from_state(force_readback=True))
self.session.session_emptied.connect(self._render_memo.clear)
Expand Down Expand Up @@ -2278,6 +2288,21 @@ def poll_light_temp(self, port: str) -> None:
self._ensure_capture_thread()
self.poll_light_temp_requested.emit(port)

def set_camera_backend(self, backend: str, fuji_cfg: dict) -> None:
"""Arm the shutter path: "usb" (gphoto2) or "fuji_ble" (Bluetooth trigger + drop folder)."""
self._ensure_capture_thread()
self.camera_backend_requested.emit(backend, fuji_cfg)

def pair_fuji(self, timeout: float = 12.0) -> None:
"""Discover + pair a Fujifilm body; result arrives on ``fuji_pairing_finished``."""
self._ensure_capture_thread()
self.fuji_pair_requested.emit(timeout)

def detect_fuji(self, timeout: float = 6.0) -> None:
"""Scan for advertising Fujifilm bodies; result arrives on ``fuji_detected``."""
self._ensure_capture_thread()
self.fuji_detect_requested.emit(timeout)

def _on_capture_finished(self, paths: list) -> None:
"""Feed the captured frame(s) into NegPy. A 3-file RGB triplet → RGB-Scan negative
(C-41) pipeline; a single white-light slide → E-6/positive; a normal white-light
Expand Down
Loading
Loading