fmd-api 2.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fmd_api-2.0.8/PKG-INFO +286 -0
- fmd_api-2.0.8/README.md +249 -0
- fmd_api-2.0.8/fmd_api/__init__.py +19 -0
- fmd_api-2.0.8/fmd_api/_version.py +1 -0
- fmd_api-2.0.8/fmd_api/client.py +1101 -0
- fmd_api-2.0.8/fmd_api/device.py +179 -0
- fmd_api-2.0.8/fmd_api/exceptions.py +31 -0
- fmd_api-2.0.8/fmd_api/helpers.py +13 -0
- fmd_api-2.0.8/fmd_api/models.py +72 -0
- fmd_api-2.0.8/fmd_api/py.typed +0 -0
- fmd_api-2.0.8/fmd_api/types.py +36 -0
- fmd_api-2.0.8/fmd_api.egg-info/PKG-INFO +286 -0
- fmd_api-2.0.8/fmd_api.egg-info/SOURCES.txt +16 -0
- fmd_api-2.0.8/fmd_api.egg-info/dependency_links.txt +1 -0
- fmd_api-2.0.8/fmd_api.egg-info/requires.txt +13 -0
- fmd_api-2.0.8/fmd_api.egg-info/top_level.txt +3 -0
- fmd_api-2.0.8/pyproject.toml +99 -0
- fmd_api-2.0.8/setup.cfg +4 -0
fmd_api-2.0.8/PKG-INFO
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fmd_api
|
|
3
|
+
Version: 2.0.8
|
|
4
|
+
Summary: A Python client for the FMD (Find My Device) server API
|
|
5
|
+
Author: devinslick
|
|
6
|
+
Project-URL: Homepage, https://github.com/devinslick/fmd_api
|
|
7
|
+
Project-URL: Repository, https://github.com/devinslick/fmd_api
|
|
8
|
+
Project-URL: Issues, https://github.com/devinslick/fmd_api/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/devinslick/fmd_api#readme
|
|
10
|
+
Keywords: fmd,find-my-device,location,tracking,device-tracking,api-client
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Development Status :: 4 - Beta
|
|
20
|
+
Classifier: Intended Audience :: Developers
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: argon2-cffi
|
|
26
|
+
Requires-Dist: cryptography
|
|
27
|
+
Requires-Dist: aiohttp
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: aioresponses>=0.7.0; extra == "dev"
|
|
33
|
+
Requires-Dist: black; extra == "dev"
|
|
34
|
+
Requires-Dist: flake8; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy; extra == "dev"
|
|
36
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
37
|
+
|
|
38
|
+
# fmd_api: Python client for FMD (Find My Device)
|
|
39
|
+
|
|
40
|
+
[](https://github.com/devinslick/fmd_api/actions/workflows/test.yml)
|
|
41
|
+
[](https://codecov.io/gh/devinslick/fmd_api)
|
|
42
|
+
[](https://pypi.org/project/fmd-api/)
|
|
43
|
+
|
|
44
|
+
Modern, async Python client for the open‑source FMD (Find My Device) server. It handles authentication, key management, encrypted data decryption, location/picture retrieval, and common device commands with safe, validated helpers.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
- Requires Python 3.8+
|
|
49
|
+
- Stable (PyPI):
|
|
50
|
+
```bash
|
|
51
|
+
pip install fmd_api
|
|
52
|
+
```
|
|
53
|
+
<!-- Pre-release via TestPyPI removed. Use stable releases from PyPI or GitHub Releases for pre-release artifacts -->
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio, json
|
|
59
|
+
from fmd_api import FmdClient
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
# Recommended: async context manager auto-closes session
|
|
63
|
+
async with await FmdClient.create("https://fmd.example.com", "alice", "secret", drop_password=True) as client:
|
|
64
|
+
# Request a fresh GPS fix and wait a bit on your side
|
|
65
|
+
await client.request_location("gps")
|
|
66
|
+
|
|
67
|
+
# Fetch most recent locations and decrypt the latest
|
|
68
|
+
blobs = await client.get_locations(num_to_get=1)
|
|
69
|
+
# decrypt_data_blob() returns raw bytes — decode then parse JSON for clarity
|
|
70
|
+
decrypted = client.decrypt_data_blob(blobs[0])
|
|
71
|
+
loc = json.loads(decrypted.decode("utf-8"))
|
|
72
|
+
print(loc["lat"], loc["lon"], loc.get("accuracy"))
|
|
73
|
+
|
|
74
|
+
# Take a picture (validated helper)
|
|
75
|
+
await client.take_picture("front")
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### TLS and self-signed certificates
|
|
81
|
+
|
|
82
|
+
Find My Device always requires HTTPS; plain HTTP is not allowed by this client. If you need to connect to a server with a self-signed certificate, you have two options:
|
|
83
|
+
|
|
84
|
+
- Preferred (secure): provide a custom SSLContext that trusts your CA or certificate
|
|
85
|
+
- Last resort (not for production): disable certificate validation explicitly
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import ssl
|
|
91
|
+
from fmd_api import FmdClient
|
|
92
|
+
|
|
93
|
+
# 1) Custom CA bundle / pinned cert (recommended)
|
|
94
|
+
ctx = ssl.create_default_context()
|
|
95
|
+
ctx.load_verify_locations(cafile="/path/to/your/ca.pem")
|
|
96
|
+
|
|
97
|
+
# Via constructor
|
|
98
|
+
client = FmdClient("https://fmd.example.com", ssl=ctx)
|
|
99
|
+
|
|
100
|
+
# Or via factory
|
|
101
|
+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
|
|
102
|
+
|
|
103
|
+
# 2) Disable verification (development only)
|
|
104
|
+
insecure_client = FmdClient("https://fmd.example.com", ssl=False)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Notes:
|
|
108
|
+
- HTTP (http://) is rejected. Use only HTTPS URLs.
|
|
109
|
+
- Prefer a custom SSLContext over disabling verification.
|
|
110
|
+
- For higher security, consider pinning the server cert in your context.
|
|
111
|
+
|
|
112
|
+
> Warning
|
|
113
|
+
>
|
|
114
|
+
> Passing `ssl=False` disables TLS certificate validation and should only be used in development. For production, use a custom `ssl.SSLContext` that trusts your CA/certificate or pin the server certificate. The client enforces HTTPS and rejects `http://` URLs.
|
|
115
|
+
|
|
116
|
+
#### Pinning the exact server certificate (recommended for self-signed)
|
|
117
|
+
|
|
118
|
+
If you're using a self-signed certificate and want to pin to that exact cert, load the server's PEM (or DER) directly into an SSLContext. This ensures only that certificate (or its CA) is trusted.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import ssl
|
|
122
|
+
from fmd_api import FmdClient
|
|
123
|
+
|
|
124
|
+
# Export your server's certificate to PEM (e.g., server-cert.pem)
|
|
125
|
+
ctx = ssl.create_default_context()
|
|
126
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
127
|
+
ctx.check_hostname = True # keep hostname verification when possible
|
|
128
|
+
ctx.load_verify_locations(cafile="/path/to/server-cert.pem")
|
|
129
|
+
|
|
130
|
+
client = FmdClient("https://fmd.example.com", ssl=ctx)
|
|
131
|
+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Tips:
|
|
135
|
+
- If the server cert changes, pinning will fail until you update the PEM.
|
|
136
|
+
- For intermediate/CA signing chains, prefer pinning a private CA instead of the leaf.
|
|
137
|
+
|
|
138
|
+
## What’s in the box
|
|
139
|
+
|
|
140
|
+
- `FmdClient` (primary API)
|
|
141
|
+
- Auth and key retrieval (salt → Argon2id → access token → private key retrieval and decryption)
|
|
142
|
+
- Decrypt blobs (RSA‑OAEP wrapped AES‑GCM)
|
|
143
|
+
- Fetch data: `get_locations`, `get_pictures`
|
|
144
|
+
- Export: `export_data_zip(out_path)` — client-side packaging of all locations/pictures into ZIP (mimics web UI, no server endpoint)
|
|
145
|
+
- Validated command helpers:
|
|
146
|
+
- `request_location("all|gps|cell|last")`
|
|
147
|
+
- `take_picture("front|back")`
|
|
148
|
+
- `set_bluetooth(enable: bool)` — True = on, False = off
|
|
149
|
+
- `set_do_not_disturb(enable: bool)` — True = on, False = off
|
|
150
|
+
- `set_ringer_mode("normal|vibrate|silent")`
|
|
151
|
+
|
|
152
|
+
> **Note:** Device statistics functionality (`get_device_stats()`) has been temporarily removed and will be restored when the FMD server supports it (see [fmd-server#74](https://gitlab.com/fmd-foss/fmd-server/-/issues/74)).
|
|
153
|
+
|
|
154
|
+
- Low‑level: `decrypt_data_blob(b64_blob)`
|
|
155
|
+
|
|
156
|
+
- `Device` helper (per‑device convenience)
|
|
157
|
+
- `await device.refresh()` → hydrate cached state
|
|
158
|
+
- `await device.get_location()` → parsed last location
|
|
159
|
+
- `await device.get_picture_blobs(n)` + `await device.decode_picture(blob)`
|
|
160
|
+
- `await device.get_picture_metadata(n)` -> returns only metadata dicts (if the server exposes them)
|
|
161
|
+
|
|
162
|
+
IMPORTANT (breaking change in v2.0.5): legacy compatibility wrappers were removed.
|
|
163
|
+
The following legacy methods were removed from the `Device` API: `fetch_pictures`,
|
|
164
|
+
`get_pictures`, `download_photo`, `get_picture`, `take_front_photo`, and `take_rear_photo`.
|
|
165
|
+
Update your code to use `get_picture_blobs()`, `decode_picture()`, `take_front_picture()`
|
|
166
|
+
and `take_rear_picture()` instead.
|
|
167
|
+
- Commands: `await device.play_sound()`, `await device.take_front_picture()`,
|
|
168
|
+
`await device.take_rear_picture()`, `await device.lock(message=None)`,
|
|
169
|
+
`await device.wipe(pin="YourSecurePIN", confirm=True)`
|
|
170
|
+
Note: wipe requires the FMD PIN (alphanumeric ASCII, no spaces) and must be enabled in the Android app's General settings.
|
|
171
|
+
Future versions may enforce a 16+ character PIN length ([fmd-android#379](https://gitlab.com/fmd-foss/fmd-android/-/merge_requests/379)).
|
|
172
|
+
|
|
173
|
+
### Example: Lock device with a message
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import asyncio
|
|
177
|
+
from fmd_api import FmdClient, Device
|
|
178
|
+
|
|
179
|
+
async def main():
|
|
180
|
+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
|
|
181
|
+
device = Device(client, "alice")
|
|
182
|
+
# Optional message is sanitized (quotes/newlines removed, whitespace collapsed)
|
|
183
|
+
await device.lock(message="Lost phone. Please call +1-555-555-1234")
|
|
184
|
+
await client.close()
|
|
185
|
+
|
|
186
|
+
asyncio.run(main())
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Example: Inspect pictures metadata (when available)
|
|
190
|
+
|
|
191
|
+
Use `get_picture_blobs()` to fetch the raw server responses (strings or dicts). If you want a
|
|
192
|
+
strongly-typed list of picture metadata objects (where the server provides metadata as JSON
|
|
193
|
+
objects), use `get_picture_metadata()`, which filters for dict entries and returns only those.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from fmd_api import FmdClient, Device
|
|
197
|
+
|
|
198
|
+
async def inspect_metadata():
|
|
199
|
+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
|
|
200
|
+
device = Device(client, "alice")
|
|
201
|
+
|
|
202
|
+
# Raw values may be strings (base64 blobs) or dicts (metadata). Keep raw when you need
|
|
203
|
+
# to decode or handle both forms yourself.
|
|
204
|
+
raw = await device.get_picture_blobs(10)
|
|
205
|
+
|
|
206
|
+
# If you want only metadata entries returned by the server, use get_picture_metadata().
|
|
207
|
+
# This returns a list of dict-like metadata objects (e.g. id/date/filename) and filters
|
|
208
|
+
# out any raw string blobs.
|
|
209
|
+
metadata = await device.get_picture_metadata(10)
|
|
210
|
+
for m in metadata:
|
|
211
|
+
print(m.get("id"), m.get("date"))
|
|
212
|
+
|
|
213
|
+
await client.close()
|
|
214
|
+
|
|
215
|
+
asyncio.run(inspect_metadata())
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
### Functional tests
|
|
221
|
+
|
|
222
|
+
Runnable scripts under `tests/functional/`:
|
|
223
|
+
|
|
224
|
+
- `test_auth.py` – basic auth smoke test
|
|
225
|
+
- `test_locations.py` – list and decrypt recent locations
|
|
226
|
+
- `test_pictures.py` – list and download/decrypt a photo
|
|
227
|
+
- `test_device.py` – device helper flows
|
|
228
|
+
- `test_commands.py` – validated command wrappers (no raw strings)
|
|
229
|
+
- `test_export.py` – export data to ZIP
|
|
230
|
+
- `test_request_location.py` – request location and poll for results
|
|
231
|
+
|
|
232
|
+
Put credentials in `tests/utils/credentials.txt` (copy from `credentials.txt.example`).
|
|
233
|
+
|
|
234
|
+
### Unit tests
|
|
235
|
+
|
|
236
|
+
Located in `tests/unit/`:
|
|
237
|
+
- `test_client.py` – client HTTP flows with mocked responses
|
|
238
|
+
- `test_device.py` – device wrapper logic
|
|
239
|
+
|
|
240
|
+
Run with pytest:
|
|
241
|
+
```bash
|
|
242
|
+
pip install -e ".[dev]"
|
|
243
|
+
pytest tests/unit/
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## API highlights
|
|
247
|
+
|
|
248
|
+
- Encryption compatible with FMD web client
|
|
249
|
+
- RSA‑3072 OAEP (SHA‑256) wrapping AES‑GCM session key
|
|
250
|
+
- AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes
|
|
251
|
+
- Password/key derivation with Argon2id
|
|
252
|
+
- Robust HTTP JSON/text fallback and 401 re‑auth
|
|
253
|
+
- Supports password-free resume via exported auth artifacts (hash + token + private key)
|
|
254
|
+
|
|
255
|
+
### Advanced: Password-Free Resume
|
|
256
|
+
|
|
257
|
+
You can onboard once with a raw password, optionally discard it immediately using `drop_password=True`, export authentication artifacts, and later resume without storing the raw secret:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
client = await FmdClient.create(url, fmd_id, password, drop_password=True)
|
|
261
|
+
artifacts = await client.export_auth_artifacts()
|
|
262
|
+
|
|
263
|
+
# Persist `artifacts` securely (contains hash, token, private key)
|
|
264
|
+
|
|
265
|
+
# Later / after restart
|
|
266
|
+
client2 = await FmdClient.from_auth_artifacts(artifacts)
|
|
267
|
+
locations = await client2.get_locations(1)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
On a 401, the client will transparently reauthenticate using the stored Argon2id `password_hash` if available. When `drop_password=True`, the raw password is never retained after initial onboarding.
|
|
271
|
+
|
|
272
|
+
## Troubleshooting
|
|
273
|
+
|
|
274
|
+
- "Blob too small for decryption": server returned empty/placeholder data. Skip and continue.
|
|
275
|
+
- Pictures may be double‑encoded (encrypted blob → base64 image string). The examples show how to decode safely.
|
|
276
|
+
|
|
277
|
+
## Credits
|
|
278
|
+
|
|
279
|
+
This client targets the FMD ecosystem:
|
|
280
|
+
|
|
281
|
+
- https://fmd-foss.org/
|
|
282
|
+
- https://gitlab.com/fmd-foss
|
|
283
|
+
- Public community instance: https://server.fmd-foss.org/
|
|
284
|
+
- Listed on the official FMD community page: https://fmd-foss.org/docs/fmd-server/community
|
|
285
|
+
|
|
286
|
+
MIT © 2025 Devin Slick
|
fmd_api-2.0.8/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# fmd_api: Python client for FMD (Find My Device)
|
|
2
|
+
|
|
3
|
+
[](https://github.com/devinslick/fmd_api/actions/workflows/test.yml)
|
|
4
|
+
[](https://codecov.io/gh/devinslick/fmd_api)
|
|
5
|
+
[](https://pypi.org/project/fmd-api/)
|
|
6
|
+
|
|
7
|
+
Modern, async Python client for the open‑source FMD (Find My Device) server. It handles authentication, key management, encrypted data decryption, location/picture retrieval, and common device commands with safe, validated helpers.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
- Requires Python 3.8+
|
|
12
|
+
- Stable (PyPI):
|
|
13
|
+
```bash
|
|
14
|
+
pip install fmd_api
|
|
15
|
+
```
|
|
16
|
+
<!-- Pre-release via TestPyPI removed. Use stable releases from PyPI or GitHub Releases for pre-release artifacts -->
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio, json
|
|
22
|
+
from fmd_api import FmdClient
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
# Recommended: async context manager auto-closes session
|
|
26
|
+
async with await FmdClient.create("https://fmd.example.com", "alice", "secret", drop_password=True) as client:
|
|
27
|
+
# Request a fresh GPS fix and wait a bit on your side
|
|
28
|
+
await client.request_location("gps")
|
|
29
|
+
|
|
30
|
+
# Fetch most recent locations and decrypt the latest
|
|
31
|
+
blobs = await client.get_locations(num_to_get=1)
|
|
32
|
+
# decrypt_data_blob() returns raw bytes — decode then parse JSON for clarity
|
|
33
|
+
decrypted = client.decrypt_data_blob(blobs[0])
|
|
34
|
+
loc = json.loads(decrypted.decode("utf-8"))
|
|
35
|
+
print(loc["lat"], loc["lon"], loc.get("accuracy"))
|
|
36
|
+
|
|
37
|
+
# Take a picture (validated helper)
|
|
38
|
+
await client.take_picture("front")
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### TLS and self-signed certificates
|
|
44
|
+
|
|
45
|
+
Find My Device always requires HTTPS; plain HTTP is not allowed by this client. If you need to connect to a server with a self-signed certificate, you have two options:
|
|
46
|
+
|
|
47
|
+
- Preferred (secure): provide a custom SSLContext that trusts your CA or certificate
|
|
48
|
+
- Last resort (not for production): disable certificate validation explicitly
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import ssl
|
|
54
|
+
from fmd_api import FmdClient
|
|
55
|
+
|
|
56
|
+
# 1) Custom CA bundle / pinned cert (recommended)
|
|
57
|
+
ctx = ssl.create_default_context()
|
|
58
|
+
ctx.load_verify_locations(cafile="/path/to/your/ca.pem")
|
|
59
|
+
|
|
60
|
+
# Via constructor
|
|
61
|
+
client = FmdClient("https://fmd.example.com", ssl=ctx)
|
|
62
|
+
|
|
63
|
+
# Or via factory
|
|
64
|
+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
|
|
65
|
+
|
|
66
|
+
# 2) Disable verification (development only)
|
|
67
|
+
insecure_client = FmdClient("https://fmd.example.com", ssl=False)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Notes:
|
|
71
|
+
- HTTP (http://) is rejected. Use only HTTPS URLs.
|
|
72
|
+
- Prefer a custom SSLContext over disabling verification.
|
|
73
|
+
- For higher security, consider pinning the server cert in your context.
|
|
74
|
+
|
|
75
|
+
> Warning
|
|
76
|
+
>
|
|
77
|
+
> Passing `ssl=False` disables TLS certificate validation and should only be used in development. For production, use a custom `ssl.SSLContext` that trusts your CA/certificate or pin the server certificate. The client enforces HTTPS and rejects `http://` URLs.
|
|
78
|
+
|
|
79
|
+
#### Pinning the exact server certificate (recommended for self-signed)
|
|
80
|
+
|
|
81
|
+
If you're using a self-signed certificate and want to pin to that exact cert, load the server's PEM (or DER) directly into an SSLContext. This ensures only that certificate (or its CA) is trusted.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import ssl
|
|
85
|
+
from fmd_api import FmdClient
|
|
86
|
+
|
|
87
|
+
# Export your server's certificate to PEM (e.g., server-cert.pem)
|
|
88
|
+
ctx = ssl.create_default_context()
|
|
89
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
90
|
+
ctx.check_hostname = True # keep hostname verification when possible
|
|
91
|
+
ctx.load_verify_locations(cafile="/path/to/server-cert.pem")
|
|
92
|
+
|
|
93
|
+
client = FmdClient("https://fmd.example.com", ssl=ctx)
|
|
94
|
+
# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client:
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Tips:
|
|
98
|
+
- If the server cert changes, pinning will fail until you update the PEM.
|
|
99
|
+
- For intermediate/CA signing chains, prefer pinning a private CA instead of the leaf.
|
|
100
|
+
|
|
101
|
+
## What’s in the box
|
|
102
|
+
|
|
103
|
+
- `FmdClient` (primary API)
|
|
104
|
+
- Auth and key retrieval (salt → Argon2id → access token → private key retrieval and decryption)
|
|
105
|
+
- Decrypt blobs (RSA‑OAEP wrapped AES‑GCM)
|
|
106
|
+
- Fetch data: `get_locations`, `get_pictures`
|
|
107
|
+
- Export: `export_data_zip(out_path)` — client-side packaging of all locations/pictures into ZIP (mimics web UI, no server endpoint)
|
|
108
|
+
- Validated command helpers:
|
|
109
|
+
- `request_location("all|gps|cell|last")`
|
|
110
|
+
- `take_picture("front|back")`
|
|
111
|
+
- `set_bluetooth(enable: bool)` — True = on, False = off
|
|
112
|
+
- `set_do_not_disturb(enable: bool)` — True = on, False = off
|
|
113
|
+
- `set_ringer_mode("normal|vibrate|silent")`
|
|
114
|
+
|
|
115
|
+
> **Note:** Device statistics functionality (`get_device_stats()`) has been temporarily removed and will be restored when the FMD server supports it (see [fmd-server#74](https://gitlab.com/fmd-foss/fmd-server/-/issues/74)).
|
|
116
|
+
|
|
117
|
+
- Low‑level: `decrypt_data_blob(b64_blob)`
|
|
118
|
+
|
|
119
|
+
- `Device` helper (per‑device convenience)
|
|
120
|
+
- `await device.refresh()` → hydrate cached state
|
|
121
|
+
- `await device.get_location()` → parsed last location
|
|
122
|
+
- `await device.get_picture_blobs(n)` + `await device.decode_picture(blob)`
|
|
123
|
+
- `await device.get_picture_metadata(n)` -> returns only metadata dicts (if the server exposes them)
|
|
124
|
+
|
|
125
|
+
IMPORTANT (breaking change in v2.0.5): legacy compatibility wrappers were removed.
|
|
126
|
+
The following legacy methods were removed from the `Device` API: `fetch_pictures`,
|
|
127
|
+
`get_pictures`, `download_photo`, `get_picture`, `take_front_photo`, and `take_rear_photo`.
|
|
128
|
+
Update your code to use `get_picture_blobs()`, `decode_picture()`, `take_front_picture()`
|
|
129
|
+
and `take_rear_picture()` instead.
|
|
130
|
+
- Commands: `await device.play_sound()`, `await device.take_front_picture()`,
|
|
131
|
+
`await device.take_rear_picture()`, `await device.lock(message=None)`,
|
|
132
|
+
`await device.wipe(pin="YourSecurePIN", confirm=True)`
|
|
133
|
+
Note: wipe requires the FMD PIN (alphanumeric ASCII, no spaces) and must be enabled in the Android app's General settings.
|
|
134
|
+
Future versions may enforce a 16+ character PIN length ([fmd-android#379](https://gitlab.com/fmd-foss/fmd-android/-/merge_requests/379)).
|
|
135
|
+
|
|
136
|
+
### Example: Lock device with a message
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
import asyncio
|
|
140
|
+
from fmd_api import FmdClient, Device
|
|
141
|
+
|
|
142
|
+
async def main():
|
|
143
|
+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
|
|
144
|
+
device = Device(client, "alice")
|
|
145
|
+
# Optional message is sanitized (quotes/newlines removed, whitespace collapsed)
|
|
146
|
+
await device.lock(message="Lost phone. Please call +1-555-555-1234")
|
|
147
|
+
await client.close()
|
|
148
|
+
|
|
149
|
+
asyncio.run(main())
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Example: Inspect pictures metadata (when available)
|
|
153
|
+
|
|
154
|
+
Use `get_picture_blobs()` to fetch the raw server responses (strings or dicts). If you want a
|
|
155
|
+
strongly-typed list of picture metadata objects (where the server provides metadata as JSON
|
|
156
|
+
objects), use `get_picture_metadata()`, which filters for dict entries and returns only those.
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from fmd_api import FmdClient, Device
|
|
160
|
+
|
|
161
|
+
async def inspect_metadata():
|
|
162
|
+
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
|
|
163
|
+
device = Device(client, "alice")
|
|
164
|
+
|
|
165
|
+
# Raw values may be strings (base64 blobs) or dicts (metadata). Keep raw when you need
|
|
166
|
+
# to decode or handle both forms yourself.
|
|
167
|
+
raw = await device.get_picture_blobs(10)
|
|
168
|
+
|
|
169
|
+
# If you want only metadata entries returned by the server, use get_picture_metadata().
|
|
170
|
+
# This returns a list of dict-like metadata objects (e.g. id/date/filename) and filters
|
|
171
|
+
# out any raw string blobs.
|
|
172
|
+
metadata = await device.get_picture_metadata(10)
|
|
173
|
+
for m in metadata:
|
|
174
|
+
print(m.get("id"), m.get("date"))
|
|
175
|
+
|
|
176
|
+
await client.close()
|
|
177
|
+
|
|
178
|
+
asyncio.run(inspect_metadata())
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
### Functional tests
|
|
184
|
+
|
|
185
|
+
Runnable scripts under `tests/functional/`:
|
|
186
|
+
|
|
187
|
+
- `test_auth.py` – basic auth smoke test
|
|
188
|
+
- `test_locations.py` – list and decrypt recent locations
|
|
189
|
+
- `test_pictures.py` – list and download/decrypt a photo
|
|
190
|
+
- `test_device.py` – device helper flows
|
|
191
|
+
- `test_commands.py` – validated command wrappers (no raw strings)
|
|
192
|
+
- `test_export.py` – export data to ZIP
|
|
193
|
+
- `test_request_location.py` – request location and poll for results
|
|
194
|
+
|
|
195
|
+
Put credentials in `tests/utils/credentials.txt` (copy from `credentials.txt.example`).
|
|
196
|
+
|
|
197
|
+
### Unit tests
|
|
198
|
+
|
|
199
|
+
Located in `tests/unit/`:
|
|
200
|
+
- `test_client.py` – client HTTP flows with mocked responses
|
|
201
|
+
- `test_device.py` – device wrapper logic
|
|
202
|
+
|
|
203
|
+
Run with pytest:
|
|
204
|
+
```bash
|
|
205
|
+
pip install -e ".[dev]"
|
|
206
|
+
pytest tests/unit/
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## API highlights
|
|
210
|
+
|
|
211
|
+
- Encryption compatible with FMD web client
|
|
212
|
+
- RSA‑3072 OAEP (SHA‑256) wrapping AES‑GCM session key
|
|
213
|
+
- AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes
|
|
214
|
+
- Password/key derivation with Argon2id
|
|
215
|
+
- Robust HTTP JSON/text fallback and 401 re‑auth
|
|
216
|
+
- Supports password-free resume via exported auth artifacts (hash + token + private key)
|
|
217
|
+
|
|
218
|
+
### Advanced: Password-Free Resume
|
|
219
|
+
|
|
220
|
+
You can onboard once with a raw password, optionally discard it immediately using `drop_password=True`, export authentication artifacts, and later resume without storing the raw secret:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
client = await FmdClient.create(url, fmd_id, password, drop_password=True)
|
|
224
|
+
artifacts = await client.export_auth_artifacts()
|
|
225
|
+
|
|
226
|
+
# Persist `artifacts` securely (contains hash, token, private key)
|
|
227
|
+
|
|
228
|
+
# Later / after restart
|
|
229
|
+
client2 = await FmdClient.from_auth_artifacts(artifacts)
|
|
230
|
+
locations = await client2.get_locations(1)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
On a 401, the client will transparently reauthenticate using the stored Argon2id `password_hash` if available. When `drop_password=True`, the raw password is never retained after initial onboarding.
|
|
234
|
+
|
|
235
|
+
## Troubleshooting
|
|
236
|
+
|
|
237
|
+
- "Blob too small for decryption": server returned empty/placeholder data. Skip and continue.
|
|
238
|
+
- Pictures may be double‑encoded (encrypted blob → base64 image string). The examples show how to decode safely.
|
|
239
|
+
|
|
240
|
+
## Credits
|
|
241
|
+
|
|
242
|
+
This client targets the FMD ecosystem:
|
|
243
|
+
|
|
244
|
+
- https://fmd-foss.org/
|
|
245
|
+
- https://gitlab.com/fmd-foss
|
|
246
|
+
- Public community instance: https://server.fmd-foss.org/
|
|
247
|
+
- Listed on the official FMD community page: https://fmd-foss.org/docs/fmd-server/community
|
|
248
|
+
|
|
249
|
+
MIT © 2025 Devin Slick
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# fmd_api package exports
|
|
2
|
+
from .client import FmdClient
|
|
3
|
+
from .device import Device
|
|
4
|
+
from .models import Location, PhotoResult
|
|
5
|
+
from .exceptions import FmdApiException, AuthenticationError, DeviceNotFoundError, OperationError, RateLimitError
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"FmdClient",
|
|
10
|
+
"Device",
|
|
11
|
+
"Location",
|
|
12
|
+
"PhotoResult",
|
|
13
|
+
"FmdApiException",
|
|
14
|
+
"AuthenticationError",
|
|
15
|
+
"DeviceNotFoundError",
|
|
16
|
+
"OperationError",
|
|
17
|
+
"RateLimitError",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.8"
|