Skip to content

Commit 28fae2e

Browse files
committed
Initial commit — nikon-transfer 1.0.0
Wi-Fi photo transfer from Nikon D5300 via PTP/IP. - CLI core: stdlib only, no runtime deps - Optional GUI: PySide6 + Pillow (thumbnail grid, EXIF panel, full-res preview, auto-sync via 3s polling) - macOS .app bundle via PyInstaller (build_app.sh) - 25 tests with fully mocked sockets — no camera required - MIT licensed Tested on Apple Silicon MacBook (M1) / macOS.
0 parents  commit 28fae2e

26 files changed

Lines changed: 3555 additions & 0 deletions

.gitignore

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.egg-info/
5+
dist/
6+
build/
7+
8+
# Virtualenvs
9+
.venv/
10+
venv/
11+
.env
12+
13+
# Test / coverage
14+
.coverage
15+
htmlcov/
16+
.pytest_cache/
17+
18+
# Editors / OS
19+
.DS_Store
20+
.idea/
21+
.vscode/
22+
.claude/
23+
24+
# Runtime logs (timestamped session logs at the project root)
25+
*.log
26+
27+
# Captures d'écran ad hoc à la racine
28+
/Capture d’écran*.png

CLAUDE.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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.

CONTRIBUTING.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Contributing
2+
3+
Thanks for your interest! A few things to know before opening a PR or issue.
4+
5+
## Scope and tested hardware
6+
7+
This project has only been tested on a **Nikon D5300**. PRs adding support for
8+
other Nikon bodies (D750, D7500, Z6, Z fc…) or other PTP/IP cameras are very
9+
welcome — please open an issue first so we can discuss the protocol differences
10+
(some bodies need the vendor opcode `NikonGetEvent 0x90C7`, others have
11+
different storage layouts, etc.).
12+
13+
## Running the tests without a camera
14+
15+
All 25 tests fully mock the sockets — you don't need a camera to validate
16+
changes to the PTP/IP layer.
17+
18+
```bash
19+
pip install -e ".[gui,dev]"
20+
pytest
21+
pytest --cov # coverage report
22+
```
23+
24+
If you change protocol code, please add a test that mocks the new packet
25+
exchange. `tests/test_client.py` has helpers (`_make_packet`, `_data_start`,
26+
`_data_end`, `_response_packet`) that make this easy.
27+
28+
## Coding conventions
29+
30+
- **CLI / core (`nikon_transfer.client`, `transfer`, `cli`)** must remain
31+
**stdlib-only** — no runtime dependencies. PySide6 and Pillow are GUI-only
32+
(extra `[gui]` in `pyproject.toml`).
33+
- User-facing strings and logs are in **French** (the original audience is
34+
French-speaking); code identifiers and docstrings are in **English**.
35+
- Target Python **3.11+**.
36+
37+
## Reporting protocol bugs
38+
39+
If you hit a PTP/IP protocol issue, please run with `--debug` and include the
40+
relevant packet hex dump in the issue — it speeds up diagnosis a lot:
41+
42+
```bash
43+
nikon-transfer --debug 2>&1 | tee debug.log
44+
```
45+
46+
The non-obvious gotchas we've already documented live in `CLAUDE.md` (and the
47+
"PTP/IP gotchas" section of the README). Check there first; if your issue
48+
matches one of them, the fix may already exist.
49+
50+
## License
51+
52+
By contributing, you agree that your contributions will be licensed under the
53+
[MIT License](LICENSE).

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Stéphane Dobbelaere
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.fr.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# nikon-transfer
2+
3+
> 🇬🇧 [Read in English](README.md)
4+
5+
Transfert Wi-Fi de photos depuis un **Nikon D5300** vers un Mac (ou tout autre
6+
ordinateur) via PTP/IP. Pas de câble USB, pas d'app propriétaire (Nikon WMU est
7+
abandonnée et cassée sur les macOS récents), pas de `libgphoto2`. Le cœur est
8+
en **Python stdlib pur** ; la GUI optionnelle ajoute PySide6 + Pillow.
9+
10+
[![tests](https://img.shields.io/badge/tests-25%20passing-brightgreen)]()
11+
[![python](https://img.shields.io/badge/python-3.11%2B-blue)]()
12+
[![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
13+
14+
## ⚠️ Avertissement
15+
16+
Ce logiciel est fourni « tel quel », sans aucune garantie. Il communique avec
17+
la caméra via un protocole PTP/IP reverse-engineered et non couvert par la
18+
garantie Nikon. L'auteur décline toute responsabilité en cas de
19+
dysfonctionnement, perte de données, ou dommage matériel résultant de son
20+
utilisation. Voir [LICENSE](LICENSE).
21+
22+
## Capture d'écran
23+
24+
> _Capture GUI à ajouter — voir `docs/screenshot.png`._
25+
26+
## Fonctionnalités
27+
28+
- **CLI** pour les transferts scriptés (`--new-only`, `--dry-run`, filtre
29+
d'extensions…).
30+
- **GUI** avec grille de miniatures, tri par nom / date de prise de vue / taille,
31+
panneau EXIF à la demande, aperçu pleine résolution au double-clic.
32+
- **Synchro automatique** : les photos prises pendant que la GUI est connectée
33+
apparaissent dans la grille en ~3 s, sans rechargement manuel.
34+
- **Aucune dépendance runtime externe** pour la CLI/cœur (stdlib uniquement).
35+
36+
## Pré-requis
37+
38+
| Étape | Action |
39+
|--------|------------------------------------------------------------------------|
40+
| Caméra | Menu → Setup ⚙ → Wi-Fi → **Enable** |
41+
| Mac | Se connecter au réseau Wi-Fi de la caméra (`Nikon_XXXXXXXX`) |
42+
| Python | 3.11 ou plus récent |
43+
44+
IP par défaut de la caméra : `192.168.1.1`, port PTP/IP `15740`.
45+
46+
Testé sur **MacBook Apple Silicon (M1)** sous macOS. Devrait fonctionner sur
47+
Intel Mac, Linux et Windows (cœur Python pur), mais non vérifié.
48+
49+
## Installation
50+
51+
```bash
52+
pip install . # CLI seule
53+
pip install ".[gui]" # CLI + GUI
54+
pip install ".[progress]" # ajoute une barre tqdm à la CLI
55+
```
56+
57+
## Usage
58+
59+
### CLI
60+
61+
```
62+
nikon-transfer [options]
63+
64+
Options :
65+
--host HOST IP de la caméra (défaut : 192.168.1.1)
66+
--dest PATH Dossier de destination (défaut : ~/Pictures/Nikon_D5300)
67+
--new-only Ignore les fichiers déjà présents dans la destination
68+
--dry-run Liste les fichiers sans télécharger
69+
--ext EXT [EXT …] Extensions à transférer (défaut : .jpg .nef .tif .png …)
70+
--debug Log chaque paquet PTP/IP en hex (verbeux)
71+
```
72+
73+
Exemples :
74+
75+
```bash
76+
nikon-transfer # tout transférer
77+
nikon-transfer --dest ~/Desktop/Shoot --new-only --ext .nef
78+
nikon-transfer --dry-run # lister, sans télécharger
79+
nikon-transfer --host 192.168.0.10 # IP caméra différente
80+
```
81+
82+
### GUI
83+
84+
```bash
85+
nikon-transfer-gui
86+
```
87+
88+
Clic sur **Connecter**, attendre le chargement des miniatures, cocher les photos
89+
voulues, puis **Télécharger la sélection**. Prends une nouvelle photo pendant
90+
que la GUI est connectée — elle apparaît automatiquement dans la grille.
91+
92+
### Application macOS double-clic
93+
94+
Pour générer un `Nikon Transfer.app` autonome (sans Python à installer chez
95+
l'utilisateur final) :
96+
97+
```bash
98+
pip install ".[build]" # installe PyInstaller
99+
./build_app.sh # → dist/Nikon Transfer.app
100+
./build_app.sh --install # copie le .app dans /Applications
101+
```
102+
103+
Le bundle pèse ~100 Mo (Qt + Python embarqués). Au premier lancement, macOS
104+
demande l'autorisation réseau local pour que l'app puisse parler à la caméra.
105+
106+
## Comment ça marche
107+
108+
L'outil parle **PTP/IP** (CIPA DC-X 005-2005) directement sur le port TCP `15740`.
109+
Deux connexions TCP sont établies (commande + event) et un petit sous-ensemble
110+
des opérations PTP est utilisé : `OpenSession`, `GetStorageIDs`,
111+
`GetObjectHandles`, `GetObjectInfo`, `GetObject`, `GetThumb`, `GetPartialObject`.
112+
113+
## Pièges PTP/IP (appris à la dure sur le D5300)
114+
115+
Ces points ne se devinent pas dans la spec — voir `CLAUDE.md` pour le détail.
116+
117+
1. **Deux canaux TCP obligatoires avant toute opération PTP.** La caméra reste
118+
silencieuse sur `OpenSession` tant que le handshake event n'est pas fait.
119+
2. **Les paquets DATA / DATA_END sont préfixés par 4 octets de TransactionID**
120+
avant la charge utile PTP réelle. Sans les retirer, `GetStorageIDs` renvoie
121+
un ID parasite en tête.
122+
3. **`parent=0`** dans `GetObjectHandles` signifie « tous les objets quel que soit
123+
le parent » (pas `0xFFFFFFFF`).
124+
4. **GUID fraîche par instance** (`uuid.uuid4().bytes`) — la réutiliser fait
125+
que la caméra prend la connexion pour une session abandonnée et la refuse.
126+
5. **Le D5300 ne pousse pas d'events PTP/IP.** Le canal event est obligatoire
127+
pour le handshake mais la caméra le ferme juste après. La détection de
128+
nouvelles photos passe donc par un polling de `GetObjectHandles` toutes les
129+
~3 s plutôt que par le canal event.
130+
131+
## Développement
132+
133+
```bash
134+
pip install -e ".[gui,dev]"
135+
pytest # 25 tests, sockets entièrement mockés — pas besoin de caméra
136+
pytest --cov
137+
```
138+
139+
## Limitations
140+
141+
- Débit **~1–2 Mo/s**, plafonné par le Wi-Fi 802.11g du D5300 (pas Python).
142+
- **Pas de preview RAW (NEF) native** dans l'aperçu pleine résolution (Qt ne
143+
décode pas le NEF — il faudrait `rawpy` ou équivalent).
144+
- **Pas de reprise après erreur réseau en cours de transfert** — se reconnecter
145+
manuellement.
146+
- Testé uniquement sur **D5300**. PRs bienvenues pour d'autres boîtiers Nikon —
147+
voir [`CONTRIBUTING.md`](CONTRIBUTING.md).
148+
149+
## Licence
150+
151+
[MIT](LICENSE)

0 commit comments

Comments
 (0)