diff --git a/build.py b/build.py index 672097b1..fc1b0356 100644 --- a/build.py +++ b/build.py @@ -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") @@ -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") diff --git a/docs/CAMERA_SCANNING.md b/docs/CAMERA_SCANNING.md index 95a9c0c1..7beb74de 100644 --- a/docs/CAMERA_SCANNING.md +++ b/docs/CAMERA_SCANNING.md @@ -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 | diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 5630f461..098943e7 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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__() @@ -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) @@ -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 diff --git a/negpy/desktop/view/sidebar/scanlight.py b/negpy/desktop/view/sidebar/scanlight.py index 6ff1f5c9..d5bb2729 100644 --- a/negpy/desktop/view/sidebar/scanlight.py +++ b/negpy/desktop/view/sidebar/scanlight.py @@ -191,8 +191,115 @@ def _gphoto_available(self) -> bool: return importlib.util.find_spec("gphoto2") is not None def _refresh_setup_hint(self) -> None: - """Show the setup note only while python-gphoto2 is missing.""" - self._setup_hint.setVisible(not self._gphoto_available()) + """Show the setup note only while python-gphoto2 is missing (and the USB backend is + armed — the Bluetooth trigger does not use gphoto2 at all).""" + self._setup_hint.setVisible(not self._gphoto_available() and self._settings.camera_backend != "fuji_ble") + + # ── camera backend (USB gphoto2 vs Fujifilm Bluetooth) ──────────── + + def _fuji_cfg(self) -> dict: + """The fuji_ble config the worker needs: the stored pairing plus the drop folder.""" + s = self._settings + return { + "address": s.fuji_address, + "name": s.fuji_name, + "flavor": s.fuji_flavor, + "token": s.fuji_token, + "serial": s.fuji_serial, + "drop_folder": s.fuji_drop_folder, + } + + def _push_camera_backend(self) -> None: + self.controller.set_camera_backend(self._settings.camera_backend, self._fuji_cfg()) + + def _maybe_detect_fuji(self) -> None: + """While the Bluetooth backend is armed but not yet linked, look for an advertising + body — a camera the operator already bonded elsewhere still advertises, so this can + offer to link it rather than show a blank 'not paired'.""" + if self._settings.camera_backend == "fuji_ble" and not self._settings.fuji_paired and not self._scanning: + self.fuji_status.setText("Looking for the camera…") + self.controller.detect_fuji(6.0) + + def _refresh_backend_ui(self) -> None: + idx = self.backend_combo.findData(self._settings.camera_backend) + if idx >= 0: + self.backend_combo.blockSignals(True) + self.backend_combo.setCurrentIndex(idx) + self.backend_combo.blockSignals(False) + fuji = self._settings.camera_backend == "fuji_ble" + self._fuji_widget.setVisible(fuji) + self._refresh_setup_hint() # the gphoto2 note hides in Bluetooth mode + if fuji: + self.fuji_status.setText( + f"Paired: {self._settings.fuji_name or self._settings.fuji_address}" + if self._settings.fuji_paired + else "Not paired — put the camera in Bluetooth pairing mode and press Pair." + ) + + @pyqtSlot(int) + def _on_backend_changed(self, _index: int) -> None: + backend = self.backend_combo.currentData() or "usb" + self._settings = replace(self._settings, camera_backend=backend) + self._save_settings() + self._refresh_backend_ui() + self._push_camera_backend() + self._poll_connection_tick() # refresh the camera dot for the new backend + self._maybe_detect_fuji() + + @pyqtSlot() + def _on_pair_fuji(self) -> None: + self.fuji_pair_btn.setEnabled(False) + self.fuji_status.setText("Pairing — keep the camera in pairing mode…") + self.controller.pair_fuji(12.0) + + @pyqtSlot(bool, str, dict) + def _on_fuji_pairing_finished(self, ok: bool, message: str, pairing: dict) -> None: + self.fuji_pair_btn.setEnabled(True) + if ok and pairing: + self._settings = replace( + self._settings, + camera_backend="fuji_ble", + fuji_address=pairing.get("address", ""), + fuji_name=pairing.get("name", ""), + fuji_flavor=pairing.get("flavor", ""), + fuji_token=pairing.get("token", ""), + fuji_serial=pairing.get("serial", ""), + ) + self._save_settings() + self._refresh_backend_ui() + self._push_camera_backend() + self.fuji_status.setText(f"Paired: {message}") + self._set_status(f"Paired {message}.") + self._poll_connection_tick() + else: + self.fuji_status.setText(message or "Pairing failed.") + + @pyqtSlot(list) + def _on_fuji_detected(self, devices: list) -> None: + if self._settings.camera_backend != "fuji_ble" or self._settings.fuji_paired: + return # not in Bluetooth mode, or already linked (the poll dot reflects it) + if devices: + best = max(devices, key=lambda d: d.get("rssi", -999)) + name = best.get("name") or best.get("address") + self.fuji_status.setText(f"Found: {name} — press Pair to link NegPy to it.") + else: + self.fuji_status.setText("No Fujifilm camera found — Bluetooth on and the camera awake?") + + @pyqtSlot() + def _on_fuji_drop_browse(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Choose the camera's WiFi auto-save folder", self.fuji_drop_edit.text() or "") + if folder: + self.fuji_drop_edit.setText(folder) + self._on_fuji_drop_changed() + + @pyqtSlot() + def _on_fuji_drop_changed(self) -> None: + folder = self.fuji_drop_edit.text().strip() + if folder == self._settings.fuji_drop_folder: + return + self._settings = replace(self._settings, fuji_drop_folder=folder) + self._save_settings() + self._push_camera_backend() # ── UI construction ─────────────────────────────────────────────── @@ -257,6 +364,18 @@ def _init_ui(self) -> None: self._conn_hint.setWordWrap(True) self._conn_hint.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") layout.addWidget(self._conn_hint) + # Which path fires the shutter: USB tethered capture (gphoto2) or the Fujifilm Bluetooth + # remote (the RAW still arrives via the camera's WiFi auto-save folder). + backend_row = QHBoxLayout() + _backend_tag = QLabel("Trigger") + _backend_tag.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") + self.backend_combo = QComboBox() + self.backend_combo.setToolTip("How the shutter is fired — USB (gphoto2) or the Fujifilm Bluetooth remote") + self.backend_combo.addItem("USB (gphoto2)", "usb") + self.backend_combo.addItem("Fujifilm Bluetooth", "fuji_ble") + backend_row.addWidget(_backend_tag) + backend_row.addWidget(self.backend_combo, 1) + layout.addLayout(backend_row) status_row = QHBoxLayout() self.cam_status = QLabel() self.light_status = QLabel() @@ -268,6 +387,35 @@ def _init_ui(self) -> None: status_row.addWidget(self.light_temp) status_row.addStretch() layout.addLayout(status_row) + # Fujifilm Bluetooth controls — only shown while that backend is armed (_refresh_backend_ui). + self._fuji_widget = QWidget() + _fuji = QVBoxLayout(self._fuji_widget) + _fuji.setContentsMargins(0, 0, 0, 0) + _fuji.setSpacing(6) + _pair_row = QHBoxLayout() + self.fuji_pair_btn = QPushButton("Pair…") + self.fuji_pair_btn.setToolTip("Discover and pair the Fujifilm body over Bluetooth (put it in pairing mode first)") + _pair_row.addWidget(self.fuji_pair_btn) + _pair_row.addStretch() + _fuji.addLayout(_pair_row) + self.fuji_status = QLabel("") + self.fuji_status.setWordWrap(True) + self.fuji_status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") + _fuji.addWidget(self.fuji_status) + _drop_row = QHBoxLayout() + _drop_tag = QLabel("Drop") + _drop_tag.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") + self.fuji_drop_edit = QLineEdit(self._settings.fuji_drop_folder) + self.fuji_drop_edit.setPlaceholderText("WiFi auto-save folder…") + self.fuji_drop_edit.setToolTip("Folder the camera's WiFi auto-save writes RAWs to — NegPy watches it after each trigger") + self.fuji_drop_browse = QPushButton("…") + self.fuji_drop_browse.setFixedWidth(32) + _drop_row.addWidget(_drop_tag) + _drop_row.addWidget(self.fuji_drop_edit, 1) + _drop_row.addWidget(self.fuji_drop_browse) + _fuji.addLayout(_drop_row) + self._fuji_widget.setVisible(False) + layout.addWidget(self._fuji_widget) self._set_conn_status(self.cam_status, None, "Camera") self._set_conn_status(self.light_status, None, "Light") # RGB scanning needs the Scanlight; when it's absent (normal white-light mode) this hint @@ -395,6 +543,10 @@ def _init_ui(self) -> None: def _connect_signals(self) -> None: self.off_btn.clicked.connect(self._on_light_off) self.folder_browse.clicked.connect(self._on_browse_folder) + self.backend_combo.activated.connect(self._on_backend_changed) + self.fuji_pair_btn.clicked.connect(self._on_pair_fuji) + self.fuji_drop_browse.clicked.connect(self._on_fuji_drop_browse) + self.fuji_drop_edit.editingFinished.connect(self._on_fuji_drop_changed) self.lv_btn.toggled.connect(self._on_live_view_toggled) self.preset_combo.activated.connect(self._on_preset_selected) self.preset_new_btn.clicked.connect(self._on_preset_new) @@ -421,6 +573,8 @@ def _connect_signals(self) -> None: self.controller.capture_calibration_exposure.connect(self._on_calibration_exposure) self.controller.connection_polled.connect(self._on_poll_status) self.controller.light_temp_polled.connect(self._on_light_temp) + self.controller.fuji_pairing_finished.connect(self._on_fuji_pairing_finished) + self.controller.fuji_detected.connect(self._on_fuji_detected) self.controller.batch_started.connect(self._keep_scan_windows_on_top) # Pop-up toolbar mirrors the panel actions (scan a roll without tab-switching). self.lv_window.scanRequested.connect(self._on_scan) @@ -440,6 +594,9 @@ def _connect_signals(self) -> None: def on_activated(self) -> None: """Called when the Scan tab is switched to — kick an immediate connection poll.""" + self._refresh_backend_ui() # sync the Trigger selector + arm the worker's backend + self._push_camera_backend() + self._maybe_detect_fuji() # an already-bonded body still advertises — offer to link it self._refresh_setup_hint() # re-check whether python-gphoto2 is installed self._apply_gating() # refresh the "what's still missing to scan" hint self._poll_connection_tick() @@ -1445,8 +1602,11 @@ def _on_poll_status(self, status: dict) -> None: self._camera_has_config = bool(status.get("camera_config", True)) # An RGB triplet has to write shutter/ISO/aperture onto the body; a camera whose driver # entry has no CONFIG cannot be told any of it, so only plain white-light scanning is - # honest there — even with a Scanlight attached (issue #621). - self._set_rgb_mode(status["light_ok"] and self._camera_has_config) + # honest there — even with a Scanlight attached (issue #621). The Bluetooth trigger + # (fuji_ble) is the exception: it carries no remote config either, but RGB still works — + # the Scanlight supplies the per-channel exposure and the body's shutter is set by hand. + _fuji = self._settings.camera_backend == "fuji_ble" + self._set_rgb_mode(status["light_ok"] and (self._camera_has_config or _fuji)) self._refresh_light_channels() # show/hide the W slider + white preset for the connected model claimed = bool(status.get("usb_claimed_elsewhere")) # A body another app holds the claim on is present but not usable: gate Scan/Calibrate @@ -1493,6 +1653,15 @@ def _set_cam_status(self, ok: bool, model: str, claimed_elsewhere: bool = False) self._conn_hint.setStyleSheet(f"color: {_WARN_COLOR}; font-size: {THEME.font_size_small}px;") self._conn_hint.setVisible(True) return + if self._settings.camera_backend == "fuji_ble": + # Bluetooth trigger: presence is "paired", and there is no USB claim to fight. + self._conn_hint.setText("Trigger over Bluetooth — pair the body and point its WiFi auto-save at the drop folder.") + self._conn_hint.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") + short = "Camera (BLE)" if ok else "Camera" + detail = f"Camera: {model} (Bluetooth)" if ok and model else "Bluetooth trigger not paired" + self._set_conn_status(self.cam_status, ok, short, detail) + self._conn_hint.setVisible(not ok) + return self._conn_hint.setText("Connect the camera by USB, in PC Remote mode — it's detected automatically.") self._conn_hint.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") short = "Camera (USB)" if ok else "Camera" diff --git a/negpy/desktop/workers/capture_worker.py b/negpy/desktop/workers/capture_worker.py index e8e592a2..06682efd 100644 --- a/negpy/desktop/workers/capture_worker.py +++ b/negpy/desktop/workers/capture_worker.py @@ -12,12 +12,14 @@ from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot -from negpy.infrastructure.capture.base import CaptureSettings +from negpy.infrastructure.capture.base import Camera, CaptureSettings +from negpy.infrastructure.capture import fuji_ble from negpy.infrastructure.capture.gphoto import ( CameraCapabilities, CameraClaimedError, CameraUnavailable, GphotoCamera, + GphotoError, LiveViewUnsupported, list_cameras, ) @@ -100,6 +102,10 @@ class CaptureWorker(QObject): calibration_exposure = pyqtSignal(str) # "over"/"under" — target unreachable, run aborted, no preset poll_status = pyqtSignal(dict) # {usb_ok, usb_model, light_ok, light_detail} light_temp_polled = pyqtSignal(object) # Scanlight LED temperature °C, or None (light-only, safe mid-scan) + #: A Fujifilm BLE pair attempt finished: (ok, operator-facing message, pairing dict — empty on failure). + fuji_pairing_finished = pyqtSignal(bool, str, dict) + #: Advertising Fujifilm bodies seen by a detect_fuji scan (list of advertisement dicts). + fuji_detected = pyqtSignal(list) def __init__(self) -> None: super().__init__() @@ -108,9 +114,18 @@ def __init__(self) -> None: self._cancel = threading.Event() # One libgphoto2 session serves live view, settings and stills alike — the body # allows a single PTP claim, and GphotoCamera serialises the three internally. - # Held open across frames; closed on error and at shutdown. - self._camera: Optional[GphotoCamera] = None + # Held open across frames; closed on error and at shutdown. For the fuji_ble + # backend this holds a fuji_ble camera instead (trigger over BLE, no PTP session). + self._camera: Optional[Camera] = None self._model = "" + # Which shutter path is armed. "usb" = libgphoto2 tethered capture; "fuji_ble" = + # fire over Bluetooth and pick the RAW out of the camera's WiFi drop folder. Set by + # the sidebar via set_camera_backend; the gphoto-only features (live view, settings, + # calibration) gate themselves off in the fuji_ble mode. + self._backend = "usb" + # Opaque fuji_ble config (pairing fields + drop_folder), owned by the adapter — the + # worker never interprets it, just stores and hands it back to fuji_ble.make_camera. + self._fuji_cfg: dict = {} # True after an open failed because another program holds the USB claim; drives the # "in use by another app" camera status. Cleared by the next open attempt that does # not hit the claim (success, or a different failure). @@ -124,11 +139,30 @@ def __init__(self) -> None: # ----- camera session (one per body, held open) ----- - def _acquire_camera(self) -> GphotoCamera: - """Return the held session, opening it on first use. Every open attempt re-decides - the "claimed by another app" verdict — while we hold a session the poll must never - probe (a probe IS a claim, and would steal the body mid-live-view), so open attempts - are the verdict's only eyes.""" + def _acquire_camera(self) -> Camera: + """Return the held session for the armed backend, opening it on first use.""" + if self._backend == "fuji_ble": + return self._acquire_fuji() + return self._acquire_gphoto() + + def _acquire_fuji(self) -> Camera: + """The Bluetooth trigger session. No PTP claim, no preview, no remote settings — + the body is fired over BLE and the RAW arrives via its WiFi drop folder. Built + entirely through the fuji_ble adapter; no device type is constructed here.""" + if not (self._fuji_cfg.get("address") and self._fuji_cfg.get("flavor")): + raise GphotoError("Bluetooth trigger isn't paired — pair the camera in the CAMERA panel first") + if self._camera is None: + self._camera = fuji_ble.make_camera(self._fuji_cfg, cancel=self._cancel) + self._model = self._fuji_cfg.get("name") or "Fujifilm (Bluetooth)" + # This remote protocol carries only the shutter, so gate the live-view/stepper UI off. + self._caps = CameraCapabilities(driver_model="fuji-ble", preview=False, config=False) + return self._camera + + def _acquire_gphoto(self) -> Camera: + """Return the held gphoto2 session, opening it on first use. Every open attempt + re-decides the "claimed by another app" verdict — while we hold a session the poll + must never probe (a probe IS a claim, and would steal the body mid-live-view), so + open attempts are the verdict's only eyes.""" if self._camera is None: # The callback runs on the camera's own preview thread; the signal hop is what # moves the report onto the Qt side. @@ -171,6 +205,60 @@ def _close_camera(self) -> None: logger.exception("error closing camera session") self._camera = None + @pyqtSlot(str, dict) + def set_camera_backend(self, backend: str, fuji_cfg: dict) -> None: + """Arm the shutter path. ``backend`` is "usb" (gphoto2) or "fuji_ble"; ``fuji_cfg`` + carries the stored pairing (address/name/flavor/token/serial) plus ``drop_folder`` + and is ignored for usb. Pushed by the sidebar whenever those settings change.""" + self._backend = backend + self._fuji_cfg = fuji_cfg # opaque to the worker; handed back to fuji_ble.make_camera + # Drop any held session so the next acquire rebuilds for the new backend. + self._close_camera() + self._model = "" + self._caps = None + self._claimed_elsewhere = False + self._identify_attempted = False + + @pyqtSlot(float) + def detect_fuji(self, timeout: float) -> None: + """Scan for advertising Fujifilm bodies and report them (off the UI thread). + + Emits ``fuji_detected`` with a list of advertisement dicts. A bonded body keeps + advertising, so this sees a camera the operator already paired elsewhere — the + sidebar can then offer to link it instead of showing a blank "not paired". + """ + + def _run() -> None: + try: + found = fuji_ble.discover(timeout=timeout) + logger.info("fuji detect: found %s", [d.get("name") or d.get("address") for d in found]) + self.fuji_detected.emit(found) + except Exception as exc: # noqa: BLE001 — exceptions cannot cross a Qt slot + logger.debug("fuji detect failed: %s", exc) + self.fuji_detected.emit([]) + + threading.Thread(target=_run, name="fuji-detect", daemon=True).start() + + @pyqtSlot(float) + def pair_fuji(self, timeout: float) -> None: + """Discover + pair a Fujifilm body and report the result (off the UI thread). + + Emits ``fuji_pairing_finished(ok, message, pairing_dict)``. The body rotates its BLE + address and its pairing/"Searching" window is brief, so each candidate is tried in + turn until one bonds. State is not applied here — the sidebar persists the pairing + and arms the backend via ``set_camera_backend`` on success (single writer). + """ + + def _run() -> None: + try: + ok, message, pairing_dict = fuji_ble.pair(timeout=timeout) + self.fuji_pairing_finished.emit(ok, message, pairing_dict) + except Exception as exc: # noqa: BLE001 — exceptions cannot cross a Qt slot + logger.exception("fuji pairing failed") + self.fuji_pairing_finished.emit(False, str(exc), {}) + + threading.Thread(target=_run, name="fuji-pair", daemon=True).start() + # ----- light ----- def _ensure_light(self, port: str) -> Scanlight: @@ -343,6 +431,13 @@ def poll_connection(self, port: str) -> None: "camera_preview": True, "camera_config": True, } + if self._backend == "fuji_ble": + # No gphoto2 bus to enumerate — presence is "a pairing is stored"; the body is + # reached over BLE only at fire time. Light is polled exactly as in the usb path. + self._poll_fuji(status) + self._poll_light(status, port) + self.poll_status.emit(status) + return try: # Always ask the bus, never our own handle: unplugging the camera leaves the # handle behind, and it would keep reporting "connected" forever. Enumerating @@ -394,18 +489,35 @@ def poll_connection(self, port: str) -> None: status["usb_model"] = str(e) except Exception: logger.exception("camera poll failed") + self._poll_light(status, port) + self.poll_status.emit(status) + + def _poll_fuji(self, status: dict) -> None: + """Presence for the Bluetooth trigger: a stored pairing is the green dot. There is no + bus enumeration (the body is reached over BLE only at fire time) and no preview/config.""" + paired = bool(self._fuji_cfg.get("address") and self._fuji_cfg.get("flavor")) + status["usb_ok"] = paired + status["usb_model"] = (self._fuji_cfg.get("name") or "Fujifilm (Bluetooth)") if paired else "Bluetooth: not paired" + status["camera_preview"] = False + status["camera_config"] = False + + def _poll_light(self, status: dict, port: str) -> None: try: fw, hw = self._ensure_light(port).get_fw_version() status["light_ok"], status["light_detail"] = True, f"{describe_hardware(hw)} (fw{fw})" status["light_has_white"] = has_white_channel(hw) except Exception: pass - self.poll_status.emit(status) # ----- live view ----- @pyqtSlot(LiveViewRequest) def start_live_view(self, req: LiveViewRequest) -> None: + if self._backend == "fuji_ble": + # The Bluetooth remote carries only the shutter — no preview stream. Reported on + # the unsupported signal so the scan window opens without a stream instead of erroring. + self.live_view_unsupported.emit("Live view isn't available over Bluetooth — focus manually, then scan.") + return try: camera = self._acquire_camera() camera.start() # a no-op when the preview thread is already up @@ -422,7 +534,7 @@ def start_live_view(self, req: LiveViewRequest) -> None: @pyqtSlot() def stop_live_view(self) -> None: """Stop the preview thread but keep the session — a scan still needs it.""" - if self._camera is not None: + if self._camera is not None and self._backend == "usb": try: self._camera.stop() except Exception: @@ -447,7 +559,7 @@ def _camera_control_failed(self, action: str, exc: Exception) -> None: def set_focus_magnifier(self, on: bool) -> None: """Toggle the camera's hardware focus magnifier.""" try: - if self._holds_camera(): + if self._holds_camera() and self._backend == "usb": self._camera.set_focus_magnifier(on) except Exception as exc: # noqa: BLE001 — exceptions cannot cross a Qt slot self._camera_control_failed("set_focus_magnifier", exc) @@ -456,7 +568,7 @@ def set_focus_magnifier(self, on: bool) -> None: def set_focus_magnifier_pos(self, x: int, y: int) -> None: """Aim the magnifier at (x, y) on the 640x480 preview grid.""" try: - if self._holds_camera(): + if self._holds_camera() and self._backend == "usb": self._camera.set_focus_magnifier_at(x, y) except Exception as exc: # noqa: BLE001 — exceptions cannot cross a Qt slot self._camera_control_failed("set_focus_magnifier_pos", exc) @@ -465,7 +577,7 @@ def set_focus_magnifier_pos(self, x: int, y: int) -> None: def set_camera_setting(self, which: str, raw: int) -> None: """Change a live camera setting (iso/shutter/wb/aperture); `raw` is a choice index.""" try: - if self._holds_camera(): + if self._holds_camera() and self._backend == "usb": cam = self._camera { "iso": cam.set_iso, diff --git a/negpy/infrastructure/capture/base.py b/negpy/infrastructure/capture/base.py index bb2fa1a2..21a97072 100644 --- a/negpy/infrastructure/capture/base.py +++ b/negpy/infrastructure/capture/base.py @@ -48,6 +48,12 @@ class Camera(Protocol): def capture(self, out_path: str, shutter: Optional[str] = None, iso: Optional[str] = None, aperture: Optional[str] = None) -> str: ... + def is_open(self) -> bool: + """Whether the session is up. The worker treats this like the gphoto claim — every + backend (gphoto2 USB, Bluetooth trigger) reports it, so the worker never calls a + backend-specific method to ask.""" + ... + def close(self) -> None: ... diff --git a/negpy/infrastructure/capture/fuji_ble.py b/negpy/infrastructure/capture/fuji_ble.py new file mode 100644 index 00000000..90c17dba --- /dev/null +++ b/negpy/infrastructure/capture/fuji_ble.py @@ -0,0 +1,129 @@ +"""Thin connector from the standalone ``fuji-ble-negpy-trigger`` package into NegPy. + +This is the ONLY NegPy module that imports ``fujitrigger``. It mirrors how NegPy already +consumes ``python-sane`` and ``gphoto2``: an optional dependency, imported lazily, entirely +absent by default. Everything above it — the capture worker, the sidebar — talks only +through the functions here, which accept and return plain NegPy-native dicts; no +``fujitrigger`` type escapes this module. Operational errors are translated to plain +``RuntimeError`` with the original preserved as ``__cause__`` (the same contract +``SaneBackend`` and ``GphotoCamera`` already honour). + +The device-specific BLE protocol lives in the standalone package; NegPy maintains nothing +here but the glue. +""" + +from __future__ import annotations + +import importlib.util +import threading +from typing import Optional + +from negpy.infrastructure.capture.base import Camera +from negpy.kernel.system.logging import get_logger + +logger = get_logger(__name__) + + +def available() -> bool: + """True when the optional ``fuji-ble-negpy-trigger`` package (import ``fujitrigger``) is present.""" + return importlib.util.find_spec("fujitrigger") is not None + + +def _ft(): + """Import ``fujitrigger`` lazily, so NegPy runs fine without it.""" + try: + import fujitrigger # noqa: PLC0415 — optional dependency, imported on demand + except ImportError as exc: # pragma: no cover — depends on the install + raise RuntimeError( + "Bluetooth triggering needs the optional fuji-ble-negpy-trigger package. " + "Install it with `uv sync --group fuji-ble` (macOS and Linux)." + ) from exc + return fujitrigger + + +def _pairing(ft, cfg: dict): + return ft.Pairing( + address=cfg.get("address", ""), + name=cfg.get("name", ""), + flavor=ft.Flavor(cfg.get("flavor") or "secure"), + token=cfg.get("token", ""), + serial=cfg.get("serial", ""), + ) + + +def make_camera(cfg: dict, cancel: Optional[threading.Event] = None) -> Camera: + """Build the BLE capture connector from a stored config (pairing fields + ``drop_folder``). + + Returns an object satisfying NegPy's ``Camera`` protocol. The caller owns ``cancel`` + (the worker's stop event) so a cancelled scan aborts the wait for the dropped RAW. + """ + ft = _ft() + drop_folder = cfg.get("drop_folder", "") + if not drop_folder: + raise RuntimeError("Bluetooth trigger needs a drop folder — the camera's WiFi auto-save folder") + trigger = ft.FujiBleTrigger(_pairing(ft, cfg)) + return ft.FujiCamera(trigger, ft.FolderWatchAcquirer(drop_folder), cancel=cancel) + + +def discover(timeout: float = 8.0) -> list[dict]: + """Advertising Fujifilm bodies as plain dicts (name/address/flavor/serial/token/rssi).""" + ft = _ft() + try: + found = ft.discover(timeout=timeout) + except Exception as exc: # noqa: BLE001 — translate to a plain RuntimeError + raise RuntimeError(f"Bluetooth discovery failed: {exc}") from exc + return [ + { + "name": a.name, + "address": a.address, + "flavor": a.flavor.value, + "serial": a.serial.hex(), + "token": a.token.hex(), + "rssi": a.rssi, + } + for a in found + ] + + +def pair(timeout: float = 12.0) -> tuple[bool, str, dict]: + """Discover + bond a body. Returns ``(ok, message, pairing_dict)``; the dict is empty on failure. + + The body rotates its BLE address and its pairing window is brief, so each candidate is + tried in turn (strongest signal first) until one bonds. ``pairing_dict`` carries the + pairing fields the sidebar persists and later hands back to ``make_camera``. + """ + ft = _ft() + try: + found = ft.discover(timeout=timeout) + if not found: + return False, "No Fujifilm camera found — Bluetooth on and the camera in pairing mode?", {} + by_id: dict[str, object] = {} + for adv in sorted(found, key=lambda a: a.rssi, reverse=True): + by_id.setdefault(adv.serial.hex() or adv.token.hex() or adv.address, adv) + last = "" + for adv in by_id.values(): + pairing = ft.Pairing(address=adv.address, name=adv.name, flavor=adv.flavor, token=adv.token.hex(), serial=adv.serial.hex()) + trigger = ft.FujiBleTrigger(pairing) + try: + trigger.connect(timeout=40.0) + except Exception as exc: # noqa: BLE001 — try the next candidate address + last = str(exc) + trigger.close() + continue + trigger.close() + return ( + True, + pairing.name or pairing.address, + { + "address": pairing.address, + "name": pairing.name, + "flavor": pairing.flavor.value, + "token": pairing.token, + "serial": pairing.serial, + }, + ) + return False, f"Could not pair: {last}", {} + except RuntimeError: + raise + except Exception as exc: # noqa: BLE001 — translate vendor errors to a plain RuntimeError + raise RuntimeError(f"Bluetooth pairing failed: {exc}") from exc diff --git a/negpy/infrastructure/capture/settings.py b/negpy/infrastructure/capture/settings.py index 1fea4e5d..213b9a68 100644 --- a/negpy/infrastructure/capture/settings.py +++ b/negpy/infrastructure/capture/settings.py @@ -29,7 +29,23 @@ class ScanlightSettings: roll_name: str = "Roll001" output_folder: str = "" port: str = "" # Scanlight serial port ("" = autodetect); the camera needs no address + # How the shutter is triggered. "usb" = libgphoto2 tethered capture (the default). + # "fuji_ble" = fire the shutter over Bluetooth and pick the RAW up out of the folder the + # camera's WiFi auto-save writes to — for bodies (Fujifilm GFX) that stick in a gphoto2 + # tethered-capture state. The pairing is stored flat below; the drop folder is watched. + camera_backend: str = "usb" + fuji_drop_folder: str = "" + fuji_address: str = "" + fuji_name: str = "" + fuji_flavor: str = "" # "basic" | "secure" + fuji_token: str = "" # hex, Basic pairing only + fuji_serial: str = "" # hex, Secure pairing only @classmethod def defaults(cls) -> "ScanlightSettings": return cls() + + @property + def fuji_paired(self) -> bool: + """A Fujifilm BLE pairing is stored (enough to connect and fire).""" + return bool(self.fuji_address and self.fuji_flavor) diff --git a/pyproject.toml b/pyproject.toml index 368661ee..701cb88d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,14 @@ camera = [ # Camera Scanning tab with them — are macOS and Linux only. "gphoto2>=2.5 ; sys_platform != 'win32'", ] +fuji-ble = [ + # Fujifilm Bluetooth shutter trigger (optional). All device code lives in the standalone + # fuji-ble-negpy-trigger package; NegPy keeps only the thin adapter (capture/fuji_ble.py), + # mirroring the scanner (python-sane) and camera (gphoto2) groups. macOS/Linux only + # (bleak/CoreBluetooth). Pinned to an immutable commit, exactly as the coolscan-roll group + # pins coolscanpy. + "fuji-ble-negpy-trigger @ git+https://github.com/rohanpandula/fuji-ble-negpy-trigger.git@cb62f9e297078a15db3c364dea7893fa0ebf8bed ; sys_platform != 'win32'", +] [project.urls] Homepage = "https://github.com/marcinz606/NegPy" diff --git a/tests/test_capture_fuji_ble.py b/tests/test_capture_fuji_ble.py new file mode 100644 index 00000000..1af8b742 --- /dev/null +++ b/tests/test_capture_fuji_ble.py @@ -0,0 +1,171 @@ +"""Adapter tests for the fuji_ble connector — a fake `fujitrigger` module is injected, so +no Bluetooth hardware and no real fuji-ble-negpy-trigger package are needed (the same +approach #615 uses for coolscanpy). The adapter is the only module under test; it must +never let a fujitrigger type escape and must translate vendor errors to plain RuntimeError. +""" + +import importlib.machinery +import sys +import types + +import pytest + +from negpy.infrastructure.capture import fuji_ble +from negpy.infrastructure.capture.base import Camera + + +def _fake_fujitrigger(discover_exc=None): + mod = types.ModuleType("fujitrigger") + mod.__spec__ = importlib.machinery.ModuleSpec("fujitrigger", loader=None) + + class Flavor: + def __init__(self, value="secure"): + self.value = value + + class Pairing: + def __init__(self, address="", name="", flavor=None, token="", serial=""): + self.address = address + self.name = name + self.flavor = flavor or Flavor() + self.token = token + self.serial = serial + + class FujiBleTrigger: + def __init__(self, pairing, **_kw): + self.pairing = pairing + self._connected = False + + def connect(self, timeout=60.0): + self._connected = True + + def is_connected(self): + return self._connected + + def fire(self, **_kw): + pass + + def close(self): + self._connected = False + + class FolderWatchAcquirer: + def __init__(self, drop_dir, **_kw): + self.drop_dir = drop_dir + + def arm(self): + pass + + def acquire(self, out_path, timeout=60.0, cancel=None): + import os + + final = os.path.splitext(out_path)[0] + ".RAF" + os.makedirs(os.path.dirname(final) or ".", exist_ok=True) + with open(final, "wb") as fh: + fh.truncate(8 * 1024 * 1024) + return final + + def close(self): + pass + + class FujiCamera: + def __init__(self, trigger, acquirer, cancel=None): + self.trigger = trigger + self.acquirer = acquirer + + def is_open(self): + return self.trigger.is_connected() + + def capture(self, out_path, shutter=None, iso=None, aperture=None): + self.trigger.connect() + self.acquirer.arm() + self.trigger.fire() + return self.acquirer.acquire(out_path) + + def close(self): + self.trigger.close() + self.acquirer.close() + + def discover(timeout=8.0): + if discover_exc is not None: + raise discover_exc + return [ + types.SimpleNamespace( + name="GFX100II", + address="AA-BB", + flavor=Flavor("secure"), + serial=b"\x01\x02\x03\x04\x05", + token=b"", + rssi=-50, + ) + ] + + mod.Flavor = Flavor + mod.Pairing = Pairing + mod.FujiBleTrigger = FujiBleTrigger + mod.FujiCamera = FujiCamera + mod.FolderWatchAcquirer = FolderWatchAcquirer + mod.discover = discover + return mod + + +@pytest.fixture +def fake_ft(monkeypatch): + mod = _fake_fujitrigger() + monkeypatch.setitem(sys.modules, "fujitrigger", mod) + return mod + + +def test_available_true_when_package_present(fake_ft): + assert fuji_ble.available() is True + + +def test_available_false_when_package_absent(monkeypatch): + monkeypatch.setattr(fuji_ble.importlib.util, "find_spec", lambda _name: None) + monkeypatch.setitem(sys.modules, "fujitrigger", None) # force `import fujitrigger` to fail + assert fuji_ble.available() is False + with pytest.raises(RuntimeError, match="fuji-ble-negpy-trigger"): + fuji_ble._ft() + + +def test_make_camera_returns_camera_and_captures(fake_ft, tmp_path): + cfg = {"address": "AA-BB", "name": "GFX100II", "flavor": "secure", "serial": "0102030405", "drop_folder": str(tmp_path)} + camera = fuji_ble.make_camera(cfg) + assert isinstance(camera, Camera) + path = camera.capture(str(tmp_path / "stage" / "Roll001_Frame001_R.raw")) + assert path.endswith("Roll001_Frame001_R.RAF") + camera.close() + + +def test_make_camera_requires_drop_folder(fake_ft): + with pytest.raises(RuntimeError, match="drop folder"): + fuji_ble.make_camera({"address": "AA-BB", "flavor": "secure"}) + + +def test_discover_returns_plain_dicts(fake_ft): + found = fuji_ble.discover() + assert found == [{"name": "GFX100II", "address": "AA-BB", "flavor": "secure", "serial": "0102030405", "token": "", "rssi": -50}] + + +def test_pair_returns_pairing_dict(fake_ft): + ok, message, pairing = fuji_ble.pair() + assert ok is True + assert message == "GFX100II" + assert pairing["address"] == "AA-BB" + assert pairing["flavor"] == "secure" + assert pairing["serial"] == "0102030405" + + +def test_pair_no_camera_found(monkeypatch): + mod = _fake_fujitrigger() + mod.discover = lambda timeout=8.0: [] + monkeypatch.setitem(sys.modules, "fujitrigger", mod) + ok, message, pairing = fuji_ble.pair() + assert ok is False and pairing == {} and "No Fujifilm camera" in message + + +def test_vendor_error_is_translated_with_cause(monkeypatch): + boom = ValueError("ble went sideways") + mod = _fake_fujitrigger(discover_exc=boom) + monkeypatch.setitem(sys.modules, "fujitrigger", mod) + with pytest.raises(RuntimeError) as exc_info: + fuji_ble.discover() + assert exc_info.value.__cause__ is boom diff --git a/tests/test_capture_worker.py b/tests/test_capture_worker.py index 8abbc028..d480801e 100644 --- a/tests/test_capture_worker.py +++ b/tests/test_capture_worker.py @@ -6,7 +6,7 @@ import pytest -from negpy.desktop.workers.capture_worker import CalibrationRequest, CaptureRequest, CaptureWorker +from negpy.desktop.workers.capture_worker import CalibrationRequest, CaptureRequest, CaptureWorker, LiveViewRequest from negpy.services.capture.calibration import Roi @@ -381,3 +381,89 @@ def close(self) -> None: worker._camera = OpensFine() worker._acquire_camera() assert worker._claimed_elsewhere is False + + +# ---- Fujifilm BLE backend --------------------------------------------------- + +_FUJI_CFG = { + "address": "AA-BB", + "name": "GFX100II", + "flavor": "secure", + "serial": "0102030405", + "drop_folder": "", +} + + +class FakeFujiCamera: + """Stands in for FujiBleCamera (no bleak): records the trigger + fakes a RAW.""" + + def __init__(self, pairing=None, drop_folder="") -> None: + self.pairing = pairing + self.drop_folder = drop_folder + self.closed = False + self._open = False + + def is_open(self) -> bool: + return self._open + + def capture(self, out_path: str, shutter=None, iso=None, aperture=None) -> str: + self._open = True + path = os.path.splitext(out_path)[0] + ".RAF" + with open(path, "wb") as raw: + raw.truncate(8 * 1024 * 1024) + return path + + def close(self) -> None: + self.closed = True + self._open = False + + +def test_set_camera_backend_stores_the_pairing(): + worker = CaptureWorker() + worker.set_camera_backend("fuji_ble", _FUJI_CFG) + assert worker._backend == "fuji_ble" + assert worker._fuji_cfg.get("serial") == "0102030405" + # An unpaired config carries no address/flavor. + worker.set_camera_backend("fuji_ble", {"drop_folder": ""}) + assert not worker._fuji_cfg.get("address") + + +def test_fuji_backend_single_capture(tmp_path, monkeypatch): + import negpy.desktop.workers.capture_worker as cw + + monkeypatch.setattr(cw.fuji_ble, "make_camera", lambda cfg, cancel=None: FakeFujiCamera(cfg, cfg.get("drop_folder", ""))) + worker = CaptureWorker() + worker.set_camera_backend("fuji_ble", {**_FUJI_CFG, "drop_folder": str(tmp_path)}) + finished = [] + worker.finished.connect(finished.append) + + worker.run_capture(CaptureRequest(roll_name="Roll01", frame_number=1, output_folder=str(tmp_path), levels=(255, 0, 0), rgb_mode=False)) + + assert finished and finished[0][0].endswith(".RAF") + + +def test_fuji_backend_live_view_is_unsupported(): + worker = CaptureWorker() + worker.set_camera_backend("fuji_ble", _FUJI_CFG) + messages = [] + worker.live_view_unsupported.connect(messages.append) + + worker.start_live_view(LiveViewRequest()) + + assert messages and "Bluetooth" in messages[0] + + +def test_fuji_backend_poll_reports_pairing_as_presence(): + worker = CaptureWorker() + worker.set_camera_backend("fuji_ble", _FUJI_CFG) + statuses = [] + worker.poll_status.connect(statuses.append) + + worker.poll_connection("") # no Scanlight — the light poll fails silently + + assert statuses + status = statuses[-1] + assert status["usb_ok"] is True + assert status["usb_model"] == "GFX100II" + assert status["camera_preview"] is False + assert status["camera_config"] is False diff --git a/uv.lock b/uv.lock index cfbb8304..e588fc46 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -134,6 +149,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, +] + +[[package]] +name = "fuji-ble-negpy-trigger" +version = "0.1.0" +source = { git = "https://github.com/rohanpandula/fuji-ble-negpy-trigger.git?rev=cb62f9e297078a15db3c364dea7893fa0ebf8bed#cb62f9e297078a15db3c364dea7893fa0ebf8bed" } +dependencies = [ + { name = "bleak" }, +] + [[package]] name = "gphoto2" version = "2.6.4" @@ -322,6 +372,9 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +fuji-ble = [ + { name = "fuji-ble-negpy-trigger", marker = "sys_platform != 'win32'" }, +] scanner = [ { name = "python-sane" }, ] @@ -354,6 +407,7 @@ dev = [ { name = "ruff", specifier = "==0.14.10" }, { name = "ty", specifier = ">=0.0.26" }, ] +fuji-ble = [{ name = "fuji-ble-negpy-trigger", marker = "sys_platform != 'win32'", git = "https://github.com/rohanpandula/fuji-ble-negpy-trigger.git?rev=cb62f9e297078a15db3c364dea7893fa0ebf8bed" }] scanner = [{ name = "python-sane", specifier = ">=2.9" }] [[package]] @@ -601,6 +655,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/79/890a53ed45c1006eedcf60627b7d661c8696e5367723ceb25cc6a0216b30/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:30a26eef36c250fc14e73335641e24f764b32b7e42bae945a5d8a1c2347040b5", size = 13235, upload-time = "2026-06-19T16:08:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/7a/40ffc3be8e31b1eb1f8f5eb2a58ef832287fb1ea6b3c452dc8b25b9e064b/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:756933b9ce6160a986c8877ab667659fa2d8e15aff28d0981b5284fbdd1ea735", size = 13416, upload-time = "2026-06-19T16:08:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/30/ff/6f3b0bb3110ec82dbedaea47de151bd688980f5aadc634ef0cd236fdbd16/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2a2e6d56f51e4ca3e3b9766ef34150a9a7ce5f0cf4f9ee879ec10923af58e97e", size = 13223, upload-time = "2026-06-19T16:08:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/4e12660569219e4a68186ae9709b85278d3ebaf8d2f8e1c826a7337f4f7a/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50d7e4245dbdc8789dcc1f11fca2e633aa126a298b09db62f8216531fe107ee2", size = 13414, upload-time = "2026-06-19T16:08:31.679Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4c/976ae9bcce3615af806e3c314ea9caa3faacf11ec44f00b1a149559c6cb3/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c8d126c56b71c25218be186930a1b41739f83e931726a52e3298beeb170c5e5b", size = 13222, upload-time = "2026-06-19T16:08:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/99/be/44bb648a6b5c8aec79138bf562dab9eef414016ee31f37066bf81d809ae9/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:81023518feb75e9b2b676b28198955c51ae00548cf23c73c524c7101263b68db", size = 13424, upload-time = "2026-06-19T16:08:33.336Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c6/cfe97f1beb13f5b7ca5c4348158c2de886d58ffba5be09a9376557f7d6f6/pyobjc_framework_libdispatch-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9c0ebf99520083bf17c007a544c100056a0d4ae5c346fb89e1bdfe6d041f16f2", size = 15679, upload-time = "2026-06-19T16:12:51.28Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/ef6b51bc72fe5ac1df80c34b1b13a97d0922ddd6bc5d3ecf5ead1557bf34/pyobjc_framework_libdispatch-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3f56fd71b963a0b6e440ed2f0ea2fb635221758b7eb908ba38f96f5144b83ca3", size = 15946, upload-time = "2026-06-19T16:12:52.098Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/5b4a6c8580f2a486daf4b0d14a2356c47abfda401b329e71e46ac9b5460c/pyobjc_framework_libdispatch-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:999bad9a2c9198c837ba8f57a3ca9f05b4fc4bf7b69318baaa266dd2ab2fc8f7", size = 15699, upload-time = "2026-06-19T16:12:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/68cff50cb37a6ea311b7e805105ea13c33043762772714bc25d269c0730d/pyobjc_framework_libdispatch-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3fc93971f40d9757995c1e4b995a1614a468a5178be27e3d81e9bdc0b5e3cf75", size = 15981, upload-time = "2026-06-19T16:12:53.845Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/1f48e023555817f1271e86849ebd092743fc8bd292b6f82e87aba5df6122/pyobjc_framework_libdispatch-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:26a096c81c8cf272f4f1bb8f6c4b7565e005d273d218b53b83d925da5292633f", size = 15719, upload-time = "2026-06-19T16:12:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/b45c32851a3bcd367c62804c23aa55ea7918af6e16fddf1df23f5d7ca750/pyobjc_framework_libdispatch-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:82c6512fb4985f3bcd6b60b0cff79a4b483b44d1d2e5405010e34dd4b60aa01b", size = 16009, upload-time = "2026-06-19T16:12:55.513Z" }, +] + [[package]] name = "pyqt6" version = "6.11.0"