|
| 1 | +# fmd_api: Python client for interacting with FMD (fmd-foss.org) |
| 2 | + |
| 3 | +This directory contains Python scripts for interacting with an FMD (Find My Device) server, including authentication, key retrieval, and location data decryption. |
| 4 | +For more information on this open source alternative to Google's Find My Device service, read the Credits section at the bottom of this README. |
| 5 | +In this repo you'll find fmd_api.py is the tool supporting fmd_client.py, used in most of the examples. |
| 6 | + |
| 7 | +## Prerequisites |
| 8 | +- Python 3.7+ |
| 9 | +- Install dependencies: |
| 10 | + ``` |
| 11 | + pip install requests argon2-cffi cryptography |
| 12 | + ``` |
| 13 | + |
| 14 | +## Scripts Overview |
| 15 | + |
| 16 | +### Main Client |
| 17 | + |
| 18 | +#### `fmd_client.py` |
| 19 | +**The primary tool for bulk data export.** Downloads locations and/or pictures, saving them to a directory or ZIP archive. |
| 20 | + |
| 21 | +**Usage:** |
| 22 | +```bash |
| 23 | +python fmd_client.py --url <server_url> --id <fmd_id> --password <password> --output <path> [--locations [N]] [--pictures [N]] |
| 24 | +``` |
| 25 | +
|
| 26 | +**Options:** |
| 27 | +- `--locations [N]`: Export all locations, or specify N for the most recent N locations |
| 28 | +- `--pictures [N]`: Export all pictures, or specify N for the most recent N pictures |
| 29 | +- `--output`: Output directory or `.zip` file path |
| 30 | +- `--session`: Session duration in seconds (default: 3600) |
| 31 | +
|
| 32 | +**Examples:** |
| 33 | +```bash |
| 34 | +# Export all locations to CSV |
| 35 | +python fmd_client.py --url https://fmd.example.com --id alice --password secret --output data --locations |
| 36 | + |
| 37 | +# Export last 10 locations and 5 pictures to ZIP |
| 38 | +python fmd_client.py --url https://fmd.example.com --id alice --password secret --output export.zip --locations 10 --pictures 5 |
| 39 | +``` |
| 40 | +
|
| 41 | +### Debugging Scripts |
| 42 | +
|
| 43 | +Located in `debugging/`, these scripts help test individual workflows and troubleshoot issues. |
| 44 | +
|
| 45 | +#### `fmd_get_location.py` |
| 46 | +**End-to-end test:** Authenticates, retrieves, and decrypts the latest location in one step. |
| 47 | +
|
| 48 | +**Usage:** |
| 49 | +```bash |
| 50 | +cd debugging |
| 51 | +python fmd_get_location.py --url <server_url> --id <fmd_id> --password <password> |
| 52 | +``` |
| 53 | +
|
| 54 | +#### `fmd_export_data.py` |
| 55 | +**Test native export:** Downloads the server's pre-packaged export ZIP (if available). |
| 56 | +
|
| 57 | +**Usage:** |
| 58 | +```bash |
| 59 | +cd debugging |
| 60 | +python fmd_export_data.py --url <server_url> --id <fmd_id> --password <password> --output export.zip |
| 61 | +``` |
| 62 | +
|
| 63 | +#### `request_location_example.py` |
| 64 | +**Request new location:** Triggers a device to capture and upload a new location update. |
| 65 | +
|
| 66 | +**Usage:** |
| 67 | +```bash |
| 68 | +cd debugging |
| 69 | +python request_location_example.py --url <server_url> --id <fmd_id> --password <password> [--provider all|gps|cell|last] [--wait SECONDS] |
| 70 | +``` |
| 71 | +
|
| 72 | +**Options:** |
| 73 | +- `--provider`: Location provider to use (default: all) |
| 74 | + - `all`: Use all available providers (GPS, network, fused) |
| 75 | + - `gps`: GPS only (most accurate, slower) |
| 76 | + - `cell`: Cellular network (faster, less accurate) |
| 77 | + - `last`: Don't request new location, just get last known |
| 78 | +- `--wait`: Seconds to wait for location update (default: 30) |
| 79 | +
|
| 80 | +**Example:** |
| 81 | +```bash |
| 82 | +# Request GPS location and wait 45 seconds |
| 83 | +python request_location_example.py --url https://fmd.example.com --id alice --password secret --provider gps --wait 45 |
| 84 | + |
| 85 | +# Quick cellular network location |
| 86 | +python request_location_example.py --url https://fmd.example.com --id alice --password secret --provider cell --wait 20 |
| 87 | +``` |
| 88 | +
|
| 89 | +#### `diagnose_blob.py` |
| 90 | +**Diagnostic tool:** Analyzes encrypted blob structure to troubleshoot decryption issues. |
| 91 | +
|
| 92 | +**Usage:** |
| 93 | +```bash |
| 94 | +cd debugging |
| 95 | +python diagnose_blob.py --url <server_url> --id <fmd_id> --password <password> |
| 96 | +``` |
| 97 | +
|
| 98 | +Shows: |
| 99 | +- Private key size and type |
| 100 | +- Actual blob size vs. expected structure |
| 101 | +- Analysis of RSA session key packet layout |
| 102 | +- First/last bytes in hex for inspection |
| 103 | +
|
| 104 | +## Core Library |
| 105 | +
|
| 106 | +### `fmd_api.py` |
| 107 | +The foundational API library providing the `FmdApi` class. Handles: |
| 108 | +- Authentication (salt retrieval, Argon2id password hashing, token management) |
| 109 | +- Encrypted private key retrieval and decryption |
| 110 | +- Data blob decryption (RSA-OAEP + AES-GCM) |
| 111 | +- Location and picture retrieval |
| 112 | +- Command sending (request location updates, ring, lock, camera) |
| 113 | + - Commands are cryptographically signed using RSA-PSS to prove authenticity |
| 114 | +
|
| 115 | +**For application developers:** See [LOCATION_FIELDS.md](LOCATION_FIELDS.md) for detailed documentation on extracting and using accuracy, altitude, speed, and heading fields. |
| 116 | +
|
| 117 | +**Quick example:** |
| 118 | +```python |
| 119 | +import asyncio |
| 120 | +import json |
| 121 | +from fmd_api import FmdApi |
| 122 | + |
| 123 | +async def main(): |
| 124 | + # Authenticate (automatically retrieves and decrypts private key) |
| 125 | + api = await FmdApi.create("https://fmd.example.com", "alice", "secret") |
| 126 | + |
| 127 | + # Request a new location update |
| 128 | + await api.request_location('gps') # or 'all', 'cell', 'last' |
| 129 | + await asyncio.sleep(30) # Wait for device to respond |
| 130 | + |
| 131 | + # Get locations |
| 132 | + locations = await api.get_all_locations(num_to_get=10) # Last 10, or -1 for all |
| 133 | + |
| 134 | + # Decrypt a location blob |
| 135 | + decrypted_data = api.decrypt_data_blob(locations[0]) |
| 136 | + location = json.loads(decrypted_data) |
| 137 | + |
| 138 | + # Access fields (use .get() for optional fields) |
| 139 | + lat = location['lat'] |
| 140 | + lon = location['lon'] |
| 141 | + speed = location.get('speed') # Optional, only when moving |
| 142 | + heading = location.get('heading') # Optional, only when moving |
| 143 | + |
| 144 | + # Send commands (see Available Commands section below) |
| 145 | + await api.send_command('ring') # Make device ring |
| 146 | + await api.send_command('bluetooth on') # Enable Bluetooth |
| 147 | + await api.send_command('camera front') # Take picture with front camera |
| 148 | + |
| 149 | +asyncio.run(main()) |
| 150 | +``` |
| 151 | +
|
| 152 | +### Available Commands |
| 153 | +
|
| 154 | +The FMD Android app supports a comprehensive set of commands. You can send them using `api.send_command(command)` or use the convenience methods and constants: |
| 155 | +
|
| 156 | +#### Location Requests |
| 157 | +```python |
| 158 | +# Using convenience method |
| 159 | +await api.request_location('gps') # GPS only |
| 160 | +await api.request_location('all') # All providers (default) |
| 161 | +await api.request_location('cell') # Cellular network only |
| 162 | + |
| 163 | +# Using send_command directly |
| 164 | +await api.send_command('locate gps') |
| 165 | +await api.send_command('locate') |
| 166 | +await api.send_command('locate cell') |
| 167 | +await api.send_command('locate last') # Last known, no new request |
| 168 | + |
| 169 | +# Using constants |
| 170 | +from fmd_api import FmdCommands |
| 171 | +await api.send_command(FmdCommands.LOCATE_GPS) |
| 172 | +``` |
| 173 | +
|
| 174 | +#### Device Control |
| 175 | +```python |
| 176 | +# Ring device |
| 177 | +await api.send_command('ring') |
| 178 | +await api.send_command(FmdCommands.RING) |
| 179 | + |
| 180 | +# Lock device screen |
| 181 | +await api.send_command('lock') |
| 182 | +await api.send_command(FmdCommands.LOCK) |
| 183 | + |
| 184 | +# ⚠️ Delete/wipe device (DESTRUCTIVE - factory reset!) |
| 185 | +await api.send_command('delete') |
| 186 | +await api.send_command(FmdCommands.DELETE) |
| 187 | +``` |
| 188 | +
|
| 189 | +#### Camera |
| 190 | +```python |
| 191 | +# Using convenience method |
| 192 | +await api.take_picture('back') # Rear camera (default) |
| 193 | +await api.take_picture('front') # Front camera (selfie) |
| 194 | + |
| 195 | +# Using send_command |
| 196 | +await api.send_command('camera back') |
| 197 | +await api.send_command('camera front') |
| 198 | + |
| 199 | +# Using constants |
| 200 | +await api.send_command(FmdCommands.CAMERA_BACK) |
| 201 | +await api.send_command(FmdCommands.CAMERA_FRONT) |
| 202 | +``` |
| 203 | +
|
| 204 | +#### Bluetooth |
| 205 | +```python |
| 206 | +# Using convenience method |
| 207 | +await api.toggle_bluetooth(True) # Enable |
| 208 | +await api.toggle_bluetooth(False) # Disable |
| 209 | + |
| 210 | +# Using send_command |
| 211 | +await api.send_command('bluetooth on') |
| 212 | +await api.send_command('bluetooth off') |
| 213 | + |
| 214 | +# Using constants |
| 215 | +await api.send_command(FmdCommands.BLUETOOTH_ON) |
| 216 | +await api.send_command(FmdCommands.BLUETOOTH_OFF) |
| 217 | +``` |
| 218 | +
|
| 219 | +**Note:** Android 12+ requires BLUETOOTH_CONNECT permission. |
| 220 | +
|
| 221 | +#### Do Not Disturb Mode |
| 222 | +```python |
| 223 | +# Using convenience method |
| 224 | +await api.toggle_do_not_disturb(True) # Enable DND |
| 225 | +await api.toggle_do_not_disturb(False) # Disable DND |
| 226 | + |
| 227 | +# Using send_command |
| 228 | +await api.send_command('nodisturb on') |
| 229 | +await api.send_command('nodisturb off') |
| 230 | + |
| 231 | +# Using constants |
| 232 | +await api.send_command(FmdCommands.NODISTURB_ON) |
| 233 | +await api.send_command(FmdCommands.NODISTURB_OFF) |
| 234 | +``` |
| 235 | +
|
| 236 | +**Note:** Requires Do Not Disturb Access permission. |
| 237 | +
|
| 238 | +#### Ringer Mode |
| 239 | +```python |
| 240 | +# Using convenience method |
| 241 | +await api.set_ringer_mode('normal') # Sound + vibrate |
| 242 | +await api.set_ringer_mode('vibrate') # Vibrate only |
| 243 | +await api.set_ringer_mode('silent') # Silent (also enables DND) |
| 244 | + |
| 245 | +# Using send_command |
| 246 | +await api.send_command('ringermode normal') |
| 247 | +await api.send_command('ringermode vibrate') |
| 248 | +await api.send_command('ringermode silent') |
| 249 | + |
| 250 | +# Using constants |
| 251 | +await api.send_command(FmdCommands.RINGERMODE_NORMAL) |
| 252 | +await api.send_command(FmdCommands.RINGERMODE_VIBRATE) |
| 253 | +await api.send_command(FmdCommands.RINGERMODE_SILENT) |
| 254 | +``` |
| 255 | +
|
| 256 | +**Note:** Setting to "silent" also enables Do Not Disturb (Android behavior). Requires Do Not Disturb Access permission. |
| 257 | +
|
| 258 | +#### Device Information |
| 259 | +```python |
| 260 | +# Get network statistics (IP addresses, WiFi SSID/BSSID) |
| 261 | +await api.get_device_stats() |
| 262 | +await api.send_command('stats') |
| 263 | +await api.send_command(FmdCommands.STATS) |
| 264 | + |
| 265 | +# Get battery and GPS status |
| 266 | +await api.send_command('gps') |
| 267 | +await api.send_command(FmdCommands.GPS) |
| 268 | +``` |
| 269 | +
|
| 270 | +**Note:** `stats` command requires Location permission to access WiFi information. |
| 271 | +
|
| 272 | +#### Command Testing Script |
| 273 | +Test any command easily: |
| 274 | +```bash |
| 275 | +cd debugging |
| 276 | +python test_command.py <command> --url <server_url> --id <fmd_id> --password <password> |
| 277 | + |
| 278 | +# Examples |
| 279 | +python test_command.py "ring" --url https://fmd.example.com --id alice --password secret |
| 280 | +python test_command.py "bluetooth on" --url https://fmd.example.com --id alice --password secret |
| 281 | +python test_command.py "ringermode vibrate" --url https://fmd.example.com --id alice --password secret |
| 282 | +``` |
| 283 | +
|
| 284 | +## Troubleshooting |
| 285 | +
|
| 286 | +### Empty or Invalid Blobs |
| 287 | +If you see warnings like `"Blob too small for decryption"`, the server returned empty/corrupted data. This can happen when: |
| 288 | +- No location data was uploaded for that time period |
| 289 | +- Data was deleted or corrupted server-side |
| 290 | +- The server returns placeholder values for missing data |
| 291 | +
|
| 292 | +The client will skip these automatically and report the count at the end. |
| 293 | +
|
| 294 | +### Debugging Decryption Issues |
| 295 | +Use `debugging/diagnose_blob.py` to analyze blob structure: |
| 296 | +```bash |
| 297 | +cd debugging |
| 298 | +python diagnose_blob.py --url <server_url> --id <fmd_id> --password <password> |
| 299 | +``` |
| 300 | +
|
| 301 | +This shows the actual blob size, expected structure, and helps identify if the RSA key size or encryption format has changed. |
| 302 | +
|
| 303 | +## Notes |
| 304 | +- All scripts use Argon2id password hashing and AES-GCM/RSA-OAEP encryption, matching the FMD web client |
| 305 | +- Blobs must be at least 396 bytes (384 RSA session key + 12 IV + ciphertext) to be valid |
| 306 | +- Base64 data from the server may be missing padding - use `_pad_base64()` helper when needed |
| 307 | +- **Location data fields**: |
| 308 | + - Always present: `time`, `provider`, `bat` (battery %), `lat`, `lon`, `date` (Unix ms) |
| 309 | + - Optional (depending on provider): `accuracy` (meters), `altitude` (meters), `speed` (m/s), `heading` (degrees) |
| 310 | +- Picture data is double-encoded: encrypted blob → base64 string → actual image bytes |
| 311 | +
|
| 312 | +## Credits |
| 313 | +
|
| 314 | +This project is a client for the open-source FMD (Find My Device) server. The FMD project provides a decentralized, self-hostable alternative to commercial device tracking services. |
| 315 | +
|
| 316 | +- **[fmd-foss.org](https://fmd-foss.org/)**: The official project website, offering general information, documentation, and news. |
| 317 | +- **[fmd-foss on GitLab](https://gitlab.com/fmd-foss)**: The official GitLab group hosting the source code for the server, Android client, web UI, and other related projects. |
| 318 | +- **[fmd.nulide.de](https://fmd.nulide.de/)**: A generously hosted public instance of the FMD server available for community use. |
0 commit comments