Skip to content

Commit a094da0

Browse files
committed
Add HTTP timeouts, fix version format, add type marker, improve export
Critical fixes for Home Assistant integration: - Add configurable HTTP request timeouts (default 30s) to all requests - Fix version format inconsistency (_version.py now uses PEP 440 dot notation) - Add py.typed marker file for PEP 561 compliance - Reimplement export_data_zip for client-side packaging (no server endpoint) - Extract pictures as actual image files with format detection - Remove redundant encrypted blobs from exports Changes: - FmdClient: Add timeout parameter to __init__, create(), and _make_api_request() - Export: Fetch locations/pictures via existing APIs, decrypt and package into ZIP - Export: Include info.json, locations.json, pictures/*.jpg with manifest - Tests: Add test_timeout_configuration() and update export tests - Documentation: Update HOME_ASSISTANT_REVIEW.md with fix details
1 parent 8f25b6d commit a094da0

File tree

7 files changed

+235
-44
lines changed

7 files changed

+235
-44
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ asyncio.run(main())
4747
- `FmdClient` (primary API)
4848
- Auth and key retrieval (salt → Argon2id → access token → private key decrypt)
4949
- Decrypt blobs (RSA‑OAEP wrapped AES‑GCM)
50-
- Fetch data: `get_locations`, `get_pictures`, `export_data_zip`
50+
- Fetch data: `get_locations`, `get_pictures`
51+
- Export: `export_data_zip(out_path)` — client-side packaging of all locations/pictures into ZIP (mimics web UI, no server endpoint)
5152
- Validated command helpers:
5253
- `request_location("all|gps|cell|last")`
5354
- `take_picture("front|back")`

docs/HOME_ASSISTANT_REVIEW.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ async def _make_api_request(self, ..., timeout: int = 30):
4949

5050
**HA Rationale:** All network calls MUST have timeouts. This is a hard requirement for HA integrations.
5151

52-
**Status:** ❌ TODO
52+
**Status:** ✅ FIXED
53+
- Added `timeout` parameter to `FmdClient.__init__()` with default of 30 seconds
54+
- Applied timeout to all HTTP requests in `_make_api_request()`, `get_pictures()`, and `export_data_zip()`
55+
- Timeout can be overridden at client level or per-request
56+
- Added test coverage with `test_timeout_configuration()`
57+
- All 51 unit tests pass
5358

5459
---
5560

@@ -79,7 +84,9 @@ async def _make_api_request(self, ..., timeout: int = 30):
7984

8085
**HA Rationale:** Version inconsistencies cause packaging and dependency resolution issues.
8186

82-
**Status:** ❌ TODO
87+
**Status:** ✅ FIXED
88+
- Changed `_version.py` from "2.0.0-dev9" to "2.0.0.dev9" (PEP 440 compliant)
89+
- Both files now use consistent dot notation
8390

8491
---
8592

@@ -111,7 +118,9 @@ if resp.status == 429:
111118

112119
**HA Rationale:** Type hints are required for HA integrations. The `py.typed` marker enables type checking for library users.
113120

114-
**Status:** ❌ TODO
121+
**Status:** ✅ FIXED
122+
- Created empty `fmd_api/py.typed` marker file
123+
- Type checkers will now recognize the package's type hints per PEP 561
115124

116125
---
117126

fmd_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.0-dev9"
1+
__version__ = "2.0.0.dev10"

fmd_api/client.py

Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@
4040

4141

4242
class FmdClient:
43-
def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: int = 30):
43+
def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: int = 30, timeout: float = 30.0):
4444
self.base_url = base_url.rstrip('/')
4545
self.session_duration = session_duration
4646
self.cache_ttl = cache_ttl
47+
self.timeout = timeout # default timeout for all HTTP requests (seconds)
4748

4849
self._fmd_id: Optional[str] = None
4950
self._password: Optional[str] = None
@@ -53,8 +54,17 @@ def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: in
5354
self._session: Optional[aiohttp.ClientSession] = None
5455

5556
@classmethod
56-
async def create(cls, base_url: str, fmd_id: str, password: str, session_duration: int = 3600):
57-
inst = cls(base_url, session_duration)
57+
async def create(
58+
cls,
59+
base_url: str,
60+
fmd_id: str,
61+
password: str,
62+
session_duration: int = 3600,
63+
*,
64+
cache_ttl: int = 30,
65+
timeout: float = 30.0
66+
):
67+
inst = cls(base_url, session_duration, cache_ttl=cache_ttl, timeout=timeout)
5868
inst._fmd_id = fmd_id
5969
inst._password = password
6070
await inst.authenticate(fmd_id, password, session_duration)
@@ -170,15 +180,16 @@ def decrypt_data_blob(self, data_b64: str) -> bytes:
170180
# HTTP helper
171181
# -------------------------
172182
async def _make_api_request(self, method: str, endpoint: str, payload: Any,
173-
stream: bool = False, expect_json: bool = True, retry_auth: bool = True):
183+
stream: bool = False, expect_json: bool = True, retry_auth: bool = True, timeout: Optional[float] = None):
174184
"""
175185
Makes an API request and returns Data or text depending on expect_json/stream.
176186
Mirrors get_all_locations/_make_api_request logic from original file (including 401 re-auth).
177187
"""
178188
url = self.base_url + endpoint
179189
await self._ensure_session()
190+
req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout)
180191
try:
181-
async with self._session.request(method, url, json=payload) as resp:
192+
async with self._session.request(method, url, json=payload, timeout=req_timeout) as resp:
182193
# Handle 401 -> re-authenticate once
183194
if resp.status == 401 and retry_auth and self._fmd_id and self._password:
184195
log.info("Received 401 Unauthorized, re-authenticating...")
@@ -187,7 +198,7 @@ async def _make_api_request(self, method: str, endpoint: str, payload: Any,
187198
payload["IDT"] = self.access_token
188199
return await self._make_api_request(
189200
method, endpoint, payload, stream, expect_json,
190-
retry_auth=False)
201+
retry_auth=False, timeout=timeout)
191202

192203
resp.raise_for_status()
193204
log.debug(
@@ -294,13 +305,15 @@ async def get_locations(
294305

295306
return locations
296307

297-
async def get_pictures(self, num_to_get: int = -1) -> List[Any]:
308+
async def get_pictures(self, num_to_get: int = -1, timeout: Optional[float] = None) -> List[Any]:
298309
"""Fetches all or the N most recent picture metadata blobs (raw server response)."""
310+
req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout)
299311
try:
300312
await self._ensure_session()
301313
async with self._session.put(
302314
f"{self.base_url}/api/v1/pictures",
303-
json={"IDT": self.access_token, "Data": ""}) as resp:
315+
json={"IDT": self.access_token, "Data": ""},
316+
timeout=req_timeout) as resp:
304317
resp.raise_for_status()
305318
json_data = await resp.json()
306319
# Extract the Data field if it exists, otherwise use the response as-is
@@ -322,30 +335,118 @@ async def get_pictures(self, num_to_get: int = -1) -> List[Any]:
322335
log.info(f"Found {len(all_pictures)} pictures. Selecting the {num_to_download} most recent.")
323336
return all_pictures[-num_to_download:][::-1]
324337

325-
async def export_data_zip(self, out_path: str, session_duration: int = 3600, fallback: bool = True):
326-
url = f"{self.base_url}/api/v1/exportData"
338+
async def export_data_zip(self, out_path: str, include_pictures: bool = True) -> str:
339+
"""
340+
Export all account data to a ZIP file (client-side packaging).
341+
342+
This mimics the FMD web UI's export functionality by fetching all locations
343+
and pictures via the existing API endpoints, decrypting them, and packaging
344+
them into a user-friendly ZIP file.
345+
346+
NOTE: There is no server-side /api/v1/exportData endpoint. This method
347+
performs client-side data collection, decryption, and packaging, similar
348+
to how the web UI implements its export feature.
349+
350+
ZIP Contents:
351+
- info.json: Export metadata (date, device ID, counts)
352+
- locations.json: Decrypted location data (human-readable JSON)
353+
- pictures/picture_NNNN.jpg: Extracted picture files
354+
- pictures/manifest.json: Picture metadata (filename, size, index)
355+
356+
Args:
357+
out_path: Path where the ZIP file will be saved
358+
include_pictures: Whether to include pictures in the export (default: True)
359+
360+
Returns:
361+
Path to the created ZIP file
362+
363+
Raises:
364+
FmdApiException: If data fetching or ZIP creation fails
365+
"""
366+
import zipfile
367+
from datetime import datetime
368+
327369
try:
328-
await self._ensure_session()
329-
# POST with session request; stream the response to file
330-
async with self._session.post(url, json={"session": session_duration}) as resp:
331-
if resp.status == 404:
332-
raise FmdApiException("exportData endpoint not found (404)")
333-
resp.raise_for_status()
334-
# stream to file
335-
with open(out_path, "wb") as f:
336-
async for chunk in resp.content.iter_chunked(8192):
337-
if not chunk:
338-
break
339-
f.write(chunk)
340-
return out_path
341-
except aiohttp.ClientResponseError as e:
342-
# include server response text for diagnostics
343-
try:
344-
body = await e.response.text()
345-
except Exception:
346-
body = "<no body>"
347-
raise FmdApiException(f"Failed to export data: {e.status}, body={body}") from e
370+
log.info("Starting data export (client-side packaging)...")
371+
372+
# Fetch all locations
373+
log.info("Fetching all locations...")
374+
location_blobs = await self.get_locations(num_to_get=-1, skip_empty=False)
375+
376+
# Fetch all pictures if requested
377+
picture_blobs = []
378+
if include_pictures:
379+
log.info("Fetching all pictures...")
380+
picture_blobs = await self.get_pictures(num_to_get=-1)
381+
382+
# Create ZIP file with exported data
383+
log.info(f"Creating export ZIP at {out_path}...")
384+
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
385+
# Decrypt and add readable locations
386+
decrypted_locations = []
387+
if location_blobs:
388+
log.info(f"Decrypting {len(location_blobs)} locations...")
389+
for i, blob in enumerate(location_blobs):
390+
try:
391+
decrypted = self.decrypt_data_blob(blob)
392+
loc_data = json.loads(decrypted)
393+
decrypted_locations.append(loc_data)
394+
except Exception as e:
395+
log.warning(f"Failed to decrypt location {i}: {e}")
396+
decrypted_locations.append({"error": str(e), "index": i})
397+
398+
# Decrypt and extract pictures as image files
399+
picture_file_list = []
400+
if picture_blobs:
401+
log.info(f"Decrypting and extracting {len(picture_blobs)} pictures...")
402+
for i, blob in enumerate(picture_blobs):
403+
try:
404+
decrypted = self.decrypt_data_blob(blob)
405+
# Pictures are double-encoded: decrypt -> base64 string -> image bytes
406+
inner_b64 = decrypted.decode('utf-8').strip()
407+
from .helpers import b64_decode_padded
408+
image_bytes = b64_decode_padded(inner_b64)
409+
410+
# Determine image format from magic bytes
411+
if image_bytes.startswith(b'\xff\xd8\xff'):
412+
ext = 'jpg'
413+
elif image_bytes.startswith(b'\x89PNG'):
414+
ext = 'png'
415+
else:
416+
ext = 'jpg' # default to jpg
417+
418+
filename = f"pictures/picture_{i:04d}.{ext}"
419+
zipf.writestr(filename, image_bytes)
420+
picture_file_list.append({"index": i, "filename": filename, "size": len(image_bytes)})
421+
422+
except Exception as e:
423+
log.warning(f"Failed to decrypt/extract picture {i}: {e}")
424+
picture_file_list.append({"index": i, "error": str(e)})
425+
426+
# Add metadata file (after processing so we have accurate counts)
427+
export_info = {
428+
"export_date": datetime.now().isoformat(),
429+
"fmd_id": self._fmd_id,
430+
"location_count": len(location_blobs),
431+
"picture_count": len(picture_blobs),
432+
"pictures_extracted": len([p for p in picture_file_list if "error" not in p]),
433+
"version": "2.0"
434+
}
435+
zipf.writestr("info.json", json.dumps(export_info, indent=2))
436+
437+
# Add locations as readable JSON
438+
if decrypted_locations:
439+
zipf.writestr("locations.json", json.dumps(decrypted_locations, indent=2))
440+
441+
# Add picture manifest if we extracted any
442+
if picture_file_list:
443+
zipf.writestr("pictures/manifest.json", json.dumps(picture_file_list, indent=2))
444+
445+
log.info(f"Export completed successfully: {out_path}")
446+
return out_path
447+
348448
except Exception as e:
449+
log.error(f"Failed to export data: {e}")
349450
raise FmdApiException(f"Failed to export data: {e}") from e
350451

351452
# -------------------------

fmd_api/py.typed

Whitespace-only changes.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fmd_api"
3-
version = "2.0.0.dev9"
3+
version = "2.0.0.dev10"
44
authors = [{name = "devinslick"}]
55
description = "A Python client for the FMD (Find My Device) server API"
66
readme = "README.md"

0 commit comments

Comments
 (0)