|
| 1 | +# nikon-transfer — contexte projet |
| 2 | + |
| 3 | +Outil de transfert Wi-Fi Nikon D5300 → Mac via PTP/IP (CIPA DC-X 005-2005). |
| 4 | +Stdlib uniquement pour le cœur ; PySide6 + Pillow pour la GUI. |
| 5 | + |
| 6 | +## Lancer |
| 7 | + |
| 8 | +```bash |
| 9 | +# CLI |
| 10 | +nikon-transfer --dest ~/Pictures/D5300 --new-only |
| 11 | +nikon-transfer --debug # hex de chaque paquet PTP/IP |
| 12 | + |
| 13 | +# GUI (PySide6) |
| 14 | +nikon-transfer-gui |
| 15 | + |
| 16 | +# Tests |
| 17 | +python -m pytest # 30 tests, sockets mockés |
| 18 | + |
| 19 | +# Bundle macOS (.app double-clic) |
| 20 | +./build_app.sh # → dist/Nikon Transfer.app |
| 21 | +./build_app.sh --install # copie aussi dans /Applications |
| 22 | +``` |
| 23 | + |
| 24 | +Pré-requis matériel : D5300 allumé, Wi-Fi activé (Menu → Setup → Wi-Fi → Enable), |
| 25 | +Mac connecté au SSID `Nikon_XXXXXXXX`, IP caméra par défaut `192.168.1.1:15740`. |
| 26 | + |
| 27 | +## Carte du code |
| 28 | + |
| 29 | +``` |
| 30 | +nikon_transfer/ |
| 31 | + protocol.py constantes PTP/IP, opcodes, codes réponse, rsp_name() |
| 32 | + client.py PtpIpClient — handshake, _recv_data, opérations PTP |
| 33 | + (get_storage_ids/info, get_object_handles/info, get_object, |
| 34 | + get_partial_object, get_thumb, get_device_info, |
| 35 | + get_device_datetime, get_battery_level) |
| 36 | + transfer.py orchestration CLI (discover_camera, transfer_photos) |
| 37 | + cli.py argparse + main() |
| 38 | + gui.py Qt MainWindow + CameraWorker (QThread) + PreviewDialog |
| 39 | + utils.py recv_exactly, read_ptp_string, _read_uint16_array, |
| 40 | + format_size, format_storage_size, md5_file |
| 41 | + log.py config logging (console + fichier horodaté) |
| 42 | +tests/ test_client.py, test_transfer.py, test_utils.py |
| 43 | +nikon_transfer_gui.py point d'entrée PyInstaller |
| 44 | +nikon_transfer.spec config PyInstaller (.app, Info.plist, exclusions Qt) |
| 45 | +build_app.sh wrapper avec nettoyage chflags + --open / --install |
| 46 | +assets/ |
| 47 | + make_icon.py régénère icon.icns depuis Pillow + iconutil |
| 48 | + icon.icns icône de l'app (référencée dans le .spec) |
| 49 | + icon_1024.png maître PNG (sert de source aux 10 tailles iconset) |
| 50 | +``` |
| 51 | + |
| 52 | +## Pièges PTP/IP appris à la dure |
| 53 | + |
| 54 | +Ces points ne se devinent pas en lisant la spec, ils ont coûté du temps de debug — |
| 55 | +ne pas les défaire sans raison. |
| 56 | + |
| 57 | +1. **Deux canaux obligatoires avant toute opération.** La caméra reste silencieuse sur |
| 58 | + `OpenSession` tant qu'on n'a pas établi le canal Event. Séquence : |
| 59 | + `INIT_CMD_REQUEST → INIT_CMD_ACK (donne conn_num)` sur `cmd_sock`, puis |
| 60 | + `INIT_EVENT_REQUEST(conn_num) → INIT_EVENT_ACK` sur un **second TCP** vers le même port. |
| 61 | + Voir `client.py:57-98`. |
| 62 | + |
| 63 | +2. **Préfixe TransactionID dans les paquets DATA/DATA_END.** Chaque paquet de données |
| 64 | + PTP/IP commence par 4 octets de TxID *avant* la charge utile PTP réelle. |
| 65 | + `_recv_data` doit faire `chunks.append(chunk[4:])` (`client.py:263`). |
| 66 | + Symptôme si oublié : `GetStorageIDs` renvoie un ID parasite en tête, puis |
| 67 | + `GetObjectHandles` répond `NikonVendor:0xA081` (InvalidStorageID maquillé). |
| 68 | + |
| 69 | +3. **Parent handle = 0** dans `GetObjectHandles`, pas `0xFFFFFFFF`. |
| 70 | + PTP §10.4.6 : 0 = tous objets quel que soit le parent (`client.py:153`). |
| 71 | + |
| 72 | +4. **GUID fraîche par instance** (`uuid.uuid4().bytes`) — sinon la caméra prend la |
| 73 | + nouvelle connexion pour une vieille session abandonnée et refuse (`client.py:53`). |
| 74 | + |
| 75 | +5. **Le D5300 ne pousse pas d'events PTP/IP.** Le canal event est obligatoire pour |
| 76 | + le handshake (piège #1), mais la caméra le ferme avec `Connection reset by peer` |
| 77 | + juste après l'`OpenSession` — aucun paquet 0x0008 (PKT_EVENT) ne sera jamais |
| 78 | + reçu. Pour détecter les nouvelles photos, polling de `GetObjectHandles` toutes |
| 79 | + les ~3 s avec diff vs `_known_handles` (voir `CameraWorker._poll_events` dans |
| 80 | + `gui.py`). La méthode `client.poll_event()` et la constante `PKT_EVENT` restent |
| 81 | + présentes pour un éventuel futur boîtier Nikon ou un portage à l'opcode vendor |
| 82 | + `NikonGetEvent 0x90C7` (voie utilisée par libgphoto2). |
| 83 | + |
| 84 | +## GUI — décisions non évidentes |
| 85 | + |
| 86 | +- **EXIF récupéré à la demande, pas via la miniature.** Le D5300 strippe l'EXIF des |
| 87 | + thumbnails embarqués. Solution : `OP_GET_PARTIAL_OBJECT (0x101B)` premiers 128 KiB |
| 88 | + du vrai fichier au clic, mis en cache dans `PhotoItem.exif_head`. Pillow lit l'IFD |
| 89 | + EXIF (sub-tag `0x8769`) depuis ce buffer. Voir `CameraWorker.fetch_exif` et |
| 90 | + `_format_exif` dans `gui.py`. |
| 91 | + |
| 92 | +- **NEF non décodable nativement par Qt.** `QPixmap.loadFromData` échoue sur les RAW. |
| 93 | + La preview pleine résolution (`PreviewDialog`) affiche un message d'erreur explicite |
| 94 | + dans ce cas plutôt qu'une image vide. Pas de décodage RAW prévu (ajouter `rawpy` si |
| 95 | + un jour nécessaire). |
| 96 | + |
| 97 | +- **Annulation thread-safe** via `threading.Event` lue entre deux itérations de |
| 98 | + download (`CameraWorker._cancel`). Une fois `GetObject` lancé sur un fichier, on |
| 99 | + attend qu'il se termine — pas d'interruption en plein transfert. |
| 100 | + |
| 101 | +- **Tri dynamique** sans tout réindexer : `PhotoItem.__lt__` lit `_sort_key`, |
| 102 | + `_resort` recalcule les clés puis appelle `sortItems()`. |
| 103 | + |
| 104 | +- **Reconnexion auto sur socket cassée.** Le `cmd_sock` du D5300 tombe sans prévenir |
| 105 | + (idle, ou pendant un download). `CameraWorker._mark_disconnected` ferme la session |
| 106 | + et émet `connection_lost(str)` au lieu de `error+disconnected` — `MainWindow. |
| 107 | + _on_connection_lost` enchaîne 3 tentatives (backoff 2/4/8 s via `QTimer.singleShot`) |
| 108 | + avant de remonter une `QMessageBox`. Le tuple `_DEAD_SOCKET_ERRORS` (BrokenPipe, |
| 109 | + ConnectionReset/Aborted, EOF) doit être attrapé partout où l'on parle au PTP |
| 110 | + (fetch_full, fetch_exif, _poll_events, download, _refresh_battery). Pendant |
| 111 | + une reconnexion, `_fresh_session_reset` vide la grille — `_on_photo_found` dédoublonne |
| 112 | + par handle pour que la ré-énumération ne crée pas de doublons. Le flag |
| 113 | + `_user_initiated_disconnect` (mis dans `closeEvent`) empêche les retries après |
| 114 | + fermeture de fenêtre. |
| 115 | + |
| 116 | +- **GetDeviceInfo / DateTime / BatteryLevel sont nice-to-have.** Une caméra firmware |
| 117 | + exotique peut retourner `DevicePropNotSupported (0x200A)` — `get_device_datetime` |
| 118 | + et `get_battery_level` renvoient `None` dans ce cas plutôt que de lever. Le |
| 119 | + handshake `connect_camera` log-et-ignore l'échec de `get_device_info` / |
| 120 | + `get_device_datetime` pour ne pas casser la connexion. |
| 121 | + |
| 122 | +## Limites connues |
| 123 | + |
| 124 | +- **Débit ~1–2 Mo/s** plafonné par le Wi-Fi 802.11g du D5300 — pas un problème Python. |
| 125 | +- **Pas d'aperçu RAW** (NEF) en preview pleine résolution. |
| 126 | +- **Reprise réseau seulement *entre* fichiers.** L'auto-reconnect (cf. GUI) relance |
| 127 | + la session après une coupure, mais un téléchargement interrompu en plein milieu |
| 128 | + n'est pas repris : le fichier en cours est marqué erreur et la boucle s'arrête. |
| 129 | + La sélection cochée reste, donc relancer « Télécharger » suffit en pratique. |
| 130 | + |
| 131 | +## Conventions |
| 132 | + |
| 133 | +- Strings et logs en français (utilisateur francophone). |
| 134 | +- Stdlib only pour `nikon_transfer.client/transfer/cli` — pas de dépendances cachées. |
| 135 | + PySide6 + Pillow uniquement pour la GUI (extra `[gui]` dans `pyproject.toml`). |
| 136 | + PyInstaller pour le bundle macOS (extra `[build]`). |
| 137 | +- Tests Python 3.11+ (`requires-python = ">=3.11"`), sockets mockés intégralement — |
| 138 | + jamais besoin d'une caméra réelle pour `pytest`. |
| 139 | +- Tailles d'octets : `format_size` (base 1024, pour la taille des fichiers) |
| 140 | + vs `format_storage_size` (base 1000 SI, pour l'affichage carte mémoire — colle |
| 141 | + à l'étiquette « 32 Go » du fabricant). Ne pas mélanger. |
0 commit comments