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 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
+ [![Tests](https://github.com/devinslick/fmd_api/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/devinslick/fmd_api/actions/workflows/test.yml)
41
+ [![codecov](https://codecov.io/gh/devinslick/fmd_api/branch/main/graph/badge.svg?token=8WA2TKXIOW)](https://codecov.io/gh/devinslick/fmd_api)
42
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/fmd-api)](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
@@ -0,0 +1,249 @@
1
+ # fmd_api: Python client for FMD (Find My Device)
2
+
3
+ [![Tests](https://github.com/devinslick/fmd_api/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/devinslick/fmd_api/actions/workflows/test.yml)
4
+ [![codecov](https://codecov.io/gh/devinslick/fmd_api/branch/main/graph/badge.svg?token=8WA2TKXIOW)](https://codecov.io/gh/devinslick/fmd_api)
5
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/fmd-api)](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"